GLXY
@@ -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")
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
import platform
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import tarfile
|
||||
import time
|
||||
import threading
|
||||
import urllib.request
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
|
||||
from openpilot.common.params import Params
|
||||
|
||||
GALAXY_DIR = Path("/data/galaxy")
|
||||
FRPC_VERSION = "0.67.0"
|
||||
FRPC_LOG = GALAXY_DIR / "frpc.log"
|
||||
AUTH_PORT = 8083
|
||||
|
||||
process = None
|
||||
auth_server = None
|
||||
|
||||
|
||||
class AuthHandler(BaseHTTPRequestHandler):
|
||||
"""Serves only GET /glxyauth — returns the PIN hash file contents."""
|
||||
def do_GET(self):
|
||||
if self.path == "/glxyauth":
|
||||
auth_file = GALAXY_DIR / "glxyauth"
|
||||
if auth_file.exists():
|
||||
data = auth_file.read_bytes()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
return
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass # suppress request logs
|
||||
|
||||
|
||||
def start_auth_server():
|
||||
global auth_server
|
||||
if auth_server is not None:
|
||||
return
|
||||
auth_server = HTTPServer(("127.0.0.1", AUTH_PORT), AuthHandler)
|
||||
thread = threading.Thread(target=auth_server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
print(f"Galaxy: Auth server listening on 127.0.0.1:{AUTH_PORT}")
|
||||
|
||||
|
||||
def cleanup_frpc(*_):
|
||||
global process
|
||||
if process is not None and process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process = None
|
||||
|
||||
|
||||
def get_arch_url():
|
||||
arch = platform.machine()
|
||||
if arch in ("aarch64", "arm64"):
|
||||
return f"https://github.com/fatedier/frp/releases/download/v{FRPC_VERSION}/frp_{FRPC_VERSION}_linux_arm64.tar.gz", f"frp_{FRPC_VERSION}_linux_arm64"
|
||||
elif arch in ("x86_64", "amd64"):
|
||||
return f"https://github.com/fatedier/frp/releases/download/v{FRPC_VERSION}/frp_{FRPC_VERSION}_linux_amd64.tar.gz", f"frp_{FRPC_VERSION}_linux_amd64"
|
||||
return None, None
|
||||
|
||||
|
||||
def setup_frpc():
|
||||
GALAXY_DIR.mkdir(parents=True, exist_ok=True)
|
||||
frpc_bin = GALAXY_DIR / "frpc"
|
||||
|
||||
if not frpc_bin.exists():
|
||||
print("Galaxy: Downloading frpc...")
|
||||
url, folder_name = get_arch_url()
|
||||
if not url:
|
||||
print("Galaxy: Unsupported architecture")
|
||||
return False
|
||||
|
||||
tar_path = GALAXY_DIR / "frp.tar.gz"
|
||||
try:
|
||||
urllib.request.urlretrieve(url, tar_path)
|
||||
with tarfile.open(tar_path, "r:gz") as tar:
|
||||
tar.extractall(path=GALAXY_DIR, filter='data')
|
||||
|
||||
# Move binary
|
||||
extracted_bin = GALAXY_DIR / folder_name / "frpc"
|
||||
extracted_bin.rename(frpc_bin)
|
||||
frpc_bin.chmod(0o755)
|
||||
|
||||
# Cleanup
|
||||
tar_path.unlink()
|
||||
shutil.rmtree(GALAXY_DIR / folder_name)
|
||||
print("Galaxy: frpc downloaded and installed.")
|
||||
except Exception as e:
|
||||
print(f"Galaxy: Failed to install frpc: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
global process
|
||||
params = Params()
|
||||
|
||||
signal.signal(signal.SIGTERM, cleanup_frpc)
|
||||
signal.signal(signal.SIGINT, cleanup_frpc)
|
||||
|
||||
# Wait for DongleId to be set (usually set on boot/pairing)
|
||||
dongle_id = params.get("DongleId", encoding='utf8')
|
||||
while not dongle_id:
|
||||
print("Galaxy: Waiting for DongleId...")
|
||||
time.sleep(5)
|
||||
dongle_id = params.get("DongleId", encoding='utf8')
|
||||
|
||||
print(f"Galaxy: DongleId: {dongle_id}")
|
||||
print("Galaxy: Starting manager loop...")
|
||||
|
||||
while True:
|
||||
glxyauth_file = GALAXY_DIR / "glxyauth"
|
||||
galaxy_pin = glxyauth_file.read_text().strip() if glxyauth_file.exists() else None
|
||||
is_paired = galaxy_pin and len(galaxy_pin) == 64
|
||||
|
||||
if is_paired:
|
||||
if process is None or process.poll() is not None:
|
||||
if process is not None:
|
||||
print(f"Galaxy: frpc exited with code {process.returncode}. Restarting...")
|
||||
|
||||
print("Galaxy: PIN set. Preparing frpc tunnel...")
|
||||
if not setup_frpc():
|
||||
print("Galaxy: FRPC setup failed. Retrying later...")
|
||||
time.sleep(10)
|
||||
continue
|
||||
|
||||
# Start the tiny auth HTTP server (serves /glxyauth on localhost)
|
||||
start_auth_server()
|
||||
|
||||
frpc_toml = GALAXY_DIR / "frpc.toml"
|
||||
config = f"""\
|
||||
serverAddr = "galaxy.firestar.link"
|
||||
serverPort = 7000
|
||||
|
||||
[transport]
|
||||
tls.enable = true
|
||||
poolCount = 2
|
||||
|
||||
[[proxies]]
|
||||
name = "{dongle_id}_pond"
|
||||
type = "http"
|
||||
localIP = "127.0.0.1"
|
||||
localPort = 8082
|
||||
customDomains = ["{dongle_id}.devices.local"]
|
||||
transport.useCompression = true
|
||||
|
||||
[[proxies]]
|
||||
name = "{dongle_id}_auth"
|
||||
type = "http"
|
||||
localIP = "127.0.0.1"
|
||||
localPort = {AUTH_PORT}
|
||||
customDomains = ["auth-{dongle_id}.devices.local"]
|
||||
"""
|
||||
frpc_toml.write_text(config)
|
||||
|
||||
print("Galaxy: Starting frpc tunnel...")
|
||||
log_file = open(FRPC_LOG, 'a')
|
||||
process = subprocess.Popen(
|
||||
[str(GALAXY_DIR / "frpc"), "-c", str(frpc_toml)],
|
||||
stdout=log_file,
|
||||
stderr=log_file
|
||||
)
|
||||
else:
|
||||
if process is not None and process.poll() is None:
|
||||
print("Galaxy: PIN cleared. Stopping frpc tunnel...")
|
||||
cleanup_frpc()
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
|
||||
.disk .progress {
|
||||
background: linear-gradient(to right, green 0%, yellow 80%, orange 90%, red 100%);
|
||||
background: linear-gradient(to right, #5ec8c8 0%, #8b6cc5 60%, #e05577 85%, #c04466 100%);
|
||||
border-radius: var(--border-radius-md);
|
||||
height: var(--padding-base);
|
||||
overflow: hidden;
|
||||
@@ -78,4 +78,4 @@
|
||||
gap: 0.5em 1em;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,18 +102,18 @@ export function Home() {
|
||||
return html`
|
||||
<div>
|
||||
${() => {
|
||||
if (state.isLoading) {
|
||||
return html`<p>Loading...</p>`;
|
||||
}
|
||||
if (state.isLoading) {
|
||||
return html`<p>Loading...</p>`;
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return html`<p class="error">Failed to load data: ${state.error}</p>`;
|
||||
}
|
||||
if (state.error) {
|
||||
return html`<p class="error">Failed to load data: ${state.error}</p>`;
|
||||
}
|
||||
|
||||
if (state.data) {
|
||||
const { driveStats, firehoseStats, softwareInfo } = state.data;
|
||||
return html`
|
||||
<h1>The Pond</h1>
|
||||
if (state.data) {
|
||||
const { driveStats, firehoseStats, softwareInfo } = state.data;
|
||||
return html`
|
||||
<h1>Galaxy</h1>
|
||||
|
||||
<div class="drivingStats">
|
||||
${DriveStat("All Time", driveStats?.all, state.unit)}
|
||||
@@ -139,10 +139,10 @@ export function Home() {
|
||||
<div class="softwareGrid">${renderSoftwareInfo(softwareInfo)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
return html`<p>No data available.</p>`;
|
||||
}}
|
||||
return html`<p>No data available.</p>`;
|
||||
}}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -20,47 +20,49 @@
|
||||
--breakpoint-lg: 1024px;
|
||||
--breakpoint-xl: 1280px;
|
||||
|
||||
/* Colors */
|
||||
--accent-bg: #673ab7;
|
||||
--accent-hover-bg: #512da8;
|
||||
--card-bg: #234423;
|
||||
/* Colors — Galaxy palette (cosmic purple · teal · rose · amber) */
|
||||
--accent-bg: #8b6cc5;
|
||||
--accent-hover-bg: #7558b0;
|
||||
--card-bg: #121224;
|
||||
--color-black: #000000;
|
||||
--color-confirm: #1a73e8;
|
||||
--color-confirm-hover: #0758ad;
|
||||
--color-gray-100: #f5f5f5;
|
||||
--color-gray-200: #e0e0e0;
|
||||
--color-gray-300: #c2c2c2;
|
||||
--color-gray-400: #a4a4a4;
|
||||
--color-gray-500: #8f8f8f;
|
||||
--color-gray-600: #737373;
|
||||
--color-gray-700: #595959;
|
||||
--color-gray-800: #333333;
|
||||
--color-gray-900: #1a1a1a;
|
||||
--color-confirm: #8b6cc5;
|
||||
--color-confirm-hover: #7558b0;
|
||||
--color-gray-100: #f0f0f8;
|
||||
--color-gray-200: #d8d8e4;
|
||||
--color-gray-300: #b8b8cc;
|
||||
--color-gray-400: #9898b0;
|
||||
--color-gray-500: #7e7e98;
|
||||
--color-gray-600: #636380;
|
||||
--color-gray-700: #4a4a64;
|
||||
--color-gray-800: #2e2e44;
|
||||
--color-gray-900: #1a1a30;
|
||||
--color-white: #ffffff;
|
||||
--danger-bg: #b71c1c;
|
||||
--danger-fg: #ef1313;
|
||||
--danger-hover-bg: #d32f2f;
|
||||
--input-bg: #2f5432;
|
||||
--main-bg: #0b1b0b;
|
||||
--main-fg: #178643;
|
||||
--secondary-bg: #264026;
|
||||
--selected-camera-bg: #1f2f1f;
|
||||
--sidebar-active-bg: #64c87826;
|
||||
--sidebar-bg: #264026;
|
||||
--sidebar-border-color: #1e2e1e;
|
||||
--danger-bg: #e05577;
|
||||
--danger-fg: #e05577;
|
||||
--danger-hover-bg: #c04466;
|
||||
--glow-primary: 0 0 0 2px var(--main-fg), 0 0 10px rgba(139, 108, 197, 0.35);
|
||||
--input-bg: #161630;
|
||||
--main-bg: #06060f;
|
||||
--main-fg: #8b6cc5;
|
||||
--secondary-bg: #0e0e1a;
|
||||
--selected-camera-bg: #0b0b18;
|
||||
--sidebar-active-bg: rgba(139, 108, 197, 0.15);
|
||||
--sidebar-bg: #0a0a16;
|
||||
--sidebar-border-color: #1e1e3e;
|
||||
--sidebar-fg: #ffffff;
|
||||
--sidebar-title-fg: #193446;
|
||||
--success-bg: #039226;
|
||||
--success-fg: #00a100;
|
||||
--success-hover-bg: #01be2d;
|
||||
--text-color: #ffffff;
|
||||
--text-muted: #a0a0a0;
|
||||
--sidebar-title-fg: #8b6cc5;
|
||||
--success-bg: #5ec8c8;
|
||||
--success-fg: #5ec8c8;
|
||||
--success-hover-bg: #4ab3b3;
|
||||
--switch-inactive-bg: #1e1e3e;
|
||||
--text-color: #e8e8f0;
|
||||
--text-muted: #8080a8;
|
||||
--text-on-primary: var(--sidebar-fg);
|
||||
--text-on-surface: var(--text-color);
|
||||
--thumb-color: #178643;
|
||||
--track-color: #1a3a1a;
|
||||
--warning-bg: #ff9800;
|
||||
--warning-hover-bg: #f57c00;
|
||||
--thumb-color: #8b6cc5;
|
||||
--track-color: #14142e;
|
||||
--warning-bg: #d4a060;
|
||||
--warning-hover-bg: #b8884a;
|
||||
|
||||
/* Effects */
|
||||
--disabled-opacity: 0.5;
|
||||
@@ -71,7 +73,7 @@
|
||||
--hover-scale-lg: scale(1.1);
|
||||
|
||||
/* Fonts */
|
||||
--font-body: "Open Sans", sans-serif;
|
||||
--font-body: "Inter", "Open Sans", sans-serif;
|
||||
--font-mono: "Courier New", Courier, monospace;
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.85rem;
|
||||
@@ -96,9 +98,9 @@
|
||||
--width-xxxxl: 1200px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-xs: 0 1px 2px rgba(0,0,0,0.05);
|
||||
--shadow-sm: 0 2px 4px rgba(0,0,0,0.1);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.2);
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
|
||||
/* Spacing */
|
||||
--gap-xxs: 0.125rem;
|
||||
@@ -172,12 +174,14 @@ input[type="checkbox"],
|
||||
input[type="radio"],
|
||||
.manage-keys-link,
|
||||
.route_card,
|
||||
.sidebar .menu_section > li > ul > li,
|
||||
.sidebar .menu_section>li>ul>li,
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
body { padding-bottom: 0 !important; }
|
||||
body {
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
/* ――― Layout containers ――― */
|
||||
.content {
|
||||
@@ -199,9 +203,17 @@ h3 {
|
||||
}
|
||||
|
||||
/* ――― Helpers & states ――― */
|
||||
.hidden { display: none; }
|
||||
html { cursor: default; }
|
||||
.no_scroll { overflow: hidden; }
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.no_scroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.not_implemented {
|
||||
cursor: not-allowed;
|
||||
@@ -251,7 +263,9 @@ textarea {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
label[for] { cursor: default; }
|
||||
label[for] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ――― Snackbar component ――― */
|
||||
.snackbar {
|
||||
@@ -297,6 +311,7 @@ a {
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
bottom: var(--snackbar-offset, 30px);
|
||||
opacity: 1;
|
||||
@@ -304,13 +319,20 @@ a {
|
||||
}
|
||||
|
||||
@keyframes fadeout {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ――― Breakpoint overrides ――― */
|
||||
@media only screen and (max-width: var(--breakpoint-md)) {
|
||||
.content { margin-left: 0; }
|
||||
.content {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) and (orientation: portrait) {
|
||||
@@ -320,6 +342,7 @@ a {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
#snackbar_wrapper {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
@@ -333,6 +356,7 @@ a {
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
bottom: var(--snackbar-offset, 30px);
|
||||
opacity: 1;
|
||||
@@ -340,18 +364,62 @@ a {
|
||||
}
|
||||
|
||||
@-webkit-keyframes fadeout {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ——— Tunnel notice banner ——— */
|
||||
.tunnel-notice {
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, var(--card-bg), var(--secondary-bg));
|
||||
border: var(--border-width-thin) var(--border-style-base) var(--sidebar-border-color);
|
||||
border-left: 3px solid var(--main-fg);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-md);
|
||||
margin: var(--margin-xl) auto;
|
||||
max-width: var(--width-xl);
|
||||
padding: var(--padding-xl) var(--padding-xxl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tunnel-notice-icon {
|
||||
font-size: 2.5rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.tunnel-notice-title {
|
||||
color: var(--text-color);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tunnel-notice-body {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-base);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--track-color);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--thumb-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,7 @@
|
||||
|
||||
.favorites-toggle-button:hover {
|
||||
background-color: var(--main-fg);
|
||||
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
|
||||
box-shadow: var(--glow-primary);
|
||||
color: var(--text-color);
|
||||
font-weight: var(--font-weight-bold);
|
||||
transform: var(--hover-scale-sm);
|
||||
@@ -352,7 +352,7 @@
|
||||
|
||||
.navigation-summary-widget button.directions:hover {
|
||||
background-color: var(--success-hover-bg);
|
||||
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
|
||||
box-shadow: var(--glow-primary);
|
||||
color: var(--text-color);
|
||||
transform: var(--hover-scale-sm);
|
||||
}
|
||||
@@ -466,12 +466,12 @@
|
||||
}
|
||||
|
||||
.search-provider-toggle button.active {
|
||||
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
|
||||
box-shadow: var(--glow-primary);
|
||||
transform: var(--hover-scale-sm);
|
||||
}
|
||||
|
||||
.search-provider-toggle button:hover {
|
||||
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
|
||||
box-shadow: var(--glow-primary);
|
||||
transform: var(--hover-scale-sm);
|
||||
}
|
||||
|
||||
@@ -533,4 +533,4 @@
|
||||
min-width: 0;
|
||||
width: calc(100% - var(--padding-xl));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.navkeys-group > .navkeys-row:last-of-type {
|
||||
.navkeys-group>.navkeys-row:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
.navkeys-input:hover,
|
||||
.navkeys-input:focus {
|
||||
border-color: var(--thumb-color);
|
||||
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
|
||||
box-shadow: var(--glow-primary);
|
||||
transform: var(--hover-scale-sm);
|
||||
}
|
||||
|
||||
@@ -153,4 +153,4 @@
|
||||
.navkeys-input {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { isGalaxyTunnel } from "/assets/js/utils.js"
|
||||
import { getOrdinalSuffix } from "/assets/components/navigation/navigation_utilities.js"
|
||||
import { Modal } from "/assets/components/modal.js";
|
||||
|
||||
@@ -124,27 +125,27 @@ async function deleteRoute(route) {
|
||||
}
|
||||
|
||||
async function resetRouteName(route, dlg) {
|
||||
const res = await fetch(`/api/routes/reset_name`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: route.name })
|
||||
});
|
||||
if (res.ok) {
|
||||
const { timestamp } = await res.json();
|
||||
closeDialog(dlg);
|
||||
const routeInList = state.routes.find(r => r.name === route.name);
|
||||
if (routeInList) {
|
||||
routeInList.timestamp = formatRouteDate(timestamp);
|
||||
}
|
||||
route.timestamp = formatRouteDate(timestamp);
|
||||
const overlayTitleSpan = overlay.querySelector(".media-player-title span");
|
||||
if (overlayTitleSpan) {
|
||||
overlayTitleSpan.textContent = formatRouteDate(timestamp);
|
||||
}
|
||||
showSnackbar("Route name reset!");
|
||||
} else {
|
||||
showSnackbar("Resetting name failed...", "error");
|
||||
const res = await fetch(`/api/routes/reset_name`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: route.name })
|
||||
});
|
||||
if (res.ok) {
|
||||
const { timestamp } = await res.json();
|
||||
closeDialog(dlg);
|
||||
const routeInList = state.routes.find(r => r.name === route.name);
|
||||
if (routeInList) {
|
||||
routeInList.timestamp = formatRouteDate(timestamp);
|
||||
}
|
||||
route.timestamp = formatRouteDate(timestamp);
|
||||
const overlayTitleSpan = overlay.querySelector(".media-player-title span");
|
||||
if (overlayTitleSpan) {
|
||||
overlayTitleSpan.textContent = formatRouteDate(timestamp);
|
||||
}
|
||||
showSnackbar("Route name reset!");
|
||||
} else {
|
||||
showSnackbar("Resetting name failed...", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function renameRoute(route) {
|
||||
@@ -317,6 +318,16 @@ async function deleteAllRoutes() {
|
||||
}
|
||||
|
||||
export function RouteRecordings() {
|
||||
if (isGalaxyTunnel()) {
|
||||
return html`
|
||||
<div class="tunnel-notice">
|
||||
<div class="tunnel-notice-icon">🛰️</div>
|
||||
<h3 class="tunnel-notice-title">Dashcam Routes Unavailable via Galaxy</h3>
|
||||
<p class="tunnel-notice-body">Loading dashcam routes requires a direct connection.<br>Connect to your device's local network to use this feature.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (state.selectedRoute && !overlay) openOverlay(state.selectedRoute);
|
||||
|
||||
return html`
|
||||
@@ -332,75 +343,75 @@ export function RouteRecordings() {
|
||||
</button>
|
||||
|
||||
${() => {
|
||||
const routesToShow = state.routes.filter(r => !state.showPreservedOnly || r.is_preserved);
|
||||
const routesToShow = state.routes.filter(r => !state.showPreservedOnly || r.is_preserved);
|
||||
|
||||
if (routesToShow.length === 0) {
|
||||
if (state.loading && state.total > 0) {
|
||||
return html`<p class="screen-recordings-message">Processing Routes: ${state.progress} of ${state.total}</p>`;
|
||||
}
|
||||
if (state.loading && !state.isDeletingAll) {
|
||||
return html`<p class="screen-recordings-message">Loading...</p>`;
|
||||
}
|
||||
if (state.isDeletingAll) {
|
||||
return html`<p class="screen-recordings-message">Deleting routes...</p>`;
|
||||
}
|
||||
if (state.showPreservedOnly) {
|
||||
return html`<p class="screen-recordings-message">No preserved routes...</p>`;
|
||||
}
|
||||
if (state.error) {
|
||||
return html`<p class="screen-recordings-message">${state.error}</p>`;
|
||||
}
|
||||
return html`<p class="screen-recordings-message">No routes found...</p>`;
|
||||
}
|
||||
if (routesToShow.length === 0) {
|
||||
if (state.loading && state.total > 0) {
|
||||
return html`<p class="screen-recordings-message">Processing Routes: ${state.progress} of ${state.total}</p>`;
|
||||
}
|
||||
if (state.loading && !state.isDeletingAll) {
|
||||
return html`<p class="screen-recordings-message">Loading...</p>`;
|
||||
}
|
||||
if (state.isDeletingAll) {
|
||||
return html`<p class="screen-recordings-message">Deleting routes...</p>`;
|
||||
}
|
||||
if (state.showPreservedOnly) {
|
||||
return html`<p class="screen-recordings-message">No preserved routes...</p>`;
|
||||
}
|
||||
if (state.error) {
|
||||
return html`<p class="screen-recordings-message">${state.error}</p>`;
|
||||
}
|
||||
return html`<p class="screen-recordings-message">No routes found...</p>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
return html`
|
||||
<div class="screen-recordings-grid">
|
||||
${routesToShow.map(
|
||||
route => html`
|
||||
route => html`
|
||||
<div
|
||||
class="recording-card"
|
||||
@mouseenter="${e => {
|
||||
if (state.selectedRoute) return;
|
||||
if (state.selectedRoute) return;
|
||||
|
||||
const card = e.currentTarget;
|
||||
const gif = card.querySelector(".recording-preview-gif");
|
||||
const png = card.querySelector(".recording-preview-png");
|
||||
const card = e.currentTarget;
|
||||
const gif = card.querySelector(".recording-preview-gif");
|
||||
const png = card.querySelector(".recording-preview-png");
|
||||
|
||||
if (card.dataset.gifLoaded) {
|
||||
png.style.display = "none";
|
||||
gif.style.display = "block";
|
||||
return;
|
||||
}
|
||||
if (card.dataset.gifLoaded) {
|
||||
png.style.display = "none";
|
||||
gif.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
card.dataset.loadingGif = "true";
|
||||
const preloader = new Image();
|
||||
preloader.onload = () => {
|
||||
if (card.dataset.loadingGif === "true") {
|
||||
gif.src = preloader.src;
|
||||
png.style.display = "none";
|
||||
gif.style.display = "block";
|
||||
card.dataset.gifLoaded = true;
|
||||
}
|
||||
delete card.dataset.loadingGif;
|
||||
};
|
||||
preloader.onerror = () => {
|
||||
console.error("Failed to load preview GIF:", preloader.src);
|
||||
delete card.dataset.loadingGif;
|
||||
};
|
||||
card.dataset.loadingGif = "true";
|
||||
const preloader = new Image();
|
||||
preloader.onload = () => {
|
||||
if (card.dataset.loadingGif === "true") {
|
||||
gif.src = preloader.src;
|
||||
png.style.display = "none";
|
||||
gif.style.display = "block";
|
||||
card.dataset.gifLoaded = true;
|
||||
}
|
||||
delete card.dataset.loadingGif;
|
||||
};
|
||||
preloader.onerror = () => {
|
||||
console.error("Failed to load preview GIF:", preloader.src);
|
||||
delete card.dataset.loadingGif;
|
||||
};
|
||||
|
||||
preloader.src = gif.dataset.src;
|
||||
}}"
|
||||
preloader.src = gif.dataset.src;
|
||||
}}"
|
||||
@mouseleave="${e => {
|
||||
const card = e.currentTarget;
|
||||
card.querySelector(".recording-preview-png").style.display = "block";
|
||||
card.querySelector(".recording-preview-gif").style.display = "none";
|
||||
if (card.dataset.loadingGif === "true") {
|
||||
delete card.dataset.loadingGif;
|
||||
}
|
||||
}}"
|
||||
const card = e.currentTarget;
|
||||
card.querySelector(".recording-preview-png").style.display = "block";
|
||||
card.querySelector(".recording-preview-gif").style.display = "none";
|
||||
if (card.dataset.loadingGif === "true") {
|
||||
delete card.dataset.loadingGif;
|
||||
}
|
||||
}}"
|
||||
@click="${() => {
|
||||
state.selectedRoute = route;
|
||||
}}"
|
||||
state.selectedRoute = route;
|
||||
}}"
|
||||
>
|
||||
<div class="preserved-icon" @click="${e => togglePreserved(route, e)}">
|
||||
${() => html`<i class="bi ${route.is_preserved ? "bi-heart-fill" : "bi-heart"}"></i>`}
|
||||
@@ -410,6 +421,7 @@ export function RouteRecordings() {
|
||||
src="${route.png}"
|
||||
class="recording-preview recording-preview-png"
|
||||
style="display:block;"
|
||||
loading="lazy"
|
||||
>
|
||||
<img
|
||||
data-src="${route.gif}"
|
||||
@@ -420,13 +432,13 @@ export function RouteRecordings() {
|
||||
<p class="recording-filename">${route.timestamp}</p>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}}
|
||||
}}
|
||||
${() => {
|
||||
if (state.routes.length > 0) {
|
||||
return html`
|
||||
if (state.routes.length > 0) {
|
||||
return html`
|
||||
<button
|
||||
class="delete-all-button"
|
||||
@click="${() => (state.showDeleteAllModal = true)}"
|
||||
@@ -435,17 +447,17 @@ export function RouteRecordings() {
|
||||
${() => (state.isDeletingAll ? "Deleting..." : "Delete All Routes")}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
return "";
|
||||
}}
|
||||
}
|
||||
return "";
|
||||
}}
|
||||
</div>
|
||||
${() => state.showDeleteAllModal ? Modal({
|
||||
title: "Confirm Delete All",
|
||||
message: "Are you sure you want to delete all routes? This action cannot be undone...",
|
||||
onConfirm: deleteAllRoutes,
|
||||
onCancel: () => { state.showDeleteAllModal = false; },
|
||||
confirmText: "Delete All"
|
||||
}) : ""}
|
||||
title: "Confirm Delete All",
|
||||
message: "Are you sure you want to delete all routes? This action cannot be undone...",
|
||||
onConfirm: deleteAllRoutes,
|
||||
onCancel: () => { state.showDeleteAllModal = false; },
|
||||
confirmText: "Delete All"
|
||||
}) : ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { isGalaxyTunnel } from "/assets/js/utils.js"
|
||||
import { Modal } from "/assets/components/modal.js";
|
||||
|
||||
const state = reactive({
|
||||
@@ -142,8 +143,8 @@ async function renameFile(rec) {
|
||||
}
|
||||
|
||||
function confirmDeleteFile(rec) {
|
||||
state.recordingToDelete = rec;
|
||||
state.showDeleteModal = true;
|
||||
state.recordingToDelete = rec;
|
||||
state.showDeleteModal = true;
|
||||
}
|
||||
|
||||
async function deleteFile() {
|
||||
@@ -152,11 +153,11 @@ async function deleteFile() {
|
||||
|
||||
const res = await fetch(`/api/screen_recordings/delete/${encodeURIComponent(rec.filename)}`, { method: "DELETE" })
|
||||
if (res.ok) {
|
||||
closeOverlay();
|
||||
refresh();
|
||||
showSnackbar("Recording deleted!");
|
||||
closeOverlay();
|
||||
refresh();
|
||||
showSnackbar("Recording deleted!");
|
||||
} else {
|
||||
showSnackbar("Delete failed...", "error");
|
||||
showSnackbar("Delete failed...", "error");
|
||||
}
|
||||
|
||||
state.showDeleteModal = false;
|
||||
@@ -221,6 +222,16 @@ async function deleteAllRecordings() {
|
||||
}
|
||||
|
||||
export function ScreenRecordings() {
|
||||
if (isGalaxyTunnel()) {
|
||||
return html`
|
||||
<div class="tunnel-notice">
|
||||
<div class="tunnel-notice-icon">🛰️</div>
|
||||
<h3 class="tunnel-notice-title">Screen Recordings Unavailable via Galaxy</h3>
|
||||
<p class="tunnel-notice-body">Loading screen recordings requires a direct connection.<br>Connect to your device's local network to use this feature.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (state.selectedRecording && !overlay) openOverlay(state.selectedRecording)
|
||||
|
||||
return html`
|
||||
@@ -229,77 +240,77 @@ export function ScreenRecordings() {
|
||||
<div class="screen-recordings-title">Screen Recordings</div>
|
||||
|
||||
${() => {
|
||||
if (state.loading && state.recordings.length === 0) return html`<p class="screen-recordings-message">Loading...</p>`
|
||||
if (state.error) return html`<p class="screen-recordings-message">${state.error}</p>`
|
||||
if (state.progress > 0 && state.progress < state.total) {
|
||||
return html`<p class="screen-recordings-message">Processing Recordings: ${state.progress} of ${state.total}</p>`
|
||||
}
|
||||
if (state.recordings.length === 0 && !state.loading) {
|
||||
return html`<p class="screen-recordings-message">No screen recordings found...</p>`
|
||||
}
|
||||
return ""
|
||||
}}
|
||||
if (state.loading && state.recordings.length === 0) return html`<p class="screen-recordings-message">Loading...</p>`
|
||||
if (state.error) return html`<p class="screen-recordings-message">${state.error}</p>`
|
||||
if (state.progress > 0 && state.progress < state.total) {
|
||||
return html`<p class="screen-recordings-message">Processing Recordings: ${state.progress} of ${state.total}</p>`
|
||||
}
|
||||
if (state.recordings.length === 0 && !state.loading) {
|
||||
return html`<p class="screen-recordings-message">No screen recordings found...</p>`
|
||||
}
|
||||
return ""
|
||||
}}
|
||||
|
||||
<div class="screen-recordings-grid">
|
||||
${() => state.recordings.map(rec => {
|
||||
const displayName = rec.is_custom_name ? rec.filename.replace(/\.mp4$/i, "").replace(/_/g, " ") : formatScreenRecordingDate(rec.timestamp)
|
||||
return html`
|
||||
const displayName = rec.is_custom_name ? rec.filename.replace(/\.mp4$/i, "").replace(/_/g, " ") : formatScreenRecordingDate(rec.timestamp)
|
||||
return html`
|
||||
<div
|
||||
class="recording-card"
|
||||
@mouseenter="${e => {
|
||||
if (state.selectedRecording) return;
|
||||
if (state.selectedRecording) return;
|
||||
|
||||
const card = e.currentTarget;
|
||||
const gif = card.querySelector(".recording-preview-gif");
|
||||
const png = card.querySelector(".recording-preview-png");
|
||||
const card = e.currentTarget;
|
||||
const gif = card.querySelector(".recording-preview-gif");
|
||||
const png = card.querySelector(".recording-preview-png");
|
||||
|
||||
if (card.dataset.gifLoaded) {
|
||||
png.style.display = "none";
|
||||
gif.style.display = "block";
|
||||
return;
|
||||
}
|
||||
if (card.dataset.gifLoaded) {
|
||||
png.style.display = "none";
|
||||
gif.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
card.dataset.loadingGif = "true";
|
||||
const preloader = new Image();
|
||||
preloader.onload = () => {
|
||||
if (card.dataset.loadingGif === "true") {
|
||||
gif.src = preloader.src;
|
||||
png.style.display = "none";
|
||||
gif.style.display = "block";
|
||||
card.dataset.gifLoaded = true;
|
||||
}
|
||||
delete card.dataset.loadingGif;
|
||||
};
|
||||
preloader.onerror = () => {
|
||||
console.error("Failed to load preview GIF:", preloader.src);
|
||||
delete card.dataset.loadingGif;
|
||||
};
|
||||
card.dataset.loadingGif = "true";
|
||||
const preloader = new Image();
|
||||
preloader.onload = () => {
|
||||
if (card.dataset.loadingGif === "true") {
|
||||
gif.src = preloader.src;
|
||||
png.style.display = "none";
|
||||
gif.style.display = "block";
|
||||
card.dataset.gifLoaded = true;
|
||||
}
|
||||
delete card.dataset.loadingGif;
|
||||
};
|
||||
preloader.onerror = () => {
|
||||
console.error("Failed to load preview GIF:", preloader.src);
|
||||
delete card.dataset.loadingGif;
|
||||
};
|
||||
|
||||
preloader.src = gif.dataset.src;
|
||||
}}"
|
||||
preloader.src = gif.dataset.src;
|
||||
}}"
|
||||
@mouseleave="${e => {
|
||||
const card = e.currentTarget;
|
||||
card.querySelector(".recording-preview-png").style.display = "block";
|
||||
card.querySelector(".recording-preview-gif").style.display = "none";
|
||||
if (card.dataset.loadingGif === "true") {
|
||||
delete card.dataset.loadingGif;
|
||||
}
|
||||
}}"
|
||||
const card = e.currentTarget;
|
||||
card.querySelector(".recording-preview-png").style.display = "block";
|
||||
card.querySelector(".recording-preview-gif").style.display = "none";
|
||||
if (card.dataset.loadingGif === "true") {
|
||||
delete card.dataset.loadingGif;
|
||||
}
|
||||
}}"
|
||||
@click="${() => { state.selectedRecording = rec }}"
|
||||
>
|
||||
<div class="recording-preview-container">
|
||||
<img src="${rec.png}" class="recording-preview recording-preview-png" style="display:block;">
|
||||
<img src="${rec.png}" class="recording-preview recording-preview-png" style="display:block;" loading="lazy">
|
||||
<img data-src="${rec.gif}" class="recording-preview recording-preview-gif" style="display:none;">
|
||||
</div>
|
||||
<p class="recording-filename">${displayName}</p>
|
||||
</div>
|
||||
`
|
||||
})}
|
||||
})}
|
||||
</div>
|
||||
|
||||
${() => {
|
||||
if (state.recordings.length > 0) {
|
||||
return html`
|
||||
if (state.recordings.length > 0) {
|
||||
return html`
|
||||
<button
|
||||
class="delete-all-button"
|
||||
@click="${() => (state.showDeleteAllModal = true)}"
|
||||
@@ -307,24 +318,24 @@ export function ScreenRecordings() {
|
||||
Delete All Recordings
|
||||
</button>
|
||||
`
|
||||
}
|
||||
return ""
|
||||
}}
|
||||
}
|
||||
return ""
|
||||
}}
|
||||
</div>
|
||||
${() => state.showDeleteModal ? Modal({
|
||||
title: "Confirm Delete",
|
||||
message: `Are you sure you want to delete <strong>${state.recordingToDelete.filename}</strong>?`,
|
||||
onConfirm: deleteFile,
|
||||
onCancel: () => { state.showDeleteModal = false; state.recordingToDelete = null; },
|
||||
confirmText: "Delete"
|
||||
}) : ""}
|
||||
title: "Confirm Delete",
|
||||
message: `Are you sure you want to delete <strong>${state.recordingToDelete.filename}</strong>?`,
|
||||
onConfirm: deleteFile,
|
||||
onCancel: () => { state.showDeleteModal = false; state.recordingToDelete = null; },
|
||||
confirmText: "Delete"
|
||||
}) : ""}
|
||||
${() => state.showDeleteAllModal ? Modal({
|
||||
title: "Confirm Delete All",
|
||||
message: "Are you sure you want to delete all screen recordings? This action cannot be undone...",
|
||||
onConfirm: deleteAllRecordings,
|
||||
onCancel: () => { state.showDeleteAllModal = false; },
|
||||
confirmText: "Delete All"
|
||||
}) : ""}
|
||||
title: "Confirm Delete All",
|
||||
message: "Are you sure you want to delete all screen recordings? This action cannot be undone...",
|
||||
onConfirm: deleteAllRecordings,
|
||||
onCancel: () => { state.showDeleteAllModal = false; },
|
||||
confirmText: "Delete All"
|
||||
}) : ""}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { createBrowserHistory, createRouter } from "https://esm.sh/@remix-run/router@1.3.1"
|
||||
import { hideSidebar } from "/assets/js/utils.js"
|
||||
import { DeviceSettings } from "/assets/components/tools/device_settings.js"
|
||||
import { DoorControl } from "/assets/components/tools/doors.js"
|
||||
import { ErrorLogs } from "/assets/components/tools/error_logs.js"
|
||||
import { Home } from "/assets/components/home/home.js"
|
||||
@@ -11,7 +12,6 @@ import { SettingsView } from "/assets/components/settings.js"
|
||||
import { ScreenRecordings } from "/assets/components/recordings/screen_recordings.js"
|
||||
import { Sidebar } from "/assets/components/sidebar.js"
|
||||
import { SpeedLimits } from "/assets/components/tools/speed_limits.js"
|
||||
import { TailscaleControl } from "/assets/components/tailscale/tailscale.js"
|
||||
import { ThemeMaker } from "/assets/components/tools/theme_maker.js"
|
||||
import { TmuxLog } from "/assets/components/tools/tmux.js"
|
||||
import { ToggleControl } from "/assets/components/tools/toggles.js"
|
||||
@@ -23,13 +23,14 @@ function createRoute(id, path, component) {
|
||||
return {
|
||||
id,
|
||||
path,
|
||||
loader: () => {},
|
||||
loader: () => { },
|
||||
element: component,
|
||||
}
|
||||
}
|
||||
|
||||
function Root() {
|
||||
let routes = [
|
||||
createRoute("device_settings", "/device_settings", DeviceSettings),
|
||||
createRoute("doors", "/lock_or_unlock_doors", DoorControl),
|
||||
createRoute("errorLogs", "/manage_error_logs", ErrorLogs),
|
||||
createRoute("navdestination", "/set_navigation_destination", NavDestination),
|
||||
@@ -39,7 +40,6 @@ function Root() {
|
||||
createRoute("screen_recordings", "/screen_recordings", ScreenRecordings),
|
||||
createRoute("settings", "/settings/:section/:subsection?", SettingsView),
|
||||
createRoute("speed_limits", "/download_speed_limits", SpeedLimits),
|
||||
createRoute("tailscale", "/manage_tailscale", TailscaleControl),
|
||||
createRoute("thememaker", "/theme_maker", ThemeMaker),
|
||||
createRoute("tmux", "/manage_tmux", TmuxLog),
|
||||
createRoute("toggles", "/manage_toggles", ToggleControl),
|
||||
@@ -76,17 +76,17 @@ function Root() {
|
||||
${() => Sidebar(routerState.activePathFull)}
|
||||
<div class="content">
|
||||
${() => {
|
||||
if (!routerState.initialized || routerState.navigation.state === "loading") {
|
||||
return html`<div>Loading...</div>`
|
||||
}
|
||||
if (!routerState.initialized || routerState.navigation.state === "loading") {
|
||||
return html`<div>Loading...</div>`
|
||||
}
|
||||
|
||||
if (routerState.errors?.root?.status === 404) {
|
||||
return html`<h1>Not Found</h1>`
|
||||
}
|
||||
if (routerState.errors?.root?.status === 404) {
|
||||
return html`<h1>Not Found</h1>`
|
||||
}
|
||||
|
||||
const match = routes.find(r => r.path === routerState.activePath)
|
||||
return match.element({ params: routerState.params })
|
||||
}}
|
||||
const match = routes.find(r => r.path === routerState.activePath)
|
||||
return match.element({ params: routerState.params })
|
||||
}}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown > select {
|
||||
.dropdown>select {
|
||||
appearance: none;
|
||||
background-color: var(--sidebar-bg);
|
||||
border: var(--border-width-thin) solid var(--sidebar-border-color);
|
||||
@@ -74,19 +74,19 @@ input.searchfield:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
input:checked+.slider {
|
||||
background-color: var(--success-bg);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
input:checked+.slider:before {
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
input:checked + .slider.loading:before {
|
||||
input:checked+.slider.loading:before {
|
||||
animation: rotationChecked 1s linear infinite;
|
||||
}
|
||||
|
||||
input:focus + .slider {
|
||||
input:focus+.slider {
|
||||
box-shadow: 0 0 var(--border-width-thin) var(--success-bg);
|
||||
}
|
||||
|
||||
@@ -133,11 +133,11 @@ input:focus + .slider {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.options > input {
|
||||
.options>input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.options > input:checked + label {
|
||||
.options>input:checked+label {
|
||||
background-color: var(--success-bg);
|
||||
color: var(--text-color);
|
||||
font-weight: bold;
|
||||
@@ -220,14 +220,13 @@ input:focus + .slider {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.setting.subsetting_link {
|
||||
}
|
||||
.setting.subsetting_link {}
|
||||
|
||||
.setting.subtoggle {
|
||||
margin: 0 0 0 var(--padding-xl);
|
||||
}
|
||||
|
||||
.setting.subtoggle + .setting {
|
||||
.setting.subtoggle+.setting {
|
||||
margin-top: var(--border-radius-xl);
|
||||
}
|
||||
|
||||
@@ -300,7 +299,7 @@ input.searchfield {
|
||||
animation: rotation 1s linear infinite;
|
||||
background-color: none !important;
|
||||
border: var(--border-width-thick) solid var(--sidebar-fg);
|
||||
border-bottom-color: #46439b;
|
||||
border-bottom-color: var(--main-fg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -337,6 +336,7 @@ i.switch {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
@@ -346,7 +346,8 @@ i.switch {
|
||||
0% {
|
||||
transform: translateX(26px) rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(26px) rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,10 @@
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-color: var(--sidebar-bg);
|
||||
background:
|
||||
radial-gradient(ellipse at 30% 80%, rgba(139, 108, 197, 0.06) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at 70% 20%, rgba(94, 200, 200, 0.04) 0%, transparent 50%),
|
||||
var(--sidebar-bg);
|
||||
border-right: var(--border-width-thin) solid var(--sidebar-border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -102,7 +105,7 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar .menu_section > li > a span {
|
||||
.sidebar .menu_section>li>a span {
|
||||
color: var(--text-color);
|
||||
display: inline-block;
|
||||
font-size: var(--font-size-lg);
|
||||
@@ -111,13 +114,13 @@
|
||||
padding-left: var(--padding-sm);
|
||||
}
|
||||
|
||||
.sidebar .menu_section > li > ul {
|
||||
.sidebar .menu_section>li>ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar .menu_section > li > ul > li {
|
||||
.sidebar .menu_section>li>ul>li {
|
||||
border-radius: var(--border-radius-sm);
|
||||
margin-bottom: var(--padding-sm);
|
||||
overflow: hidden;
|
||||
@@ -125,23 +128,23 @@
|
||||
transition: background-color var(--transition-fast), box-shadow var(--transition-fast), color var(--transition-fast), transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar .menu_section > li > ul > li > a.menu-item-link {
|
||||
.sidebar .menu_section>li>ul>li>a.menu-item-link {
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.sidebar .menu_section > li > ul > li.active,
|
||||
.sidebar .menu_section > li > ul > li:hover {
|
||||
.sidebar .menu_section>li>ul>li.active,
|
||||
.sidebar .menu_section>li>ul>li:hover {
|
||||
background-color: var(--sidebar-active-bg);
|
||||
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
|
||||
box-shadow: var(--glow-primary);
|
||||
transform: var(--hover-scale-sm);
|
||||
}
|
||||
|
||||
.sidebar .menu_section > li > ul > li.active > a {
|
||||
.sidebar .menu_section>li>ul>li.active>a {
|
||||
color: var(--color-white);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.sidebar .menu_section > li > ul > li:hover > a {
|
||||
.sidebar .menu_section>li>ul>li:hover>a {
|
||||
color: var(--color-white);
|
||||
font-weight: var(--font-weight-demi-bold);
|
||||
}
|
||||
@@ -203,9 +206,13 @@
|
||||
}
|
||||
|
||||
.sidebar_header p {
|
||||
color: var(--success-hover-bg);
|
||||
background: linear-gradient(135deg, #8b6cc5 0%, #5ec8c8 55%, #d4789c 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.sidebar_widget {
|
||||
@@ -234,7 +241,7 @@
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar .menu_section > li > a {
|
||||
.sidebar .menu_section>li>a {
|
||||
font-size: var(--padding-sm);
|
||||
}
|
||||
|
||||
@@ -262,7 +269,7 @@
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar .menu_section > li > a {
|
||||
.sidebar .menu_section>li>a {
|
||||
font-size: var(--padding-sm);
|
||||
}
|
||||
|
||||
@@ -277,4 +284,4 @@
|
||||
#menu_button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,8 @@ const MenuItems = {
|
||||
{ name: "Dashcam Routes", link: "/dashcam_routes", icon: "bi-camera-reels" },
|
||||
{ name: "Screen Recordings", link: "/screen_recordings", icon: "bi-record-circle" },
|
||||
],
|
||||
tailscale: [
|
||||
{ name: "Tailscale", link: "/manage_tailscale", icon: "bi-wifi" },
|
||||
],
|
||||
tools: [
|
||||
{ name: "Device Settings", link: "/device_settings", icon: "bi-sliders" },
|
||||
{ name: "Download Speed Limits", link: "/download_speed_limits", icon: "bi-download" },
|
||||
{ name: "Error Logs", link: "/manage_error_logs", icon: "bi-exclamation-triangle" },
|
||||
{ name: "Lock/Unlock Doors", link: "/lock_or_unlock_doors", icon: "bi-door-closed" },
|
||||
@@ -88,10 +86,9 @@ export function Sidebar() {
|
||||
<div id="sidebar" class="sidebar">
|
||||
<div>
|
||||
<div class="title">
|
||||
<img class="logo" src="/assets/images/main_logo.png" alt="FrogPilot logo" />
|
||||
<img class="logo" src="/assets/images/main_logo.png" alt="Galaxy logo" />
|
||||
<div class="title_text sidebar_header">
|
||||
<p>The Pond</p>
|
||||
<a href="https://github.com/Aidenir">by Aidenir</a>
|
||||
<p>Galaxy</p>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
@@ -102,30 +99,30 @@ export function Sidebar() {
|
||||
<span class="section-title">${upperFirst(section)}</span>
|
||||
<ul id="${section}">
|
||||
${links.map(link => {
|
||||
if (link.name === "Lock/Unlock Doors" && !state.doorsVisible) {
|
||||
return "";
|
||||
}
|
||||
if (link.name === "Lock/Unlock Doors" && !state.doorsVisible) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (link.name === "Toyota Security Keys" && !state.tskVisible) {
|
||||
return "";
|
||||
}
|
||||
if (link.name === "Toyota Security Keys" && !state.tskVisible) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const isActive = state.activeRoute === link.name;
|
||||
const classList = [isActive && "active"].filter(Boolean).join(" ");
|
||||
const isActive = state.activeRoute === link.name;
|
||||
const classList = [isActive && "active"].filter(Boolean).join(" ");
|
||||
|
||||
const content = html`
|
||||
const content = html`
|
||||
<div class="menu-item-link">
|
||||
<i class="bi ${link.icon}"></i>
|
||||
<span>${upperFirst(link.name)}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html`
|
||||
return html`
|
||||
<li class="${classList}">
|
||||
${Link(link.link, content, () => navigate(link))}
|
||||
</li>
|
||||
`;
|
||||
})}
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -52,13 +52,14 @@ export function TailscaleControl() {
|
||||
checkInstallStatus()
|
||||
|
||||
return html`
|
||||
<div class="tailscale-wrapper">
|
||||
<div class="toggle-control-widget" style="margin-top: 1.5rem">
|
||||
<section class="tailscale-widget">
|
||||
<div class="tailscale-title">
|
||||
<div class="toggle-control-title">
|
||||
${() => state.installed ? 'Uninstall Tailscale' : 'Install Tailscale'}
|
||||
</div>
|
||||
<p class="tailscale-text">
|
||||
Tailscale creates a secure, private connection between your openpilot device and your phone or PC so you can access and control it from anywhere!
|
||||
Tailscale creates a secure, private connection between your openpilot device and your phone or PC so you can access and control it from anywhere!<br><br>
|
||||
<strong style="color: #ff9494;">Note: Not recommended. Using Galaxy Tunnel is the preferred remote connection method.</strong>
|
||||
</p>
|
||||
<div class="tailscale-button-wrapper">
|
||||
<button
|
||||
@@ -67,11 +68,11 @@ export function TailscaleControl() {
|
||||
disabled="${() => state.status === 'installing' || state.status === 'uninstalling'}"
|
||||
>
|
||||
${() => {
|
||||
if (state.status === 'installing') return 'Installing...'
|
||||
if (state.status === 'uninstalling') return 'Uninstalling...'
|
||||
if (state.installed) return 'Uninstall'
|
||||
return 'Install'
|
||||
}}
|
||||
if (state.status === 'installing') return 'Installing...'
|
||||
if (state.status === 'uninstalling') return 'Uninstalling...'
|
||||
if (state.installed) return 'Uninstall'
|
||||
return 'Install'
|
||||
}}
|
||||
</button>
|
||||
<a class="tailscale-link" href="https://tailscale.com/download" target="_blank">
|
||||
Download Tailscale on your other devices
|
||||
@@ -79,12 +80,12 @@ export function TailscaleControl() {
|
||||
</div>
|
||||
</section>
|
||||
${() => state.showUninstallModal ? Modal({
|
||||
title: "Confirm Uninstall",
|
||||
message: "Are you sure you want to uninstall Tailscale?",
|
||||
onConfirm: handleAction,
|
||||
onCancel: () => { state.showUninstallModal = false; },
|
||||
confirmText: "Uninstall"
|
||||
}) : ""}
|
||||
title: "Confirm Uninstall",
|
||||
message: "Are you sure you want to uninstall Tailscale?",
|
||||
onConfirm: handleAction,
|
||||
onCancel: () => { state.showUninstallModal = false; },
|
||||
confirmText: "Uninstall"
|
||||
}) : ""}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
/* ――― Device Settings Page ――― */
|
||||
.ds-wrapper {
|
||||
max-width: var(--width-xxxl);
|
||||
padding: var(--padding-base) var(--padding-lg) var(--padding-xxl);
|
||||
}
|
||||
|
||||
/* ――― Search / Filter ――― */
|
||||
.ds-search {
|
||||
background-color: var(--input-bg);
|
||||
border: var(--border-style-input);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-sizing: border-box;
|
||||
color: var(--text-color);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--font-size-base);
|
||||
margin-bottom: var(--margin-lg);
|
||||
outline: none;
|
||||
padding: var(--padding-sm) var(--padding-base);
|
||||
transition: box-shadow var(--transition-fast);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ds-search:focus {
|
||||
box-shadow: 0 0 0 2px var(--main-fg);
|
||||
}
|
||||
|
||||
/* ――― Section Cards ――― */
|
||||
.ds-section {
|
||||
background-color: var(--secondary-bg);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: var(--margin-lg);
|
||||
overflow: hidden;
|
||||
transition: box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.ds-section:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.ds-section-header {
|
||||
align-items: center;
|
||||
background-color: var(--input-bg);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--padding-sm) var(--padding-base);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ds-section-header i {
|
||||
color: var(--main-fg);
|
||||
font-size: var(--font-size-lg);
|
||||
margin-right: var(--margin-sm);
|
||||
min-width: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ds-section-title {
|
||||
color: var(--text-color);
|
||||
flex: 1;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.ds-section-chevron {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.ds-section.collapsed .ds-section-chevron {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.ds-section-body {
|
||||
max-height: 5000px;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.ds-section.collapsed .ds-section-body {
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
/* ――― Individual Toggle Rows ――― */
|
||||
.ds-row {
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--sidebar-border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--padding-sm) var(--padding-base);
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.ds-row:hover {
|
||||
background-color: var(--sidebar-active-bg);
|
||||
}
|
||||
|
||||
.ds-row-label {
|
||||
color: var(--text-color);
|
||||
flex: 1;
|
||||
font-size: var(--font-size-sm);
|
||||
margin-right: var(--margin-base);
|
||||
}
|
||||
|
||||
.ds-row-desc {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
margin-top: 0.25rem;
|
||||
line-height: 1.3;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* ――― Child Row Modifier (Sub-menus) ――― */
|
||||
.ds-child-modifier {
|
||||
border-left: 2px solid var(--color-gray-200);
|
||||
margin-left: 1rem;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.ds-manage-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: var(--color-gray-200);
|
||||
border: 1px solid var(--color-gray-300);
|
||||
border-radius: 1rem;
|
||||
color: var(--main-fg);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
margin-top: 0.6rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
transition: all var(--transition-fast);
|
||||
user-select: none;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.ds-manage-btn:hover {
|
||||
background-color: var(--color-gray-300);
|
||||
}
|
||||
|
||||
.ds-manage-btn i {
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
|
||||
/* ――― Toggle Switch ――― */
|
||||
.ds-toggle {
|
||||
appearance: none;
|
||||
background-color: var(--track-color);
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
flex-shrink: 0;
|
||||
height: 1.5rem;
|
||||
outline: none;
|
||||
position: relative;
|
||||
transition: background-color var(--transition-fast);
|
||||
width: 2.75rem;
|
||||
}
|
||||
|
||||
.ds-toggle::after {
|
||||
background-color: var(--color-gray-300);
|
||||
border-radius: 50%;
|
||||
content: "";
|
||||
height: 1.1rem;
|
||||
left: 0.2rem;
|
||||
position: absolute;
|
||||
top: 0.2rem;
|
||||
transition: transform var(--transition-fast), background-color var(--transition-fast);
|
||||
width: 1.1rem;
|
||||
}
|
||||
|
||||
.ds-toggle:checked {
|
||||
background-color: var(--main-fg);
|
||||
}
|
||||
|
||||
.ds-select {
|
||||
appearance: none;
|
||||
background-color: var(--color-gray-950);
|
||||
border: 1px solid var(--track-color);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--color-gray-200);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.5rem 2rem 0.5rem 1rem;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
width: 100%;
|
||||
max-width: 250px;
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/200.svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.5rem center;
|
||||
background-size: 1em;
|
||||
}
|
||||
|
||||
.ds-select:hover {
|
||||
border-color: var(--color-gray-500);
|
||||
}
|
||||
|
||||
.ds-select:focus {
|
||||
border-color: var(--main-fg);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ds-toggle:checked::after {
|
||||
background-color: var(--color-white);
|
||||
transform: translateX(1.25rem);
|
||||
}
|
||||
|
||||
.ds-toggle:disabled {
|
||||
opacity: var(--disabled-opacity);
|
||||
}
|
||||
|
||||
/* ――― Loading State ――― */
|
||||
.ds-loading {
|
||||
color: var(--text-muted);
|
||||
padding: var(--padding-xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ds-status-bar {
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
font-size: var(--font-size-xs);
|
||||
gap: var(--gap-sm);
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--margin-sm);
|
||||
}
|
||||
|
||||
/* ――― Empty Filter State ――― */
|
||||
.ds-empty {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: var(--padding-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ――― Custom Range Slider ――― */
|
||||
.ds-row-numeric {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ds-row-info {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--margin-sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ds-row-value {
|
||||
background-color: var(--sidebar-bg);
|
||||
border: var(--border-style-main);
|
||||
border-radius: var(--border-radius-base);
|
||||
color: var(--text-color);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--font-size-sm);
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
|
||||
.ds-slider-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.ds-slider-container {
|
||||
max-width: 350px;
|
||||
}
|
||||
}
|
||||
|
||||
.ds-slider {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
padding-bottom: var(--padding-sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ds-slider:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ds-slider::-webkit-slider-runnable-track {
|
||||
background: var(--track-color);
|
||||
border-radius: var(--border-radius-base);
|
||||
height: 0.4rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ds-slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
background: var(--main-fg);
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--shadow-sm);
|
||||
cursor: pointer;
|
||||
height: 1.2rem;
|
||||
margin-top: -0.4rem;
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
width: 1.2rem;
|
||||
}
|
||||
|
||||
.ds-slider::-webkit-slider-thumb:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.ds-slider::-moz-range-track {
|
||||
background: var(--track-color);
|
||||
border-radius: var(--border-radius-base);
|
||||
height: 0.4rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ds-slider::-moz-range-thumb {
|
||||
background: var(--main-fg);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--shadow-sm);
|
||||
cursor: pointer;
|
||||
height: 1.2rem;
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
width: 1.2rem;
|
||||
}
|
||||
|
||||
.ds-slider::-moz-range-thumb:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
/* ――― Mobile ――― */
|
||||
@media only screen and (max-width: 768px) and (orientation: portrait) {
|
||||
.ds-wrapper {
|
||||
padding: var(--padding-sm) var(--padding-base) var(--padding-xl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
|
||||
// ―――――――――――――――――――――――――――――――
|
||||
// Module-level state (persists across re-renders)
|
||||
// ―――――――――――――――――――――――――――――――
|
||||
const state = reactive({
|
||||
layout: [],
|
||||
allKeys: [],
|
||||
values: {},
|
||||
loadingLayout: true,
|
||||
loadingValues: true,
|
||||
filter: "",
|
||||
collapsed: {},
|
||||
expanded: {},
|
||||
updatingKeys: {},
|
||||
fetched: false,
|
||||
})
|
||||
|
||||
async function fetchLayoutAndParams() {
|
||||
state.loadingLayout = true
|
||||
state.loadingValues = true
|
||||
|
||||
// 1. Fetch Layout Structure (Build-time Static JSON)
|
||||
try {
|
||||
const layoutRes = await fetch("/assets/components/tools/device_settings_layout.json")
|
||||
const layoutData = await layoutRes.json()
|
||||
state.layout = layoutData
|
||||
|
||||
// Extract flatter key map
|
||||
const keys = []
|
||||
for (const section of layoutData) {
|
||||
for (const p of section.params) {
|
||||
keys.push(p.key)
|
||||
}
|
||||
}
|
||||
state.allKeys = keys
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch UI layout:", e)
|
||||
}
|
||||
state.loadingLayout = false
|
||||
|
||||
// 2. Fetch Live Values (Device State)
|
||||
try {
|
||||
const res = await fetch("/api/params/all")
|
||||
const data = await res.json()
|
||||
state.values = data
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch param values:", e)
|
||||
}
|
||||
state.loadingValues = false
|
||||
requestAnimationFrame(syncInputs)
|
||||
}
|
||||
|
||||
function syncInputs() {
|
||||
for (const key of state.allKeys) {
|
||||
const el = document.getElementById(`ds-${key}`)
|
||||
if (el) {
|
||||
if (el.type === "checkbox") {
|
||||
el.checked = !!state.values[key]
|
||||
} else if (el.tagName === "SELECT") {
|
||||
const endpoint = el.getAttribute("data-endpoint")
|
||||
if (endpoint && !el.dataset.hydrated) {
|
||||
el.dataset.hydrated = "1"
|
||||
fetch(endpoint).then(r => r.json()).then(options => {
|
||||
el.innerHTML = ""
|
||||
for (const opt of options) {
|
||||
const o = document.createElement("option")
|
||||
o.value = opt.value
|
||||
o.textContent = opt.label
|
||||
el.appendChild(o)
|
||||
}
|
||||
el.value = state.values[key] || ""
|
||||
}).catch(() => { el.innerHTML = '<option value="">Error loading</option>' })
|
||||
} else {
|
||||
el.value = state.values[key] || ""
|
||||
}
|
||||
} else {
|
||||
el.value = state.values[key]
|
||||
const displayEl = document.getElementById(`ds-display-${key}`)
|
||||
if (displayEl) {
|
||||
const precision = el.getAttribute("data-precision")
|
||||
const pInt = precision ? parseInt(precision, 10) : null
|
||||
displayEl.textContent = formatSliderValue(state.values[key], el.getAttribute("step"), pInt, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatSliderValue(val, stepStr, precisionInt, key) {
|
||||
if (val === null || val === undefined) return "--"
|
||||
const v = parseFloat(val)
|
||||
if (isNaN(v)) return val
|
||||
|
||||
// Specific formatting for the Audio Volume sliders mappings to simulate C++ behavior
|
||||
const volumeKeys = [
|
||||
"DisengageVolume", "EngageVolume", "PromptVolume",
|
||||
"PromptDistractedVolume", "RefuseVolume",
|
||||
"WarningImmediateVolume", "WarningSoftVolume"
|
||||
]
|
||||
if (key && volumeKeys.includes(key)) {
|
||||
if (v === 0) return "Muted"
|
||||
if (v === 101) return "Auto"
|
||||
return `${v}%`
|
||||
}
|
||||
|
||||
if (precisionInt !== undefined && precisionInt !== null) {
|
||||
return Number(v.toFixed(precisionInt)).toString()
|
||||
}
|
||||
|
||||
if (!stepStr || !stepStr.includes(".")) return Math.round(v).toString()
|
||||
const dec = stepStr.split(".")[1].length
|
||||
return Number(v.toFixed(dec)).toString()
|
||||
}
|
||||
|
||||
async function updateParam(key, elType) {
|
||||
const current = state.values[key]
|
||||
|
||||
// Extract new value from the DOM directly to avoid reactive race conditions
|
||||
const el = document.getElementById(`ds-${key}`)
|
||||
if (!el) return
|
||||
|
||||
let formattedVal
|
||||
if (elType === "checkbox") {
|
||||
formattedVal = current ? false : true
|
||||
} else if (elType === "dropdown") {
|
||||
formattedVal = el.value
|
||||
} else {
|
||||
// Numeric slider - coerce to float
|
||||
formattedVal = parseFloat(el.value)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/params", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key, value: formattedVal }),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (res.ok) {
|
||||
state.values = { ...state.values, [key]: formattedVal }
|
||||
showSnackbar(data.message || `${key} updated`)
|
||||
} else {
|
||||
revertInput(key, current, elType)
|
||||
showSnackbar(data.error || "Failed to update parameter")
|
||||
}
|
||||
} catch (e) {
|
||||
revertInput(key, current, elType)
|
||||
showSnackbar("Network error — is the device reachable?")
|
||||
}
|
||||
}
|
||||
|
||||
function revertInput(key, current, elType) {
|
||||
const el = document.getElementById(`ds-${key}`)
|
||||
if (el) {
|
||||
if (elType === "checkbox") el.checked = !!current
|
||||
else if (elType === "dropdown") el.value = current || ""
|
||||
else {
|
||||
el.value = current
|
||||
const displayEl = document.getElementById(`ds-display-${key}`)
|
||||
if (displayEl) {
|
||||
const precision = el.getAttribute("data-precision")
|
||||
const pInt = precision ? parseInt(precision, 10) : null
|
||||
displayEl.textContent = formatSliderValue(current, el.getAttribute("step"), pInt, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSliderInput(e, key) {
|
||||
const displayEl = document.getElementById(`ds-display-${key}`)
|
||||
if (displayEl) {
|
||||
const el = e.target
|
||||
const precision = el.getAttribute("data-precision")
|
||||
const pInt = precision ? parseInt(precision, 10) : null
|
||||
displayEl.textContent = formatSliderValue(el.value, el.getAttribute("step"), pInt, key)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSection(name) {
|
||||
state.collapsed = { ...state.collapsed, [name]: !state.collapsed[name] }
|
||||
setTimeout(syncInputs, 50)
|
||||
}
|
||||
|
||||
function toggleManage(key) {
|
||||
state.expanded = { ...state.expanded, [key]: !state.expanded[key] }
|
||||
setTimeout(syncInputs, 50)
|
||||
}
|
||||
|
||||
function matchesFilter(p) {
|
||||
if (!state.filter) return true
|
||||
const q = state.filter.toLowerCase()
|
||||
return p.label.toLowerCase().includes(q) || p.key.toLowerCase().includes(q)
|
||||
}
|
||||
|
||||
// ―――――――――――――――――――――――――――――――
|
||||
// Component
|
||||
// ―――――――――――――――――――――――――――――――
|
||||
export function DeviceSettings() {
|
||||
if (!state.fetched) {
|
||||
state.fetched = true
|
||||
fetchLayoutAndParams()
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="ds-wrapper">
|
||||
<h2>Device Settings</h2>
|
||||
|
||||
<input
|
||||
class="ds-search"
|
||||
type="text"
|
||||
placeholder="Search settings..."
|
||||
@input="${(e) => { state.filter = e.target.value }}"
|
||||
/>
|
||||
|
||||
${() => {
|
||||
if (state.loadingLayout || state.loadingValues) {
|
||||
return html`<div class="ds-loading">Loading configuration...</div>`
|
||||
}
|
||||
|
||||
const loadedKeys = state.allKeys.length
|
||||
|
||||
// Sync DOM inputs after reactive render
|
||||
requestAnimationFrame(syncInputs)
|
||||
|
||||
return html`
|
||||
<div class="ds-status-bar">
|
||||
<span>${loadedKeys} settings mapped dynamically</span>
|
||||
</div>
|
||||
|
||||
${state.layout.map(section => {
|
||||
const visibleParams = section.params.filter(p => matchesFilter(p))
|
||||
if (visibleParams.length === 0) return ""
|
||||
|
||||
const isCollapsed = state.collapsed[section.name]
|
||||
|
||||
return html`
|
||||
<div class="ds-section ${isCollapsed ? 'collapsed' : ''}">
|
||||
<div class="ds-section-header" @click="${() => toggleSection(section.name)}">
|
||||
<i class="bi ${section.icon}"></i>
|
||||
<span class="ds-section-title">${section.name} (${visibleParams.length})</span>
|
||||
<i class="bi bi-chevron-down ds-section-chevron"></i>
|
||||
</div>
|
||||
<div class="ds-section-body">
|
||||
${() => visibleParams.map(p => {
|
||||
if (p.parent_key) {
|
||||
if (!state.values[p.parent_key]) return ""
|
||||
if (!state.expanded[p.parent_key]) return ""
|
||||
}
|
||||
|
||||
const isNumeric = p.ui_type === "numeric" || p.data_type === "float" || p.data_type === "int"
|
||||
const isChild = p.parent_key ? "ds-child-modifier" : ""
|
||||
|
||||
return html`
|
||||
<div class="ds-row ${isNumeric ? 'ds-row-numeric' : ''} ${isChild}">
|
||||
<div class="ds-row-info">
|
||||
<div class="ds-row-text">
|
||||
<span class="ds-row-label">${p.label}</span>
|
||||
${p.description ? html`<div class="ds-row-desc">${p.description}</div>` : ""}
|
||||
|
||||
${() => p.is_parent_toggle && state.values[p.key] ? html`
|
||||
<div class="ds-manage-btn" @click="${() => toggleManage(p.key)}">
|
||||
${state.expanded[p.key] ? 'Close' : 'Manage'} <i class="bi bi-chevron-${state.expanded[p.key] ? 'up' : 'down'}"></i>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
${isNumeric ? html`<span class="ds-row-value" id="ds-display-${p.key}">${state.values[p.key] !== undefined ? formatSliderValue(state.values[p.key], p.step !== undefined ? String(p.step) : undefined, p.precision, p.key) : '..'}</span>` : ""}
|
||||
</div>
|
||||
|
||||
${isNumeric ? html`
|
||||
<div class="ds-slider-container">
|
||||
<input
|
||||
type="range"
|
||||
class="ds-slider"
|
||||
id="ds-${p.key}"
|
||||
min="${p.min !== undefined ? p.min : (p.data_type === 'float' ? 0.0 : 0)}"
|
||||
max="${p.max !== undefined ? p.max : (p.data_type === 'float' ? 100.0 : 100)}"
|
||||
step="${p.step !== undefined ? p.step : (p.data_type === 'float' ? 0.01 : 1)}"
|
||||
data-precision="${p.precision !== undefined ? p.precision : ''}"
|
||||
value="${state.values[p.key] !== undefined ? state.values[p.key] : ''}"
|
||||
@input="${(e) => handleSliderInput(e, p.key)}"
|
||||
@change="${() => updateParam(p.key, 'numeric')}"
|
||||
/>
|
||||
</div>
|
||||
` : p.ui_type === "dropdown" ? html`
|
||||
<select class="ds-select" id="ds-${p.key}"
|
||||
data-endpoint="${p.options_endpoint || ''}"
|
||||
@change="${() => updateParam(p.key, 'dropdown')}">
|
||||
<option value="">Loading…</option>
|
||||
</select>
|
||||
` : html`
|
||||
<input
|
||||
type="checkbox"
|
||||
class="ds-toggle"
|
||||
id="ds-${p.key}"
|
||||
.checked="${!!state.values[p.key]}"
|
||||
@change="${() => updateParam(p.key, 'checkbox')}"
|
||||
/>
|
||||
`}
|
||||
</div>
|
||||
`
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})}
|
||||
|
||||
${() => {
|
||||
const totalVisible = state.layout.reduce((acc, s) =>
|
||||
acc + s.params.filter(p => matchesFilter(p)).length, 0)
|
||||
if (totalVisible === 0) {
|
||||
return html`<div class="ds-empty">No settings match your search.</div>`
|
||||
}
|
||||
return ""
|
||||
}}
|
||||
`
|
||||
}}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -77,11 +77,11 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.checklist-item input[type="checkbox"]:checked ~ .custom-checkbox {
|
||||
.checklist-item input[type="checkbox"]:checked~.custom-checkbox {
|
||||
background-color: var(--success-bg);
|
||||
}
|
||||
|
||||
.checklist-item input[type="checkbox"]:checked ~ .custom-checkbox::before {
|
||||
.checklist-item input[type="checkbox"]:checked~.custom-checkbox::before {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@
|
||||
flex-shrink: 0;
|
||||
transition: color var(--transition-fast), transform 0.2s;
|
||||
}
|
||||
|
||||
.file-clear-button:hover {
|
||||
color: var(--danger-hover-bg);
|
||||
transform: scale(1.1);
|
||||
@@ -613,7 +614,7 @@ label.file-upload-button {
|
||||
.turn-signal-input:focus,
|
||||
.turn-signal-input:hover {
|
||||
border-color: var(--thumb-color);
|
||||
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
|
||||
box-shadow: var(--glow-primary);
|
||||
outline: none;
|
||||
transform: var(--hover-scale-sm);
|
||||
}
|
||||
@@ -690,8 +691,15 @@ label.file-upload-button {
|
||||
}
|
||||
|
||||
@keyframes modalFadeIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) and (orientation: portrait) {
|
||||
@@ -713,8 +721,8 @@ label.file-upload-button {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.save-button-wrapper > button {
|
||||
.save-button-wrapper>button {
|
||||
flex-basis: calc(50% - 1rem);
|
||||
margin-bottom: var(--gap-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { formatSecondsToHuman } from "/assets/js/utils.js"
|
||||
import { formatSecondsToHuman, isGalaxyTunnel } from "/assets/js/utils.js"
|
||||
import { Modal } from "/assets/components/modal.js";
|
||||
|
||||
const logSelectorState = reactive({
|
||||
@@ -122,34 +122,34 @@ function TmuxLogSelector({ action, closeFn }) {
|
||||
</div>
|
||||
|
||||
${() => {
|
||||
if (logSelectorState.loading && !logSelectorState.logsLoadedOnce) {
|
||||
return html`<div class="fileEntry"><p>Loading...</p></div>`;
|
||||
}
|
||||
if (logSelectorState.files.length === 0) {
|
||||
return html`<div class="fileEntry"><p>No tmux logs found!</p></div>`;
|
||||
}
|
||||
return logSelectorState.files.map(file => html`
|
||||
if (logSelectorState.loading && !logSelectorState.logsLoadedOnce) {
|
||||
return html`<div class="fileEntry"><p>Loading...</p></div>`;
|
||||
}
|
||||
if (logSelectorState.files.length === 0) {
|
||||
return html`<div class="fileEntry"><p>No tmux logs found!</p></div>`;
|
||||
}
|
||||
return logSelectorState.files.map(file => html`
|
||||
<div class="fileEntry" @click="${() => handleFileClick(file)}">
|
||||
<p><span class="label">Filename:</span> <span class="value">${file.filename}</span></p>
|
||||
<p><span class="label">Date:</span> <span class="value">${file.date}</span></p>
|
||||
<p><span class="label">Age:</span> <span class="value">${file.timeSince < 60 ? "just now" : `${formatSecondsToHuman(file.timeSince, "minutes")} ago`}</span></p>
|
||||
</div>
|
||||
`);
|
||||
}}
|
||||
}}
|
||||
|
||||
<button @click="${closeFn}" class="cancel-button">Close</button>
|
||||
|
||||
${() => logSelectorState.logToDelete ? Modal({
|
||||
title: "Confirm Delete",
|
||||
message: `Are you sure you want to delete <strong>${logSelectorState.logToDelete.filename}</strong>?`,
|
||||
onConfirm: confirmDeleteFile,
|
||||
onCancel: () => { logSelectorState.logToDelete = null },
|
||||
confirmText: "Yes, Delete"
|
||||
}) : ""}
|
||||
title: "Confirm Delete",
|
||||
message: `Are you sure you want to delete <strong>${logSelectorState.logToDelete.filename}</strong>?`,
|
||||
onConfirm: confirmDeleteFile,
|
||||
onCancel: () => { logSelectorState.logToDelete = null },
|
||||
confirmText: "Yes, Delete"
|
||||
}) : ""}
|
||||
|
||||
${() => logSelectorState.logToRename ? Modal({
|
||||
title: "Rename Log",
|
||||
message: html`
|
||||
title: "Rename Log",
|
||||
message: html`
|
||||
<div>
|
||||
<p>Rename <strong>${logSelectorState.logToRename.filename}</strong> to:</p>
|
||||
<div style="margin-top: 10px;">
|
||||
@@ -163,20 +163,30 @@ function TmuxLogSelector({ action, closeFn }) {
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
onConfirm: confirmRenameFile,
|
||||
onCancel: () => {
|
||||
logSelectorState.logToRename = null;
|
||||
logSelectorState.newName = "";
|
||||
},
|
||||
confirmText: "Rename",
|
||||
confirmClass: "btn-primary"
|
||||
}) : ""}
|
||||
onConfirm: confirmRenameFile,
|
||||
onCancel: () => {
|
||||
logSelectorState.logToRename = null;
|
||||
logSelectorState.newName = "";
|
||||
},
|
||||
confirmText: "Rename",
|
||||
confirmClass: "btn-primary"
|
||||
}) : ""}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
export function TmuxLog() {
|
||||
if (isGalaxyTunnel()) {
|
||||
return html`
|
||||
<div class="tunnel-notice">
|
||||
<div class="tunnel-notice-icon">🛰️</div>
|
||||
<h3 class="tunnel-notice-title">Tmux Log Unavailable via Galaxy</h3>
|
||||
<p class="tunnel-notice-body">Live tmux streaming requires a direct connection.<br>Connect to your device's local network to use this feature.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const state = reactive({
|
||||
paused: false,
|
||||
latest: '',
|
||||
@@ -198,7 +208,7 @@ export function TmuxLog() {
|
||||
event_source.close();
|
||||
}
|
||||
|
||||
function togglePause () {
|
||||
function togglePause() {
|
||||
state.paused = !state.paused;
|
||||
if (!state.paused) {
|
||||
state.log = state.latest;
|
||||
@@ -263,20 +273,20 @@ export function TmuxLog() {
|
||||
</div>
|
||||
|
||||
${() => state.selectorAction
|
||||
? TmuxLogSelector({
|
||||
action: state.selectorAction,
|
||||
closeFn: () => (state.selectorAction = null)
|
||||
})
|
||||
: ""
|
||||
}
|
||||
? TmuxLogSelector({
|
||||
action: state.selectorAction,
|
||||
closeFn: () => (state.selectorAction = null)
|
||||
})
|
||||
: ""
|
||||
}
|
||||
|
||||
${() => logSelectorState.showDeleteAllModal ? Modal({
|
||||
title: "Delete All Logs",
|
||||
message: "Are you sure you want to delete all of your session logs?",
|
||||
onConfirm: deleteAllSessions,
|
||||
onCancel: () => { logSelectorState.showDeleteAllModal = false },
|
||||
confirmText: "Delete All"
|
||||
}) : ""}
|
||||
title: "Delete All Logs",
|
||||
message: "Are you sure you want to delete all of your session logs?",
|
||||
onConfirm: deleteAllSessions,
|
||||
onCancel: () => { logSelectorState.showDeleteAllModal = false },
|
||||
confirmText: "Delete All"
|
||||
}) : ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { Modal } from "/assets/components/modal.js"
|
||||
import { TailscaleControl } from "/assets/components/tailscale/tailscale.js"
|
||||
|
||||
export function ToggleControl () {
|
||||
export function ToggleControl() {
|
||||
const state = reactive({
|
||||
showResetDefaultModal: false,
|
||||
showResetStockModal: false,
|
||||
@@ -14,7 +15,7 @@ export function ToggleControl () {
|
||||
fileInput.addEventListener("change", restoreToggles)
|
||||
document.body.appendChild(fileInput)
|
||||
|
||||
async function backupToggles () {
|
||||
async function backupToggles() {
|
||||
const response = await fetch("/api/toggles/backup", { method: "POST" })
|
||||
const blob = await response.blob()
|
||||
|
||||
@@ -26,7 +27,7 @@ export function ToggleControl () {
|
||||
URL.revokeObjectURL(downloadUrl)
|
||||
}
|
||||
|
||||
async function restoreToggles (event) {
|
||||
async function restoreToggles(event) {
|
||||
const uploadedFile = event.target.files[0]
|
||||
if (uploadedFile) {
|
||||
const fileContents = await uploadedFile.text()
|
||||
@@ -45,11 +46,11 @@ export function ToggleControl () {
|
||||
}
|
||||
}
|
||||
|
||||
function confirmResetDefault () {
|
||||
function confirmResetDefault() {
|
||||
state.showResetDefaultModal = true;
|
||||
}
|
||||
|
||||
async function resetTogglesToDefault () {
|
||||
async function resetTogglesToDefault() {
|
||||
state.showResetDefaultModal = false;
|
||||
showSnackbar("Resetting toggles to their default values...");
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
@@ -58,11 +59,11 @@ export function ToggleControl () {
|
||||
await fetch("/api/toggles/reset_default", { method: "POST" });
|
||||
}
|
||||
|
||||
function confirmResetStock () {
|
||||
function confirmResetStock() {
|
||||
state.showResetStockModal = true;
|
||||
}
|
||||
|
||||
async function resetTogglesToStock () {
|
||||
async function resetTogglesToStock() {
|
||||
state.showResetStockModal = false;
|
||||
showSnackbar("Resetting toggles to stock openpilot values...");
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
@@ -71,7 +72,7 @@ export function ToggleControl () {
|
||||
await fetch("/api/toggles/reset_stock", { method: "POST" });
|
||||
}
|
||||
|
||||
function triggerRestorePrompt () {
|
||||
function triggerRestorePrompt() {
|
||||
fileInput.click()
|
||||
}
|
||||
|
||||
@@ -98,20 +99,22 @@ export function ToggleControl () {
|
||||
Reset Toggles to Stock
|
||||
</button>
|
||||
</section>
|
||||
|
||||
${TailscaleControl()}
|
||||
</div>
|
||||
${() => state.showResetDefaultModal ? Modal({
|
||||
title: "Reset Toggles",
|
||||
message: "Are you sure you want to reset all toggles to their default FrogPilot values?",
|
||||
onConfirm: resetTogglesToDefault,
|
||||
onCancel: () => { state.showResetDefaultModal = false; },
|
||||
confirmText: "Reset to Default"
|
||||
}) : ""}
|
||||
title: "Reset Toggles",
|
||||
message: "Are you sure you want to reset all toggles to their default FrogPilot values?",
|
||||
onConfirm: resetTogglesToDefault,
|
||||
onCancel: () => { state.showResetDefaultModal = false; },
|
||||
confirmText: "Reset to Default"
|
||||
}) : ""}
|
||||
${() => state.showResetStockModal ? Modal({
|
||||
title: "Reset Toggles",
|
||||
message: "Are you sure you want to reset all toggles to stock openpilot values?",
|
||||
onConfirm: resetTogglesToStock,
|
||||
onCancel: () => { state.showResetStockModal = false; },
|
||||
confirmText: "Reset to Stock"
|
||||
}) : ""}
|
||||
title: "Reset Toggles",
|
||||
message: "Are you sure you want to reset all toggles to stock openpilot values?",
|
||||
onConfirm: resetTogglesToStock,
|
||||
onCancel: () => { state.showResetStockModal = false; },
|
||||
confirmText: "Reset to Stock"
|
||||
}) : ""}
|
||||
`
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
transition: opacity var(--transition-slow);
|
||||
}
|
||||
|
||||
.tskkeys-group > .tskkeys-row:last-of-type {
|
||||
.tskkeys-group>.tskkeys-row:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
.tskkeys-input:hover,
|
||||
.tskkeys-input:focus {
|
||||
border-color: var(--thumb-color);
|
||||
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
|
||||
box-shadow: var(--glow-primary);
|
||||
transform: var(--hover-scale-sm);
|
||||
}
|
||||
|
||||
@@ -211,4 +211,4 @@
|
||||
.tskkeys-input {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 680 B After Width: | Height: | Size: 975 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 1008 KiB After Width: | Height: | Size: 332 KiB |
@@ -65,3 +65,11 @@ export function hideSidebar() {
|
||||
document.getElementById("sidebarUnderlay")?.classList.add("hidden")
|
||||
html.classList.remove("no_scroll")
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the page is being accessed through the Galaxy tunnel
|
||||
* (the public-facing domain is galaxy.firestar.link)
|
||||
*/
|
||||
export function isGalaxyTunnel() {
|
||||
return window.location.hostname === 'galaxy.firestar.link';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "The Pond",
|
||||
"name": "Galaxy",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{
|
||||
@@ -17,4 +17,4 @@
|
||||
"background_color": "#151414",
|
||||
"theme_color": "#151414",
|
||||
"display": "standalone"
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,62 @@
|
||||
<!doctype html>
|
||||
<html lang="en" id="htmlElement" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/images/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/images/favicon-16x16.png">
|
||||
<link rel="manifest" href="/assets/manifest.json">
|
||||
<link rel="shortcut icon" href="/assets/images/favicon.ico">
|
||||
|
||||
<meta name="theme-color" content="#4285f4" />
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/images/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/images/favicon-16x16.png">
|
||||
<link rel="manifest" href="/assets/manifest.json">
|
||||
<link rel="shortcut icon" href="/assets/images/favicon.ico">
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.css" rel="stylesheet">
|
||||
<meta name="theme-color" content="#8b6cc5" />
|
||||
|
||||
<link rel="stylesheet" href="/assets/components/home/home.css">
|
||||
<link rel="stylesheet" href="/assets/components/main.css">
|
||||
<link rel="stylesheet" href="/assets/components/modal.css">
|
||||
<link rel="stylesheet" href="/assets/components/navigation/navigation_destination.css">
|
||||
<link rel="stylesheet" href="/assets/components/navigation/navigation_keys.css">
|
||||
<link rel="stylesheet" href="/assets/components/recordings/dashcam_routes.css">
|
||||
<link rel="stylesheet" href="/assets/components/recordings/screen_recordings.css">
|
||||
<link rel="stylesheet" href="/assets/components/settings.css">
|
||||
<link rel="stylesheet" href="/assets/components/sidebar.css">
|
||||
<link rel="stylesheet" href="/assets/components/tailscale/tailscale.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/doors.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/error_logs.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/speed_limits.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/theme_maker.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/tmux.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/toggles.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/tsk_manager.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.css" rel="stylesheet">
|
||||
|
||||
<script type="module" src="/assets/components/router.js"></script>
|
||||
<script src="/assets/js/snackbar.js"></script>
|
||||
<link rel="stylesheet" href="/assets/components/home/home.css">
|
||||
<link rel="stylesheet" href="/assets/components/main.css">
|
||||
<link rel="stylesheet" href="/assets/components/modal.css">
|
||||
<link rel="stylesheet" href="/assets/components/navigation/navigation_destination.css">
|
||||
<link rel="stylesheet" href="/assets/components/navigation/navigation_keys.css">
|
||||
<link rel="stylesheet" href="/assets/components/recordings/dashcam_routes.css">
|
||||
<link rel="stylesheet" href="/assets/components/recordings/screen_recordings.css">
|
||||
<link rel="stylesheet" href="/assets/components/settings.css">
|
||||
<link rel="stylesheet" href="/assets/components/sidebar.css">
|
||||
<link rel="stylesheet" href="/assets/components/tailscale/tailscale.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/doors.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/error_logs.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/speed_limits.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/theme_maker.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/tmux.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/toggles.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/device_settings.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/tsk_manager.css">
|
||||
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.js"></script>
|
||||
<script type="module" src="/assets/components/router.js"></script>
|
||||
<script src="/assets/js/snackbar.js"></script>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap"
|
||||
rel="stylesheet">
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.js"></script>
|
||||
|
||||
<title>The Pond</title>
|
||||
</head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300..800&family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap"
|
||||
rel="stylesheet">
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<!-- Menu button for phone -->
|
||||
<div id="menu_button">
|
||||
<i class="bi bi-list"></i>
|
||||
</div>
|
||||
<!-- Snackbar for messages -->
|
||||
<div id="snackbar_wrapper"></div>
|
||||
</body>
|
||||
<title>Galaxy</title>
|
||||
</head>
|
||||
|
||||
</html>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<!-- Menu button for phone -->
|
||||
<div id="menu_button">
|
||||
<i class="bi bi-list"></i>
|
||||
</div>
|
||||
<!-- Snackbar for messages -->
|
||||
<div id="snackbar_wrapper"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -300,10 +300,136 @@ def setup(app):
|
||||
|
||||
return jsonify(message=f"{', '.join(saved)} saved successfully!")
|
||||
|
||||
@app.route("/api/params", methods=["GET"])
|
||||
@app.route("/api/params", methods=["GET", "PUT"])
|
||||
def get_param():
|
||||
if request.method == "PUT":
|
||||
data = request.get_json()
|
||||
if not data or "key" not in data or "value" not in data:
|
||||
return jsonify({"error": "Missing 'key' or 'value' in request body."}), 400
|
||||
|
||||
key = data["key"]
|
||||
val = data["value"]
|
||||
|
||||
# Python json parses true/false as boolean
|
||||
if isinstance(val, bool):
|
||||
str_val = "1" if val else "0"
|
||||
else:
|
||||
str_val = str(val)
|
||||
|
||||
allowed_keys = {k for k, _, _, _ in frogpilot_default_params if k not in EXCLUDED_KEYS}
|
||||
if key not in allowed_keys:
|
||||
return jsonify({"error": f"Parameter '{key}' is not editable."}), 403
|
||||
|
||||
# 1. Prevent changing the model or reboot-required toggles while the car is actively driving
|
||||
reboot_keys = {"Model", "AlwaysOnLateral", "ForceTorqueController", "NNFF", "NNFFLite"}
|
||||
if key in reboot_keys and params.get_bool("IsOnroad"):
|
||||
friendly_names = {
|
||||
"Model": "Driving Model",
|
||||
"AlwaysOnLateral": "Always On Lateral",
|
||||
"ForceTorqueController": "Force Torque Controller",
|
||||
"NNFF": "NNFF",
|
||||
"NNFFLite": "NNFF-Lite"
|
||||
}
|
||||
name = friendly_names.get(key, key)
|
||||
return jsonify({"error": f"Cannot change {name} while the car is driving. A reboot is required."}), 403
|
||||
|
||||
params.put(key, str_val)
|
||||
|
||||
if key == "Model":
|
||||
# 2. Sync ModelVersion explicitly
|
||||
try:
|
||||
import json
|
||||
with open("/data/models/.model_versions.json", "r") as f:
|
||||
versions = json.load(f)
|
||||
if str_val in versions:
|
||||
params.put("ModelVersion", versions[str_val])
|
||||
except Exception:
|
||||
pass # Failsafe if json doesn't exist
|
||||
|
||||
update_frogpilot_toggles()
|
||||
|
||||
return jsonify({"message": f"Parameter '{key}' updated successfully."}), 200
|
||||
|
||||
return params.get(request.args.get("key")) or "", 200
|
||||
|
||||
@app.route("/api/params/all", methods=["GET"])
|
||||
def get_all_params():
|
||||
allowed_keys = {k for k, _, _, _ in frogpilot_default_params if k not in EXCLUDED_KEYS}
|
||||
|
||||
# Establish intended types from defaults
|
||||
types = {}
|
||||
for k, default_val, _, _ in frogpilot_default_params:
|
||||
if k in allowed_keys:
|
||||
if default_val in ("0", "1", b"0", b"1") or isinstance(default_val, bool):
|
||||
types[k] = bool
|
||||
elif isinstance(default_val, float) or (isinstance(default_val, str) and "." in default_val and default_val.replace(".", "", 1).isdigit()):
|
||||
types[k] = float
|
||||
elif isinstance(default_val, int) or (isinstance(default_val, str) and default_val.isdigit()):
|
||||
types[k] = int
|
||||
else:
|
||||
types[k] = str
|
||||
|
||||
# Override ambiguous "0"/"1" defaults using layout JSON's authoritative data_type
|
||||
try:
|
||||
layout_path = os.path.join(os.path.dirname(__file__), "assets", "components", "tools", "device_settings_layout.json")
|
||||
with open(layout_path) as f:
|
||||
layout_data = json.load(f)
|
||||
for section in layout_data:
|
||||
for p in section.get("params", []):
|
||||
k = p.get("key")
|
||||
dt = p.get("data_type")
|
||||
if k in types and dt in ("int", "float") and types[k] == bool:
|
||||
types[k] = float if dt == "float" else int
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = {}
|
||||
for key in allowed_keys:
|
||||
t = types.get(key, str)
|
||||
try:
|
||||
if t == bool:
|
||||
result[key] = params.get_bool(key)
|
||||
else:
|
||||
raw = params.get(key)
|
||||
raw_str = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else str(raw or "")
|
||||
|
||||
if not raw_str:
|
||||
result[key] = 0.0 if t == float else (0 if t == int else "")
|
||||
elif t == float:
|
||||
result[key] = float(raw_str)
|
||||
elif t == int:
|
||||
result[key] = int(float(raw_str))
|
||||
else:
|
||||
result[key] = raw_str
|
||||
except Exception:
|
||||
result[key] = None
|
||||
|
||||
return jsonify(result), 200
|
||||
|
||||
@app.route("/api/models/installed", methods=["GET"])
|
||||
def get_installed_models():
|
||||
"""Returns only models with files present in /data/models/."""
|
||||
import os
|
||||
|
||||
available = (params.get("AvailableModels", encoding="utf-8") or "").split(",")
|
||||
names = (params.get("AvailableModelNames", encoding="utf-8") or "").split(",")
|
||||
models_dir = "/data/models"
|
||||
|
||||
try:
|
||||
on_disk = os.listdir(models_dir) if os.path.isdir(models_dir) else []
|
||||
except Exception:
|
||||
on_disk = []
|
||||
|
||||
installed = []
|
||||
for i, key in enumerate(available):
|
||||
if not key:
|
||||
continue
|
||||
if any(f.startswith(f"{key}.") or f.startswith(f"{key}_") for f in on_disk):
|
||||
label = names[i] if i < len(names) else key
|
||||
installed.append({"value": key, "label": label})
|
||||
|
||||
return jsonify(installed), 200
|
||||
|
||||
@app.route("/api/params_memory", methods=["GET"])
|
||||
def get_param_memory():
|
||||
return params_memory.get(request.args.get("key")) or "", 200
|
||||
@@ -1441,12 +1567,15 @@ def setup(app):
|
||||
run_cmd(["tmux", "resize-window", "-t", "comma:0", "-x", "240", "-y", "70"], "Resized tmux window", "Failed to resize tmux window")
|
||||
|
||||
def generate():
|
||||
last_output = ""
|
||||
while True:
|
||||
output = subprocess.check_output(["tmux", "capture-pane", "-t", "comma:0", "-p", "-S", "-1000"], text=True)
|
||||
|
||||
yield "data: " + "\n".join(reversed(output.splitlines())).replace("\n", "\ndata: ") + "\n\n"
|
||||
if output != last_output:
|
||||
yield "data: " + "\n".join(reversed(output.splitlines())).replace("\n", "\ndata: ") + "\n\n"
|
||||
last_output = output
|
||||
|
||||
time.sleep(0.1)
|
||||
time.sleep(0.5)
|
||||
return Response(generate(), mimetype="text/event-stream")
|
||||
|
||||
@app.route("/api/tmux_log/rename/<old>/<new>", methods=["PUT"])
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
#include <vector>
|
||||
|
||||
#include <QDebug>
|
||||
#include <QCryptographicHash>
|
||||
#include <QrCode.hpp>
|
||||
|
||||
#include "common/watchdog.h"
|
||||
#include "common/util.h"
|
||||
@@ -207,6 +209,43 @@ void TogglesPanel::updateToggles() {
|
||||
}
|
||||
}
|
||||
|
||||
GalaxyQRPopup::GalaxyQRPopup(const QString &url, QWidget *parent) : DialogBase(parent) {
|
||||
setStyleSheet("GalaxyQRPopup { background-color: #1a1a30; }");
|
||||
QVBoxLayout *layout = new QVBoxLayout(this);
|
||||
layout->setAlignment(Qt::AlignCenter);
|
||||
layout->setSpacing(30);
|
||||
layout->setContentsMargins(60, 40, 60, 40);
|
||||
|
||||
// Generate QR image
|
||||
auto qr = qrcodegen::QrCode::encodeText(url.toUtf8().data(), qrcodegen::QrCode::Ecc::LOW);
|
||||
int sz = qr.getSize();
|
||||
QImage im(sz, sz, QImage::Format_RGB32);
|
||||
for (int y = 0; y < sz; y++)
|
||||
for (int x = 0; x < sz; x++)
|
||||
im.setPixel(x, y, qr.getModule(x, y) ? qRgb(0,0,0) : qRgb(255,255,255));
|
||||
QPixmap qrPixmap = QPixmap::fromImage(im.scaled(400, 400, Qt::KeepAspectRatio), Qt::MonoOnly);
|
||||
|
||||
QLabel *title = new QLabel(tr("Scan to open Galaxy"), this);
|
||||
title->setStyleSheet("font-size: 52px; font-weight: bold; color: white;");
|
||||
title->setAlignment(Qt::AlignCenter);
|
||||
layout->addWidget(title);
|
||||
|
||||
QLabel *qrLabel = new QLabel(this);
|
||||
qrLabel->setPixmap(qrPixmap);
|
||||
qrLabel->setAlignment(Qt::AlignCenter);
|
||||
layout->addWidget(qrLabel);
|
||||
|
||||
QLabel *urlLabel = new QLabel(url, this);
|
||||
urlLabel->setStyleSheet("font-size: 36px; color: #8b6cc5;");
|
||||
urlLabel->setAlignment(Qt::AlignCenter);
|
||||
layout->addWidget(urlLabel);
|
||||
|
||||
QLabel *hint = new QLabel(tr("Tap anywhere to dismiss"), this);
|
||||
hint->setStyleSheet("font-size: 28px; color: #7e7e98;");
|
||||
hint->setAlignment(Qt::AlignCenter);
|
||||
layout->addWidget(hint);
|
||||
}
|
||||
|
||||
DevicePanel::DevicePanel(SettingsWindow *parent) : ListWidget(parent) {
|
||||
setSpacing(50);
|
||||
addItem(new LabelControl(tr("Dongle ID"), getDongleId().value_or(tr("N/A"))));
|
||||
@@ -220,6 +259,41 @@ DevicePanel::DevicePanel(SettingsWindow *parent) : ListWidget(parent) {
|
||||
});
|
||||
addItem(pair_device);
|
||||
|
||||
pair_galaxy = new ButtonControl(tr("Galaxy"), tr("Pair"), tr("Pair your device with Galaxy for remote access to The Pond."));
|
||||
connect(pair_galaxy, &ButtonControl::clicked, [=]() {
|
||||
std::string current_pin = util::read_file("/data/galaxy/glxyauth");
|
||||
if (current_pin.empty()) {
|
||||
QString new_pin = InputDialog::getText(tr("Enter 6-digit PIN"), this, tr("Please enter a 6-digit PIN to secure your Galaxy access."), false, 6);
|
||||
if (!new_pin.isEmpty()) {
|
||||
std::string hash = QCryptographicHash::hash(new_pin.toUtf8(), QCryptographicHash::Sha256).toHex().toStdString();
|
||||
util::create_directories("/data/galaxy", 0775);
|
||||
util::write_file("/data/galaxy/glxyauth", hash.data(), hash.size(), O_WRONLY | O_CREAT | O_TRUNC);
|
||||
pair_galaxy->setText(tr("Unpair"));
|
||||
ConfirmationDialog::alert(tr("Pairing successful! Visit galaxy.firestar.link/") + getDongleId().value_or("") + tr(" to connect."), this);
|
||||
galaxy_qr->setVisible(true);
|
||||
}
|
||||
} else {
|
||||
if (ConfirmationDialog::confirm(tr("Are you sure you want to unpair from Galaxy?"), tr("Unpair"), this)) {
|
||||
std::remove("/data/galaxy/glxyauth");
|
||||
pair_galaxy->setText(tr("Pair"));
|
||||
galaxy_qr->setVisible(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
addItem(pair_galaxy);
|
||||
|
||||
galaxy_qr = new ButtonControl(tr("Galaxy QR"), tr("SHOW"), tr("Show a QR code to quickly open Galaxy on your phone."));
|
||||
connect(galaxy_qr, &ButtonControl::clicked, [=]() {
|
||||
auto dongleId = getDongleId();
|
||||
if (!dongleId) return;
|
||||
|
||||
QString url = "https://galaxy.firestar.link/" + *dongleId;
|
||||
GalaxyQRPopup *popup = new GalaxyQRPopup(url, this);
|
||||
popup->exec();
|
||||
popup->deleteLater();
|
||||
});
|
||||
addItem(galaxy_qr);
|
||||
|
||||
// offroad-only buttons
|
||||
|
||||
auto dcamBtn = new ButtonControl(tr("Driver Camera"), tr("PREVIEW"),
|
||||
@@ -359,6 +433,12 @@ void DevicePanel::poweroff() {
|
||||
|
||||
void DevicePanel::showEvent(QShowEvent *event) {
|
||||
pair_device->setVisible(uiState()->primeType() == PrimeType::UNPAIRED);
|
||||
|
||||
std::string galaxy_pin = util::read_file("/data/galaxy/glxyauth");
|
||||
bool galaxy_paired = !galaxy_pin.empty();
|
||||
pair_galaxy->setText(galaxy_paired ? tr("Unpair") : tr("Pair"));
|
||||
galaxy_qr->setVisible(galaxy_paired);
|
||||
|
||||
ListWidget::showEvent(event);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
derive_feasible_params.py
|
||||
|
||||
Dynamically parses the OpenPilot/StarPilot codebase to cross-reference logically
|
||||
registered Param keys with UI string literals. This ensures that no hidden or
|
||||
dynamically-instantiated UI toggles are missed, outputting a highly accurate "Golden List"
|
||||
of parameters that can be safely modified by The Pond or other configuration interfaces.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
def get_repo_root() -> str:
|
||||
# Resolves to the root of the StarPilot repository based on this script's location
|
||||
return os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))
|
||||
|
||||
# Constants
|
||||
REPO_ROOT = get_repo_root()
|
||||
PARAMS_CC_PATH = os.path.join(REPO_ROOT, 'common/params.cc')
|
||||
UI_DIRECTORIES = [
|
||||
os.path.join(REPO_ROOT, 'selfdrive/ui'),
|
||||
os.path.join(REPO_ROOT, 'frogpilot/ui')
|
||||
]
|
||||
|
||||
# A curated list of parameters that are known to be strictly readable state metadata
|
||||
# rather than user-toggled configurations.
|
||||
KNOWN_READ_ONLY = {
|
||||
"ApiCache_Device", "ApiCache_DriveStats", "ApiCache_NavDestinations",
|
||||
"CarMake", "CarModel", "CarModelName", "CarParamsPersistent", "CarVin",
|
||||
"ClusterOffset", "Compass", "DeveloperSidebarMetric1", "DeveloperSidebarMetric2",
|
||||
"DeveloperSidebarMetric3", "DeveloperSidebarMetric4", "DeveloperSidebarMetric5",
|
||||
"DeveloperSidebarMetric6", "DeveloperSidebarMetric7", "DongleId",
|
||||
"FrogPilotCarParamsPersistent", "FrogPilotDrives", "FrogPilotKilometers",
|
||||
"FrogPilotMinutes", "GitBranch", "GitCommit", "GitCommitDate", "GitDiff",
|
||||
"GitRemote", "GithubSshKeys", "GithubUsername", "HardwareSerial", "IMEI",
|
||||
"InstallDate", "IsRhdDetected", "KonikMinutes", "LastGPSPosition",
|
||||
"LastMapsUpdate", "LastUpdateTime", "ModelDrivesAndScores", "ModelReleasedDates",
|
||||
"ModelVersions", "PrimeType", "TermsVersion", "TrainingVersion", "Version",
|
||||
"openpilotMinutes", "CompletedTrainingVersion"
|
||||
}
|
||||
|
||||
def extract_registered_keys(params_path: str) -> set:
|
||||
"""Extracts all legally registered parameter keys from common/params.cc"""
|
||||
registered_keys = set()
|
||||
try:
|
||||
with open(params_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Isolate the keys `unordered_map` block
|
||||
keys_block_match = re.search(r'unordered_map<std::string, uint32_t> keys = \{(.*?)\};', content, re.DOTALL)
|
||||
if not keys_block_match:
|
||||
print("Error: Could not locate 'keys' map in params.cc")
|
||||
return registered_keys
|
||||
|
||||
# Extract {"KeyName", FLAG} entries
|
||||
for match in re.finditer(r'\{"([A-Za-z0-9_]+)",\s*([^}]+)\}', keys_block_match.group(1)):
|
||||
key, flag = match.group(1), match.group(2)
|
||||
# Remove keys that are strictly internal ephemeral states
|
||||
if 'CLEAR_ON_MANAGER_START' not in flag:
|
||||
registered_keys.add(key)
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Could not find params source file at {params_path}")
|
||||
|
||||
return registered_keys
|
||||
|
||||
def extract_ui_string_literals(ui_dirs: list) -> set:
|
||||
"""Recursively walks UI directories to extract every string literal."""
|
||||
ui_strings = set()
|
||||
valid_extensions = ('.cc', '.h', '.cpp', '.hpp', '.qml')
|
||||
|
||||
for directory in ui_dirs:
|
||||
if not os.path.exists(directory):
|
||||
print(f"Warning: UI directory not found {directory}")
|
||||
continue
|
||||
|
||||
for root, _, files in os.walk(directory):
|
||||
for file in files:
|
||||
if file.endswith(valid_extensions):
|
||||
filepath = os.path.join(root, file)
|
||||
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
# Extract all "StringLiterals" block
|
||||
matches = re.findall(r'\"([A-Za-z0-9_]+)\"', f.read())
|
||||
ui_strings.update(matches)
|
||||
|
||||
return ui_strings
|
||||
|
||||
def main():
|
||||
print(f"Starting parameter derivation inside {REPO_ROOT}...")
|
||||
|
||||
# 1. Fetch
|
||||
registered_keys = extract_registered_keys(PARAMS_CC_PATH)
|
||||
ui_strings = extract_ui_string_literals(UI_DIRECTORIES)
|
||||
|
||||
# 2. Intersect
|
||||
feasible_keys = registered_keys.intersection(ui_strings)
|
||||
|
||||
# 3. Filter Read-Only
|
||||
editable_keys = feasible_keys - KNOWN_READ_ONLY
|
||||
|
||||
# 4. Export
|
||||
output_path = os.path.join(os.path.dirname(__file__), 'feasibleparams.txt')
|
||||
try:
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write("Dynamically Derived Feasible Param Candidates (The Golden List)\n")
|
||||
f.write("===============================================================\n\n")
|
||||
f.write(f"Total globally registered C++ keys: {len(registered_keys)}\n")
|
||||
f.write(f"Total explicit UI string references: {len(feasible_keys)}\n")
|
||||
f.write(f"Total Editable/Toggleable targets: {len(editable_keys)}\n\n")
|
||||
|
||||
for key in sorted(list(editable_keys)):
|
||||
f.write(f"{key}\n")
|
||||
|
||||
print(f"Successfully derived {len(editable_keys)} highly feasible parameter targets.")
|
||||
print(f"Report exported to: {output_path}")
|
||||
except Exception as e:
|
||||
print(f"Error writing to output file: {e}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -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
|
||||
@@ -0,0 +1,342 @@
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
import ast
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))
|
||||
|
||||
CATEGORIES = [
|
||||
{"file": "lateral_settings.cc", "name": "Lateral (Steering)", "icon": "bi-arrows-move"},
|
||||
{"file": "longitudinal_settings.cc", "name": "Longitudinal (Speed & Following)", "icon": "bi-speedometer2"},
|
||||
{"file": "visual_settings.cc", "name": "Visual (Display & UI)", "icon": "bi-eye"},
|
||||
{"file": "sounds_settings.cc", "name": "Sounds & Alerts", "icon": "bi-volume-up"},
|
||||
{"file": "vehicle_settings.cc", "name": "Vehicle", "icon": "bi-car-front"},
|
||||
{"file": "device_settings.cc", "name": "Device & Data", "icon": "bi-hdd"},
|
||||
{"file": "model_settings.cc", "name": "Model & Customization", "icon": "bi-cpu"},
|
||||
]
|
||||
|
||||
DROPDOWN_MAPPING = {
|
||||
"SelectModel": {
|
||||
"key": "Model",
|
||||
"options_endpoint": "/api/models/installed"
|
||||
}
|
||||
}
|
||||
|
||||
PARENT_KEYS_MAPPING = {
|
||||
"device_settings.cc": {
|
||||
"deviceManagementKeys": "DeviceManagement",
|
||||
"screenKeys": "ScreenManagement"
|
||||
},
|
||||
"lateral_settings.cc": {
|
||||
"advancedLateralTuneKeys": "AdvancedLateralTune",
|
||||
"aolKeys": "AlwaysOnLateral",
|
||||
"laneChangeKeys": "LaneChanges",
|
||||
"lateralTuneKeys": "LateralTune",
|
||||
"qolKeys": "QOLLateral"
|
||||
},
|
||||
"longitudinal_settings.cc": {
|
||||
"advancedLongitudinalTuneKeys": "AdvancedLongitudinalTune",
|
||||
"aggressivePersonalityKeys": "AggressivePersonalityProfile",
|
||||
"conditionalExperimentalKeys": "ConditionalExperimental",
|
||||
"curveSpeedKeys": "CurveSpeedControl",
|
||||
"customDrivingPersonalityKeys": "CustomDrivingPersonality",
|
||||
"longitudinalTuneKeys": "LongitudinalTune",
|
||||
"qolKeys": "QOLLongitudinal",
|
||||
"relaxedPersonalityKeys": "RelaxedPersonalityProfile",
|
||||
"speedLimitControllerKeys": "SpeedLimitController",
|
||||
"standardPersonalityKeys": "StandardPersonalityProfile",
|
||||
"trafficPersonalityKeys": "TrafficPersonalityProfile"
|
||||
},
|
||||
"sounds_settings.cc": {
|
||||
"alertVolumeControlKeys": "AlertVolumeControl",
|
||||
"customAlertsKeys": "CustomAlerts"
|
||||
},
|
||||
"theme_settings.cc": {
|
||||
"customThemeKeys": "CustomTheme"
|
||||
},
|
||||
"visual_settings.cc": {
|
||||
"advancedCustomOnroadUIKeys": "AdvancedCustomUI",
|
||||
"customOnroadUIKeys": "CustomUI",
|
||||
"developerMetricKeys": "DeveloperMetrics",
|
||||
"developerSidebarKeys": "DeveloperSidebar",
|
||||
"developerUIKeys": "DeveloperUI",
|
||||
"developerWidgetKeys": "DeveloperWidgets",
|
||||
"modelUIKeys": "ModelUI",
|
||||
"navigationUIKeys": "NavigationUI",
|
||||
"qualityOfLifeKeys": "QOLVisuals"
|
||||
},
|
||||
"vehicle_settings.cc": {}
|
||||
}
|
||||
|
||||
ALL_PARENT_KEYS = set()
|
||||
for cmap in PARENT_KEYS_MAPPING.values():
|
||||
for parent in cmap.values():
|
||||
ALL_PARENT_KEYS.add(parent)
|
||||
|
||||
def get_variables_data():
|
||||
filepath = os.path.join(REPO_ROOT, "frogpilot/common/frogpilot_variables.py")
|
||||
excluded = set()
|
||||
defaults = {}
|
||||
if not os.path.exists(filepath):
|
||||
return excluded, defaults
|
||||
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
tree = ast.parse(f.read())
|
||||
|
||||
def parse_params_list(value_node):
|
||||
try:
|
||||
if isinstance(value_node, ast.List):
|
||||
for elt in value_node.elts:
|
||||
if isinstance(elt, ast.Tuple) and len(elt.elts) >= 2:
|
||||
key_node = elt.elts[0]
|
||||
val_node = elt.elts[1]
|
||||
if isinstance(key_node, ast.Constant):
|
||||
key = key_node.value
|
||||
if isinstance(val_node, ast.Constant):
|
||||
val = val_node.value
|
||||
if isinstance(val, (str, bytes)):
|
||||
v = val.decode('utf-8') if isinstance(val, bytes) else str(val)
|
||||
if v in ("0", "1"):
|
||||
defaults[key] = "bool"
|
||||
elif "." in v and v.replace(".", "", 1).isdigit():
|
||||
defaults[key] = "float"
|
||||
elif v.isdigit():
|
||||
defaults[key] = "int"
|
||||
else:
|
||||
defaults[key] = "string"
|
||||
else:
|
||||
defaults[key] = "unknown"
|
||||
else:
|
||||
defaults[key] = "unknown"
|
||||
except:
|
||||
pass
|
||||
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if getattr(target, 'id', '') == 'EXCLUDED_KEYS':
|
||||
try:
|
||||
excluded = ast.literal_eval(node.value)
|
||||
except:
|
||||
pass
|
||||
elif getattr(target, 'id', '') == 'frogpilot_default_params':
|
||||
parse_params_list(node.value)
|
||||
elif isinstance(node, ast.AnnAssign):
|
||||
if getattr(node.target, 'id', '') == 'frogpilot_default_params':
|
||||
parse_params_list(node.value)
|
||||
|
||||
return excluded, defaults
|
||||
|
||||
EXCLUDED_KEYS, DEFAULT_TYPES = get_variables_data()
|
||||
|
||||
def get_param_type(key):
|
||||
return DEFAULT_TYPES.get(key, "unknown")
|
||||
|
||||
def extract_bracket_block(text, start_idx):
|
||||
if text[start_idx] != '{': return ""
|
||||
depth = 0
|
||||
in_str = False
|
||||
escape = False
|
||||
for i in range(start_idx, len(text)):
|
||||
char = text[i]
|
||||
if escape:
|
||||
escape = False
|
||||
continue
|
||||
if char == '\\':
|
||||
escape = True
|
||||
continue
|
||||
if char == '"':
|
||||
in_str = not in_str
|
||||
continue
|
||||
if not in_str:
|
||||
if char == '{': depth += 1
|
||||
elif char == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return text[start_idx:i+1]
|
||||
return ""
|
||||
|
||||
def parse_cpp_file(filename):
|
||||
filepath = os.path.join(REPO_ROOT, "frogpilot/ui/qt/offroad", filename)
|
||||
if not os.path.exists(filepath): return []
|
||||
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
vector_match = re.search(r'const std::vector<std::tuple<QString,\s*QString,\s*QString,\s*QString>> \w+\s*\{', content)
|
||||
if not vector_match: return []
|
||||
|
||||
start_idx = vector_match.end() - 1
|
||||
vector_content = extract_bracket_block(content, start_idx)
|
||||
|
||||
local_parent_map = PARENT_KEYS_MAPPING.get(filename, {})
|
||||
child_to_parent = {}
|
||||
|
||||
header_filename = filename.replace(".cc", ".h")
|
||||
header_filepath = os.path.join(REPO_ROOT, "frogpilot/ui/qt/offroad", header_filename)
|
||||
full_source = content
|
||||
if os.path.exists(header_filepath):
|
||||
with open(header_filepath, 'r', encoding='utf-8') as fh:
|
||||
full_source += "\n" + fh.read()
|
||||
|
||||
for qset_match in re.finditer(r'QSet<QString>\s+(\w+)\s*(?:=\s*)?\{([^}]+)\};', full_source):
|
||||
qset_name = qset_match.group(1)
|
||||
if qset_name in local_parent_map:
|
||||
parent_key = local_parent_map[qset_name]
|
||||
children_str = qset_match.group(2)
|
||||
children = [c.strip().strip('"') for c in children_str.split(',') if c.strip()]
|
||||
for child in children:
|
||||
child_to_parent[child] = parent_key
|
||||
|
||||
items = []
|
||||
|
||||
idx = 0
|
||||
while True:
|
||||
idx = vector_content.find('{"', idx)
|
||||
if idx == -1: break
|
||||
|
||||
block = extract_bracket_block(vector_content, idx)
|
||||
if not block:
|
||||
idx += 1
|
||||
continue
|
||||
|
||||
row_match = re.search(r'\{"([A-Za-z0-9_]+)"\s*,\s*(.*?)\s*\}$', block, re.DOTALL)
|
||||
if not row_match:
|
||||
idx += len(block)
|
||||
continue
|
||||
|
||||
key = row_match.group(1)
|
||||
rest = row_match.group(2)
|
||||
idx += len(block)
|
||||
|
||||
if key in EXCLUDED_KEYS or key.startswith("IgnoreMe"):
|
||||
continue
|
||||
|
||||
strings = re.findall(r'tr\("((?:[^"\\]|\\.)+)"\)|"((?:[^"\\]|\\.)+)"', rest)
|
||||
valid_strings = [s[0] or s[1] for s in strings if s[0] or s[1]]
|
||||
|
||||
if not valid_strings: continue
|
||||
|
||||
title = valid_strings[0]
|
||||
desc = valid_strings[1] if len(valid_strings) > 1 else ""
|
||||
options_endpoint = None
|
||||
|
||||
if key in DROPDOWN_MAPPING:
|
||||
m = DROPDOWN_MAPPING[key]
|
||||
key = m["key"]
|
||||
widget_type = "dropdown"
|
||||
options_endpoint = m["options_endpoint"]
|
||||
data_type = "string"
|
||||
else:
|
||||
data_type = get_param_type(key)
|
||||
if data_type == "unknown": continue
|
||||
widget_type = "toggle"
|
||||
min_val, max_val, step = None, None, None
|
||||
|
||||
for i in range(1, 10):
|
||||
placeholder = f"%{i}"
|
||||
if placeholder in desc and len(valid_strings) > i + 1:
|
||||
desc = desc.replace(placeholder, valid_strings[i + 1])
|
||||
|
||||
desc = re.sub(r'<br\s*/?>', '\n', desc, flags=re.IGNORECASE)
|
||||
desc = re.sub(r'<[^>]+>', '', desc)
|
||||
desc = desc.replace('\\"', '"').strip()
|
||||
title = re.sub(r'\s*\(\s*Default:\s*%\d\s*\)', '', title)
|
||||
title = re.sub(r'%\d', '', title).strip()
|
||||
desc = re.sub(r'\s*\(\s*Default:\s*%\d\s*\)', '', desc)
|
||||
desc = re.sub(r'%\d', '', desc).strip()
|
||||
|
||||
if widget_type == "toggle":
|
||||
snippet_match = None
|
||||
qset_name = ""
|
||||
if key in child_to_parent:
|
||||
parent_k = child_to_parent[key]
|
||||
for q, pk in local_parent_map.items():
|
||||
if pk == parent_k:
|
||||
qset_name = q
|
||||
break
|
||||
|
||||
# Let's match the original's regex for finding the Toggle = assignment line
|
||||
search_patterns = [r'param\s*==\s*"' + key + r'"']
|
||||
if qset_name:
|
||||
search_patterns.append(r'(?:' + qset_name + r'\.contains\(param\))')
|
||||
|
||||
for pattern in search_patterns:
|
||||
match = re.search(pattern + r'.*?[a-zA-Z]+Toggle\s*=\s*(.*?);', content, re.DOTALL)
|
||||
if match:
|
||||
snippet_match = match
|
||||
break
|
||||
|
||||
if snippet_match:
|
||||
assignment = snippet_match.group(1)
|
||||
if "FrogPilotParamValueControl" in assignment or "FrogPilotParamValueButtonControl" in assignment:
|
||||
widget_type = "numeric"
|
||||
if data_type in ("string", "bool", "unknown"):
|
||||
data_type = "float"
|
||||
|
||||
if qset_name == "alertVolumeControlKeys":
|
||||
if key in ["WarningImmediateVolume", "WarningSoftVolume"]:
|
||||
min_val, max_val, step = "25", "101", "1"
|
||||
else:
|
||||
min_val, max_val, step = "0", "101", "1"
|
||||
else:
|
||||
args_match = re.search(r'Control[^(]*\(([^;]+)\)', assignment)
|
||||
if args_match:
|
||||
args_str = args_match.group(1)
|
||||
num_match = re.search(r'icon\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,(?:[^,]*,){2}\s*([-\d.]+)', args_str)
|
||||
if num_match:
|
||||
min_val, max_val, step = num_match.group(1), num_match.group(2), num_match.group(3)
|
||||
else:
|
||||
num_match = re.search(r'icon\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)', args_str)
|
||||
if num_match:
|
||||
min_val, max_val = num_match.group(1), num_match.group(2)
|
||||
step_match = re.search(r'(?:std::map<float,\s*QString>\(\)|[a-zA-Z0-9_]+Labels)\s*,\s*([-\d.]+)', args_str)
|
||||
if step_match:
|
||||
step = step_match.group(1)
|
||||
|
||||
precision = None
|
||||
precision_match = re.search(r"QString::number\([^,]+,\s*'f'\s*,\s*(\d+)\)", rest)
|
||||
if precision_match:
|
||||
precision = int(precision_match.group(1))
|
||||
|
||||
if data_type == "float" and step and float(step).is_integer():
|
||||
data_type = "int"
|
||||
|
||||
s = {
|
||||
"key": key,
|
||||
"label": title,
|
||||
"description": desc,
|
||||
"data_type": data_type,
|
||||
"ui_type": widget_type
|
||||
}
|
||||
if widget_type == "numeric":
|
||||
if min_val is not None: s["min"] = float(min_val)
|
||||
if max_val is not None: s["max"] = float(max_val)
|
||||
if step is not None: s["step"] = float(step)
|
||||
if precision is not None: s["precision"] = precision
|
||||
elif widget_type == "dropdown":
|
||||
if options_endpoint: s["options_endpoint"] = options_endpoint
|
||||
if key in child_to_parent: s["parent_key"] = child_to_parent[key]
|
||||
if key in ALL_PARENT_KEYS: s["is_parent_toggle"] = True
|
||||
|
||||
items.append(s)
|
||||
|
||||
return items
|
||||
|
||||
def main():
|
||||
layout = []
|
||||
for cat in CATEGORIES:
|
||||
items = parse_cpp_file(cat["file"])
|
||||
if items:
|
||||
layout.append({
|
||||
"name": cat["name"],
|
||||
"icon": cat["icon"],
|
||||
"params": items
|
||||
})
|
||||
output_path = os.path.join(REPO_ROOT, "frogpilot/system/the_pond/assets/components/tools/device_settings_layout.json")
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(layout, f, indent=2)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||