mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-06-29 10:32:10 +08:00
Model Manager/Galaxy
This commit is contained in:
@@ -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`
|
||||
<div>
|
||||
|
||||
@@ -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)}
|
||||
<div class="content">
|
||||
${() => {
|
||||
if (!routerState.initialized || routerState.navigation.state === "loading") {
|
||||
return html`<div>Loading...</div>`
|
||||
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 })
|
||||
}}
|
||||
</div>
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`<span class="mm-chip mm-chip-active">Active</span>`;
|
||||
}
|
||||
|
||||
if (state.status.downloading) {
|
||||
if (state.status.downloadAll || modelIsDownloading) {
|
||||
return html`<button class="mm-btn mm-btn-danger" data-mm-action="cancel">Cancel</button>`;
|
||||
}
|
||||
return html`<span class="mm-chip">Busy</span>`;
|
||||
}
|
||||
|
||||
if (model.installed) {
|
||||
return html`
|
||||
<button class="mm-btn mm-btn-secondary" data-mm-action="select" data-model="${modelKey}">Set Active</button>
|
||||
<button class="mm-btn mm-btn-danger" data-mm-action="delete" data-model="${modelKey}">Delete</button>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`<button class="mm-btn mm-btn-primary" data-mm-action="download" data-model="${modelKey}">Download</button>`;
|
||||
}
|
||||
|
||||
function renderModelRow(model) {
|
||||
const label = safeText(model.label, safeText(model.value, "Unnamed"));
|
||||
const key = safeText(model.value, "");
|
||||
|
||||
return html`
|
||||
<div class="mm-row">
|
||||
<div class="mm-row-main">
|
||||
<div class="mm-row-title">
|
||||
<span>${label}</span>
|
||||
</div>
|
||||
<div class="mm-row-meta">
|
||||
<span class="mm-chip">${key}</span>
|
||||
${state.sortMode === "release_date" ? "" : model.series ? html`<span class="mm-chip">${safeText(model.series)}</span>` : ""}
|
||||
${model.version ? html`<span class="mm-chip">Version ${safeText(model.version)}</span>` : ""}
|
||||
${model.released ? html`<span class="mm-chip">Released ${safeText(model.released)}</span>` : ""}
|
||||
${model.communityFavorite ? html`<span class="mm-chip mm-chip-favorite">Community Favorite</span>` : ""}
|
||||
${model.partial ? html`<span class="mm-chip mm-chip-warning">Partial Files</span>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mm-row-actions">
|
||||
${renderActions(model)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSeriesSection(seriesName, models) {
|
||||
return html`
|
||||
<section class="mm-series">
|
||||
<header class="mm-series-header">
|
||||
<h3>${seriesName}</h3>
|
||||
<span>${models.length}</span>
|
||||
</header>
|
||||
<div class="mm-series-body">
|
||||
${models.map(model => renderModelRow(model))}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div class="mm-wrapper">
|
||||
<h2>Model Manager</h2>
|
||||
|
||||
${() => state.error ? html`<div class="mm-error">${state.error}</div>` : ""}
|
||||
|
||||
<div class="mm-debug">
|
||||
Available Models=${() => state.models.length}
|
||||
Current Model=${() => getCurrentModelName()}
|
||||
</div>
|
||||
|
||||
<div class="mm-toolbar">
|
||||
<div class="mm-summary">
|
||||
<span><b>${state.summary.installed}</b> installed</span>
|
||||
<span><b>${state.summary.missing}</b> missing</span>
|
||||
<span><b>${state.summary.total}</b> total</span>
|
||||
</div>
|
||||
|
||||
<div class="mm-actions">
|
||||
${() => state.status.downloading
|
||||
? html`<button class="mm-btn mm-btn-danger" data-mm-action="cancel">Cancel Download</button>`
|
||||
: html`<button class="mm-btn mm-btn-primary" data-mm-action="download-all">Download All Missing</button>`}
|
||||
<button class="mm-btn mm-btn-secondary" data-mm-action="refresh">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mm-status">
|
||||
<span class="mm-chip">Current: ${getCurrentModelName()}</span>
|
||||
<span class="mm-chip">Progress: ${safeText(state.status.progress, "Idle")}</span>
|
||||
${() => state.status.isOnroad ? html`<span class="mm-chip mm-chip-warning">Onroad: actions disabled</span>` : ""}
|
||||
</div>
|
||||
|
||||
<div class="mm-filters">
|
||||
<label class="mm-filter-label" for="mm-active-model-select">Active Model</label>
|
||||
<select class="mm-select" id="mm-active-model-select">
|
||||
${(() => {
|
||||
const orderedInstalled = getInstalledModels().sort((a, b) => {
|
||||
const aCurrent = safeText(a.value) === state.currentModel ? 0 : 1;
|
||||
const bCurrent = safeText(b.value) === state.currentModel ? 0 : 1;
|
||||
if (aCurrent !== bCurrent) return aCurrent - bCurrent;
|
||||
return safeText(a.label, a.value).localeCompare(safeText(b.label, b.value), undefined, { sensitivity: "base" });
|
||||
});
|
||||
|
||||
return orderedInstalled.length > 0
|
||||
? orderedInstalled.map(model => html`
|
||||
<option value="${safeText(model.value)}" ${safeText(model.value) === state.currentModel ? "selected" : ""}>
|
||||
${safeText(model.label, model.value)}
|
||||
</option>
|
||||
`)
|
||||
: html`<option value="">No installed models</option>`;
|
||||
})()}
|
||||
</select>
|
||||
|
||||
<label class="mm-filter-label" for="mm-sort-mode-select">Sort</label>
|
||||
<select class="mm-select" id="mm-sort-mode-select">
|
||||
<option value="alphabetical" ${state.sortMode === "alphabetical" ? "selected" : ""}>Alphabetical</option>
|
||||
<option value="release_date" ${state.sortMode === "release_date" ? "selected" : ""}>Release Date</option>
|
||||
</select>
|
||||
|
||||
<div class="mm-filter-break"></div>
|
||||
|
||||
<label class="mm-filter-label" for="mm-community-filter-select">Community Favorite</label>
|
||||
<select class="mm-select" id="mm-community-filter-select">
|
||||
<option value="all" ${state.communityFavoriteFilter === "all" ? "selected" : ""}>All</option>
|
||||
<option value="yes" ${state.communityFavoriteFilter === "yes" ? "selected" : ""}>Yes</option>
|
||||
<option value="no" ${state.communityFavoriteFilter === "no" ? "selected" : ""}>No</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
${() => state.loading ? html`<div class="mm-empty">Loading models...</div>` : ""}
|
||||
|
||||
${() => !state.loading ? html`
|
||||
<div class="mm-list">
|
||||
${(() => {
|
||||
if (state.sortMode === "release_date") {
|
||||
const models = getReleaseOrderedModels();
|
||||
return models.length === 0
|
||||
? html`<div class="mm-empty">No models available.</div>`
|
||||
: models.map(model => renderModelRow(model));
|
||||
}
|
||||
|
||||
const { grouped, seriesNames } = getSeriesGroups();
|
||||
return seriesNames.length === 0
|
||||
? html`<div class="mm-empty">No models available.</div>`
|
||||
: seriesNames.map(seriesName => renderSeriesSection(seriesName, grouped[seriesName]));
|
||||
})()}
|
||||
</div>
|
||||
` : ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -165,15 +165,16 @@ const api = {
|
||||
}
|
||||
}
|
||||
|
||||
let hasLoaded = false
|
||||
if (!hasLoaded) {
|
||||
hasLoaded = true
|
||||
api.load()
|
||||
}
|
||||
|
||||
export function TSKManager() {
|
||||
return html`
|
||||
<div class="tskkeys-wrapper tskkeys-offset-top">
|
||||
let hasLoaded = false
|
||||
|
||||
export function TSKManager() {
|
||||
if (!hasLoaded) {
|
||||
hasLoaded = true
|
||||
api.load()
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="tskkeys-wrapper tskkeys-offset-top">
|
||||
<div class="tskkeys-container">
|
||||
<div class="tskkeys-title">Toyota Security Key Manager</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/images/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/images/favicon-16x16.png">
|
||||
<link rel="manifest" href="/assets/manifest.json">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="shortcut icon" href="/assets/images/favicon.ico">
|
||||
|
||||
<meta name="theme-color" content="#8b6cc5" />
|
||||
@@ -28,6 +28,7 @@
|
||||
<link rel="stylesheet" href="/assets/components/tailscale/tailscale.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/doors.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/error_logs.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/model_manager.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/speed_limits.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/theme_maker.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/tmux.css">
|
||||
@@ -59,4 +60,4 @@
|
||||
<div id="snackbar_wrapper"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user