Mobile Friendly Galaxy

This commit is contained in:
Prabhaav Pillai
2026-09-02 23:40:08 -04:00
parent bb3b1429eb
commit 6f5e267493
39 changed files with 22344 additions and 8 deletions
+41
View File
@@ -484,6 +484,47 @@ launch_c4() {
run_in_worktree "${WORK_DIR}/scripts/launch_ui_c4_desktop.sh" "${jobs}" "$@"
}
pick_free_galaxy_port() {
"${ROOT_DIR}/.venv/bin/python3" - <<'PY'
import socket
# Desktop ZMQ hashes replay service names into ports 8023-65535. Keep Galaxy
# below that range so its HTTP server never steals a replay service port.
for port in range(4600, 8023):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try:
sock.bind(("0.0.0.0", port))
except OSError:
continue
print(port)
raise SystemExit(0)
raise SystemExit("Unable to find a free local Galaxy port.")
PY
}
launch_galaxy() {
sync_worktree
ensure_host_python_extensions
local port
port="$(pick_free_galaxy_port)"
local galaxy_dir="${HOME}/.comma/starpilot/data/galaxy"
echo "Starting local Galaxy session on port ${port}..."
(
cd "${WORK_DIR}"
setup_build_env
export_workdir_pythonpath
export SP_GALAXY_DIR="${galaxy_dir}"
export SP_GALAXY_HOST="0.0.0.0"
export SP_GALAXY_PORT="${port}"
export SP_GALAXY_DEBUG="${SP_GALAXY_DEBUG:-1}"
export SP_GALAXY_RELOAD="${SP_GALAXY_RELOAD:-0}"
exec "${WORK_DIR}/.venv/bin/python3" -m openpilot.starpilot.system.the_galaxy.the_galaxy
)
}
launch_onroad() {
local jobs
jobs="$(default_jobs)"
@@ -193,6 +193,19 @@ body {
padding-left: var(--padding-lg);
}
.embedded #sidebar,
.embedded #sidebar_shell,
.embedded #sidebarUnderlay {
display: none !important;
}
.embedded #menu_button {
display: none !important;
}
.embedded .content {
margin-left: 0 !important;
padding-left: var(--padding-lg);
}
/* ――― Headings ――― */
h1,
h2,
@@ -4,8 +4,10 @@ import { hideSidebar } from "/assets/js/utils.js"
import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=favorite-c4-hint-1"
import { Bluetooth } from "/assets/components/tools/bluetooth.js?v=bluetooth-live-15"
import { WheelControls } from "/assets/components/tools/wheel_controls.js?v=controllers-2"
import { DoorControl } from "/assets/components/tools/doors.js"
import { ErrorLogs } from "/assets/components/tools/error_logs.js"
import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js"
import { TSKManager } from "/assets/components/tools/tsk_manager.js"
import { GalaxyPairing } from "/assets/components/tools/galaxy.js"
import { Home } from "/assets/components/home/home.js"
import { LongitudinalManeuvers } from "/assets/components/tools/longitudinal_maneuvers.js"
@@ -70,12 +72,15 @@ function Root() {
let routes = [
createRoute("bluetooth", "/bluetooth", Bluetooth),
createRoute("wheel_controls", "/wheel-controls", WheelControls),
createRoute("doors", "/manage_doors", DoorControl),
createRoute("tsk", "/manage_tsk", TSKManager),
createRoute("device_settings", "/device_settings/:section?", DeviceSettings),
createRoute("errorLogs", "/manage_error_logs", ErrorLogs),
createRoute("galaxy", "/galaxy", GalaxyPairing),
createRoute("navdestination", "/set_navigation_destination", NavDestination),
createRoute("navkeys", "/manage_navigation_keys", NavKeys),
createRoute("root", "/", Home),
createRoute("classicRoot", "/classic", Home),
createRoute("routes", "/dashcam_routes", RouteRecordings),
createRoute("screen_recordings", "/screen_recordings", ScreenRecordings),
createRoute("sentry", "/sentry", SentryMode),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
<!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=5, viewport-fit=cover">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Galaxy">
<meta name="format-detection" content="telephone=no">
<meta name="theme-color" content="#8b6cc5" />
<link rel="manifest" href="/assets/mobile/manifest.json">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/images/favicon-32x32.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/images/apple-touch-icon.png">
<link rel="stylesheet" href="/assets/vendor/bootstrap-icons/bootstrap-icons.min.css" />
<link rel="stylesheet" href="/assets/mobile/css/material.css">
<script type="importmap">
{
"imports": {
"vue": "/assets/vendor/vue/vue.esm-browser.js"
}
}
</script>
<title>Galaxy</title>
</head>
<body>
<div id="galaxy-bg" aria-hidden="true"></div>
<div id="galaxy-app" v-cloak>
<app-shell></app-shell>
</div>
<!-- Snackbar for messages -->
<div id="snackbar_wrapper"></div>
<script type="module" src="/assets/mobile/js/app.js"></script>
</body>
</html>
@@ -0,0 +1,496 @@
export const LAYOUT_URL = "/assets/components/tools/device_settings_layout.json?v=settings-tier-1"
async function handle(res) {
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const err = new Error(data?.error || data?.message || res.statusText || "Request failed")
err.data = data
throw err
}
return data
}
export const api = {
async postAction(endpoint) {
const res = await fetch(endpoint, { method: "POST" })
return handle(res)
},
async getOptions(endpoint) {
const res = await fetch(endpoint)
return handle(res)
},
async getLayout() {
const res = await fetch(LAYOUT_URL, { cache: "no-store" })
const data = await handle(res)
return (data || [])
.map((section) => ({ ...section, params: (section.params || []).filter((p) => p.key !== "Model") }))
.filter((section) => (section.params || []).length > 0)
},
async getParams() {
const res = await fetch("/api/params/all")
return handle(res)
},
async getDefaults() {
const res = await fetch("/api/params/defaults")
return res.ok ? handle(res) : {}
},
async updateParam({ key, value, label }) {
const body = { key, value }
if (label) body.label = label
const res = await fetch("/api/params", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
return handle(res)
},
async getFlmWorkspace() {
const res = await fetch("/api/flm/workspace", { cache: "no-store" })
return res.ok ? handle(res) : null
},
async getFavoritesSlots() {
const res = await fetch("/api/favorites/slots", { cache: "no-store" })
return handle(res)
},
async saveFavoritesSlots(slots) {
const res = await fetch("/api/favorites/slots", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slots }),
})
return handle(res)
},
async activateFavoriteAction(key) {
const res = await fetch("/api/favorites/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
})
return handle(res)
},
async getDeviceStatus() {
const res = await fetch("/api/device/status")
return res.ok ? handle(res) : null
},
async getStats() {
const res = await fetch("/api/stats")
return res.ok ? handle(res) : null
},
async getRoutesStream({ onProgress, onRoutes, signal } = {}) {
const res = await fetch("/api/routes", { signal })
if (!res.ok || !res.body) throw new Error(`Route request failed (${res.status})`)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const events = buffer.split(/\r?\n\r?\n/)
buffer = events.pop() || ""
for (const event of events) {
const lines = event.split(/\r?\n/).filter((l) => l.startsWith("data:"))
if (!lines.length) continue
try {
const payload = JSON.parse(lines.map((l) => l.slice(5).trimStart()).join("\n"))
if (Number.isFinite(payload.progress)) onProgress?.(payload.progress)
onRoutes?.(Array.isArray(payload.routes) ? payload.routes : [])
} catch (e) { }
}
}
},
async getRoute(name) {
const res = await fetch(`/api/routes/${encodeURIComponent(name)}`)
return handle(res)
},
async deleteRoute(name) {
const res = await fetch(`/api/routes/${encodeURIComponent(name)}`, { method: "DELETE" })
return handle(res)
},
async renameRoute(oldName, newName) {
const res = await fetch("/api/routes/rename", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ old: oldName, new: newName }),
})
return handle(res)
},
async resetRouteName(name) {
const res = await fetch("/api/routes/reset_name", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
})
return handle(res)
},
async setRoutePreserved(name, preserved) {
const res = await fetch(`/api/routes/${encodeURIComponent(name)}/preserve`, { method: preserved ? "POST" : "DELETE" })
return handle(res)
},
async deleteAllRoutes(includePreserved) {
const res = await fetch(`/api/routes/delete_all?include_preserved=${includePreserved}`, { method: "DELETE" })
return handle(res)
},
async getRouteLogs(name) {
const res = await fetch(`/api/routes/${encodeURIComponent(name)}/logs`)
return handle(res)
},
async getScreenRecordings() {
const res = await fetch("/api/screen_recordings/list")
return handle(res)
},
async deleteScreenRecording(filename) {
const res = await fetch(`/api/screen_recordings/delete/${encodeURIComponent(filename)}`, { method: "DELETE" })
return handle(res)
},
async deleteAllScreenRecordings() {
const res = await fetch("/api/screen_recordings/delete_all", { method: "DELETE" })
return handle(res)
},
async renameScreenRecording(oldName, newName) {
const res = await fetch("/api/screen_recordings/rename", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ old: oldName, new: newName }),
})
return handle(res)
},
async getErrorLogs() {
const res = await fetch("/api/error_logs", { headers: { Accept: "application/json" } })
return handle(res)
},
async getErrorLog(filename) {
const res = await fetch(`/api/error_logs/${encodeURIComponent(filename)}`)
return res.text()
},
async deleteErrorLog(filename) {
const res = await fetch(`/api/error_logs/${encodeURIComponent(filename)}`, { method: "DELETE" })
return res.ok
},
async deleteAllErrorLogs() {
const res = await fetch("/api/error_logs/delete_all", { method: "DELETE" })
return res.ok
},
async getTmuxLogs() {
const res = await fetch("/api/tmux_log/list")
return handle(res)
},
async tmuxCapture() {
const res = await fetch("/api/tmux_log/capture", { method: "POST" })
return res.ok
},
async tmuxSnapshot() {
const res = await fetch("/api/tmux_log/snapshot")
return handle(res)
},
async deleteTmuxLog(filename) {
const res = await fetch(`/api/tmux_log/delete/${encodeURIComponent(filename)}`, { method: "DELETE" })
return res.ok
},
async deleteAllTmuxLogs() {
const res = await fetch("/api/tmux_log/delete_all", { method: "DELETE" })
return res.ok
},
async renameTmuxLog(oldName, newName) {
const res = await fetch(`/api/tmux_log/rename/${encodeURIComponent(oldName)}/${encodeURIComponent(newName)}`, { method: "PUT" })
return res.ok
},
async runTroubleshoot() {
const res = await fetch("/api/troubleshoot", { method: "POST" })
return handle(res)
},
async getTroubleshoot() {
const res = await fetch("/api/troubleshoot")
return res.ok ? handle(res) : null
},
async resetTroubleshoot() {
const res = await fetch("/api/troubleshoot/reset", { method: "POST" })
return res.ok
},
async getWheelControlsStatus() {
const res = await fetch("/api/wheel-controls/status", { cache: "no-store" })
return handle(res)
},
async wheelControlsOp(operation, body = {}) {
const res = await fetch(`/api/wheel-controls/${operation}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
return handle(res)
},
async getBluetoothStatus() {
const res = await fetch("/api/bluetooth/status")
return handle(res)
},
async bluetoothOp(operation, body = {}) {
const res = await fetch(`/api/bluetooth/${operation}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
return handle(res)
},
async carFeaturesCheck(tool = "") {
const query = tool ? `?tool=${encodeURIComponent(tool)}` : ""
const res = await fetch(`/api/car_features_check${query}`)
return res.ok ? handle(res) : null
},
async lateralManeuvers(action) {
const res = await fetch(`/api/lateral_maneuvers/${action}`, { method: "POST" })
return handle(res)
},
async lateralManeuversStatus() {
const res = await fetch("/api/lateral_maneuvers/status")
return handle(res)
},
async longitudinalManeuvers(action) {
const res = await fetch(`/api/longitudinal_maneuvers/${action}`, { method: "POST" })
return handle(res)
},
async longitudinalManeuversStatus() {
const res = await fetch("/api/longitudinal_maneuvers/status")
return handle(res)
},
async getMapsStatus() {
const res = await fetch("/api/maps/status")
return handle(res)
},
async getMapsCatalog() {
const res = await fetch("/api/maps/catalog")
return handle(res)
},
async mapsOp(operation, body = {}) {
const res = await fetch(`/api/maps/${operation}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
return handle(res)
},
async getNavigation() {
const res = await fetch("/api/navigation")
return handle(res)
},
async setNavigation(body) {
const res = await fetch("/api/navigation", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
return handle(res)
},
async getNavigationKeys() {
const res = await fetch("/api/navigation_key")
return handle(res)
},
async setNavigationKey(body) {
const res = await fetch("/api/navigation_key", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
return handle(res)
},
async navigationFavorite(body) {
const res = await fetch("/api/navigation/favorite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
return handle(res)
},
async backupToggles() {
const res = await fetch("/api/toggles/backup", { method: "POST" })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data?.message || "Failed to create toggle backup.")
}
return res.blob()
},
async restoreToggles(data) {
const res = await fetch("/api/toggles/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
return handle(res)
},
async resetTogglesDefault() {
const res = await fetch("/api/toggles/reset_default", { method: "POST" })
return handle(res)
},
async getUpdateBranches() {
const res = await fetch("/api/update/branches")
return handle(res)
},
async getUpdateBranch() {
const res = await fetch("/api/update/branch")
return handle(res)
},
async setUpdateBranch(branch) {
const res = await fetch("/api/update/branch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ branch }),
})
return handle(res)
},
async updateFast() {
const res = await fetch("/api/update/fast", { method: "POST" })
return handle(res)
},
async getUpdateFastStatus() {
const res = await fetch("/api/update/fast/status")
return handle(res)
},
async updateRecover() {
const res = await fetch("/api/update/recover", { method: "POST" })
return handle(res)
},
async updateRollback() {
const res = await fetch("/api/update/rollback", { method: "POST" })
return handle(res)
},
async factoryReset() {
const res = await fetch("/api/update/factory_reset", { method: "POST" })
return handle(res)
},
async getAgnosStatus() {
const res = await fetch("/api/update/agnos_status")
return res.ok ? handle(res) : null
},
async getVasmConfig() {
const res = await fetch("/api/v_asm/config")
return handle(res)
},
async setVasmConfig(body) {
const res = await fetch("/api/v_asm/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
return handle(res)
},
async vasmSnapshot() {
const res = await fetch("/api/v_asm/snapshot")
return res.ok ? handle(res) : null
},
async getPipConfig() {
const res = await fetch("/api/pip_preview/config")
return handle(res)
},
async setPipConfig(body) {
const res = await fetch("/api/pip_preview/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
return handle(res)
},
async pipSnapshot() {
const res = await fetch("/api/pip_preview/snapshot")
return res.ok ? handle(res) : null
},
}
export function showSnackbar(message, level = "info") {
const wrapper = document.getElementById("snackbar_wrapper")
if (!wrapper) return
for (const el of Array.from(wrapper.children)) {
el.classList.remove("show")
el.remove()
}
const el = document.createElement("div")
el.className = "snackbar show"
el.style.background = level === "error" ? "var(--error)" : "var(--color-confirm, #8b6cc5)"
el.style.borderRadius = "var(--border-radius-base, 5px)"
el.style.color = "var(--text-color, #fff)"
el.style.margin = "0 auto var(--margin-base, 1rem)"
el.style.padding = "var(--padding-base, 1rem)"
el.style.textAlign = "center"
el.textContent = message
wrapper.appendChild(el)
setTimeout(() => {
el.classList.remove("show")
setTimeout(() => el.remove(), 500)
}, 2400)
}
@@ -0,0 +1,85 @@
import { createApp, h } from "vue"
import { AppShell } from "./components/AppShell.js"
import { Home } from "./views/Home.js"
import { Settings } from "./views/Settings.js"
import { Tools } from "./views/Tools.js"
import { Recordings } from "./views/Recordings.js"
import { Logs } from "./views/Logs.js"
import { Tuning } from "./views/Tuning.js"
import { Navigation } from "./views/Navigation.js"
import { Vehicle } from "./views/Vehicle.js"
import { SystemTools } from "./views/SystemTools.js"
import { ToolEmbed } from "./views/ToolEmbed.js"
import { store, initRouter, navigate } from "./store.js"
import { showSnackbar } from "./api.js"
window.__galaxyVue = { createApp, h }
window.addEventListener("message", (event) => {
const data = event?.data
if (!data || data.source !== "galaxy-embed" || typeof data.path !== "string") return
const current = store.params.src || ""
const target = data.path
if (target === current || target === "/" + current) return
navigate("/embed?src=" + encodeURIComponent(target))
})
const VIEWS = {
"/": Home,
"/settings": Settings,
"/tools": Tools,
"/recordings": Recordings,
"/logs": Logs,
"/tuning": Tuning,
"/navigation": Navigation,
"/vehicle": Vehicle,
"/system": SystemTools,
"/embed": ToolEmbed,
}
function resolveView(path) {
if (path === "/embed" || path.startsWith("/embed/")) return ToolEmbed
for (const [root, view] of Object.entries(VIEWS)) {
if (path === root || (root !== "/" && path.startsWith(root + "/"))) return view
}
if (path === "/") return Home
return ToolEmbed
}
const app = createApp({
name: "GalaxyApp",
errorCaptured(err) {
console.error("[galaxy-ui]", err)
showSnackbar("Something went wrong: " + (err?.message || err), "error")
return false
},
computed: {
View() {
return resolveView(store.route)
},
},
render() {
return h(AppShell, null, {
default: () => h(this.View),
})
},
})
app.mount("#galaxy-app")
initRouter()
;(() => {
const bg = document.getElementById("galaxy-bg")
if (!bg) return
for (let i = 0; i < 14; i++) {
const s = document.createElement("i")
s.className = "galaxy-hero"
s.style.left = (Math.random() * 100).toFixed(2) + "%"
s.style.top = (Math.random() * 100).toFixed(2) + "%"
s.style.animationDelay = (Math.random() * 4).toFixed(2) + "s"
const size = Math.random() > 0.6 ? 3 : 2
s.style.width = s.style.height = size + "px"
bg.appendChild(s)
}
})()
@@ -0,0 +1,168 @@
import { store, navigate, goBack, toolHref, toggleTheme } from "../store.js"
import { api } from "../api.js"
import { usePolling } from "../composables.js"
const NAV = {
recordings: [
{ name: "Recordings", link: "/recordings", icon: "bi-camera-reels" },
],
tools: [
{ name: "Logs & Diagnostics", link: "/logs", icon: "bi-exclamation-triangle" },
{ name: "Tuning & Maneuvers", link: "/tuning", icon: "bi-sign-turn-right" },
{ name: "Navigation & Maps", link: "/navigation", icon: "bi-map" },
{ name: "Vehicle Controls", link: "/vehicle", icon: "bi-car-front" },
{ name: "V-ASM Spot Monitor", link: "/manage_v_asm", icon: "bi-bounding-box" },
{ name: "PiP Side Camera", link: "/manage_pip_sidecam", icon: "bi-camera-video" },
{ name: "System Tools", link: "/system", icon: "bi-arrow-repeat" },
{ name: "Galaxy", link: "/galaxy", icon: "bi-globe2" },
{ name: "Sentry Mode", link: "/sentry", icon: "bi-shield-exclamation" },
{ name: "Model Manager", link: "/manage_models", icon: "bi-cpu" },
{ name: "Plots", link: "/plots", icon: "bi-graph-up-arrow" },
{ name: "Testing Ground", link: "/testing_ground", icon: "bi-bezier2" },
{ name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill" },
],
}
const BOTTOM_NAV = [
{ name: "Home", link: "/", icon: "bi-house-fill" },
{ name: "Settings", link: "/settings", icon: "bi-toggle-on" },
{ name: "Tools", link: "/tools", icon: "bi-tools" },
{ name: "Recordings", link: "/recordings", icon: "bi-camera-reels" },
]
export const AppShell = {
name: "AppShell",
data() {
return { store, BOTTOM_NAV, NAV }
},
computed: {
online() { return store.online },
statusLabel() { return store.online ? store.deviceStatus : "Offline" },
isLight() { return store.theme === "light" },
drawerOpen: {
get() { return store.drawerOpen },
set(v) { store.drawerOpen = v },
},
activePath() { return store.route },
search: {
get() { return store.search },
set(v) { store.search = v },
},
},
watch: {
"store.search"(q) {
if (q && store.route !== "/settings" && !store.route.startsWith("/settings/")) {
navigate("/settings")
}
},
},
methods: {
closeDrawer() { store.drawerOpen = false },
back() { goBack() },
async refreshStatus() {
try {
const payload = await api.getDeviceStatus()
if (!payload) throw new Error("no status")
store.online = true
store.deviceStatus = String(payload.status || "Parked")
} catch (e) {
store.online = false
}
},
clearSearch() {
store.search = ""
this.$nextTick(() => { const el = this.$refs.searchInput; if (el) el.focus() })
},
themeToggle() { toggleTheme() },
navTo(link) {
this.closeDrawer()
navigate(toolHref(link))
},
bottomNavTo(item) {
navigate(item.link)
},
isActive(link) {
return this.activePath === link || (link !== "/" && this.activePath.startsWith(link))
},
},
created() {
this.statusPoll = usePolling(() => this.refreshStatus(), { interval: 5000 })
this.statusPoll.start()
},
beforeUnmount() {
this.statusPoll?.destroy()
},
template: `
<div class="gx-app">
<header class="gx-appbar">
<button type="button" class="gx-icon-btn gx-appbar__back gx-back-btn" aria-label="Back" @click="back">
<i class="bi bi-arrow-left"></i>
</button>
<div class="gx-appbar__pill">
<button type="button" class="gx-icon-btn gx-menu-btn" aria-label="Menu" @click="store.drawerOpen = true">
<i class="bi bi-list"></i>
</button>
<span class="gx-appbar__title">Galaxy</span>
<div class="gx-searchwrap">
<input ref="searchInput" class="gx-search gx-appbar__search" type="search" placeholder="Search settings..."
v-model="search" aria-label="Search settings" />
<button v-if="search" type="button" class="gx-search-clear" aria-label="Clear search" @click="clearSearch">
<i class="bi bi-x"></i>
</button>
</div>
<div class="gx-appbar__right">
<span class="gx-status-pill">
<span class="gx-status-dot" :class="online ? 'online' : 'offline'"></span>
{{ statusLabel }}
</span>
</div>
</div>
<button type="button" class="gx-icon-btn gx-theme-toggle" :aria-label="isLight ? 'Switch to dark mode' : 'Switch to light mode'"
:title="isLight ? 'Dark mode' : 'Light mode'" @click="themeToggle">
<i class="bi" :class="isLight ? 'bi-moon-stars-fill' : 'bi-sun-fill'"></i>
</button>
</header>
<transition name="gx-fade">
<div v-if="store.drawerOpen" class="gx-underlay" @click="closeDrawer"></div>
</transition>
<aside class="gx-drawer" :class="{ open: store.drawerOpen }">
<div class="gx-drawer__header">
<img class="gx-logo" src="/assets/images/main_logo.png" alt="Galaxy logo" />
<span class="gx-drawer-title">Galaxy</span>
</div>
<div class="gx-nav-section">
<div class="gx-nav-section__title">Main</div>
<a class="gx-nav-item" :class="{ active: isActive('/') }" @click.prevent="navTo('/')">
<i class="bi bi-house-fill"></i><span>Home</span>
</a>
<a class="gx-nav-item" :class="{ active: isActive('/settings') }" @click.prevent="navTo('/settings')">
<i class="bi bi-toggle-on"></i><span>Toggles</span>
</a>
<a class="gx-nav-item" :class="{ active: isActive('/tools') }" @click.prevent="navTo('/tools')">
<i class="bi bi-tools"></i><span>Tools</span>
</a>
</div>
<div v-for="(links, section) in NAV" :key="section" class="gx-nav-section">
<div class="gx-nav-section__title">{{ section }}</div>
<a v-for="link in links" :key="link.link" class="gx-nav-item" @click.prevent="navTo(link.link)">
<i class="bi" :class="link.icon"></i><span>{{ link.name }}</span>
</a>
</div>
</aside>
<main class="gx-content">
<slot />
</main>
<nav class="liquid-glass-nav">
<button v-for="item in BOTTOM_NAV" :key="item.link" type="button"
class="nav-item" :class="{ active: isActive(item.link) }"
@click="bottomNavTo(item)">
<i class="bi" :class="item.icon"></i>
<span>{{ item.name }}</span>
</button>
</nav>
</div>
`,
}
@@ -0,0 +1,134 @@
import { api, showSnackbar } from "../api.js"
import { usePolling } from "../composables.js"
function address(device) { return String(device.address || "").toUpperCase() }
export const BluetoothPanel = {
name: "BluetoothPanel",
data() {
return {
loading: true, busy: "", available: false, enabled: false, powered: false, discovering: false,
offroad: false, selectedAudio: "", pairingAddress: "", devices: [], prompt: null, pairValue: "", error: "",
}
},
created() { this.poll = usePolling(() => this.refresh(), { interval: 2000 }); this.poll.start() },
beforeUnmount() { this.poll?.destroy() },
computed: {
known() { return this.devices.filter((d) => d.paired || d.trusted || d.connected) },
availableDevices() { return this.devices.filter((d) => !d.paired && !d.trusted && !d.connected) },
},
methods: {
async refresh() {
try {
const p = await api.getBluetoothStatus()
this.available = !!p.available
this.enabled = !!p.enabled
this.powered = !!p.powered
this.discovering = !!p.discovering
this.offroad = !!p.offroad
this.selectedAudio = String(p.selected_audio || "")
this.pairingAddress = String(p.pairing_address || "")
this.devices = Array.isArray(p.devices) ? p.devices : []
this.prompt = p.prompt || null
this.error = p.error || ""
} catch (e) {
this.available = false
this.error = e?.message || "Bluetooth service unavailable"
} finally {
this.loading = false
}
},
async request(operation, body = {}) {
if (this.busy) return
this.busy = operation
try {
await api.bluetoothOp(operation, body)
this.error = ""
await this.refresh()
} catch (e) {
this.error = e?.message || "Bluetooth operation failed"
} finally {
this.busy = ""
}
},
pair(d) { this.request("pair", { address: d.address }) },
connect(d) { this.request(d.connected ? "disconnect" : "connect", { address: d.address }) },
forget(d) { this.request("forget", { address: d.address }) },
audio(d) { const isSel = this.selectedAudio.toUpperCase() === address(d); this.request("select_audio", { address: isSel ? "" : d.address }) },
testAudio(d) { this.request("test_audio", { address: d.address }) },
respondPairing(accepted) {
const prompt = this.prompt
if (!prompt || this.busy === "pairing_response") return
if (accepted && (prompt.kind === "pin" || prompt.kind === "passkey") && !this.pairValue.trim()) {
this.error = "Enter the value to continue pairing."
return
}
this.request("pairing_response", { prompt_id: prompt.id, accepted, value: this.pairValue.trim() })
},
isPairing(d) { return !!this.pairingAddress && this.pairingAddress.toUpperCase() === address(d) },
statusOf(d) {
if (this.isPairing(d)) return "Pairing…"
if (d.connected) {
const audioSel = this.selectedAudio.toUpperCase() === address(d)
return audioSel ? "Connected · Audio output" : "Connected"
}
return d.paired ? "Saved" : "Ready to pair"
},
offroadDisabled() { return !this.offroad || !!this.busy },
needsPairValue() { return this.prompt && (this.prompt.kind === "pin" || this.prompt.kind === "passkey") },
},
template: `
<div>
<div style="padding: var(--sp-3);">
<div style="display:flex; align-items:center; gap:12px; justify-content:space-between;">
<span>Bluetooth {{ enabled ? 'On' : 'Off' }}</span>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!available || offroadDisabled()" @click="request('power', { enabled: !enabled })">{{ enabled ? 'Turn Off' : 'Turn On' }}</button>
</div>
<p v-if="!offroad" style="color:var(--text-muted);">Scanning, pairing, and forgetting devices are available offroad only.</p>
<p v-if="error" style="color:var(--error);">{{ error }}</p>
<div v-if="prompt" class="gx-card" style="margin:12px 0; background:var(--surface-variant);">
<div class="gx-section__header"><i class="bi bi-shield-check"></i><span class="gx-section__title">Pairing request · {{ prompt.name }}</span></div>
<div style="padding: var(--sp-3);">
<p style="color:var(--text-muted);">{{ prompt.kind === 'confirmation' ? 'Confirm the pairing request.' : prompt.kind === 'authorization' ? 'Allow this device to connect?' : prompt.kind === 'pin' ? 'Enter the PIN supplied by the device.' : 'Enter the device passkey.' }}</p>
<input v-if="needsPairValue" v-model="pairValue" class="gx-field" style="width:100%;" inputmode="numeric" placeholder="Value" />
<div v-if="!prompt.display_only" style="display:flex; gap:8px; margin-top:8px;">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy==='pairing_response'" @click="respondPairing(false)">Cancel</button>
<button type="button" class="gx-btn" :disabled="busy==='pairing_response'" @click="respondPairing(true)">Allow</button>
</div>
</div>
</div>
<div style="display:flex; gap:8px; margin:12px 0;">
<button type="button" class="gx-btn" :disabled="!offroad || !enabled || offroadDisabled()" @click="request(discovering ? 'stop_scan' : 'scan')">{{ discovering ? 'Searching…' : 'Search for Devices' }}</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy" @click="refresh"><i class="bi bi-arrow-clockwise"></i> Refresh</button>
</div>
<h4 style="margin:12px 0 8px;">My Devices</h4>
<div v-if="!known.length" class="gx-empty" style="padding: var(--sp-2) 0;">No saved devices yet.</div>
<div v-for="d in known" :key="d.address" class="gx-row" style="flex-wrap:wrap;">
<div class="gx-row__info">
<span class="gx-row__label">{{ d.name }} <span v-if="d.connected" class="gx-chip gx-chip--dev">Connected</span></span>
<span class="gx-row__desc">{{ d.audio && d.controller ? 'Audio · Controller' : d.audio ? 'Audio' : d.controller ? 'Controller' : 'Bluetooth' }} · {{ statusOf(d) }}</span>
</div>
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<button v-if="d.paired || d.connected" type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy" @click="connect(d)">{{ d.connected ? 'Disconnect' : 'Connect' }}</button>
<button v-if="d.audio" type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy" @click="audio(d)">{{ selectedAudio.toUpperCase() === address(d) ? 'Stop Using for Audio' : 'Use for Audio' }}</button>
<button v-if="d.audio && d.connected" type="button" class="gx-btn gx-btn--tonal" :disabled="offroadDisabled()" @click="testAudio(d)">Test Audio</button>
<button v-if="d.paired" type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" :disabled="offroadDisabled()" @click="forget(d)"><i class="bi bi-trash"></i></button>
</div>
</div>
<h4 style="margin:12px 0 8px;">Available Devices</h4>
<div v-if="!availableDevices.length" class="gx-empty" style="padding: var(--sp-2) 0;">{{ discovering ? 'Searching for nearby devices…' : 'No nearby devices found.' }}</div>
<div v-for="d in availableDevices" :key="d.address" class="gx-row">
<div class="gx-row__info">
<span class="gx-row__label">{{ d.name }}</span>
<span class="gx-row__desc">{{ statusOf(d) }}</span>
</div>
<button type="button" class="gx-btn" :disabled="!offroad || !!busy || isPairing(d)" @click="pair(d)">{{ isPairing(d) ? 'Pairing…' : 'Pair' }}</button>
</div>
</div>
</div>
`,
}
@@ -0,0 +1,25 @@
import { navigate } from "../store.js"
export const DevModeBanner = {
name: "DevModeBanner",
props: {
hiddenCount: { type: Number, default: 0 },
devModeOn: { type: Boolean, default: false },
},
computed: {
visible() { return !this.devModeOn && this.hiddenCount > 0 },
},
methods: {
unlock() { navigate("/settings/developer") },
},
template: `
<div v-if="visible" class="gx-alert gx-alert--warn" role="status">
<i class="bi bi-shield-lock gx-alert__icon"></i>
<div class="gx-alert__body">
<strong>{{ hiddenCount }} advanced setting{{ hiddenCount !== 1 ? "s" : "" }} hidden.</strong>
<span>Advanced features are tucked away until you enable Developer Mode.</span>
</div>
<button type="button" class="gx-btn gx-btn--tonal" @click="unlock">Enable Developer Mode</button>
</div>
`,
}
@@ -0,0 +1,184 @@
import { api, showSnackbar } from "../api.js"
const FAVORITE_COUNT = 3
const ACTION_PREFIX = "__starpilot_favorite_action__:"
function sortOptions(options) {
return (options || []).slice().sort((a, b) =>
String(a?.label || a?.key || "").localeCompare(String(b?.label || b?.key || ""), undefined, { numeric: true, sensitivity: "base" })
)
}
function defaultSlots() {
return [0, 1, 2].map(() => ({ enabled: false, show_onroad: false, key: null, label: "" }))
}
function normalizeSlots(slots) {
const base = defaultSlots()
if (!Array.isArray(slots)) return base
slots.slice(0, FAVORITE_COUNT).forEach((slot, index) => {
if (!slot || typeof slot !== "object") return
const key = slot.key ? String(slot.key) : null
base[index] = {
enabled: !!slot.enabled,
show_onroad: !!slot.show_onroad,
key,
label: key ? String(slot.label || key) : "",
}
})
return base
}
export const FavoritesEditor = {
name: "FavoritesEditor",
data() {
return {
loading: true,
saving: false,
slots: [],
options: [],
values: {},
filters: ["", "", ""],
}
},
computed: {
optionByKey() { return new Map(this.options.map((o) => [o.key, o])) },
quickFavorites() {
return this.slots
.map((slot, index) => {
const opt = this.optionByKey.get(slot.key || "")
return { index, slot, opt, checked: !!(slot.key && opt && !!this.values[slot.key]) }
})
.filter((f) => f.slot.enabled && f.slot.key && f.opt)
},
},
methods: {
normalizeSlots,
filteredOptions(index) {
const q = (this.filters[index] || "").toLowerCase()
return this.options.filter((o) =>
!q || [o.label, o.key, o.section, o.description].some((v) => String(v || "").toLowerCase().includes(q))
)
},
isActionSlot(slot) {
const opt = this.optionByKey.get(slot.key || "")
return String(slot.key || "").startsWith(ACTION_PREFIX) || !!opt?.action
},
async load() {
this.loading = true
try {
const data = await api.getFavoritesSlots()
this.options = sortOptions(data?.options)
this.slots = normalizeSlots(data?.slots)
this.values = { ...this.values, ...(data?.values || {}) }
} catch (e) {
showSnackbar("Failed to load favorite slots.", "error")
} finally {
this.loading = false
}
},
async saveSlots() {
if (this.saving) return
this.saving = true
try {
const data = await api.saveFavoritesSlots(this.slots)
this.slots = normalizeSlots(data?.slots)
if (Array.isArray(data?.options)) this.options = sortOptions(data.options)
if (data?.values) this.values = { ...this.values, ...data.values }
showSnackbar(data?.message || "Favorite slots saved.")
} catch (e) {
showSnackbar(e?.message || "Failed to save favorite slots.", "error")
} finally {
this.saving = false
}
},
updateSlot(index, patch) {
const slots = this.slots.slice()
slots[index] = { ...slots[index], ...patch }
if (!slots[index].key) {
slots[index].label = ""
} else {
slots[index].label = this.optionByKey.get(slots[index].key)?.label || slots[index].key
}
this.slots = slots
this.saveSlots()
},
async toggleValue(key, checked) {
const previous = this.values[key]
this.values = { ...this.values, [key]: checked }
try {
const data = await api.updateParam({ key, value: checked })
if (data?.updated && typeof data.updated === "object") this.values = { ...this.values, ...data.updated }
showSnackbar(data?.message || `Parameter '${key}' updated.`)
} catch (e) {
this.values = { ...this.values, [key]: previous }
showSnackbar(e?.message || "Network error — is the device reachable?", "error")
}
},
async runAction(key) {
try {
const data = await api.activateFavoriteAction(key)
showSnackbar(data?.message || "Favorite action sent.")
} catch (e) {
showSnackbar(e?.message || "Failed to send favorite action.", "error")
}
},
},
async mounted() { await this.load() },
template: `
<div class="favorites-editor" style="display:grid; gap:var(--sp-3);">
<div v-if="loading" class="gx-loading">Loading favorite slots...</div>
<template v-else>
<div v-if="quickFavorites.length" style="display:grid; gap:8px; grid-template-columns:repeat(auto-fit,minmax(180px,1fr));">
<div v-for="f in quickFavorites" :key="f.slot.key"
style="display:flex; flex-direction:column; gap:4px; padding:var(--sp-2) var(--sp-3); border:1px solid var(--outline-variant); border-radius:var(--radius-md);">
<small style="color:var(--text-muted);">Favorite #{{ f.index + 1 }}</small>
<strong>{{ f.opt.label || f.slot.key }}</strong>
<span style="color:var(--text-muted); font-size:var(--fs-sm);">{{ f.opt.section || '' }}</span>
<button v-if="isActionSlot(f.slot)" type="button" class="gx-btn" :disabled="saving" @click.prevent="runAction(f.slot.key)">
Press
</button>
<label v-else class="gx-switch" style="align-self:flex-start;">
<input type="checkbox" :checked="f.checked" :disabled="saving" @change="toggleValue(f.slot.key, $event.target.checked)" />
<span class="gx-switch__track"></span>
<span class="gx-switch__thumb"></span>
</label>
</div>
</div>
<div v-for="(slot, index) in slots" :key="index" class="gx-card">
<div class="gx-section__header">
<span class="gx-section__title">Favorite #{{ index + 1 }}</span>
<label class="gx-switch">
<input type="checkbox" :checked="slot.enabled" :disabled="saving" @change="updateSlot(index, { enabled: $event.target.checked })" />
<span class="gx-switch__track"></span>
<span class="gx-switch__thumb"></span>
</label>
</div>
<div style="padding: var(--sp-3); display:grid; gap:12px;">
<label style="display:grid; gap:4px;">
<span style="font-size:var(--fs-sm); color:var(--text-muted);">Search</span>
<input class="gx-field" type="search" :value="filters[index] || ''" :disabled="saving" placeholder="Search toggles..." @input="filters = filters.map((f,i)=> i===index ? $event.target.value : f)" />
</label>
<label style="display:grid; gap:4px;">
<span style="font-size:var(--fs-sm); color:var(--text-muted);">Toggle</span>
<select class="gx-field" :value="slot.key || ''" :disabled="saving" @change="updateSlot(index, { key: $event.target.value || null })">
<option value="">Select a toggle...</option>
<option v-for="opt in filteredOptions(index)" :key="opt.key" :value="opt.key">{{ opt.label }}</option>
</select>
</label>
<div style="display:flex; align-items:center; gap:8px;">
<span style="flex:1; font-size:var(--fs-sm);">On-Road Button (C4: tap invisible third)</span>
<label class="gx-switch">
<input type="checkbox" :checked="slot.show_onroad" :disabled="saving || !slot.enabled || !slot.key" @change="updateSlot(index, { show_onroad: $event.target.checked })" />
<span class="gx-switch__track"></span>
<span class="gx-switch__thumb"></span>
</label>
</div>
</div>
</div>
</template>
</div>
`,
}
@@ -0,0 +1,82 @@
// Single source of truth for page-in-page embeds of the classic Galaxy SPA.
// Every embed (ToolEmbed, Home dashboard, Tuning, Navigation maps/keys/speeds,
// SystemTools toggles, Logs troubleshoot) renders through this component so the
// classic page always gets: an `embedded=1` marker, sidebar-hide styles, and
// optional navigation forwarding back to the mobile app.
export const GalaxyEmbed = {
name: "GalaxyEmbed",
props: {
src: { type: String, required: true },
title: { type: String, default: "Tool" },
// When true, injects a bridge that forwards the classic page's internal
// navigation to the mobile app (which opens it as a proper ToolEmbed page).
forwardNav: { type: Boolean, default: false },
},
data() {
return {
embedStyle: `
#sidebar, #sidebar_shell, #sidebarUnderlay { display: none !important; }
#menu_button { display: none !important; }
.content { margin-left: 0 !important; }
body { padding-left: 0 !important; }
`,
}
},
computed: {
frameSrc() {
const base = this.src
return base + (base.includes("?") ? "&" : "?") + "embedded=1"
},
},
methods: {
injectEmbedStyles() {
const frame = this.$refs.frame
if (!frame) return
try {
const doc = frame.contentDocument || frame.contentWindow?.document
if (!doc || !doc.head) return
let style = doc.getElementById("gx-embed-hide-sidebar")
if (!style) {
style = doc.createElement("style")
style.id = "gx-embed-hide-sidebar"
doc.head.appendChild(style)
}
style.textContent = this.embedStyle
if (!this.forwardNav) return
let bridge = doc.getElementById("gx-embed-nav-bridge")
if (!bridge) {
bridge = doc.createElement("script")
bridge.id = "gx-embed-nav-bridge"
bridge.textContent = `(() => {
const post = () => {
if (window.self === window.top) return
const params = new URLSearchParams(window.location.search)
params.delete("embedded")
const qs = params.toString()
window.parent.postMessage({ source: "galaxy-embed", path: window.location.pathname + (qs ? "?" + qs : "") }, "*")
}
const patch = (type) => {
const orig = history[type]
history[type] = function () { const r = orig.apply(this, arguments); post(); return r }
}
patch("pushState")
patch("replaceState")
window.addEventListener("popstate", post)
})()`
doc.head.appendChild(bridge)
}
} catch (e) {
}
},
},
mounted() {
this.$refs.frame?.addEventListener("load", () => this.injectEmbedStyles())
},
template: `
<div class="gx-embed">
<iframe ref="frame" :src="frameSrc" class="gx-embed__frame" frameborder="0"
allow="clipboard-read; clipboard-write" :title="title"></iframe>
</div>
`,
}
@@ -0,0 +1,63 @@
export const GalaxyModal = {
name: "GalaxyModal",
props: {
modelValue: { type: Boolean, default: false },
title: { type: String, default: "Are you sure?" },
message: { type: String, default: "" },
confirmLabel: { type: String, default: "Confirm" },
cancelLabel: { type: String, default: "Cancel" },
danger: { type: Boolean, default: false },
sheet: { type: Boolean, default: true },
},
emits: ["update:modelValue", "confirm", "cancel"],
methods: {
close() { this.$emit("update:modelValue", false) },
cancel() { this.close(); this.$emit("cancel") },
confirm() { this.$emit("confirm"); this.close() },
},
template: `
<transition name="gx-fade">
<div v-if="modelValue" class="gx-scrim" @click.self="cancel">
<transition name="gx-slide" appear>
<div class="gx-sheet" role="dialog" :aria-label="title">
<h3 class="gx-sheet__title">{{ title }}</h3>
<p v-if="message" style="color: var(--text-muted); line-height: 1.5;">{{ message }}</p>
<div class="gx-dialog__actions">
<button type="button" class="gx-btn gx-btn--text" @click="cancel">{{ cancelLabel }}</button>
<button type="button" class="gx-btn" :style="danger ? 'background: var(--error); color: var(--on-error);' : ''" @click="confirm">{{ confirmLabel }}</button>
</div>
</div>
</transition>
</div>
</transition>
`,
}
export function GalaxyConfirm({ title, message, confirmLabel = "Confirm", danger = false } = {}) {
return new Promise((resolve) => {
const host = document.createElement("div")
document.body.appendChild(host)
const { createApp, h } = window.__galaxyVue
let instance
const app = createApp({
render() {
return h(GalaxyModal, {
modelValue: true,
title,
message,
confirmLabel,
danger,
"onUpdate:modelValue": (v) => { if (!v) teardown() },
onConfirm: () => { teardown(); resolve(true) },
onCancel: () => { teardown(); resolve(false) },
})
},
})
const teardown = () => {
app.unmount()
host.remove()
resolve(false)
}
instance = app.mount(host)
})
}
@@ -0,0 +1,25 @@
export const GalaxySection = {
name: "GalaxySection",
props: {
title: { type: String, required: true },
icon: { type: String, default: "bi-toggles" },
count: { type: [Number, String], default: "" },
defaultOpen: { type: Boolean, default: true },
},
data() { return { open: this.defaultOpen } },
template: `
<section class="gx-card">
<div class="gx-section__header" role="button" @click="open = !open">
<i class="bi" :class="icon"></i>
<span class="gx-section__title">{{ title }}</span>
<span v-if="count !== ''" class="gx-section__count">{{ count }}</span>
<i class="bi bi-chevron-down gx-chevron" :class="{ open }"></i>
</div>
<transition name="gx-collapse">
<div v-show="open" class="gx-section__body">
<slot />
</div>
</transition>
</section>
`,
}
@@ -0,0 +1,226 @@
import { api, showSnackbar } from "../api.js"
import {
coerceValueByType, formatSliderValue, formatReadoutValue, getColorDefault,
normalizeHexColor, numericBounds, numericEpsilon, snapNumericToBoundsAndStep,
stepPrecision,
} from "../params.js"
import { FavoritesEditor } from "./FavoritesEditor.js"
export const GalaxyToggleCard = {
name: "GalaxyToggleCard",
components: { FavoritesEditor },
props: {
param: { type: Object, required: true },
value: { default: undefined },
locked: { type: Boolean, default: false },
manageable: { type: Boolean, default: false },
manageOpen: { type: Boolean, default: false },
},
emits: ["change", "manage"],
data() {
return {
updating: false,
endpointOptions: null,
optionsLoaded: false,
endpointLoading: false,
preview: undefined,
interacting: false,
}
},
computed: {
bounds() { return numericBounds(this.param, {}) },
precision() { return stepPrecision(this.bounds.step, this.param.precision) },
epsilon() { return numericEpsilon(this.precision) },
isSlider() { return this.isNumeric },
isNumeric() { return this.param.ui_type === "numeric" },
isReadout() { return this.param.ui_type === "readout" },
isGroup() { return this.param.ui_type === "group" },
currentValue() { return this.preview !== undefined ? this.preview : this.value },
displayValue() {
if (this.isColor) return normalizeHexColor(this.value) ? normalizeHexColor(this.value).toUpperCase() : "Stock"
if (this.isReadout) return formatReadoutValue(this.param, this.value)
return this.value !== undefined && this.value !== null ? formatSliderValue(this.value, String(this.bounds.step), this.param.precision, this.param.key) : ".."
},
sliderDisplay() {
return this.value !== undefined ? formatSliderValue(this.currentValue, String(this.bounds.step), this.param.precision, this.param.key) : ".."
},
isColor() { return this.param.ui_type === "color" },
isAction() { return this.param.ui_type === "action" },
isFavorites() { return this.param.ui_type === "favorites" },
isText() { return this.param.ui_type === "text" },
isSelect() { return this.param.ui_type === "dropdown" },
isSwitch() { return !this.isNumeric && !this.isColor && !this.isAction && !this.isFavorites && !this.isGroup && !this.isReadout && !this.isSelect && !this.isText },
selectOptions() {
return this.param.options || this.endpointOptions || []
},
optionsLoading() {
return Boolean(this.param.options_endpoint) && this.endpointLoading
},
},
methods: {
normalizeHexColor,
getColorDefault,
coerce(v) { return coerceValueByType(v, this.param.data_type) },
labelOf(el) { return el?.options?.[el.selectedIndex]?.textContent || "" },
rollback(prev) { this.$emit("change", { key: this.param.key, value: prev }) },
async commit(nextValue) {
const prev = this.value
const label = this.lastLabel || ""
this.$emit("change", { key: this.param.key, value: nextValue })
this.updating = true
try {
const data = await api.updateParam({ key: this.param.key, value: nextValue, label })
const updated = data?.updated && typeof data.updated === "object" ? data.updated : {}
if (Object.prototype.hasOwnProperty.call(updated, this.param.key)) {
this.$emit("change", { key: this.param.key, value: updated[this.param.key], ...updated })
}
showSnackbar(data?.message || `Parameter '${this.param.key}' updated.`)
} catch (err) {
this.rollback(prev)
showSnackbar(err?.message || "Network error — is the device reachable?", "error")
} finally {
this.updating = false
}
},
onSwitch(e) {
if (!this.locked) this.commit(!!e.target.checked)
else e.target.checked = !!this.value
},
onSelect(e) {
if (this.locked) { e.target.value = String(this.value ?? "") ; return }
this.lastLabel = e.target.options?.[e.target.selectedIndex]?.textContent || ""
this.commit(this.coerce(e.target.value))
},
onText(e) {
if (!this.locked) this.commit(this.coerce(e.target.value))
},
onColor(e) {
if (this.locked) return
this.commit(normalizeHexColor(e.target.value) || getColorDefault(this.param))
},
beginInteract() { this.interacting = true },
flushSlider(rawValue) {
const next = snapNumericToBoundsAndStep(rawValue, this.bounds, this.precision)
this.preview = undefined
if (next === null) return
const current = this.snap(this.value)
if (Math.abs(next - current) <= this.epsilon) return
this.commit(next)
},
onSliderInput(e) {
this.beginInteract()
this.preview = Number(e.target.value)
},
onSliderCommit(e) {
this.interacting = false
this.flushSlider(e.target.value)
},
onSliderBlur(e) {
if (this.interacting) this.onSliderCommit(e)
},
snap(raw) {
return snapNumericToBoundsAndStep(raw, this.bounds, this.precision)
},
async resetToDefault() {
const defaults = await api.getDefaults()
const stockKey = `${this.param.key}Stock`
const stock = defaults?.[stockKey]
const raw = stock !== undefined && stock !== null ? stock : defaults?.[this.param.key]
const next = this.snap(raw)
if (next === null) { showSnackbar("No default value available for this setting.", "error"); return }
if (Math.abs(next - (this.snap(this.value) ?? 0)) <= this.epsilon) return
this.commit(next)
},
resetColor() {
if (normalizeHexColor(this.value) === "") return
this.commit("stock")
},
runAction() {
if (this.locked || this.updating) return
this.updating = true
api.postAction(String(this.param.action_endpoint || ""))
.then((data) => {
if (!data?.error) {
showSnackbar(data?.message || `${this.param.label || this.param.key} completed.`)
if (data?.updated && typeof data.updated === "object") this.$emit("change", data.updated)
} else {
showSnackbar(data.error, "error")
}
})
.catch(() => showSnackbar(`${this.param.label || this.param.key} failed.`, "error"))
.finally(() => { this.updating = false })
},
loadEndpointOptions() {
if (!this.param.options_endpoint || this.optionsLoaded) return
this.optionsLoaded = true
this.endpointLoading = true
api.getOptions(this.param.options_endpoint)
.then((opts) => { this.endpointOptions = opts })
.catch(() => { this.endpointOptions = [] })
.finally(() => { this.endpointLoading = false })
},
},
mounted() {
if (this.param.options_endpoint) this.loadEndpointOptions()
},
template: `
<div>
<div class="gx-row" :class="{ disabled: locked, 'gx-row--favorites': isFavorites, 'gx-row--stack': isSlider || isSelect }">
<div class="gx-row__info">
<span class="gx-row__label">{{ param.label }}
<span v-if="param.settings_tier === 'advanced'" class="gx-chip gx-chip--advanced">Advanced</span>
</span>
<span v-if="param.description" class="gx-row__desc">{{ param.description }}</span>
<div v-if="locked" class="gx-row__desc"><strong>Locked:</strong> This setting can only be changed while parked.</div>
</div>
<label v-if="isSwitch" class="gx-switch">
<input type="checkbox" :checked="!!value" :disabled="locked || updating" @change="onSwitch" />
<span class="gx-switch__track"></span>
<span class="gx-switch__thumb"></span>
</label>
<div v-else-if="isFavorites" style="width:100%;">
<FavoritesEditor />
</div>
<div v-else-if="isSlider" class="gx-slider-row">
<span class="gx-row__value" style="min-width:64px; text-align:right;">{{ sliderDisplay }}</span>
<input type="range" class="gx-slider" :min="bounds.min" :max="bounds.max" :step="bounds.step"
:value="currentValue" :disabled="locked || updating"
@input="onSliderInput" @change="onSliderCommit" @blur="onSliderBlur"
@touchstart="beginInteract" @mousedown="beginInteract" @keydown="beginInteract" />
<button class="gx-slider-reset" :disabled="locked || updating" @click="resetToDefault">Default</button>
</div>
<select v-else-if="isSelect" class="gx-field" :disabled="locked || updating" :value="String(value ?? '')" @change="onSelect">
<option v-if="optionsLoading" value="">Loading...</option>
<option v-else-if="!selectOptions.length" value="">No options available</option>
<option v-for="opt in selectOptions" :key="String(opt.value)" :value="String(opt.value)">{{ opt.label }}</option>
</select>
<input v-else-if="isText" class="gx-field" :type="param.input_type || 'text'" :value="value ?? ''"
:placeholder="param.placeholder || ''" :disabled="locked || updating" @change="onText" />
<div v-else-if="isColor" style="display:flex; align-items:center; gap:8px;">
<span class="gx-row__value">{{ displayValue }}</span>
<input type="color" class="gx-color" :value="normalizeHexColor(value) || getColorDefault(param)"
:disabled="locked || updating" @change="onColor" />
<button class="gx-slider-reset" :disabled="locked || updating || !normalizeHexColor(value)" @click="resetColor">Stock</button>
</div>
<span v-else-if="isReadout" class="gx-row__value">{{ displayValue }}</span>
<button v-else-if="isAction" class="gx-btn" :disabled="locked || updating" @click="runAction">
{{ updating ? "Working..." : (param.action_label || "Run") }}
</button>
<button v-else-if="isGroup" class="gx-btn gx-btn--tonal" @click="$emit('manage', param.key)">Manage</button>
</div>
<button v-if="manageable" type="button" class="gx-manage-btn" @click="$emit('manage', param.key)">
{{ manageOpen ? "Close" : "Manage" }}
<i class="bi" :class="manageOpen ? 'bi-chevron-up' : 'bi-chevron-down'"></i>
</button>
</div>
`,
}
@@ -0,0 +1,89 @@
import { usePolling, formatAgeSeconds } from "../composables.js"
import { showSnackbar } from "../api.js"
function safeNumber(value, fallback = 0) {
const n = Number(value)
return Number.isFinite(n) ? n : fallback
}
export const ManeuverCard = {
name: "ManeuverCard",
props: {
title: { type: String, required: true },
icon: { type: String, default: "bi-sign-turn-right" },
intro: { type: String, default: "" },
start: { type: Function, required: true },
stop: { type: Function, required: true },
status: { type: Function, required: true },
interval: { type: Number, default: 3000 },
},
data() {
return { loading: true, busy: false, data: null }
},
created() {
this.poll = usePolling(() => this.refreshStatus(), { interval: this.interval })
this.poll.start()
},
beforeUnmount() { this.poll?.destroy() },
methods: {
formatAgeSeconds,
safeNumber,
async refreshStatus() {
try {
const payload = await this.status()
this.data = payload && typeof payload === "object" ? { ...payload, history: Array.isArray(payload.history) ? payload.history : [] } : null
this.loading = false
} catch (e) {
this.loading = false
throw e
}
},
async run(action) {
if (this.busy) return
this.busy = true
try {
const fn = action === "start" ? this.start : this.stop
const payload = await fn()
this.data = payload && typeof payload === "object" ? { ...payload, history: Array.isArray(payload.history) ? payload.history : [] } : this.data
showSnackbar(payload?.message || "Action complete.")
} catch (e) {
showSnackbar(e?.message || "Action failed.", "error")
} finally {
this.busy = false
}
},
},
template: `
<section class="gx-card">
<div class="gx-section__header">
<i class="bi" :class="icon"></i>
<span class="gx-section__title">{{ title }}</span>
</div>
<div style="padding: var(--sp-3);">
<p style="color: var(--text-muted); line-height:1.5;">{{ intro }}</p>
<div style="display:flex; gap:8px; margin: 12px 0;">
<button type="button" class="gx-btn" :disabled="busy" @click="run('start')">Start / Arm</button>
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" :disabled="busy" @click="run('stop')">Stop</button>
</div>
<div v-if="loading" class="gx-loading">Loading status...</div>
<dl v-else-if="data" class="gx-stat-grid" style="display:grid; grid-template-columns:1fr 1fr; gap:8px;">
<div><strong>Mode</strong><span>{{ data.modeEnabled ? 'Yes' : 'No' }}</span></div>
<div><strong>State</strong><span>{{ data.state || 'idle' }}</span></div>
<div><strong>Onroad</strong><span>{{ data.isOnroad ? 'Yes' : 'No' }}</span></div>
<div><strong>Engaged</strong><span>{{ data.isEngaged ? 'Yes' : 'No' }}</span></div>
<div><strong>Phase</strong><span>{{ data.phase || 'n/a' }}</span></div>
<div><strong>Step</strong><span>{{ safeNumber(data.stepIndex, 0) }}/{{ safeNumber(data.stepTotal, 0) }}</span></div>
<div><strong>Run</strong><span>{{ safeNumber(data.runIndex, 0) }}/{{ safeNumber(data.runTotal, 0) }}</span></div>
<div><strong>Updated</strong><span>{{ formatAgeSeconds(data.updatedAgeSec) }}</span></div>
<div style="grid-column:1/-1;"><strong>Current</strong><span>{{ data.maneuver || 'n/a' }}</span></div>
</dl>
<div v-if="data && data.history?.length" class="gx-card" style="margin-top:12px;">
<div class="gx-section__header"><i class="bi bi-list-ol"></i><span class="gx-section__title">Progress Chain</span></div>
<ol style="margin:0; padding: var(--sp-3) var(--sp-4);">
<li v-for="line in [...data.history].reverse()" :key="line">{{ line }}</li>
</ol>
</div>
</div>
</section>
`,
}
@@ -0,0 +1,66 @@
import { api } from "../api.js"
import { isSettingVisible, slugifySectionName, applyParamChange } from "../params.js"
import { SettingTree } from "./SettingTree.js"
import { GalaxySection } from "./GalaxySection.js"
export const ParamSections = {
name: "ParamSections",
components: { SettingTree, GalaxySection },
props: {
sectionNames: { type: Array, required: true },
search: { type: String, default: "" },
},
data() {
return {
layout: [],
values: {},
expanded: {},
loading: true,
error: "",
}
},
computed: {
sections() {
return this.layout
.filter((s) => this.sectionNames.includes(s.name))
.map((s) => ({
...s,
params: (s.params || []).filter((p) => isSettingVisible(s, p, this.values) && this.matches(p)),
slug: slugifySectionName(s.name),
}))
.filter((s) => s.params.length > 0)
},
},
methods: {
matches(p) {
if (!this.search) return true
const q = this.search.toLowerCase()
return [p.label, p.key, p.description].some((v) => String(v || "").toLowerCase().includes(q))
},
async load() {
try {
const [layout, values] = await Promise.all([api.getLayout(), api.getParams()])
this.layout = layout
this.values = values || {}
} catch (e) {
this.error = e?.message || "Failed to load settings."
} finally {
this.loading = false
}
},
onParamChange(patch) { this.values = applyParamChange(this.values, patch) },
toggleManage(key) { this.expanded = { ...this.expanded, [key]: !this.expanded[key] } },
},
async mounted() { await this.load() },
template: `
<div>
<div v-if="loading" class="gx-loading">Loading configuration...</div>
<div v-if="error" class="gx-empty" style="color: var(--error);">{{ error }}</div>
<GalaxySection v-for="s in sections" :key="s.slug" :title="s.name" :icon="s.icon || 'bi-toggles'" :count="s.params.length">
<SettingTree :params="s.params" :parent-key="null" :values="values" :expanded="expanded"
@change="onParamChange" @manage="toggleManage" />
<div v-if="!s.params.length" class="gx-empty">No settings in this section.</div>
</GalaxySection>
</div>
`,
}
@@ -0,0 +1,46 @@
import { GalaxyToggleCard } from "./GalaxyToggleCard.js"
import { hasChildParams, isGroupParam, isParamEnabledForChildren } from "../params.js"
export const SettingTree = {
name: "SettingTree",
components: { GalaxyToggleCard },
props: {
params: { type: Array, required: true },
parentKey: { default: null },
depth: { type: Number, default: 0 },
values: { type: Object, required: true },
expanded: { type: Object, default: () => ({}) },
lockReason: { type: Function, default: () => "" },
},
emits: ["change", "manage"],
computed: {
children() {
return this.params.filter((p) => (p.parent_key || null) === this.parentKey)
},
},
methods: {
enabledForChildren(p) { return isParamEnabledForChildren(p, this.values) },
isParent(p) { return hasChildParams(this.params, p.key) },
isGroup(p) { return isGroupParam(p) },
isExpanded(p) { return !!this.expanded[p.key] },
showChildren(p) { return this.isParent(p) && this.enabledForChildren(p) && this.isExpanded(p) },
manageable(p) { return this.isParent(p) && this.enabledForChildren(p) },
manageOpen(p) { return this.isParent(p) && this.enabledForChildren(p) && this.isExpanded(p) },
},
template: `
<template v-for="p in children" :key="p.key">
<div class="gx-tree-node" :class="{ 'gx-tree-node--child': depth > 0 }" :style="'--gx-depth:' + depth">
<GalaxyToggleCard :param="p" :value="values[p.key]" :locked="lockReason(p) !== ''"
:manageable="manageable(p)" :manage-open="manageOpen(p)"
@change="$emit('change', $event)" @manage="$emit('manage', $event)" />
</div>
<transition name="gx-collapse">
<div v-if="showChildren(p)" class="gx-tree-children">
<SettingTree :params="params" :parent-key="p.key" :depth="depth + 1"
:values="values" :expanded="expanded" :lock-reason="lockReason"
@change="$emit('change', $event)" @manage="$emit('manage', $event)" />
</div>
</transition>
</template>
`,
}
@@ -0,0 +1,165 @@
import { api } from "../api.js"
import { usePolling } from "../composables.js"
const FAVORITE_SLOT_COUNT = 3
export const WheelControls = {
name: "WheelControls",
data() {
return {
loading: true, busy: "", available: false, offroad: false, learning: false,
devices: [], mappings: [], slots: [], controllerSlots: [], controllerOptions: [],
joystickDevice: "", learningSlot: null, remainingSeconds: 0, testing: false,
lastTested: null, speedUnit: "mph", speedMinimum: 0, speedMaximum: 0, error: "",
}
},
created() { this.poll = usePolling(() => this.refresh(), { interval: 750 }); this.poll.start() },
beforeUnmount() { this.poll?.destroy() },
methods: {
async refresh() {
try {
const p = await api.getWheelControlsStatus()
this.available = !!p.available
this.offroad = !!p.offroad
this.learning = !!p.learning
this.devices = Array.isArray(p.devices) ? p.devices : []
this.mappings = Array.isArray(p.mappings) ? p.mappings : []
this.slots = Array.isArray(p.slots) ? p.slots : []
this.controllerSlots = Array.isArray(p.controller_slots) ? p.controller_slots : []
this.controllerOptions = Array.isArray(p.controller_options) ? p.controller_options : []
this.joystickDevice = typeof p.joystick_device === "string" ? p.joystick_device : ""
this.learningSlot = Number.isInteger(p.learning_slot) ? p.learning_slot : null
this.remainingSeconds = Number.isFinite(Number(p.remaining_seconds)) ? Number(p.remaining_seconds) : 0
this.testing = !!p.testing
this.lastTested = p.last_tested && typeof p.last_tested === "object" ? p.last_tested : null
this.speedUnit = String(p.speed_unit || "mph")
this.speedMinimum = Number.isFinite(Number(p.speed_minimum)) ? Number(p.speed_minimum) : 0
this.speedMaximum = Number.isFinite(Number(p.speed_maximum)) ? Number(p.speed_maximum) : 0
this.error = ""
} catch (e) {
this.available = false
this.error = e?.message || "Wheel controls are unavailable"
} finally {
this.loading = false
}
},
async request(operation, body = {}) {
if (this.busy) return
this.busy = operation
try {
await api.wheelControlsOp(operation, body)
this.error = ""
await this.refresh()
} catch (e) {
this.error = e?.message || "Wheel control operation failed"
} finally {
this.busy = ""
}
},
mappingsOf(slot) { return this.mappings.filter((m) => m.slot === slot) },
actionSlotIndex(i) { return FAVORITE_SLOT_COUNT + i },
learn(slot) { this.request(this.learningAt(slot) ? "cancel" : "learn", { slot }) },
learningAt(slot) { return !!this.learning && this.learningSlot === slot },
disabled() { return !this.offroad || !!this.busy },
configured(slot) { return !!slot?.enabled && !!slot?.key },
optionByKey(key) { return this.controllerOptions.find((o) => o.key === key) || null },
isSpeedSlot(slot) { return this.optionByKey(slot?.key)?.value_type === "speed" },
onActionSelect(i, e) {
if (this.disabled()) return
const key = String(e.target.value || "")
const option = this.optionByKey(key)
const value = option?.value_type === "speed"
? Number(this.controllerSlots[i]?.value ?? option.default_value ?? 30)
: null
this.request("action", { slot: i, key, value })
},
onSpeedChange(i, e) {
if (this.disabled()) return
const key = String(this.controllerSlots[i]?.key || "")
const value = Number(e.target.value)
if (!Number.isFinite(value)) return
this.request("action", { slot: i, key, value })
},
listenLabel(slot) {
if (!this.learningAt(slot)) return "Learn Button"
const seconds = Math.max(0, Math.ceil(this.remainingSeconds))
return seconds > 0 ? `Listening (${seconds}s)` : "Listening..."
},
},
template: `
<div>
<div style="padding: var(--sp-3);">
<p v-if="!offroad" style="color: var(--text-muted);">Mappings can only be changed while offroad. Mapped buttons continue working onroad.</p>
<p v-if="error" style="color: var(--error);">{{ error }}</p>
<p v-if="!loading && !available && !mappings.length" style="color: var(--text-muted);">The wheel control service is starting.</p>
<div style="display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap;">
<button type="button" class="gx-btn" :disabled="disabled() || !mappings.length" @click="request(testing ? 'test-stop' : 'test')">{{ testing ? 'Stop Testing' : 'Test Buttons' }}</button>
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" :disabled="disabled() || !mappings.length" @click="request('clear')">Clear All</button>
</div>
<div v-if="testing && lastTested" style="margin-bottom:12px;">
<span class="gx-chip" :style="lastTested.mapped ? 'background:var(--success);' : 'background:var(--error);'">{{ lastTested.mapped ? 'Successful' : 'Not mapped' }}</span>
<p style="color:var(--text-muted); margin-top:6px;">{{ lastTested.event_name || ('Button ' + lastTested.event_code) }} on {{ lastTested.device_name || 'External input' }} {{ lastTested.mapped ? 'is mapped to slot ' + lastTested.slot : 'has no mapping' }}.</p>
</div>
<h4 style="margin:12px 0 8px;">Connected input devices</h4>
<p style="color:var(--text-muted); margin:0 0 8px;">Favorite buttons are the default, with controller-only actions below. Only the selected gamepad controls Joystick Mode.</p>
<div v-if="devices.length" style="display:grid; gap:8px;">
<div v-for="d in devices" :key="d.device_id" class="gx-row" style="flex-wrap:wrap;">
<div class="gx-row__info">
<span class="gx-row__label">{{ d.name }}</span>
<span class="gx-row__desc">{{ d.joystick_capable ? 'Buttons and joystick axes' : 'Buttons only' }}</span>
</div>
<button v-if="d.joystick_capable" type="button" class="gx-btn gx-btn--tonal" :disabled="disabled()"
@click="request('joystick', { device_id: d.device_id, enabled: !(d.device_id === joystickDevice) })">
{{ d.device_id === joystickDevice ? 'Enabled for Joystick Mode' : 'Enable for Joystick Mode' }}
</button>
</div>
</div>
<p v-else style="color:var(--text-muted); margin:0;">Connect or pair a controller, macropad, or keyboard.</p>
<h4 style="margin:12px 0 8px;">On-screen Favorites</h4>
<div style="display:grid; gap:8px;">
<div v-for="(slot, i) in slots" :key="'fav'+i" class="gx-row" style="flex-wrap:wrap;">
<div class="gx-row__info">
<span class="gx-row__label">Favorite #{{ i + 1 }}</span>
<span class="gx-row__desc">{{ configured(slot) ? (slot.label || slot.key) : 'Not configured' }}</span>
</div>
<button v-if="configured(slot)" type="button" class="gx-btn gx-btn--tonal" :disabled="disabled() || testing" @click="learn(i)">{{ listenLabel(i) }}</button>
<span v-if="mappingsOf(i).length" class="gx-row__desc">{{ mappingsOf(i).map(m => m.event_name).join(', ') }}</span>
</div>
</div>
<p v-if="!configured(slots[0]) && !configured(slots[1]) && !configured(slots[2])" style="color:var(--text-muted); margin:0;">Choose and enable these slots in Toggles to map buttons to them.</p>
<h4 style="margin:16px 0 8px;">Controller-only Actions</h4>
<p style="color:var(--text-muted); margin:0 0 8px;">Ten additional actions for physical buttons. These never appear as on-screen Favorites.</p>
<div style="display:grid; gap:8px;">
<div v-for="(slot, i) in controllerSlots" :key="'act'+i" class="gx-card" style="padding:var(--sp-3); display:grid; gap:8px; margin:0;">
<div class="gx-row" style="border:none; padding:0; flex-wrap:wrap;">
<div class="gx-row__info">
<span class="gx-row__label">Controller Action #{{ i + 1 }}</span>
<span class="gx-row__desc">{{ slot.enabled ? (slot.label || 'Configured') : 'Not configured' }}</span>
</div>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!slot.enabled || disabled() || testing" @click="learn(actionSlotIndex(i))">{{ listenLabel(actionSlotIndex(i)) }}</button>
</div>
<select class="gx-field gx-field--full" :value="String(slot.key || '')" :disabled="disabled()" @change="onActionSelect(i, $event)">
<option value="">Not configured</option>
<option v-for="opt in controllerOptions" :key="opt.key" :value="opt.key">{{ opt.label }}</option>
</select>
<div v-if="isSpeedSlot(slot)" class="gx-row" style="border:none; padding:0;">
<div class="gx-row__info">
<span class="gx-row__label">Set speed ({{ speedUnit }})</span>
</div>
<input class="gx-field" type="number" inputmode="decimal" style="min-width:90px;"
:min="speedMinimum" :max="speedMaximum" step="1"
:value="Number(slot.value ?? 30)" :disabled="disabled()" @change="onSpeedChange(i, $event)" />
</div>
<div v-if="learningAt(actionSlotIndex(i))" style="color:var(--text-muted); font-size:var(--fs-sm);">Press one button on your controller, macropad, or keyboard.</div>
<div v-if="mappingsOf(actionSlotIndex(i)).length">
<span v-for="m in mappingsOf(actionSlotIndex(i))" :key="m.id || m.event_code" class="gx-chip gx-chip--dev" style="margin-right:4px;">{{ m.event_name || ('Button ' + m.event_code) }}</span>
</div>
</div>
</div>
</div>
</div>
`,
}
@@ -0,0 +1,100 @@
import { computed, reactive } from "vue"
import { showSnackbar } from "./api.js"
import { navigate, store } from "./store.js"
export function useTabRouting(basePath, tabs) {
const tab = computed(() => {
const prefix = basePath + "/"
const slug = store.route.startsWith(prefix)
? store.route.slice(prefix.length).split("/")[0]
: ""
for (const [key, s] of Object.entries(tabs)) {
if (s === slug) return key
}
return Object.keys(tabs)[0]
})
function selectTab(key) {
const slug = tabs[key]
if (slug === undefined) return
const href = slug ? `${basePath}/${slug}` : basePath
if (href !== store.route) navigate(href)
}
return { tab, selectTab }
}
export function usePolling(fn, { interval = 3000, enabled = () => true } = {}) {
const state = reactive({ running: false, lastError: "", lastErrorAt: 0 })
let timer = null
let destroyed = false
const stop = () => { if (timer) { clearTimeout(timer); timer = null } }
const tick = async () => {
if (destroyed || !enabled() || document.visibilityState !== "visible") {
timer = setTimeout(tick, interval)
return
}
try {
await fn()
state.lastError = ""
} catch (e) {
state.lastError = e?.message || String(e)
state.lastErrorAt = Date.now()
}
if (!destroyed) timer = setTimeout(tick, interval)
}
const start = () => { stop(); timer = setTimeout(tick, 0) }
const destroy = () => { destroyed = true; stop() }
return { state, start, stop, destroy }
}
export function useLogStream({ endpoint, snapshotFn, interval = 2000 } = {}) {
const state = reactive({ log: "", latest: "", paused: false, transport: "idle" })
let es = null
let timer = null
let destroyed = false
const apply = (data) => {
state.latest = data || ""
if (!state.paused) state.log = state.latest
}
const snapshotFetch = async () => {
if (!snapshotFn) return
try { apply((await snapshotFn())?.data || "") } catch (e) { }
}
const stopStream = () => { if (es) { es.close(); es = null } }
const stopPolling = () => { if (timer) { clearInterval(timer); timer = null } }
const startPolling = () => {
stopStream()
state.transport = "polling"
snapshotFetch()
timer = setInterval(() => { if (!destroyed) snapshotFetch() }, interval)
}
const startStream = () => {
stopPolling()
if (!endpoint) return startPolling()
state.transport = "streaming"
es = new EventSource(endpoint)
es.onmessage = (e) => apply(e.data)
es.onerror = () => { if (snapshotFn) startPolling() }
}
const start = () => (snapshotFn ? startPolling() : startStream())
const destroy = () => { destroyed = true; stopStream(); stopPolling() }
const togglePause = () => {
state.paused = !state.paused
if (!state.paused) state.log = state.latest
}
const notify = (message, level) => showSnackbar(message, level)
return { state, start, destroy, togglePause, notify }
}
export function formatAgeSeconds(value) {
const sec = Number(value)
if (!Number.isFinite(sec) || sec < 0) return "unknown"
if (sec < 1) return "just now"
if (sec < 60) return `${Math.round(sec)}s ago`
const min = sec / 60
if (min < 60) return `${Math.round(min)}m ago`
return `${Math.round(min / 60)}h ago`
}
@@ -0,0 +1,269 @@
export const GALAXY_DEVELOPER_MODE_KEY = "GalaxyDeveloperMode"
const HIDDEN_SETTING_KEYS = new Set(["HumanAcceleration"])
const RADAR_REQUIRED_KEYS = new Set(["HumanLaneChanges", "RadarTakeoffs"])
const VEHICLE_SETTING_MAKES = {
RivianAngleControl: ["Rivian"],
TeslaCoopSteering: ["Tesla"],
NAPRadarEnabled: ["Tesla"],
NAPRadarBehindNosecone: ["Tesla"],
NAPRadarOffset: ["Tesla"],
NAPPedalEnabled: ["Tesla"],
NAPPedalCanBus: ["Tesla"],
NAPAdaptiveAccel: ["Tesla"],
NAPPedalCalibDone: ["Tesla"],
NAPPedalCalibFactor: ["Tesla"],
NAPPedalCalibZero: ["Tesla"],
GMPedalLongitudinal: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"],
GMDashSpoofOffsets: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"],
IgnoreIgnitionLine: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"],
LongPitch: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"],
RemoteStartBootsComma: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"],
HKGRemoteStartBootsComma: ["Genesis", "Hyundai", "Kia"],
VoltSNG: ["Chevrolet", "Holden"],
GMAutoHold: ["Chevrolet", "Holden"],
VoltOnePedalMode: ["Chevrolet", "Holden"],
RemapCancelToDistance: ["Chevrolet", "Holden"],
JeepBrakeHold: ["Jeep"],
SubaruSNG: ["Subaru"],
SubaruSNGManualParkingBrake: ["Subaru"],
SubaruStopStartOff: ["Subaru"],
SubaruAvhOnAtStartup: ["Subaru"],
ClusterOffset: ["Lexus", "Toyota"],
SNGHack: ["Lexus", "Toyota"],
ToyotaAutoHold: ["Lexus", "Toyota"],
}
export function normalizeVehicleMake(value) {
return String(value ?? "").trim().toLowerCase()
}
export function isVehicleSettingVisible(section, param, values) {
const allowedMakes = param.vehicle_makes || (section.name === "Vehicle" ? VEHICLE_SETTING_MAKES[param.key] : null)
if (!allowedMakes) return true
const selectedMake = normalizeVehicleMake(values.CarMake)
return allowedMakes.some((make) => normalizeVehicleMake(make) === selectedMake)
}
function toSelectValue(value) {
return value === null || value === undefined ? "" : String(value)
}
export function matchesSettingValueCondition(param, values) {
if (!param.visible_when_key) return true
const allowedValues = Array.isArray(param.visible_when_values) ? param.visible_when_values : []
const currentValue = toSelectValue(values[param.visible_when_key])
return allowedValues.some((value) => toSelectValue(value) === currentValue)
}
export function isSettingVisible(section, param, values) {
if (HIDDEN_SETTING_KEYS.has(param.key) || !isVehicleSettingVisible(section, param, values) || !matchesSettingValueCondition(param, values)) return false
if (param.requires_capability && !values[param.requires_capability]) return false
if (RADAR_REQUIRED_KEYS.has(param.key) && !values.HasRadar) return false
if (param.key === "AlphaLongitudinalEnabled" && !values.AlphaLongitudinalAvailable) return false
if (values[GALAXY_DEVELOPER_MODE_KEY]) return true
return section.name === "Favorites" || param.settings_tier === "simple"
}
export function isAdvancedHiddenByDeveloperMode(section, param, values) {
if (param.settings_tier !== "advanced") return false
if (HIDDEN_SETTING_KEYS.has(param.key)) return false
if (!isVehicleSettingVisible(section, param, values) || !matchesSettingValueCondition(param, values)) return false
if (param.requires_capability && !values[param.requires_capability]) return false
if (RADAR_REQUIRED_KEYS.has(param.key) && !values.HasRadar) return false
if (param.key === "AlphaLongitudinalEnabled" && !values.AlphaLongitudinalAvailable) return false
return true
}
export function countAdvancedHiddenByDeveloperMode(layout, values) {
if (values[GALAXY_DEVELOPER_MODE_KEY]) return 0
let count = 0
for (const section of layout) {
if (section.name === "Favorites") continue
for (const param of section.params || []) {
if (isAdvancedHiddenByDeveloperMode(section, param, values)) count++
}
}
return count
}
export function numericBounds(param, values) {
const defaultBounds = {
min: param.min !== undefined ? param.min : (param.data_type === "float" ? 0.0 : 0),
max: param.max !== undefined ? param.max : (param.data_type === "float" ? 100.0 : 100),
step: param.step !== undefined ? param.step : (param.data_type === "float" ? 0.01 : 1),
}
const toFinite = (value) => {
const n = Number(value)
return Number.isFinite(n) ? n : null
}
if (param.key === "ScreenBrightness" || param.key === "ScreenBrightnessOnroad") {
return { min: 1, max: 101, step: 1 }
}
if (/^(Traffic|Aggressive|Standard|Relaxed)Jerk(Acceleration|Deceleration|Danger|SpeedDecrease|Speed)$/.test(String(param.key || ""))) {
return { min: 25, max: 200, step: 1 }
}
if (param.key === "SteerKP") {
const base = toFinite(values?.SteerKPStock) || toFinite(values?.SteerKP) || 0.6
return { min: +(base * 0.5).toFixed(2), max: +(base * 1.5).toFixed(2), step: 0.01 }
}
if (param.key === "SteerLatAccel") {
const base = toFinite(values?.SteerLatAccelStock) || toFinite(values?.SteerLatAccel) || 2.0
return { min: +(base * 0.5).toFixed(2), max: +(base * 1.25).toFixed(2), step: 0.01 }
}
if (param.key === "SteerRatio") {
const base = toFinite(values?.SteerRatioStock) || toFinite(values?.SteerRatio) || 15.0
return { min: +(base * 0.25).toFixed(2), max: +(base * 1.5).toFixed(2), step: 0.01 }
}
return defaultBounds
}
export function stepPrecision(step, explicitPrecision) {
if (explicitPrecision !== undefined && explicitPrecision !== null && explicitPrecision !== "") {
const parsed = Number.parseInt(explicitPrecision, 10)
if (Number.isFinite(parsed) && parsed >= 0) return parsed
}
const stepStr = String(step ?? "")
if (!stepStr.includes(".")) return 0
return stepStr.split(".")[1].length
}
export function numericEpsilon(precision) {
return Math.pow(10, -(precision + 2))
}
export function clampNumeric(value, min, max) {
return Math.min(max, Math.max(min, value))
}
export function snapNumericToBoundsAndStep(rawValue, bounds, precision) {
const min = Number(bounds.min)
const max = Number(bounds.max)
const step = Number(bounds.step)
const value = Number(rawValue)
if (!Number.isFinite(min) || !Number.isFinite(max) || !Number.isFinite(value)) return null
const clamped = clampNumeric(value, min, max)
if (!Number.isFinite(step) || step <= 0) {
return clampNumeric(Number(clamped.toFixed(precision)), min, max)
}
const snapped = min + Math.round((clamped - min) / step) * step
return clampNumeric(Number(snapped.toFixed(precision)), min, max)
}
export function coerceValueByType(rawValue, dataType) {
if (dataType === "int") {
const n = Number.parseInt(rawValue, 10)
return Number.isFinite(n) ? n : rawValue
}
if (dataType === "float") {
const n = Number.parseFloat(rawValue)
return Number.isFinite(n) ? n : rawValue
}
return rawValue
}
export function formatSliderValue(val, stepStr, precisionInt, key) {
if (val === null || val === undefined) return "--"
const v = parseFloat(val)
if (Number.isNaN(v)) return val
if (key === "SwitchbackModeCooldown") {
if (v === 0) return "Off"
return v === 1 ? "1 min" : `${v} min`
}
if (key === "DeviceShutdown") {
return v === 1 ? "1 hour" : `${v} hours`
}
const volumeKeys = [
"BelowSteerSpeedVolume", "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()
}
export function formatReadoutValue(p, value) {
const raw = value
const parsed = parseFloat(raw)
if (raw === undefined || raw === null || Number.isNaN(parsed)) return "--"
const precision = p.precision !== undefined && p.precision !== null ? Number(p.precision) : 2
const formatted = Number(parsed.toFixed(Math.max(0, precision))).toString()
return p.unit ? `${formatted}${p.unit}` : formatted
}
export function normalizeHexColor(rawValue) {
const value = String(rawValue ?? "").trim()
if (!value || value.toLowerCase() === "stock") return ""
const stripped = value.startsWith("#") ? value.slice(1) : value
if (!/^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(stripped)) return ""
return `#${stripped.slice(0, 6).toLowerCase()}`
}
export function getColorDefault(param) {
const candidate = normalizeHexColor(param?.default_color)
if (candidate) return candidate
return { LaneLinesColor: "#00ff00", PathEdgesColor: "#00ff00", PathColor: "#30ff9c" }[param?.key] || "#ffffff"
}
export function slugifySectionName(name) {
return String(name || "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
}
export function isGroupParam(param) {
return !!param && param.ui_type === "group"
}
export function applyParamChange(values, patch) {
const next = { ...(values || {}) }
if (!patch || typeof patch !== "object") return next
if ("key" in patch && "value" in patch) {
next[patch.key] = patch.value
for (const [k, v] of Object.entries(patch)) {
if (k !== "key" && k !== "value") next[k] = v
}
} else {
Object.assign(next, patch)
}
return next
}
export function isParamEnabledForChildren(paramOrKey, values) {
const param = typeof paramOrKey === "string" ? { key: paramOrKey } : paramOrKey
if (isGroupParam(param)) return true
return !!(param && param.key && values[param.key])
}
export function hasChildParams(paramsList, key) {
return (paramsList || []).some((param) => (param.parent_key || null) === key)
}
export function buildRenderTree(paramsList, values, expanded, isVisible) {
const out = []
const list = paramsList || []
const visible = isVisible || (() => true)
function walk(parentKey, depth) {
for (const param of list) {
if ((param.parent_key || null) !== parentKey) continue
if (!visible(param)) continue
out.push({ param, depth })
if (hasChildParams(list, param.key) && isParamEnabledForChildren(param, values) && expanded[param.key]) {
walk(param.key, depth + 1)
}
}
}
walk(null, 0)
return out
}
@@ -0,0 +1,122 @@
import { reactive } from "vue"
const THEME_KEY = "galaxy-theme"
function initialTheme() {
return localStorage.getItem(THEME_KEY) || "dark"
}
export const store = reactive({
route: "/",
params: {},
drawerOpen: false,
search: "",
snackbar: null,
online: false,
deviceStatus: "Parked",
history: ["/"],
theme: initialTheme(),
})
export function setTheme(theme) {
const next = theme === "light" ? "light" : "dark"
store.theme = next
document.documentElement.setAttribute("data-theme", next)
try { localStorage.setItem(THEME_KEY, next) } catch (e) {}
}
export function toggleTheme() {
setTheme(store.theme === "dark" ? "light" : "dark")
}
export function parseHash(hash) {
const raw = hash.replace(/^#/, "") || "/"
const [pathname, queryString] = raw.split("?")
const params = {}
if (queryString) {
for (const pair of queryString.split("&")) {
const [k, v] = pair.split("=")
if (k) params[decodeURIComponent(k)] = decodeURIComponent(v || "")
}
}
return { path: pathname, params }
}
// Rebuild the canonical hash string for a route, preserving its query params.
export function toHash(route) {
const { path, params } = parseHash(route)
const qs = Object.keys(params).map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`).join("&")
return qs ? `${path}?${qs}` : path
}
function currentPath() {
return parseHash(window.location.hash).path
}
function pushIfNew(route) {
const last = store.history[store.history.length - 1]
if (last !== route) store.history.push(route)
}
function applyRoute(route, { scrollToTop = false } = {}) {
const { path, params } = parseHash(route)
const pathChanged = path !== store.route
store.route = path
store.params = params
store.drawerOpen = false
// Only jump to the top for real view changes. In-place hash updates (a Manage
// panel opening under the same section, an embed switching src) must not yank
// the reader back to the top of the page.
if (scrollToTop || pathChanged) window.scrollTo(0, 0)
}
export function navigate(target) {
const { path } = parseHash(target)
if (path === currentPath()) {
applyRoute(target)
window.location.hash = toHash(target)
return
}
pushIfNew(path)
applyRoute(target)
window.location.hash = toHash(target)
}
export function goHome() {
navigate("/")
}
export function goBack() {
const current = currentPath()
if (store.history[store.history.length - 1] === current && store.history.length > 1) {
store.history.pop()
}
const prev = store.history[store.history.length - 1] || "/"
applyRoute(prev, { scrollToTop: true })
window.location.hash = prev
}
const NATIVE_ROOTS = new Set(["/", "/settings", "/tools", "/recordings", "/logs", "/tuning", "/navigation", "/vehicle", "/system", "/embed"])
export function toolHref(link) {
const path = link.split("?")[0]
if (NATIVE_ROOTS.has(path) || path.startsWith("/settings/") || path.startsWith("/embed")) return link
return "/embed?src=" + encodeURIComponent(path)
}
export function initRouter() {
const apply = () => {
const route = (window.location.hash || "").replace(/^#/, "") || "/"
const { path, params } = parseHash(route)
const pathChanged = path !== store.route
store.route = path
store.params = params
store.drawerOpen = false
pushIfNew(route)
if (pathChanged) window.scrollTo(0, 0)
}
window.addEventListener("hashchange", apply)
store.history = ["/"]
apply()
setTheme(store.theme)
}
@@ -0,0 +1,11 @@
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
export const Home = {
name: "Home",
components: { GalaxyEmbed },
template: `
<div class="gx-view">
<GalaxyEmbed src="/classic" title="Home" />
</div>
`,
}
@@ -0,0 +1,246 @@
import { api, showSnackbar } from "../api.js"
import { useLogStream } from "../composables.js"
import { GalaxyConfirm } from "../components/GalaxyModal.js"
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
function parseLogDate(filename) {
const m = filename.match(/(\d{4})-(\d{2})-(\d{2})[T_]?(\d{2})-?(\d{2})-?(\d{2})?/)
if (!m) return new Date()
const date = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4] || 0), Number(m[5] || 0), Number(m[6] || 0))
return isNaN(date.getTime()) ? new Date() : date
}
export const Logs = {
name: "Logs",
components: { GalaxyEmbed },
data() {
return {
tab: "errors",
errorFiles: [],
errorsLoading: true,
selectedLog: "",
logContent: "",
logLoading: false,
tmuxFiles: [],
tmuxLoading: true,
troubleshootData: null,
troubleshootRunning: false,
searchFilter: "",
tmuxAutoScroll: true,
}
},
created() {
this.stream = useLogStream({ endpoint: "/api/tmux_log/live", snapshotFn: () => api.tmuxSnapshot(), interval: 2000 })
},
watch: {
"stream.state.log"() {
this.$nextTick(() => {
if (this.stream.state.paused || !this.tmuxAutoScroll) return
const el = this.$refs.tmuxtail
if (el) el.scrollTop = el.scrollHeight
})
},
},
mounted() {
this.loadErrorLogs()
this.loadTmuxLogs()
this.stream.start()
this.loadTroubleshoot()
},
beforeUnmount() { this.stream.destroy() },
computed: {
filteredErrorFiles() {
if (!this.searchFilter) return this.errorFiles
const q = this.searchFilter.toLowerCase()
return this.errorFiles.filter((f) => f.filename.toLowerCase().includes(q))
},
},
methods: {
async loadErrorLogs() {
try {
const files = await api.getErrorLogs()
this.errorFiles = files.map((f) => {
const date = parseLogDate(f)
return { filename: f, date: date.toLocaleString() }
})
} catch (e) {
showSnackbar("Failed to load error logs.", "error")
} finally {
this.errorsLoading = false
}
},
async viewLog(file) {
this.selectedLog = file.filename
this.logLoading = true
try {
this.logContent = await api.getErrorLog(file.filename)
} catch (e) {
this.logContent = "Could not load this log."
} finally {
this.logLoading = false
this.$nextTick(() => {
const el = this.$refs.logview
if (el) el.scrollTop = el.scrollHeight
})
}
},
async deleteLog(file) {
if (!(await GalaxyConfirm({ title: "Delete log?", message: `Delete ${file.filename}?`, confirmLabel: "Delete", danger: true }))) return
await api.deleteErrorLog(file.filename)
this.errorFiles = this.errorFiles.filter((f) => f.filename !== file.filename)
if (this.selectedLog === file.filename) this.selectedLog = ""
showSnackbar("Log deleted!")
},
async deleteAllLogs() {
if (!(await GalaxyConfirm({ title: "Delete all error logs?", message: "This cannot be undone.", confirmLabel: "Delete All", danger: true }))) return
try {
const ok = await api.deleteAllErrorLogs()
if (!ok) throw new Error("Delete failed")
this.errorFiles = []
this.selectedLog = ""
showSnackbar("All error logs deleted!")
} catch (e) {
showSnackbar("Delete all failed.", "error")
}
},
copyLog() {
const text = this.logContent
if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(text)
else { const ta = document.createElement("textarea"); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove() }
showSnackbar("Copied to clipboard!")
},
async loadTmuxLogs() {
try {
const files = await api.getTmuxLogs()
this.tmuxFiles = files.map((f) => {
const date = new Date(f.timestamp * 1000)
return { filename: f.filename, date: date.toLocaleString() }
})
} catch (e) {
this.tmuxFiles = []
} finally {
this.tmuxLoading = false
}
},
async captureTmux() {
const ok = await api.tmuxCapture()
showSnackbar(ok ? "Current session captured!" : "Capture failed.", ok ? "info" : "error")
this.tmuxLoading = true
await this.loadTmuxLogs()
},
async deleteTmux(file) {
if (!(await GalaxyConfirm({ title: "Delete log?", message: `Delete ${file.filename}?`, confirmLabel: "Delete", danger: true }))) return
const ok = await api.deleteTmuxLog(file.filename)
showSnackbar(ok ? "Deleted!" : "Delete failed.", ok ? "info" : "error")
this.tmuxFiles = this.tmuxFiles.filter((f) => f.filename !== file.filename)
},
async deleteAllTmux() {
if (!(await GalaxyConfirm({ title: "Delete all logs?", message: "This cannot be undone.", confirmLabel: "Delete All", danger: true }))) return
const ok = await api.deleteAllTmuxLogs()
showSnackbar(ok ? "All logs deleted!" : "Delete failed.", ok ? "info" : "error")
this.tmuxFiles = []
},
async loadTroubleshoot() {
try {
this.troubleshootData = await api.getTroubleshoot()
} catch (e) { this.troubleshootData = null }
},
async runTroubleshoot() {
this.troubleshootRunning = true
try {
this.troubleshootData = await api.runTroubleshoot()
} catch (e) {
showSnackbar("Troubleshoot failed.", "error")
} finally {
this.troubleshootRunning = false
}
},
async resetTroubleshoot() {
const ok = await api.resetTroubleshoot()
if (ok) this.troubleshootData = null
showSnackbar(ok ? "Troubleshoot reset!" : "Reset failed.", ok ? "info" : "error")
},
onTmuxScroll(e) {
const el = e.target
this.tmuxAutoScroll = el.scrollHeight - el.scrollTop - el.clientHeight < 40
},
},
template: `
<div class="gx-view">
<h2 style="margin-top:0;">Logs & Diagnostics</h2>
<div class="gx-tabs" style="display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap;">
<button type="button" class="gx-chip" :style="tab==='errors'?'background:var(--primary);color:var(--on-primary);':''" @click="tab='errors'">Error Logs</button>
<button type="button" class="gx-chip" :style="tab==='tmux'?'background:var(--primary);color:var(--on-primary);':''" @click="tab='tmux'">Tmux Live Log</button>
<button type="button" class="gx-chip" :style="tab==='troubleshoot'?'background:var(--primary);color:var(--on-primary);':''" @click="tab='troubleshoot'">Troubleshoot</button>
</div>
<template v-if="tab === 'errors'">
<div style="display:flex; gap:8px; margin-bottom:8px; align-items:center;">
<input class="gx-field" style="flex:1;" type="search" v-model="searchFilter" placeholder="Search logs..." />
<button v-if="errorFiles.length" type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click="deleteAllLogs()">Delete All</button>
</div>
<section class="gx-card">
<div v-if="errorsLoading" class="gx-loading">Loading...</div>
<div v-else-if="!filteredErrorFiles.length" class="gx-empty">No error logs!</div>
<div v-for="f in filteredErrorFiles" :key="f.filename" class="gx-row" style="cursor:pointer;" @click="viewLog(f)">
<div class="gx-row__info">
<span class="gx-row__label">{{ f.date }}</span>
<span class="gx-row__desc">{{ f.filename }}</span>
</div>
<div style="display:flex; gap:6px;">
<a class="gx-btn gx-btn--tonal" :href="'/api/error_logs/' + encodeURIComponent(f.filename)" download><i class="bi bi-download"></i></a>
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click.stop="deleteLog(f)"><i class="bi bi-trash"></i></button>
</div>
</div>
</section>
<div v-if="selectedLog" class="gx-card" style="margin-top:12px;">
<div class="gx-section__header">
<i class="bi bi-file-text"></i>
<span class="gx-section__title">{{ selectedLog }}</span>
<button type="button" class="gx-btn gx-btn--tonal" @click="copyLog"><i class="bi bi-clipboard"></i> Copy</button>
</div>
<pre ref="logview" style="max-height:60vh; overflow:auto; padding:var(--sp-3); font-size:12px; line-height:1.5; white-space:pre-wrap;">{{ logLoading ? 'Loading...' : logContent }}</pre>
</div>
</template>
<template v-else-if="tab === 'tmux'">
<section class="gx-card">
<div class="gx-section__header">
<i class="bi bi-terminal"></i>
<span class="gx-section__title">Tmux Live Log</span>
<span class="gx-chip" :style="stream.state.transport === 'streaming' ? 'background:var(--success);' : ''">{{ stream.state.transport }}</span>
</div>
<pre ref="tmuxtail" class="gx-terminal" @scroll.passive="onTmuxScroll">{{ stream.state.log || '(waiting for log output…)' }}</pre>
<div style="display:flex; gap:8px; padding: var(--sp-3); flex-wrap:wrap;">
<button type="button" class="gx-btn gx-btn--tonal" @click="stream.togglePause()">{{ stream.state.paused ? '▶️ Resume' : '⏸️ Pause' }}</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="captureTmux">Capture Log</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="deleteAllTmux">Delete All</button>
</div>
</section>
<section class="gx-card" style="margin-top:12px;">
<div class="gx-section__header">
<i class="bi bi-collection"></i>
<span class="gx-section__title">Saved Session Logs</span>
</div>
<div v-if="tmuxLoading" class="gx-loading">Loading...</div>
<div v-else-if="!tmuxFiles.length" class="gx-empty">No tmux logs found.</div>
<div v-for="f in tmuxFiles" :key="f.filename" class="gx-row">
<div class="gx-row__info">
<span class="gx-row__label">{{ f.filename }}</span>
<span class="gx-row__desc">{{ f.date }}</span>
</div>
<div style="display:flex; gap:6px;">
<a class="gx-btn gx-btn--tonal" :href="'/api/tmux_log/download/' + encodeURIComponent(f.filename)" download><i class="bi bi-download"></i></a>
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click="deleteTmux(f)"><i class="bi bi-trash"></i></button>
</div>
</div>
</section>
</template>
<template v-else>
<GalaxyEmbed src="/troubleshoot" title="Troubleshoot" />
</template>
</div>
`,
}
@@ -0,0 +1,89 @@
import { api, showSnackbar } from "../api.js"
import { GalaxySection } from "../components/GalaxySection.js"
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
import { useTabRouting } from "../composables.js"
export const Navigation = {
name: "Navigation",
components: { GalaxySection, GalaxyEmbed },
data() {
return { destination: "", favorites: [], navLoading: true }
},
setup() {
return useTabRouting("/navigation", {
nav: "", maps: "maps", keys: "keys", speeds: "speeds",
})
},
computed: {
embedSrc() {
const sources = {
maps: "/manage_maps",
keys: "/manage_navigation_keys",
speeds: "/download_speed_limits",
}
return sources[this.tab] || ""
},
embedTitle() {
const titles = {
maps: "Maps",
keys: "App Keys",
speeds: "Speed Limits",
}
return titles[this.tab] || "Navigation"
},
},
mounted() { this.loadNavigation() },
methods: {
async loadNavigation() {
this.navLoading = true
try {
const data = await api.getNavigation()
this.destination = data?.destination || data?.name || ""
this.favorites = Array.isArray(data?.favorites) ? data.favorites : []
} catch (e) {
this.favorites = []
} finally {
this.navLoading = false
}
},
async setDestination() {
if (!this.destination) return
try {
const payload = await api.setNavigation({ destination: this.destination })
showSnackbar(payload?.message || "Destination set.")
} catch (e) {
showSnackbar(e?.message || "Failed to set destination.", "error")
}
},
},
template: `
<div class="gx-view">
<h2 style="margin-top:0;">Navigation & Maps</h2>
<div class="gx-tabs" style="display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap;">
<button type="button" class="gx-chip" :style="tab==='nav'?'background:var(--primary);color:var(--on-primary);':''" @click="selectTab('nav')">Destination</button>
<button type="button" class="gx-chip" :style="tab==='maps'?'background:var(--primary);color:var(--on-primary);':''" @click="selectTab('maps')">Maps</button>
<button type="button" class="gx-chip" :style="tab==='keys'?'background:var(--primary);color:var(--on-primary);':''" @click="selectTab('keys')">App Keys</button>
<button type="button" class="gx-chip" :style="tab==='speeds'?'background:var(--primary);color:var(--on-primary);':''" @click="selectTab('speeds')">Speed Limits</button>
</div>
<template v-if="tab === 'nav'">
<GalaxySection title="Navigation Destination" icon="bi-geo-alt-fill">
<div style="padding: var(--sp-3); display:grid; gap:8px;">
<input class="gx-field" v-model="destination" placeholder="Destination address or name" />
<button type="button" class="gx-btn" @click="setDestination"><i class="bi bi-send"></i> Send to Device</button>
<div v-if="favorites.length">
<h4 style="margin:12px 0 8px;">Favorites</h4>
<div v-for="fav in favorites" :key="fav.name" class="gx-row">
<span class="gx-row__label">{{ fav.name }}</span>
<button type="button" class="gx-btn gx-btn--tonal" @click="destination = fav.name; setDestination()">Use</button>
</div>
</div>
</div>
</GalaxySection>
</template>
<GalaxyEmbed v-else-if="embedSrc" :src="embedSrc" :title="embedTitle" />
</div>
`,
}
@@ -0,0 +1,305 @@
import { api, showSnackbar } from "../api.js"
import { GalaxyConfirm } from "../components/GalaxyModal.js"
import { GalaxySection } from "../components/GalaxySection.js"
function fmtDuration(seconds) {
seconds = Number(seconds) || 0
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
function formatBytes(bytes) {
if (!bytes) return "0 MB"
const mb = bytes / 1e6
return mb >= 1000 ? `${(mb / 1000).toFixed(2)} GB` : `${mb.toFixed(1)} MB`
}
function normalizeRoute(r) {
const name = String(r?.name || "")
const isCustomName = !!r?.isCustomName
return {
name,
displayName: r?.displayName || name.split("--").pop() || name,
displayDate: r?.displayDate || "",
approxDurationSeconds: Number(r?.approxDurationSeconds || 0),
segmentCount: Number(r?.segmentCount || r?.numSegments || 0),
is_preserved: !!r?.is_preserved,
isCustomName,
png: r?.png || "",
}
}
export const Recordings = {
name: "Recordings",
components: { GalaxySection },
data() {
return {
loading: true,
error: "",
routes: [],
progress: 0,
searchQuery: "",
sortOrder: "newest",
showPreservedOnly: false,
playerRoute: null,
playerLoading: false,
playerError: "",
segments: [],
current: 0,
cameras: [],
selectedCamera: "",
logsRoute: null,
logsData: null,
}
},
computed: {
stats() {
return {
count: this.routes.length,
formattedDuration: fmtDuration(this.routes.reduce((n, r) => n + r.approxDurationSeconds, 0)),
preservedCount: this.routes.filter((r) => r.is_preserved).length,
}
},
visibleRoutes() {
let list = this.routes.slice()
if (this.showPreservedOnly) list = list.filter((r) => r.is_preserved)
if (this.searchQuery.trim()) {
const q = this.searchQuery.toLowerCase()
list = list.filter((r) => [r.displayName, r.displayDate, r.name].some((v) => String(v || "").toLowerCase().includes(q)))
}
const sorters = {
newest: (a, b) => (b.name > a.name ? 1 : -1),
oldest: (a, b) => (a.name > b.name ? 1 : -1),
longest: (a, b) => b.approxDurationSeconds - a.approxDurationSeconds,
shortest: (a, b) => a.approxDurationSeconds - b.approxDurationSeconds,
}
return list.sort(sorters[this.sortOrder] || sorters.newest)
},
},
methods: {
fmtDuration,
formatBytes,
async loadRoutes() {
this.loading = true
this.error = ""
this.routes = []
this.progress = 0
const seen = new Set()
try {
this.controller?.abort()
this.controller = new AbortController()
await api.getRoutesStream({
signal: this.controller.signal,
onProgress: (p) => { this.progress = p },
onRoutes: (raw) => {
for (const r of raw) {
if (seen.has(r.name)) continue
seen.add(r.name)
this.routes.push(normalizeRoute(r))
}
},
})
} catch (e) {
if (e?.name !== "AbortError") this.error = "Couldn't load routes. Try refreshing."
} finally {
this.loading = false
}
},
async deleteRoute(route) {
if (!(await GalaxyConfirm({ title: "Delete route?", message: `Delete “${route.displayName}”?`, confirmLabel: "Delete", danger: true }))) return
try {
await api.deleteRoute(route.name)
this.routes = this.routes.filter((r) => r.name !== route.name)
showSnackbar("Route deleted!")
} catch (e) {
showSnackbar("Delete failed.", "error")
}
},
async togglePreserved(route) {
try {
await api.setRoutePreserved(route.name, !route.is_preserved)
route.is_preserved = !route.is_preserved
} catch (e) {
showSnackbar("Failed to update preserved state.", "error")
}
},
async renameRoute(route) {
const newName = prompt("Rename route:", route.displayName)
if (!newName || newName === route.displayName) return
try {
const payload = await api.renameRoute(route.name, newName)
Object.assign(route, normalizeRoute({ ...route, name: payload.name || newName, isCustomName: true }))
route.displayName = payload.name || newName
showSnackbar("Route renamed!")
} catch (e) {
showSnackbar("Rename failed.", "error")
}
},
async deleteAllRoutes(includePreserved) {
const label = includePreserved ? "Delete all routes, including preserved?" : "Delete all non-preserved routes?"
if (!(await GalaxyConfirm({ title: label, message: "This action cannot be undone.", confirmLabel: includePreserved ? "Delete Everything" : "Delete Non-Preserved", danger: true }))) return
try {
const payload = await api.deleteAllRoutes(includePreserved)
this.routes = []
showSnackbar(payload?.message || "Routes deleted!")
} catch (e) {
showSnackbar("Failed to delete routes.", "error")
}
},
async openPlayer(route) {
this.playerRoute = route
this.playerLoading = true
this.playerError = ""
try {
const data = await api.getRoute(route.name)
const segments = Array.isArray(data.segment_urls) ? data.segment_urls.filter((u) => typeof u === "string") : []
const cameras = ["forward", "wide", "driver"].filter((c) => data.available_cameras?.includes(c))
if (!segments.length) throw new Error("No video segments for this route.")
if (!cameras.length) throw new Error("No camera video for this route.")
this.segments = segments
this.current = 0
this.cameras = cameras
this.selectedCamera = cameras.includes("forward") ? "forward" : cameras[0]
this.$nextTick(() => this.playSegment())
} catch (e) {
this.playerError = e?.message || "Could not load route."
} finally {
this.playerLoading = false
}
},
cameraUrl(url, low) {
if (this.selectedCamera === "forward") return low && !url.includes("?" ) ? `${url}?quality=low` : url
const sep = url.includes("?") ? "&" : "?"
return `${url}${sep}camera=${encodeURIComponent(this.selectedCamera)}${low ? "&quality=low" : ""}`
},
playSegment() {
const video = this.$refs.player
if (!video || !this.segments[this.current]) return
video.src = this.cameraUrl(this.segments[this.current])
video.load()
video.play().catch(() => {})
},
downloadRoute() {
if (!this.playerRoute) return
const a = document.createElement("a")
a.href = `/video/${this.playerRoute.name}/combined?camera=${encodeURIComponent(this.selectedCamera)}`
a.download = `${this.playerRoute.displayName}-${this.selectedCamera}.mp4`
a.click()
},
closePlayer() {
if (this.$refs.player) { this.$refs.player.pause(); this.$refs.player.removeAttribute("src") }
this.playerRoute = null
this.playerLoading = false
this.playerError = ""
this.segments = []
this.cameras = []
},
async openLogs(route) {
try {
this.logsData = await api.getRouteLogs(route.name)
this.logsRoute = route
} catch (e) {
showSnackbar("Could not read logs.", "error")
}
},
},
async mounted() { await this.loadRoutes() },
beforeUnmount() { this.controller?.abort() },
template: `
<div>
<h2 style="margin-top:0;">Recordings</h2>
<section class="gx-card">
<div class="gx-section__header">
<i class="bi bi-camera-reels"></i>
<span class="gx-section__title">Dashcam Routes</span>
<span class="gx-section__count">{{ stats.count }} drives · {{ stats.formattedDuration }}</span>
</div>
<div style="padding: var(--sp-3); display:flex; gap:8px; flex-wrap:wrap;">
<input class="gx-field" style="flex:1; min-width:160px;" type="search" placeholder="Search routes..." v-model="searchQuery" />
<select class="gx-field" v-model="sortOrder">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="longest">Longest duration</option>
<option value="shortest">Shortest duration</option>
</select>
<button type="button" class="gx-chip" :style="!showPreservedOnly?'background:var(--primary);color:var(--on-primary);':''" @click="showPreservedOnly=false">All</button>
<button type="button" class="gx-chip" :style="showPreservedOnly?'background:var(--primary);color:var(--on-primary);':''" @click="showPreservedOnly=true">Preserved</button>
</div>
</section>
<div v-if="loading" class="gx-loading">Finding local routes... ({{ Math.round(progress) }}%)</div>
<div v-if="error" class="gx-empty" style="color: var(--error);">{{ error }}</div>
<section class="gx-card">
<div v-if="!visibleRoutes.length && !loading" class="gx-empty">No routes found.</div>
<article v-for="r in visibleRoutes" :key="r.name" class="gx-row" style="cursor:pointer;" @click="openPlayer(r)">
<div class="gx-row__info">
<span class="gx-row__label">{{ r.displayName }} <span v-if="r.is_preserved" class="gx-chip gx-chip--dev">Preserved</span></span>
<span class="gx-row__desc">{{ fmtDuration(r.approxDurationSeconds) }} · {{ r.segmentCount }} segments</span>
</div>
<div style="display:flex; gap:6px;">
<button type="button" class="gx-btn gx-btn--tonal" title="Preserve" @click.stop="togglePreserved(r)"><i class="bi" :class="r.is_preserved ? 'bi-heart-fill' : 'bi-heart'"></i></button>
<button type="button" class="gx-btn gx-btn--tonal" title="Logs" @click.stop="openLogs(r)"><i class="bi bi-file-earmark-arrow-down"></i></button>
<button type="button" class="gx-btn gx-btn--tonal" title="Rename" @click.stop="renameRoute(r)"><i class="bi bi-pencil"></i></button>
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" title="Delete" @click.stop="deleteRoute(r)"><i class="bi bi-trash"></i></button>
</div>
</article>
</section>
<section class="gx-card" v-if="routes.length">
<div class="gx-section__header">
<i class="bi bi-exclamation-triangle"></i>
<span class="gx-section__title">Delete local routes</span>
</div>
<div style="display:flex; gap:8px; padding: var(--sp-3); flex-wrap:wrap;">
<button type="button" class="gx-btn gx-btn--tonal" @click="deleteAllRoutes(false)">Delete Non-Preserved</button>
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click="deleteAllRoutes(true)">Delete All Including Preserved</button>
</div>
</section>
<div v-if="logsRoute && logsData" class="gx-card" style="margin-top:12px;">
<div class="gx-section__header">
<i class="bi bi-file-earmark-arrow-down"></i>
<span class="gx-section__title">{{ logsData.segments?.length || 0 }} segments · {{ formatBytes(logsData.totalBytes) }}</span>
<a class="gx-btn gx-btn--tonal" :href="'/api/routes/' + logsRoute.name + '/logs/download'" download>Download all (.tar)</a>
</div>
<div v-for="seg in logsData.segments || []" :key="seg.segmentNum" class="gx-row">
<div class="gx-row__info">
<span class="gx-row__label">Segment {{ seg.segmentNum }}</span>
<span class="gx-row__desc">{{ seg.filename }} · {{ formatBytes(seg.bytes) }}</span>
</div>
<a class="gx-btn gx-btn--tonal" :href="seg.url" download>Download</a>
</div>
</div>
<transition name="gx-fade">
<div v-if="playerRoute" class="gx-scrim" @click.self="closePlayer">
<div class="gx-sheet" style="max-width:640px; width:100%;" role="dialog" aria-label="Route video player">
<div class="gx-section__header" style="cursor:default;">
<i class="bi bi-camera-video"></i>
<span class="gx-section__title">{{ playerRoute.displayName }}</span>
<button type="button" class="gx-icon-btn" aria-label="Close player" @click="closePlayer"><i class="bi bi-x-lg"></i></button>
</div>
<div style="padding: var(--sp-3);">
<div v-if="playerError" class="gx-empty" style="color: var(--error);">{{ playerError }}</div>
<div v-else-if="playerLoading" class="gx-loading"><i class="bi bi-hourglass-split"></i> Loading video...</div>
<template v-else-if="segments.length">
<video ref="player" class="gx-video" controls muted playsinline preload="metadata"></video>
<div style="display:flex; gap:8px; padding: var(--sp-3) 0 0; flex-wrap:wrap; align-items:center;">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="current<=0" @click="current--; playSegment()"><i class="bi bi-skip-start-fill"></i></button>
<select class="gx-field" :value="current" @change="current = Number($event.target.value); playSegment()">
<option v-for="(s,i) in segments" :key="i" :value="i">Segment {{ i + 1 }}</option>
</select>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="current>=segments.length-1" @click="current++; playSegment()"><i class="bi bi-skip-end-fill"></i></button>
<button v-for="c in cameras" :key="c" type="button" class="gx-chip" :style="selectedCamera===c?'background:var(--primary);color:var(--on-primary);':''" @click="selectedCamera=c; playSegment()">{{ c }}</button>
<button type="button" class="gx-btn" @click="downloadRoute"><i class="bi bi-download"></i> Download</button>
</div>
</template>
</div>
</div>
</div>
</transition>
</div>
`,
}
@@ -0,0 +1,166 @@
import { api, showSnackbar } from "../api.js"
import { navigate, store } from "../store.js"
import {
applyParamChange, countAdvancedHiddenByDeveloperMode, GALAXY_DEVELOPER_MODE_KEY, isSettingVisible,
slugifySectionName,
} from "../params.js"
import { SettingTree } from "../components/SettingTree.js"
import { GalaxyToggleCard } from "../components/GalaxyToggleCard.js"
import { GalaxySection } from "../components/GalaxySection.js"
import { DevModeBanner } from "../components/DevModeBanner.js"
export const Settings = {
name: "Settings",
components: { SettingTree, GalaxyToggleCard, GalaxySection, DevModeBanner },
data() {
return {
layout: [],
values: {},
expanded: {},
loading: true,
activeSectionSlug: "",
defaultSectionSlug: "lateral-steering",
}
},
computed: {
devModeOn() { return !!this.values[GALAXY_DEVELOPER_MODE_KEY] },
route() { return store.route },
sections() {
return this.layout
.filter((s) => s.name !== "Model & Customization")
.map((s) => ({
...s,
params: (s.params || []).filter((p) => isSettingVisible(s, p, this.values)),
slug: slugifySectionName(s.name),
}))
.filter((s) => s.params.length > 0)
},
activeSection() {
return this.sections.find((s) => s.slug === this.activeSectionSlug) || this.sections[0]
},
hiddenAdvancedCount() { return countAdvancedHiddenByDeveloperMode(this.layout, this.values) },
searchActive() { return !!this.searchTerm },
searchTerm: {
get() { return store.search },
set(v) { store.search = v },
},
searchResults() {
if (!this.searchActive) return []
return this.sections
.map((s) => ({ ...s, matches: s.params.filter((p) => this.matchesFilter(p)) }))
.filter((s) => s.matches.length > 0)
},
},
methods: {
async load() {
try {
const [layout, values, defaults] = await Promise.all([
api.getLayout(), api.getParams(), api.getDefaults(),
])
this.layout = layout
this.values = values || {}
this.defaults = defaults || {}
if (!this.activeSectionSlug && this.sections.length) {
const preferred = this.sections.find((s) => s.slug === this.defaultSectionSlug)
this.activeSectionSlug = (preferred || this.sections[0]).slug
}
} catch (e) {
showSnackbar("Failed to load settings: " + (e?.message || e), "error")
} finally {
this.loading = false
}
},
onParamChange(patch) {
this.values = applyParamChange(this.values, patch)
},
toggleManage(key) {
const next = !this.expanded[key]
this.expanded = { ...this.expanded, [key]: next }
const base = "/settings/" + this.activeSectionSlug
window.location.hash = next ? `${base}?open=${encodeURIComponent(key)}` : base
},
matchesFilter(p) {
if (!this.searchTerm) return true
const q = this.searchTerm.toLowerCase()
return [p.label, p.key, p.description].some((v) => String(v || "").toLowerCase().includes(q))
},
selectSection(slug) {
if (slug !== this.activeSectionSlug) navigate("/settings/" + slug)
},
applyRouteSection() {
const route = store.route
if (!route.startsWith("/settings/")) return
const slug = route.replace(/^\/settings\/?/, "").split("?")[0].split("/")[0]
if (slug && this.sections.some((s) => s.slug === slug)) this.activeSectionSlug = slug
if (store.params.open) this.expanded = { ...this.expanded, [store.params.open]: true }
},
lockReason(param) {
if (param?.requires_offroad && this.values.IsOnroad) return "This setting can only be changed while parked."
if (param?.requires_parked && !this.values.VehicleParked) return "This setting can only be changed while the vehicle is in Park."
if (param?.disabled_when_key_true && this.values[param.disabled_when_key_true]) return param.disabled_reason || "Disabled by another setting."
if (param?.requires_nonempty_key) {
const val = this.values[param.requires_nonempty_key]
if (!val || val === "{}" || val === "") return param.disabled_reason || "Required configuration missing."
}
return ""
},
},
watch: {
route() { this.applyRouteSection() },
devModeOn() { this.load() },
},
async mounted() {
await this.load()
this.applyRouteSection()
},
template: `
<div>
<h2 style="margin-top:0;">Toggles</h2>
<DevModeBanner :hidden-count="hiddenAdvancedCount" :dev-mode-on="devModeOn" />
<div v-if="loading" class="gx-loading">Loading configuration...</div>
<template v-else-if="sections.length">
<div v-if="searchActive">
<div class="gx-card">
<div class="gx-section__header">
<i class="bi bi-search"></i>
<span class="gx-section__title">{{ searchResults.reduce((n, s) => n + s.matches.length, 0) }} result(s)</span>
</div>
</div>
<template v-for="section in searchResults" :key="section.slug">
<GalaxySection :title="section.name + ' (' + section.matches.length + ')'" :icon="section.icon || 'bi-search'" :default-open="false">
<template v-for="p in section.matches" :key="p.key">
<GalaxyToggleCard :param="p" :value="values[p.key]" :locked="lockReason(p) !== ''"
@change="onParamChange" />
</template>
</GalaxySection>
</template>
</div>
<div v-else>
<div class="gx-tabs" style="display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px;">
<button v-for="s in sections" :key="s.slug" type="button"
class="gx-chip" :style="s.slug === activeSection.slug ? 'background: var(--primary); color: var(--on-primary);' : 'background: var(--surface-variant); color: var(--on-surface-variant); cursor:pointer;'"
@click="selectSection(s.slug)">
{{ s.name }}
</button>
</div>
<div class="gx-card">
<div class="gx-section__header">
<i class="bi" :class="activeSection.icon"></i>
<span class="gx-section__title">{{ activeSection.name }}</span>
</div>
<SettingTree :params="activeSection.params" :parent-key="null" :values="values"
:expanded="expanded" :lock-reason="lockReason" @change="onParamChange" @manage="toggleManage" />
<div v-if="!activeSection.params.length" class="gx-empty">No settings in this section.</div>
</div>
</div>
</template>
<div v-else class="gx-empty">No settings available.</div>
</div>
`,
}
@@ -0,0 +1,261 @@
import { api, showSnackbar } from "../api.js"
import { usePolling } from "../composables.js"
import { GalaxyConfirm } from "../components/GalaxyModal.js"
import { GalaxySection } from "../components/GalaxySection.js"
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
function shortCommit(commit) {
return String(commit || "").slice(0, 10) || "—"
}
export const SystemTools = {
name: "SystemTools",
components: { GalaxySection, GalaxyEmbed },
data() {
return {
branches: [],
currentBranch: "",
branchLoading: true,
fastStatus: null,
checkedForUpdates: false,
busy: "",
}
},
created() { this.poll = usePolling(() => this.loadFastStatus(), { interval: 3000 }); this.poll.start() },
mounted() { this.loadBranches() },
beforeUnmount() { this.poll?.destroy() },
computed: {
updateAvailable() { return !!this.fastStatus?.updateAvailable && !this.fastStatus?.running },
},
methods: {
shortCommit,
async loadBranches() {
try {
const data = await api.getUpdateBranches()
this.branches = Array.isArray(data?.branches) ? data.branches : []
this.currentBranch = data?.currentBranch || ""
this.isOnroad = !!data?.isOnroad
} catch (e) {
showSnackbar("Failed to load update info.", "error")
} finally {
this.branchLoading = false
}
},
async loadFastStatus() {
try { this.fastStatus = await api.getUpdateFastStatus() } catch (e) { this.fastStatus = null }
},
async backupToggles() {
try {
const blob = await api.backupToggles()
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "toggle-backup.json"
a.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
showSnackbar("Toggle backup downloaded.")
} catch (e) {
showSnackbar(e?.message || "Backup failed.", "error")
}
},
onRestoreFile(e) {
const file = e.target.files[0]
e.target.value = ""
if (!file) return
if (file.size > 5_000_000) { showSnackbar("That toggle backup file is too large.", "error"); return }
file.text().then((text) => {
let data
try { data = JSON.parse(text) } catch { showSnackbar("That file is not a valid toggle backup.", "error"); return }
if (!data || typeof data !== "object" || Array.isArray(data)) { showSnackbar("That file is not a valid toggle backup.", "error"); return }
api.restoreToggles(data).then((res) => {
showSnackbar(res?.message || "Toggles restored!")
}).catch((err) => showSnackbar(err?.message || "Failed to restore toggles.", "error"))
})
},
async resetDefault() {
if (!(await GalaxyConfirm({ title: "Reset toggles to default?", message: "This resets all toggles to their default values and reboots.", confirmLabel: "Reset", danger: true }))) return
try {
await api.resetTogglesDefault()
showSnackbar("Resetting toggles to default... rebooting.")
} catch (e) {
showSnackbar("Reset failed.", "error")
}
},
onBranchSelect(e) {
const branch = e.target.value
e.target.value = this.currentBranch || ""
this.switchBranch(branch)
},
async switchBranch(branch) {
if (!branch || branch === this.currentBranch) return
if (!(await GalaxyConfirm({ title: "Switch branch?", message: `Switch to ${branch} and update?`, confirmLabel: "Switch" }))) return
try {
await api.setUpdateBranch(branch)
showSnackbar(`Switching to ${branch}...`)
} catch (e) {
showSnackbar(e?.message || "Switch failed.", "error")
}
},
async checkUpdates() {
if (this.busy) return
this.busy = "check"
try {
await this.loadFastStatus()
this.checkedForUpdates = true
const st = this.fastStatus
if (st?.running) showSnackbar("An update is already running.")
else if (st?.updateAvailable) showSnackbar(st?.message || "Update available.")
else showSnackbar(st?.message || "No update available — you're up to date.")
} catch (e) {
showSnackbar("Failed to check for updates.", "error")
} finally {
this.busy = ""
}
},
async applyFastUpdate() {
if (this.busy || this.isOnroad) return
if (this.fastStatus?.running) { showSnackbar("Fast update is already running."); return }
if (!this.checkedForUpdates || !this.updateAvailable) {
showSnackbar("No update available. Run \"Check for Updates\" first.", "error")
return
}
const st = this.fastStatus
const confirmed = await GalaxyConfirm({
title: "Update available",
message: `Fast update to the latest commit on ${st?.branch || "this branch"}.\n\nYour device will reboot when the update is done.`,
confirmLabel: "Update & Reboot",
danger: true,
})
if (!confirmed) return
await this.runUpdate("fast")
},
async runUpdate(action) {
if (this.busy) return
this.busy = action
try {
if (action === "rollback") {
const st = this.fastStatus
if (st && !st.rollbackAvailable) {
showSnackbar("No previous installed version is available to roll back to.", "error")
return
}
}
if (action !== "fast") {
const actionLabels = { recover: "Recover the interrupted update?", rollback: "Roll back to the previous installed version?" }
if (!(await GalaxyConfirm({ title: actionLabels[action] || "Continue?", message: "Your device will reboot when the operation is done.", confirmLabel: "Continue", danger: true }))) return
}
const fn = action === "fast" ? api.updateFast : action === "recover" ? api.updateRecover : api.updateRollback
const payload = await fn()
showSnackbar(payload?.message || "Update started.")
await this.loadFastStatus()
} catch (e) {
showSnackbar(e?.message || "Update failed.", "error")
} finally {
this.busy = ""
}
},
async factoryReset() {
if (!(await GalaxyConfirm({ title: "Factory reset?", message: "This wipes params, backups, themes, models, maps, and route data, then reboots. This cannot be undone.", confirmLabel: "Factory Reset", danger: true }))) return
try {
await api.factoryReset()
showSnackbar("Factory resetting...")
} catch (e) {
showSnackbar(e?.message || "Factory reset failed.", "error")
}
},
async saveMe() {
if (!(await GalaxyConfirm({ title: "SAVE ME", message: "This will factory reset the device by wiping params, backups, themes, models, maps, and route data. The device will reboot when the wipe is complete. This cannot be undone.", confirmLabel: "Factory Reset", danger: true }))) return
try {
await api.factoryReset()
showSnackbar("SAVE ME initiated — factory resetting...")
} catch (e) {
showSnackbar(e?.message || "Factory reset failed.", "error")
}
},
async deleteAllDrivingRoutes() {
if (!(await GalaxyConfirm({ title: "Delete All Driving Routes", message: "This permanently deletes all local routes from standard, high-resolution, and alternate footage storage. It does not reset settings or reboot the device.", confirmLabel: "Delete Routes", danger: true }))) return
try {
const payload = await api.deleteAllRoutes(true)
showSnackbar(payload?.message || "All local driving routes deleted.")
} catch (e) {
showSnackbar(e?.message || "Failed to delete driving routes.", "error")
}
},
},
template: `
<div>
<h2 style="margin-top:0;">System Tools</h2>
<GalaxySection title="Backup & Restore" icon="bi-arrow-repeat">
<div style="padding: var(--sp-3); display:flex; gap:8px; flex-wrap:wrap;">
<button type="button" class="gx-btn" @click="backupToggles"><i class="bi bi-download"></i> Backup Toggles</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="$refs.restoreInput.click()"><i class="bi bi-upload"></i> Restore Toggles</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="resetDefault">Reset to Default</button>
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click="saveMe">SAVE ME</button>
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click="deleteAllDrivingRoutes">Delete All Driving Routes</button>
<input ref="restoreInput" type="file" accept=".json" style="display:none;" @change="onRestoreFile" />
</div>
<GalaxyEmbed src="/manage_toggles" title="Backup & Restore" style="min-height:60vh; margin: var(--sp-3);" />
</GalaxySection>
<GalaxySection title="Software & Updates" icon="bi-arrow-up-circle">
<div style="padding: var(--sp-3);">
<div v-if="branchLoading" class="gx-loading">Loading update info...</div>
<template v-else>
<p v-if="isOnroad" style="color:var(--text-muted);">Updates and branch switching are only available while offroad.</p>
<div v-if="fastStatus" class="gx-card" style="margin-bottom:12px;">
<div class="gx-section__header">
<i class="bi bi-arrow-repeat"></i>
<span class="gx-section__title">Update Status</span>
<span v-if="fastStatus.running" class="gx-chip" style="background:var(--primary);color:var(--on-primary);">{{ fastStatus.progressPercent }}%</span>
<span v-else-if="fastStatus.updateAvailable" class="gx-chip" style="background:var(--warning);color:var(--black);">Update available</span>
<span v-else class="gx-chip">Up to date</span>
</div>
<div style="padding: var(--sp-3); display:grid; gap:6px;">
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Branch</span><span class="gx-row__value">{{ fastStatus.branch || currentBranch || '—' }}</span></div>
<div v-if="fastStatus.running" class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Stage</span><span class="gx-row__value">{{ fastStatus.stage }} · {{ fastStatus.progressLabel }}</span></div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Local</span><span class="gx-row__value" style="font-family:monospace;">{{ shortCommit(fastStatus.localCommit) }}</span></div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Remote</span><span class="gx-row__value" style="font-family:monospace;">{{ shortCommit(fastStatus.remoteCommit) }}</span></div>
<div v-if="fastStatus.message" class="gx-row__desc">{{ fastStatus.message }}</div>
<div v-if="fastStatus.warning && (fastStatus.running || fastStatus.updateAvailable)" class="gx-row__desc" style="color:var(--warning);">{{ fastStatus.warning }}</div>
<div v-if="fastStatus.agnosUpdate?.available && fastStatus.agnosUpdate?.warnings?.length" style="margin-top:4px;">
<div v-for="w in fastStatus.agnosUpdate.warnings" :key="w" class="gx-row__desc" style="color:var(--warning);">⚠ {{ w }}</div>
</div>
</div>
</div>
<h4 style="margin:12px 0 8px;">Switch branch</h4>
<div class="gx-row" style="border-top:none; padding:4px 0;">
<select class="gx-field gx-field--full" :disabled="!!isOnroad" @change="onBranchSelect">
<option v-if="!branches.length" value="">No branches available</option>
<option v-for="b in branches" :key="b" :value="b" :selected="b === currentBranch">{{ b === currentBranch ? b + ' (current)' : b }}</option>
</select>
</div>
<div style="display:flex; gap:8px; margin-top:12px; flex-wrap:wrap;">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad || !!fastStatus?.running" @click="checkUpdates">
<i v-if="busy === 'check'" class="bi bi-arrow-repeat gx-spin"></i>
<i v-else class="bi bi-search"></i> {{ busy === 'check' ? 'Checking...' : 'Check for Updates' }}
</button>
<button type="button" class="gx-btn" :disabled="!updateAvailable || !!busy || isOnroad" @click="applyFastUpdate">
<i class="bi bi-arrow-up-circle"></i> {{ busy === 'fast' ? 'Updating...' : 'Update Now' }}
</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad" @click="runUpdate('recover')">Recover</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad" @click="runUpdate('rollback')">Rollback</button>
</div>
<p v-if="checkedForUpdates && !updateAvailable && !fastStatus?.running" style="color:var(--text-muted); margin:8px 0 0;">
The device is up to date. Update becomes available only after a check finds a newer commit.
</p>
</template>
</div>
</GalaxySection>
<GalaxySection title="Danger Zone" icon="bi-exclamation-triangle">
<div style="padding: var(--sp-3);">
<p style="color: var(--text-muted);">Last resort only. This wipes params, backups, themes, models, maps, and route data, then reboots the device.</p>
<button type="button" class="gx-btn" style="background:var(--error);color:var(--on-error);" @click="factoryReset">Factory Reset Device</button>
</div>
</GalaxySection>
</div>
`,
}
@@ -0,0 +1,46 @@
import { store } from "../store.js"
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
export const ToolEmbed = {
name: "ToolEmbed",
components: { GalaxyEmbed },
computed: {
src() { return store.params.src || "/tools" },
title() {
const map = {
"/manage_models": "Model Manager",
"/galaxy": "Galaxy",
"/sentry": "Sentry Mode",
"/plots": "Live Plots",
"/download_speed_limits": "Download Speed Limits",
"/testing_ground": "Testing Ground",
"/theme_maker": "Theme Maker",
"/troubleshoot": "Troubleshoot",
"/manage_tmux": "Tmux Log",
"/manage_toggles": "Backup and Restore",
"/manage_updates": "Software",
"/manage_error_logs": "Error Logs",
"/bluetooth": "Bluetooth",
"/wheel-controls": "Controllers",
"/vehicle_features": "Vehicle Features",
"/manage_v_asm": "V-Adj Spot Monitor",
"/manage_pip_sidecam": "PiP Side Camera",
"/manage_doors": "Lock/Unlock Doors",
"/manage_tsk": "Toyota Security Keys",
"/set_navigation_destination": "Navigation Destination",
"/manage_navigation_keys": "App Keys",
"/manage_maps": "Maps",
}
return map[this.src] || "Tool"
},
},
template: `
<div class="gx-view">
<div class="gx-section__header" style="padding: var(--sp-3) var(--sp-4);">
<i class="bi bi-grid"></i>
<span class="gx-section__title">{{ title }}</span>
</div>
<GalaxyEmbed :src="src" :title="title" forward-nav />
</div>
`,
}
@@ -0,0 +1,39 @@
import { navigate, toolHref } from "../store.js"
const TOOLS = [
{ name: "Galaxy", link: "/galaxy", icon: "bi-globe2", desc: "Pairing & remote access" },
{ name: "Logs & Diagnostics", link: "/logs", icon: "bi-exclamation-triangle", desc: "Error logs, tmux, troubleshoot" },
{ name: "Model Manager", link: "/manage_models", icon: "bi-cpu", desc: "Install/swap models" },
{ name: "Navigation & Maps", link: "/navigation", icon: "bi-map", desc: "Offline maps & destinations" },
{ name: "PiP Side Camera", link: "/manage_pip_sidecam", icon: "bi-camera-video", desc: "Adjust PiP side-camera window" },
{ name: "Plots", link: "/plots", icon: "bi-graph-up-arrow", desc: "Live telemetry plots" },
{ name: "Sentry Mode", link: "/sentry", icon: "bi-shield-exclamation", desc: "Sentry alerts & security" },
{ name: "System Tools", link: "/system", icon: "bi-arrow-repeat", desc: "Backup, restore, updates" },
{ name: "Testing Ground", link: "/testing_ground", icon: "bi-bezier2", desc: "Experiments & testing" },
{ name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill", desc: "Customize the look" },
{ name: "Tuning & Maneuvers", link: "/tuning", icon: "bi-sign-turn-right", desc: "Steering & speed behaviour" },
{ name: "V-ASM Spot Monitor", link: "/manage_v_asm", icon: "bi-bounding-box", desc: "Adjust spot-monitor window" },
{ name: "Vehicle Controls", link: "/vehicle", icon: "bi-car-front", desc: "Controllers, bluetooth, vehicle features" },
].sort((a, b) => a.name.localeCompare(b.name))
export const Tools = {
name: "Tools",
data() { return { TOOLS } },
methods: {
open(t) {
navigate(toolHref(t.link))
},
},
template: `
<div>
<h2 style="margin-top:0;">Tools</h2>
<div class="gx-grid">
<button v-for="t in TOOLS" :key="t.link" type="button" class="gx-tile" @click="open(t)">
<i class="bi" :class="t.icon"></i>
<span>{{ t.name }}</span>
<small style="color: var(--text-muted);">{{ t.desc }}</small>
</button>
</div>
</div>
`,
}
@@ -0,0 +1,15 @@
import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
export const Tuning = {
name: "Tuning",
components: { GalaxyEmbed },
template: `
<div class="gx-view">
<div class="gx-section__header" style="padding: var(--sp-3) var(--sp-4);">
<i class="bi bi-sign-turn-right"></i>
<span class="gx-section__title">Tuning & Maneuvers</span>
</div>
<GalaxyEmbed src="/tuning" title="Tuning" />
</div>
`,
}
@@ -0,0 +1,79 @@
import { api, showSnackbar } from "../api.js"
import { navigate, toolHref } from "../store.js"
import { WheelControls } from "../components/WheelControls.js"
import { BluetoothPanel } from "../components/BluetoothPanel.js"
import { GalaxySection } from "../components/GalaxySection.js"
const FEATURES = [
{ key: "doors", name: "Lock/Unlock Doors", icon: "bi-door-closed", desc: "Send lock or unlock commands remotely to your vehicle.", embed: "/manage_doors" },
{ key: "tsk", name: "Toyota Security Keys", icon: "bi-key-fill", desc: "Manage and apply security keys for secOC protected devices.", embed: "/manage_tsk" },
]
export const Vehicle = {
name: "Vehicle",
components: { WheelControls, BluetoothPanel, GalaxySection },
data() {
return {
features: FEATURES,
featureStatus: {},
busy: "",
}
},
methods: {
statusOf(key) { return this.featureStatus[key] || "untested" },
async openFeature(f) {
if (this.busy) return
if (this.statusOf(f.key) === "denied") {
showSnackbar(`${f.name} is not supported for your current vehicle.`, "error")
return
}
if (this.statusOf(f.key) === "allowed") {
navigate(toolHref(f.embed))
return
}
this.busy = f.key
try {
const data = await api.carFeaturesCheck(f.key)
const allowed = !!data?.result
this.featureStatus = { ...this.featureStatus, [f.key]: allowed ? "allowed" : "denied" }
if (allowed) navigate(toolHref(f.embed))
else showSnackbar(`${f.name} is not supported for your current vehicle.`, "error")
} catch (e) {
this.featureStatus = { ...this.featureStatus, [f.key]: "denied" }
showSnackbar("Could not check vehicle compatibility.", "error")
} finally {
this.busy = ""
}
},
},
template: `
<div>
<h2 style="margin-top:0;">Vehicle Controls</h2>
<GalaxySection title="Controllers" icon="bi-controller">
<WheelControls />
</GalaxySection>
<GalaxySection title="Bluetooth" icon="bi-bluetooth">
<BluetoothPanel />
</GalaxySection>
<GalaxySection title="Vehicle Features" icon="bi-check2-square">
<div style="padding: var(--sp-3); display:grid; gap:8px;">
<button v-for="f in features" :key="f.key" type="button"
class="gx-row" style="width:100%; border:none; background:transparent; color:inherit; cursor:pointer; text-align:left;"
@click="openFeature(f)">
<div class="gx-row__info">
<span class="gx-row__label"><i class="bi" :class="f.icon" style="margin-right:6px; color:var(--primary);"></i>{{ f.name }}</span>
<span class="gx-row__desc">{{ f.desc }}</span>
</div>
<span v-if="busy === f.key" class="gx-chip" style="background:var(--surface-variant);">Checking...</span>
<span v-else-if="statusOf(f.key) === 'denied'" class="gx-chip" style="background:var(--error);">Not supported</span>
<i v-else class="bi bi-chevron-right" style="color:var(--text-muted);"></i>
</button>
<p style="color:var(--text-muted); margin:0;">These features verify vehicle compatibility when launched.</p>
</div>
</GalaxySection>
</div>
`,
}
@@ -0,0 +1,18 @@
{
"name": "Galaxy",
"short_name": "Galaxy",
"description": "Control and configure your openpilot device from anywhere.",
"icons": [
{ "src": "/assets/images/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/assets/images/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" },
{ "src": "/assets/images/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" }
],
"id": "46bf2df73deba8e1512c35de",
"start_url": "/mobile/",
"scope": "/",
"background_color": "#06060f",
"theme_color": "#8b6cc5",
"display": "standalone",
"display_override": ["standalone", "minimal-ui"],
"orientation": "portrait-primary"
}
File diff suppressed because it is too large Load Diff
@@ -66,6 +66,15 @@
<script src="/assets/js/snackbar.js"></script>
<title>Galaxy</title>
<script>
const _inEmbed =
new URLSearchParams(window.location.search).has("embedded") ||
(window.self !== window.top);
if (_inEmbed) {
document.documentElement.classList.add("embedded");
}
</script>
</head>
<body>
@@ -0,0 +1,53 @@
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[4]
DEVICE_SETTINGS_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/device_settings.js"
DEVICE_SETTINGS_CSS_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/device_settings.css"
def _device_settings():
return DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
def test_device_settings_surfaces_hidden_advanced_settings_count():
source = _device_settings()
assert "countAdvancedHiddenByDeveloperMode" in source
assert "isAdvancedHiddenByDeveloperMode" in source
assert "hiddenAdvancedCount" in source
def test_developer_mode_notice_is_rendered_when_advanced_settings_hidden():
source = _device_settings()
assert "ds-dev-mode-notice" in source
assert "advanced setting" in source
assert "Enable Developer Mode" in source
def test_developer_mode_notice_navigates_to_developer_section():
source = _device_settings()
assert 'window.__theGalaxyNavigate("/device_settings/developer")' in source
def test_advanced_settings_hidden_count_shown_in_status_bar():
source = _device_settings()
assert "advanced hidden" in source
def test_device_settings_uses_the_params_api_and_layout_json():
source = _device_settings()
assert 'fetch("/api/params/all")' in source
assert 'fetch("/api/params/defaults")' in source
assert 'fetch("/assets/components/tools/device_settings_layout.json?v=settings-tier-1"' in source
def test_developer_mode_notice_has_styles():
css = DEVICE_SETTINGS_CSS_PATH.read_text(encoding="utf-8")
assert ".ds-dev-mode-notice" in css
assert ".ds-dev-mode-notice-btn" in css
@@ -0,0 +1,437 @@
import json
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[4]
UI_ROOT = REPO_ROOT / "starpilot/system/the_galaxy/assets/mobile"
GALAXY_PY = REPO_ROOT / "starpilot/system/the_galaxy/the_galaxy.py"
def _read(rel):
return (UI_ROOT / rel).read_text(encoding="utf-8")
def test_ui_app_shell_files_exist():
required = [
"index.html",
"manifest.json",
"css/material.css",
"js/app.js",
"js/store.js",
"js/api.js",
"js/params.js",
"js/components/AppShell.js",
"js/components/GalaxyModal.js",
"js/components/GalaxySection.js",
"js/components/GalaxyEmbed.js",
"js/components/GalaxyToggleCard.js",
"js/components/SettingTree.js",
"js/components/ParamSections.js",
"js/components/ManeuverCard.js",
"js/components/WheelControls.js",
"js/components/BluetoothPanel.js",
"js/components/DevModeBanner.js",
"js/composables.js",
"js/views/Home.js",
"js/views/Settings.js",
"js/views/Tools.js",
"js/views/Recordings.js",
"js/views/Logs.js",
"js/views/Tuning.js",
"js/views/Navigation.js",
"js/views/Vehicle.js",
"js/views/SystemTools.js",
]
for rel in required:
assert (UI_ROOT / rel).is_file(), f"missing new-UI file: {rel}"
def test_ui_index_wires_vue_and_mount_point():
index = _read("index.html")
assert 'id="galaxy-app"' in index
assert 'src="/assets/mobile/js/app.js"' in index
assert '"vue": "/assets/vendor/vue/vue.esm-browser.js"' in index
def test_ui_uses_same_backend_endpoints():
settings = _read("js/views/Settings.js")
params = _read("js/params.js")
api = _read("js/api.js")
# Settings fetches the exact same layout JSON + params API the original UI used.
assert '/assets/components/tools/device_settings_layout.json?v=settings-tier-1' in settings or \
'/assets/components/tools/device_settings_layout.json?v=settings-tier-1' in api
assert '"/api/params/all"' in api
assert '"/api/params"' in api
assert '"/api/params/defaults"' in api
def test_ui_ports_developer_mode_gating():
params = _read("js/params.js")
assert "countAdvancedHiddenByDeveloperMode" in params
assert "isSettingVisible" in params
assert "isAdvancedHiddenByDeveloperMode" in params
def test_ui_restores_hierarchical_sub_toggle_rendering():
# Children must nest under parents via the recursive SettingTree, gated on
# the parent being enabled AND expanded (classic renderSettingTree contract).
params = _read("js/params.js")
settings = _read("js/views/Settings.js")
tree = _read("js/components/SettingTree.js")
assert "buildRenderTree" in params
assert "isParamEnabledForChildren" in params
assert "hasChildParams" in params
assert "SettingTree" in settings
assert '<SettingTree :params="activeSection.params"' in settings
# SettingTree recursively reveals children; subpanels are collapsed by default
# (classic Galaxy behavior) and expand only when the user taps Manage/Close.
assert "showChildren(p)" in tree
assert "enabledForChildren(p) && this.isExpanded(p)" in tree
assert "gx-tree-node--child" in tree
assert "gx-collapse" in tree
# It self-references so arbitrary nesting depth (grandchildren, etc.) works.
assert "<SettingTree" in tree
def test_ui_ports_all_tool_views():
# Endpoint usage may live in a view or in a reusable component it composes.
checks = {
"js/views/Recordings.js": ["/api/routes", "getRoutesStream", "getRouteLogs"],
"js/views/Logs.js": ["getErrorLogs", "tmuxSnapshot", "runTroubleshoot"],
"js/views/Tuning.js": ["GalaxyEmbed", 'src="/tuning"'],
"js/views/Navigation.js": ["getNavigation", "setNavigation"],
"js/views/ToolEmbed.js": ["/manage_maps", "/manage_navigation_keys"],
"js/views/SystemTools.js": ["backupToggles", "restoreToggles", "getUpdateBranches", "factoryReset"],
"js/components/WheelControls.js": ["getWheelControlsStatus"],
"js/components/BluetoothPanel.js": ["getBluetoothStatus"],
}
for rel, endpoints in checks.items():
src = _read(rel)
assert src, f"missing file: {rel}"
for ep in endpoints:
assert ep in src, f"{rel} should use api.{ep}"
vehicle = _read("js/views/Vehicle.js")
assert "WheelControls" in vehicle and "BluetoothPanel" in vehicle and "carFeaturesCheck" in vehicle
def test_ui_routes_ported_views_natively_no_classic_fallback():
app = _read("js/app.js")
shell = _read("js/components/AppShell.js")
tools = _read("js/views/Tools.js")
home = _read("js/views/Home.js")
for view in ["Recordings", "Logs", "Tuning", "Navigation", "Vehicle", "SystemTools"]:
assert view in app, f"app.js should register {view}"
# Ported routes must resolve natively in the Vue app (zero /classic redirect).
for route in ["/recordings", "/logs", "/tuning", "/navigation", "/vehicle", "/system"]:
assert route in shell, f"AppShell should route {route} natively"
assert route in app, f"app.js should resolve {route} natively"
# Tools grid routes the native categories (Recordings lives in the bottom nav
# and is intentionally absent from the Tools page).
for tool in ["/tuning", "/logs", "/navigation", "/vehicle", "/system"]:
assert tool in tools, f"Tools grid should route {tool} natively"
# V-ASM Spot Monitor and PiP Side Camera use the classic page-in-page embeds
# instead of the removed native Annotation Tool.
assert "/manage_v_asm" in tools and "/manage_pip_sidecam" in tools
# Unmigrated tools are embedded (ToolEmbed), never a full-page redirect.
assert "return ToolEmbed" in app
assert "ToolEmbed" in app
# Home renders the classic dashboard as a shared page-in-page embed.
assert 'src="/classic"' in home and "GalaxyEmbed" in home
# Neither the shell, tools grid, nor home tiles ever redirect out of the UI.
for src in [shell, tools, home, _read("js/views/ToolEmbed.js"), _read("js/store.js")]:
assert "window.location.href" not in src
def test_ui_embeds_unmigrated_tools_inapp():
# All page-in-page embeds go through the single shared GalaxyEmbed component.
embed_comp = _read("js/components/GalaxyEmbed.js")
assert "iframe" in embed_comp and "gx-embed__frame" in embed_comp
tool = _read("js/views/ToolEmbed.js")
assert "GalaxyEmbed" in tool and "forward-nav" in tool
store = _read("js/store.js")
assert "toolHref" in store and "/embed?src=" in store
shell = _read("js/components/AppShell.js")
assert "toolHref" in shell
# Embed mode tags the iframe src so the classic SPA hides its sidebar/menu.
assert "embedded=1" in embed_comp
classic_index = (REPO_ROOT / "starpilot/system/the_galaxy/templates/index.html").read_text(encoding="utf-8")
assert 'has("embedded")' in classic_index and 'classList.add("embedded")' in classic_index
assert "window.self !== window.top" in classic_index
def test_ui_numeric_toggles_are_sliders_with_default():
card = _read("js/components/GalaxyToggleCard.js")
# All numeric params render as a slider (no more +/- stepper or bare 0 button).
assert "isSlider() { return this.isNumeric }" in card
assert 'type="range"' in card
assert "onSliderInput" in card and "onSliderCommit" in card
# A Default (reset-to-stock) button is present for numerics.
assert "resetToDefault" in card
# The stepper/number-input UI is gone.
assert 'type="number"' not in card
assert "{{ stepLabel() }}" not in card
assert 'title="Set to zero"' not in card
def test_ui_centralizes_api_and_uses_composables():
api = _read("js/api.js")
composables = _read("js/composables.js")
for view in ["Recordings", "Logs", "Tuning", "Navigation", "Vehicle", "SystemTools"]:
src = _read(f"js/views/{view}.js")
# Views must not issue raw fetch() / hand-rolled polling / SSE.
assert "fetch(" not in src.replace("api.", ""), f"{view} should not use raw fetch()"
# Shared polling + log streaming live in composables, not duplicated in views.
assert "usePolling" in composables
assert "useLogStream" in composables
assert "usePolling" in _read("js/components/ManeuverCard.js")
assert "usePolling" in _read("js/components/WheelControls.js")
assert "useLogStream" in _read("js/views/Logs.js")
def test_ui_schema_driven_param_engine_reused():
# Tuning is a page-in-page embed of the classic /tuning SPA (which owns its
# own tuning UI). Vehicle is a hub of Controllers/Bluetooth/Features — all
# toggles live in the dedicated Settings view, so it must NOT render toggles.
tuning = _read("js/views/Tuning.js")
embed_comp = _read("js/components/GalaxyEmbed.js")
assert 'src="/tuning"' in tuning and "GalaxyEmbed" in tuning, "Tuning should embed the classic tuning SPA"
assert "embedded=1" in embed_comp
vehicle = _read("js/views/Vehicle.js")
assert "ParamSections" not in vehicle, "Vehicle must not render redundant toggles"
assert "WheelControls" in vehicle and "BluetoothPanel" in vehicle
assert "GalaxySection" in vehicle
engine = _read("js/components/ParamSections.js")
assert "SettingTree" in engine
assert "isSettingVisible" in engine
def test_ui_eliminates_slider_toggle_flicker():
card = _read("js/components/GalaxyToggleCard.js")
css = _read("css/material.css")
# Optimistic local preview + interacting guard. The value only commits when
# the drag/keyboard interaction is RELEASED, so holding never drops it.
assert "preview" in card
assert "interacting" in card
assert "onSliderCommit" in card
assert "flushSlider" in card
# No mid-drag auto-commit timer: holding still must NOT release/lock.
assert "commitTimer" not in card
assert "setTimeout" not in card
# Release (change) and blur (keyboard) both flush the commit.
assert "interacting = false" in card
assert "onSliderBlur" in card
assert "@blur=\"onSliderBlur\"" in card
assert "currentValue() { return this.preview !== undefined ? this.preview : this.value }" in card
# Manage/Close affordance for parent toggles with nested children.
assert "manageable" in card
assert "manageOpen" in card
assert "gx-manage-btn" in card
# Nested indentation / expand animation styling present.
assert ".gx-tree-node--child" in css
assert ".gx-manage-btn" in css
assert ".gx-tree-children" in css
def test_ui_developer_mode_banner_offers_unlock():
banner = _read("js/components/DevModeBanner.js")
assert "Enable Developer Mode" in banner
assert 'navigate("/settings/developer")' in banner
assert "advanced setting" in banner
def test_ui_has_bottom_navigation_and_drawer():
shell = _read("js/components/AppShell.js")
# Exactly one navigation affordance: liquid-glass bottom nav (mobile) OR the
# drawer hamburger (desktop). Mobile shows a back button instead.
assert "liquid-glass-nav" in shell
assert "nav-item" in shell
assert "gx-menu-btn" in shell
assert "gx-back-btn" in shell
assert "goBack" in shell or "back()" in shell
assert "gx-drawer" in shell
assert "gx-appbar" in shell
assert "Search settings" in shell
def test_ui_search_visible_on_mobile_and_content_full_width():
css = _read("css/material.css")
# Search must NOT be hidden on mobile (regression: it was display:none <600px).
assert ".gx-appbar__search" in css
# A single breakpoint picks mobile (bottom nav + back) vs desktop (drawer).
assert ".liquid-glass-nav { display: flex; }" in css
assert ".gx-menu-btn { display: none; }" in css
assert ".gx-back-btn { display: inline-flex; }" in css
# Content + embedded tools fill the available width (no 760px cap).
assert "max-width: none" in css
# Liquid Glass styling is present.
assert "--glass-bg" in css
assert "backdrop-filter" in css
def test_ui_glass_nav_single_breakpoint_no_dual_nav():
css = _read("css/material.css")
assert ".liquid-glass-nav" in css
assert "@media (min-width: 768px)" in css
assert ".liquid-glass-nav { display: none; }" in css
assert ".gx-back-btn { display: none; }" in css
assert ".gx-menu-btn { display: inline-flex; }" in css
def test_ui_settings_deep_links_and_dev_mode_updates():
settings = _read("js/views/Settings.js")
# Route is reactive via a computed (not the broken this.store watch), so a
# Developer-Mode navigation updates the page without a refresh.
assert "route() { return store.route }" in settings
assert "route() { this.applyRouteSection() }" in settings
# Selecting a section + opening a subpanel update the URL (deep-linkable).
assert 'navigate("/settings/" + slug)' in settings
assert "?open=" in settings
# Turning Developer Mode on reloads data so newly-visible sections (e.g.
# Favorites) render immediately.
assert "devModeOn() { this.load() }" in settings
def test_ui_galaxy_background_is_css_only_and_lightweight():
index = _read("index.html")
css = _read("css/material.css")
# Background is a single fixed layer, pure CSS (no canvas/WebGL/images).
assert 'id="galaxy-bg"' in index
assert "#galaxy-bg" in css
assert "position: fixed" in css
assert "radial-gradient" in css
# Cheap GPU-friendly transforms + stars; disabled for reduced-motion.
assert "--gx-para-x" in css
assert "prefers-reduced-motion" in css
# App content sits above the background layer.
assert ".gx-app" in css and "z-index: 1" in css
def test_galaxy_py_serves_classic_at_root_and_new_ui_at_mobile():
source = GALAXY_PY.read_text(encoding="utf-8")
# The classic Galaxy SPA is the default landing at / (original behaviour).
assert '@app.route("/", methods=["GET"])' in source
assert 'render_template("index.html")' in source
# Classic also stays reachable at /classic (page-in-page embed target).
assert '@app.route("/classic", methods=["GET"])' in source
# The modern Vue UI is served at /mobile (and /ui), not the root.
assert '@app.route("/mobile", methods=["GET"])' in source
assert '@app.route("/ui", methods=["GET"])' in source
assert 'Path(app.static_folder) / "mobile" / "index.html"' in source
def test_ui_manifest_is_valid_pwa_manifest():
manifest = json.loads((UI_ROOT / "manifest.json").read_text(encoding="utf-8"))
assert manifest["display"] == "standalone"
assert manifest["name"]
assert manifest["icons"]
assert manifest["start_url"] == "/mobile/"
def _node_exe():
candidates = [
shutil.which("node"),
"/mnt/c/Program Files/nodejs/node.exe",
]
for path in candidates:
if path and Path(path).exists():
return path
return None
@pytest.mark.skipif(_node_exe() is None, reason="no node.js runtime available")
def test_ui_ported_param_logic_runs_and_passes(tmp_path):
node = _node_exe()
params_src = _read("js/params.js")
params_dst = tmp_path / "params.mjs"
params_dst.write_text(params_src, encoding="utf-8")
script = tmp_path / "pcheck.mjs"
script.write_text(
"""
import * as P from "./params.mjs"
const sec = { name: "Lateral (Steering)", params: [
{ key: "AlwaysOnLateral", settings_tier: "simple", data_type: "bool" },
{ key: "VASMEnabled", settings_tier: "advanced", data_type: "bool" },
]}
const assert = (cond, msg) => { if (!cond) throw new Error("FAIL: " + msg) }
assert(P.isSettingVisible(sec, sec.params[0], {}) === true, "simple visible (off)")
assert(P.isSettingVisible(sec, sec.params[1], {}) === false, "advanced hidden (off)")
assert(P.isSettingVisible(sec, sec.params[1], { GalaxyDeveloperMode: true }) === true, "advanced visible (on)")
assert(P.countAdvancedHiddenByDeveloperMode([sec], {}) === 1, "count hidden (off)")
assert(P.countAdvancedHiddenByDeveloperMode([sec], { GalaxyDeveloperMode: true }) === 0, "count hidden (on)")
const slider = { key: "DeviceShutdown", data_type: "int", min: 1, max: 30, step: 1 }
assert(P.snapNumericToBoundsAndStep(17.9, P.numericBounds(slider, {}), 0) === 18, "snap")
assert(P.formatSliderValue(6, "1", 0, "DeviceShutdown") === "6 hours", "format")
console.log("params.js logic OK")
""",
encoding="utf-8",
)
result = subprocess.run([node, str(script)], capture_output=True, text=True)
assert result.returncode == 0, f"node failed:\n{result.stdout}\n{result.stderr}"
@pytest.mark.skipif(_node_exe() is None, reason="no node.js runtime available")
def test_ui_hierarchical_tree_logic_runs_and_passes(tmp_path):
node = _node_exe()
params_src = _read("js/params.js")
(tmp_path / "params.mjs").write_text(params_src, encoding="utf-8")
script = tmp_path / "treecheck.mjs"
script.write_text(
"""
import * as P from "./params.mjs"
const params = [
{ key: "Parent", ui_type: "toggle" },
{ key: "Child", parent_key: "Parent", ui_type: "toggle" },
{ key: "GrandChild", parent_key: "Child", ui_type: "toggle" },
{ key: "Sibling", ui_type: "toggle" },
]
const assert = (cond, msg) => { if (!cond) throw new Error("FAIL: " + msg) }
// Parent off -> no children rendered.
let tree = P.buildRenderTree(params, {}, {})
assert(tree.length === 2, "parent off shows only roots, got " + tree.length)
assert(tree.map(t => t.param.key).join(",") === "Parent,Sibling", "parent off keys")
// Parent on but collapsed -> still no children.
tree = P.buildRenderTree(params, { Parent: true }, {})
assert(tree.length === 2, "parent on but collapsed hides children")
// Parent on + expanded -> children, but grandchild hidden until Child expanded.
tree = P.buildRenderTree(params, { Parent: true }, { Parent: true })
assert(tree.map(t => t.param.key).join(",") === "Parent,Child,Sibling", "one level deep")
assert(tree[1].depth === 1, "child depth is 1")
// Full expansion -> grandchild at depth 2.
tree = P.buildRenderTree(params, { Parent: true, Child: true }, { Parent: true, Child: true })
const gc = tree.find(t => t.param.key === "GrandChild")
assert(gc && gc.depth === 2, "grandchild nested at depth 2")
assert(tree[0].depth === 0, "root depth is 0")
assert(P.isParamEnabledForChildren({ key: "X" }, { X: true }) === true, "enabled when true")
assert(P.isParamEnabledForChildren({ key: "X" }, { X: false }) === false, "disabled when false")
assert(P.isParamEnabledForChildren({ ui_type: "group" }, {}) === true, "group always enabled")
assert(P.hasChildParams(params, "Parent") === true, "hasChildParams true")
assert(P.hasChildParams(params, "Sibling") === false, "hasChildParams false")
console.log("hierarchy logic OK")
""",
encoding="utf-8",
)
result = subprocess.run([node, str(script)], capture_output=True, text=True)
assert result.returncode == 0, f"node failed:\n{result.stdout}\n{result.stderr}"
+33
View File
@@ -4958,6 +4958,18 @@ def setup(app):
response.headers["Expires"] = "0"
return response
def _no_store_response(response):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
def _serve_new_ui():
ui_index_path = Path(app.static_folder) / "mobile" / "index.html"
if not ui_index_path.is_file():
return "Galaxy UI not found", 404
return _no_store_response(make_response(send_file(str(ui_index_path))))
@app.route("/", methods=["GET"])
def index():
response = make_response(render_template("index.html"))
@@ -4966,6 +4978,18 @@ def setup(app):
response.headers["Expires"] = "0"
return response
@app.route("/classic", methods=["GET"])
@app.route("/classic/", methods=["GET"])
def classic_index():
return _no_store_response(make_response(render_template("index.html")))
@app.route("/mobile", methods=["GET"])
@app.route("/mobile/", methods=["GET"])
@app.route("/ui", methods=["GET"])
@app.route("/ui/", methods=["GET"])
def mobile_index():
return _serve_new_ui()
@app.route("/api/bluetooth/status", methods=["GET"])
def bluetooth_status():
try:
@@ -7213,6 +7237,15 @@ def setup(app):
with _STATS_RESPONSE_LOCK:
return _get_stats_locked()
@app.route("/api/device/status", methods=["GET"])
def device_status():
return jsonify({
"status": "Driving" if params.get_bool("IsOnroad") else "Parked",
"online": True,
"lanIp": utilities.get_current_lan_ip(),
"networkName": utilities.get_current_network_name(),
}), 200
@app.route("/api/stats/ignore_drive", methods=["POST"])
def ignore_drive_stats():
request_data = request.get_json() or {}
+8 -8
View File
@@ -1103,7 +1103,7 @@ def _dashboard_time_is_valid(value, now=None, require_recent=False):
return True
def _timestamp_to_dashboard_time(timestamp, require_recent=False):
def _timestamp_to_dashboard_time(timestamp, require_recent=False, now=None):
timestamp = _safe_float(timestamp, 0.0)
if timestamp <= 0.0:
return None
@@ -1111,7 +1111,7 @@ def _timestamp_to_dashboard_time(timestamp, require_recent=False):
parsed = datetime.fromtimestamp(timestamp)
except (OSError, OverflowError, ValueError):
return None
return parsed if _dashboard_time_is_valid(parsed, require_recent=require_recent) else None
return parsed if _dashboard_time_is_valid(parsed, require_recent=require_recent, now=now) else None
def _parse_segment_dir_name(name):
@@ -1605,15 +1605,15 @@ def _public_drive(drive, is_metric):
return public
def _route_time_range(route_info, duration_seconds):
def _route_time_range(route_info, duration_seconds, now=None):
modified_at = _safe_float(route_info.get("modifiedAt", 0.0), 0.0)
duration_seconds = max(0.0, _safe_float(duration_seconds, 0.0))
started_at = route_info.get("startedAt")
if _dashboard_time_is_valid(started_at, require_recent=True):
if _dashboard_time_is_valid(started_at, now=now, require_recent=True):
end_time = started_at + timedelta(seconds=duration_seconds) if duration_seconds > 0.0 else None
return _jsonable_time(started_at), _jsonable_time(end_time)
modified_time = _timestamp_to_dashboard_time(modified_at, require_recent=True)
modified_time = _timestamp_to_dashboard_time(modified_at, now=now, require_recent=True)
if modified_time is not None and duration_seconds > 0.0:
end_time = modified_time
start_time = end_time - timedelta(seconds=duration_seconds)
@@ -1625,10 +1625,10 @@ def _distance_from_meters(distance_m, is_metric):
return distance_m * (METER_TO_KILOMETER if is_metric else METER_TO_MILE)
def _route_shell_drive(route_info, params_obj, model_names, is_metric):
def _route_shell_drive(route_info, params_obj, model_names, is_metric, now=None):
segment_count = max(0, _safe_int(route_info.get("segmentCount", 0), 0))
duration_seconds = segment_count * 60
start_date, end_date = _route_time_range(route_info, duration_seconds)
start_date, end_date = _route_time_range(route_info, duration_seconds, now=now)
return {
"name": route_info.get("name", ""),
"routeNames": [route_info.get("name", "")],
@@ -2986,7 +2986,7 @@ def get_dashboard_stats(footage_paths, params_obj=None, now=None):
persistent_stats = _load_dashboard_persistent_stats(params_obj)
shell_drives = [
_route_shell_drive(route_info, params_obj, model_names, is_metric)
_route_shell_drive(route_info, params_obj, model_names, is_metric, now=now)
for route_info in route_infos
]
if shell_drives: