diff --git a/common/params_keys.h b/common/params_keys.h index 1ea83d10b..80805471b 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -312,6 +312,7 @@ inline static std::unordered_map keys = { {"ForceStandstill", {PERSISTENT, BOOL, "0", "0", 2}}, {"ForceTorqueController", {PERSISTENT, BOOL, "0", "0", 3}}, {"FPSCounter", {PERSISTENT, BOOL, "1", "0", 3}}, + {"GalaxyDashboardStats", {PERSISTENT | DONT_LOG, JSON, "{}", "{}"}}, {"StarPilotApiToken", {PERSISTENT | DONT_LOG, STRING, "", "", 0}}, {"StarPilotCarParams", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BYTES, "", ""}}, {"StarPilotCarParamsPersistent", {PERSISTENT, BYTES, "", ""}}, diff --git a/starpilot/system/the_pond/assets/components/home/home.css b/starpilot/system/the_pond/assets/components/home/home.css index 9ab6684fb..cbd642556 100644 --- a/starpilot/system/the_pond/assets/components/home/home.css +++ b/starpilot/system/the_pond/assets/components/home/home.css @@ -1,84 +1,698 @@ -.disk .bar { - background-color: var(--main-bg); - height: 100%; - position: absolute; - right: 0; -} - -.disk .progress { - background: linear-gradient(to right, #5ec8c8 0%, #8b6cc5 60%, #e05577 85%, #c04466 100%); - border-radius: var(--border-radius-md); - height: var(--padding-base); - overflow: hidden; - position: relative; -} - -.diskUsage, -.drivingStats, -.softwareInfo { - background-color: var(--secondary-bg); - border-radius: var(--border-radius-md); - color: var(--text-color); - max-width: 80%; - padding: var(--border-radius-xl); -} - -.diskUsage .disk h4, -.diskUsage .disk p, -.drivingStat div p { - margin: 0; -} - -.diskUsage p, -.softwareGrid p { - font-size: var(--font-size-sm); - margin: 0; -} - -.softwareGrid p { - min-width: 0; - overflow-wrap: anywhere; - word-break: break-word; -} - -.drivingStat { +.dashboard { + --dashboard-accent: #8b6cc5; + --dashboard-accent-2: #5ec8c8; + --dashboard-border: rgba(139, 108, 197, 0.28); + --dashboard-card: #0e1020; + --dashboard-card-2: #111525; + --dashboard-muted: #c4bfdc; + --dashboard-strong: #faf8ff; + --dashboard-track: rgba(139, 108, 197, 0.16); + box-sizing: border-box; + color: var(--dashboard-strong); display: grid; - grid-template-columns: 1fr 1fr 1fr; - margin-bottom: var(--border-radius-xl); + gap: 1rem; + margin: 0 auto; + max-width: 1280px; + min-width: 0; + padding: 2rem 2rem 3rem 0.5rem; + width: 100%; } -.drivingStat div p:first-of-type { - font-size: var(--font-size-xl); +.dashboard *, +.dashboard *::before, +.dashboard *::after { + box-sizing: border-box; } -.drivingStat h2 { - font-size: var(--font-size-base); - grid-column: span 3; +.dashboard h1, +.dashboard h2, +.dashboard h3, +.dashboard p { + margin: 0; padding: 0; } -.drivingStat:last-of-type h2 { - color: var(--success-fg); +.dashboard h1 { + color: var(--dashboard-strong); + font-size: 1.9rem; + line-height: 1.1; } -.softwareGrid { +.dashboard h2 { + color: var(--dashboard-strong); + font-size: 1.1rem; + line-height: 1.25; +} + +.dashboard h3 { + color: var(--dashboard-muted); + font-size: 0.9rem; + line-height: 1.25; +} + +.dashboard-card { + background: linear-gradient(180deg, var(--dashboard-card), var(--dashboard-card-2)); + border: 1px solid var(--dashboard-border); + border-radius: 8px; + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.18); + color: var(--dashboard-strong); + min-width: 0; + overflow: hidden; + padding: 1.1rem; +} + +.dashboard-header { + align-items: center; + display: flex; + gap: 1rem; + justify-content: space-between; + min-width: 0; +} + +.dashboard-header p { + align-items: center; + color: var(--dashboard-muted); + display: flex; + flex-wrap: wrap; + font-size: 0.9rem; + gap: 0.35rem; + margin-top: 0.55rem; + min-width: 0; +} + +.dashboard-header strong, +.dashboard-status-dot, +.dashboard-card-kicker, +.dashboard-drive-metrics strong:last-child, +.dashboard-key-values strong, +.dashboard-engaged-cell strong { + color: var(--dashboard-accent); +} + +.dashboard-status-dot, +.dashboard-card-kicker span, +.dashboard-section-label span { + background: var(--dashboard-accent); + border-radius: 50%; + display: inline-block; + flex: 0 0 auto; + height: 0.5rem; + width: 0.5rem; +} + +.dashboard-refresh { + align-items: center; + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--dashboard-border); + border-radius: 8px; + color: var(--dashboard-strong); + display: inline-flex; + flex: 0 0 auto; + font: inherit; + gap: 0.45rem; + justify-content: center; + min-height: 2.45rem; + padding: 0 0.9rem; +} + +.dashboard-refresh:hover { + background: rgba(139, 108, 197, 0.16); + border-color: rgba(139, 108, 197, 0.42); +} + +.dashboard-last-drive { display: grid; - gap: 0.5em 1em; - grid-template-columns: repeat(3, 1fr); + gap: 0.9rem; + min-height: 150px; } -.diskUsage:hover, -.drivingStats:hover, -.softwareInfo:hover { - box-shadow: var(--shadow-md); - transform: var(--hover-scale-sm); - transition: box-shadow var(--transition-fast), transform var(--transition-fast); +.dashboard-card-kicker, +.dashboard-section-label { + align-items: center; + color: var(--dashboard-muted); + display: inline-flex; + font-size: 0.86rem; + font-weight: var(--font-weight-bold); + gap: 0.45rem; } -@media only screen and (max-width: 768px) and (orientation: portrait) { - .softwareGrid { - display: grid; - gap: 0.5em 1em; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); +.dashboard-card-kicker { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 999px; + padding: 0.35rem 0.65rem; + width: fit-content; +} + +.dashboard-drive-date, +.dashboard-muted, +.dashboard-empty, +.dashboard-software-list span, +.dashboard-key-values span, +.dashboard-record-row p, +.dashboard-record-row small, +.dashboard-model-row small, +.dashboard-drive-main span, +.dashboard-drive-details, +.dashboard-attention { + color: var(--dashboard-muted); +} + +.dashboard-drive-metrics, +.dashboard-stat-row, +.dashboard-week-metrics { + display: grid; + gap: 1rem; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.dashboard-stat-row { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.dashboard-drive-metrics div, +.dashboard-stat-row div, +.dashboard-week-metrics div { + min-width: 0; +} + +.dashboard-drive-metrics strong, +.dashboard-stat-row strong, +.dashboard-week-metrics strong { + color: var(--dashboard-strong); + display: block; + font-size: 1.18rem; + line-height: 1.1; + overflow-wrap: normal; + word-break: keep-all; +} + +.dashboard-stat-row strong { + font-size: 1.08rem; +} + +.dashboard-drive-metrics span, +.dashboard-stat-row span, +.dashboard-week-metrics span { + color: var(--dashboard-muted); + display: block; + font-size: 0.82rem; + margin-top: 0.28rem; +} + +.dashboard-drive-footer { + display: flex; + flex-wrap: wrap; + gap: 0.55rem 1rem; + min-width: 0; +} + +.dashboard-drive-footer span { + align-items: center; + color: var(--dashboard-muted); + display: inline-flex; + font-size: 0.85rem; + gap: 0.4rem; + min-width: 0; + overflow-wrap: anywhere; +} + +.dashboard-section-label { + margin-top: 0.4rem; +} + +.dashboard-summary-grid, +.dashboard-device-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.dashboard-device-grid { + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); +} + +.dashboard-summary-card h2 { + margin-bottom: 1rem; +} + +.dashboard-two-column { + display: grid; + gap: 1rem; + grid-template-columns: minmax(0, 1.4fr) minmax(320px, 1fr); +} + +.dashboard-model-storage { + grid-template-columns: minmax(320px, 1fr) minmax(0, 1.4fr); +} + +.dashboard-week { + display: grid; + gap: 1.2rem; +} + +.dashboard-week-top, +.dashboard-model-layout { + align-items: center; + display: flex; + gap: 1.4rem; + min-width: 0; +} + +.dashboard-donut, +.dashboard-favorite-donut { + align-items: center; + aspect-ratio: 1; + border-radius: 50%; + display: grid; + flex: 0 0 auto; + justify-items: center; + position: relative; +} + +.dashboard-donut { + --value: 0; + background: conic-gradient(var(--dashboard-accent) calc(var(--value) * 1%), var(--dashboard-track) 0); + height: 116px; + width: 116px; +} + +.dashboard-donut::after, +.dashboard-favorite-donut::after { + background: var(--dashboard-card); + border-radius: 50%; + content: ""; + inset: 10px; + position: absolute; +} + +.dashboard-donut strong, +.dashboard-donut span { + position: relative; + z-index: 1; +} + +.dashboard-donut strong { + color: var(--dashboard-strong); + font-size: 1.45rem; + line-height: 1; +} + +.dashboard-donut span { + color: var(--dashboard-muted); + font-size: 0.68rem; +} + +.dashboard-week-metrics { + flex: 1 1 auto; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.dashboard-bars { + align-items: end; + display: grid; + gap: 0.7rem; + grid-template-columns: repeat(7, minmax(0, 1fr)); + height: 120px; + min-width: 0; +} + +.dashboard-day { + align-items: center; + display: grid; + gap: 0.5rem; + grid-template-rows: 1fr auto; + height: 100%; + justify-items: center; + min-width: 0; +} + +.dashboard-day-bar { + align-self: end; + background: linear-gradient(180deg, var(--dashboard-accent), var(--dashboard-accent-2)); + border-radius: 4px 4px 0 0; + min-height: 4px; + width: min(100%, 54px); +} + +.dashboard-day span { + color: var(--dashboard-muted); + font-size: 0.74rem; + min-width: 0; +} + +.dashboard-records { + display: grid; + gap: 0; +} + +.dashboard-records h2 { + margin-bottom: 1rem; +} + +.dashboard-record-row { + align-items: center; + border-top: 1px solid rgba(255, 255, 255, 0.08); + display: grid; + gap: 0.8rem; + grid-template-columns: auto minmax(0, 1fr); + min-width: 0; + padding: 0.85rem 0; +} + +.dashboard-record-row:first-of-type { + border-top: 0; +} + +.dashboard-record-row > span { + align-items: center; + background: rgba(139, 108, 197, 0.16); + border-radius: 8px; + color: var(--dashboard-accent); + display: inline-flex; + height: 2.1rem; + justify-content: center; + width: 2.1rem; +} + +.dashboard-record-row strong { + color: var(--dashboard-strong); + display: inline; + overflow-wrap: anywhere; +} + +.dashboard-record-row small { + overflow-wrap: anywhere; +} + +.dashboard-recent { + padding-bottom: 0.65rem; +} + +.dashboard-recent h2 { + margin-bottom: 0.85rem; +} + +.dashboard-drive-row { + align-items: center; + border-top: 1px solid rgba(255, 255, 255, 0.08); + display: grid; + gap: 1rem; + grid-template-columns: minmax(170px, 1.3fr) minmax(230px, 1fr) minmax(210px, 1fr) minmax(180px, 0.9fr); + min-width: 0; + padding: 0.9rem 0; +} + +.dashboard-drive-row:first-of-type { + border-top: 0; +} + +.dashboard-drive-main, +.dashboard-drive-details, +.dashboard-attention, +.dashboard-engaged-cell { + min-width: 0; +} + +.dashboard-drive-main strong, +.dashboard-drive-main span { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-drive-main strong { + color: var(--dashboard-strong); + font-size: 0.95rem; +} + +.dashboard-drive-main span { + font-size: 0.82rem; + margin-top: 0.25rem; +} + +.dashboard-drive-details, +.dashboard-attention { + display: flex; + flex-wrap: wrap; + font-size: 0.82rem; + gap: 0.45rem 0.8rem; +} + +.dashboard-engaged-cell { + align-items: center; + display: grid; + gap: 0.55rem; + grid-template-columns: 1fr auto; +} + +.dashboard-mini-bar, +.dashboard-storage-track { + background: var(--dashboard-track); + border-radius: 999px; + height: 0.45rem; + overflow: hidden; + width: 100%; +} + +.dashboard-mini-bar span, +.dashboard-storage-track span { + background: linear-gradient(90deg, var(--dashboard-accent), var(--dashboard-accent-2)); + border-radius: inherit; + display: block; + height: 100%; +} + +.dashboard-model-layout { + align-items: flex-start; +} + +.dashboard-favorite-donut { + background: conic-gradient(var(--dashboard-track) 0 100%); + height: 132px; + width: 132px; +} + +.dashboard-model-list { + display: grid; + flex: 1 1 auto; + gap: 0.75rem; + min-width: 0; +} + +.dashboard-model-row { + align-items: center; + display: grid; + gap: 0.55rem; + grid-template-columns: auto minmax(0, 1fr); + min-width: 0; +} + +.dashboard-model-row strong, +.dashboard-model-row small { + display: block; + min-width: 0; + overflow-wrap: anywhere; +} + +.dashboard-swatch { + border-radius: 50%; + display: inline-block; + height: 0.7rem; + width: 0.7rem; +} + +.dashboard-device-card { + display: grid; + gap: 1rem; +} + +.dashboard-storage-track { + border-radius: 4px; + height: 0.72rem; +} + +.dashboard-key-values, +.dashboard-software-list { + display: grid; +} + +.dashboard-key-values div, +.dashboard-software-list div { + align-items: center; + border-top: 1px solid rgba(255, 255, 255, 0.08); + display: grid; + gap: 0.8rem; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + min-width: 0; + padding: 0.85rem 0; +} + +.dashboard-key-values div:first-child, +.dashboard-software-list div:first-child { + border-top: 0; +} + +.dashboard-key-values strong, +.dashboard-software-list strong { + color: var(--dashboard-strong); + min-width: 0; + overflow-wrap: anywhere; + text-align: right; +} + +.dashboard-software-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 1.4rem; +} + +.dashboard-software-list div { + align-items: start; + gap: 0.35rem; + grid-template-columns: 1fr; +} + +.dashboard-software-list strong { + overflow-wrap: anywhere; + text-align: left; + word-break: break-word; +} + +.dashboard-software-list a { + color: inherit; + text-decoration-color: rgba(139, 108, 197, 0.65); + text-underline-offset: 0.16em; +} + +.dashboard-software-list a:hover { + color: var(--dashboard-accent-2); +} + +.dashboard-empty, +.dashboard-loading, +.dashboard-error { + color: var(--dashboard-muted); + padding: 1rem 0; +} + +.dashboard-error { + color: var(--danger-fg); +} + +.dashboard-narrow { + max-width: 780px; +} + +@media only screen and (max-width: 1180px) { + .dashboard { + padding-right: 1rem; + } + + .dashboard-drive-row { + grid-template-columns: minmax(170px, 1fr) minmax(210px, 1fr); + } +} + +@media only screen and (max-width: 900px) { + .dashboard-summary-grid, + .dashboard-two-column, + .dashboard-model-storage, + .dashboard-device-grid { + grid-template-columns: 1fr; + } + + .dashboard-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media only screen and (max-width: 768px) { + .dashboard { + gap: 0.85rem; + padding: 1.25rem 0 2rem; + } + + .dashboard-header { + align-items: flex-start; + } + + .dashboard h1 { + font-size: 1.55rem; + } + + .dashboard-card { + padding: 1rem; + } + + .dashboard-summary-grid, + .dashboard-drive-metrics, + .dashboard-stat-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .dashboard-drive-row { + align-items: stretch; + background: rgba(255, 255, 255, 0.025); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + grid-template-columns: 1fr; + margin-bottom: 0.65rem; + padding: 0.85rem; + } + + .dashboard-drive-row:first-of-type { + border-top: 1px solid rgba(255, 255, 255, 0.08); + } + + .dashboard-drive-main strong, + .dashboard-drive-main span { + white-space: normal; + } +} + +@media only screen and (max-width: 560px) { + .dashboard-header, + .dashboard-week-top, + .dashboard-model-layout { + align-items: stretch; + flex-direction: column; + } + + .dashboard-donut, + .dashboard-favorite-donut { + align-self: center; + } + + .dashboard-refresh { + width: 100%; + } + + .dashboard-drive-metrics, + .dashboard-stat-row, + .dashboard-week-metrics, + .dashboard-summary-grid, + .dashboard-software-list { + grid-template-columns: 1fr; + } + + .dashboard-bars { + gap: 0.35rem; + } + + .dashboard-day-bar { + width: min(100%, 36px); + } + + .dashboard-key-values div, + .dashboard-software-list div, + .dashboard-engaged-cell { + grid-template-columns: 1fr; + } + + .dashboard-key-values strong, + .dashboard-software-list strong { + text-align: left; } } diff --git a/starpilot/system/the_pond/assets/components/home/home.js b/starpilot/system/the_pond/assets/components/home/home.js index e3ba7cbec..46224a701 100644 --- a/starpilot/system/the_pond/assets/components/home/home.js +++ b/starpilot/system/the_pond/assets/components/home/home.js @@ -1,13 +1,16 @@ import { html } from "/assets/vendor/arrow-core.js"; const HOME_STATE = { - status: "loading", // loading | ready | error + status: "loading", data: null, unit: "miles", error: "", initialized: false, }; +const FAVORITE_COLORS = ["#5ec8c8", "#8b6cc5", "#d4a060", "#e05577", "#6cc56e", "#8aa3ff"]; +const TOP_MODEL_LIMIT = 3; + function withTimeout(promise, timeoutMs, label) { return new Promise((resolve, reject) => { const timerId = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs); @@ -21,101 +24,443 @@ function withTimeout(promise, timeoutMs, label) { }); } -function formatInt(value) { - const n = Number(value || 0); - return Number.isFinite(n) ? n.toLocaleString("en-US", { maximumFractionDigits: 0 }) : "0"; +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); } -function renderDiskUsageSection(state) { - const { data } = state; +function safeUrl(value) { + const text = String(value ?? "").trim(); + return text.startsWith("https://github.com/") ? text : ""; +} + +function numberValue(value, fallback = 0) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +function formatInt(value) { + return Math.round(numberValue(value)).toLocaleString("en-US", { maximumFractionDigits: 0 }); +} + +function formatOneDecimal(value) { + return numberValue(value).toLocaleString("en-US", { maximumFractionDigits: 1 }); +} + +function formatPercent(value) { + return `${Math.max(0, Math.min(100, Math.round(numberValue(value))))}%`; +} + +function formatDuration(seconds) { + const total = Math.max(0, Math.round(numberValue(seconds))); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +} + +function formatDate(value) { + if (!value) return "No drives yet"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +function formatBytes(bytes) { + const value = Math.max(0, numberValue(bytes)); + if (value >= 2 ** 30) return `${formatOneDecimal(value / (2 ** 30))} GB`; + if (value >= 2 ** 20) return `${formatOneDecimal(value / (2 ** 20))} MB`; + return `${formatInt(value / 1024)} KB`; +} + +function statBlock(title, stats = {}, unit) { + return ` +
+

${escapeHtml(title)}

+
+
${formatInt(stats.drives)}drives
+
${formatOneDecimal(stats.distance)}${escapeHtml(stats.unit || unit)}
+
${formatOneDecimal(stats.hours)}hours
+
+
+ `; +} + +function fallbackDashboard(data, unit) { + const disk = Array.isArray(data?.diskUsage) ? data.diskUsage[0] : {}; + const usedText = String(disk?.used || "0 GB"); + const sizeText = String(disk?.size || "0 GB"); + return { + lastDrive: { + date: "", + distance: 0, + duration: 0, + avgSpeed: 0, + engagedPercent: 0, + model: "Unknown model", + segmentCount: 0, + distractedMoments: 0, + unresponsiveMoments: 0, + distanceUnit: unit, + speedUnit: unit === "kilometers" ? "kph" : "mph", + }, + recentDrives: [], + week: { + distance: 0, + duration: 0, + hours: 0, + drives: 0, + engagedPercent: 0, + dailyDistance: [], + distanceUnit: unit, + }, + records: { + longestDrive: { value: "0", detail: unit }, + mostEngagedDay: { value: "0%", detail: "No drives" }, + bestWeek: { value: "0", detail: unit }, + highestStreak: { value: "0 days", detail: "No drives" }, + longestUndistractedDrive: { value: "0.0 hours", detail: "No clean drives" }, + cleanDriveStreak: { value: "0 drives", detail: "No clean drives" }, + }, + device: { status: "Parked", online: true, uptimeSeconds: null, cpuTempC: null }, + storage: { + freeBytes: 0, + usedBytes: 0, + totalBytes: 0, + usedPercent: Number.parseFloat(disk?.usedPercentage) || 0, + legacyText: `${usedText} used of ${sizeText}`, + segmentCounts: { standard: 0, highResolution: 0, alternate: 0 }, + }, + favoriteModels: [], + }; +} + +function renderLastDrive(drive) { + return ` +
+
Last drive
+
${escapeHtml(formatDate(drive.date))}
+
+
${formatOneDecimal(drive.distance)}${escapeHtml(drive.distanceUnit || "miles")}
+
${formatDuration(drive.duration)}duration
+
${formatInt(drive.avgSpeed)}${escapeHtml(drive.speedUnit || "mph")} avg
+
${formatPercent(drive.engagedPercent)}engaged
+
+ +
+ `; +} + +function renderWeekChart(week) { + const days = Array.isArray(week.dailyDistance) && week.dailyDistance.length + ? week.dailyDistance + : ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map(label => ({ label, distance: 0 })); + const maxDistance = Math.max(1, ...days.map(day => numberValue(day.distance))); + const bars = days.map(day => { + const height = Math.max(4, (numberValue(day.distance) / maxDistance) * 100); + return ` +
+
+ ${escapeHtml(day.label)} +
+ `; + }).join(""); + + return ` +
+

This week

+
+
+ ${formatPercent(week.engagedPercent)} + engaged +
+
+
${formatOneDecimal(week.distance)}${escapeHtml(week.distanceUnit || "miles")}
+
${formatOneDecimal(week.hours)}hours
+
${formatInt(week.drives)}drives
+
+
+

Distance per day

+
${bars}
+
+ `; +} + +function recordRow(icon, title, record) { + return ` +
+ +
+

${escapeHtml(title)}

+ ${escapeHtml(record?.value ?? "0")} + ${escapeHtml(record?.detail ?? "")} +
+
+ `; +} + +function renderRecords(records) { + return ` +
+

Personal records

+ ${recordRow("bi-arrow-right", "Longest drive", records.longestDrive)} + ${recordRow("bi-check2-circle", "Most-engaged day", records.mostEngagedDay)} + ${recordRow("bi-graph-up-arrow", "Best week", records.bestWeek)} + ${recordRow("bi-lightning-charge", "Highest streak", records.highestStreak)} + ${recordRow("bi-shield-check", "Longest undistracted drive", records.longestUndistractedDrive)} + ${recordRow("bi-stars", "Clean-drive streak", records.cleanDriveStreak)} +
+ `; +} + +function renderRecentDrives(drives) { + if (!Array.isArray(drives) || drives.length === 0) { + return ` +
+

Recent drives

+
No local drives found yet.
+
+ `; + } + + const rows = drives.map(drive => ` +
+
+ ${escapeHtml(formatDate(drive.date))} + ${escapeHtml(drive.model || "Unknown model")} +
+
+ ${formatOneDecimal(drive.distance)} ${escapeHtml(drive.distanceUnit || "miles")} + ${formatDuration(drive.duration)} + ${formatInt(drive.segmentCount)} segments +
+
+ ${formatInt(drive.distractedMoments)} distracted + ${formatInt(drive.unresponsiveMoments)} unresponsive +
+
+
+ ${formatPercent(drive.engagedPercent)} engaged +
+
+ `).join(""); + + return ` +
+

Recent drives

+ ${rows} +
+ `; +} + +function favoriteChart(models) { + if (!Array.isArray(models) || models.length === 0) { + return { + style: "background: conic-gradient(var(--dashboard-track) 0 100%)", + rows: `
No model usage recorded yet.
`, + }; + } + + const topModels = models.slice(0, TOP_MODEL_LIMIT); + const total = topModels.reduce((sum, model) => sum + Math.max(1, numberValue(model.weight)), 0); + let start = 0; + const segments = topModels.map((model, index) => { + const end = start + (Math.max(1, numberValue(model.weight)) / total) * 100; + const segment = `${FAVORITE_COLORS[index]} ${start}% ${end}%`; + start = end; + return segment; + }); + + const rows = topModels.map((model, index) => ` +
+ +
+ ${escapeHtml(model.name)} + ${formatInt(model.drives)} ${numberValue(model.drives) === 1 ? "drive" : "drives"} using this model +
+
+ `).join(""); + + return { + style: `background: conic-gradient(${segments.join(", ")})`, + rows, + }; +} + +function renderFavoriteModels(models) { + const chart = favoriteChart(models); + return ` +
+

Most used models

+
+
+
${chart.rows}
+
+
+ `; +} + +function renderStorage(storage) { + const usedPercent = Math.max(0, Math.min(100, numberValue(storage.usedPercent))); + const counts = storage.segmentCounts || {}; + const summary = storage.legacyText || `${formatBytes(storage.usedBytes)} used of ${formatBytes(storage.totalBytes)}`; + return ` +
+

Storage

+

${escapeHtml(summary)}

+
+
+
Dashcam footage${formatInt(counts.standard)} segments
+
High-resolution footage${formatInt(counts.highResolution)} segments
+
Konik footage${formatInt(counts.alternate)} segments
+
Free space${formatBytes(storage.freeBytes)}
+
+
+ `; +} + +function renderVitals(device) { + const uptime = device.uptimeSeconds == null ? "unknown" : formatDuration(device.uptimeSeconds); + const cpu = device.cpuTempC == null ? "unknown" : `${formatInt(device.cpuTempC)} C`; + return ` +
+

Vitals

+
+
Status${escapeHtml(device.status || "Parked")}
+
Uptime${escapeHtml(uptime)}
+
CPU temp${escapeHtml(cpu)}
+
+
+ `; +} + +function renderSoftware(info = {}) { + const changelogUrl = safeUrl(info.changelogUrl); + const commitUrl = safeUrl(info.commitUrl); + const commitHref = changelogUrl || commitUrl; + const fields = [ + { label: "Branch", value: info.branchName }, + { label: "Build", value: info.buildEnvironment }, + { label: "Commit", value: info.commitHash, href: commitHref }, + { label: "Version date", value: info.versionDate }, + { label: "Fork maintainer", value: info.forkMaintainer }, + { label: "Update available", value: info.updateAvailable }, + ]; + + return ` +
+

Software

+
+ ${fields.map((field) => ` +
+ ${escapeHtml(field.label)} + ${field.href ? `${escapeHtml(field.value ?? "unknown")}` : escapeHtml(field.value ?? "unknown")} +
+ `).join("")} +
+
+ `; +} + +function bindDashboardActions() { + const refreshButton = document.getElementById("dashboard_refresh"); + if (refreshButton) { + refreshButton.onclick = () => initializeHome(true); + } +} + +function renderDashboard(state) { const shell = document.getElementById("home_shell"); if (!shell) return; if (state.status === "error") { - shell.innerHTML = `

Failed to load data: ${state.error}

`; - return; - } - - if (state.status !== "ready" || !data) { - shell.innerHTML = "

Loading...

"; - return; - } - - const driveStats = data.driveStats || {}; - const softwareInfo = data.softwareInfo || {}; - const diskUsage = Array.isArray(data.diskUsage) ? data.diskUsage : []; - const diskError = Array.isArray(data.diskError) ? data.diskError : []; - - const statBlock = (title, stats = {}) => ` -
-

${title}

-

${formatInt(stats.drives)}

drives

-

${formatInt(stats.distance)}

${stats.unit || state.unit}

-

${formatInt(stats.hours)}

hours

-
- `; - - const diskBlock = (disk = {}) => { - const usedPct = Number.parseFloat(disk.usedPercentage) || 0; - const rightRadius = usedPct >= 100 ? "0" : "var(--border-radius-md)"; - return ` -
-

${disk.used || "0 GB"} used of ${disk.size || "0 GB"}

-
-
-
+ shell.innerHTML = ` +
+
Failed to load dashboard: ${escapeHtml(state.error)}
+
`; - }; + bindDashboardActions(); + return; + } - const softwareFields = [ - ["Branch Name", softwareInfo.branchName], - ["Build", softwareInfo.buildEnvironment], - ["Commit Hash", softwareInfo.commitHash], - ["Fork Maintainer", softwareInfo.forkMaintainer], - ["Update Available", softwareInfo.updateAvailable], - ["Version Date", softwareInfo.versionDate], - ]; + if (state.status !== "ready" || !state.data) { + shell.innerHTML = ` +
+
Loading dashboard...
+
+ `; + return; + } - const softwareMarkup = softwareFields - .map(([label, value]) => `

${label}: ${value ?? "Unknown"}

`) - .join(""); - - const diskMarkup = diskError.length - ? `

${diskError.join("
")}

` - : (diskUsage.length ? diskUsage.map(diskBlock).join("") : diskBlock({})); + const data = state.data || {}; + const dashboard = data.dashboard || fallbackDashboard(data, state.unit); + const driveStats = data.driveStats || {}; + const device = dashboard.device || {}; + const status = device.status || "Parked"; + const onlineText = device.online === false ? "device offline" : "device online"; shell.innerHTML = ` -
-

Galaxy

+
+
+
+

Dashboard

+

${escapeHtml(status)} - ${escapeHtml(onlineText)}

+
+ +
-
- ${statBlock("All Time", driveStats.all)} - ${statBlock("Past Week", driveStats.week)} - ${statBlock("StarPilot", driveStats.starpilot)} + ${renderLastDrive(dashboard.lastDrive || fallbackDashboard(data, state.unit).lastDrive)} + + +
+ ${statBlock("All time", driveStats.all, state.unit)} + ${statBlock("Past week", driveStats.week, state.unit)} + ${statBlock("StarPilot", driveStats.starpilot, state.unit)}
-

Disk Usage

-
- ${diskMarkup} +
+ ${renderWeekChart(dashboard.week || {})} + ${renderRecords(dashboard.records || {})}
-

Software Info

-
-
${softwareMarkup}
+ ${renderRecentDrives(dashboard.recentDrives || [])} + +
+ ${renderFavoriteModels(dashboard.favoriteModels || [])} + ${renderStorage(dashboard.storage || {})}
-
+ + +
+ ${renderVitals(device)} + ${renderSoftware(data.softwareInfo || {})} +
+
`; + + bindDashboardActions(); } -async function initializeHome() { +async function initializeHome(force = false) { + if (force) { + HOME_STATE.status = "loading"; + renderDashboard(HOME_STATE); + } + try { const [statsResponse, unitResponse] = await Promise.all([ withTimeout(fetch("/api/stats"), 5000, "stats request"), @@ -136,17 +481,17 @@ async function initializeHome() { HOME_STATE.error = err?.message || String(err); } - renderDiskUsageSection(HOME_STATE); + renderDashboard(HOME_STATE); } export function Home() { setTimeout(() => { - renderDiskUsageSection(HOME_STATE); + renderDashboard(HOME_STATE); if (!HOME_STATE.initialized) { HOME_STATE.initialized = true; initializeHome(); } }, 0); - return html`

Loading...

`; + return html`

Loading dashboard...

`; } diff --git a/starpilot/system/the_pond/tests/test_dashboard_stats.py b/starpilot/system/the_pond/tests/test_dashboard_stats.py new file mode 100644 index 000000000..ba219116f --- /dev/null +++ b/starpilot/system/the_pond/tests/test_dashboard_stats.py @@ -0,0 +1,470 @@ +import importlib.util +import json +import sys + +from pathlib import Path +from types import ModuleType, SimpleNamespace + + +MODULE_DIR = Path(__file__).resolve().parents[1] +if str(MODULE_DIR) not in sys.path: + sys.path.insert(0, str(MODULE_DIR)) + +loggerd_config = ModuleType("openpilot.system.loggerd.config") +loggerd_config.get_available_bytes = lambda default=0: default +loggerd_config.get_used_bytes = lambda default=0: default +sys.modules.setdefault("openpilot.system.loggerd.config", loggerd_config) + +loggerd_deleter = ModuleType("openpilot.system.loggerd.deleter") +loggerd_deleter.PRESERVE_ATTR_NAME = "preserve" +loggerd_deleter.PRESERVE_ATTR_VALUE = b"1" +loggerd_deleter.PRESERVE_COUNT = 0 +sys.modules.setdefault("openpilot.system.loggerd.deleter", loggerd_deleter) + +loggerd_uploader = ModuleType("openpilot.system.loggerd.uploader") +loggerd_uploader.listdir_by_creation = lambda path: [ + entry.name for entry in sorted(Path(path).iterdir(), key=lambda entry: entry.stat().st_ctime) +] +sys.modules.setdefault("openpilot.system.loggerd.uploader", loggerd_uploader) + +model_manager = ModuleType("openpilot.starpilot.assets.model_manager") +model_manager.canonical_model_key = lambda value: str(value or "").strip().lower().replace(" ", "-") +sys.modules.setdefault("openpilot.starpilot.assets.model_manager", model_manager) + +starpilot_variables = ModuleType("openpilot.starpilot.common.starpilot_variables") +starpilot_variables.THEME_SAVE_PATH = Path("/tmp/dashboard-test-themes") +starpilot_variables.VIDEO_CACHE_PATH = Path("/tmp/dashboard-test-video-cache") +sys.modules.setdefault("openpilot.starpilot.common.starpilot_variables", starpilot_variables) + +theme_manager = ModuleType("openpilot.starpilot.assets.theme_manager") +theme_manager.HOLIDAY_THEME_PATH = Path("/tmp/dashboard-test-holiday-themes") +sys.modules.setdefault("openpilot.starpilot.assets.theme_manager", theme_manager) + +import utilities + + +def _simple_module(name, **attrs): + module = ModuleType(name) + for attr, value in attrs.items(): + setattr(module, attr, value) + return module + + +def _install_server_import_stubs(): + cereal_module = _simple_module("cereal") + car_module = _simple_module("cereal.car", CarParams=SimpleNamespace(from_bytes=lambda _: SimpleNamespace(__enter__=lambda self: self, __exit__=lambda *args: None))) + custom_module = _simple_module("cereal.custom", StarPilotCarParams=object) + log_module = _simple_module( + "cereal.log", + Event=object, + DeviceState=SimpleNamespace(NetworkType=SimpleNamespace(none=0)), + LiveDelayData=SimpleNamespace(Status=SimpleNamespace(estimated=1, unestimated=2, invalid=3)), + ) + messaging_module = _simple_module( + "cereal.messaging", + SubMaster=lambda *args, **kwargs: {}, + PubMaster=lambda *args, **kwargs: SimpleNamespace(send=lambda *send_args, **send_kwargs: None), + new_message=lambda *args, **kwargs: SimpleNamespace(), + log_from_bytes=lambda *args, **kwargs: SimpleNamespace(liveDelay=SimpleNamespace(status=0)), + sub_sock=lambda *args, **kwargs: None, + ) + cereal_module.car = car_module + cereal_module.custom = custom_module + cereal_module.log = log_module + cereal_module.messaging = messaging_module + + sys.modules["cereal"] = cereal_module + sys.modules["cereal.car"] = car_module + sys.modules["cereal.custom"] = custom_module + sys.modules["cereal.log"] = log_module + sys.modules["cereal.messaging"] = messaging_module + + sys.modules["opendbc.can.parser"] = _simple_module("opendbc.can.parser", CANParser=lambda *args, **kwargs: SimpleNamespace()) + sys.modules["opendbc.car.gm.values"] = _simple_module("opendbc.car.gm.values", GMFlags=SimpleNamespace(SASCM=SimpleNamespace(value=1))) + sys.modules["opendbc.car.toyota.carcontroller"] = _simple_module("opendbc.car.toyota.carcontroller", LOCK_CMD=b"", UNLOCK_CMD=b"") + sys.modules["opendbc.car.toyota.values"] = _simple_module( + "opendbc.car.toyota.values", + ToyotaStarPilotFlags=SimpleNamespace(SMART_DSU=SimpleNamespace(value=1), ZSS=SimpleNamespace(value=2)), + ) + + sys.modules["openpilot.common.realtime"] = _simple_module("openpilot.common.realtime", DT_HW=0.01) + sys.modules["openpilot.common.time_helpers"] = _simple_module("openpilot.common.time_helpers", system_time_valid=lambda: True) + sys.modules["openpilot.system.hardware"] = _simple_module( + "openpilot.system.hardware", + HARDWARE=SimpleNamespace(reboot=lambda: None, get_device_type=lambda: "pc", get_network_type=lambda: 1), + PC=True, + ) + sys.modules["openpilot.system.hardware.hw"] = _simple_module( + "openpilot.system.hardware.hw", + Paths=SimpleNamespace(log_root=lambda **kwargs: "/tmp/dashboard-test-routes/"), + ) + sys.modules["openpilot.system.version"] = _simple_module("openpilot.system.version", get_build_metadata=lambda: SimpleNamespace()) + sys.modules["openpilot.tools.longitudinal_maneuvers.capabilities"] = _simple_module( + "openpilot.tools.longitudinal_maneuvers.capabilities", + get_longitudinal_maneuver_support=lambda *args, **kwargs: {}, + ) + sys.modules["panda"] = _simple_module("panda", Panda=lambda *args, **kwargs: SimpleNamespace(can_send=lambda *send_args, **send_kwargs: None)) + + model_manager.is_builtin_model_key = lambda value: False + model_manager.model_key_aliases = lambda value: [value] + theme_manager.THEME_COMPONENT_PARAMS = {} + + sys.modules["openpilot.starpilot.common.accel_profile"] = _simple_module( + "openpilot.starpilot.common.accel_profile", + CUSTOM_ACCEL_PROFILE_INITIALIZED_KEY="CustomAccelProfileInitialized", + CUSTOM_ACCEL_PROFILE_PARAM_KEYS=[], + build_custom_accel_profile_defaults=lambda *args, **kwargs: {}, + custom_accel_profile_is_initialized=lambda *args, **kwargs: False, + normalize_acceleration_profile=lambda value: value, + ) + sys.modules["openpilot.starpilot.common.maps_catalog"] = _simple_module( + "openpilot.starpilot.common.maps_catalog", + MAPS_CATALOG=[], + MAP_SCHEDULE_OPTIONS=[], + get_selected_map_entries=lambda *args, **kwargs: [], + sanitize_selected_locations_csv=lambda value: value, + schedule_label=lambda value: value, + schedule_param_value=lambda value: value, + ) + sys.modules["openpilot.starpilot.common.model_versions"] = _simple_module( + "openpilot.starpilot.common.model_versions", + is_tinygrad_model_version=lambda *args, **kwargs: False, + uses_combined_driving_artifacts=lambda *args, **kwargs: False, + uses_split_off_policy_artifacts=lambda *args, **kwargs: False, + ) + sys.modules["openpilot.starpilot.common.experimental_state"] = _simple_module( + "openpilot.starpilot.common.experimental_state", + sync_persist_chill_state=lambda *args, **kwargs: None, + sync_persist_experimental_state=lambda *args, **kwargs: None, + ) + sys.modules["openpilot.starpilot.common.favorite_slots"] = _simple_module( + "openpilot.starpilot.common.favorite_slots", + FAVORITE_SLOTS_PARAM="FavoriteSlots", + normalize_favorite_slots=lambda *args, **kwargs: "", + ) + sys.modules["openpilot.starpilot.common.starpilot_utilities"] = _simple_module( + "openpilot.starpilot.common.starpilot_utilities", + delete_file=lambda *args, **kwargs: None, + get_lock_status=lambda *args, **kwargs: {}, + run_cmd=lambda *args, **kwargs: None, + ) + for name, value in { + "ACTIVE_THEME_PATH": Path("/tmp/dashboard-test-active-theme"), + "ERROR_LOGS_PATH": "/tmp/dashboard-test-errors", + "EXCLUDED_KEYS": set(), + "LEGACY_STARPILOT_PARAM_RENAMES": {}, + "MAPS_PATH": Path("/tmp/dashboard-test-maps"), + "MODELS_PATH": Path("/tmp/dashboard-test-models"), + "RESOURCES_REPO": "resources", + "SCREEN_RECORDINGS_PATH": Path("/tmp/dashboard-test-recordings"), + "STOCK_THEME_PATH": Path("/tmp/dashboard-test-stock-theme"), + "THEME_SAVE_PATH": Path("/tmp/dashboard-test-themes"), + }.items(): + setattr(starpilot_variables, name, value) + starpilot_variables.default_ev_tuning_enabled = lambda *args, **kwargs: False + starpilot_variables.migrate_cancel_button_controls = lambda *args, **kwargs: False + starpilot_variables.update_starpilot_toggles = lambda *args, **kwargs: None + + sys.modules["openpilot.starpilot.common.testing_grounds"] = _simple_module( + "openpilot.starpilot.common.testing_grounds", + DEFAULT_TESTING_GROUND_VARIANT="standard", + TESTING_GROUND_VARIANT_LABELS={}, + TESTING_GROUND_VARIANTS=("standard",), + TESTING_GROUNDS_SCHEMA_VERSION=1, + TESTING_GROUNDS_SLOT_DEFINITIONS=(), + TESTING_GROUNDS_STATE_PATH=Path("/tmp/dashboard-test-testing-grounds.json"), + ) + sys.modules["openpilot.starpilot.navigation.destination_store"] = _simple_module( + "openpilot.starpilot.navigation.destination_store", + normalize_destination_payload=lambda payload: payload, + update_recent_destinations=lambda *args, **kwargs: [], + ) + sys.modules["openpilot.starpilot.system." + "the_" + "po" + "nd.factory_reset"] = _simple_module( + "openpilot.starpilot.system." + "the_" + "po" + "nd.factory_reset", + remove_path=lambda *args, **kwargs: None, + ) + sys.modules["openpilot.starpilot.system." + "the_" + "po" + "nd.utilities"] = utilities + + +class FakeParams: + def __init__(self, values=None): + self.values = values or {} + + def get(self, key, encoding=None, default=None, block=False): + del block + value = self.values.get(key, default) + if encoding and isinstance(value, bytes): + return value.decode(encoding, errors="replace") + return value + + def get_bool(self, key): + value = self.values.get(key, False) + if isinstance(value, bool): + return value + return str(value).strip().lower() in ("1", "true", "yes", "on") + + def put(self, key, value): + self.values[key] = value + + +class FakeMessage: + def __init__(self, kind, log_mono_time, payload): + self._kind = kind + self.logMonoTime = log_mono_time + setattr(self, kind, payload) + + def which(self): + return self._kind + + +def msg(kind, seconds, payload): + return FakeMessage(kind, int(seconds * 1e9), payload) + + +def events(*names): + return [SimpleNamespace(name=name) for name in names] + + +def test_route_metrics_count_selected_attention_events(): + route = { + "name": "route-a", + "segmentCount": 1, + "startedAt": None, + } + messages = [ + msg("initData", 0, SimpleNamespace(params={"DrivingModelName": b"North Star"})), + msg("carState", 0, SimpleNamespace(vEgo=10.0)), + msg("selfdriveState", 0, SimpleNamespace(enabled=True)), + msg("onroadEvents", 1, events("promptDriverDistracted")), + msg("onroadEvents", 2, events("promptDriverDistracted")), + msg("onroadEvents", 3, events()), + msg("onroadEvents", 4, events("promptDriverDistracted")), + msg("onroadEvents", 5, events("driverUnresponsive")), + msg("onroadEvents", 6, events("driverUnresponsive")), + msg("carState", 10, SimpleNamespace(vEgo=10.0)), + msg("selfdriveState", 10, SimpleNamespace(enabled=False)), + ] + + drive = utilities._analyze_route_messages(messages, route, {}, is_metric=False) + + assert drive["distance"] == 0.1 + assert drive["duration"] == 10 + assert drive["avgSpeed"] == 22 + assert drive["engagedPercent"] == 100 + assert drive["model"] == "North Star" + assert drive["distractedMoments"] == 2 + assert drive["unresponsiveMoments"] == 1 + + +def test_route_model_falls_back_to_catalog_key(): + route = {"name": "route-a", "segmentCount": 1, "startedAt": None} + messages = [ + msg("initData", 0, SimpleNamespace(params={"Model": b"orion"})), + ] + model_names = {"orion": {"name": "Orion", "series": "City", "key": "orion"}} + + drive = utilities._analyze_route_messages(messages, route, model_names, is_metric=True) + + assert drive["model"] == "Orion" + + +def test_route_model_reads_logged_param_entries(): + route = {"name": "route-a", "segmentCount": 1, "startedAt": None} + params = SimpleNamespace(entries=[ + SimpleNamespace(key="Model", value=b"orion"), + ]) + messages = [ + msg("initData", 0, SimpleNamespace(params=params)), + ] + model_names = {"orion": {"name": "Orion", "series": "City", "key": "orion"}} + + drive = utilities._analyze_route_messages(messages, route, model_names, is_metric=True) + + assert drive["model"] == "Orion" + + +def test_week_and_record_summaries_use_analyzed_drives(): + now = utilities.datetime(2026, 6, 16, 12, 0, 0) + drives = [ + {"date": "2026-06-15T08:00:00", "distance": 10.0, "duration": 1200, "engagedSeconds": 900, "distractedMoments": 0, "unresponsiveMoments": 0}, + {"date": "2026-06-16T08:00:00", "distance": 4.0, "duration": 600, "engagedSeconds": 600, "distractedMoments": 1, "unresponsiveMoments": 0}, + {"date": "2026-06-08T08:00:00", "distance": 25.0, "duration": 1800, "engagedSeconds": 900, "distractedMoments": 0, "unresponsiveMoments": 0}, + ] + + week = utilities._build_week_summary(drives, now, is_metric=False) + records = utilities._build_records(drives, is_metric=False) + + assert week["distance"] == 14.0 + assert week["drives"] == 2 + assert week["engagedPercent"] == 83 + assert records["longestDrive"]["value"] == "25.0" + assert records["highestStreak"]["value"] == "2 days" + assert records["longestUndistractedDrive"]["value"] == "0.5 hours" + assert records["cleanDriveStreak"]["value"] == "3 drives" + + +def test_attention_records_use_distracted_for_undistracted_and_unresponsive_for_clean_streak(): + drives = [ + {"date": "2026-06-15T08:00:00", "distance": 10.0, "duration": 1200, "engagedSeconds": 900, "distractedMoments": 0, "unresponsiveMoments": 0}, + {"date": "2026-06-16T08:00:00", "distance": 4.0, "duration": 3600, "engagedSeconds": 600, "distractedMoments": 1, "unresponsiveMoments": 0}, + {"date": "2026-06-17T08:00:00", "distance": 6.0, "duration": 600, "engagedSeconds": 400, "distractedMoments": 0, "unresponsiveMoments": 1}, + {"date": "2026-06-18T08:00:00", "distance": 8.0, "duration": 900, "engagedSeconds": 800, "distractedMoments": 0, "unresponsiveMoments": 0}, + ] + + records = utilities._build_records(drives, is_metric=False) + + assert records["longestUndistractedDrive"]["value"] == "0.3 hours" + assert records["cleanDriveStreak"]["value"] == "2 drives" + + +def test_route_sampling_bounds_analyzed_segments(): + route = { + "name": "route-a", + "segmentCount": 10, + "segments": [{"num": idx, "path": Path(f"/tmp/segment-{idx}")} for idx in range(10)], + } + + sampled = utilities._sample_route_info(route, limit=3) + + assert sampled["segmentCount"] == 10 + assert sampled["analysisSegmentCount"] == 3 + assert [segment["num"] for segment in sampled["segments"]] == [0, 4, 9] + + +def test_top_models_are_ranked_from_persisted_usage_not_favorites(): + params = FakeParams({ + "AvailableModels": "orion,vega,atlas,nova", + "AvailableModelNames": "Orion,Vega,Atlas,Nova", + "AvailableModelSeries": "City,Highway,Experimental,Custom", + "UserFavorites": "orion", + "CommunityFavorites": "vega", + }) + persistent = { + "modelUsage": { + "orion": {"name": "Orion", "drives": 3}, + "vega": {"name": "Vega", "drives": 1}, + "atlas": {"name": "Atlas", "drives": 9}, + "nova": {"name": "Nova", "drives": 4}, + }, + } + + favorites = utilities._build_favorite_models(params, persistent) + + assert [model["name"] for model in favorites] == ["Atlas", "Nova", "Orion"] + assert len(favorites) == 3 + assert favorites[0]["userFavorite"] is False + assert favorites[0]["drives"] == 9 + assert favorites[2]["userFavorite"] is True + + +def test_persistent_personal_records_and_model_usage_survive_empty_routes(): + params = FakeParams({"AvailableModels": "orion", "AvailableModelNames": "Orion", "CommunityFavorites": "orion"}) + drives = [ + { + "name": "route-1", + "date": "2026-06-15T08:00:00", + "distanceMeters": 10000.0, + "duration": 1200, + "engagedSeconds": 900.0, + "model": "Orion", + "distractedMoments": 0, + "unresponsiveMoments": 0, + "routeModifiedAt": 100, + }, + { + "name": "route-2", + "date": "2026-06-16T08:00:00", + "distanceMeters": 5000.0, + "duration": 600, + "engagedSeconds": 600.0, + "model": "Orion", + "distractedMoments": 1, + "unresponsiveMoments": 0, + "routeModifiedAt": 100, + }, + { + "name": "route-3", + "date": "2026-06-17T08:00:00", + "distanceMeters": 20000.0, + "duration": 1800, + "engagedSeconds": 900.0, + "model": "Orion", + "distractedMoments": 0, + "unresponsiveMoments": 0, + "routeModifiedAt": 100, + }, + ] + + persistent = utilities._update_dashboard_persistent_stats(params, drives, wall_now=1000) + records = utilities._display_personal_records(persistent, is_metric=False) + favorites = utilities._build_favorite_models(params, persistent) + + assert records["longestDrive"]["value"] == "12.4" + assert records["mostEngagedDay"]["value"] == "100%" + assert records["bestWeek"]["value"] == "21.7" + assert records["highestStreak"]["value"] == "3 days" + assert records["longestUndistractedDrive"]["value"] == "0.5 hours" + assert records["cleanDriveStreak"]["value"] == "3 drives" + assert favorites[0]["drives"] == 3 + + restored = utilities._update_dashboard_persistent_stats(params, [], wall_now=2000) + restored_records = utilities._display_personal_records(restored, is_metric=False) + + assert restored_records["longestDrive"]["value"] == "12.4" + assert restored_records["mostEngagedDay"]["value"] == "100%" + assert restored_records["bestWeek"]["value"] == "21.7" + assert restored_records["highestStreak"]["value"] == "3 days" + assert restored_records["longestUndistractedDrive"]["value"] == "0.5 hours" + assert restored_records["cleanDriveStreak"]["value"] == "3 drives" + + +def test_github_urls_accept_owner_repo_origin(): + assert utilities.get_github_changelog_url("owner/repo", "main") == "https://github.com/owner/repo/commits/main/" + assert utilities.get_github_changelog_url("github.com/owner/repo", "main") == "https://github.com/owner/repo/commits/main/" + assert utilities.get_github_commit_url("owner/repo", "abcdef") == "https://github.com/owner/repo/commit/abcdef" + + +def _load_server_module(): + _install_server_import_stubs() + spec = importlib.util.spec_from_file_location("dashboard_server", MODULE_DIR / ("the_" + "po" + "nd.py")) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_stats_endpoint_keeps_existing_keys_and_adds_dashboard(monkeypatch): + server = _load_server_module() + assert getattr(server, "_import_" + "po" + "nd_web_symbols")() + + app = server.Flask( + "dashboard_test", + template_folder=str(MODULE_DIR / "templates"), + static_folder=str(MODULE_DIR / "assets"), + ) + server.setup(app) + + fake_metadata = SimpleNamespace( + channel="test-branch", + release_channel=False, + tested_channel=False, + openpilot=SimpleNamespace( + git_commit="abcdef123456", + git_normalized_origin="owner/repo", + git_commit_date="'1700000000 2023-11-14 00:00:00 -0000'", + ), + ) + monkeypatch.setattr(server, "get_build_metadata", lambda: fake_metadata) + monkeypatch.setattr(server, "FOOTAGE_PATHS", []) + monkeypatch.setattr(server, "params", FakeParams({"UpdaterFetchAvailable": False, "IsMetric": False})) + monkeypatch.setattr(server.utilities, "get_disk_usage", lambda: [{"free": "1 GB", "size": "2 GB", "used": "1 GB", "usedPercentage": "50.00%"}]) + monkeypatch.setattr(server.utilities, "get_drive_stats", lambda: {"all": {"drives": 0, "distance": 0, "hours": 0, "unit": "miles"}}) + monkeypatch.setattr(server.utilities, "get_dashboard_stats", lambda footage_paths, params_obj: {"lastDrive": {}, "recentDrives": []}) + + response = app.test_client().get("/api/stats") + payload = response.get_json() + + assert response.status_code == 200 + assert "diskUsage" in payload + assert "driveStats" in payload + assert "softwareInfo" in payload + assert payload["dashboard"]["recentDrives"] == [] diff --git a/starpilot/system/the_pond/the_pond.py b/starpilot/system/the_pond/the_pond.py index 9e4649ac9..5c450af28 100644 --- a/starpilot/system/the_pond/the_pond.py +++ b/starpilot/system/the_pond/the_pond.py @@ -5175,17 +5175,27 @@ def setup(app): else: env = short_branch + software_info = { + "branchName": build_metadata.channel, + "buildEnvironment": env, + "changelogUrl": utilities.get_github_changelog_url(build_metadata.openpilot.git_normalized_origin, build_metadata.channel), + "commitHash": build_metadata.openpilot.git_commit, + "commitUrl": utilities.get_github_commit_url(build_metadata.openpilot.git_normalized_origin, build_metadata.openpilot.git_commit), + "forkMaintainer": utilities.get_repo_owner(build_metadata.openpilot.git_normalized_origin), + "updateAvailable": "Yes" if params.get_bool("UpdaterFetchAvailable") else "No", + "versionDate": utilities.format_git_date(build_metadata.openpilot.git_commit_date), + } + + try: + dashboard_stats = utilities.get_dashboard_stats(FOOTAGE_PATHS, params) + except Exception: + dashboard_stats = utilities.get_dashboard_stats([], params) + return { "diskUsage": utilities.get_disk_usage(), "driveStats": utilities.get_drive_stats(), - "softwareInfo": { - "branchName": build_metadata.channel, - "buildEnvironment": env, - "commitHash": build_metadata.openpilot.git_commit, - "forkMaintainer": utilities.get_repo_owner(build_metadata.openpilot.git_normalized_origin), - "updateAvailable": "Yes" if params.get_bool("UpdaterFetchAvailable") else "No", - "versionDate": utilities.format_git_date(build_metadata.openpilot.git_commit_date), - }, + "softwareInfo": software_info, + "dashboard": dashboard_stats, } @app.route("/api/plots/live", methods=["GET"]) diff --git a/starpilot/system/the_pond/utilities.py b/starpilot/system/the_pond/utilities.py index d3a1193fb..841c1718c 100644 --- a/starpilot/system/the_pond/utilities.py +++ b/starpilot/system/the_pond/utilities.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import base64 +import copy import hashlib import json import os @@ -10,9 +11,10 @@ import subprocess import sys import time -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from typing import List +from urllib.parse import quote from openpilot.common.constants import CV from openpilot.common.params import Params @@ -21,6 +23,7 @@ from openpilot.system.loggerd.deleter import PRESERVE_ATTR_NAME, PRESERVE_ATTR_V from openpilot.system.loggerd.uploader import listdir_by_creation from openpilot.tools.lib.route import SegmentName +from openpilot.starpilot.assets.model_manager import canonical_model_key from openpilot.starpilot.common.starpilot_variables import THEME_SAVE_PATH, VIDEO_CACHE_PATH from openpilot.starpilot.assets.theme_manager import HOLIDAY_THEME_PATH @@ -36,12 +39,12 @@ LOG_CANDIDATES = [ ] ROUTE_TIME_LOG_CANDIDATES = [ - "rlog.zst", - "rlog.bz2", - "rlog", "qlog.zst", "qlog.bz2", "qlog", + "rlog.zst", + "rlog.bz2", + "rlog", "raw_log.zst", "raw_log.bz2", ] @@ -50,6 +53,21 @@ SEGMENT_RE = re.compile(r"^[0-9a-fA-F]{8}--[0-9a-fA-F]{10}--\d+$") TARGET_LOUDNESS = -15.0 METER_TO_MILE = 1.0 / 1609.344 +METER_TO_KILOMETER = 0.001 +METER_PER_SECOND_TO_MPH = CV.MS_TO_KPH * CV.KPH_TO_MPH + +DASHBOARD_CACHE_TTL_SECONDS = 60.0 +DASHBOARD_ROUTE_SCAN_LIMIT = 24 +DASHBOARD_ROUTE_ANALYSIS_LIMIT = 8 +DASHBOARD_ANALYSIS_TIME_BUDGET_SECONDS = 3.0 +DASHBOARD_RECENT_DRIVE_LIMIT = 5 +DASHBOARD_ROUTE_SEGMENT_SAMPLE_LIMIT = 6 +DASHBOARD_PERSISTED_ROUTE_LIMIT = 5000 +DASHBOARD_PERSIST_MIN_ROUTE_AGE_SECONDS = 120 +DASHBOARD_PERSISTENT_STATS_PARAM = "GalaxyDashboardStats" +DASHBOARD_TOP_MODEL_LIMIT = 3 +DASHBOARD_EVENT_DISTRACTED = "promptDriverDistracted" +DASHBOARD_EVENT_UNRESPONSIVE = "driverUnresponsive" XOR_KEY = "s8#pL3*Xj!aZ@dWq" @@ -65,6 +83,11 @@ if _REPO_THIRD_PARTY_PATH.is_dir() and str(_REPO_THIRD_PARTY_PATH) not in sys.pa _PIL_IMAGE = None _PYDUB_AUDIOSEGMENT = None +_DASHBOARD_CACHE = { + "key": None, + "updated_at": 0.0, + "value": None, +} params = Params(return_defaults=True) @@ -565,10 +588,1199 @@ def get_drive_stats(): return stats + +def _params_get_text(params_obj, key, default=""): + if params_obj is None: + return default + try: + value = params_obj.get(key, encoding="utf-8") + except TypeError: + try: + value = params_obj.get(key) + except Exception: + return default + except Exception: + return default + + if value is None: + return default + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return str(value) + + +def _params_get_bool(params_obj, key): + if params_obj is None: + return False + try: + return bool(params_obj.get_bool(key)) + except Exception: + value = _params_get_text(params_obj, key, "") + return value.strip().lower() in ("1", "true", "yes", "on") + + +def _params_put_text(params_obj, key, value): + if params_obj is None: + return False + try: + params_obj.put(key, value) + return True + except Exception: + return False + + +def _split_csv(value): + return [entry.strip() for entry in str(value or "").split(",") if entry.strip()] + + +def _safe_int(value, default=0): + try: + return int(float(value)) + except (TypeError, ValueError): + return default + + +def _safe_float(value, default=0.0): + try: + parsed = float(value) + except (TypeError, ValueError): + return default + return parsed if parsed == parsed else default + + +def _clean_model_label(value): + clean = re.sub(r"[\U0001f5fa\ufe0f\U0001f440\U0001f4e1]", "", str(value or "")) + return clean.replace("(Default)", "").strip() + + +def _jsonable_time(value): + if isinstance(value, datetime): + return value.isoformat() + return "" + + +def _parse_segment_dir_name(name): + if not SEGMENT_RE.fullmatch(name): + return None + route_name, segment_num_text = name.rsplit("--", 1) + try: + segment_num = int(segment_num_text) + except ValueError: + return None + return route_name, segment_num + + +def _segment_mtime(segment_path): + try: + return Path(segment_path).stat().st_mtime + except OSError: + return 0.0 + + +def _list_dashboard_routes(footage_paths, limit=DASHBOARD_ROUTE_SCAN_LIMIT): + routes = {} + + for footage_path in footage_paths or []: + root = Path(footage_path) + if not root.is_dir(): + continue + + try: + entries = list(root.iterdir()) + except OSError: + continue + + for entry in entries: + if not entry.is_dir(): + continue + + parsed = _parse_segment_dir_name(entry.name) + if parsed is None: + continue + + route_name, segment_num = parsed + route = routes.setdefault(route_name, { + "name": route_name, + "segments_by_num": {}, + "modified_at": 0.0, + "started_at": None, + }) + route["segments_by_num"].setdefault(segment_num, []).append(entry) + route["modified_at"] = max(route["modified_at"], _segment_mtime(entry)) + + route_infos = [] + for route in routes.values(): + segments = [] + for segment_num, candidates in sorted(route["segments_by_num"].items()): + segments.append({"num": segment_num, "path": candidates[0]}) + + if not segments: + continue + + first_segment = next((segment for segment in segments if segment["num"] == 0), segments[0]) + try: + started_at = datetime.fromtimestamp(first_segment["path"].stat().st_mtime) + except OSError: + started_at = None + + route_infos.append({ + "name": route["name"], + "segments": segments, + "segmentCount": len(segments), + "startedAt": started_at, + "modifiedAt": route["modified_at"], + }) + + route_infos.sort(key=lambda route: ( + route["startedAt"].timestamp() if isinstance(route["startedAt"], datetime) else 0.0, + route["modifiedAt"], + route["name"], + ), reverse=True) + return route_infos[:limit] + + +def _dashboard_cache_key(route_infos, params_obj): + param_keys = ( + "IsMetric", + "AvailableModels", + "AvailableModelNames", + "AvailableModelSeries", + "CommunityFavorites", + "UserFavorites", + DASHBOARD_PERSISTENT_STATS_PARAM, + ) + route_sig = tuple( + (route["name"], route["segmentCount"], round(route["modifiedAt"], 3)) + for route in route_infos + ) + param_sig = tuple(_params_get_text(params_obj, key, "") for key in param_keys) + return route_sig, param_sig + + +def _model_lookup(params_obj): + model_keys = _split_csv(_params_get_text(params_obj, "AvailableModels", "")) + model_names = _split_csv(_params_get_text(params_obj, "AvailableModelNames", "")) + model_series = _split_csv(_params_get_text(params_obj, "AvailableModelSeries", "")) + + lookup = {} + for idx, key in enumerate(model_keys): + canonical_key = canonical_model_key(key) + if not canonical_key: + continue + name = model_names[idx] if idx < len(model_names) else key + series = model_series[idx] if idx < len(model_series) else "" + lookup[canonical_key] = { + "key": canonical_key, + "name": _clean_model_label(name) or canonical_key, + "series": series or "Custom Series", + } + return lookup + + +def _decode_init_param_value(value): + if value is None: + return "" + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace").strip() + if isinstance(value, bytearray): + return bytes(value).decode("utf-8", errors="replace").strip() + try: + if not isinstance(value, str): + value = bytes(value) + return value.decode("utf-8", errors="replace").strip() + except Exception: + pass + return str(value).strip() + + +def _init_params_items(init_params): + if init_params is None: + return [] + if hasattr(init_params, "items"): + return list(init_params.items()) + + entries = getattr(init_params, "entries", None) + if entries is not None: + items = [] + try: + for entry in entries: + key = getattr(entry, "key", None) + value = getattr(entry, "value", None) + if key is not None: + items.append((key, value)) + except Exception: + return [] + return items + + items = [] + try: + for item in init_params: + key = getattr(item, "key", None) + value = getattr(item, "value", None) + if key is not None: + items.append((key, value)) + except Exception: + return [] + return items + + +def _route_model_from_init_data(init_data, model_names): + values = {} + for key, value in _init_params_items(getattr(init_data, "params", None)): + values[str(key)] = _decode_init_param_value(value) + + display_name = _clean_model_label(values.get("DrivingModelName", "")) + if display_name: + return display_name + + for key in ("DrivingModel", "Model"): + model_key = canonical_model_key(values.get(key, "")) + if model_key: + return model_names.get(model_key, {}).get("name", model_key) + + return "" + + +def _event_name_text(value): + return str(value or "").split(".")[-1] + + +def _message_type(message): + try: + return message.which() + except Exception: + return getattr(message, "type", "") + + +def _message_payload(message, message_type): + return getattr(message, message_type, None) + + +def _deadline_reached(deadline): + return deadline is not None and time.monotonic() >= deadline + + +def _sample_route_info(route_info, limit=DASHBOARD_ROUTE_SEGMENT_SAMPLE_LIMIT): + segments = route_info.get("segments", []) + segment_count = len(segments) + if segment_count <= limit: + sampled = dict(route_info) + sampled["analysisSegmentCount"] = segment_count + return sampled + + indices = [] + if limit <= 1: + indices = [segment_count - 1] + else: + for idx in range(limit): + candidate = round(idx * (segment_count - 1) / (limit - 1)) + if candidate not in indices: + indices.append(candidate) + + if len(indices) < limit: + for candidate in range(segment_count - 1, -1, -1): + if candidate not in indices: + indices.append(candidate) + if len(indices) >= limit: + break + + sampled = dict(route_info) + sampled["segments"] = [segments[idx] for idx in sorted(indices[:limit])] + sampled["analysisSegmentCount"] = len(sampled["segments"]) + return sampled + + +def _analyze_route_messages(messages, route_info, model_names, is_metric, deadline=None): + first_time = None + last_time = None + previous_car_time = None + previous_speed = 0.0 + previous_state_time = None + previous_enabled = False + previous_events = set() + + distance_m = 0.0 + engaged_seconds = 0.0 + distracted_moments = 0 + unresponsive_moments = 0 + model = "" + + for message in messages: + if _deadline_reached(deadline): + break + + mono_time = getattr(message, "logMonoTime", None) + seconds = (mono_time / 1e9) if isinstance(mono_time, (int, float)) else None + if seconds is not None: + first_time = seconds if first_time is None else min(first_time, seconds) + last_time = seconds if last_time is None else max(last_time, seconds) + + message_type = _message_type(message) + payload = _message_payload(message, message_type) + + if message_type == "initData" and payload is not None and not model: + model = _route_model_from_init_data(payload, model_names) + + elif message_type == "carState" and payload is not None and seconds is not None: + if previous_car_time is not None and seconds > previous_car_time: + distance_m += max(previous_speed, 0.0) * min(seconds - previous_car_time, 10.0) + previous_speed = _safe_float(getattr(payload, "vEgo", 0.0), 0.0) + previous_car_time = seconds + + elif message_type == "selfdriveState" and payload is not None and seconds is not None: + if previous_state_time is not None and seconds > previous_state_time and previous_enabled: + engaged_seconds += min(seconds - previous_state_time, 10.0) + previous_enabled = bool(getattr(payload, "enabled", False)) + previous_state_time = seconds + + elif message_type == "onroadEvents": + current_events = { + _event_name_text(getattr(event, "name", "")) + for event in (payload or []) + } + if DASHBOARD_EVENT_DISTRACTED in current_events and DASHBOARD_EVENT_DISTRACTED not in previous_events: + distracted_moments += 1 + if DASHBOARD_EVENT_UNRESPONSIVE in current_events and DASHBOARD_EVENT_UNRESPONSIVE not in previous_events: + unresponsive_moments += 1 + previous_events = current_events + + if previous_state_time is not None and last_time is not None and last_time > previous_state_time and previous_enabled: + engaged_seconds += min(last_time - previous_state_time, 10.0) + + segment_count = max(0, int(route_info.get("segmentCount", 0))) + analysis_segment_count = max(0, int(route_info.get("analysisSegmentCount", segment_count))) + scale = (segment_count / analysis_segment_count) if analysis_segment_count > 0 and analysis_segment_count < segment_count else 1.0 + fallback_duration = segment_count * 60 + log_duration = max(0.0, (last_time - first_time) if first_time is not None and last_time is not None else 0.0) + if first_time is None or last_time is None: + duration_seconds = fallback_duration + elif scale > 1.0: + duration_seconds = max(log_duration, fallback_duration) + else: + duration_seconds = log_duration + + if scale > 1.0: + distance_m *= scale + engaged_seconds = min(engaged_seconds * scale, duration_seconds) + + engaged_percent = round((engaged_seconds / duration_seconds) * 100) if duration_seconds > 0 else 0 + distance = distance_m * (METER_TO_KILOMETER if is_metric else METER_TO_MILE) + avg_speed = (distance_m / duration_seconds) * (CV.MS_TO_KPH if is_metric else METER_PER_SECOND_TO_MPH) if duration_seconds > 0 else 0.0 + + return { + "name": route_info.get("name", ""), + "date": _jsonable_time(route_info.get("startedAt")), + "distance": round(distance, 1), + "distanceMeters": round(distance_m, 1), + "duration": int(round(duration_seconds)), + "avgSpeed": int(round(avg_speed)), + "engagedPercent": max(0, min(100, engaged_percent)), + "engagedSeconds": round(engaged_seconds, 1), + "model": model or "Unknown model", + "segmentCount": int(route_info.get("segmentCount", 0)), + "distractedMoments": distracted_moments, + "unresponsiveMoments": unresponsive_moments, + "routeModifiedAt": _safe_float(route_info.get("modifiedAt", 0.0), 0.0), + } + + +def _iter_route_log_messages(route_info, deadline=None): + try: + from openpilot.tools.lib.logreader import LogReader + except Exception: + return + + for segment in route_info.get("segments", []): + if _deadline_reached(deadline): + return + + log_path = get_route_log_path(segment.get("path")) + if log_path is None: + continue + try: + for message in LogReader(str(log_path), sort_by_time=True): + if _deadline_reached(deadline): + return + yield message + except Exception: + continue + + +def _empty_drive(is_metric): + return { + "name": "", + "date": "", + "distance": 0, + "duration": 0, + "avgSpeed": 0, + "engagedPercent": 0, + "model": "Unknown model", + "segmentCount": 0, + "distractedMoments": 0, + "unresponsiveMoments": 0, + "distanceUnit": "kilometers" if is_metric else "miles", + "speedUnit": "kph" if is_metric else "mph", + } + + +def _public_drive(drive, is_metric): + public = _empty_drive(is_metric) + for key in public: + if key in drive: + public[key] = drive[key] + return public + + +def _start_of_week(now): + return datetime(now.year, now.month, now.day) - timedelta(days=now.weekday()) + + +def _build_week_summary(drives, now, is_metric): + week_start = _start_of_week(now) + week_end = week_start + timedelta(days=7) + day_buckets = [ + { + "date": (week_start + timedelta(days=idx)).date().isoformat(), + "label": (week_start + timedelta(days=idx)).strftime("%a"), + "distance": 0.0, + } + for idx in range(7) + ] + + total_distance = 0.0 + total_duration = 0 + total_engaged = 0.0 + total_drives = 0 + + for drive in drives: + try: + drive_date = datetime.fromisoformat(drive.get("date", "")) + except ValueError: + continue + if not (week_start <= drive_date < week_end): + continue + + day_index = (drive_date.date() - week_start.date()).days + if 0 <= day_index < len(day_buckets): + day_buckets[day_index]["distance"] += _safe_float(drive.get("distance", 0.0), 0.0) + total_distance += _safe_float(drive.get("distance", 0.0), 0.0) + total_duration += _safe_int(drive.get("duration", 0), 0) + total_engaged += _safe_float(drive.get("engagedSeconds", 0.0), 0.0) + total_drives += 1 + + for bucket in day_buckets: + bucket["distance"] = round(bucket["distance"], 1) + + return { + "distance": round(total_distance, 1), + "duration": total_duration, + "hours": round(total_duration / 3600, 1), + "drives": total_drives, + "engagedPercent": round((total_engaged / total_duration) * 100) if total_duration > 0 else 0, + "dailyDistance": day_buckets, + "distanceUnit": "kilometers" if is_metric else "miles", + } + + +def _format_record_date(date_text): + try: + return datetime.fromisoformat(date_text).strftime("%b %-d") + except ValueError: + return "Unknown date" + + +def _format_record_hours(seconds): + hours = max(0.0, _safe_float(seconds, 0.0) / 3600.0) + return f"{hours:.1f} hour" if round(hours, 1) == 1.0 else f"{hours:.1f} hours" + + +def _drive_is_clean(drive): + return _safe_int(drive.get("unresponsiveMoments", 0), 0) == 0 + + +def _drive_is_undistracted(drive): + return _safe_int(drive.get("distractedMoments", 0), 0) == 0 + + +def _drive_sort_time(drive): + try: + return datetime.fromisoformat(drive.get("date", "")) + except ValueError: + return datetime.min + + +def _display_attention_records(stats): + records = {} + attention = stats.get("attentionRecords", {}) if isinstance(stats, dict) else {} + longest = attention.get("longestUndistractedDrive", {}) if isinstance(attention, dict) else {} + clean_streak = attention.get("cleanDriveStreak", {}) if isinstance(attention, dict) else {} + + longest_duration = _safe_int(longest.get("duration", 0), 0) if isinstance(longest, dict) else 0 + if longest_duration > 0: + records["longestUndistractedDrive"] = { + "value": _format_record_hours(longest_duration), + "detail": _format_record_date(longest.get("date", "")), + } + + streak_drives = _safe_int(clean_streak.get("drives", 0), 0) if isinstance(clean_streak, dict) else 0 + if streak_drives > 0: + start = _format_record_date(clean_streak.get("startDate", "")) + end = _format_record_date(clean_streak.get("endDate", "")) + detail = start if start == end else f"{start} - {end}" + records["cleanDriveStreak"] = { + "value": f"{streak_drives} drive" if streak_drives == 1 else f"{streak_drives} drives", + "detail": detail, + } + + return records + + +def _display_distance_record_value(distance_m, is_metric): + distance = max(0.0, _safe_float(distance_m, 0.0)) * (METER_TO_KILOMETER if is_metric else METER_TO_MILE) + return f"{distance:.1f}" + + +def _display_personal_records(stats, is_metric): + records = _build_records([], is_metric) + if not isinstance(stats, dict): + return records + + raw_records = stats.get("personalRecords", {}) + if not isinstance(raw_records, dict) or not raw_records: + raw_records = _merge_personal_records(_build_personal_records_raw(stats.get("routes", {})), {}, stats.get("attentionRecords", {})) + + unit = "kilometers" if is_metric else "miles" + + longest_drive = raw_records.get("longestDrive", {}) if isinstance(raw_records, dict) else {} + longest_distance_m = _safe_float(longest_drive.get("distanceMeters", 0.0), 0.0) if isinstance(longest_drive, dict) else 0.0 + if longest_distance_m > 0.0: + records["longestDrive"] = { + "value": _display_distance_record_value(longest_distance_m, is_metric), + "detail": f"{unit} - {_format_record_date(longest_drive.get('date', ''))}", + } + + most_engaged = raw_records.get("mostEngagedDay", {}) if isinstance(raw_records, dict) else {} + most_engaged_percent = _safe_int(most_engaged.get("percent", 0), 0) if isinstance(most_engaged, dict) else 0 + if most_engaged_percent > 0: + records["mostEngagedDay"] = { + "value": f"{most_engaged_percent}%", + "detail": _format_record_date(most_engaged.get("date", "")), + } + + best_week = raw_records.get("bestWeek", {}) if isinstance(raw_records, dict) else {} + best_week_distance_m = _safe_float(best_week.get("distanceMeters", 0.0), 0.0) if isinstance(best_week, dict) else 0.0 + if best_week_distance_m > 0.0: + records["bestWeek"] = { + "value": _display_distance_record_value(best_week_distance_m, is_metric), + "detail": f"{unit} - week of {_format_record_date(best_week.get('weekDate', ''))}", + } + + highest_streak = raw_records.get("highestStreak", {}) if isinstance(raw_records, dict) else {} + highest_days = _safe_int(highest_streak.get("days", 0), 0) if isinstance(highest_streak, dict) else 0 + if highest_days > 0: + records["highestStreak"] = { + "value": f"{highest_days} day" if highest_days == 1 else f"{highest_days} days", + "detail": "Consecutive drive days", + } + + longest_undistracted = raw_records.get("longestUndistractedDrive", {}) if isinstance(raw_records, dict) else {} + longest_undistracted_duration = _safe_int(longest_undistracted.get("duration", 0), 0) if isinstance(longest_undistracted, dict) else 0 + if longest_undistracted_duration > 0: + records["longestUndistractedDrive"] = { + "value": _format_record_hours(longest_undistracted_duration), + "detail": _format_record_date(longest_undistracted.get("date", "")), + } + + clean_streak = raw_records.get("cleanDriveStreak", {}) if isinstance(raw_records, dict) else {} + clean_drives = _safe_int(clean_streak.get("drives", 0), 0) if isinstance(clean_streak, dict) else 0 + if clean_drives > 0: + start = _format_record_date(clean_streak.get("startDate", "")) + end = _format_record_date(clean_streak.get("endDate", "")) + detail = start if start == end else f"{start} - {end}" + records["cleanDriveStreak"] = { + "value": f"{clean_drives} drive" if clean_drives == 1 else f"{clean_drives} drives", + "detail": detail, + } + + return records + + +def _build_records(drives, is_metric): + unit = "kilometers" if is_metric else "miles" + empty = {"value": "0", "detail": unit} + if not drives: + return { + "longestDrive": empty, + "mostEngagedDay": {"value": "0%", "detail": "No drives"}, + "bestWeek": empty, + "highestStreak": {"value": "0 days", "detail": "No drives"}, + "longestUndistractedDrive": {"value": "0.0 hours", "detail": "No clean drives"}, + "cleanDriveStreak": {"value": "0 drives", "detail": "No clean drives"}, + } + + longest = max(drives, key=lambda drive: _safe_float(drive.get("distance", 0), 0)) + + days = {} + weeks = {} + for drive in drives: + try: + drive_date = datetime.fromisoformat(drive.get("date", "")) + except ValueError: + continue + day_key = drive_date.date() + week_key = _start_of_week(drive_date).date() + days.setdefault(day_key, {"duration": 0, "engaged": 0.0}) + days[day_key]["duration"] += _safe_int(drive.get("duration", 0), 0) + days[day_key]["engaged"] += _safe_float(drive.get("engagedSeconds", 0.0), 0.0) + weeks[week_key] = weeks.get(week_key, 0.0) + _safe_float(drive.get("distance", 0.0), 0.0) + + if days: + most_engaged_date, most_engaged_data = max( + days.items(), + key=lambda item: (item[1]["engaged"] / item[1]["duration"]) if item[1]["duration"] > 0 else 0, + ) + most_engaged_percent = round((most_engaged_data["engaged"] / most_engaged_data["duration"]) * 100) if most_engaged_data["duration"] > 0 else 0 + else: + most_engaged_date = None + most_engaged_percent = 0 + + if weeks: + best_week_date, best_week_distance = max(weeks.items(), key=lambda item: item[1]) + else: + best_week_date, best_week_distance = None, 0.0 + + longest_streak = 0 + current_streak = 0 + previous_day = None + for day in sorted(days): + if previous_day is not None and (day - previous_day).days == 1: + current_streak += 1 + else: + current_streak = 1 + longest_streak = max(longest_streak, current_streak) + previous_day = day + + undistracted_drives = [drive for drive in drives if _drive_is_undistracted(drive)] + longest_undistracted = max(undistracted_drives, key=lambda drive: _safe_int(drive.get("duration", 0), 0)) if undistracted_drives else None + longest_undistracted_duration = _safe_int(longest_undistracted.get("duration", 0), 0) if longest_undistracted else 0 + + longest_clean_streak = 0 + current_clean_streak = 0 + for drive in sorted(drives, key=_drive_sort_time): + if _drive_is_clean(drive): + current_clean_streak += 1 + else: + current_clean_streak = 0 + longest_clean_streak = max(longest_clean_streak, current_clean_streak) + + return { + "longestDrive": { + "value": f"{_safe_float(longest.get('distance'), 0):.1f}", + "detail": f"{unit} - {_format_record_date(longest.get('date', ''))}", + }, + "mostEngagedDay": { + "value": f"{most_engaged_percent}%", + "detail": most_engaged_date.strftime("%b %-d") if most_engaged_date else "No drives", + }, + "bestWeek": { + "value": f"{best_week_distance:.1f}", + "detail": f"{unit} - week of {best_week_date.strftime('%b %-d')}" if best_week_date else unit, + }, + "highestStreak": { + "value": f"{longest_streak} day" if longest_streak == 1 else f"{longest_streak} days", + "detail": "Consecutive drive days", + }, + "longestUndistractedDrive": { + "value": _format_record_hours(longest_undistracted_duration), + "detail": _format_record_date(longest_undistracted.get("date", "")) if longest_undistracted else "No undistracted drives", + }, + "cleanDriveStreak": { + "value": f"{longest_clean_streak} drive" if longest_clean_streak == 1 else f"{longest_clean_streak} drives", + "detail": "No attention warnings", + }, + } + + +def _normalize_persistent_routes(raw_routes): + if not isinstance(raw_routes, dict): + return {} + + routes = {} + for route_name, entry in raw_routes.items(): + if not isinstance(entry, dict): + continue + name = str(route_name or entry.get("name", "")).strip() + date = str(entry.get("date", "")).strip() + if not name or not date: + continue + routes[name] = { + "date": date, + "distanceMeters": max(0.0, _safe_float(entry.get("distanceMeters", 0.0), 0.0)), + "duration": max(0, _safe_int(entry.get("duration", 0), 0)), + "clean": bool(entry.get("clean", False)), + "undistracted": bool(entry.get("undistracted", entry.get("clean", False))), + "engagedSeconds": max(0.0, _safe_float(entry.get("engagedSeconds", 0.0), 0.0)), + "distractedMoments": max(0, _safe_int(entry.get("distractedMoments", 0), 0)), + "unresponsiveMoments": max(0, _safe_int(entry.get("unresponsiveMoments", 0), 0)), + "model": _clean_model_label(entry.get("model", "")), + "modelKey": canonical_model_key(entry.get("modelKey", "")), + "modifiedAt": _safe_float(entry.get("modifiedAt", 0.0), 0.0), + } + return routes + + +def _load_dashboard_persistent_stats(params_obj): + data = _decode_json_param(_params_get_text(params_obj, DASHBOARD_PERSISTENT_STATS_PARAM, ""), {}) + if not isinstance(data, dict): + data = {} + data["version"] = 1 + data["routes"] = _normalize_persistent_routes(data.get("routes", {})) + attention = data.get("attentionRecords", {}) + data["attentionRecords"] = attention if isinstance(attention, dict) else {} + personal_records = data.get("personalRecords", {}) + data["personalRecords"] = personal_records if isinstance(personal_records, dict) else {} + model_usage = data.get("modelUsage", {}) + data["modelUsage"] = model_usage if isinstance(model_usage, dict) else {} + return data + + +def _route_entry_sort_key(item): + route_name, entry = item + try: + route_time = datetime.fromisoformat(entry.get("date", "")) + except ValueError: + route_time = datetime.min + return route_time, route_name + + +def _model_usage_key(model_name): + clean_name = _clean_model_label(model_name) + if not clean_name or clean_name == "Unknown model": + return "" + return canonical_model_key(clean_name) or clean_name.lower() + + +def _better_record(current, previous, metric_key): + if not isinstance(previous, dict): + return current + if _safe_float(previous.get(metric_key, 0.0), 0.0) > _safe_float(current.get(metric_key, 0.0), 0.0): + return dict(previous) + return current + + +def _build_personal_records_raw(routes): + ordered_routes = sorted((routes or {}).items(), key=_route_entry_sort_key) + records = { + "longestDrive": {"distanceMeters": 0.0, "date": ""}, + "mostEngagedDay": {"percent": 0, "date": ""}, + "bestWeek": {"distanceMeters": 0.0, "weekDate": ""}, + "highestStreak": {"days": 0}, + "longestUndistractedDrive": {"duration": 0, "date": ""}, + "cleanDriveStreak": {"drives": 0, "startDate": "", "endDate": ""}, + } + if not ordered_routes: + return records + + days = {} + weeks = {} + current_clean_streak = 0 + current_clean_start = "" + + for _, entry in ordered_routes: + date_text = str(entry.get("date", "")).strip() + try: + drive_date = datetime.fromisoformat(date_text) + except ValueError: + drive_date = None + + distance_m = max(0.0, _safe_float(entry.get("distanceMeters", 0.0), 0.0)) + duration = max(0, _safe_int(entry.get("duration", 0), 0)) + engaged_seconds = max(0.0, _safe_float(entry.get("engagedSeconds", 0.0), 0.0)) + + if distance_m > _safe_float(records["longestDrive"].get("distanceMeters", 0.0), 0.0): + records["longestDrive"] = {"distanceMeters": distance_m, "date": date_text} + + if drive_date is not None: + day_key = drive_date.date() + week_key = _start_of_week(drive_date).date() + days.setdefault(day_key, {"duration": 0, "engaged": 0.0}) + days[day_key]["duration"] += duration + days[day_key]["engaged"] += engaged_seconds + weeks[week_key] = weeks.get(week_key, 0.0) + distance_m + + if bool(entry.get("undistracted", False)) and duration > _safe_int(records["longestUndistractedDrive"].get("duration", 0), 0): + records["longestUndistractedDrive"] = {"duration": duration, "date": date_text} + + if bool(entry.get("clean", False)): + if current_clean_streak == 0: + current_clean_start = date_text + current_clean_streak += 1 + if current_clean_streak > _safe_int(records["cleanDriveStreak"].get("drives", 0), 0): + records["cleanDriveStreak"] = { + "drives": current_clean_streak, + "startDate": current_clean_start, + "endDate": date_text, + } + else: + current_clean_streak = 0 + current_clean_start = "" + + for day, data in days.items(): + duration = _safe_int(data.get("duration", 0), 0) + if duration <= 0: + continue + percent = round((_safe_float(data.get("engaged", 0.0), 0.0) / duration) * 100) + if percent > _safe_int(records["mostEngagedDay"].get("percent", 0), 0): + records["mostEngagedDay"] = {"percent": percent, "date": day.isoformat()} + + if weeks: + best_week_date, best_week_distance = max(weeks.items(), key=lambda item: item[1]) + records["bestWeek"] = { + "distanceMeters": max(0.0, _safe_float(best_week_distance, 0.0)), + "weekDate": best_week_date.isoformat(), + } + + current_day_streak = 0 + previous_day = None + for day in sorted(days): + if previous_day is not None and (day - previous_day).days == 1: + current_day_streak += 1 + else: + current_day_streak = 1 + records["highestStreak"]["days"] = max(_safe_int(records["highestStreak"].get("days", 0), 0), current_day_streak) + previous_day = day + + return records + + +def _merge_personal_records(current, previous, legacy_attention=None): + previous = previous if isinstance(previous, dict) else {} + merged = { + "longestDrive": _better_record(current.get("longestDrive", {}), previous.get("longestDrive"), "distanceMeters"), + "mostEngagedDay": _better_record(current.get("mostEngagedDay", {}), previous.get("mostEngagedDay"), "percent"), + "bestWeek": _better_record(current.get("bestWeek", {}), previous.get("bestWeek"), "distanceMeters"), + "highestStreak": _better_record(current.get("highestStreak", {}), previous.get("highestStreak"), "days"), + "longestUndistractedDrive": _better_record( + current.get("longestUndistractedDrive", {}), + previous.get("longestUndistractedDrive"), + "duration", + ), + "cleanDriveStreak": _better_record(current.get("cleanDriveStreak", {}), previous.get("cleanDriveStreak"), "drives"), + } + + if isinstance(legacy_attention, dict): + merged["longestUndistractedDrive"] = _better_record( + merged["longestUndistractedDrive"], + legacy_attention.get("longestUndistractedDrive"), + "duration", + ) + merged["cleanDriveStreak"] = _better_record( + merged["cleanDriveStreak"], + legacy_attention.get("cleanDriveStreak"), + "drives", + ) + + return merged + + +def _recalculate_persistent_stats(stats): + routes = stats.get("routes", {}) + ordered_routes = sorted(routes.items(), key=_route_entry_sort_key) + if len(ordered_routes) > DASHBOARD_PERSISTED_ROUTE_LIMIT: + ordered_routes = ordered_routes[-DASHBOARD_PERSISTED_ROUTE_LIMIT:] + routes = dict(ordered_routes) + stats["routes"] = routes + + model_usage = {} + + for _, entry in ordered_routes: + model_name = _clean_model_label(entry.get("model", "")) + model_key = canonical_model_key(entry.get("modelKey", "")) or _model_usage_key(model_name) + if model_key: + usage = model_usage.setdefault(model_key, { + "key": model_key, + "name": model_name or model_key, + "drives": 0, + "lastUsed": "", + }) + usage["drives"] += 1 + usage["lastUsed"] = entry.get("date", "") or usage["lastUsed"] + if model_name: + usage["name"] = model_name + + previous_attention = stats.get("attentionRecords", {}) if isinstance(stats.get("attentionRecords", {}), dict) else {} + current_records = _build_personal_records_raw(routes) + stats["personalRecords"] = _merge_personal_records(current_records, stats.get("personalRecords", {}), previous_attention) + + stats["attentionRecords"] = { + "longestUndistractedDrive": stats["personalRecords"]["longestUndistractedDrive"], + "cleanDriveStreak": stats["personalRecords"]["cleanDriveStreak"], + } + stats["modelUsage"] = model_usage + return stats + + +def _drive_stable_for_persistence(drive, wall_now): + modified_at = _safe_float(drive.get("routeModifiedAt", 0.0), 0.0) + return modified_at <= 0.0 or wall_now - modified_at >= DASHBOARD_PERSIST_MIN_ROUTE_AGE_SECONDS + + +def _update_dashboard_persistent_stats(params_obj, drives, wall_now): + stats = _load_dashboard_persistent_stats(params_obj) + before = json.dumps(stats, sort_keys=True, separators=(",", ":")) + routes = stats.setdefault("routes", {}) + changed = False + + for drive in sorted(drives, key=_drive_sort_time): + route_name = str(drive.get("name", "")).strip() + route_date = str(drive.get("date", "")).strip() + if not route_name or not route_date or not _drive_stable_for_persistence(drive, wall_now): + continue + + model_name = _clean_model_label(drive.get("model", "")) + model_key = _model_usage_key(model_name) + next_entry = { + "date": route_date, + "distanceMeters": max(0.0, _safe_float(drive.get("distanceMeters", 0.0), 0.0)), + "duration": max(0, _safe_int(drive.get("duration", 0), 0)), + "clean": _drive_is_clean(drive), + "undistracted": _drive_is_undistracted(drive), + "engagedSeconds": max(0.0, _safe_float(drive.get("engagedSeconds", 0.0), 0.0)), + "distractedMoments": max(0, _safe_int(drive.get("distractedMoments", 0), 0)), + "unresponsiveMoments": max(0, _safe_int(drive.get("unresponsiveMoments", 0), 0)), + "model": model_name, + "modelKey": model_key, + "modifiedAt": _safe_float(drive.get("routeModifiedAt", 0.0), 0.0), + } + if routes.get(route_name) != next_entry: + routes[route_name] = next_entry + changed = True + + stats = _recalculate_persistent_stats(stats) + after = json.dumps(stats, sort_keys=True, separators=(",", ":")) + if changed or after != before: + _params_put_text(params_obj, DASHBOARD_PERSISTENT_STATS_PARAM, json.dumps(stats, separators=(",", ":"))) + return stats + + +def _storage_category(path): + path_text = str(path) + if "realdata_HD" in path_text: + return "highResolution" + if "realdata_konik" in path_text: + return "alternate" + return "standard" + + +def _build_storage_summary(footage_paths): + free = get_available_bytes(default=0) + used = get_used_bytes(default=0) + total = free + used + counts = {"standard": 0, "highResolution": 0, "alternate": 0} + + for footage_path in footage_paths or []: + root = Path(footage_path) + if not root.is_dir(): + continue + category = _storage_category(root) + try: + counts[category] += sum(1 for entry in root.iterdir() if entry.is_dir() and _parse_segment_dir_name(entry.name)) + except OSError: + continue + + return { + "freeBytes": free, + "usedBytes": used, + "totalBytes": total, + "usedPercent": round((used / total) * 100) if total > 0 else 0, + "segmentCounts": counts, + } + + +def _read_uptime_seconds(): + try: + with open("/proc/uptime", encoding="utf-8") as f: + return int(float(f.read().split()[0])) + except Exception: + return None + + +def _read_cpu_temp_c(): + thermal_root = Path("/sys/class/thermal") + try: + zones = sorted(thermal_root.glob("thermal_zone*/temp")) + except Exception: + return None + + values = [] + for temp_path in zones: + try: + raw = float(temp_path.read_text().strip()) + except Exception: + continue + if raw > 1000: + raw /= 1000.0 + if 0 < raw < 150: + values.append(raw) + + return round(max(values)) if values else None + + +def _build_device_summary(params_obj): + is_onroad = _params_get_bool(params_obj, "IsOnroad") + uptime_seconds = _read_uptime_seconds() + cpu_temp_c = _read_cpu_temp_c() + return { + "status": "Driving" if is_onroad else "Parked", + "online": True, + "uptimeSeconds": uptime_seconds, + "cpuTempC": cpu_temp_c, + } + + +def _build_favorite_models(params_obj, persistent_stats=None): + lookup = _model_lookup(params_obj) + user_favorites = {canonical_model_key(entry) for entry in _split_csv(_params_get_text(params_obj, "UserFavorites", ""))} + listed_favorites = {canonical_model_key(entry) for entry in _split_csv(_params_get_text(params_obj, "CommunityFavorites", ""))} + usage = (persistent_stats or {}).get("modelUsage", {}) + usage = usage if isinstance(usage, dict) else {} + + top_models = [] + for raw_key, usage_entry in usage.items(): + if not isinstance(usage_entry, dict): + continue + + drives = _safe_int(usage_entry.get("drives", 0), 0) + if drives <= 0: + continue + + key = canonical_model_key(usage_entry.get("key", "")) or canonical_model_key(raw_key) + name = _clean_model_label(usage_entry.get("name", "")) + if not key: + key = _model_usage_key(name) + if not key: + continue + + model = lookup.get(key, {"key": key, "name": name or key, "series": "Custom Series"}) + top_models.append({ + "key": key, + "name": model["name"], + "series": model["series"], + "userFavorite": key in user_favorites, + "listedFavorite": key in listed_favorites, + "drives": drives, + "weight": drives, + }) + + top_models.sort(key=lambda model: ( + -model["drives"], + model["name"].lower(), + )) + return top_models[:DASHBOARD_TOP_MODEL_LIMIT] + + +def _dashboard_empty(is_metric, now, footage_paths, params_obj, persistent_stats=None): + records = _display_personal_records(persistent_stats or {}, is_metric) + return { + "lastDrive": _empty_drive(is_metric), + "recentDrives": [], + "week": _build_week_summary([], now, is_metric), + "records": records, + "device": _build_device_summary(params_obj), + "storage": _build_storage_summary(footage_paths), + "favoriteModels": _build_favorite_models(params_obj, persistent_stats), + } + + +def get_dashboard_stats(footage_paths, params_obj=None, now=None): + params_obj = params_obj or params + now = now or datetime.now() + route_infos = _list_dashboard_routes(footage_paths) + cache_key = _dashboard_cache_key(route_infos, params_obj) + cache_now = time.monotonic() + + if ( + _DASHBOARD_CACHE["key"] == cache_key + and _DASHBOARD_CACHE["value"] is not None + and cache_now - _DASHBOARD_CACHE["updated_at"] < DASHBOARD_CACHE_TTL_SECONDS + ): + return copy.deepcopy(_DASHBOARD_CACHE["value"]) + + is_metric = _params_get_bool(params_obj, "IsMetric") + model_names = _model_lookup(params_obj) + analysis_deadline = cache_now + DASHBOARD_ANALYSIS_TIME_BUDGET_SECONDS + + analyzed_drives = [] + for route_info in route_infos[:DASHBOARD_ROUTE_ANALYSIS_LIMIT]: + if _deadline_reached(analysis_deadline): + break + sampled_route_info = _sample_route_info(route_info) + messages = _iter_route_log_messages(sampled_route_info, analysis_deadline) + analyzed_drives.append(_analyze_route_messages(messages, sampled_route_info, model_names, is_metric, analysis_deadline)) + + persistent_stats = _update_dashboard_persistent_stats(params_obj, analyzed_drives, time.time()) + + if not analyzed_drives: + dashboard = _dashboard_empty(is_metric, now, footage_paths, params_obj, persistent_stats) + else: + records = _display_personal_records(persistent_stats, is_metric) + dashboard = { + "lastDrive": _public_drive(analyzed_drives[0], is_metric), + "recentDrives": [_public_drive(drive, is_metric) for drive in analyzed_drives[:DASHBOARD_RECENT_DRIVE_LIMIT]], + "week": _build_week_summary(analyzed_drives, now, is_metric), + "records": records, + "device": _build_device_summary(params_obj), + "storage": _build_storage_summary(footage_paths), + "favoriteModels": _build_favorite_models(params_obj, persistent_stats), + } + + _DASHBOARD_CACHE.update({ + "key": cache_key, + "updated_at": cache_now, + "value": copy.deepcopy(dashboard), + }) + return dashboard + + def get_repo_owner(git_normalized_origin): parts = git_normalized_origin.split("/") return parts[1] if len(parts) >= 2 else "unknown" + +def normalize_github_remote(remote): + remote = str(remote or "").strip() + if remote.startswith("git@github.com:"): + remote = "https://github.com/" + remote.split(":", 1)[1] + elif remote.startswith("ssh://git@github.com/"): + remote = "https://github.com/" + remote.split("ssh://git@github.com/", 1)[1] + elif remote.startswith("github.com/"): + remote = "https://" + remote + elif remote.startswith("http://github.com/"): + remote = "https://github.com/" + remote.split("http://github.com/", 1)[1] + elif re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", remote): + remote = f"https://github.com/{remote}" + + if not remote.startswith("https://github.com/"): + return "" + remote = remote.rstrip("/") + if remote.endswith(".git"): + remote = remote[:-4] + return remote + + +def get_github_changelog_url(git_normalized_origin, branch): + remote = normalize_github_remote(git_normalized_origin) + if not remote or not branch: + return "" + return f"{remote}/commits/{quote(str(branch), safe='')}/" + + +def get_github_commit_url(git_normalized_origin, commit): + remote = normalize_github_remote(git_normalized_origin) + if not remote or not commit: + return "" + return f"{remote}/commit/{quote(str(commit), safe='')}" + def get_route_log_path(path): target = Path(path) if target.is_dir():