diff --git a/frogpilot/system/the_pond/assets/components/home/home.js b/frogpilot/system/the_pond/assets/components/home/home.js index 7a700cb81..b4247fc01 100644 --- a/frogpilot/system/the_pond/assets/components/home/home.js +++ b/frogpilot/system/the_pond/assets/components/home/home.js @@ -64,40 +64,45 @@ function renderDiskUsageSection({ diskError, diskUsage }) { return DiskUsage({ size: "0 GB", used: "0 GB", usedPercentage: "0" }); } -export function Home() { - const state = reactive({ - data: null, - unit: "miles", - isLoading: true, - error: null, - }); +const state = reactive({ + data: null, + unit: "miles", + isLoading: true, + error: null, +}); - async function initialize() { - try { - const [statsResponse, unitResponse] = await Promise.all([ - fetch("/api/stats"), - fetch("/api/params?key=IsMetric"), - ]); +let initialized = false; - if (!statsResponse.ok) throw new Error(`API error: ${statsResponse.statusText}`); - if (!unitResponse.ok) throw new Error(`API error: ${unitResponse.statusText}`); +async function initialize() { + try { + const [statsResponse, unitResponse] = await Promise.all([ + fetch("/api/stats"), + fetch("/api/params?key=IsMetric"), + ]); - const statsJson = await statsResponse.json(); - const isMetricText = (await unitResponse.text()).trim(); - const isMetric = isMetricText === "1"; + if (!statsResponse.ok) throw new Error(`API error: ${statsResponse.statusText}`); + if (!unitResponse.ok) throw new Error(`API error: ${unitResponse.statusText}`); - state.data = statsJson; - state.unit = isMetric ? "kilometers" : "miles"; - localStorage.setItem("isMetric", isMetricText); - } catch (err) { - console.error("Failed to initialize component:", err); - state.error = err.message; - } finally { - state.isLoading = false; - } + const statsJson = await statsResponse.json(); + const isMetricText = (await unitResponse.text()).trim(); + const isMetric = isMetricText === "1"; + + state.data = statsJson; + state.unit = isMetric ? "kilometers" : "miles"; + localStorage.setItem("isMetric", isMetricText); + } catch (err) { + console.error("Failed to initialize component:", err); + state.error = err.message; + } finally { + state.isLoading = false; } +} - initialize(); +export function Home() { + if (!initialized) { + initialized = true; + initialize(); + } return html`
diff --git a/frogpilot/system/the_pond/assets/components/router.js b/frogpilot/system/the_pond/assets/components/router.js index 657c2e5a9..c17e80575 100644 --- a/frogpilot/system/the_pond/assets/components/router.js +++ b/frogpilot/system/the_pond/assets/components/router.js @@ -12,6 +12,7 @@ import { SettingsView } from "/assets/components/settings.js" import { ScreenRecordings } from "/assets/components/recordings/screen_recordings.js" import { Sidebar } from "/assets/components/sidebar.js" import { SpeedLimits } from "/assets/components/tools/speed_limits.js" +import { ModelManager } from "/assets/components/tools/model_manager.js?v=20260303t" import { ThemeMaker } from "/assets/components/tools/theme_maker.js" import { TmuxLog } from "/assets/components/tools/tmux.js" import { ToggleControl } from "/assets/components/tools/toggles.js" @@ -40,6 +41,7 @@ function Root() { createRoute("screen_recordings", "/screen_recordings", ScreenRecordings), createRoute("settings", "/settings/:section/:subsection?", SettingsView), createRoute("speed_limits", "/download_speed_limits", SpeedLimits), + createRoute("model_manager", "/manage_models", ModelManager), createRoute("thememaker", "/theme_maker", ThemeMaker), createRoute("tmux", "/manage_tmux", TmuxLog), createRoute("toggles", "/manage_toggles", ToggleControl), @@ -61,13 +63,13 @@ function Root() { }) router.subscribe(({ initialized, navigation, matches, errors }) => { - const [match] = matches + const [match] = matches || [] Object.assign(routerState, { initialized, - activePath: match.route.path, - activePathFull: match.pathname, + activePath: match?.route?.path || "", + activePathFull: match?.pathname || "", navigation, - params: match.params, + params: match?.params || {}, errors, }) }) @@ -76,8 +78,8 @@ function Root() { ${() => Sidebar(routerState.activePathFull)}
${() => { - if (!routerState.initialized || routerState.navigation.state === "loading") { - return html`
Loading...
` + if (!routerState.initialized) { + return Home({ params: routerState.params }) } if (routerState.errors?.root?.status === 404) { @@ -85,6 +87,10 @@ function Root() { } const match = routes.find(r => r.path === routerState.activePath) + if (!match) { + console.warn("[router] no route match for path:", routerState.activePathFull) + return Home({ params: routerState.params }) + } return match.element({ params: routerState.params }) }}
@@ -109,4 +115,9 @@ export function Navigate(href) { window.scrollTo(0, 0) } -Root()(document.getElementById("app")) +if (!window.__thePondRouterMounted) { + window.__thePondRouterMounted = true + Root()(document.getElementById("app")) +} else { + console.warn("[router] duplicate mount prevented") +} diff --git a/frogpilot/system/the_pond/assets/components/sidebar.js b/frogpilot/system/the_pond/assets/components/sidebar.js index 5fbcca1e0..7dc930fb1 100644 --- a/frogpilot/system/the_pond/assets/components/sidebar.js +++ b/frogpilot/system/the_pond/assets/components/sidebar.js @@ -19,6 +19,7 @@ const MenuItems = { { name: "Download Speed Limits", link: "/download_speed_limits", icon: "bi-download" }, { name: "Error Logs", link: "/manage_error_logs", icon: "bi-exclamation-triangle" }, { name: "Lock/Unlock Doors", link: "/lock_or_unlock_doors", icon: "bi-door-closed" }, + { name: "Model Manager", link: "/manage_models", icon: "bi-cpu" }, { name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill" }, { name: "Tmux Log", link: "/manage_tmux", icon: "bi-terminal" }, { name: "Toggles", link: "/manage_toggles", icon: "bi-toggle-on" }, diff --git a/frogpilot/system/the_pond/assets/components/tools/device_settings.js b/frogpilot/system/the_pond/assets/components/tools/device_settings.js index 5a7d3204e..f04abf2a8 100644 --- a/frogpilot/system/the_pond/assets/components/tools/device_settings.js +++ b/frogpilot/system/the_pond/assets/components/tools/device_settings.js @@ -23,7 +23,13 @@ async function fetchLayoutAndParams() { // 1. Fetch Layout Structure (Build-time Static JSON) try { const layoutRes = await fetch("/assets/components/tools/device_settings_layout.json") - const layoutData = await layoutRes.json() + const rawLayoutData = await layoutRes.json() + const layoutData = rawLayoutData + .map(section => ({ + ...section, + params: (section.params || []).filter(param => param.key !== "Model"), + })) + .filter(section => section.params.length > 0) state.layout = layoutData // Extract flatter key map diff --git a/frogpilot/system/the_pond/assets/components/tools/model_manager.css b/frogpilot/system/the_pond/assets/components/tools/model_manager.css new file mode 100644 index 000000000..42092fa84 --- /dev/null +++ b/frogpilot/system/the_pond/assets/components/tools/model_manager.css @@ -0,0 +1,253 @@ +.mm-wrapper { + color: var(--text-color); + max-width: 1200px; + padding-right: var(--padding-base); +} + +.mm-toolbar { + align-items: center; + background: var(--card-bg); + border: 1px solid var(--sidebar-border-color); + border-radius: var(--border-radius-lg); + display: flex; + flex-wrap: wrap; + gap: var(--gap-md); + justify-content: space-between; + margin: var(--margin-base) 0; + padding: var(--padding-base); +} + +.mm-summary { + color: var(--text-muted); + display: flex; + flex-wrap: wrap; + gap: var(--gap-lg); +} + +.mm-actions { + display: flex; + flex-wrap: wrap; + gap: var(--gap-sm); +} + +.mm-status { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--gap-sm); + margin-bottom: var(--margin-base); +} + +.mm-progress { + background: var(--secondary-bg); + border: 1px solid var(--sidebar-border-color); + border-radius: var(--border-radius-base); + color: var(--text-color); + padding: 0.35rem 0.6rem; +} + +.mm-filters { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--gap-sm); + margin-bottom: var(--margin-base); +} + +.mm-search, +.mm-select { + background: var(--input-bg); + border: 1px solid var(--sidebar-border-color); + border-radius: var(--border-radius-base); + color: var(--text-color); + font-size: var(--font-size-base); + min-height: 2.15rem; + padding: 0.4rem 0.55rem; +} + +.mm-search { + min-width: min(420px, 90vw); +} + +.mm-filter-label, +.mm-filter-checkbox { + align-items: center; + color: var(--text-muted); + display: flex; + gap: var(--gap-xs); +} + +.mm-filter-break { + display: none; +} + +.mm-series { + background: var(--card-bg); + border: 1px solid var(--sidebar-border-color); + border-radius: var(--border-radius-lg); + margin-bottom: var(--margin-base); + overflow: hidden; +} + +.mm-series-header { + align-items: center; + background: rgba(255, 255, 255, 0.03); + border-bottom: 1px solid var(--sidebar-border-color); + display: flex; + justify-content: space-between; + padding: 0.55rem 0.8rem; +} + +.mm-series-header h3 { + font-size: 1.02rem; + margin: 0; + padding: 0; +} + +.mm-series-header span { + color: var(--text-muted); +} + +.mm-row { + align-items: center; + border-bottom: 1px solid var(--sidebar-border-color); + display: flex; + gap: var(--gap-md); + justify-content: space-between; + padding: 0.75rem 0.8rem; +} + +.mm-row:last-child { + border-bottom: none; +} + +.mm-row-main { + min-width: 0; +} + +.mm-row-title { + align-items: center; + display: flex; + font-size: 1rem; + gap: var(--gap-xs); +} + +.mm-row-meta { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.35rem; +} + +.mm-row-actions { + align-items: center; + display: flex; + flex-shrink: 0; + gap: var(--gap-sm); +} + +.mm-btn { + border: none; + border-radius: var(--border-radius-base); + color: var(--text-color); + min-height: 2.2rem; + min-width: 7.5rem; + padding: 0.45rem 0.8rem; +} + +.mm-btn:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.mm-btn-primary { + background: var(--success-bg); + color: var(--color-black); +} + +.mm-btn-primary:hover:not(:disabled) { + background: var(--success-hover-bg); +} + +.mm-btn-secondary { + background: var(--sidebar-active-bg); +} + +.mm-btn-secondary:hover:not(:disabled) { + background: var(--accent-hover-bg); +} + +.mm-btn-danger { + background: var(--danger-bg); +} + +.mm-btn-danger:hover:not(:disabled) { + background: var(--danger-hover-bg); +} + +.mm-chip { + background: var(--secondary-bg); + border: 1px solid var(--sidebar-border-color); + border-radius: 999px; + color: var(--text-muted); + display: inline-flex; + font-size: 0.78rem; + padding: 0.1rem 0.5rem; +} + +.mm-chip-active { + background: var(--success-bg); + border-color: transparent; + color: var(--color-black); +} + +.mm-chip-favorite { + background: rgba(212, 160, 96, 0.18); + border-color: rgba(212, 160, 96, 0.5); + color: var(--warning-bg); +} + +.mm-chip-warning { + background: rgba(224, 85, 119, 0.12); + border-color: rgba(224, 85, 119, 0.45); + color: var(--danger-fg); +} + +.mm-star { + background: transparent; + border: none; + color: #e0b45a; + font-size: 1rem; + line-height: 1; + padding: 0; +} + +.mm-empty { + color: var(--text-muted); + padding: 0.7rem 0; +} + +@media (max-width: 850px) { + .mm-row { + align-items: flex-start; + flex-direction: column; + } + + .mm-row-actions { + width: 100%; + } + + .mm-btn { + width: 100%; + } + + .mm-search { + min-width: 100%; + } + + .mm-filter-break { + display: block; + flex-basis: 100%; + height: 0; + } +} diff --git a/frogpilot/system/the_pond/assets/components/tools/model_manager.js b/frogpilot/system/the_pond/assets/components/tools/model_manager.js new file mode 100644 index 000000000..35f81a263 --- /dev/null +++ b/frogpilot/system/the_pond/assets/components/tools/model_manager.js @@ -0,0 +1,583 @@ +import { html, reactive } from "https://esm.sh/@arrow-js/core"; + +const state = reactive({ + loading: true, + refreshing: false, + error: "", + actionBusy: false, + sortMode: "alphabetical", + communityFavoriteFilter: "all", + models: [], + currentModel: "", + summary: { installed: 0, missing: 0, total: 0 }, + status: { + modelToDownload: "", + downloadAll: false, + downloading: false, + cancelling: false, + progress: "", + isOnroad: false, + terminal: false, + }, +}); + +let initialized = false; +let pollingHandle = null; +let statusInFlight = false; +let lastStatusSignature = ""; + +const REQUEST_TIMEOUT_MS = 20000; +const ACTIVE_POLL_INTERVAL_MS = 1000; +const IDLE_POLL_INTERVAL_MS = 4000; + +function notify(message, variant = "success") { + if (typeof showSnackbar === "function") { + showSnackbar(message, variant); + } else if (variant === "error") { + console.error(message); + } else { + console.log(message); + } +} + +function logDebug(message, details = null) { + if (details === null || details === undefined) { + console.log(`[ModelManager] ${message}`); + } else { + console.log(`[ModelManager] ${message}`, details); + } +} + +function isModelRouteActive() { + return window.location.pathname === "/manage_models"; +} + +function safeText(value, fallback = "") { + if (value === null || value === undefined) return fallback; + return String(value); +} + +function toBool(value) { + return !!value; +} + +function toInt(value) { + const n = Number(value); + return Number.isFinite(n) ? n : 0; +} + +function parseReleased(value) { + const ts = Date.parse(safeText(value, "")); + return Number.isNaN(ts) ? 0 : ts; +} + +function normalizeSeries(model) { + return safeText(model?.series, "Custom Series") || "Custom Series"; +} + +function modelSortCompare(a, b) { + if (state.sortMode === "release_date") { + const dateDelta = parseReleased(b?.released) - parseReleased(a?.released); + if (dateDelta !== 0) return dateDelta; + } + + return safeText(a?.label, a?.value).localeCompare( + safeText(b?.label, b?.value), + undefined, + { sensitivity: "base", numeric: true }, + ); +} + +function getFilteredModels() { + let rows = [...state.models].filter(model => model && typeof model === "object"); + + if (state.communityFavoriteFilter === "yes") { + rows = rows.filter(model => !!model.communityFavorite); + } else if (state.communityFavoriteFilter === "no") { + rows = rows.filter(model => !model.communityFavorite); + } + + return rows; +} + +function getSeriesGroups() { + const grouped = {}; + + for (const model of getFilteredModels()) { + const seriesName = normalizeSeries(model); + if (!grouped[seriesName]) grouped[seriesName] = []; + grouped[seriesName].push(model); + } + + const seriesNames = Object.keys(grouped); + for (const seriesName of seriesNames) { + grouped[seriesName].sort(modelSortCompare); + } + + if (state.sortMode === "release_date") { + seriesNames.sort((a, b) => { + const aNewest = Math.max(...grouped[a].map(model => parseReleased(model?.released))); + const bNewest = Math.max(...grouped[b].map(model => parseReleased(model?.released))); + const delta = bNewest - aNewest; + if (delta !== 0) return delta; + return a.localeCompare(b, undefined, { sensitivity: "base" }); + }); + } else { + seriesNames.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); + } + + return { grouped, seriesNames }; +} + +function getVisibleModels() { + const { grouped, seriesNames } = getSeriesGroups(); + const rows = []; + for (const seriesName of seriesNames) { + rows.push(...grouped[seriesName]); + } + return rows; +} + +function getReleaseOrderedModels() { + return getFilteredModels().sort(modelSortCompare); +} + +function getInstalledModels() { + const rows = state.sortMode === "release_date" ? getReleaseOrderedModels() : getVisibleModels(); + return rows.filter(model => !!model.installed); +} + +function getCurrentModelName() { + const current = safeText(state.currentModel, ""); + if (!current) return "none"; + + const match = state.models.find(model => safeText(model?.value, "") === current); + if (!match) return current; + + return safeText(match.label, current); +} + +async function fetchJson(url, options = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const response = await fetch(url, { ...options, signal: controller.signal }); + + let payload = {}; + try { + payload = await response.json(); + } catch { + payload = {}; + } + + if (!response.ok) { + const message = payload?.error || payload?.message || `Request failed (${response.status})`; + throw new Error(message); + } + + return payload; + } finally { + clearTimeout(timer); + } +} + +async function fetchStatus() { + if (statusInFlight) return; + statusInFlight = true; + + try { + const payload = await fetchJson("/api/models/status"); + + const models = Array.isArray(payload.models) + ? payload.models.filter(model => model && typeof model === "object") + : []; + + state.models = models; + state.currentModel = safeText(payload.currentModel, ""); + + const summary = payload.summary && typeof payload.summary === "object" ? payload.summary : {}; + state.summary = { + installed: toInt(summary.installed), + missing: toInt(summary.missing), + total: toInt(summary.total), + }; + + state.status = { + modelToDownload: safeText(payload.modelToDownload, ""), + downloadAll: toBool(payload.downloadAll), + downloading: toBool(payload.downloading), + cancelling: toBool(payload.cancelling), + progress: safeText(payload.progress, ""), + isOnroad: toBool(payload.isOnroad), + terminal: toBool(payload.terminal), + }; + + state.error = ""; + + const signature = [ + state.models.length, + state.currentModel, + state.status.downloading, + state.status.downloadAll, + state.status.modelToDownload, + state.status.progress, + ].join("|"); + + if (signature !== lastStatusSignature) { + lastStatusSignature = signature; + logDebug("Status updated", { + models: state.models.length, + currentModel: state.currentModel || "none", + downloading: state.status.downloading, + progress: state.status.progress || "Idle", + }); + } + } catch (error) { + state.error = error?.message || String(error); + logDebug("Status fetch failed", state.error); + } finally { + statusInFlight = false; + state.loading = false; + state.refreshing = false; + } +} + +async function refreshAll(showToast = false) { + state.refreshing = true; + if (state.models.length === 0) { + state.loading = true; + } + + await fetchStatus(); + + if (showToast && !state.error) { + notify("Model list refreshed."); + } +} + +function ensurePolling() { + if (pollingHandle) return; + + const poll = async () => { + let nextDelay = IDLE_POLL_INTERVAL_MS; + try { + if (isModelRouteActive()) { + await fetchStatus(); + nextDelay = state.status.downloading ? ACTIVE_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS; + } + } finally { + pollingHandle = setTimeout(poll, nextDelay); + } + }; + + pollingHandle = setTimeout(poll, ACTIVE_POLL_INTERVAL_MS); +} + +async function setActiveModel(modelKey) { + const payload = await fetchJson("/api/params", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key: "Model", value: modelKey }), + }); + + notify(payload.message || `Selected "${modelKey}".`); +} + +async function startDownload(modelKey) { + const payload = await fetchJson("/api/models/download", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: modelKey }), + }); + + notify(payload.message || `Downloading "${modelKey}"...`); +} + +async function startDownloadAll() { + const payload = await fetchJson("/api/models/download_all", { method: "POST" }); + notify(payload.message || "Started downloading all models."); +} + +async function cancelDownload() { + const payload = await fetchJson("/api/models/cancel", { method: "POST" }); + notify(payload.message || "Cancellation requested."); +} + +async function deleteModel(modelKey) { + const payload = await fetchJson("/api/models/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: modelKey }), + }); + + notify(payload.message || `Deleted files for "${modelKey}".`); +} + +async function refreshManifest() { + const payload = await fetchJson("/api/models/refresh_manifest", { method: "POST" }); + notify(payload.message || "Model manifest refreshed."); +} + +async function runAction(action, modelKey = "") { + if (state.actionBusy) { + notify("Please wait for the current action to finish.", "error"); + return; + } + + state.actionBusy = true; + try { + if (action === "refresh") { + await refreshManifest(); + await refreshAll(false); + return; + } + + if (state.status.isOnroad && action !== "refresh") { + notify("Actions are blocked while onroad.", "error"); + return; + } + + if (action === "select") { + if (!modelKey) return; + await setActiveModel(modelKey); + } else if (action === "download") { + if (!modelKey) return; + await startDownload(modelKey); + } else if (action === "download-all") { + await startDownloadAll(); + } else if (action === "cancel") { + await cancelDownload(); + } else if (action === "delete") { + if (!modelKey) return; + const confirmed = window.confirm(`Delete local files for model \"${modelKey}\"?`); + if (!confirmed) return; + await deleteModel(modelKey); + } + + await fetchStatus(); + } catch (error) { + notify(error?.message || String(error), "error"); + } finally { + state.actionBusy = false; + } +} + +function bindDomHandlers() { + if (window.__modelManagerHandlersBound) return; + window.__modelManagerHandlersBound = true; + + document.addEventListener("click", event => { + if (!isModelRouteActive()) return; + + const target = event.target; + if (!(target instanceof Element)) return; + + const button = target.closest("[data-mm-action]"); + if (!button) return; + + const action = safeText(button.getAttribute("data-mm-action"), ""); + const modelKey = safeText(button.getAttribute("data-model"), ""); + + runAction(action, modelKey).catch(() => {}); + }); + + document.addEventListener("change", event => { + if (!isModelRouteActive()) return; + + const target = event.target; + if (!(target instanceof HTMLSelectElement)) return; + if (target.id === "mm-active-model-select") { + const modelKey = safeText(target.value, ""); + if (!modelKey) return; + runAction("select", modelKey).catch(() => {}); + return; + } + + if (target.id === "mm-sort-mode-select") { + const value = safeText(target.value, "alphabetical"); + state.sortMode = value === "release_date" ? "release_date" : "alphabetical"; + return; + } + + if (target.id === "mm-community-filter-select") { + const value = safeText(target.value, "all"); + if (value === "yes" || value === "no" || value === "all") { + state.communityFavoriteFilter = value; + } else { + state.communityFavoriteFilter = "all"; + } + } + }); +} + +function renderActions(model) { + const modelKey = safeText(model.value, ""); + const modelIsDownloading = state.status.downloading && !state.status.downloadAll && state.status.modelToDownload === modelKey; + + if (state.currentModel === modelKey) { + return html`Active`; + } + + if (state.status.downloading) { + if (state.status.downloadAll || modelIsDownloading) { + return html``; + } + return html`Busy`; + } + + if (model.installed) { + return html` + + + `; + } + + return html``; +} + +function renderModelRow(model) { + const label = safeText(model.label, safeText(model.value, "Unnamed")); + const key = safeText(model.value, ""); + + return html` +
+
+
+ ${label} +
+
+ ${key} + ${state.sortMode === "release_date" ? "" : model.series ? html`${safeText(model.series)}` : ""} + ${model.version ? html`Version ${safeText(model.version)}` : ""} + ${model.released ? html`Released ${safeText(model.released)}` : ""} + ${model.communityFavorite ? html`Community Favorite` : ""} + ${model.partial ? html`Partial Files` : ""} +
+
+
+ ${renderActions(model)} +
+
+ `; +} + +function renderSeriesSection(seriesName, models) { + return html` +
+
+

${seriesName}

+ ${models.length} +
+
+ ${models.map(model => renderModelRow(model))} +
+
+ `; +} + +export function ModelManager() { + if (!initialized) { + initialized = true; + bindDomHandlers(); + logDebug("Initializing component"); + refreshAll().catch(error => { + state.error = error?.message || String(error); + state.loading = false; + state.refreshing = false; + logDebug("Initial refresh failed", state.error); + }); + ensurePolling(); + } + + return html` +
+

Model Manager

+ + ${() => state.error ? html`
${state.error}
` : ""} + +
+ Available Models=${() => state.models.length} + Current Model=${() => getCurrentModelName()} +
+ +
+
+ ${state.summary.installed} installed + ${state.summary.missing} missing + ${state.summary.total} total +
+ +
+ ${() => state.status.downloading + ? html`` + : html``} + +
+
+ +
+ Current: ${getCurrentModelName()} + Progress: ${safeText(state.status.progress, "Idle")} + ${() => state.status.isOnroad ? html`Onroad: actions disabled` : ""} +
+ +
+ + + + + + +
+ + + +
+ + ${() => state.loading ? html`
Loading models...
` : ""} + + ${() => !state.loading ? html` +
+ ${(() => { + if (state.sortMode === "release_date") { + const models = getReleaseOrderedModels(); + return models.length === 0 + ? html`
No models available.
` + : models.map(model => renderModelRow(model)); + } + + const { grouped, seriesNames } = getSeriesGroups(); + return seriesNames.length === 0 + ? html`
No models available.
` + : seriesNames.map(seriesName => renderSeriesSection(seriesName, grouped[seriesName])); + })()} +
+ ` : ""} +
+ `; +} diff --git a/frogpilot/system/the_pond/assets/components/tools/tsk_manager.js b/frogpilot/system/the_pond/assets/components/tools/tsk_manager.js index 9324a2ca0..9ce77aedf 100644 --- a/frogpilot/system/the_pond/assets/components/tools/tsk_manager.js +++ b/frogpilot/system/the_pond/assets/components/tools/tsk_manager.js @@ -165,15 +165,16 @@ const api = { } } -let hasLoaded = false -if (!hasLoaded) { - hasLoaded = true - api.load() -} - -export function TSKManager() { - return html` -
+let hasLoaded = false + +export function TSKManager() { + if (!hasLoaded) { + hasLoaded = true + api.load() + } + + return html` +
Toyota Security Key Manager
diff --git a/frogpilot/system/the_pond/templates/index.html b/frogpilot/system/the_pond/templates/index.html index d4c5f9035..a731efdf2 100644 --- a/frogpilot/system/the_pond/templates/index.html +++ b/frogpilot/system/the_pond/templates/index.html @@ -7,7 +7,7 @@ - + @@ -28,6 +28,7 @@ + @@ -59,4 +60,4 @@
- \ No newline at end of file + diff --git a/frogpilot/system/the_pond/the_pond.py b/frogpilot/system/the_pond/the_pond.py index 0cfc294a5..04c4d81ec 100644 --- a/frogpilot/system/the_pond/the_pond.py +++ b/frogpilot/system/the_pond/the_pond.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timedelta, timezone -from flask import Flask, Response, jsonify, render_template, request, send_file, send_from_directory +from flask import Flask, Response, jsonify, make_response, render_template, request, send_file, send_from_directory from io import BytesIO from pathlib import Path from werkzeug.utils import secure_filename @@ -57,14 +57,69 @@ KEYS = { TMUX_LOGS_PATH = Path("/data/tmux_logs") +MODEL_DOWNLOAD_PARAM = "ModelToDownload" +MODEL_DOWNLOAD_ALL_PARAM = "DownloadAllModels" +MODEL_DOWNLOAD_PROGRESS_PARAM = "ModelDownloadProgress" +MODEL_CANCEL_DOWNLOAD_PARAM = "CancelModelDownload" +MODEL_SORT_MODE_PARAM = "ModelSortMode" +MODEL_USER_FAVORITES_PARAM = "UserFavorites" + +def read_legacy_param_file(key, default_value=""): + try: + value_path = Path(params.get_param_path(key)) + if value_path.is_file(): + return value_path.read_text(encoding="utf-8").strip() or default_value + except Exception: + pass + return default_value + +def write_legacy_param_file(key, value): + value_path = Path(params.get_param_path(key)) + value_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = value_path.with_name(f".tmp_{value_path.name}") + tmp_path.write_text(str(value), encoding="utf-8") + os.replace(tmp_path, value_path) + def setup(app): + model_status_debug = { + "last_signature": None, + "last_log_time": 0.0, + "last_empty_catalog_log_time": 0.0, + } + @app.errorhandler(404) def not_found(_): - return render_template("index.html") + response = make_response(render_template("index.html")) + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response @app.route("/", methods=["GET"]) def index(): - return render_template("index.html") + response = make_response(render_template("index.html")) + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response + + @app.route("/manifest.json", methods=["GET"]) + @app.route("/assets/manifest.json", methods=["GET"]) + def manifest(): + manifest_path = Path(app.static_folder) / "manifest.json" + if manifest_path.is_file(): + return send_file(str(manifest_path), mimetype="application/manifest+json") + + # Fallback so the browser doesn't keep logging noisy 404s. + return jsonify({ + "name": "Galaxy", + "short_name": "Galaxy", + "display": "standalone", + "start_url": "/", + "background_color": "#000000", + "theme_color": "#8b6cc5", + "icons": [], + }), 200 @app.route("/api/doors_available", methods=["GET"]) def doors_available(): @@ -408,32 +463,329 @@ def setup(app): @app.route("/api/models/installed", methods=["GET"]) def get_installed_models(): - """Returns only models with files present in /data/models/.""" - import os + catalog = get_model_catalog() + installed = [{"value": model["value"], "label": model["label"]} for model in catalog if model["installed"]] - available = (params.get("AvailableModels", encoding="utf-8") or "").split(",") - names = (params.get("AvailableModelNames", encoding="utf-8") or "").split(",") - models_dir = "/data/models" - - try: - on_disk = os.listdir(models_dir) if os.path.isdir(models_dir) else [] - except Exception: - on_disk = [] - - installed = [] - for i, key in enumerate(available): - if not key: - continue - if any(f.startswith(f"{key}.") or f.startswith(f"{key}_") for f in on_disk): - label = names[i] if i < len(names) else key - installed.append({"value": key, "label": label}) + # Keep current model selectable even if local files are currently inconsistent. + current_model = params.get("Model", encoding="utf-8") or "" + if current_model and all(model["value"] != current_model for model in installed): + for model in catalog: + if model["value"] == current_model: + installed.append({"value": model["value"], "label": model["label"]}) + break return jsonify(installed), 200 + @app.route("/api/models/catalog", methods=["GET"]) + def get_models_catalog(): + models = get_model_catalog() + return jsonify({ + "models": models, + "currentModel": params.get("Model", encoding="utf-8") or "", + "summary": { + "installed": sum(1 for model in models if model["installed"]), + "missing": sum(1 for model in models if not model["installed"]), + "total": len(models), + }, + }), 200 + + @app.route("/api/models/preferences", methods=["GET", "PUT"]) + def get_or_set_models_preferences(): + if request.method == "GET": + return jsonify({ + "sortMode": read_legacy_param_file(MODEL_SORT_MODE_PARAM, "alphabetical"), + "userFavorites": [entry for entry in (params.get(MODEL_USER_FAVORITES_PARAM, encoding="utf-8") or "").split(",") if entry], + }), 200 + + data = request.get_json() or {} + changed = [] + + if "sortMode" in data: + sort_mode = str(data.get("sortMode") or "alphabetical").strip() or "alphabetical" + write_legacy_param_file(MODEL_SORT_MODE_PARAM, sort_mode) + changed.append("sort mode") + + if "userFavorites" in data: + incoming = data.get("userFavorites") + if isinstance(incoming, list): + favorites = ",".join(entry.strip() for entry in incoming if str(entry).strip()) + else: + favorites = ",".join(entry.strip() for entry in str(incoming or "").split(",") if entry.strip()) + params.put(MODEL_USER_FAVORITES_PARAM, favorites) + changed.append("favorites") + + if not changed: + return jsonify({"error": "No preferences provided."}), 400 + + return jsonify({"message": f"Updated model {' and '.join(changed)}."}), 200 + + @app.route("/api/models/status", methods=["GET"]) + def get_models_status(): + models = get_model_catalog() + model_to_download = params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or "" + download_all = params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) + progress = params_memory.get(MODEL_DOWNLOAD_PROGRESS_PARAM, encoding="utf-8") or "" + cancelling = params_memory.get_bool(MODEL_CANCEL_DOWNLOAD_PARAM) + + downloading = bool(model_to_download) or download_all + current_model = params.get("Model", encoding="utf-8") or "" + sort_mode = read_legacy_param_file(MODEL_SORT_MODE_PARAM, "alphabetical") + terminal = progress in ("Downloaded!", "All models downloaded!") or bool(re.search(r"cancelled|exists|failed|offline|invalid|error", progress, re.IGNORECASE)) + summary = { + "installed": sum(1 for model in models if model["installed"]), + "missing": sum(1 for model in models if not model["installed"]), + "total": len(models), + } + + now = time.monotonic() + signature = ( + summary["total"], + summary["installed"], + summary["missing"], + model_to_download, + download_all, + downloading, + cancelling, + progress, + current_model, + sort_mode, + terminal, + bool(params.get_bool("IsOnroad")), + ) + if model_status_debug["last_signature"] != signature or now - model_status_debug["last_log_time"] >= 15: + print( + f"[ModelStatus] addr={request.remote_addr or 'unknown'} total={summary['total']} " + f"installed={summary['installed']} missing={summary['missing']} downloading={downloading} " + f"download_all={download_all} model='{model_to_download or '-'}' current='{current_model or '-'}' " + f"progress='{progress or 'Idle'}' cancelling={cancelling} onroad={params.get_bool('IsOnroad')} terminal={terminal}" + ) + model_status_debug["last_signature"] = signature + model_status_debug["last_log_time"] = now + + if summary["total"] == 0 and now - model_status_debug["last_empty_catalog_log_time"] >= 15: + available_models = params.get("AvailableModels", encoding="utf-8") or "" + available_names = params.get("AvailableModelNames", encoding="utf-8") or "" + available_models_count = len([item for item in available_models.split(",") if item.strip()]) + available_names_count = len([item for item in available_names.split(",") if item.strip()]) + print( + f"[ModelStatus] WARNING empty catalog available_models={available_models_count} " + f"available_names={available_names_count} raw_available_models='{available_models[:120]}'" + ) + model_status_debug["last_empty_catalog_log_time"] = now + + return jsonify({ + "modelToDownload": model_to_download, + "downloadAll": download_all, + "downloading": downloading, + "cancelling": cancelling, + "progress": progress, + "isOnroad": params.get_bool("IsOnroad"), + "terminal": terminal, + "models": models, + "currentModel": current_model, + "summary": summary, + "sortMode": sort_mode, + }), 200 + + @app.route("/api/models/refresh_manifest", methods=["POST"]) + def refresh_models_manifest(): + if params.get_bool("IsOnroad"): + return jsonify({"error": "Cannot refresh model manifest while driving."}), 403 + + if params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""): + return jsonify({"error": "Cannot refresh model manifest while a download is in progress."}), 409 + + try: + from openpilot.frogpilot.assets.model_manager import ModelManager + + manager = ModelManager() + manager.update_models(False) + except Exception as exception: + return jsonify({"error": f"Failed to refresh model manifest: {exception}"}), 500 + + return jsonify({"message": "Model manifest refreshed."}), 200 + + @app.route("/api/models/download", methods=["POST"]) + def start_model_download(): + if params.get_bool("IsOnroad"): + return jsonify({"error": "Cannot download models while driving."}), 403 + + if params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""): + return jsonify({"error": "A model download is already in progress."}), 409 + + data = request.get_json() or {} + model_key = (data.get("model") or "").strip() + if not model_key: + return jsonify({"error": "Missing model key."}), 400 + + catalog = {model["value"]: model for model in get_model_catalog()} + model = catalog.get(model_key) + if model is None: + return jsonify({"error": f"Unknown model '{model_key}'."}), 404 + + if model["installed"]: + return jsonify({"message": f"\"{model['label']}\" is already installed."}), 200 + + params_memory.remove(MODEL_CANCEL_DOWNLOAD_PARAM) + params_memory.remove(MODEL_DOWNLOAD_ALL_PARAM) + params_memory.put(MODEL_DOWNLOAD_PARAM, model_key) + params_memory.put(MODEL_DOWNLOAD_PROGRESS_PARAM, "Downloading...") + + return jsonify({"message": f"Started downloading \"{model['label']}\"."}), 200 + + @app.route("/api/models/download_all", methods=["POST"]) + def start_models_download_all(): + if params.get_bool("IsOnroad"): + return jsonify({"error": "Cannot download models while driving."}), 403 + + if params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""): + return jsonify({"error": "A model download is already in progress."}), 409 + + missing_models = [model for model in get_model_catalog() if not model["installed"]] + if not missing_models: + return jsonify({"message": "All models are already installed."}), 200 + + params_memory.remove(MODEL_CANCEL_DOWNLOAD_PARAM) + params_memory.remove(MODEL_DOWNLOAD_PARAM) + params_memory.put_bool(MODEL_DOWNLOAD_ALL_PARAM, True) + params_memory.put(MODEL_DOWNLOAD_PROGRESS_PARAM, "Downloading...") + + return jsonify({"message": f"Started downloading {len(missing_models)} model(s)."}), 200 + + @app.route("/api/models/cancel", methods=["POST"]) + def cancel_model_download(): + model_to_download = params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or "" + download_all = params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) + if not model_to_download and not download_all: + return jsonify({"message": "No active model download to cancel."}), 200 + + params_memory.put_bool(MODEL_CANCEL_DOWNLOAD_PARAM, True) + return jsonify({"message": "Cancellation requested."}), 200 + + @app.route("/api/models/delete", methods=["POST"]) + def delete_model_files(): + if params.get_bool("IsOnroad"): + return jsonify({"error": "Cannot delete model files while driving."}), 403 + + if params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""): + return jsonify({"error": "Cannot delete model files while a download is in progress."}), 409 + + data = request.get_json() or {} + model_key = (data.get("model") or "").strip() + if not model_key: + return jsonify({"error": "Missing model key."}), 400 + + current_model = params.get("Model", encoding="utf-8") or "" + if model_key == current_model: + return jsonify({"error": "Cannot delete the currently active model."}), 409 + + catalog = {model["value"]: model for model in get_model_catalog()} + model = catalog.get(model_key) + if model is None: + return jsonify({"error": f"Unknown model '{model_key}'."}), 404 + + models_dir = Path("/data/models") + if not models_dir.is_dir(): + return jsonify({"message": "No model directory exists yet."}), 200 + + deleted = [] + for item in models_dir.iterdir(): + name = item.name + is_match = ( + name == f"{model_key}.thneed" or + name == f"{model_key}.pkl" or + name.startswith(f"{model_key}_") + ) + + if not is_match: + continue + + try: + if item.is_dir(): + shutil.rmtree(item) + else: + item.unlink(missing_ok=True) + deleted.append(name) + except Exception as exception: + return jsonify({"error": f"Failed deleting '{name}': {exception}"}), 500 + + if not deleted: + return jsonify({"message": f"No files found for \"{model['label']}\"."}), 200 + + return jsonify({"message": f"Deleted {len(deleted)} file(s) for \"{model['label']}\"."}), 200 + @app.route("/api/params_memory", methods=["GET"]) def get_param_memory(): return params_memory.get(request.args.get("key")) or "", 200 + def is_model_installed(model_key, model_version, on_disk_files): + if f"{model_key}.thneed" in on_disk_files: + return True + + if model_version in ("v8", "v9", "v10", "v11", "v12"): + required_files = { + f"{model_key}_driving_policy_tinygrad.pkl", + f"{model_key}_driving_vision_tinygrad.pkl", + f"{model_key}_driving_policy_metadata.pkl", + f"{model_key}_driving_vision_metadata.pkl", + } + if model_version == "v12": + required_files |= { + f"{model_key}_driving_off_policy_tinygrad.pkl", + f"{model_key}_driving_off_policy_metadata.pkl", + } + return required_files.issubset(on_disk_files) + + if model_version == "v7": + return f"{model_key}.pkl" in on_disk_files + + # Fallback for unknown versions + return any(file.startswith(f"{model_key}.") or file.startswith(f"{model_key}_") for file in on_disk_files) + + def get_model_catalog(): + available = [model.strip() for model in (params.get("AvailableModels", encoding="utf-8") or "").split(",")] + names = [name.strip() for name in (params.get("AvailableModelNames", encoding="utf-8") or "").split(",")] + series = [entry.strip() for entry in (params.get("AvailableModelSeries", encoding="utf-8") or "").split(",")] + versions = [entry.strip() for entry in (params.get("ModelVersions", encoding="utf-8") or "").split(",")] + released_dates = [entry.strip() for entry in (params.get("ModelReleasedDates", encoding="utf-8") or "").split(",")] + + community_favorites = {entry.strip() for entry in (params.get("CommunityFavorites", encoding="utf-8") or "").split(",") if entry.strip()} + user_favorites = {entry.strip() for entry in (params.get(MODEL_USER_FAVORITES_PARAM, encoding="utf-8") or "").split(",") if entry.strip()} + + models_dir = "/data/models" + try: + on_disk_files = set(os.listdir(models_dir)) if os.path.isdir(models_dir) else set() + except Exception: + on_disk_files = set() + + models = [] + for i, key in enumerate(available): + if not key: + continue + + label = names[i] if i < len(names) and names[i] else key + model_version = versions[i] if i < len(versions) else "" + model_series = series[i] if i < len(series) and series[i] else "Custom Series" + released = released_dates[i] if i < len(released_dates) else "" + + installed = is_model_installed(key, model_version, on_disk_files) + partial = not installed and any(file.startswith(f"{key}.") or file.startswith(f"{key}_") for file in on_disk_files) + + models.append({ + "value": key, + "label": label, + "series": model_series, + "version": model_version, + "released": released, + "installed": installed, + "partial": partial, + "communityFavorite": key in community_favorites, + "userFavorite": key in user_favorites, + }) + + models.sort(key=lambda model: (model["series"].lower(), model["label"].lower())) + return models + @app.route("/api/routes", methods=["GET"]) def list_routes(): def generate():