Galaxy web presentation

Add Galaxy web assets and client settings interfaces with their bundled vendor notices.
This commit is contained in:
firestar5683
2026-09-25 14:53:21 -05:00
parent 51a0d50187
commit 1ff98bacf6
78 changed files with 28091 additions and 0 deletions
@@ -0,0 +1,14 @@
import assert from "node:assert/strict"
import { validDocument } from "../web/js/onroad-layout.js"
const metadata = { profiles: Object.fromEntries(["large", "compact"].map(name => [name, {
bounds: {x:0,y:0,width:100,height:100}, widgets:{}, reservedZones:[]
}])) }
const document = { version:4, palette:{cardFill:"#000000A6",cardBorder:"#C4CDD0B4",text:"#FFFFFFFF"},
layouts:{large:{},compact:{}},widgetColors:{large:{},compact:{}},roadColors:{large:{},compact:{}} }
assert.equal(validDocument(document, metadata), true)
for (const flag of [true, false]) assert.equal(validDocument({...document,speedSources:flag},metadata),true)
for (const flag of [1, 0, null, "true", {}, []]) assert.equal(validDocument({...document,speedSources:flag},metadata),false)
for (const malformed of [null,undefined,[],1,"layout",{}]) assert.equal(validDocument(malformed,metadata),false)
assert.equal(validDocument({...document,unexpected:true},metadata),false)
assert.equal(validDocument({...document,layouts:null},metadata),false)
console.log("PASS speed source preference: absent/booleans/invalid flags/null/undefined/malformed documents")
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.1 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,459 @@
/* Settings controls adapted from the current StarPilot Vue Galaxy. */
.gx-settings { gap: var(--sp-4); max-width: none; }
.gx-settings__header h2 { margin: 0; }
.gx-settings__header p { color: var(--text-muted); font-size: var(--fs-sm); line-height: 1.45; }
.gx-settings__body { gap: var(--sp-4); }
.gx-settings__section { padding: 0; }
.gx-settings__section .gx-section__header { cursor: default; }
.gx-settings__save-status { position: fixed; bottom: calc(var(--bottomnav-clearance) + 8px); left: 50%; transform: translateX(-50%); z-index: var(--z-snackbar); padding: var(--sp-2) var(--sp-4); border-radius: var(--radius-full); background: var(--surface-container-high); box-shadow: var(--elev-2); }
.gx-slider-header { display: flex; align-items: baseline; gap: var(--sp-2); min-height: 24px; }
.gx-slider-hint { color: var(--text-muted); font-size: var(--fs-xs); }
.gx-row__readout { max-width: 55%; text-align: right; white-space: normal !important; overflow-wrap: anywhere; }
.gx-switch input:focus-visible + .gx-switch__track { outline: 2px solid var(--secondary); outline-offset: 4px; }
input[type="range"].gx-slider:focus-visible { outline: 2px solid var(--secondary); outline-offset: 6px; }
input[type="range"].gx-slider:disabled { cursor: default; opacity: .4; }
.gx-row .gx-btn { flex-shrink: 0; }
.gx-settings-tabs { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
.gx-settings-tabs button.gx-chip[aria-pressed="true"] { background: var(--primary); color: var(--on-primary); }
@media (max-width: 520px) { .gx-row__readout { max-width: 45%; } }
.gx-section__header {
align-items: center;
cursor: pointer;
display: flex;
gap: var(--sp-3);
min-height: 56px;
padding: var(--sp-3) var(--sp-4);
background: rgba(255, 255, 255, 0.02);
}
.gx-section__header i {
color: var(--primary);
font-size: 1.35rem;
}
.gx-section__title {
flex: 1;
font-weight: var(--fw-bold);
letter-spacing: -0.01em;
}
.gx-section__count {
color: var(--text-muted);
font-size: var(--fs-xs);
background: rgba(255, 255, 255, 0.08);
padding: 2px 8px;
border-radius: var(--radius-full);
}
[data-theme="light"] .gx-section__count {
background: rgba(120, 73, 232, 0.12);
color: var(--primary);
font-weight: var(--fw-bold);
}
.gx-chevron {
color: var(--text-muted);
transition: transform var(--motion-base);
}
.gx-chevron.open { transform: rotate(180deg); }
.gx-row {
align-items: center;
border-top: 1px solid var(--outline);
display: flex;
gap: var(--sp-3);
min-height: 64px;
padding: var(--sp-3) var(--sp-4);
transition: background-color var(--motion-fast);
}
.gx-row:hover {
background-color: rgba(255, 255, 255, 0.03);
}
.gx-row__info { flex: 1; min-width: 0; }
.gx-row__label {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2);
font-weight: var(--fw-medium);
font-size: var(--fs-base);
line-height: 1.3;
}
.gx-row__desc {
display: block;
color: var(--text-muted);
font-size: var(--fs-sm);
margin-top: 6px;
line-height: 1.45;
}
.gx-row__value {
color: var(--primary);
font-weight: var(--fw-bold);
white-space: nowrap;
}
.gx-row__actions { align-self: flex-end; display: flex; flex-wrap: wrap; gap: 6px; }
.gx-row.disabled .gx-row__label, .gx-row.disabled .gx-row__desc { opacity: 0.45; }
.gx-row--favorites { align-items: stretch; flex-direction: column; }
.gx-row--favorites .gx-row__info { flex: none; }
.gx-row--stack { align-items: stretch; flex-direction: column; }
.gx-row--stack .gx-row__info { flex: none; }
.gx-row--stack .gx-field,
.gx-row--stack .gx-slider-row { width: 100%; }
.gx-switch {
display: inline-flex;
position: relative;
cursor: pointer;
}
.gx-switch input { height: 1px; opacity: 0; position: absolute; width: 1px; }
.gx-switch__track {
background: rgba(255, 255, 255, 0.12);
border: 1px solid var(--glass-border);
border-radius: var(--radius-full);
height: 30px;
width: 52px;
transition: background-color var(--motion-base), border-color var(--motion-base);
}
[data-theme="light"] .gx-switch__track {
background: rgba(0, 0, 0, 0.08);
border: 1.5px solid var(--primary);
}
.gx-switch__thumb {
background: #ffffff;
border-radius: 50%;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.35);
height: 22px;
left: 4px;
position: absolute;
top: 4px;
transition: transform var(--motion-base), width var(--motion-fast);
width: 22px;
}
[data-theme="light"] .gx-switch__thumb {
border: 1px solid rgba(120, 73, 232, 0.3);
}
.gx-switch:active .gx-switch__thumb {
width: 26px;
}
.gx-switch input:checked + .gx-switch__track {
background: var(--primary);
border-color: var(--primary);
}
.gx-switch input:checked ~ .gx-switch__thumb {
transform: translateX(22px);
}
.gx-switch:active input:checked ~ .gx-switch__thumb {
transform: translateX(18px);
}
.gx-switch input:disabled + .gx-switch__track { opacity: 0.4; }
.gx-slider-row {
align-items: stretch;
display: flex;
flex-direction: column;
gap: var(--sp-2);
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
width: 100%;
}
.gx-slider-row .gx-row__value { text-align: left; min-width: 0; transition: color var(--motion-fast), text-shadow var(--motion-fast); }
.gx-slider-meta {
color: var(--text-muted);
display: flex;
font-size: var(--fs-xs);
justify-content: space-between;
}
input[type="range"].gx-slider {
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.12);
border-radius: var(--radius-full);
cursor: pointer;
margin: 0;
padding: 0;
border: none;
height: 8px;
outline: none;
-webkit-tap-highlight-color: transparent;
touch-action: none;
width: 100%;
}
[data-theme="light"] input[type="range"].gx-slider {
background: rgba(120, 73, 232, 0.18);
border: 1px solid rgba(120, 73, 232, 0.3);
}
input[type="range"].gx-slider::-webkit-slider-thumb {
-webkit-appearance: none;
background: #ffffff;
border: 2px solid var(--primary);
border-radius: 50%;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
height: 22px;
width: 22px;
transition: transform var(--motion-fast);
}
input[type="range"].gx-slider:active::-webkit-slider-thumb {
transform: scale(1.15);
}
input[type="range"].gx-slider::-moz-range-track {
background: transparent;
border: none;
height: 8px;
border-radius: var(--radius-full);
}
input[type="range"].gx-slider::-moz-range-thumb {
background: #ffffff;
border: 2px solid var(--primary);
border-radius: 50%;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
height: 22px;
width: 22px;
box-sizing: border-box;
transition: transform var(--motion-fast);
}
input[type="range"].gx-slider:active::-moz-range-thumb {
transform: scale(1.15);
}
.gx-slider-reset {
background: var(--surface-container-high);
border: 1px solid var(--glass-border);
border-radius: var(--radius-full);
color: var(--on-surface);
cursor: pointer;
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
min-height: 32px;
padding: 0 var(--sp-3);
transition: background-color var(--motion-fast);
}
.gx-slider-reset:hover { background: var(--glass-bg-hover); }
[data-theme="light"] .gx-slider-reset {
border: 1.5px solid var(--primary);
}
.gx-chip {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
font-size: var(--fs-xs);
font-weight: var(--fw-bold);
padding: 2px 8px;
line-height: 1.2;
vertical-align: middle;
}
button.gx-chip {
-webkit-appearance: none;
appearance: none;
background: var(--surface-container-high);
border: 1px solid var(--glass-border);
color: var(--on-surface-variant);
cursor: pointer;
font-family: var(--font-body);
}
button.gx-chip:hover {
background: var(--glass-bg-hover);
}
.gx-select > select { display: none !important; }
.gx-select { display: inline-flex; position: relative; vertical-align: middle; min-width: 0; max-width: 100%; }
.gx-select.gx-field { padding: 0; background: var(--surface-container-high); }
.gx-select.gx-field--full { display: flex; width: 100%; }
.gx-select__button { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; min-width: 0; min-height: 44px; padding: 10px var(--sp-3); border: 0; border-radius: inherit; color: inherit; background: transparent; font: inherit; text-align: left; cursor: pointer; touch-action: manipulation; }
.gx-select__button > span { min-width: 0; overflow-wrap: anywhere; white-space: normal; }
.gx-select__button > i { flex-shrink: 0; }
.gx-select:focus-within { outline: 2px solid var(--primary); outline-offset: 2px; }
.gx-select__button:disabled { opacity: .5; cursor: default; }
.gx-select-menu { position: fixed; inset: auto; margin: 0; padding: 6px; box-sizing: border-box; max-width: none; overflow-y: auto; overscroll-behavior: contain; border: 1px solid var(--outline-variant, var(--glass-border)); border-radius: var(--radius-md); color: var(--on-surface); background: var(--surface-container-high, #252033); box-shadow: 0 12px 36px #0006; font: var(--fs-base) var(--font-body); }
.gx-select-menu::backdrop { background: transparent; }
.gx-select-menu [role="option"] { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; min-height: 44px; padding: 10px 12px; border: 0; border-radius: 10px; background: transparent; color: inherit; text-align: left; font: inherit; white-space: normal; overflow-wrap: anywhere; cursor: pointer; touch-action: manipulation; }
.gx-select-menu [role="option"][aria-selected="true"] { background: var(--primary-container); color: var(--on-primary-container); }
.gx-select-menu [role="option"]:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; background: var(--primary-container); color: var(--on-primary-container); }
@media (hover: hover) {
.gx-select-menu [role="option"]:hover:not(:disabled) { outline: 2px solid var(--primary); outline-offset: -2px; background: var(--primary-container); color: var(--on-primary-container); }
}
.gx-select-menu [role="option"]:disabled { opacity: .45; cursor: default; }
.gx-select-menu__group { padding: 10px 12px 4px; color: var(--on-surface-variant); font-size: .85em; }
.gx-select-menu { background: linear-gradient(var(--surface-container-high), var(--surface-container-high)), #141020; }
[data-theme="light"] .gx-select-menu { background: linear-gradient(var(--surface-container-high), var(--surface-container-high)), #fff; }
.gx-select-menu:not([open]) { display: none; }
.gx-slider-row.is-fine-scrubbing input[type="range"].gx-slider {
background: rgba(157, 114, 255, 0.28);
box-shadow: 0 0 8px var(--primary-glow);
}
.gx-slider-row.is-fine-scrubbing input[type="range"].gx-slider::-webkit-slider-thumb {
transform: scale(1.35);
background: var(--secondary);
border-color: #ffffff;
box-shadow: 0 0 10px var(--secondary-glow);
}
.gx-slider-row.is-fine-scrubbing input[type="range"].gx-slider::-moz-range-thumb {
transform: scale(1.35);
background: var(--secondary);
border-color: #ffffff;
box-shadow: 0 0 10px var(--secondary-glow);
}
.gx-slider-row.is-fine-scrubbing .gx-row__value {
color: var(--secondary);
text-shadow: 0 0 10px var(--secondary-glow);
}
.gx-model-control { flex-wrap: wrap; }
.gx-model-control > .gx-row__label { flex: 0 1 160px; min-width: 0; }
.gx-model-control > .gx-field { flex: 1 1 240px; min-width: 0; }
@media (max-width: 520px) {
.gx-model-control { align-items: stretch; flex-direction: column; }
.gx-model-control > .gx-row__label { flex: none; }
.gx-model-control > .gx-field { flex: none; width: 100%; }
}
.gx-model-manager .gx-card-grid { grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); }
.gx-model-manager .gx-card { overflow: visible; }
.gx-model-manager .gx-card > div { min-width: 0; }
.gx-model-manager .gx-card strong, .gx-model-manager .gx-chip { overflow-wrap: anywhere; }
.gx-model-manager .gx-alert { display: flex; align-items: center; flex-wrap: wrap; gap: var(--sp-3); border-radius: var(--radius-md); padding: var(--sp-3) var(--sp-4); }
.gx-model-manager .gx-alert--info { background: rgba(157, 114, 255, .12); }
.gx-model-manager .gx-alert--warn { background: rgba(255, 184, 101, .12); }
.gx-model-manager .gx-alert__icon { font-size: 1.4rem; color: var(--primary); }
.gx-model-manager .gx-alert__body { flex: 1 1 220px; }
.gx-model-manager .gx-alert__body strong { display: block; font-weight: var(--fw-bold); }
.gx-model-manager .gx-alert__body span { color: var(--text-muted); font-size: var(--fs-sm); }
.gx-model-manager .gx-btn--danger { background: var(--error); color: var(--on-error); }
.gx-model-reason { margin: 0; padding: 0 var(--sp-3) var(--sp-3); color: var(--text-muted); font-size: var(--fs-sm); }
.gx-model-runtime { margin-top: var(--sp-3); }
.gx-model-runtime__body { padding: 0 var(--sp-3) var(--sp-3); overflow-wrap: anywhere; }
.gx-model-runtime__body p { margin: 0 0 var(--sp-2); }
.gx-layout { width: 100%; max-width: 1280px; min-width: 0; }
.gx-favorites__status, .gx-favorites__slot-header, .gx-favorites__show { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.gx-favorites__slots { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 310px), 1fr)); gap: 18px; }
.gx-favorites__slot { display: grid; gap: 18px; padding: 24px; min-width: 0; }
.gx-favorites__slot h3 { margin: 0; }
.gx-favorites__slot small { display: block; color: var(--on-surface-variant); font-size: var(--fs-sm); font-weight: normal; margin-top: 4px; }
.gx-favorites__slot > label { display: grid; gap: 8px; min-width: 0; }
.gx-favorites__slot input[type="text"] { min-width: 0; width: 100%; }
.gx-favorites__slot .gx-switch { flex-shrink: 0; }
.gx-controllers__switch-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.gx-controllers__row { flex-wrap: wrap; }
.gx-controllers__row .gx-row__info { flex: 1 1 180px; }
.gx-controllers__selector { display: grid; gap: 8px; }
.gx-controllers__selector .gx-field { width: 100%; min-width: 0; }
.gx-controllers__row .gx-btn { flex: 0 0 auto; }
@media (max-width: 440px) {
.gx-controllers__row .gx-row__info, .gx-controllers__row .gx-btn { flex-basis: 100%; }
}
.gx-layout h3, .gx-layout h4, .gx-layout p { margin: 0; }
.gx-layout .gx-settings__header p { max-width: 760px; }
.gx-layout__savebar, .gx-layout__subhead { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.gx-layout__savebar { padding: 14px 18px; border: 1px solid var(--glass-border); border-radius: var(--radius-md); background: var(--surface-container); }
.gx-layout__savebar > span, .gx-layout__subhead > :first-child { margin-right: auto; }
.gx-layout__subhead > span, .gx-layout__savebar > span { color: var(--on-surface-variant); }
.gx-layout__tabs { display: flex; gap: 10px; }
.gx-layout__zone-toggle { display: flex; align-items: center; gap: 8px; color: var(--on-surface-variant); font-size: var(--fs-sm); }
.gx-layout__zone-toggle input { accent-color: var(--primary); }
.gx-layout button[aria-pressed="true"] { background: var(--primary-container); color: var(--on-primary-container); outline: 2px solid var(--primary); outline-offset: -2px; }
.gx-layout__workspace { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 18px; align-items: start; min-width: 0; }
.gx-layout__preview-card, .gx-layout__inspector, .gx-layout__colors, .gx-layout__discard { padding: 20px; display: grid; gap: 16px; min-width: 0; }
.gx-layout__preview-card { padding: 20px 0; }
.gx-layout__preview-card > :not(svg) { margin-inline: 20px; }
.gx-layout__preview { display: block; width: 100%; height: auto; border-block: 1px solid var(--glass-border); touch-action: none; user-select: none; }
.gx-layout__device { display: grid; gap: 12px; min-width: 0; border-top: 1px solid var(--glass-border); padding-top: 16px; }
.gx-layout__device .gx-layout__scenes { margin-inline: 0; }
.gx-layout__device-frame { display: grid; place-items: center; min-width: 0; width: 100%; overflow: hidden; background: #090c10; color: var(--on-surface-variant); border: 1px solid var(--glass-border); }
.gx-layout__device-frame img { display: block; width: 100%; height: 100%; object-fit: contain; }
.gx-layout__device-frame span { padding: 16px; text-align: center; }
.gx-layout__scenes { display: flex; flex-wrap: wrap; gap: 8px; margin-inline: 20px; }
.gx-layout__preview-status { min-height: 24px; margin-inline: 20px; display: flex; align-items: center; gap: 10px; color: var(--text-muted); }
.gx-layout__preview-status:empty { min-height: 0; }
.gx-layout__widget { cursor: grab; outline: none; }
.gx-layout__widget:active { cursor: grabbing; }
.gx-layout-widget-art { pointer-events: none; font-family: var(--font-body); }
.gx-layout__selection { pointer-events: none; opacity: 0; }
.gx-layout__widget.is-selected .gx-layout__selection, .gx-layout__widget:focus-visible .gx-layout__selection { opacity: 1; }
.gx-layout__widget:focus-visible .gx-layout__selection { stroke: #fff; stroke-dasharray: none; }
.gx-layout__widget-list { display: grid; gap: 8px; }
.gx-layout__widget-list > button { justify-content: flex-start; text-align: left; white-space: normal; }
.gx-layout__position, .gx-layout__tray, .gx-layout__color { display: grid; gap: 10px; }
.gx-layout__position, .gx-layout__tray { padding-block: 16px; border-top: 1px solid var(--glass-border); }
.gx-layout__coordinates { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.gx-layout__coordinates label { display: grid; gap: 6px; }
.gx-layout__coordinates input { width: 100%; min-width: 0; }
.gx-layout__tray-item { display: flex; gap: 6px; align-items: center; border: 1px solid var(--glass-border); border-radius: var(--radius-sm); padding: 6px; }
.gx-layout__drag-handle { flex: 1; min-width: 0; display: flex; align-items: center; gap: 4px; min-height: 44px; color: var(--on-surface); background: transparent; border: 0; font: inherit; text-align: left; cursor: grab; touch-action: none; }
.gx-layout__drag-handle:disabled { opacity: .5; cursor: default; }
.gx-layout__drag-handle i { color: var(--on-surface-variant); }
.gx-layout__palette { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }
.gx-layout__inspector .gx-layout__colors { padding: 0; }
.gx-layout .gx-layout__reset { justify-self: start; flex: 0 0 auto; min-height: 36px; padding: 7px 12px; font-size: var(--fs-sm); }
.gx-layout__inspector .gx-layout__palette { grid-template-columns: 1fr; gap: 18px; }
.gx-layout__inspector .gx-layout__colors .gx-layout__subhead { flex-wrap: wrap; }
.gx-layout__color > label:first-child { font-weight: var(--fw-bold); }
.gx-layout__color-values { display: flex; gap: 10px; align-items: center; }
.gx-layout__color-values input[type="color"] { width: 48px; height: 44px; flex: 0 0 48px; padding: 3px; background: var(--surface-container-high); border: 1px solid var(--glass-border); border-radius: 8px; cursor: pointer; }
.gx-layout__color-values input[type="text"] { min-width: 0; width: 100%; font-family: ui-monospace, monospace; }
.gx-layout__color input[type="range"] { width: 100%; accent-color: var(--primary); min-height: 36px; }
.gx-layout__color > label { color: var(--on-surface-variant); }
.gx-layout__discard { border: 1px solid var(--primary); }
@media (min-width: 1041px) and (max-width: 1360px) {
.gx-app.gx-nav-pinned .gx-layout__workspace { grid-template-columns: minmax(0, 1fr); }
.gx-app.gx-nav-pinned .gx-layout__inspector { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.gx-app.gx-nav-pinned .gx-layout__inspector > h3 { grid-column: 1 / -1; }
.gx-app.gx-nav-pinned .gx-layout__position { grid-column: 2; grid-row: 2 / span 2; border-top: 0; padding-top: 0; }
.gx-app.gx-nav-pinned .gx-layout__tray { grid-column: 1; }
.gx-app.gx-nav-pinned .gx-layout__inspector > button:last-child { grid-column: 1 / -1; }
}
@media (max-width: 1040px) {
.gx-layout__workspace { grid-template-columns: 1fr; }
.gx-layout__inspector { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.gx-layout__inspector > h3 { grid-column: 1 / -1; }
.gx-layout__position { grid-column: 2; grid-row: 2 / span 2; border-top: 0; padding-top: 0; }
.gx-layout__tray { grid-column: 1; }
.gx-layout__inspector > button:last-child { grid-column: 1 / -1; }
}
@media (max-width: 600px) {
.gx-layout__palette, .gx-layout__inspector { grid-template-columns: 1fr; }
.gx-layout__position { grid-column: auto; grid-row: auto; padding-top: 16px; border-top: 1px solid var(--glass-border); }
.gx-layout__savebar > span { flex-basis: 100%; }
.gx-layout__savebar > button { flex: 1; }
.gx-layout__color:not(:last-child) { padding-bottom: 18px; border-bottom: 1px solid var(--glass-border); }
}
@@ -0,0 +1,96 @@
{
"mode": "offline-preview",
"tools": [
{
"name": "Bluetooth",
"path": "/bluetooth",
"icon": "bi-bluetooth",
"description": "Adapter power, saved devices and connections",
"availability": "local-only"
},
{
"name": "Cameras & Monitoring",
"path": "/cameras",
"icon": "bi-camera-video",
"description": "Saved Side Camera, Sentry motion, and V-ASM choices; live camera tools pending",
"availability": "local-only"
},
{
"name": "Force Drive State",
"path": "/device-preferences",
"icon": "bi-volume-up",
"description": "Select a temporary drive state for this device",
"availability": "local-only"
},
{
"name": "Galaxy Tunnel",
"path": "/galaxy",
"icon": "bi-globe2",
"description": "Remote access and device pairing",
"availability": "local-only"
},
{
"name": "Logs & Diagnostics",
"path": "/logs",
"icon": "bi-exclamation-triangle",
"description": "Live console, driving configuration, system activity and crash reports",
"availability": "partial-preview"
},
{
"name": "Model Manager",
"path": "/manage_models",
"icon": "bi-cpu",
"description": "Bundled model identity and runtime health",
"availability": "local-only"
},
{
"name": "Model Laboratory",
"path": "/model_laboratory",
"icon": "bi-bezier2",
"description": "Pair lateral and longitudinal eGPU variants",
"availability": "local-only"
},
{
"name": "Onroad Appearance",
"path": "/appearance",
"icon": "bi-display",
"description": "Saved compact border and onroad visibility choices",
"availability": "local-only"
},
{
"name": "Navigation & Maps",
"path": "/navigation",
"icon": "bi-map",
"description": "Destinations, directions, saved places and offline maps",
"availability": "local-only"
},
{
"name": "System Tools",
"path": "/system",
"icon": "bi-arrow-repeat",
"description": "Branch updates, automatic downloads and version history",
"availability": "local-only"
},
{
"name": "Theme Maker",
"path": "/theme_maker",
"icon": "bi-palette-fill",
"description": "Parked colors and Big or Small driving layouts",
"availability": "local-only"
},
{
"name": "Plots & Analysis",
"path": "/tuning",
"icon": "bi-sign-turn-right",
"description": "Live steering and acceleration plots, and recorded-drive analysis",
"availability": "local-only"
},
{
"name": "Vehicle Controls",
"path": "/vehicle",
"icon": "bi-car-front",
"description": "Parked Auto or manual next-start vehicle selection",
"availability": "local-only"
}
]
}
@@ -0,0 +1 @@
{"schemaVersion": 1, "monitor": "sample"}
@@ -0,0 +1,21 @@
{
"mode": "synthetic-preview",
"schemaVersion": 1,
"source": "Illustrative Galaxy System Monitor sample; no device was read",
"sampledAt": 1789992000,
"cpuPercent": 27.4,
"cores": [{"name":"cpu0","percent":32.1},{"name":"cpu1","percent":22.7},{"name":"cpu2","percent":null},{"name":"cpu3","percent":28.3}],
"memory": {"totalMiB":7872,"usedMiB":3182.4,"availableMiB":4689.6,"percent":40.4},
"storage": {"usedGiB":38.7,"totalGiB":118.1},
"uptimeSeconds": 98432,
"processCount": 6,
"processes": [
{"pid":101,"name":"selfdrive.controls.controlsd","user":"comma","kernel":false,"state":"R","cpu":8.3,"memoryMiB":222.6},
{"pid":102,"name":"selfdrive.modeld.modeld","user":"comma","kernel":false,"state":"S","cpu":12.7,"memoryMiB":410.2},
{"pid":103,"name":"system.hardware.hardwared","user":"comma","kernel":false,"state":"S","cpu":null,"memoryMiB":91.1},
{"pid":104,"name":"system.loggerd.uploader","user":"comma","kernel":false,"state":"S","cpu":1.2,"memoryMiB":73.4},
{"pid":105,"name":"system.manager.manager","user":"root","kernel":false,"state":"R","cpu":2.4,"memoryMiB":54.5},
{"pid":2,"name":"kthreadd","user":"root","kernel":true,"state":"S","cpu":null,"memoryMiB":0}
],
"vitals": {"cpuTempC":51.2,"gpuTempC":48.4,"hotspotTempC":null,"gpuEdgeTempC":null,"memoryUsedBytes":null,"memoryTotalBytes":null,"onboardMaxAgeMs":2500,"maxAgeMs":0,"memoryMaxAgeMs":0}
}
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#6f42a6">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Galaxy">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Galaxy</title>
<link rel="icon" type="image/svg+xml" href="./assets/galaxy-icon.svg">
<link rel="apple-touch-icon" sizes="180x180" href="./assets/galaxy-icon-180.png">
<link rel="manifest" href="./manifest.webmanifest" crossorigin="use-credentials">
<link rel="stylesheet" href="./vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="./css/galaxy.css">
<link rel="stylesheet" href="./css/settings.css">
</head>
<body>
<div id="galaxy-bg" aria-hidden="true"></div>
<section id="galaxy-boot" class="gx-card gx-message" role="status">
<h1>Galaxy</h1><p>Opening Galaxy…</p><button type="button" class="gx-btn" hidden>Try again</button>
<noscript>Enable JavaScript to open Galaxy.</noscript>
</section>
<div id="galaxy-app" v-cloak></div>
<script type="module" src="./js/boot.js"></script>
</body>
</html>
@@ -0,0 +1,13 @@
export function spawnAmbientStars(background, random = Math.random) {
if (!background || background.querySelector(".galaxy-hero")) return 0
for (let index = 0; index < 14; index++) {
const star = background.ownerDocument.createElement("i")
star.className = "galaxy-hero"
star.style.left = `${(random() * 100).toFixed(2)}%`
star.style.top = `${(random() * 100).toFixed(2)}%`
star.style.animationDelay = `${(random() * 4).toFixed(2)}s`
star.style.width = star.style.height = random() > 0.6 ? "3px" : "2px"
background.appendChild(star)
}
return 14
}
@@ -0,0 +1,457 @@
import { BluetoothDeviceList } from "./bluetooth-devices.js"
export function uploadPackage(path, options, progress, makeRequest = () => new XMLHttpRequest()) {
return new Promise((resolve, reject) => {
const xhr = makeRequest()
let settled = false
const abort = () => xhr.abort()
const finish = (callback, value) => {
if (settled) return
settled = true
options.signal?.removeEventListener("abort", abort)
callback(value)
}
xhr.open("POST", path)
xhr.withCredentials = true
xhr.timeout = 180000
xhr.setRequestHeader("Content-Type", "application/octet-stream")
xhr.upload.onprogress = (event) => {
if (!settled && event.lengthComputable) progress({ loaded: event.loaded, total: event.total })
}
xhr.onload = () => finish(resolve, { status: xhr.status, ok: xhr.status >= 200 && xhr.status < 300,
json: async () => JSON.parse(xhr.responseText) })
xhr.onerror = () => finish(reject, new Error("Upload interrupted. Check your connection and retry."))
xhr.ontimeout = () => finish(reject, new Error("Upload timed out. Check your connection and retry."))
xhr.onabort = () => finish(reject, new Error("Upload canceled."))
options.signal?.addEventListener("abort", abort, { once: true })
if (options.signal?.aborted) { finish(reject, new Error("Upload canceled.")); return }
xhr.send(options.body)
})
}
const ADDRESS = /^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$/
const PROMPT = new Set(["pin", "passkey", "confirmation", "authorization", "display_pin", "display_passkey"])
export function validPromptInput(prompt, value) {
if (prompt?.kind === "pin") return /^[\x20-\x7E]{1,16}$/.test(value)
if (prompt?.kind === "passkey") return /^[0-9]{1,6}$/.test(value)
return ["confirmation", "authorization"].includes(prompt?.kind) && value === ""
}
export function validPairingStatus(value) {
if (!value || typeof value.active !== "boolean" || typeof value.approved !== "boolean") return false
if (value.devices !== undefined && (!Array.isArray(value.devices) || !value.devices.every(validDiscoveredDevice))) return false
if (value.discovering !== undefined && typeof value.discovering !== "boolean") return false
if (value.state !== undefined && !["idle", "pairing", "paired", "failed", "connecting", "connected"].includes(value.state)) return false
if (value.error !== undefined && typeof value.error !== "string") return false
const receiver = value.receiver
if (receiver !== null && (!ADDRESS.test(receiver?.address) || typeof receiver.name !== "string" || receiver.name.length > 80)) return false
const prompt = value.prompt
return prompt === null || (receiver !== null && /^[0-9a-f]{32}$/.test(prompt?.id) && PROMPT.has(prompt.kind) &&
typeof prompt.value === "string" && prompt.value.length <= 16 && typeof prompt.displayOnly === "boolean")
}
export function validDiscoveredDevice(value) {
return value !== null && ADDRESS.test(value?.address) && typeof value.name === "string" && value.name.length <= 80 &&
["paired", "connected", "android_auto"].every((key) => typeof value[key] === "boolean")
}
function validSelected(value) {
return value === null || (ADDRESS.test(value?.address) && typeof value.name === "string" && value.name.length <= 80)
}
function validSetup(value) {
return value && typeof value.enabled === "boolean" && typeof value.bluetoothEnabled === "boolean" &&
typeof value.parked === "boolean" && typeof value.installReady === "boolean" && typeof value.serviceReady === "boolean" &&
value.identity && typeof value.identity.installed === "boolean" && value.import &&
Number.isSafeInteger(value.maxUploadBytes) && value.maxUploadBytes > 0
}
export class AndroidAutoFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
uploader = uploadPackage, later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, uploader, later, cancelTimer })
this.active = false
this.generation = 0
this.controller = null
this.timer = null
this.pollTimer = null
this.setup = null
this.pairing = null
this.selected = null
this.runtime = null
this.receivers = []
this.endReason = ""
this.busy = false
this.refreshing = false
this.deviceList = new BluetoothDeviceList()
this.receiverList = new BluetoothDeviceList()
this.uploadProgress = null
this.error = ""
}
emit() { this.publish({ setup: this.setup, pairing: this.pairing, selected: this.selected, runtime: this.runtime,
receivers: this.receivers,
endReason: this.endReason, busy: this.busy, uploadProgress: this.uploadProgress, error: this.error }) }
stop(cancelPair = false) {
if (cancelPair && this.active && this.pairing?.active) {
this.fetcher("./api/android-auto/pairing/cancel", { method: "POST", credentials: "same-origin", keepalive: true,
headers: { "Content-Type": "application/json" }, body: "{}" }).catch(() => {})
}
this.active = false
this.generation++
this.controller?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.controller = this.timer = this.pollTimer = null
this.busy = false
this.refreshing = false
this.deviceList.reset()
this.receiverList.reset()
this.uploadProgress = null
this.pairing = null
this.emit()
}
start() {
this.stop()
this.active = true
return this.refresh()
}
async request(path, options = {}, timeout = 8000, transport = this.fetcher, background = false) {
if (!background) {
// A user action supersedes a pending poll, including a delayed JSON body.
this.generation++
this.controller?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.pollTimer = null
this.refreshing = false
}
const generation = this.generation
const controller = new AbortController()
this.controller = controller
if (!background) { this.busy = true; this.emit() }
let timedOut = false
this.timer = this.later(() => { timedOut = true; controller.abort() }, timeout)
try {
const response = await transport(path, { ...options, credentials: "same-origin", cache: "no-store", signal: controller.signal })
if (!this.active || this.generation !== generation || controller.signal.aborted) return null
if (response.status === 401) { this.stop(); this.unauthorized(); return null }
const data = await response.json()
if (!this.active || this.generation !== generation || controller.signal.aborted) return null
if (!response.ok) throw new Error(data?.error || "Android Auto setup is unavailable")
this.error = ""
return data
} catch (error) {
if (this.active && this.generation === generation) {
this.error = timedOut ? "Android Auto took too long to respond. Try again." :
error instanceof Error ? error.message : "Android Auto setup is unavailable"
}
return null
} finally {
if (this.controller === controller) {
this.cancelTimer(this.timer)
this.controller = this.timer = null
if (!background) {
this.busy = false
if (this.active && this.generation === generation && this.pollTimer === null) {
this.pollTimer = this.later(() => this.refresh(), this.pairing?.active ? 1000 : 5000)
}
}
this.emit()
}
}
}
async refresh() {
if (!this.active || this.busy || this.refreshing) return
this.refreshing = true
if (this.pollTimer !== null) { this.cancelTimer(this.pollTimer); this.pollTimer = null }
const generation = this.generation
const setup = await this.request("./api/android-auto/setup", {}, 8000, this.fetcher, true)
if (!this.active || this.generation !== generation) return
const setupValid = setup && validSetup(setup)
if (setupValid) this.setup = setup
else if (setup) this.error = "Android Auto setup response changed"
if (setupValid && !setup.enabled) {
this.pairing = this.selected = this.runtime = null
this.receivers = []
this.deviceList.reset()
this.receiverList.reset()
this.emit()
}
if (this.active && this.generation === generation && setupValid && setup.enabled) {
const result = await this.request("./api/android-auto/pairing/status", {}, 8000, this.fetcher, true)
if (!this.active || this.generation !== generation) return
if (result && validPairingStatus(result.pairing) && validSelected(result.selectedReceiver)) {
const wasActive = this.pairing?.active
if (!result.pairing.active) this.deviceList.reset()
this.pairing = { ...result.pairing, devices: this.deviceList.update(result.pairing.devices || []) }
this.selected = result.selectedReceiver
this.runtime = result.runtime && typeof result.runtime === "object" ? result.runtime : null
if (wasActive && !this.pairing.active && !this.selected && !this.endReason) this.endReason = "ended"
}
else if (result) this.error = "Pairing status response changed"
}
this.refreshing = false
this.emit()
if (this.active && this.generation === generation) {
const delay = this.pairing?.active || this.setup?.import?.state === "running" ? 1000 : 5000
this.pollTimer = this.later(() => this.refresh(), delay)
}
}
async action(path, body = {}, timeout = 12000) {
if (!this.active || this.busy || !this.setup?.parked || !this.setup?.enabled) return false
const result = await this.request(path, { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body) }, timeout)
if (result === null) return false
if (path.endsWith('/pairing')) this.endReason = ""
if (path.endsWith('/cancel')) this.endReason = "canceled"
await this.refresh()
return true
}
async selectDevice(address) {
if (!ADDRESS.test(address) || !this.pairing?.active || this.pairing?.prompt ||
["pairing", "connecting"].includes(this.pairing?.state) || this.runtime?.running ||
!this.pairing?.devices?.some((device) => device.address === address)) return false
return this.action("./api/android-auto/pairing/select", { address })
}
async loadReceivers() {
if (!this.active || this.busy || !this.setup?.enabled || this.pairing?.active) return false
const result = await this.request("./api/android-auto/receivers")
if (result === null) return false
if (!Array.isArray(result.receivers) || !result.receivers.every((car) => car !== null && validSelected(car))) {
this.error = "Paired car list changed"
this.emit()
return false
}
this.receivers = this.receiverList.update(result.receivers)
this.emit()
return true
}
async control(action, extra = {}) {
if (!this.active || this.busy || !this.setup?.enabled || this.pairing?.active ||
!["start", "stop", "select_receiver", "auto_connect"].includes(action)) return false
const result = await this.request("./api/android-auto/control", { method: "POST",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, ...extra }) })
if (result === null) return false
await this.refresh()
return true
}
async setEnabled(enabled) {
if (!this.active || this.busy || typeof enabled !== "boolean" ||
enabled && (!this.setup?.parked || !this.setup?.installReady)) return false
const result = await this.request("./api/android-auto/enable", { method: "POST",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled }) })
if (result === null) return false
await this.refresh()
return true
}
async upload(file) {
if (!this.active || this.busy || !this.setup?.parked || !this.setup?.enabled ||
!file || !Number.isSafeInteger(file.size) || file.size <= 0 || file.size > this.setup.maxUploadBytes) {
this.error = "Choose an APK, XAPK, or APKM within the shown size limit."
this.emit()
return false
}
this.uploadProgress = { loaded: 0, total: file.size }
const generation = this.generation + 1
const transport = (path, options) => this.uploader(path, options, (progress) => {
if (this.active && generation === this.generation) { this.uploadProgress = progress; this.emit() }
})
const result = await this.request("./api/android-auto/upload", { method: "POST",
headers: { "Content-Type": "application/octet-stream" }, body: file }, 180000, transport)
this.uploadProgress = null
this.emit()
if (result === null) return false
await this.refresh()
return true
}
}
export const AndroidAutoPage = {
props: { mode: { type: String, required: true }, localAccess: { type: Boolean, required: true },
unauthorized: { type: Function, required: true } },
data: () => ({ setup: null, pairing: null, selected: null, runtime: null, receivers: [], endReason: "", busy: false, error: "",
pairValue: "", packageFile: null, uploadProgress: null }),
mounted() {
this.feed = new AndroidAutoFeed({ publish: (value) => Object.assign(this, value), unauthorized: this.unauthorized })
this.visibility = () => document.hidden ? this.feed.stop(true) : this.feed.start()
document.addEventListener("visibilitychange", this.visibility)
if (!document.hidden) this.feed.start()
},
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed?.stop(true) },
watch: {
mode() { if (!document.hidden) this.feed?.start(); else this.feed?.stop(true) },
localAccess() { if (!document.hidden) this.feed?.start(); else this.feed?.stop(true) },
'pairing.prompt.id'() { this.pairValue = "" },
},
computed: {
canRespond() { return validPromptInput(this.pairing?.prompt, this.pairValue) },
packageError() {
if (!this.packageFile) return ""
if (!Number.isSafeInteger(this.packageFile.size) || this.packageFile.size <= 0) return "The selected file is empty or its size cannot be read."
if (this.setup && this.packageFile.size > this.setup.maxUploadBytes) return "The selected package exceeds the maximum size shown below."
return ""
},
uploadReason() {
if (!this.packageFile) return "Choose your Android Auto package."
if (this.packageError) return this.packageError
if (!this.setup?.parked) return "Use offroad mode or Park to upload your package."
if (!this.setup.enabled && !this.setup.installReady) return "The Android Auto display and encoder must be installed before uploading."
if (this.setup.import?.state === "running") return "Wait for the current package verification to finish."
if (this.busy) return "Waiting for Android Auto setup to respond…"
return ""
},
pairingReason() {
if (!this.setup) return "Load setup status first."
if (!this.setup.installReady) return "This build is missing the Android Auto display or encoder."
if (!this.setup.enabled) return "Enable Android Auto in Prepare."
if (!this.setup.serviceReady) return "Waiting for the Android Auto service to start."
if (!this.setup.bluetoothEnabled) return "Turn on Bluetooth on the Bluetooth page."
if (!this.setup.parked) return "Use offroad mode or Park before starting pairing."
if (!this.setup.identity.installed) return "Upload and verify your Android Auto package in Prepare."
if (this.runtime?.running) return "Stop projection before searching or pairing."
return ""
},
waitingForCar() { return this.pairing?.active && !this.pairing?.receiver && !this.pairing?.prompt && !this.pairing?.approved &&
!["pairing", "connecting", "paired", "connected", "failed"].includes(this.pairing?.state) },
deviceSelectionBlocked() { return this.busy || !!this.pairingReason || !this.pairing?.active ||
!!this.pairing?.prompt || ["pairing", "connecting"].includes(this.pairing?.state) || !!this.runtime?.running },
},
methods: {
refresh() { return this.feed.refresh() },
setEnabled(enabled) { return this.feed.setEnabled(enabled) },
startPairing() {
if (this.pairingReason || this.runtime?.running) return false
return this.feed.action("./api/android-auto/pairing")
},
selectDevice(address) { return this.feed.selectDevice(address) },
cancelPairing() { return this.feed.action("./api/android-auto/pairing/cancel") },
respond(accepted) {
const prompt = this.pairing?.prompt
if (!prompt || prompt.displayOnly || accepted && !this.canRespond) return
const value = accepted && ["pin", "passkey"].includes(prompt.kind) ? this.pairValue : ""
return this.feed.action("./api/android-auto/pairing/response", { prompt_id: prompt.id, accepted, value })
},
choosePackage(event) { this.packageFile = event.target.files?.[0] || null },
async upload() {
if (this.uploadReason) return false
const file = this.packageFile
if (!this.setup.enabled && !await this.feed.setEnabled(true)) return false
return this.feed.upload(file)
},
loadReceivers() { return this.feed.loadReceivers() },
control(action, extra = {}) { return this.feed.control(action, extra) },
},
template: `
<section class="gx-driving" aria-label="Android Auto setup">
<div class="gx-card gx-driving__intro"><h2>Android Auto</h2>
<p>Show StarPilot on your car’s wireless Android Auto display. Enable projection, upload your Android Auto package, then find your car or wireless adapter. Setup works here over local or remote Galaxy.</p></div>
<div class="gx-android-auto-setup">
<p v-if="error" class="gx-card gx-message" role="alert">{{ error }}</p>
<div v-if="!setup" class="gx-card gx-message" role="status">{{ error ? "Setup could not be loaded." : "Checking Android Auto setup…" }} <button class="gx-btn gx-btn--tonal" :disabled="busy" @click="refresh">Retry</button></div>
<template v-else>
<div class="gx-card gx-driving__intro"><h3>1. Prepare</h3>
<p v-if="!setup.installReady" role="status">Android Auto display and encoder are not installed on this build. Projection setup is unavailable.</p>
<p v-else-if="setup.enabled && !setup.serviceReady" role="status">Starting the Android Auto service. Pairing is unavailable until it is ready.</p>
<div class="gx-driving__actions"><button class="gx-btn" :disabled="busy || !setup.parked || !setup.installReady" v-if="!setup.enabled" @click="setEnabled(true)">Enable Android Auto</button>
<button class="gx-btn gx-btn--tonal" :disabled="busy" v-else @click="setEnabled(false)">Turn Off Android Auto</button></div>
<p v-if="!setup.bluetoothEnabled">Open <a href="/bluetooth">Bluetooth</a> and turn it on before pairing.</p>
<p v-if="!setup.parked">Use offroad mode or Park to upload a package or pair your car.</p>
<p>{{ setup.identity.message || (setup.identity.installed ? 'Your Android Auto package is ready.' : 'Upload your Android Auto APK, XAPK, or APKM.') }}</p>
<details><summary>Where do I get the Android Auto package?</summary>
<ol>
<li>Open <a href="https://www.apkmirror.com/apk/google-inc/android-auto/" target="_blank" rel="noopener noreferrer">Android Auto by Google LLC on APKMirror</a>, a third-party download site.</li>
<li>Download the Android Auto app package to this phone or computer. Choose the app's APK or bundle download; you do not need the APKMirror Installer app.</li>
<li>Return here, choose the downloaded APK, XAPK, or APKM file, and press Upload Package. Leave bundles zipped.</li>
</ol>
<p>You do not install an Android app on the comma. Galaxy imports the information needed to connect to your car and checks that it is usable.</p>
</details>
<p v-if="setup.identity.warning" role="status">{{ setup.identity.warning }}</p>
<p v-if="setup.import?.state === 'running'" role="status">Checking your package…</p>
<p v-if="setup.import?.state === 'failed'" role="alert">Package verification failed: {{ setup.import.error || 'Try another package.' }}</p>
<p v-if="setup.import?.state === 'done' && setup.identity.installed" role="status">Package verified and ready.</p>
<div class="gx-driving__actions"><input type="file" accept=".apk,.xapk,.apkm" style="max-width:100%;min-width:0" aria-label="Android Auto APK, XAPK, or APKM" @change="choosePackage" />
<button class="gx-btn" :disabled="!!uploadReason" @click="upload">{{ setup.enabled ? 'Upload Package' : 'Enable and Upload Package' }}</button></div>
<p v-if="packageFile" role="status">Selected: {{ packageFile.name }} ({{ Math.ceil(packageFile.size / 1048576) }} MB).</p>
<div v-if="uploadProgress" role="status" aria-live="polite">
<progress :value="uploadProgress.loaded" :max="uploadProgress.total"></progress>
Uploading {{ Math.round(100 * uploadProgress.loaded / uploadProgress.total) }}%
</div>
<p v-if="uploadReason" :role="packageError ? 'alert' : 'status'">{{ uploadReason }}</p>
<small>Maximum package size: {{ Math.floor(setup.maxUploadBytes / 1048576) }} MB. Your package stays on this device.</small>
</div>
<div class="gx-card gx-driving__intro"><h3>2. Find and Pair</h3>
<p>Using a wireless Android Auto adapter? Pair with your vehicle first, then pair with the adapter. Select the adapter to start projection.</p>
<p>Look for an adapter name such as AndroidAuto-XXXX. Keep your vehicle paired for calls.</p>
<p v-if="!setup.identity.installed">Finish package verification before pairing.</p>
<p v-else-if="!pairing?.active && selected">Saved Android Auto receiver: {{ selected.name }}. Find a car or wireless adapter in offroad mode or Park.</p>
<p v-else-if="!pairing?.active && endReason === 'canceled'">Pairing canceled. Start again when your car is ready.</p>
<p v-else-if="!pairing?.active && endReason">The pairing window ended without selecting a car. Try again in offroad mode or Park.</p>
<p v-else-if="!pairing?.active">Open your car’s phone pairing screen or put your wireless adapter in pairing mode, then find it here.</p>
<p v-else-if="waitingForCar" role="status">{{ pairing.discovering ? "Searching nearby Bluetooth devices…" : "Search is open. Choose your car or wireless adapter below." }}</p>
<p v-else-if="pairing.approved && (!pairing.state || pairing.state === 'idle')" role="status">Approved {{ pairing.receiver?.name }}. Waiting for Bluetooth pairing to finish…</p>
<p v-if="pairing?.state === 'pairing' && !pairing.prompt && !pairing.approved" role="status">Pairing with {{ pairing.receiver?.name || 'the selected device' }}…</p>
<p v-if="pairing?.state === 'connecting'" role="status">Connecting to {{ pairing.receiver?.name || 'the selected device' }}…</p>
<p v-if="['paired', 'connected'].includes(pairing?.state)" role="status">{{ pairing.receiver?.name || 'Device' }} {{ pairing.state === 'connected' ? 'connected' : 'paired' }} over Bluetooth.</p>
<p v-if="pairing?.error" role="alert">{{ pairing.error }}</p>
<div v-if="pairing?.active" style="display:grid;gap:10px;margin:16px 0;min-width:0">
<p v-if="!pairing.devices?.length && waitingForCar" role="status">No devices found yet. Keep the car or adapter’s pairing screen open.</p>
<div v-for="device in pairing.devices || []" :key="device.address" style="display:flex;flex-wrap:wrap;align-items:center;gap:10px">
<span><strong>{{ device.name }}</strong><br />{{ device.android_auto ? 'Wireless Android Auto advertised' : 'Bluetooth device; Android Auto capability not yet advertised' }}<br />{{ device.connected ? 'Connected' : device.paired ? 'Saved Bluetooth pairing' : 'Nearby' }}</span>
<button class="gx-btn gx-btn--tonal" :disabled="deviceSelectionBlocked" @click="selectDevice(device.address)">{{ device.paired ? 'Connect' : 'Pair' }}</button>
</div>
<small>Bluetooth pairing alone does not confirm Android Auto support.</small>
</div>
<div v-if="pairing?.prompt" role="status" style="display:grid;gap:10px;margin:16px 0;min-width:0"><strong>{{ pairing.receiver.name }}</strong>
<p>{{ pairing.prompt.kind === 'confirmation' ? 'Check that this code matches your car, then confirm.' :
pairing.prompt.kind === 'pin' ? 'Enter the PIN shown by your car.' :
pairing.prompt.kind === 'passkey' ? 'Enter your car’s passkey.' :
pairing.prompt.kind === 'authorization' ? 'Allow this car to pair?' : 'Enter this code in your car.' }}</p>
<strong v-if="pairing.prompt.value" style="font-size:1.3rem;letter-spacing:.1em">{{ pairing.prompt.value }}</strong>
<input v-if="['pin','passkey'].includes(pairing.prompt.kind)" class="gx-field" type="text" maxlength="16" autocomplete="off"
:inputmode="pairing.prompt.kind === 'passkey' ? 'numeric' : 'text'" :aria-label="pairing.prompt.kind === 'pin' ? 'Car PIN' : 'Car passkey'" v-model="pairValue" />
<div v-if="!pairing.prompt.displayOnly" class="gx-driving__actions">
<button class="gx-btn gx-btn--tonal" :disabled="busy" @click="respond(false)">Reject</button>
<button class="gx-btn" :disabled="busy || !canRespond" @click="respond(true)">{{ pairing.prompt.kind === 'pin' ? 'Send PIN' : pairing.prompt.kind === 'passkey' ? 'Send Passkey' : 'Confirm' }}</button></div>
</div>
<p v-if="!pairing?.active && pairingReason" role="status">{{ pairingReason }}</p>
<p v-if="selected && !pairing?.active" class="gx-muted">For a wireless adapter, connect your paired car in Galaxy Bluetooth first. That car is saved for automatic reconnection before the adapter starts.</p>
<p v-if="runtime?.companion_name" class="gx-muted">Car: {{ runtime.companion_name }}</p>
<div class="gx-driving__actions"><button v-if="!pairing?.active" class="gx-btn" :disabled="busy || !!pairingReason" @click="startPairing">Find Car or Adapter</button>
<button v-else class="gx-btn gx-btn--tonal" :disabled="busy" @click="cancelPairing">Cancel Search / Pairing</button>
<button class="gx-btn gx-btn--tonal" :disabled="busy" @click="refresh">Refresh</button></div>
</div>
<div class="gx-card gx-driving__intro"><h3>3. Connect</h3>
<p v-if="selected">Selected Android Auto receiver: {{ selected.name }}.</p>
<p v-else>Select a paired Android Auto car before connecting.</p>
<p v-if="runtime?.state">Projection: {{ runtime.state }}<span v-if="runtime.detail"> — {{ runtime.detail }}</span>.</p>
<div class="gx-driving__actions">
<button class="gx-btn gx-btn--tonal" :disabled="busy || !setup.enabled || !setup.serviceReady || !setup.parked || pairing?.active" @click="loadReceivers">Find Paired Cars</button>
<button v-for="car in receivers" :key="car.address" class="gx-btn gx-btn--tonal"
:disabled="busy || !setup.parked || pairing?.active || runtime?.running || selected?.address === car.address"
@click="control('select_receiver', { address: car.address })">Select {{ car.name }}</button>
</div>
<div class="gx-driving__actions">
<button v-if="!runtime?.running" class="gx-btn" :disabled="busy || !selected || !setup.enabled || !setup.serviceReady || !setup.identity.installed || pairing?.active" @click="control('start')">Start Projection</button>
<button v-else class="gx-btn gx-btn--tonal" :disabled="busy" @click="control('stop')">Stop Projection</button>
<button class="gx-btn gx-btn--tonal" :disabled="busy || !setup.parked || pairing?.active"
@click="control('auto_connect', { enabled: !runtime?.auto_connect })">Automatic Connection: {{ runtime?.auto_connect ? 'On' : 'Off' }}</button>
</div>
<p>Wired USB setup is unavailable.</p>
<p>If this device's video encoder cannot start, projection stops and reports the error here.</p>
<p>Projection support still needs validation with your car and this device.</p></div>
</template>
</div>
</section>`,
}
+226
View File
@@ -0,0 +1,226 @@
import { createApp, reactive, defineAsyncComponent } from "../vendor/vue/vue.esm-browser.js"
import { route, navigate, startRouter } from "./router.js"
import { loadCatalog } from "./startup.js"
import { Tools } from "./tools.js"
const Logs = page(() => import("./logs.js").then((module) => module.Logs))
const SoftwarePage = page(() => import("./software-status.js").then((module) => module.SoftwarePage))
const NavigationPage = page(() => import("./navigation.js").then((module) => module.NavigationPage))
const ModelsPage = page(() => import("./models.js").then((module) => module.ModelsPage))
const LaboratoryPage = page(() => import("./model-laboratory.js").then((module) => module.LaboratoryPage))
import { SettingsPage } from "./settings.js"
import { ToggleSearch } from "./toggle-search.js"
const PlotsPage = page(() => import("./plots.js").then((module) => module.PlotsPage))
const FlmPage = page(() => import("./flm.js").then((module) => module.FlmPage))
import { LocalAuth } from "./auth-client.js"
import { Home } from "./home.js"
const LocalRecordingsPage = page(() => import("./record-history.js").then((module) => module.LocalRecordingsPage))
const CamerasPage = page(() => import("./cameras.js").then((module) => module.CamerasPage))
const SentryEventsPage = page(() => import("./sentry.js").then((module) => module.SentryEventsPage))
const VasmPage = page(() => import("./vasm.js").then((module) => module.VasmPage))
const PipPage = page(() => import("./pip.js").then((module) => module.PipPage))
import { DrivingPage, drivingPage } from "./driving.js"
const LongitudinalCurvesPage = page(() => import("./longitudinal-curves.js").then((module) => module.LongitudinalCurvesPage))
import { DevicePreferencesPage, devicePage } from "./device-preferences.js"
const BluetoothPage = page(() => import("./bluetooth.js").then((module) => module.BluetoothPage))
const AndroidAutoPage = page(() => import("./android-auto.js").then((module) => module.AndroidAutoPage))
const VehicleControlsPage = page(() => import("./vehicle-controls.js").then((module) => module.VehicleControlsPage))
const OnroadLayoutPage = page(() => import("./onroad-layout.js").then((module) => module.OnroadLayoutPage))
const GalaxyPage = page(() => import("./galaxy.js").then((module) => module.GalaxyPage))
import { DevicePicker } from "./device-picker.js"
import { DeviceState } from "./device-state.js"
import { InstallApp } from "./install-app.js"
import { spawnAmbientStars } from "./ambient-stars.js"
function page(loader) {
return defineAsyncComponent({
loader, delay: 150, timeout: 10000,
loadingComponent: { template: '<div class="gx-card gx-message" role="status">Opening page…</div>' },
errorComponent: { template: '<div class="gx-card gx-message" role="alert">This page could not open. <button class="gx-btn" @click="reload">Try again</button></div>',
methods: { reload() { location.reload() } } },
onError(_error, retry, fail, attempts) { if (attempts < 2) setTimeout(retry, 500); else fail() },
})
}
const THEME_KEY = "galaxy-preview-theme"
const PIN_KEY = "galaxy-preview-nav-pinned"
const stored = (key) => { try { return localStorage.getItem(key) } catch { return null } }
const save = (key, value) => { try { localStorage.setItem(key, value) } catch {} }
const state = reactive({
tools: [],
loading: true,
error: "",
pageError: "",
monitorMode: "sample",
drawerOpen: false,
navPinned: stored(PIN_KEY) === "true",
theme: stored(THEME_KEY) === "light" ? "light" : "dark",
searchPage: "",
})
const authState = reactive({ status: "checking", error: "", localAccess: false, gatewayAccess: false })
const authForm = reactive({ password: "" })
const auth = new LocalAuth({ publish: (update) => Object.assign(authState, update) })
const NAV = [
{ name: "Home", path: "/", icon: "bi-house-fill" },
{ name: "Toggles", path: "/settings", icon: "bi-toggle-on" },
{ name: "Tools", path: "/tools", icon: "bi-tools" },
{ name: "Recordings", path: "/recordings", icon: "bi-camera-reels" },
]
createApp({
components: { Home, Tools, Logs, SoftwarePage, NavigationPage, ModelsPage, LaboratoryPage, SettingsPage, ToggleSearch, PlotsPage, FlmPage, LocalRecordingsPage, CamerasPage, SentryEventsPage, VasmPage, PipPage, DrivingPage, DevicePreferencesPage, BluetoothPage, AndroidAutoPage, LongitudinalCurvesPage, VehicleControlsPage, OnroadLayoutPage, GalaxyPage, DevicePicker, DeviceState, InstallApp },
data: () => ({ state, authState, authForm, route, NAV }),
computed: {
currentTool() { return state.tools.find((tool) => route.path === tool.path || route.path.startsWith(tool.path + "/")) },
drivingSettingsPage() { return drivingPage(route.path) },
deviceSettingsPage() { return devicePage(route.path) },
pageName() {
if (this.currentTool) return this.currentTool.name
return NAV.find((item) => item.path === route.path)?.name || "This page"
},
isLight() { return state.theme === "light" },
connectionLabel() {
if (state.monitorMode !== "local") return "Preview"
if (authState.status === "authenticated") return authState.localAccess ? "Local connection" : "Remote connection"
return authState.status === "checking" ? "Connecting…" : "Not connected"
},
},
errorCaptured() { state.pageError = "This page could not finish loading."; return false },
watch: { 'route.path'() { state.pageError = "" } },
methods: {
retryStartup() { initialize() },
reloadPage() { location.reload() },
go(path) { state.drawerOpen = false; state.searchPage = ""; navigate(path) },
openSearchHit(hit) { state.drawerOpen = false; state.searchPage = hit.page; navigate("/settings") },
returnFromSearch() { state.searchPage = "" },
returnToCameras() { this.go("/cameras") },
returnToDriving() { this.go("/driving") },
returnToDevice() { this.go("/device-preferences") },
back() { if (["/cameras/pip", "/cameras/events", "/cameras/sentry-settings", "/cameras/vasm"].includes(route.path)) this.go("/cameras"); else if (route.path === "/tools") history.back(); else this.go("/tools") },
toggleTheme() {
state.theme = this.isLight ? "dark" : "light"
document.documentElement.dataset.theme = state.theme
save(THEME_KEY, state.theme)
},
togglePin() {
state.navPinned = !state.navPinned
state.drawerOpen = state.navPinned
save(PIN_KEY, String(state.navPinned))
},
isActive(path) { return route.path === path || route.path.startsWith(path + "/") },
async signIn() {
const password = authForm.password
authForm.password = ""
await auth.login(password)
},
async signOut() { await auth.logout() },
sessionExpired() { auth.expired(); auth.check() },
},
mounted() { document.documentElement.dataset.theme = state.theme },
template: `
<div class="gx-app" :class="{'gx-nav-pinned':state.navPinned}">
<header class="gx-appbar">
<button type="button" class="gx-icon-btn gx-appbar__back gx-back-btn" aria-label="Back" @click="back"><i class="bi bi-arrow-left"></i></button>
<div class="gx-appbar__pill">
<button type="button" class="gx-appbar__home" aria-label="Galaxy Home" @click="go('/')"><span class="gx-brand" aria-hidden="true"></span><span class="gx-appbar__title">Galaxy</span></button>
<ToggleSearch :enabled="state.monitorMode === 'local' && authState.status === 'authenticated'" :unauthorized="sessionExpired" :open-page="openSearchHit" />
<div class="gx-appbar__right"><DeviceState v-if="state.monitorMode === 'local' && authState.status === 'authenticated'" :unauthorized="sessionExpired" :connection="connectionLabel" />
<span v-else class="gx-status-pill"><span class="gx-status-dot offline"></span>{{ connectionLabel }}</span>
<button v-if="state.monitorMode === 'local' && authState.status === 'authenticated' && !authState.localAccess && !authState.gatewayAccess" type="button" class="gx-btn gx-btn--tonal" @click="signOut">Sign out</button></div>
</div>
<button type="button" class="gx-icon-btn gx-theme-toggle" :aria-label="isLight ? 'Switch to dark mode' : 'Switch to light mode'" @click="toggleTheme"><i class="bi" :class="isLight ? 'bi-moon-stars-fill' : 'bi-sun-fill'"></i></button>
<button type="button" class="gx-icon-btn gx-appbar__menu" aria-label="Menu" @click="state.drawerOpen=true"><i class="bi bi-list"></i></button>
</header>
<div v-if="state.drawerOpen && !state.navPinned" class="gx-underlay" @click="state.drawerOpen=false"></div>
<aside class="gx-drawer" :class="{open:state.drawerOpen || state.navPinned}" aria-label="Galaxy navigation">
<div class="gx-drawer__header"><span class="gx-brand" aria-hidden="true"></span><span class="gx-drawer-title">Galaxy</span><button type="button" class="gx-icon-btn gx-drawer__pin" :aria-pressed="state.navPinned" :aria-label="state.navPinned ? 'Unpin navigation' : 'Pin navigation'" @click="togglePin"><i class="bi" :class="state.navPinned ? 'bi-pin-angle-fill' : 'bi-pin-angle'"></i></button></div>
<div class="gx-nav-section"><div class="gx-nav-section__title">Main</div>
<button v-for="item in NAV.slice(0,3)" :key="item.path" type="button" class="gx-nav-item" :class="{active:isActive(item.path)}" @click="go(item.path)"><i class="bi" :class="item.icon"></i><span>{{ item.name }}</span></button>
</div>
<div class="gx-nav-section"><div class="gx-nav-section__title">Recordings</div><button type="button" class="gx-nav-item" :class="{active:isActive('/recordings')}" @click="go('/recordings')"><i class="bi bi-camera-reels"></i><span>Recordings</span></button></div>
<div class="gx-nav-section"><div class="gx-nav-section__title">Tools</div>
<button v-for="tool in state.tools" :key="tool.path" type="button" class="gx-nav-item" :class="{active:isActive(tool.path)}" @click="go(tool.path)"><i class="bi" :class="tool.icon"></i><span>{{ tool.name }}</span></button>
<button v-if="state.monitorMode === 'local' && authState.status === 'authenticated'" type="button" class="gx-nav-item" :class="{active:isActive('/android-auto')}" @click="go('/android-auto')"><i class="bi bi-phone"></i><span>Android Auto</span></button>
</div>
<InstallApp />
<DevicePicker />
</aside>
<main class="gx-content">
<div v-if="state.loading" class="gx-card gx-message">Loading Galaxy…</div>
<div v-else-if="state.error" class="gx-card gx-message" role="alert">{{ state.error }} <button class="gx-btn" @click="retryStartup">Try again</button></div>
<section v-else-if="state.monitorMode === 'local' && authState.status !== 'authenticated'" class="gx-card gx-auth" aria-label="Galaxy sign in">
<h2>Galaxy access</h2>
<p v-if="authState.status === 'checking'">Checking Galaxy access…</p>
<p v-else-if="authState.status === 'setup_required'">Remote Galaxy access is not configured.</p>
<p v-else-if="authState.status === 'unavailable'">Galaxy access is unavailable.</p>
<p v-else-if="authState.status === 'gateway_login'"><a class="gx-btn" href="/">Reconnect to Galaxy</a></p>
<form v-else @submit.prevent="signIn"><label for="galaxy-password">Password</label><input id="galaxy-password" class="gx-field" type="password" v-model="authForm.password" minlength="8" maxlength="255" autocomplete="current-password" required><button class="gx-btn" type="submit">Sign in</button></form>
<p v-if="authState.error" class="gx-note gx-note--danger" role="alert">{{ authState.error }}</p>
<button v-if="authState.status === 'unavailable'" class="gx-btn" @click="retryStartup">Reconnect</button>
</section>
<div v-else-if="state.pageError" class="gx-card gx-message" role="alert">{{ state.pageError }} <button class="gx-btn" @click="reloadPage">Try again</button></div>
<Home v-else-if="route.path === '/'" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go" />
<LocalRecordingsPage v-else-if="route.path === '/recordings'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<CamerasPage v-else-if="route.path === '/cameras'" :mode="state.monitorMode" :go="go" :unauthorized="sessionExpired" />
<SentryEventsPage v-else-if="route.path === '/cameras/events'" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go" />
<SettingsPage v-else-if="route.path === '/cameras/sentry-settings'" :mode="state.monitorMode" :unauthorized="sessionExpired" initial-page="sentry" title="Sentry motion settings" :return-to="returnToCameras" />
<PipPage v-else-if="route.path === '/cameras/pip'" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go" />
<VasmPage v-else-if="route.path === '/cameras/vasm'" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go" />
<Tools v-else-if="route.path === '/tools'" :tools="state.tools" :mode="state.monitorMode" />
<SettingsPage v-else-if="route.path === '/developer/connect'" :mode="state.monitorMode" :unauthorized="sessionExpired" initial-section="developer" />
<GalaxyPage v-else-if="route.path === '/galaxy'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<OnroadLayoutPage v-else-if="['/theme_maker', '/theme_maker/android_auto'].includes(route.path)" :key="route.path" :projection="route.path === '/theme_maker/android_auto'" :mode="state.monitorMode" :unauthorized="sessionExpired" @target="go($event === 'projection' ? '/theme_maker/android_auto' : '/theme_maker')" @close="go('/tools')" />
<Logs v-else-if="route.path === '/logs' || route.path.startsWith('/logs/') || ['/troubleshoot', '/manage_tmux'].includes(route.path)" :path="route.path === '/troubleshoot' ? '/logs/troubleshoot' : route.path === '/manage_tmux' ? '/logs/tmux' : route.path" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<SoftwarePage v-else-if="route.path === '/system'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<NavigationPage v-else-if="route.path === '/navigation'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<ModelsPage v-else-if="route.path === '/manage_models'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<LaboratoryPage v-else-if="route.path === '/model_laboratory'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<BluetoothPage v-else-if="route.path === '/bluetooth'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<AndroidAutoPage v-else-if="route.path === '/android-auto'" :mode="state.monitorMode" :local-access="authState.localAccess" :unauthorized="sessionExpired" />
<VehicleControlsPage v-else-if="route.path === '/vehicle'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<DevicePreferencesPage v-else-if="route.path === '/device-preferences'" :mode="state.monitorMode" :go="go" />
<SettingsPage v-else-if="deviceSettingsPage" :key="deviceSettingsPage" :mode="state.monitorMode" :unauthorized="sessionExpired" :initial-page="deviceSettingsPage" :title="deviceSettingsPage === 'sounds' ? 'Sounds & Alerts' : 'Display'" :return-to="returnToDevice" />
<DrivingPage v-else-if="route.path === '/driving'" :mode="state.monitorMode" :go="go" />
<LongitudinalCurvesPage v-else-if="route.path === '/driving/longitudinal-curves'" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go" />
<SettingsPage v-else-if="drivingSettingsPage" :key="drivingSettingsPage" :mode="state.monitorMode" :unauthorized="sessionExpired" :initial-page="drivingSettingsPage" :title="'Driving settings · ' + drivingSettingsPage.replaceAll('_', ' ')" :return-to="returnToDriving" />
<SettingsPage v-else-if="route.path === '/settings'" :key="state.searchPage || 'hub'" :mode="state.monitorMode" :unauthorized="sessionExpired"
:initial-page="state.searchPage || 'hub'" :return-to="state.searchPage ? returnFromSearch : null" />
<SettingsPage v-else-if="route.path === '/appearance'" :mode="state.monitorMode" :unauthorized="sessionExpired" initial-page="appearance" title="Onroad Appearance" />
<section v-else-if="route.path === '/tuning'" class="gx-driving">
<h2>Plots &amp; Analysis</h2>
<button type="button" class="gx-card gx-driving__link" @click="go('/tuning/plots')"><span><strong>Live plots</strong><small>Compare steering and acceleration targets with measured response</small></span></button>
<button type="button" class="gx-card gx-driving__link" @click="go('/tuning/flm')"><span><strong>Recorded-drive analysis</strong><small>Review lateral tracking from saved drives</small></span></button>
</section>
<PlotsPage v-else-if="route.path === '/tuning/plots'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<FlmPage v-else-if="route.path === '/tuning/flm'" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go" />
<div v-else class="gx-card gx-message" role="status"><h2>{{ pageName }}</h2><p>This capability is unavailable in this build. No operation was attempted.</p><button type="button" class="gx-btn" @click="go('/tools')">Back to Tools</button></div>
</main>
<nav class="blur-nav" aria-label="Primary navigation"><button v-for="item in NAV" :key="item.path" type="button" class="nav-item" :class="{active:isActive(item.path)}" @click="go(item.path)"><i class="bi" :class="item.icon"></i><span>{{ item.name }}</span></button></nav>
</div>
`,
}).mount("#galaxy-app")
spawnAmbientStars(document.getElementById("galaxy-bg"))
startRouter()
let startupGeneration = 0
async function initialize() {
const generation = ++startupGeneration
state.loading = true
state.error = ""
try {
const catalog = await loadCatalog()
if (generation !== startupGeneration) return
state.monitorMode = catalog.mode
state.tools = catalog.tools
if (state.monitorMode === "local") await auth.check()
else authState.status = "preview"
} catch {
if (generation === startupGeneration) state.error = "Galaxy could not connect. Check your connection and try again."
} finally {
if (generation === startupGeneration) state.loading = false
}
}
initialize()
@@ -0,0 +1,97 @@
// Browser-only Galaxy session boundary; never persists passwords or tokens.
import { requestJson } from "./startup.js"
export class LocalAuth {
constructor({ publish, fetcher = (...args) => fetch(...args) }) {
this.publish = publish
this.fetcher = fetcher
this.generation = 0
this.gatewayAccess = false
this.localAccess = false
this.mutationTail = Promise.resolve()
}
update(state, localAccess = false) {
this.localAccess = localAccess
this.publish({ ...state, localAccess })
}
mutate(operation) {
const pending = this.mutationTail.then(operation)
this.mutationTail = pending.then(() => {}, () => {})
return pending
}
async check() {
const generation = ++this.generation
try {
const result = await requestJson("./api/auth/session", { fetcher: this.fetcher })
if (generation !== this.generation) return
this.gatewayAccess = result.gatewayAccess === true
this.publish({ gatewayAccess: this.gatewayAccess })
if (result.state === "unavailable") this.update({ status: "unavailable", error: "Galaxy access is unavailable." })
else if (result.state === "setup_required") this.update({ status: "setup_required", error: "Remote Galaxy access is not configured." })
else if (result.state === "configured") this.update({ status: result.authenticated === true ? "authenticated" : this.gatewayAccess ? "gateway_login" : "login", error: "",
...(this.gatewayAccess ? { gatewayAccess: true } : {}) },
result.authenticated === true && result.localAccess === true)
else throw new Error("Invalid session state")
} catch {
if (generation === this.generation) this.update({ status: "unavailable", error: "Galaxy access could not be checked." })
}
}
login(password) {
if (this.localAccess || this.gatewayAccess) return Promise.resolve(false)
const generation = ++this.generation
return this.mutate(async () => {
try {
const response = await this.fetcher("./api/auth/login", {
method: "POST", credentials: "same-origin", cache: "no-store",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }),
})
if (generation !== this.generation) return false
if (!response.ok) {
if (response.status === 429) this.update({ status: "login", error: "Try again shortly." })
else if (response.status === 503) this.update({ status: "unavailable", error: "Galaxy access is unavailable." })
else this.update({ status: "login", error: "Sign in failed." })
return false
}
this.update({ status: "authenticated", error: "" })
return true
} catch {
if (generation === this.generation) this.update({ status: "login", error: "Sign in could not be completed." })
return false
}
})
}
logout() {
if (this.localAccess || this.gatewayAccess) return Promise.resolve(false)
const generation = ++this.generation
return this.mutate(async () => {
try {
const response = await this.fetcher("./api/auth/logout", {
method: "POST", credentials: "same-origin", cache: "no-store",
headers: { "Content-Type": "application/json" }, body: "{}",
})
if (generation !== this.generation) return false
if (response.status === 401 || response.status === 503) {
this.update({ status: response.status === 503 ? "unavailable" : "login", error: "" })
return false
}
if (!response.ok) throw new Error("Logout failed")
this.update({ status: "login", error: "" })
return true
} catch {
if (generation === this.generation) this.update({ status: "authenticated", error: "Sign out could not be completed." })
return false
}
})
}
expired() {
this.generation++
if (this.localAccess || this.gatewayAccess) this.update({ status: "checking", error: "" })
else this.update({ status: "login", error: "Your Galaxy session expired. Sign in again." })
}
}
@@ -0,0 +1,21 @@
export function hasBluetoothName(device) {
if (typeof device?.name !== "string") return false
const name = device.name.trim()
const address = name.replace(/^[[(]|[\])]$/g, "").replace(/^dev_/i, "").replace(/[:.\s_-]/g, "")
return name.length > 0 && !/^[0-9a-f]{12}$/i.test(address)
}
// Keep discovery rows in their first-seen position while updating live state.
export class BluetoothDeviceList {
constructor() { this.reset() }
reset() { this.order = [] }
update(devices) {
const current = new Map()
for (const device of devices) {
if (!hasBluetoothName(device)) continue
current.set(device.address, { ...device, name: device.name.trim() })
if (!this.order.includes(device.address)) this.order.push(device.address)
}
return this.order.filter((address) => current.has(address)).map((address) => current.get(address))
}
}
@@ -0,0 +1,220 @@
import { ControllersPage } from "./controllers.js"
import { BluetoothDeviceList } from "./bluetooth-devices.js"
const ADDRESS = /^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$/
const OPERATIONS = new Set(["power", "scan", "stop_scan", "connect", "disconnect", "forget", "pair", "pairing_response", "cancel_pair"])
const PAIR_STATES = new Set(["pairing", "paired", "failed", "canceled"])
const BLUETOOTH_ERRORS = {
radio_unavailable: "Bluetooth radio support is unavailable on this device.",
adapter_unavailable: "No Bluetooth adapter was detected. Turn Bluetooth on, then refresh.",
service_unavailable: "The device's Bluetooth service could not be reached. Refresh to reconnect.",
radio_preference_unavailable: "The Bluetooth power setting could not be read. Try again after restarting the device.",
busy: "Bluetooth is being used by another operation. Finish pairing or stop searching, then try again.",
park_required: "Park before changing Bluetooth connections.",
session_expired: "The pairing session expired. Refresh and start pairing again.",
changed: "The Bluetooth device or pairing request changed. Refresh and try again.",
}
export const bluetoothError = (code) => BLUETOOTH_ERRORS[code] || "Bluetooth could not complete the request. Refresh to try again."
const PROMPT_KINDS = new Set(["pin", "passkey", "confirmation", "authorization", "display_pin", "display_passkey"])
export function validPairInput(prompt, value) {
if (prompt?.kind === "pin") return /^[\x20-\x7E]{1,16}$/.test(value)
if (prompt?.kind === "passkey") return /^[0-9]{1,6}$/.test(value)
return ["confirmation", "authorization"].includes(prompt?.kind)
}
function validPairing(value) {
if (value === null) return true
const prompt = value?.prompt
return ADDRESS.test(value?.address) && PAIR_STATES.has(value?.state) &&
(prompt === null || (typeof prompt === "object" && /^[0-9a-f]{32}$/.test(prompt.id) &&
PROMPT_KINDS.has(prompt.kind) && typeof prompt.value === "string" && prompt.value.length <= 16 &&
typeof prompt.displayOnly === "boolean"))
}
export function validBluetoothStatus(value) {
return value?.version === 1 && typeof value.available === "boolean" && typeof value.parked === "boolean" &&
typeof value.powered === "boolean" && typeof value.discovering === "boolean" &&
(value.errorCode === null || ["radio_unavailable", "adapter_unavailable", "service_unavailable", "radio_preference_unavailable"].includes(value.errorCode)) &&
validPairing(value.pairing ?? null) && Array.isArray(value.devices) && value.devices.length <= 64 && value.devices.every((device) =>
ADDRESS.test(device?.address) && typeof device.name === "string" && device.name.length <= 80 &&
[device.paired, device.connected, device.trusted].every((flag) => typeof flag === "boolean"))
}
export class BluetoothFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer })
this.active = false
this.generation = 0
this.request = null
this.timer = null
this.pollTimer = null
this.busy = false
this.status = null
this.deviceList = new BluetoothDeviceList()
this.error = ""
}
emit() { this.publish({ status: this.status, busy: this.busy, error: this.error }) }
stop(cancelPair = false) {
if (cancelPair && this.active && this.status?.pairing?.state === "pairing") {
this.fetcher("./api/bluetooth/action", { method: "POST", credentials: "same-origin", keepalive: true,
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ operation: "cancel_pair" }) }).catch(() => {})
}
this.active = false
this.generation++
this.request?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.request = this.timer = this.pollTimer = null
this.busy = false
this.status = null
this.deviceList.reset()
this.error = ""
this.emit()
}
start() { this.stop(); this.active = true; return this.refresh() }
retire(generation, request) {
if (!this.active || this.generation !== generation || this.request !== request) return false
if (this.timer !== null) this.cancelTimer(this.timer)
this.timer = null
this.request = null
this.busy = false
if (this.pollTimer === null) this.pollTimer = this.later(() => this.refresh(), 2000)
this.emit()
return true
}
async run(url, options, timeout, background = false) {
if (!this.active || this.busy || background && this.request !== null) return null
if (!background) {
// The user action supersedes any in-flight poll, including its JSON body.
this.generation++
this.request?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.request = this.timer = this.pollTimer = null
}
const generation = this.generation
const request = new AbortController()
this.request = request
if (!background) { this.busy = true; this.emit() }
this.timer = this.later(() => {
if (!this.retire(generation, request)) return
request.abort()
this.generation++
this.error = "Bluetooth took too long to respond. Refresh to check its current state."
this.emit()
}, timeout)
try {
const response = await this.fetcher(url, { ...options, signal: request.signal, credentials: "same-origin", cache: "no-store" })
if (!this.active || this.generation !== generation || this.request !== request || request.signal.aborted) return null
if (response.status === 401) { this.stop(); this.unauthorized(); return null }
const data = await response.json().catch(() => { throw new Error("Galaxy could not reach Bluetooth. Refresh to reconnect.") })
if (!this.active || this.generation !== generation || this.request !== request || request.signal.aborted) return null
if (response.status === 503 && ["setup_required", "access_unavailable"].includes(data?.code)) {
this.stop(); this.unauthorized(); return null
}
if (!response.ok) throw new Error(bluetoothError(data?.code))
if (!validBluetoothStatus(data)) throw new Error("Galaxy received an invalid Bluetooth response. Refresh to reconnect.")
this.status = { ...data, devices: this.deviceList.update(data.devices) }
this.error = ""
return data
} catch (error) {
if (this.active && this.generation === generation && this.request === request) {
this.error = error instanceof Error ? error.message : "Bluetooth is unavailable."
}
return null
} finally {
this.retire(generation, request)
}
}
async refresh() {
if (!this.active || this.busy || this.request !== null) return
if (this.pollTimer !== null) { this.cancelTimer(this.pollTimer); this.pollTimer = null }
await this.run("./api/bluetooth/status", {}, 12000, true)
}
async action(operation, fields = {}) {
if (!OPERATIONS.has(operation) || !this.active || this.busy || (operation === 'power' && !this.status?.parked)) return
const payload = { operation, ...fields }
const result = await this.run("./api/bluetooth/action", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload),
}, operation === "power" ? 35000 : ["connect", "disconnect"].includes(operation) ? 30000 : 15000)
if (result) await this.refresh()
}
}
export const BluetoothPage = {
components: { ControllersPage },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ status: null, busy: false, error: "", pairValue: "" }),
mounted() {
this.feed = new BluetoothFeed({ publish: (value) => Object.assign(this, value), unauthorized: this.unauthorized })
this.visibility = () => document.hidden ? this.feed.stop(true) : this.feed.start()
document.addEventListener("visibilitychange", this.visibility)
if (this.mode === "local" && !document.hidden) this.feed.start()
},
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed?.stop(true) },
watch: {
mode(value) { if (value === "local" && !document.hidden) this.feed?.start(); else this.feed?.stop(true) },
'status.pairing.prompt.id'() { this.pairValue = "" },
},
computed: {
saved() { return this.status?.devices?.filter((device) => device.paired || device.trusted || device.connected) || [] },
nearby() { return this.status?.devices?.filter((device) => !device.paired && !device.trusted && !device.connected) || [] },
canAllowPair() { return validPairInput(this.status?.pairing?.prompt, this.pairValue) },
},
methods: {
bluetoothError,
refresh() { return this.feed.refresh() },
action(operation, fields) { return this.feed.action(operation, fields) },
respond(accepted) {
const prompt = this.status?.pairing?.prompt
if (!prompt || prompt.displayOnly || accepted && !validPairInput(prompt, this.pairValue)) return
const value = accepted && ["pin", "passkey"].includes(prompt.kind) ? this.pairValue : ""
return this.action("pairing_response", { promptId: prompt.id, accepted, value })
},
},
template: `
<section class="gx-driving" aria-label="Bluetooth">
<div class="gx-card gx-driving__intro"><p class="gx-eyebrow">Bluetooth</p><h2>Bluetooth</h2>
<p>View, pair and manage nearby and saved devices.</p></div>
<div v-if="mode !== 'local'" class="gx-card gx-message">Bluetooth requires the device. No radio was checked in preview.</div>
<template v-else>
<div v-if="error" class="gx-card gx-message" role="alert">{{ error }}</div>
<div v-if="!status && !error" class="gx-card gx-message">Checking Bluetooth…</div>
<template v-else-if="status">
<div class="gx-card gx-driving__intro"><h3>Adapter {{ status.powered ? 'On' : 'Off' }}</h3>
<p v-if="status.errorCode">{{ bluetoothError(status.errorCode) }}</p>
<p v-else-if="!status.parked">Park to restart the Bluetooth radio.</p>
<div class="gx-driving__actions"><button class="gx-btn" :disabled="busy || !status.parked || !status.available" @click="action('power', {enabled: !status.powered})">Turn {{ status.powered ? 'Off' : 'On' }}</button>
<button class="gx-btn gx-btn--tonal" :disabled="busy" @click="refresh">Refresh</button>
<button class="gx-btn gx-btn--tonal" :disabled="busy || !status.powered" @click="action(status.discovering ? 'stop_scan' : 'scan')">{{ status.discovering ? 'Stop Search' : 'Search' }}</button></div></div>
<div v-if="status.pairing" class="gx-card gx-driving__intro" role="status"><h3>Pairing {{ status.pairing.address }}</h3>
<p v-if="status.pairing.state === 'pairing'">Waiting for the device and any confirmation below.</p>
<p v-else>{{ status.pairing.state === 'paired' ? 'Device paired.' : status.pairing.state === 'canceled' ? 'Pairing canceled.' : 'Pairing did not complete.' }}</p>
<div v-if="status.pairing.prompt" class="gx-driving__intro"><p>{{ status.pairing.prompt.kind === 'confirmation' ? 'Confirm this code matches the device:' :
status.pairing.prompt.kind === 'pin' ? 'Enter the PIN shown by the device.' :
status.pairing.prompt.kind === 'passkey' ? 'Enter the device passkey.' :
status.pairing.prompt.kind === 'authorization' ? 'Allow this device to pair?' : 'Use this code on the device:' }}</p>
<strong v-if="status.pairing.prompt.value">{{ status.pairing.prompt.value }}</strong>
<input v-if="['pin', 'passkey'].includes(status.pairing.prompt.kind)" v-model="pairValue" class="gx-field" type="text" maxlength="16" autocomplete="off" placeholder="PIN or passkey" />
<div v-if="!status.pairing.prompt.displayOnly" class="gx-driving__actions"><button class="gx-btn gx-btn--tonal" :disabled="busy" @click="respond(false)">Reject</button>
<button class="gx-btn" :disabled="busy || !canAllowPair" @click="respond(true)">{{ status.pairing.prompt.kind === 'pin' ? 'Submit PIN' : status.pairing.prompt.kind === 'passkey' ? 'Submit Passkey' : 'Allow' }}</button></div></div>
<button v-if="status.pairing.state === 'pairing'" class="gx-btn gx-btn--tonal" :disabled="busy" @click="action('cancel_pair')">Cancel Pairing</button></div>
<div class="gx-card gx-driving__intro"><h3>Saved Devices</h3><p v-if="!saved.length">No saved devices found.</p>
<div v-for="device in saved" :key="device.address" class="gx-row"><div class="gx-row__info"><strong>{{ device.name }}</strong><small>{{ device.connected ? 'Connected' : 'Disconnected' }}</small></div>
<div class="gx-driving__actions"><button class="gx-btn gx-btn--tonal" :disabled="busy || !status.powered" @click="action(device.connected ? 'disconnect' : 'connect', {address:device.address})">{{ device.connected ? 'Disconnect' : 'Connect' }}</button>
<button class="gx-btn gx-btn--tonal" :disabled="busy" @click="action('forget', {address:device.address})">Forget</button></div></div></div>
<div class="gx-card gx-driving__intro"><h3>Nearby Devices</h3><p v-if="!nearby.length">{{ status.discovering ? 'Searching…' : 'No nearby devices found.' }}</p>
<div v-for="device in nearby" :key="device.address" class="gx-row"><strong>{{ device.name }}</strong>
<button class="gx-btn" :disabled="busy || !status.powered || status.pairing?.state === 'pairing'" @click="action('pair', {address:device.address})">Pair</button></div></div>
</template>
</template>
<ControllersPage :mode="mode" :unauthorized="unauthorized" />
</section>`,
}
+14
View File
@@ -0,0 +1,14 @@
const status = document.getElementById("galaxy-boot")
const message = status.querySelector("p")
const retry = status.querySelector("button")
retry.addEventListener("click", () => location.reload())
function failed() {
message.textContent = "Galaxy could not finish opening. Check your connection and try again."
retry.hidden = false
}
const timer = setTimeout(failed, 10000)
import("./app.js").then(() => {
clearTimeout(timer)
if (document.querySelector("#galaxy-app .gx-app")) status.remove()
else failed()
}).catch(() => { clearTimeout(timer); failed() })
@@ -0,0 +1,100 @@
export class CameraSnapshotFeed {
constructor({ publish, unauthorized, fetcher = (...args) => fetch(...args),
createURL = (blob) => URL.createObjectURL(blob), revokeURL = (url) => URL.revokeObjectURL(url),
later = setTimeout, cancelTimer = clearTimeout }) {
Object.assign(this, { publish, unauthorized, fetcher, createURL, revokeURL, later, cancelTimer })
this.request = null
this.url = ""
this.timer = null
}
stop() {
this.request?.abort()
this.request = null
if (this.timer !== null) this.cancelTimer(this.timer)
this.timer = null
if (this.url) this.revokeURL(this.url)
this.url = ""
this.publish({ image: "", capturing: false, error: "" })
}
async capture(camera) {
this.stop()
const request = new AbortController()
this.request = request
this.publish({ image: "", capturing: true, error: "" })
this.timer = this.later(() => {
if (this.request !== request) return
this.stop()
this.publish({ image: "", capturing: false, error: "Snapshot timed out. Turn off the vehicle and try again." })
}, 13500)
try {
const response = await this.fetcher("./api/cameras/snapshot", { method: "POST", credentials: "same-origin",
cache: "no-store", signal: request.signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ camera }) })
if (this.request !== request || request.signal.aborted) return
if (response.status === 401) { this.stop(); this.unauthorized(); return }
if (!response.ok) throw new Error(response.status === 409 ? "Turn the vehicle off before taking a snapshot." :
"Turn off the vehicle and open its camera preview, then try again.")
if (response.headers.get("Content-Type")?.split(";")[0].trim().toLowerCase() !== "image/jpeg") throw new Error("Camera image is unavailable.")
const blob = await response.blob()
if (this.request !== request || request.signal.aborted) return
if (!blob.size || blob.size > 1000000) throw new Error("Camera image is unavailable.")
this.url = this.createURL(blob)
this.publish({ image: this.url, capturing: false, error: "" })
} catch (error) {
if (this.request === request && !request.signal.aborted)
this.publish({ image: "", capturing: false, error: error.message || "Camera image is unavailable." })
} finally {
if (this.request === request) {
this.request = null
this.cancelTimer(this.timer)
this.timer = null
}
}
}
}
export const CamerasPage = {
name: "CamerasPage",
props: { mode: { type: String, required: true }, go: { type: Function, required: true },
unauthorized: { type: Function, required: true } },
data: () => ({ camera: "cabin", image: "", capturing: false, error: "" }),
created() { this.snapshots = new CameraSnapshotFeed({ publish: (state) => Object.assign(this.$data, state), unauthorized: this.unauthorized }) },
mounted() {
this.visibility = () => { if (document.hidden) this.snapshots.stop() }
document.addEventListener("visibilitychange", this.visibility)
},
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.snapshots.stop() },
watch: { mode() { this.snapshots.stop() } },
template: `
<div class="gx-view gx-home" aria-label="Cameras and Monitoring">
<div class="gx-home__hero"><div><h1>Cameras &amp; Monitoring</h1>
<p class="gx-note">Camera preferences and availability</p></div></div>
<div class="gx-home__grid">
<section class="gx-card gx-home__card"><h2><i class="bi bi-camera-video"></i> Blind Spot Camera</h2>
<p>Adjust the camera crop with a live cabin preview.</p>
<button v-if="mode === 'local'" type="button" class="gx-home__link" @click="go('/cameras/pip')">Open saved preferences <i class="bi bi-arrow-right"></i></button>
<small v-else>Saved preferences are unavailable in preview.</small></section>
<section class="gx-card gx-home__card"><h2><i class="bi bi-shield"></i> Sentry</h2>
<p>View captured motion events and configure Sentry notifications.</p>
<button v-if="mode === 'local'" type="button" class="gx-home__link" @click="go('/cameras/events')">View motion events <i class="bi bi-arrow-right"></i></button>
<button v-if="mode === 'local'" type="button" class="gx-home__link" @click="go('/cameras/sentry-settings')">Saved motion settings <i class="bi bi-arrow-right"></i></button>
<small v-else>Motion events are unavailable in preview.</small></section>
<section class="gx-card gx-home__card"><h2><i class="bi bi-eye"></i> V-ASM</h2>
<p>Draw saved camera window regions and adjust visual warning choices. A live camera image and current warning status are unavailable here.</p>
<button v-if="mode === 'local'" type="button" class="gx-home__link" @click="go('/cameras/vasm')">Open saved settings <i class="bi bi-arrow-right"></i></button>
<small v-else>Saved settings are unavailable in preview.</small></section>
</div>
<section class="gx-card gx-home__card"><h2>Camera Snapshot</h2>
<p>Turn off the vehicle, choose a camera, then take a snapshot.</p>
<template v-if="mode === 'local'">
<label>Camera <select class="gx-field" v-model="camera" :disabled="capturing" @change="snapshots.stop()">
<option value="cabin">Cabin</option><option value="wide">Wide road</option><option value="narrow">Road</option>
</select></label>
<button class="gx-btn" type="button" :disabled="capturing" @click="snapshots.capture(camera)">{{ capturing ? 'Capturing…' : 'Take snapshot' }}</button>
<button v-if="image || capturing" class="gx-btn gx-btn--tonal" type="button" @click="snapshots.stop()">{{ capturing ? 'Cancel' : 'Clear snapshot' }}</button>
<p v-if="error" role="alert">{{ error }}</p>
<img v-if="image" :src="image" :alt="camera + ' camera snapshot'" style="display:block;max-width:100%;height:auto;margin-top:1rem;" />
</template>
<p v-else>Camera snapshots are available on the connected device.</p>
<small>Snapshots are not saved.</small></section>
</div>`,
}
@@ -0,0 +1,65 @@
export const validProviderStatus = value => value && ["comma", "konik"].includes(value.active) &&
["comma", "konik"].includes(value.selected) && /^[a-f0-9]{64}$/.test(value.revision) &&
typeof value.restartRequired === "boolean" && typeof value.canSelect === "boolean" &&
Array.isArray(value.providers) && value.providers.length === 2 &&
["comma", "konik"].every(name => value.providers.filter(p => p.id === name).length === 1) &&
value.providers.every(p => ["comma", "konik"].includes(p.id) && typeof p.label === "string" &&
p.url === (p.id === "comma" ? "https://connect.comma.ai" : "https://stable.konik.ai"))
export const CloudProviderPage = {
name: "CloudProviderPage",
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ status: null, busy: false, error: "", request: null }),
mounted() { this.load() },
beforeUnmount() { this.request?.abort() },
computed: {
active() { return this.status?.providers.find(p => p.id === this.status.active) },
selected() { return this.status?.providers.find(p => p.id === this.status.selected) },
},
methods: {
async call(body) {
this.request?.abort()
const controller = new AbortController()
this.request = controller
const timer = setTimeout(() => controller.abort(), 8000)
try {
const response = await fetch("./api/connect/provider", { cache: "no-store", credentials: "same-origin",
signal: controller.signal, ...(body ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } : {}) })
if (response.status === 401) this.unauthorized()
const value = await response.json()
if (!response.ok) throw new Error(value.error || "Cloud provider request failed")
if (!validProviderStatus(value)) throw new Error("Cloud provider status is unavailable")
this.status = value
} finally {
clearTimeout(timer)
if (this.request === controller) this.request = null
}
},
async load() {
if (this.mode !== "local") return
try { await this.call() } catch (error) { this.error = error.message }
},
async select(name) {
if (!this.status?.canSelect || this.busy || !this.status.providers.some(p => p.id === name)) return
const label = this.status.providers.find(p => p.id === name).label
if (!window.confirm(`Use ${label} after the next device reboot? The current session stays unchanged. Returning to comma restores its saved cloud identity.`)) return
this.busy = true
this.error = ""
try { await this.call({ provider: name, revision: this.status.revision, confirmed: true }) }
catch (error) { this.error = error.message }
finally { this.busy = false }
},
},
template: `<div><h3>Cloud Provider</h3>
<section v-if="mode !== 'local'" class="gx-card gx-message">Open this Developer page on your device to choose a cloud provider.</section>
<section v-else class="gx-card" style="padding:var(--sp-4)">
<p v-if="active">Active: <a :href="active.url" target="_blank" rel="noopener">{{ active.label }}</a></p>
<p v-if="selected">Next Boot: {{ selected.label }}</p>
<p v-if="status?.restartRequired">Restart required. No automatic reboot will occur.</p>
<p>Cloud accounts and uploads stay separate. Your older comma recordings keep their comma links.</p>
<p v-if="status && !status.canSelect">Turn off the vehicle to change this Developer setting.</p>
<button v-for="provider in status?.providers || []" :key="provider.id" class="gx-btn" :disabled="busy || !status.canSelect || status.selected === provider.id" @click="select(provider.id)">Use {{ provider.label }}</button>
<button class="gx-btn" @click="load" :disabled="busy">Refresh</button>
<p v-if="error" class="gx-note gx-note--danger" role="alert">{{ error }}</p>
</section></div>`,
}
@@ -0,0 +1,202 @@
import { reactive } from "../vendor/vue/vue.esm-browser.js"
const HEX = /^[0-9a-f]{64}$/
const string = (value, limit = 128) => typeof value === "string" && value.length <= limit && !/[\x00-\x1f\x7f]/.test(value)
const key = (value) => value === null || (string(value) && value.length > 0)
const integer = (value, max) => Number.isInteger(value) && value >= 0 && value <= max
const draftFrom = (status) => ({ revision: status.revision, enabled: status.enabled,
slots: status.slots.slice(3).map((slot) => slot.key) })
export function validControllersStatus(data) {
if (data?.version !== 1 || typeof data.available !== "boolean" || typeof data.editable !== "boolean" ||
typeof data.enabled !== "boolean" || typeof data.valid !== "boolean" || !HEX.test(data.revision) ||
!Array.isArray(data.devices) || data.devices.length > 32 || !Array.isArray(data.slots) || data.slots.length !== 13 ||
!Array.isArray(data.options) || data.options.length > 128 || !Array.isArray(data.bindings) || data.bindings.length > 128 ||
typeof data.testing !== "boolean") return false
if (!data.devices.every((device) => string(device?.id, 256) && device.id && string(device.name, 80) &&
[3, 5].includes(device.bus))) return false
if (!data.slots.every((slot, index) => slot?.index === index && string(slot.label, 80) &&
key(slot.key) && typeof slot.available === "boolean")) return false
if (!data.options.every((option) => key(option?.key) && option.key !== null &&
string(option.label, 80) && string(option.section, 80))) return false
if (new Set(data.options.map((option) => option.key)).size !== data.options.length) return false
if (!data.bindings.every((binding) => string(binding?.deviceId, 256) && binding.deviceId &&
string(binding.name, 80) && integer(binding.code, 65551) && integer(binding.slot, 12))) return false
if (data.learning !== null && (!integer(data.learning?.slot, 12) ||
!Number.isFinite(data.learning.expiresIn) || data.learning.expiresIn < 0 || data.learning.expiresIn > 20)) return false
if (data.lastPress !== null && (!string(data.lastPress?.deviceId, 256) ||
!integer(data.lastPress.code, 65551) || (data.lastPress.slot !== null && !integer(data.lastPress.slot, 12)) ||
typeof data.lastPress.executed !== "boolean" || !string(data.lastPress.message, 200))) return false
return true
}
export class ControllersFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer })
this.active = false
this.generation = 0
this.request = this.timeout = this.poll = null
this.status = null
this.busy = false
this.error = ""
}
emit() { this.publish({ status: this.status, busy: this.busy, error: this.error }) }
stop() {
if (this.active && this.status?.editable && (this.status.learning || this.status.testing)) {
this.fetcher("./api/controllers/action", { method: "POST", credentials: "same-origin", keepalive: true,
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ operation: "cancel" }) }).catch(() => {})
}
this.active = false
this.generation++
this.request?.abort()
if (this.timeout !== null) this.cancelTimer(this.timeout)
if (this.poll !== null) this.cancelTimer(this.poll)
this.request = this.timeout = this.poll = null
this.status = null
this.busy = false
this.error = ""
this.emit()
}
start() { this.stop(); this.active = true; return this.refresh() }
async run(payload = null) {
if (!this.active || this.request) return null
if (this.poll !== null) this.cancelTimer(this.poll)
this.poll = null
const generation = this.generation, request = new AbortController()
this.request = request
this.busy = payload !== null
this.emit()
this.timeout = this.later(() => request.abort(), 6000)
let failure = "Controller Buttons are unavailable. Reload before trying again."
try {
const response = await this.fetcher(payload === null ? "./api/controllers/status" : "./api/controllers/action", {
credentials: "same-origin", cache: "no-store", signal: request.signal,
...(payload === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }),
})
if (!this.active || generation !== this.generation || request.signal.aborted) return null
if (response.status === 401) { this.stop(); this.unauthorized(); return null }
if (response.status === 403) { failure = "Turn off the vehicle to change controller buttons."; throw new Error() }
if (response.status === 409) { failure = "Controller settings changed. Reload them before saving."; throw new Error() }
if (response.status === 429) { failure = "Controller is busy. Try again shortly."; throw new Error() }
if (!response.ok) throw new Error()
const data = await response.json()
if (!this.active || generation !== this.generation || request.signal.aborted) return null
if (!validControllersStatus(data)) { failure = "Controller Buttons returned an unsupported status. Try again."; throw new Error() }
this.status = data
this.error = ""
return data
} catch {
if (this.active && generation === this.generation) this.error = failure
return null
} finally {
if (this.request === request) {
if (this.timeout !== null) this.cancelTimer(this.timeout)
this.request = this.timeout = null
this.busy = false
this.emit()
if (this.active && this.poll === null) this.poll = this.later(() => { this.poll = null; this.refresh() }, 2000)
}
}
}
refresh() { return this.run() }
action(payload) { return this.run(payload) }
}
export const ControllersPage = {
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
setup(props) {
const state = reactive({ status: null, busy: false, error: "", draft: null })
const feed = new ControllersFeed({ unauthorized: props.unauthorized, publish(update) {
const prior = state.status
Object.assign(state, update)
if (update.status === null) state.draft = null
else if (update.status && (!state.draft || (prior && state.draft.revision === prior.revision &&
state.draft.enabled === prior.enabled && state.draft.slots.every((value, index) => value === prior.slots[index + 3].key)))) {
state.draft = draftFrom(update.status)
}
} })
return { state, feed }
},
computed: {
dirty() { return !!this.state.status && !!this.state.draft &&
(this.state.draft.enabled !== this.state.status.enabled ||
this.state.draft.slots.some((value, index) => value !== this.state.status.slots[index + 3].key)) },
changed() { return !!this.state.status && !!this.state.draft && this.state.draft.revision !== this.state.status.revision },
canEdit() { return !!this.state.status?.editable && !this.state.busy && !this.changed },
favorites() { return this.state.status?.slots.slice(0, 3) || [] },
actions() { return this.state.status?.slots.slice(3) || [] },
},
mounted() {
this.visibility = () => { if (this.mode === "local" && !document.hidden) this.feed.start(); else this.feed.stop() }
document.addEventListener("visibilitychange", this.visibility)
this.visibility()
},
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed.stop() },
watch: { mode() { this.visibility() } },
methods: {
async reload() {
const status = await this.feed.refresh()
if (status) this.state.draft = draftFrom(status)
},
async save() {
if (!this.canEdit || !this.dirty) return
const status = await this.feed.action({ operation: "save", revision: this.state.draft.revision,
enabled: this.state.draft.enabled, slots: this.state.draft.slots.map((value) => value || null) })
if (status) this.state.draft = draftFrom(status)
},
learn(slot) {
if (this.canEdit && !this.dirty && integer(slot, 12)) return this.feed.action({ operation: "learn", revision: this.state.status.revision, slot })
},
cancel() { if (this.state.status?.learning) return this.feed.action({ operation: "cancel" }) },
test() { if (this.canEdit) return this.feed.action({ operation: "test", enabled: !this.state.status.testing }) },
remove(binding) {
if (this.canEdit && !this.dirty) return this.feed.action({ operation: "remove", revision: this.state.status.revision,
deviceId: binding.deviceId, code: binding.code })
},
},
template: `
<section class="gx-card gx-driving__intro gx-controllers" aria-label="Controller Buttons">
<h3>Controller Buttons</h3>
<p>Assign physical USB or Bluetooth buttons to Quick Select and available driving screen actions. Turn off the vehicle to edit.</p>
<p v-if="mode !== 'local'" class="gx-note">Connect to local Galaxy to manage controller buttons.</p>
<template v-else>
<p v-if="state.error" class="gx-note" role="alert">{{ state.error }}</p>
<p v-if="!state.status" class="gx-note">Checking attached controllers…</p>
<template v-else>
<p v-if="!state.status.available" class="gx-note">Controller Buttons are unavailable. Reload to try again.</p>
<p v-else-if="!state.status.editable" class="gx-note">Turn off the vehicle to change controller buttons.</p>
<p v-if="changed" class="gx-note" role="status">Controller settings changed. Reload before saving.</p>
<p v-else-if="dirty" class="gx-note">Save these changes before learning or removing buttons.</p>
<div class="gx-driving__actions"><button class="gx-btn gx-btn--tonal" type="button" :disabled="state.busy" @click="reload">Reload</button>
<button class="gx-btn" type="button" :disabled="!canEdit || !dirty" @click="save">Save Controller Settings</button></div>
<div class="gx-controllers__switch-row"><span class="gx-row__label">Enable controller buttons</span>
<label class="gx-switch"><input type="checkbox" aria-label="Enable controller buttons" v-model="state.draft.enabled" :disabled="!canEdit"><span class="gx-switch__track"></span><span class="gx-switch__thumb"></span></label></div>
<h4>Attached Controllers</h4>
<p v-if="!state.status.devices.length" class="gx-note">No controller is attached.</p>
<div v-for="device in state.status.devices" :key="device.id" class="gx-row gx-controllers__row"><div class="gx-row__info"><span class="gx-row__label">{{ device.name }}</span><span class="gx-row__desc">{{ device.bus === 3 ? 'USB' : 'Bluetooth' }}</span></div></div>
<h4>Favorite Buttons</h4>
<div v-for="slot in favorites" :key="slot.index" class="gx-row gx-controllers__row"><div class="gx-row__info"><span class="gx-row__label">{{ slot.label }}</span>
<span class="gx-row__desc">{{ slot.key ? 'Available when its control is ready' : 'Not assigned' }}</span></div>
<button class="gx-btn gx-btn--tonal" type="button" :disabled="!canEdit || dirty || !!state.status.learning" @click="learn(slot.index)">Learn Button</button></div>
<h4>Action Buttons</h4>
<div v-for="slot in actions" :key="slot.index" class="gx-row gx-controllers__row"><label class="gx-row__info gx-controllers__selector"><span class="gx-row__label">{{ slot.label }}</span>
<select class="gx-field" :aria-label="slot.label + ' action'" v-model="state.draft.slots[slot.index - 3]" :disabled="!canEdit">
<option :value="null">No action</option><option v-for="option in state.status.options" :key="option.key" :value="option.key">{{ option.section }} · {{ option.label }}</option>
</select></label>
<button class="gx-btn gx-btn--tonal" type="button" :disabled="!canEdit || dirty || !!state.status.learning" @click="learn(slot.index)">Learn Button</button></div>
<p v-if="state.status.learning" role="status">Press a button for {{ state.status.slots[state.status.learning.slot].label }} within {{ Math.ceil(state.status.learning.expiresIn) }} seconds.
<button class="gx-btn gx-btn--tonal" type="button" :disabled="state.busy" @click="cancel">Cancel Learning</button></p>
<div class="gx-driving__actions"><button class="gx-btn gx-btn--tonal" type="button" :disabled="!canEdit" @click="test">{{ state.status.testing ? 'Stop Button Test' : 'Test Buttons for 20 Seconds' }}</button></div>
<p v-if="state.status.testing" class="gx-note">Button test shows input without running its action.</p>
<p v-if="state.status.lastPress" class="gx-note" role="status">{{ state.status.lastPress.message }}</p>
<h4>Learned Buttons</h4><p v-if="!state.status.bindings.length" class="gx-note">No buttons learned yet.</p>
<div v-for="binding in state.status.bindings" :key="binding.deviceId + ':' + binding.code" class="gx-row gx-controllers__row">
<div class="gx-row__info"><span class="gx-row__label">{{ binding.name }}</span><span class="gx-row__desc">Button {{ binding.code }} · {{ state.status.slots[binding.slot].label }}</span></div>
<button class="gx-btn gx-btn--tonal" type="button" :disabled="!canEdit || dirty" @click="remove(binding)">Remove</button></div>
</template>
</template>
</section>`,
}
@@ -0,0 +1,102 @@
// Read-only local crash reports. State is cleared on navigation and sign-out.
export class CrashReportsFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args) }) {
Object.assign(this, { publish, unauthorized, fetcher })
this.active = false
this.generation = 0
this.request = null
this.reports = []
}
clear() {
this.reports = []
this.publish({ reports: [], scanIncomplete: false, listLimited: false, status: "idle", error: "", selected: null, preview: null, previewStatus: "idle" })
}
stop() {
this.active = false
this.generation++
this.request?.abort()
this.request = null
this.clear()
}
start() {
this.stop()
this.active = true
return this.load()
}
async requestJson(url, generation, request) {
const response = await this.fetcher(url, { signal: request.signal, cache: "no-store" })
if (!this.active || generation !== this.generation || request.signal.aborted) return null
if (response.status === 401) {
this.stop()
this.unauthorized()
return null
}
if (response.status === 503) {
const body = await response.json().catch(() => null)
if (!this.active || generation !== this.generation || request.signal.aborted) return null
if (["access_unavailable", "setup_required"].includes(body?.code)) {
this.stop()
this.unauthorized()
return null
}
throw new Error("Crash reports are unavailable")
}
if (!response.ok) throw new Error(response.status === 409 ? "Report changed; refresh the list" : "Report unavailable")
const body = await response.json()
if (!this.active || generation !== this.generation || request.signal.aborted) return null
return body
}
async load() {
if (!this.active) return
const generation = ++this.generation
this.request?.abort()
const request = new AbortController()
this.request = request
this.reports = []
this.publish({ reports: [], scanIncomplete: false, listLimited: false, status: "loading", error: "", selected: null, preview: null, previewStatus: "idle" })
try {
const data = await this.requestJson("./api/crash-reports", generation, request)
if (!data) return
if (data.schemaVersion !== 1 || !Array.isArray(data.reports) || data.reports.length > 200 ||
typeof data.scanIncomplete !== "boolean" || typeof data.listLimited !== "boolean" ||
data.reports.some((item) => typeof item.id !== "string" || !item.id ||
typeof item.name !== "string" || !item.name ||
typeof item.size !== "number" || !Number.isFinite(item.size) || item.size < 0 ||
typeof item.modifiedAt !== "number" || !Number.isFinite(item.modifiedAt))) {
throw new Error("Invalid crash report list")
}
this.reports = data.reports
this.publish({ reports: data.reports, scanIncomplete: data.scanIncomplete, listLimited: data.listLimited, status: "ready", error: "", selected: null, preview: null, previewStatus: "idle" })
} catch (error) {
if (this.active && generation === this.generation) this.publish({ reports: [], scanIncomplete: false, listLimited: false, status: "unavailable", error: error.message, selected: null, preview: null, previewStatus: "idle" })
} finally {
if (generation === this.generation) this.request = null
}
}
async open(report) {
if (!this.active || !this.reports.some((item) => item.id === report?.id)) return
const generation = ++this.generation
this.request?.abort()
const request = new AbortController()
this.request = request
this.publish({ selected: report.id, preview: null, previewStatus: "loading", error: "" })
try {
const data = await this.requestJson(`./api/crash-reports/${encodeURIComponent(report.id)}`, generation, request)
if (!data) return
if (data.schemaVersion !== 1 || data.name !== report.name || typeof data.text !== "string" || typeof data.truncated !== "boolean") {
throw new Error("Invalid crash report")
}
this.publish({ selected: report.id, preview: data, previewStatus: "ready", error: "" })
} catch (error) {
if (this.active && generation === this.generation) this.publish({ selected: null, preview: null, previewStatus: "unavailable", error: error.message })
} finally {
if (generation === this.generation) this.request = null
}
}
}
@@ -0,0 +1,92 @@
const SLUG = /^[A-Za-z0-9]{16}$/
const unsupported = (response) => response.status === 404 || response.status === 405
async function readName(device) {
try {
const response = await fetch(`/${device.slug}/api/galaxy/device-name`, {
cache: "no-store", credentials: "same-origin", signal: AbortSignal.timeout(5000),
})
if (!response.ok) return device
const data = await response.json()
if (typeof data.name === "string" && Array.from(data.name).length <= 40) return { ...device, name: data.name || device.name }
} catch {}
return device
}
async function namedDevices(devices) {
const result = [...devices]
let next = 0
const worker = async () => {
while (next < devices.length) {
const index = next++
result[index] = await readName(devices[index])
}
}
await Promise.all(Array.from({ length: Math.min(4, devices.length) }, worker))
return result
}
export const DevicePicker = {
name: "DevicePicker",
data: () => ({ devices: [], activeSlug: "", loading: true, editing: "", draft: "", error: "", saving: false }),
computed: { visible() { return location.hostname === "galaxy.firestar.link" && this.devices.length > 1 } },
mounted() { if (location.hostname === "galaxy.firestar.link") this.load() },
methods: {
async load() {
try {
const response = await fetch("/_gateway/devices", { cache: "no-store", credentials: "same-origin" })
if (!response.ok) return
const data = await response.json()
this.activeSlug = SLUG.test(data?.activeSlug || "") ? data.activeSlug : ""
this.devices = Array.isArray(data?.devices) ? data.devices.filter((device) => SLUG.test(device?.slug || "")).slice(0, 40) : []
this.devices = await namedDevices(this.devices)
} catch {} finally { this.loading = false }
},
select(device) {
if (SLUG.test(device.slug) && device.slug !== this.activeSlug) window.location.assign(`/${device.slug}/`)
},
edit(device) { this.editing = device.slug; this.draft = device.name || ""; this.error = "" },
async save() {
if (!SLUG.test(this.editing)) return
const slug = this.editing
const name = Array.from(this.draft.trim()).slice(0, 40).join("")
this.saving = true
this.error = ""
try {
let response = await fetch(`/${slug}/api/galaxy/device-name`, {
method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(5000), body: JSON.stringify({ name }),
})
if (unsupported(response)) {
response = await fetch(`/_gateway/devices/${slug}/name`, {
method: "PUT", credentials: "same-origin", headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(7000), body: JSON.stringify({ name }),
})
}
const data = await response.json().catch(() => ({}))
if (!response.ok || typeof data.name !== "string") throw new Error(data.error || "Could not rename comma")
this.devices = this.devices.map((device) => device.slug === slug ? { ...device, name: data.name } : device)
if (this.editing === slug) this.editing = ""
} catch (error) { this.error = error.message }
finally { this.saving = false }
},
},
template: `
<div v-if="visible" class="gx-nav-section">
<div class="gx-nav-section__title">Commas</div>
<div v-for="(device, index) in devices" :key="device.slug" style="display:flex;align-items:center;gap:4px">
<button type="button" class="gx-nav-item" :class="{active:device.slug === activeSlug}" style="flex:1;min-width:0" @click="select(device)">
<i class="bi bi-cpu"></i><span style="overflow:hidden;text-overflow:ellipsis">{{ device.name || 'Comma ' + (index + 1) }}</span>
<small v-if="device.slug === activeSlug">Current</small>
</button>
<button type="button" class="gx-icon-btn" :aria-label="'Rename ' + (device.name || 'comma')" @click="edit(device)"><i class="bi bi-pencil"></i></button>
</div>
<form v-if="editing" @submit.prevent="save" style="padding:var(--sp-2)">
<label for="gx-device-name">Rename comma</label>
<input id="gx-device-name" class="gx-field" v-model="draft" maxlength="40" autocomplete="off" />
<div style="display:flex;gap:6px;margin-top:8px"><button type="submit" class="gx-btn" :disabled="saving">Save</button><button type="button" class="gx-btn gx-btn--tonal" @click="editing=''">Cancel</button></div>
<p v-if="error" class="gx-note gx-note--danger">{{ error }}</p>
</form>
</div>
`,
}
@@ -0,0 +1,8 @@
import { DriveStatePanel } from "./drive-state.js"
export const DEVICE_PAGES = Object.freeze({})
export function devicePage(path) { return null }
export const DevicePreferencesPage = {
components: { DriveStatePanel },
props: { mode: { type: String, required: true }, go: { type: Function, required: true } },
template: `<section class="gx-driving" aria-label="Force Drive State"><DriveStatePanel v-if="mode === 'local'" /><div v-else class="gx-card gx-message">Force Drive State is available on your connected device.</div></section>`,
}
@@ -0,0 +1,86 @@
import { LocalHistoryFeed } from "./record-history.js"
const STATES = Object.freeze({ parked: "Parked", driving: "Driving", standby: "Standby" })
export const validDeviceState = (value) => value &&
(value.state === null || Object.hasOwn(STATES, value.state)) &&
Number.isFinite(value.maxAgeMs) && value.maxAgeMs >= (value.state === null ? 0 : 1) && value.maxAgeMs <= 3000
// Reuse the authenticated request/deadline boundary; this lightweight endpoint
// does not scan processes or start any device services.
export class DeviceStateFeed extends LocalHistoryFeed {
static endpoint = "./api/device/state"
static valid = validDeviceState
static subject = "device state"
static unavailable = "Device state is unavailable."
static invalid = "Device state is unavailable."
constructor(options) {
super(options)
this.clock = options.clock || (() => performance.now())
this.poll = this.expiry = null
this.lastState = null
this.validUntil = this.retainUntil = 0
this.presented = ""
}
stop() {
this.cancelTimer(this.poll)
this.cancelTimer(this.expiry)
this.poll = this.expiry = null
this.lastState = null
this.validUntil = this.retainUntil = 0
super.stop()
}
present() {
const now = this.clock()
const state = now < this.retainUntil ? this.lastState : null
const stale = state !== null && now >= this.validUntil
const key = `${state}:${stale}`
if (key !== this.presented) {
this.presented = key
this.publish({ state, stale })
}
this.cancelTimer(this.expiry)
this.expiry = null
const next = now < this.validUntil ? this.validUntil : this.retainUntil
if (state !== null) this.expiry = this.later(() => this.present(), Math.max(1, next - now))
}
emit() {
if (this.status === "loading") return
const now = this.clock()
const elapsed = now - this.startedAt
if (this.status === "ready" && this.data?.state !== null && elapsed >= 0 && this.data.maxAgeMs > elapsed) {
this.lastState = this.data.state
this.validUntil = this.startedAt + this.data.maxAgeMs
// Display continuity only. A missed poll never extends driving authority or this deadline.
this.retainUntil = this.validUntil + 5000
}
this.present()
if (this.active && this.poll === null) {
const delay = Math.max(500, Math.min(1500, (this.validUntil - now) / 2))
this.poll = this.later(() => { this.poll = null; this.load() }, delay)
}
}
load() {
if (!this.active || this.request !== null) return
this.startedAt = this.clock()
return super.load()
}
}
export const DeviceState = {
props: { unauthorized: { type: Function, required: true }, connection: { type: String, default: "" } },
data: () => ({ state: null, stale: false }),
computed: { label() { return STATES[this.state] || "State unavailable" } },
created() { this.feed = new DeviceStateFeed({ publish: (update) => Object.assign(this.$data, update), unauthorized: this.unauthorized }) },
mounted() {
this.visibility = () => document.hidden ? this.feed.stop() : this.feed.start()
document.addEventListener("visibilitychange", this.visibility)
if (!document.hidden) this.feed.start()
},
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed.stop() },
template: `<span class="gx-status-pill gx-device-state" role="status" :title="stale ? 'Reconnecting; showing last reported state' : connection">
<span class="gx-status-dot" :class="state && !stale ? 'online' : 'offline'" aria-hidden="true"></span>{{ label }}
</span>`,
}
@@ -0,0 +1,114 @@
import { reactive } from "../vendor/vue/vue.esm-browser.js"
import { LocalAccess } from "./local-access.js"
const text = (value, max = 1024) => typeof value === "string" && value.length <= max
const rows = value => Array.isArray(value) && value.length <= 128 && value.every(row => row && text(row.label, 128) && text(row.value))
export function validTroubleshoot(value) {
return !!value && value.schemaVersion === 1 && [null, "parked", "driving", "standby"].includes(value.device?.state) &&
typeof value.vehicle?.available === "boolean" && typeof value.vehicle.longitudinal === "boolean" &&
["fingerprint", "brand", "steering"].every(key => text(value.vehicle[key])) && rows(value.snapshot) &&
Array.isArray(value.sections) && value.sections.length <= 32 &&
value.sections.every(section => text(section.title, 128) && rows(section.rows)) && text(value.note, 2048)
}
export function validTmux(value) {
return !!value && typeof value.available === "boolean" && text(value.reason) && text(value.pane, 128) &&
typeof value.truncated === "boolean" && typeof value.text === "string" &&
new TextEncoder().encode(value.text).length <= 65536 && value.text.split("\n").length <= 301
}
export function troubleshootReport(data) {
if (!data) return ""
return ["StarPilot Troubleshoot", `Device: ${data.device.state || "Unavailable"}`,
`Vehicle: ${data.vehicle.available ? data.vehicle.fingerprint : "Unavailable"}`,
`Brand: ${data.vehicle.brand}`, `Longitudinal: ${data.vehicle.longitudinal ? "Available" : "Unavailable"}`, `Steering: ${data.vehicle.steering}`,
...data.snapshot.map(row => `${row.label}: ${row.value}`),
...data.sections.flatMap(section => ["", section.title, ...section.rows.map(row => `${row.label}: ${row.value}`)]),
"", data.note].join("\n")
}
export class DiagnosticFeed {
constructor({ endpoint, validate, publish, unauthorized = () => {}, interval = 0,
fetcher = (...args) => fetch(...args), later = (fn, ms) => setTimeout(fn, ms), cancel = id => clearTimeout(id) }) {
Object.assign(this, { endpoint, validate, publish, unauthorized, interval, fetcher, later, cancel })
this.active = false; this.visible = true; this.live = true; this.data = null
this.request = this.timer = null; this.generation = 0
}
start(mode, visible = true) { this.stop(); this.mode = mode; this.visible = visible; this.active = true
if (mode !== "local") { this.data = null; this.publish({ data: null, error: "This tool is unavailable in sample mode.", loading: false }); return }
if (visible) return this.refresh()
}
cancelPending() { this.generation++; this.request?.abort(); this.request = null
if (this.timer !== null) this.cancel(this.timer)
this.timer = null
}
stop() { this.active = false; this.cancelPending() }
setVisible(value) { this.visible = value; this.cancelPending(); if (value && this.active && this.live) return this.refresh() }
setLive(value) { this.live = value; this.cancelPending(); this.publish({ live: value, loading: false }); if (value && this.visible) return this.refresh() }
async refresh() {
if (!this.active || !this.visible || this.mode !== "local" || this.request) return
if (this.timer !== null) this.cancel(this.timer)
this.timer = null
const generation = ++this.generation, request = new AbortController()
this.request = request; this.publish({ loading: !this.data, error: "" })
const deadline = this.later(() => request.abort(), 4000)
try {
const response = await this.fetcher(this.endpoint, { credentials: "same-origin", cache: "no-store", signal: request.signal })
if (!this.active || generation !== this.generation) return
if (response.status === 401) { this.stop(); this.unauthorized(); return }
const data = await response.json()
if (!this.active || generation !== this.generation) return
if (!response.ok || !this.validate(data)) throw new Error("Unsupported diagnostic response")
this.data = data; this.publish({ data, error: "" })
} catch (error) {
if (this.active && generation === this.generation)
this.publish({ error: error.name === "AbortError" ? "Reading the tool timed out. Try Refresh." : "This tool is unavailable. Try Refresh or local access." })
} finally {
this.cancel(deadline)
if (this.active && generation === this.generation) {
this.request = null; this.publish({ loading: false })
if (this.interval && this.live && this.visible) this.timer = this.later(() => { this.timer = null; this.refresh() }, this.interval)
}
}
}
}
const props = { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }
function setup(props, consoleTail) {
const state = reactive({ loading: false, live: true, data: null, error: "", notice: "" })
const feed = new DiagnosticFeed({ endpoint: consoleTail ? "./api/tmux/live" : "./api/troubleshoot",
validate: consoleTail ? validTmux : validTroubleshoot, interval: consoleTail ? 2000 : 0,
publish: update => Object.assign(state, update), unauthorized: props.unauthorized })
return { state, feed }
}
const lifecycle = {
mounted() { this._visibility = () => this.feed.setVisible(!document.hidden)
document.addEventListener("visibilitychange", this._visibility); this.feed.start(this.mode, !document.hidden) },
beforeUnmount() { document.removeEventListener("visibilitychange", this._visibility); this.feed.stop() },
watch: { mode(value) { this.feed.start(value, !document.hidden) } },
}
const methods = {
async copyText(value) { try { await navigator.clipboard.writeText(value); this.state.notice = "Visible text copied." }
catch { this.state.notice = "Copy is unavailable in this browser. Select the visible text to copy it." } },
saveText(value) { const url = URL.createObjectURL(new Blob([value], { type: "text/plain;charset=utf-8" }))
const link = document.createElement("a"); link.href = url; link.download = "starpilot-console.txt"; link.click(); URL.revokeObjectURL(url) },
}
export const TroubleshootPage = {
components: { LocalAccess }, props, emits: ["navigate"], ...lifecycle,
setup: props => setup(props, false), methods: { ...methods, report: troubleshootReport },
template: `<section class="gx-settings"><div class="gx-settings__header"><h2>Troubleshoot</h2><button class="gx-btn gx-btn--tonal" @click="$emit('navigate', '/logs')">Logs &amp; Diagnostics</button></div>
<p>Read-only device, vehicle and selected settings. Change settings in Toggles.</p>
<div class="gx-settings__controls"><button class="gx-btn gx-btn--tonal" :disabled="mode !== 'local' || state.loading" @click="feed.refresh()">Refresh</button><button class="gx-btn gx-btn--tonal" :disabled="!state.data" @click="copyText(report(state.data))">Copy Report</button><button class="gx-btn gx-btn--tonal" @click="$emit('navigate', '/settings')">Toggles</button><button class="gx-btn gx-btn--tonal" @click="$emit('navigate', '/logs/monitor')">System Monitor</button><button class="gx-btn gx-btn--tonal" @click="$emit('navigate', '/logs/crashes')">Crash Reports</button></div>
<p v-if="state.loading" role="status">Loading report…</p><p v-if="state.error" role="alert">{{ state.error }}</p><p v-if="state.notice" role="status">{{ state.notice }}</p>
<template v-if="state.data"><section class="gx-card" style="padding:var(--sp-4)"><h3>Device Snapshot</h3><p>State: {{ state.data.device.state || 'Unavailable' }}</p><p>Vehicle: {{ state.data.vehicle.available ? state.data.vehicle.fingerprint : 'Unavailable' }}</p><p>Brand: {{ state.data.vehicle.brand || 'Unavailable' }} · Longitudinal: {{ state.data.vehicle.longitudinal ? 'Available' : 'Unavailable' }} · Steering: {{ state.data.vehicle.steering || 'Unavailable' }}</p><dl><template v-for="row in state.data.snapshot" :key="row.label"><dt>{{ row.label }}</dt><dd>{{ row.value }}</dd></template></dl></section>
<section v-for="section in state.data.sections" :key="section.title" class="gx-card" style="padding:var(--sp-4)"><h3>{{ section.title }}</h3><dl><template v-for="row in section.rows" :key="row.label"><dt>{{ row.label }}</dt><dd>{{ row.value }}</dd></template></dl></section><p class="gx-note">{{ state.data.note }}</p></template>
<LocalAccess v-if="state.error" :mode="mode" :on-unauthorized="unauthorized" /></section>`,
}
export const TmuxPage = {
components: { LocalAccess }, props, emits: ["navigate"], ...lifecycle,
setup: props => setup(props, true), methods,
template: `<section class="gx-settings"><div class="gx-settings__header"><h2>tmux Live View</h2><button class="gx-btn gx-btn--tonal" @click="$emit('navigate', '/logs')">Logs &amp; Diagnostics</button></div>
<p>Read-only launcher console tail. No commands or keyboard input are sent to the comma.</p>
<div class="gx-settings__controls"><button class="gx-btn gx-btn--tonal" :disabled="mode !== 'local'" @click="feed.setLive(!state.live)">{{ state.live ? 'Pause' : 'Resume' }}</button><button class="gx-btn gx-btn--tonal" :disabled="mode !== 'local' || state.loading" @click="feed.refresh()">Refresh</button><button class="gx-btn gx-btn--tonal" :disabled="!state.data?.text" @click="copyText(state.data.text)">Copy Visible Text</button><button class="gx-btn gx-btn--tonal" :disabled="!state.data?.text" @click="saveText(state.data.text)">Save Visible Text</button></div>
<p role="status">{{ state.live ? 'Live · updates every 2 seconds while visible' : 'Paused' }}</p><p v-if="state.loading" role="status">Loading console…</p><p v-if="state.error" role="alert">{{ state.error }}</p><p v-if="state.notice" role="status">{{ state.notice }}</p>
<section v-if="state.data" class="gx-card gx-crash-preview" style="padding:var(--sp-4)"><p v-if="!state.data.available">{{ state.data.reason }}</p><p v-else>Pane: {{ state.data.pane }}</p><p v-if="state.data.truncated" class="gx-note">Console tail is limited to 300 lines and 64 KiB.</p><pre style="white-space:pre-wrap;overflow-wrap:anywhere">{{ state.data.text }}</pre></section>
<LocalAccess v-if="state.error || state.data?.available === false" :mode="mode" :on-unauthorized="unauthorized" /></section>`,
}
@@ -0,0 +1,67 @@
export const validDriveState = (value) => !!value && ["auto", "offroad", "onroad"].includes(value.mode) &&
typeof value.available === "boolean" && typeof value.overrideAllowed === "boolean" &&
(value.revision === null || typeof value.revision === "string" && /^[0-9a-f]{32}$/.test(value.revision)) &&
[null, "offroad", "onroad"].includes(value.effective)
export const DriveStatePanel = {
data: () => ({ state: null, error: "", busy: false }),
computed: {
pending() { return this.state?.mode !== "auto" && this.state?.effective !== this.state?.mode },
},
mounted() {
this.active = true
this.generation = 0
this.load()
this.timer = setInterval(() => { if (!document.hidden) this.load() }, 1000)
},
beforeUnmount() { this.active = false; this.generation++; clearInterval(this.timer); this.controller?.abort() },
methods: {
async load() {
if (this.busy || this.loading || !this.active) return
this.loading = true
const generation = ++this.generation
this.controller = new AbortController()
const controller = this.controller
const deadline = setTimeout(() => controller.abort(), 3000)
try {
const response = await fetch("./api/drive-state/status", { credentials: "same-origin", cache: "no-store", signal: this.controller.signal })
const value = await response.json()
if (!response.ok || !validDriveState(value)) throw new Error(value.error || "Drive state unavailable")
if (this.active && generation === this.generation) { this.state = value; this.error = "" }
} catch (error) { if (this.active && generation === this.generation) { this.state = null; this.error = "Drive state unavailable" } }
finally { clearTimeout(deadline); if (generation === this.generation) this.loading = false }
},
async change(mode) {
if (this.busy || !this.state?.available) return
if (mode !== "auto" && !this.state.overrideAllowed) { this.error = "Park and disengage, then retry. The device can stay on-road."; return }
if (mode === "offroad" && this.state.effective === "onroad" &&
!window.confirm("Switch to Offroad and stop driving services? Stay parked until you return to Auto.")) return
const revision = this.state.revision
this.busy = true
this.loading = false
const generation = ++this.generation
this.controller?.abort()
this.controller = new AbortController()
const controller = this.controller
const deadline = setTimeout(() => controller.abort(), 3000)
try {
const response = await fetch("./api/drive-state/action", { method: "POST", credentials: "same-origin", cache: "no-store",
signal: this.controller.signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode, revision }) })
const value = await response.json()
if (!response.ok || !validDriveState(value)) throw new Error(value.error || "Drive state unavailable")
if (this.active && generation === this.generation) { this.state = value; this.error = "" }
} catch (error) { if (this.active && generation === this.generation) this.error = error.message }
finally { clearTimeout(deadline); this.busy = false; if (this.active && generation === this.generation) this.load() }
},
},
template: `<section class="gx-card gx-force-drive" aria-label="Force Drive State"><h3>Force Drive State</h3>
<p>Device: {{ state?.effective === "onroad" ? "Onroad" : state?.effective === "offroad" ? "Offroad" : "Checking…" }}</p>
<p v-if="pending" role="status">Waiting for the device to switch {{ state.mode }}…</p>
<p>Auto follows the car. Force Offroad stops driving services even when the device is on-road. Park and disengage before switching; Return to Auto restores normal operation.</p>
<p v-if="state?.available && !state.overrideAllowed" role="status">Waiting for a fresh parked, disengaged state. You do not need to turn the vehicle off.</p>
<p v-if="state && !state.available" role="alert">The drive-state manager is unavailable. Reconnect to check again.</p>
<div><button v-for="mode in ['offroad', 'onroad', 'auto']" :key="mode" type="button"
:disabled="busy || !state?.available || (mode !== 'auto' && !state.overrideAllowed)"
:aria-pressed="state?.mode === mode" @click="change(mode)">{{ mode === 'auto' ? 'Return to Auto' : mode === 'onroad' ? 'Onroad' : 'Offroad' }}</button></div>
<p v-if="error" role="alert">{{ error }}</p></section>`,
}
@@ -0,0 +1,178 @@
const ROUTE = /^[a-zA-Z0-9_+|.-]{1,128}$/
const DATE = /^\d{4}-\d{2}-\d{2}$/
const number = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0
const optionalNumber = (value) => value === null || number(value)
const integer = (value) => Number.isSafeInteger(value) && value >= 0
const text = (value, limit = 128) => typeof value === "string" && value.length <= limit &&
!/[\x00-\x1f\x7f]/.test(value)
const optionalText = (value, limit = 128) => value === null || text(value, limit)
const validDrive = (drive) => drive && ROUTE.test(drive.routeId) &&
[drive.startTime, drive.endTime].every((value) => value === null || number(value)) &&
[drive.distanceMeters, drive.durationSeconds, drive.engagedSeconds, drive.engagedPercent,
drive.distractedMoments, drive.unresponsiveMoments].every(optionalNumber) &&
optionalText(drive.model) && optionalText(drive.reason, 256) &&
typeof drive.complete === "boolean" && typeof drive.ignored === "boolean" &&
integer(drive.segmentCount) && drive.segmentCount <= 10000 &&
(drive.engagedPercent === null || drive.engagedPercent <= 100)
export function validDriveStats(value) {
if (value?.schemaVersion !== 1 || typeof value.isMetric !== "boolean" || !value.analysis || !value.totals || !value.week ||
!Array.isArray(value.recentDrives) || value.recentDrives.length > 100 ||
!Array.isArray(value.records) || value.records.length > 24 ||
!Array.isArray(value.models) || value.models.length > 100) return false
const { analysis, totals, week, lastDrive, recentDrives, records, models } = value
if (week.timezone !== undefined && !text(week.timezone)) return false
if (typeof analysis.running !== "boolean" || !integer(analysis.pending) || !integer(analysis.failed) ||
typeof analysis.scanIncomplete !== "boolean") return false
if (![totals.distanceMeters, totals.durationSeconds, totals.engagedSeconds].every(optionalNumber) ||
!integer(totals.drives) ||
![week.distanceMeters, week.durationSeconds, week.engagedPercent].every(optionalNumber) ||
(week.engagedPercent !== null && week.engagedPercent > 100) || !integer(week.drives) ||
!Array.isArray(week.days) || week.days.length !== 7 ||
!week.days.every((day) => day && DATE.test(day.date) && Number.isFinite(Date.parse(`${day.date}T00:00:00Z`)) &&
optionalNumber(day.distanceMeters))) return false
const routes = new Set()
if (lastDrive !== null && !validDrive(lastDrive)) return false
if (!recentDrives.every((drive) => {
if (!validDrive(drive) || routes.has(drive.routeId)) return false
routes.add(drive.routeId)
return true
})) return false
const recordIds = new Set()
if (!records.every((record) => {
if (!record || !text(record.id) || !text(record.label) ||
!(record.value === null || number(record.value) || text(record.value)) || !optionalText(record.unit, 32) ||
recordIds.has(record.id)) return false
recordIds.add(record.id)
return true
})) return false
const names = new Set()
return models.every((model) => {
if (!model || !text(model.name) || names.has(model.name) ||
![model.distanceMeters, model.durationSeconds].every(optionalNumber) || !integer(model.drives)) return false
names.add(model.name)
return true
})
}
export class DriveStatsFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer })
this.active = false
this.generation = 0
this.controller = null
this.timeout = null
this.poll = null
this.data = null
this.status = "idle"
this.error = ""
this.busy = false
}
emit() { this.publish({ status: this.status, data: this.data, error: this.error, busy: this.busy }) }
stop() {
this.active = false
this.generation++
this.controller?.abort()
if (this.timeout !== null) this.cancelTimer(this.timeout)
if (this.poll !== null) this.cancelTimer(this.poll)
this.controller = this.timeout = this.poll = null
this.data = null
this.status = "idle"
this.error = ""
this.busy = false
this.emit()
}
start() { this.stop(); this.active = true; return this.load() }
schedule(delay = null) {
if (!this.active || this.poll !== null || this.controller !== null) return
const generation = this.generation
const analyzing = this.data?.analysis?.running
this.poll = this.later(() => {
this.poll = null
if (this.active && generation === this.generation) this.load()
}, delay ?? (analyzing ? 5000 : 60000))
}
async request(body = null) {
if (!this.active || this.controller !== null) return null
const generation = this.generation
const controller = new AbortController()
this.controller = controller
if (this.poll !== null) this.cancelTimer(this.poll)
this.poll = null
this.status = this.data ? "ready" : "loading"
this.busy = body !== null
this.error = ""
this.emit()
this.timeout = this.later(() => {
if (!this.active || generation !== this.generation || this.controller !== controller) return
controller.abort()
this.generation++
this.controller = this.timeout = null
this.busy = false
this.status = this.data ? "ready" : "unavailable"
this.error = "Drive history timed out. Refresh to try again."
this.emit()
this.schedule(5000)
}, 5000)
try {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
const statsUrl = "./api/drives/stats?timezone=" + encodeURIComponent(timezone)
let response = await this.fetcher(body === null ? statsUrl : "./api/drives/ignore", {
credentials: "same-origin", cache: "no-store", signal: controller.signal,
...(body === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
})
if (!this.active || generation !== this.generation || controller.signal.aborted) return null
if (response.status === 401) { this.stop(); this.unauthorized(); return null }
if (response.status === 503) {
const detail = await response.json().catch(() => null)
if (!this.active || generation !== this.generation || controller.signal.aborted) return null
if (["setup_required", "access_unavailable"].includes(detail?.code)) {
this.stop(); this.unauthorized(); return null
}
}
if (!response.ok) throw new Error("Drive history could not be updated. Refresh to try again.")
if (body !== null) {
response = await this.fetcher(statsUrl, { credentials: "same-origin", cache: "no-store", signal: controller.signal })
if (response.status === 401) { this.stop(); this.unauthorized(); return null }
if (!response.ok) throw new Error("Drive history could not be refreshed.")
}
const data = await response.json()
if (!this.active || generation !== this.generation || controller.signal.aborted) return null
if (!validDriveStats(data)) throw new Error("Drive history is unavailable. Refresh to try again.")
this.data = data
this.status = "ready"
this.emit()
return data
} catch (error) {
if (this.active && generation === this.generation) {
this.status = this.data ? "ready" : "unavailable"
this.error = controller.signal.aborted ? "Drive history timed out. Refresh to try again." : error.message
this.emit()
}
return null
} finally {
if (this.controller === controller) {
if (this.timeout !== null) this.cancelTimer(this.timeout)
this.controller = this.timeout = null
this.busy = false
this.emit()
this.schedule()
}
}
}
load() { return this.request() }
ignore(routeId, ignored) {
if (!this.data?.recentDrives?.some((drive) => drive.routeId === routeId && drive.ignored !== ignored) ||
typeof ignored !== "boolean") return Promise.resolve(null)
return this.request({ routeId, ignored })
}
}
@@ -0,0 +1,57 @@
export const DRIVING_PAGES = Object.freeze({
aol: "aol",
conditional: "conditional",
curve: "curve",
slc: "slc",
torque: "torque",
profiles: "profiles",
traffic: "traffic",
lane: "lane",
lane_change: "lane_change",
})
export const DRIVING_GROUPS = Object.freeze([
{ title: "Steering and buttons", items: [
{ page: "aol", title: "Always On Lateral", detail: "Steering assistance independent of cruise control" },
{ page: "lane", title: "Lane centering", detail: "Saved steering behavior" },
{ page: "lane_change", title: "Lane changes", detail: "Saved lane-change choices" },
{ page: "torque", title: "Torque tuning", detail: "Saved steering tune and vehicle-source selection" },
] },
{ title: "Speed and longitudinal", items: [
{ page: "conditional", title: "Conditional driving modes", detail: "Experimental and Chill conditions" },
{ page: "curve", title: "Curve Speed Controller", detail: "Saved curve-speed choices" },
{ page: "slc", title: "Speed Limit Controller", detail: "Dashboard and qualified vision source choices" },
{ page: "profiles", title: "Longitudinal profiles", detail: "Saved acceleration, braking, and following profiles" },
{ page: "traffic", title: "Traffic Mode", detail: "Saved follow, jerk, and profile curves" },
] },
])
export function drivingPage(path) {
const match = /^\/driving\/([a-z_]+)$/.exec(path)
return match && DRIVING_PAGES[match[1]] || null
}
export const DrivingPage = {
props: { mode: { type: String, required: true }, go: { type: Function, required: true } },
data: () => ({ groups: DRIVING_GROUPS }),
template: `
<section class="gx-driving" aria-label="Driving features">
<div class="gx-card gx-driving__intro"><p class="gx-eyebrow">Driving features</p><h2>Driving Settings</h2>
<p>Choose how StarPilot steers and controls speed.</p></div>
<div v-if="mode !== 'local'" class="gx-card gx-message" role="status">Local driving settings are unavailable in preview.</div>
<template v-else>
<section v-for="group in groups" :key="group.title" class="gx-driving__group">
<h3>{{ group.title }}</h3>
<div class="gx-driving__grid">
<button v-for="item in group.items" :key="item.page" type="button" class="gx-card gx-driving__link"
@click="go('/driving/' + item.page)">
<span><strong>{{ item.title }}</strong><small>{{ item.detail }}</small></span><i class="bi bi-chevron-right" aria-hidden="true"></i>
</button>
</div>
</section>
<div class="gx-driving__extras"><button type="button" class="gx-btn gx-btn--tonal" @click="go('/driving/longitudinal-curves')">Edit longitudinal curves</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="go('/tuning/plots')">Live plots</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="go('/tuning/flm')">Offline FLM diagnostics</button></div>
</template>
</section>`,
}
@@ -0,0 +1,138 @@
import { reactive } from "../vendor/vue/vue.esm-browser.js"
import { GalaxySelect } from "./galaxy-select.js"
export function validFavorites(data) {
return !!data && typeof data.revision === "string" && !!data.revision && typeof data.editable === "boolean" &&
typeof data.valid === "boolean" && Array.isArray(data.slots) && data.slots.length === 3 &&
data.slots.every((slot) => slot && typeof slot.enabled === "boolean" && typeof slot.show_onroad === "boolean" &&
(slot.key === null || typeof slot.key === "string") && typeof slot.label === "string" && slot.label.length <= 32) &&
Array.isArray(data.options) && data.options.length <= 128 && data.options.every((option) => option &&
typeof option.key === "string" && typeof option.label === "string" && ["toggle", "enum", "action"].includes(option.kind)) &&
new Set(data.options.map((option) => option.key)).size === data.options.length &&
Array.isArray(data.states) && data.states.length === 3 && data.states.every((state, index) => state &&
state.index === index && typeof state.available === "boolean" && typeof state.stateLabel === "string" && typeof state.reason === "string")
}
export class FavoritesFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer })
this.active = false; this.generation = 0; this.data = this.request = this.timer = null; this.needsReload = false
}
stop() {
this.active = false; this.generation++; this.request?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
this.request = this.timer = null
}
start() { this.stop(); this.active = true; return this.load() }
load() { return this.run() }
update(index, patch) {
if (!this.data?.editable || this.request || this.needsReload || !Number.isInteger(index) || index < 0 || index > 2 ||
!patch || Object.keys(patch).some((key) => !["key", "label", "enabled", "show_onroad"].includes(key))) return
const slots = this.data.slots.map((slot) => ({ ...slot }))
if (Object.hasOwn(patch, "key")) {
const option = this.data.options.find((entry) => entry.key === patch.key)
if (patch.key !== null && !option) return
delete slots[index].value
slots[index].label = option?.label.slice(0, 32) || ""
if (!option) Object.assign(slots[index], { enabled: false, show_onroad: false })
}
Object.assign(slots[index], patch)
return this.run({ revision: this.data.revision, slots })
}
async run(body = null) {
if (!this.active || this.request) return
const request = new AbortController(), generation = ++this.generation, saving = body !== null
this.request = request
this.publish({ status: saving ? "saving" : "loading", error: "", notice: "" })
const fail = (error) => {
this.needsReload ||= saving
this.publish({ status: this.data ? "ready" : "unavailable", needsReload: this.needsReload, error })
}
this.timer = this.later(() => {
if (!this.active || generation !== this.generation) return
request.abort(); this.generation++; this.request = this.timer = null
fail(saving ? "Saving timed out. Reload to check which Quick Select were saved." : "Reading Quick Select timed out. Reload to try again.")
}, 5000)
try {
const response = await this.fetcher("./api/favorites/slots", { credentials: "same-origin", cache: "no-store", signal: request.signal,
...(saving ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } : {}) })
if (!this.active || generation !== this.generation) return
const data = await response.json().catch(() => null)
if (!this.active || generation !== this.generation) return
if (response.status === 401 || ["access_unavailable", "setup_required"].includes(data?.code)) { this.stop(); this.unauthorized(); return }
if (response.status === 409) throw new Error("Quick Select changed on another screen. Reload before editing again.")
if (!response.ok || !validFavorites(data)) throw new Error(saving ? "Quick Select could not be confirmed. Reload before editing again." : "Saved Quick Select are unavailable.")
this.data = data; this.needsReload = false
this.publish({ status: "ready", data, needsReload: false, error: "", notice: saving ? "Quick Select saved." : "" })
} catch (error) {
if (this.active && generation === this.generation) fail(error?.message || "Quick Select are unavailable.")
} finally {
if (this.request === request) {
if (this.timer !== null) this.cancelTimer(this.timer)
this.request = this.timer = null
}
}
}
}
export const FavoritesPage = {
components: { GalaxySelect },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
emits: ["close"],
setup(props) {
const state = reactive({ status: "idle", data: null, error: "", notice: "", needsReload: false })
const feed = new FavoritesFeed({ publish: (update) => Object.assign(state, update), unauthorized: props.unauthorized })
return { state, feed }
},
computed: {
busy() { return ["loading", "saving"].includes(this.state.status) },
disabled() { return !this.state.data?.editable || this.busy || this.state.needsReload },
byKey() { return new Map((this.state.data?.options || []).map((option) => [option.key, option])) },
groups() { return [...new Set((this.state.data?.options || []).map((option) => option.section || "Controls"))] },
},
mounted() { if (this.mode === "local") this.feed.start() },
beforeUnmount() { this.feed.stop() },
methods: {
select(index, event) { return this.feed.update(index, { key: event.target.value || null }) },
toggle(index, field, event) {
const checked = event.target.checked
event.target.checked = this.state.data.slots[index][field]
return this.feed.update(index, { [field]: checked })
},
label(index, event) {
const value = event.target.value.trim().slice(0, 32)
event.target.value = this.state.data.slots[index].label
if (value !== this.state.data.slots[index].label) return this.feed.update(index, { label: value })
},
},
template: `
<section class="gx-settings gx-favorites" aria-label="Quick Select">
<div class="gx-settings__header"><div><h2>Quick Select</h2><p>Choose your three driving-screen shortcuts.</p></div>
<button class="gx-btn gx-btn--tonal" type="button" :disabled="busy" @click="$emit('close')">Back</button></div>
<p class="gx-note">Small UI: tap the invisible left, middle or right third. Big UI: tap or swipe the lower-left corner to open Quick Select.</p>
<p class="gx-note">Assigning a shortcut does not activate it. Each control keeps its usual availability; some settings can only change while parked.</p>
<div v-if="mode !== 'local'" class="gx-card gx-message" role="status">Connect to local Galaxy to configure Quick Select.</div>
<template v-else>
<div class="gx-favorites__status"><span role="status">{{ state.status === 'saving' ? 'Saving Quick Select…' : state.status === 'loading' ? 'Loading Quick Select…' : state.notice }}</span>
<button class="gx-btn gx-btn--tonal" type="button" :disabled="busy" @click="feed.load()">Reload saved</button></div>
<p v-if="state.error" class="gx-card gx-message" role="alert">{{ state.error }}</p>
<p v-if="state.data && !state.data.valid" class="gx-note" role="status">Saved Quick Select could not be read. Empty slots are shown. Choosing a control will replace the invalid saved configuration.</p>
<div v-if="state.data" class="gx-favorites__slots">
<section v-for="(slot, index) in state.data.slots" :key="index" class="gx-card gx-favorites__slot">
<div class="gx-favorites__slot-header"><h3>Favorite {{ index + 1 }} <small>{{ ['Left', 'Middle', 'Right'][index] }}</small></h3>
<label class="gx-switch"><input type="checkbox" :aria-label="'Enable favorite ' + (index + 1)" :checked="slot.enabled" :disabled="disabled || !slot.key" @change="toggle(index, 'enabled', $event)"><span class="gx-switch__track"></span><span class="gx-switch__thumb"></span></label></div>
<label>Control<GalaxySelect class="gx-field gx-field--full" :aria-label="'Quick Select ' + (index + 1) + ' control'" :value="slot.key || ''" :disabled="disabled" @change="select(index, $event)">
<option value="">Not assigned</option>
<option v-if="slot.key && !byKey.has(slot.key)" :value="slot.key">{{ slot.label || slot.key }} · Unavailable in this build</option>
<optgroup v-for="group in groups" :key="group" :label="group"><option v-for="option in state.data.options.filter(item => (item.section || 'Controls') === group)" :key="option.key" :value="option.key">{{ option.label }}</option></optgroup>
</GalaxySelect></label>
<label>Label<input class="gx-field" type="text" maxlength="32" :aria-label="'Quick Select ' + (index + 1) + ' label'" :value="slot.label" :disabled="disabled || !slot.key" @change="label(index, $event)"></label>
<div class="gx-favorites__show"><span>Show onroad · Big and Small UI</span><label class="gx-switch"><input type="checkbox" :aria-label="'Show favorite ' + (index + 1) + ' onroad'" :checked="slot.show_onroad" :disabled="disabled || !slot.enabled || !slot.key" @change="toggle(index, 'show_onroad', $event)"><span class="gx-switch__track"></span><span class="gx-switch__thumb"></span></label></div>
<p v-if="slot.key" class="gx-note">{{ state.data.states[index].stateLabel }}<template v-if="state.data.states[index].reason"> · {{ state.data.states[index].reason }}</template></p>
<button class="gx-btn gx-btn--tonal" type="button" :disabled="disabled || !slot.key" @click="feed.update(index, {key:null})">Clear slot</button>
</section>
</div>
</template>
</section>`,
}
+283
View File
@@ -0,0 +1,283 @@
import { LocalHistoryFeed, validLocalHistory } from './record-history.js'
const SEGMENT = /^(?:[a-f0-9]{8}--[a-f0-9]{10}|[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{2}-[0-9]{2}-[0-9]{2}|[a-f0-9]{16}[|_](?:[a-f0-9]{8}--[a-f0-9]{10}|[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{2}-[0-9]{2}-[0-9]{2}))--[0-9]{1,6}$/
const STATES = ['idle', 'running', 'completed', 'canceled', 'failed', 'unavailable']
const RESULTS = ['measured', 'insufficient_samples', 'missing_car_params', 'unsupported_car', 'unsupported_controller']
const FAILURE = {
not_parked: 'Park the vehicle before analyzing recordings.',
canceled: 'Analysis was canceled.',
source_changed: 'A selected recording changed. Refresh the inventory.',
invalid_segment: 'A selected full log is unavailable. Refresh the inventory.',
busy: 'Another analysis is already running.',
invalid_request: 'Select one to five closed full logs, then try again.',
operation_changed: 'The analysis session changed. Refresh status.',
process_failed: 'The analysis process stopped unexpectedly. Its error was saved to the device log.',
deadline: 'Analysis exceeded its time limit. Try one segment at a time.',
recording_unavailable: 'A selected recording is missing, still open, or changed. Refresh recordings.',
decode_failed: 'A selected full log could not be decoded. Try another segment.',
resource_limit: 'This recording exceeds the analysis memory limit. Try a smaller segment.',
}
const labelFailure = (code) => FAILURE[code] || 'Offline analysis is unavailable.'
const finite = (value) => typeof value === 'number' && Number.isFinite(value)
export function selectableSegments(inventory) {
if (!validLocalHistory(inventory)) return []
return inventory.routes.flatMap((route) => route.segments.filter((segment) => segment.files.rlog &&
typeof segment.segmentName === 'string' && SEGMENT.test(segment.segmentName))
.map((segment) => ({ routeId: route.routeId, number: segment.number, name: segment.segmentName })))
}
export function validFlmStatus(value) {
return value?.version === 1 && STATES.includes(value.state) &&
(value.operationId === null || typeof value.operationId === 'string' && /^[a-zA-Z0-9:_-]{1,100}$/.test(value.operationId)) &&
Number.isSafeInteger(value.selected) && value.selected >= 0 && value.selected <= 5 &&
Number.isSafeInteger(value.processed) && value.processed >= 0 && value.processed <= value.selected &&
(value.errorCode === null || typeof value.errorCode === 'string' && /^[a-z_]{1,60}$/.test(value.errorCode)) &&
(value.state === 'idle' ? value.operationId === null : value.operationId !== null)
}
function validSeries(points) {
return Array.isArray(points) && points.length <= 160 && points.every((point) =>
point && finite(point.mono_ns) && point.mono_ns > 0 && finite(point.desired_lat_accel) &&
finite(point.actual_lat_accel) && Number.isSafeInteger(point.continuity_id) && point.continuity_id >= 0)
}
function validAnalysis(value) {
return value && typeof value.route === 'string' && value.route.length <= 80 &&
Number.isSafeInteger(value.number) && value.number >= 0 && RESULTS.includes(value.status) &&
(value.car_params_sha256 === null || typeof value.car_params_sha256 === 'string' && /^[a-f0-9]{64}$/.test(value.car_params_sha256)) &&
['messages', 'torque_frames', 'eligible_samples'].every((key) => Number.isSafeInteger(value[key]) && value[key] >= 0) &&
[value.mean_abs_error, value.root_mean_square_error].every((number) => number === null || finite(number) && number >= 0) &&
Array.isArray(value.exclusions) && value.exclusions.length <= 32 && value.exclusions.every((item) =>
Array.isArray(item) && item.length === 2 && typeof item[0] === 'string' && /^[a-z_]{1,60}$/.test(item[0]) &&
Number.isSafeInteger(item[1]) && item[1] >= 0) && validSeries(value.series) &&
Array.isArray(value.windows) && value.windows.length <= 128 && typeof value.windows_truncated === 'boolean'
}
export function validFlmReport(value, operationId) {
return value?.schemaVersion === 1 && value.purpose === 'offline_tracking_diagnostics' &&
value.operationId === operationId && value.tuneRecommendation === null && value.vehicleQualification === false &&
Array.isArray(value.segments) && value.segments.length >= 1 && value.segments.length <= 5 && value.segments.every((entry) =>
entry?.source && typeof entry.source.segmentName === 'string' && SEGMENT.test(entry.source.segmentName) &&
typeof entry.source.sha256 === 'string' && /^[a-f0-9]{64}$/.test(entry.source.sha256) &&
Number.isSafeInteger(entry.source.compressedBytes) && entry.source.compressedBytes > 0 &&
['zst', 'bz2'].includes(entry.source.codec) && validAnalysis(entry.analysis))
}
// Each continuity ID is a distinct measured interval. Never draw through an excluded gap.
export function flmChart(series) {
if (!validSeries(series) || !series.length) return { valid: false, paths: [[], []] }
const first = series[0].mono_ns, last = series.at(-1).mono_ns
const values = series.flatMap((point) => [point.desired_lat_accel, point.actual_lat_accel])
const lo = Math.min(0, ...values), hi = Math.max(0, ...values), span = Math.max(.01, hi - lo)
const x = (stamp) => 48 + Math.max(0, Math.min(1, (stamp - first) / Math.max(1, last - first))) * 576
const y = (value) => 132 - (value - lo) / span * 114
const paths = [[], []]
for (const [keyIndex, key] of ['desired_lat_accel', 'actual_lat_accel'].entries()) {
let points = [], prior = null
const flush = () => { if (points.length >= 2) paths[keyIndex].push(points.join(' ')); points = [] }
for (const point of series) {
if (prior && (point.continuity_id !== prior.continuity_id || point.mono_ns <= prior.mono_ns)) flush()
points.push(`${x(point.mono_ns).toFixed(1)},${y(point[key]).toFixed(1)}`)
prior = point
}
flush()
}
return { valid: true, paths, top: hi.toFixed(2), bottom: lo.toFixed(2), zeroY: y(0),
duration: Math.max(0, (last - first) / 1e9).toFixed(1) }
}
export class FlmFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer })
this.active = false; this.generation = 0; this.inFlight = null; this.timer = this.deadline = null; this.busy = false
this.status = null; this.report = null; this.error = ''
}
emit() { this.publish({ operation: this.status, report: this.report, operationError: this.error,
busy: this.busy, requesting: this.inFlight !== null }) }
stop() {
this.active = false; this.generation++
this.inFlight?.abort(); this.inFlight = null
if (this.timer !== null) this.cancelTimer(this.timer)
if (this.deadline !== null) this.cancelTimer(this.deadline)
this.timer = this.deadline = null; this.busy = false; this.status = this.report = null; this.error = ''
this.emit()
}
start() { this.stop(); this.active = true; return this.refresh() }
async request(url, options = {}, timeoutMs = 10000) {
const generation = this.generation, controller = new AbortController()
this.inFlight = controller
this.emit()
let timer = null
const timeout = new Promise((_, reject) => { timer = this.later(() => { controller.abort(); reject(new Error('Offline analysis timed out. Refresh status before trying again.')) }, timeoutMs); this.deadline = timer })
try {
const response = await Promise.race([this.fetcher(url, { credentials: 'same-origin', cache: 'no-store', ...options, signal: controller.signal }), timeout])
if (!this.active || generation !== this.generation || controller.signal.aborted) return null
if (response.status === 401) { this.stop(); this.unauthorized(); return null }
const body = await Promise.race([response.json(), timeout])
if (!this.active || generation !== this.generation || controller.signal.aborted) return null
if (response.status === 503 && ['setup_required', 'access_unavailable'].includes(body?.code)) {
this.stop(); this.unauthorized(); return null
}
if (!response.ok) throw new Error(labelFailure(body?.code))
return body
} finally {
if (timer !== null) this.cancelTimer(timer)
if (this.deadline === timer) this.deadline = null
if (this.inFlight === controller) {
this.inFlight = null
if (this.active && generation === this.generation) this.emit()
}
}
}
schedule() {
if (!this.active || this.status?.state !== 'running') {
if (this.timer !== null) this.cancelTimer(this.timer)
this.timer = null
return
}
if (this.active && this.status?.state === 'running' && this.timer === null) {
this.timer = this.later(() => { this.timer = null; this.refresh() }, 1000)
}
}
async refresh() {
if (!this.active || this.inFlight || this.busy) return
const generation = this.generation
try {
const status = await this.request('./api/flm/status')
if (!this.active || generation !== this.generation || status === null) return
if (!validFlmStatus(status)) throw new Error('Offline analysis status is unavailable.')
const previousId = this.status?.operationId
this.status = status
if (previousId !== status.operationId || status.state !== 'completed') this.report = null
this.error = status.state === 'failed' || status.state === 'unavailable' ? labelFailure(status.errorCode) : ''
this.emit()
if (status.state === 'completed' && this.report === null) await this.loadReport(status.operationId, generation)
} catch (error) {
if (this.active && generation === this.generation) { this.error = error.message; this.emit() }
} finally { if (generation === this.generation) this.schedule() }
}
async loadReport(id, generation = this.generation) {
try {
const report = await this.request(`./api/flm/report?operationId=${encodeURIComponent(id)}`)
if (!this.active || generation !== this.generation || report === null || this.status?.operationId !== id) return
if (!validFlmReport(report, id)) throw new Error('Offline analysis report is unavailable.')
this.report = report; this.error = ''; this.emit()
} catch (error) {
if (this.active && generation === this.generation) { this.error = error.message; this.emit() }
}
}
async mutate(url, body) {
if (!this.active || this.inFlight || this.busy) return
const generation = this.generation
if (this.timer !== null) this.cancelTimer(this.timer)
this.timer = null
this.busy = true; this.error = ''; this.emit()
try {
const status = await this.request(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
if (!this.active || generation !== this.generation || status === null) return
if (!validFlmStatus(status)) throw new Error('Offline analysis response is unavailable.')
this.status = status
this.report = null; this.emit()
} catch (error) {
if (this.active && generation === this.generation) { this.error = error.message; this.emit() }
} finally {
if (this.active && generation === this.generation) { this.busy = false; this.emit(); this.refresh() }
}
}
startAnalysis(segments) { return this.mutate('./api/flm/start', { segments }) }
cancelAnalysis() {
if (this.status?.state !== 'running' || !this.status.operationId) return
return this.mutate('./api/flm/cancel', { operationId: this.status.operationId })
}
}
export const FlmChart = {
name: 'FlmChart',
props: { series: { type: Array, required: true } },
computed: { chart() { return flmChart(this.series) } },
template: `<svg viewBox="0 0 640 170" role="img" aria-label="Desired and actual lateral acceleration in recorded intervals">
<line class="gx-plot-axis" x1="48" y1="18" x2="48" y2="132"/><line class="gx-plot-axis" x1="48" y1="132" x2="624" y2="132"/>
<template v-if="chart.valid"><line class="gx-plot-zero" x1="48" :y1="chart.zeroY" x2="624" :y2="chart.zeroY"/>
<text class="gx-plot-label" x="42" y="21" text-anchor="end">{{ chart.top }}</text>
<text class="gx-plot-label" x="42" y="135" text-anchor="end">{{ chart.bottom }}</text>
<text class="gx-plot-label" x="52" y="160">0 s</text><text class="gx-plot-label" x="624" y="160" text-anchor="end">+{{ chart.duration }} s</text>
</template><g v-for="(paths, index) in chart.paths" :key="index"><polyline v-for="(points, part) in paths" :key="part" :points="points"
:class="index === 0 ? 'gx-plot-desired' : 'gx-plot-actual'"/></g>
</svg>`,
}
export const FlmPage = {
name: 'FlmPage',
components: { FlmChart },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, go: { type: Function, required: true } },
data: () => ({ inventoryStatus: 'idle', inventory: null, inventoryError: '', operation: null, report: null,
operationError: '', busy: false, requesting: false, selected: [] }),
created() {
this.inventoryFeed = new LocalHistoryFeed({ publish: ({ status, data, error }) => {
this.inventoryStatus = status; this.inventory = data; this.inventoryError = error
if (data) this.selected = this.selected.filter((name) => selectableSegments(data).some((segment) => segment.name === name))
}, unauthorized: this.unauthorized })
this.operationFeed = new FlmFeed({ publish: (state) => Object.assign(this.$data, state), unauthorized: this.unauthorized })
},
mounted() {
this.visibility = () => {
if (document.hidden) { this.inventoryFeed.stop(); this.operationFeed.stop() }
else if (this.mode === 'local') { this.inventoryFeed.start(); this.operationFeed.start() }
}
document.addEventListener('visibilitychange', this.visibility)
if (this.mode === 'local' && !document.hidden) { this.inventoryFeed.start(); this.operationFeed.start() }
},
beforeUnmount() { document.removeEventListener('visibilitychange', this.visibility); this.inventoryFeed.stop(); this.operationFeed.stop() },
computed: { available() { return selectableSegments(this.inventory) }, canAnalyze() { return this.selected.length >= 1 && this.selected.length <= 5 && !this.busy && !this.requesting && this.operation?.state !== 'running' } },
methods: {
toggle(name) { this.selected = this.selected.includes(name) ? this.selected.filter((item) => item !== name) :
this.selected.length < 5 ? [...this.selected, name] : this.selected },
analyze() { if (this.canAnalyze && this.selected.every((name) => this.available.some((item) => item.name === name))) this.operationFeed.startAnalysis(this.selected) },
resultLabel(status) { return ({ measured: 'Measured', insufficient_samples: 'Insufficient samples', missing_car_params: 'Missing vehicle data',
unsupported_car: 'Unsupported vehicle', unsupported_controller: 'Unsupported controller' })[status] || 'Unavailable' },
metric(value) { return finite(value) ? `${value.toFixed(2)} m/s²` : 'Unavailable' },
exclusions(items) { return items.map(([reason, count]) => `${reason.replaceAll('_', ' ')}: ${count}`).join(' · ') || 'None recorded' },
},
template: `<div class="gx-view gx-flm">
<h2>Offline Tracking</h2><p class="gx-note">Local recording diagnostics for Ioniq 6 steering torque tracking. Reports do not qualify a vehicle or recommend a tune.</p>
<p v-if="mode !== 'local'" class="gx-card gx-message">Offline analysis requires authenticated local Galaxy access.</p>
<template v-else>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="inventoryStatus === 'loading'" @click="inventoryFeed.load()">Refresh local recordings</button>
<p v-if="inventoryStatus === 'loading'" role="status">Reading local recordings…</p><p v-if="inventoryStatus === 'unavailable'" role="alert">{{ inventoryError }}</p>
<p v-if="inventory?.scanIncomplete" role="status">This recording scan was incomplete. More local segments may exist.</p>
<section class="gx-card gx-flm__panel"><h3>Choose Full Logs</h3><p class="gx-note">Select 1–5 closed local segments. Quick logs alone cannot provide this report.</p>
<div class="gx-flm__actions" style="position:sticky;top:0;z-index:1;background:var(--gx-surface, #181526);padding:0.75rem 0;display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
<button type="button" class="gx-btn" :disabled="!canAnalyze" @click="analyze">Analyze selected</button><span>{{ selected.length }} of 5 selected</span>
</div>
<p v-if="inventoryStatus === 'ready' && !available.length">No closed full logs found in this scan.</p>
<div v-for="segment in available" :key="segment.name" class="gx-flm__choice"><label><input type="checkbox" :checked="selected.includes(segment.name)"
:disabled="!selected.includes(segment.name) && selected.length >= 5 || operation?.state === 'running'" @change="toggle(segment.name)">
<span>Route {{ segment.routeId }} · Segment {{ segment.number }}</span></label></div>
</section>
<section class="gx-card gx-flm__panel"><h3>Analysis</h3>
<p v-if="operation?.state === 'running'" role="status">Analyzing {{ operation.processed }} of {{ operation.selected }} selected segments…</p>
<p v-else-if="operation?.state === 'completed'" role="status">Analysis completed.</p>
<p v-else-if="operation?.state === 'canceled'" role="status">Analysis canceled.</p>
<p v-else-if="operation?.state === 'failed'" role="alert">Analysis failed.</p>
<p v-else-if="operation?.state === 'unavailable'" role="status">Analysis owner unavailable.</p>
<p v-else role="status">No analysis running.</p>
<p v-if="operationError" role="alert">{{ operationError }}</p>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy || requesting" @click="operationFeed.refresh()">Refresh status</button>
<button v-if="operation?.state === 'running'" type="button" class="gx-btn gx-btn--tonal" :disabled="busy || requesting" @click="operationFeed.cancelAnalysis()">Cancel analysis</button>
</section>
<template v-if="report"><p class="gx-note">Offline diagnostic only · {{ report.segments.length }} {{ report.segments.length === 1 ? 'segment' : 'segments' }} · No tune recommendation</p>
<section v-for="entry in report.segments" :key="entry.source.segmentName" class="gx-card gx-flm__panel gx-plots__panel">
<h3>{{ entry.source.segmentName }}</h3><p class="gx-note">{{ resultLabel(entry.analysis.status) }} · Full log SHA {{ entry.source.sha256.slice(0,12) }}…</p>
<p>Eligible samples: {{ entry.analysis.eligible_samples }} · Mean absolute error: {{ metric(entry.analysis.mean_abs_error) }} · RMSE: {{ metric(entry.analysis.root_mean_square_error) }}</p>
<p class="gx-note">Excluded observations: {{ exclusions(entry.analysis.exclusions) }}</p>
<template v-if="entry.analysis.series.length"><p class="gx-note">Lateral acceleration · m/s² · gaps mark excluded or interrupted data</p>
<p class="gx-flm__legend"><span class="gx-flm__legend-desired">Desired</span><span class="gx-flm__legend-actual">Actual</span></p>
<flm-chart :series="entry.analysis.series" /></template>
<p v-else class="gx-note">No eligible tracking series for this segment.</p>
</section>
</template>
</template>
</div>`,
}
@@ -0,0 +1,146 @@
let nextId = 0
// Keep the native select as the value/event adapter; presentation belongs to Galaxy.
export const GalaxySelect = {
name: "GalaxySelect",
inheritAttrs: false,
props: { value: { default: undefined }, modelValue: { default: undefined }, disabled: Boolean },
emits: ["change", "update:modelValue"],
data() { return { uid: `gx-select-${++nextId}`, items: [], selected: "", label: "", open: false, name: "Choose an option", typeahead: "", typedAt: 0 } },
computed: {
current() { return this.modelValue !== undefined ? this.modelValue : this.value },
buttonId() { return this.$attrs.id || this.uid },
nativeAttrs() {
const attrs = { ...this.$attrs }
for (const key of ["class", "style", "id", "aria-label", "aria-labelledby", "aria-describedby"]) delete attrs[key]
return attrs
},
},
methods: {
sync() {
const native = this.$refs.native
if (!native) return
if (this.current !== undefined) native.value = String(this.current ?? "")
const items = [...native.options].map((option, index) => ({ index, value: option.value, label: option.label, description: option.dataset?.description || "", disabled: option.disabled || (option.parentElement?.tagName === "OPTGROUP" && option.parentElement.disabled), group: option.parentElement?.tagName === "OPTGROUP" ? option.parentElement.label : "" }))
if (JSON.stringify(items) !== JSON.stringify(this.items)) this.items = items
this.selected = native.value
this.label = native.selectedOptions[0]?.dataset?.collapsedLabel || native.selectedOptions[0]?.label || "Choose an option"
const button = this.$refs.button
const labelled = this.$attrs["aria-labelledby"]?.split(/\s+/).map(id => document.getElementById(id)?.textContent || "").join(" ")
const labels = [...new Set([...(button?.labels || []), button?.closest("label")].filter(Boolean))].map(label => {
const copy = label.cloneNode(true)
copy.querySelectorAll(".gx-select, select, button").forEach(node => node.remove())
return copy.textContent.trim()
}).filter(Boolean).join(" ")
this.name = this.$attrs["aria-label"] || labelled || labels || button?.closest(".gx-row")?.querySelector(".gx-row__label")?.textContent || this.$attrs.title || "Choose an option"
if (this.disabled && this.open) this.close()
},
async show(event) {
if (this.disabled || this.open) return
this.sync()
this.open = true
await this.$nextTick()
const menu = this.$refs.menu
try {
menu.showModal()
} catch (error) {
this.open = false
return
}
this.position()
const options = [...menu.querySelectorAll('[role="option"]:not(:disabled)')]
const selected = options.find(option => option.dataset.value === this.selected)
const first = event?.key === "End" ? options.at(-1) : options[0]
;(selected || first)?.focus()
},
close() {
if (!this.open) return
this.open = false
this.$refs.menu?.close()
if (this.$refs.button?.isConnected) this.$refs.button.focus({ preventScroll: true })
},
position() {
if (!this.open) return
const rect = this.$refs.button.getBoundingClientRect()
const viewport = window.visualViewport
const width = viewport?.width || window.innerWidth
const height = viewport?.height || window.innerHeight
const menu = this.$refs.menu
let zoom = 1
for (let node = menu; node; node = node.parentElement) zoom *= Number.parseFloat(getComputedStyle(node).zoom) || 1
const menuWidth = Math.min(Math.max(rect.width, 240), width - 24)
menu.style.width = `${menuWidth / zoom}px`
menu.style.maxHeight = `${(height - 24) / zoom}px`
const menuHeight = Math.min(menu.scrollHeight * zoom + 2, height - 24)
const below = height - rect.bottom - 12
const top = below >= Math.min(menuHeight, 220) ? Math.min(rect.bottom + 6, height - menuHeight - 12) : Math.max(12, rect.top - menuHeight - 6)
menu.style.left = `${Math.max(12, Math.min(rect.left, width - menuWidth - 12)) / zoom}px`
menu.style.top = `${Math.max(12, top) / zoom}px`
},
select(item) {
if (this.disabled) return this.close()
const native = this.$refs.native
const option = native.options[item.index]
if (!option || option.value !== item.value || option.disabled || option.parentElement?.disabled) return this.close()
this.close()
if (native.value === option.value) return
native.value = option.value
native.dispatchEvent(new Event("change", { bubbles: true }))
this.sync()
},
change(event) {
this.$emit("update:modelValue", event.target.value)
this.$emit("change", event)
},
buttonKey(event) {
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) { event.preventDefault(); this.show(event) }
},
menuKey(event) {
if (event.key === "Tab") { event.preventDefault(); this.close(); return }
const options = [...this.$refs.menu.querySelectorAll('[role="option"]:not(:disabled)')]
const index = options.indexOf(document.activeElement)
let target
if (event.key === "ArrowDown") target = options[(index + 1) % options.length]
if (event.key === "ArrowUp") target = options[(index - 1 + options.length) % options.length]
if (event.key === "Home") target = options[0]
if (event.key === "End") target = options.at(-1)
if (event.key.length === 1 && event.key !== " " && !event.ctrlKey && !event.metaKey && !event.altKey) {
const now = performance.now()
this.typeahead = (now - this.typedAt < 700 ? this.typeahead : "") + event.key.toLocaleLowerCase()
this.typedAt = now
target = options.find(option => option.textContent.trim().toLocaleLowerCase().startsWith(this.typeahead))
}
if (target) { event.preventDefault(); target.focus() }
},
},
mounted() {
this.sync()
window.addEventListener("resize", this.position)
window.visualViewport?.addEventListener("resize", this.position)
},
updated() { this.sync() },
beforeUnmount() {
this.$refs.menu?.close()
window.removeEventListener("resize", this.position)
window.visualViewport?.removeEventListener("resize", this.position)
},
template: `
<span class="gx-select" :class="$attrs.class" :style="$attrs.style">
<select ref="native" v-bind="nativeAttrs" hidden tabindex="-1" aria-hidden="true" :disabled="disabled" @change="change"><slot /></select>
<button ref="button" :id="buttonId" class="gx-select__button" type="button" role="combobox" aria-haspopup="listbox" :aria-expanded="open" :aria-controls="uid + '-list'"
:aria-label="name" :aria-describedby="$attrs['aria-describedby']" :disabled="disabled" @click="show" @keydown="buttonKey">
<span>{{ label }}</span><i class="bi bi-chevron-down" aria-hidden="true"></i>
</button>
<dialog ref="menu" class="gx-select-menu" @cancel.prevent="close" @click.self="close" @keydown="menuKey">
<div :id="uid + '-list'" role="listbox" :aria-label="name">
<template v-for="(item, index) in items" :key="item.index">
<div v-if="item.group && item.group !== items[index - 1]?.group" class="gx-select-menu__group">{{ item.group }}</div>
<button type="button" role="option" :data-value="item.value" :aria-selected="item.value === selected" :disabled="item.disabled" @click="select(item)">
<span style="min-width:0; overflow-wrap:anywhere;"><span>{{ item.label }}</span><small v-if="item.description" class="gx-note" style="display:block; margin-top:4px; white-space:normal; line-height:1.4;">{{ item.description }}</small></span><i v-if="item.value === selected" class="bi bi-check-lg" aria-hidden="true"></i>
</button>
</template>
</div>
</dialog>
</span>
`,
}
@@ -0,0 +1,184 @@
import { GalaxySelect } from "./galaxy-select.js"
export const FINE_SCRUB_HOLD_MS = 300
export const FINE_SCRUB_FACTOR = 5
const FINE_SCRUB_JITTER_PX = 4
export function numericBounds(row) {
const min = Number(row.minimum), max = Number(row.maximum), step = Number(row.step)
return Number.isFinite(min) && Number.isFinite(max) && Number.isFinite(step) && max > min && step > 0
? { min, max, step } : null
}
export function snapNumeric(value, bounds) {
if (!bounds || value === "" || value === null || value === undefined || !Number.isFinite(Number(value))) return null
const { min, max, step } = bounds
let lastStep = Math.floor((max - min) / step + 1e-9)
if (Number((min + lastStep * step).toFixed(8)) > max) lastStep--
const index = Math.max(0, Math.min(lastStep, Math.round((Number(value) - min) / step)))
return Number((min + index * step).toFixed(8))
}
export function settingControl(row) {
if (row.page) return "group"
if (row.confirm || row.repairValue) return "action"
if (row.choices?.length === 2 && row.choices.includes("Off") && row.choices.includes("On") && row.choices.includes(row.value)) return "switch"
if (numericBounds(row) && ((Number.isFinite(Number(row.value)) && row.value !== "") ||
(row.value === "Auto" && row.choices?.includes("Auto")))) return "slider"
if (row.choices?.length && row.choices.includes(row.value)) return "select"
return "readout"
}
export const GalaxySettingRow = {
name: "GalaxySettingRow",
components: { GalaxySelect },
props: {
row: { type: Object, required: true },
index: { type: Number, required: true },
disabled: { type: Boolean, default: false },
saveValue: { type: Function, required: true },
},
emits: ["open", "review"],
data() { return { preview: undefined, interacting: false, fineScrub: null, isFineScrubbing: false, updating: false } },
computed: {
control() { return settingControl(this.row) },
bounds() {
const bounds = numericBounds(this.row)
if (!bounds || !this.row.choices?.includes("Auto")) return bounds
const numericMax = snapNumeric(bounds.max, bounds)
return { ...bounds, numericMax, max: Number((numericMax + bounds.step).toFixed(8)), auto: true }
},
locked() { return this.disabled || this.updating || !this.row.available || (!this.row.action && !this.row.page) },
currentValue() { return this.preview !== undefined ? this.preview : this.row.value },
sliderValue() { return this.currentValue === "Auto" && this.bounds?.auto ? this.bounds.max : this.currentValue },
displayValue() {
return this.currentValue === "Auto" || (this.bounds?.auto && Number(this.currentValue) === this.bounds.max)
? "Auto" : `${this.currentValue}${this.row.unit ? " " + this.row.unit : ""}`
},
},
methods: {
async commit(value) {
if (this.locked || String(value) === String(this.row.value)) { this.preview = undefined; return }
this.preview = value
this.updating = true
try { await this.saveValue(this.index, value) }
finally { this.preview = undefined; this.updating = false }
},
onSwitch(event) {
const next = event.target.checked ? "On" : "Off"
event.target.checked = this.currentValue === "On"
return this.commit(next)
},
onSelect(event) { return this.commit(event.target.value) },
beginInteract() { if (!this.locked) this.interacting = true },
flushSlider(raw) {
const numeric = snapNumeric(raw, this.bounds)
const next = numeric !== null && this.bounds?.auto && numeric === this.bounds.max ? "Auto" : numeric
this.preview = undefined
if (next !== null && String(next) !== String(this.row.value)) return this.commit(next)
},
clearHoldTimer() {
if (this._holdTimer !== undefined) clearTimeout(this._holdTimer)
this._holdTimer = undefined
},
startHoldTimer() {
this.clearHoldTimer()
if (!this.fineScrub || this.fineScrub.active) return
this._holdTimer = setTimeout(() => this.activateFineScrub(), FINE_SCRUB_HOLD_MS)
},
activateFineScrub() {
if (!this.fineScrub || this.fineScrub.active || this.locked) return
this.fineScrub.active = true
this.fineScrub.baseValue = snapNumeric(this.sliderValue, this.bounds) ?? this.bounds.min
this.fineScrub.baseX = this.fineScrub.lastX
this.isFineScrubbing = true
try { globalThis.navigator?.vibrate?.(15) } catch {}
},
onSliderInput(event) {
if (this.locked || this.fineScrub?.active) { event.target.value = this.sliderValue; return }
this.beginInteract()
this.preview = Number(event.target.value)
this.startHoldTimer()
},
onSliderCommit(event) {
if (this.fineScrub) return
this.interacting = false
return this.flushSlider(event.target.value)
},
onSliderBlur(event) { if (this.interacting && !this.fineScrub) return this.onSliderCommit(event) },
onSliderPointerDown(event) {
if (this.locked) return
this.beginInteract()
try { event.target.setPointerCapture?.(event.pointerId) } catch {}
const rect = event.target.getBoundingClientRect()
this.fineScrub = { active: false, baseValue: snapNumeric(this.sliderValue, this.bounds),
baseX: event.clientX, lastX: event.clientX, track: rect.width || 200, pointerId: event.pointerId }
this.startHoldTimer()
},
onSliderPointerMove(event) {
const scrub = this.fineScrub
if (!scrub || event.pointerId !== scrub.pointerId || this.locked) return
if (!scrub.active) {
if (Math.abs(event.clientX - scrub.lastX) > FINE_SCRUB_JITTER_PX) {
scrub.lastX = event.clientX
this.startHoldTimer()
}
return
}
event.preventDefault()
const raw = scrub.baseValue + (event.clientX - scrub.baseX) * (this.bounds.max - this.bounds.min) / scrub.track / FINE_SCRUB_FACTOR
const next = snapNumeric(raw, this.bounds)
if (next === null) return
this.preview = next
if (this.$refs.slider) this.$refs.slider.value = next
},
releasePointer(event) {
const pointerId = this.fineScrub?.pointerId
this.clearHoldTimer()
this.fineScrub = null
this.isFineScrubbing = this.interacting = false
try { (event?.target || this.$refs.slider)?.releasePointerCapture?.(pointerId) } catch {}
},
onSliderPointerEnd(event) {
if (!this.fineScrub || event.pointerId !== this.fineScrub.pointerId) return
const value = this.sliderValue
this.releasePointer(event)
return this.flushSlider(value)
},
onSliderCancel(event) {
if (!this.fineScrub || event.pointerId !== this.fineScrub.pointerId) return
this.releasePointer(event)
this.preview = undefined
if (this.$refs.slider) this.$refs.slider.value = this.sliderValue
},
},
beforeUnmount() { this.clearHoldTimer() },
template: `
<div class="gx-row" :class="{ disabled: locked, 'gx-row--stack': control === 'slider' || control === 'select' }">
<div class="gx-row__info">
<span class="gx-row__label">{{ row.label }}</span>
<span v-if="row.reason" class="gx-row__desc">{{ row.reason.replace('https://firestar.link/discord', '') }}<a v-if="row.reason.includes('https://firestar.link/discord')" href="https://firestar.link/discord" target="_blank" rel="noopener">StarPilot Discord</a></span>
<span v-if="control === 'group' || control === 'action'" class="gx-row__desc">{{ row.value }}</span>
</div>
<label v-if="control === 'switch'" class="gx-switch">
<input type="checkbox" role="switch" :aria-label="row.label" :checked="currentValue === 'On'" :disabled="locked" @change="onSwitch">
<span class="gx-switch__track"></span><span class="gx-switch__thumb"></span>
</label>
<div v-else-if="control === 'slider'" class="gx-slider-row" :class="{ 'is-fine-scrubbing': isFineScrubbing }">
<div class="gx-slider-header"><span class="gx-row__value">{{ displayValue }}</span>
<span v-if="interacting" class="gx-slider-hint">{{ isFineScrubbing ? 'Fine scrubbing' : 'Hold to fine scrub' }}</span></div>
<input ref="slider" type="range" class="gx-slider" :aria-label="row.label" :aria-valuetext="displayValue"
:min="bounds.min" :max="bounds.max" :step="bounds.step" :value="sliderValue" :disabled="locked"
@input="onSliderInput" @change="onSliderCommit" @blur="onSliderBlur"
@pointerdown="onSliderPointerDown" @pointermove="onSliderPointerMove" @pointerup="onSliderPointerEnd"
@pointercancel="onSliderCancel" @lostpointercapture="onSliderCancel" @keydown="beginInteract">
<div class="gx-slider-meta"><span>{{ bounds.min }} to {{ bounds.numericMax ?? bounds.max }} {{ row.unit }}<template v-if="bounds.auto"> · Auto at right</template></span><span>Step: {{ bounds.step }} {{ row.unit }}</span></div>
</div>
<GalaxySelect v-else-if="control === 'select'" class="gx-field" :aria-label="row.label" :value="currentValue" :disabled="locked" @change="onSelect">
<option v-for="choice in row.choices" :key="choice" :value="choice">{{ choice }}</option>
</GalaxySelect>
<button v-else-if="control === 'group'" type="button" class="gx-btn gx-btn--tonal" :disabled="locked" @click="$emit('open', row.page)">Manage</button>
<button v-else-if="control === 'action' && row.action" type="button" class="gx-btn gx-btn--tonal" :disabled="locked" @click="$emit('review', index, row.confirm ? 0 : 1)">{{ row.repairValue === 'Reset' || /reset|default/i.test(row.label) ? 'Reset to Default' : row.repairValue ? 'Set ' + row.repairValue : row.label }}</button>
<span v-else class="gx-row__value gx-row__readout">{{ displayValue }}</span>
</div>`,
}
@@ -0,0 +1,81 @@
import { LocalAccess } from "./local-access.js"
export const GalaxyPage = {
name: "GalaxyPage",
components: { LocalAccess },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ loading: true, paired: false, url: "", tunnelClientAvailable: false, legacyPassword: false,
legacyPairingAvailable: false, password: "", busy: false, error: "" }),
mounted() { this.load() },
methods: {
async request(path, body) {
const response = await fetch(path, body === undefined ? { cache: "no-store" } : {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
})
if (response.status === 401) this.unauthorized()
const data = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(data.error || "Galaxy request failed")
return data
},
async load() {
if (this.mode !== "local") { this.loading = false; return }
try {
const data = await this.request("./api/galaxy/status")
this.paired = data.paired
this.url = data.url
this.tunnelClientAvailable = data.tunnelClientAvailable === true
this.legacyPassword = data.legacyPassword === true
this.legacyPairingAvailable = data.legacyPairingAvailable === true
} catch (error) { this.error = error.message }
finally { this.loading = false }
},
async pair() {
this.busy = true
this.error = ""
try {
const data = await this.request("./api/galaxy/pair", { password: this.password })
this.paired = data.paired
this.url = data.url
this.password = ""
} catch (error) { this.error = error.message }
finally { this.busy = false }
},
async unpair() {
if (!window.confirm("Unpair from Galaxy? Remote access will stop until you pair again.")) return
this.busy = true
this.error = ""
try {
await this.request("./api/galaxy/unpair", {})
this.paired = false
this.url = ""
} catch (error) { this.error = error.message }
finally { this.busy = false }
},
},
template: `
<div><h2 style="margin-top:0">Galaxy Tunnel</h2><p>Pair this comma for remote Galaxy access. Installing the Galaxy app is a separate option in the app menu.</p>
<section v-if="mode !== 'local'" class="gx-card gx-message">Pairing is available on your comma.</section>
<section v-else-if="loading" class="gx-card gx-message">Checking pairing status…</section>
<section v-else class="gx-card" style="padding:var(--sp-4)">
<template v-if="paired">
<span class="gx-chip"><i class="bi bi-check-circle-fill"></i> Paired</span>
<p>Pairing saved. Scan this code or open the link when the Galaxy tunnel is connected.</p>
<p v-if="!tunnelClientAvailable" class="gx-note gx-note--danger">The remote tunnel client is not installed on this device yet. The link will work after Galaxy's tunnel client is available.</p>
<img src="./api/galaxy/qr.svg" alt="QR code for your Galaxy link" style="display:block;width:min(250px,100%);background:white;padding:8px;border-radius:12px" />
<p><a :href="url" target="_blank" rel="noopener" style="word-break:break-all">{{ url }}</a></p>
<button type="button" class="gx-btn gx-btn--danger" :disabled="busy" @click="unpair">{{ busy ? 'Unpairing…' : 'Unpair' }}</button>
</template>
<template v-else>
<span class="gx-chip gx-chip--lock">Not Paired</span>
<p v-if="legacyPassword">Enter your existing Galaxy password to keep its saved link and QR code.</p>
<p v-else-if="legacyPairingAvailable">An earlier Galaxy pairing is available. Enter its password to keep the saved link, or choose a new password (at least 8 characters) for a new link.</p>
<p v-else>First choose a password, then pair your comma. Open Galaxy remotely using the link and QR code when the tunnel is connected.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap"><input class="gx-field" style="flex:1;min-width:200px" type="password" v-model="password" :minlength="legacyPassword || legacyPairingAvailable ? 6 : 8" maxlength="255" :autocomplete="legacyPassword ? 'current-password' : 'new-password'" :placeholder="legacyPassword ? 'Existing Galaxy password' : legacyPairingAvailable ? 'Existing or new Galaxy password' : 'New password (at least 8 characters)'" @keydown.enter="pair" />
<button type="button" class="gx-btn" :disabled="busy || password.trim().length < (legacyPassword || legacyPairingAvailable ? 6 : 8)" @click="pair">{{ busy ? 'Pairing…' : 'Set password & pair' }}</button></div>
</template>
<p v-if="error" class="gx-note gx-note--danger" role="alert">{{ error }}</p>
</section>
<LocalAccess :mode="mode" :on-unauthorized="unauthorized" />
</div>
`,
}
+270
View File
@@ -0,0 +1,270 @@
import { LocalAccess } from "./local-access.js"
import { MonitorFeed } from "./monitor-feed.js"
import { SoftwareStatusFeed } from "./software-status.js"
import { ModelStatusFeed } from "./models.js"
import { DriveStatsFeed } from "./drive-stats.js"
// The six lightweight motifs follow the original Galaxy Personal Records row.
const RECORD_ART = {
longestDrive: ["M4 12h16", "m14 6 6 6-6 6"],
mostEngagedDay: ["M20 11a8 8 0 1 1-5-7", "m8 11 4 4 8-9"],
bestWeek: ["M4 4v16h16", "m7 15 5-5 4 2 5-7", "M17 5h4v4"],
highestStreak: ["m13 2-9 12h7l-1 8 10-13h-7z"],
longestUndistractedDrive: ["m12 3 8 3v6c0 5-8 9-8 9s-8-4-8-9V6z", "m8 12 3 3 5-6"],
cleanDriveStreak: ["m12 2 2.5 7.5L22 12l-7.5 2.5L12 22l-2.5-7.5L2 12l7.5-2.5z", "M20 2v4M18 4h4"],
}
export const recordArt = (id) => RECORD_ART[id] || ["m12 3 3 6 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1z"]
const shown = (value) => typeof value === "string" && value.trim() ? value : "Unavailable"
const measure = (value, unit, digits = 0) =>
typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(digits)}${unit}` : "Unavailable"
const counted = (value, noun) => `${value} ${noun}${value === 1 ? "" : "s"}`
const distance = (meters, isMetric) => measure(meters === null ? null : meters / (isMetric ? 1000 : 1609.344),
isMetric ? " km" : " mi", 1)
const hours = (seconds) => measure(seconds === null ? null : seconds / 3600, " h", 1)
const percent = (value) => measure(value, "%")
const duration = (seconds) => {
if (seconds === null) return "Unavailable"
const minutes = Math.round(seconds / 60)
return minutes >= 60 ? `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m` : `${minutes}m`
}
const driveDate = (start, end = null) => {
if (start === null) return "Date unavailable"
const date = new Date(start * 1000)
if (!Number.isFinite(date.getTime())) return "Date unavailable"
const calendar = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric" })
const clock = (value) => new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(new Date(value * 1000))
const day = calendar.format(date)
const endDay = end !== null ? calendar.format(new Date(end * 1000)) : null
return `${day} · ${clock(start)}${end !== null ? `–${endDay === day ? "" : endDay + " · "}${clock(end)}` : ""}`
}
const dayLabel = (date) => new Intl.DateTimeFormat(undefined, { timeZone: "UTC", weekday: "short" }).format(new Date(`${date}T00:00:00Z`))
const recordDisplay = (record, isMetric) => record.value === null ? "Unavailable" :
record.unit === "m" ? distance(record.value, isMetric) : record.unit === "s" ? duration(record.value) :
record.unit === "days" || record.unit === "drives" ? counted(record.value, record.unit.slice(0, -1)) :
`${record.value}${record.unit === "%" ? "%" : record.unit ? ` ${record.unit}` : ""}`
export function driveSummary(feed) {
const data = feed.status === "ready" ? feed.data : null
if (!data) return null
const isMetric = data.isMetric
const last = data.lastDrive
const maximum = Math.max(1, ...data.week.days.map((day) => day.distanceMeters ?? 0))
const models = data.models.slice(0, 3)
const modelTotal = models.reduce((sum, model) => sum + (model.distanceMeters ?? 0), 0)
const colors = ["#5ec8c8", "#8b6cc5", "#d4a060"]
let offset = 0
const stops = models.map((model, index) => {
const next = offset + (modelTotal > 0 ? (model.distanceMeters ?? 0) / modelTotal * 100 : 0)
const stop = `${colors[index]} ${offset}% ${next}%`
offset = next
return stop
})
return {
analysis: data.analysis,
last: last && { routeId: last.routeId, date: driveDate(last.startTime, last.endTime), complete: last.complete,
distance: distance(last.distanceMeters, isMetric), duration: duration(last.durationSeconds), engaged: percent(last.engagedPercent),
model: shown(last.model), distracted: last.distractedMoments === null ? "Unavailable" : String(last.distractedMoments),
unresponsive: last.unresponsiveMoments === null ? "Unavailable" : String(last.unresponsiveMoments) },
totals: { distance: distance(data.totals.distanceMeters, isMetric), duration: hours(data.totals.durationSeconds),
engaged: duration(data.totals.engagedSeconds), drives: data.totals.drives },
week: { timezone: data.week.timezone || "UTC", distance: distance(data.week.distanceMeters, isMetric), duration: hours(data.week.durationSeconds),
engaged: percent(data.week.engagedPercent), progress: data.week.engagedPercent ?? 0, drives: data.week.drives,
days: data.week.days.map((day) => ({ date: day.date, label: dayLabel(day.date),
distance: distance(day.distanceMeters, isMetric), height: day.distanceMeters === null ? 0 : day.distanceMeters / maximum * 100 })) },
recent: data.recentDrives.slice(0, 5).map((drive) => ({ routeId: drive.routeId, date: driveDate(drive.startTime, drive.endTime),
complete: drive.complete, ignored: drive.ignored, distance: distance(drive.distanceMeters, isMetric),
duration: duration(drive.durationSeconds), engaged: percent(drive.engagedPercent), model: shown(drive.model) })),
records: data.records.map((record) => ({ id: record.id, label: record.label, display: recordDisplay(record, isMetric) })),
models: models.map((model, index) => ({ name: model.name, distance: distance(model.distanceMeters, isMetric),
drives: model.drives, color: colors[index] })),
modelStyle: modelTotal > 0 ? `background:conic-gradient(${stops.join(",")})` : "",
}
}
// Each card retains its own source status. Missing/expired data is never a zero or a healthy state.
export function homeSummary(monitor, model, software) {
const live = monitor.status === "current" ? monitor.snapshot : null
const loaded = model.status === "ready" ? model.data : null
const installed = software.status === "ready" ? software.data : null
return {
system: {
available: !!live,
cpu: measure(live?.cpuPercent, "%"),
memory: measure(live?.memory?.percent, "%"),
storage: live?.storage?.usedGiB != null && live?.storage?.totalGiB != null
? `${measure(live.storage.usedGiB, " GiB", 1)} / ${measure(live.storage.totalGiB, " GiB", 1)}` : "Unavailable",
observed: live ? new Date(live.sampledAt * 1000).toLocaleString() : "Unavailable",
},
model: {
available: !!loaded,
health: loaded ? shown(loaded.health).replaceAll("-", " ") : "Unavailable",
variant: loaded?.loadedId ? loaded.variant === "chestnut" ? "Chestnut big" : "Small" : "Identity unavailable",
fallback: loaded?.fallbackReason === "chestnut-run-stalled" ? "Chestnut output stopped; Small restarted for this drive" :
loaded?.fallbackReason === "chestnut-load-failed" ? "Small model after Chestnut load failure" : null,
},
software: {
available: !!installed,
version: shown(installed?.installed?.displayVersion ?? installed?.installed?.version),
branch: shown(installed?.installed?.branch),
update: installed?.updater?.finalizedUpdateReady === true ? "Finalized update ready" :
installed?.updater?.finalizedUpdateReady === false ? "None reported" : "Unavailable",
},
}
}
export const Home = {
name: "Home",
components: { LocalAccess },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
go: { type: Function, required: true } },
data: () => ({
monitor: { snapshot: null, status: "idle", error: "" },
model: { data: null, status: "idle", error: "" },
software: { data: null, status: "idle", error: "" },
drives: { data: null, status: "idle", error: "", busy: false },
}),
computed: {
summary() { return homeSummary(this.monitor, this.model, this.software) },
driving() { return driveSummary(this.drives) },
},
created() {
const revoked = () => { this.stopFeeds(); this.unauthorized() }
this.monitorFeed = new MonitorFeed({ mode: this.mode, publish: (update) => Object.assign(this.monitor, update), unauthorized: revoked })
this.modelFeed = new ModelStatusFeed({ publish: (update) => Object.assign(this.model, update), unauthorized: revoked })
this.softwareFeed = new SoftwareStatusFeed({ publish: (update) => Object.assign(this.software, update), unauthorized: revoked })
this.driveFeed = new DriveStatsFeed({ publish: (update) => Object.assign(this.drives, update), unauthorized: revoked })
},
mounted() {
this.visibility = () => { if (document.hidden) this.stopFeeds(); else this.startFeeds() }
document.addEventListener("visibilitychange", this.visibility)
if (!document.hidden) this.startFeeds()
},
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.stopFeeds() },
methods: {
recordArt,
counted,
startFeeds() {
if (this.mode !== "local") return
this.monitorFeed.start()
this.modelFeed.start()
this.softwareFeed.start()
this.driveFeed.start()
},
stopFeeds() {
this.monitorFeed.stop()
this.modelFeed.stop()
this.softwareFeed.stop()
this.driveFeed.stop()
this.monitor.snapshot = null
this.monitor.status = "idle"
},
refresh() {
if (this.mode !== "local" || document.hidden) return
this.monitorFeed.refresh()
this.modelFeed.load()
this.softwareFeed.load()
this.driveFeed.load()
},
ignoreDrive(drive) {
if (this.mode !== "local" || document.hidden || this.drives.busy) return
return this.driveFeed.ignore(drive.routeId, !drive.ignored)
},
},
template: `
<div class="gx-view gx-home">
<div class="gx-home__hero">
<div><h1>Dashboard</h1><p class="gx-note">Your saved drives and current device</p></div>
<button v-if="mode === 'local'" type="button" class="gx-btn gx-btn--tonal" @click="refresh"><i class="bi bi-arrow-clockwise"></i> Refresh</button>
</div>
<p v-if="mode !== 'local'" class="gx-card gx-message">Dashboard information is available on your device.</p>
<template v-else>
<section class="gx-card gx-home__last">
<div class="gx-home__kicker"><span></span> Last drive</div>
<p v-if="drives.status !== 'ready'" role="status">{{ drives.status === 'loading' ? 'Loading your drives…' : drives.error || 'Drive history is unavailable.' }}</p>
<p v-else-if="!driving.last">No saved drives yet.</p>
<template v-else>
<h2>{{ driving.last.date }}</h2>
<p v-if="!driving.last.complete" class="gx-home__subtle">This drive is still being analyzed.</p>
<div class="gx-home__four"><div><strong>{{ driving.last.distance }}</strong><span>distance</span></div>
<div><strong>{{ driving.last.duration }}</strong><span>duration</span></div>
<div><strong>{{ driving.last.engaged }}</strong><span>engaged</span></div>
<div><strong>{{ driving.last.model }}</strong><span>recorded model</span></div></div>
<div class="gx-home__attention"><span><i class="bi bi-eye"></i> {{ driving.last.distracted }} distracted {{ driving.last.distracted === "1" ? "moment" : "moments" }}</span>
<span><i class="bi bi-exclamation-triangle"></i> {{ driving.last.unresponsive }} unresponsive {{ driving.last.unresponsive === "1" ? "moment" : "moments" }}</span></div>
</template>
</section>
<template v-if="driving">
<p v-if="driving.analysis.pending" class="gx-home__analysis" role="status">
<i class="bi bi-hourglass-split"></i> {{ driving.analysis.running ? 'Updating drive history' : driving.analysis.state === 'waitingParked' ? 'Drive history will update when parked' : 'Drive history queued' }}{{ driving.analysis.pending ? ' · ' + driving.analysis.pending + ' remaining' : '' }}
</p>
<div class="gx-home__section-title"><span></span>Your driving</div>
<div class="gx-home__totals">
<section class="gx-card"><h2>Saved History</h2><strong>{{ driving.totals.distance }}</strong><span>{{ counted(driving.totals.drives, "drive") }}</span></section>
<section class="gx-card"><h2>Time on the Road</h2><strong>{{ driving.totals.duration }}</strong><span>{{ driving.totals.engaged }} engaged</span></section>
<section class="gx-card"><h2>This Week</h2><strong>{{ driving.week.distance }}</strong><span>{{ counted(driving.week.drives, "drive") }} · {{ driving.week.duration }}</span></section>
</div>
<div class="gx-home__pair">
<section class="gx-card gx-home__week"><h2>This Week</h2>
<div class="gx-home__week-top"><div class="gx-home__donut" :style="{ '--gx-home-progress': driving.week.progress + '%' }">
<strong>{{ driving.week.engaged }}</strong><span>engaged</span></div>
<div><strong>{{ driving.week.distance }}</strong><span>across {{ counted(driving.week.drives, "drive") }}</span></div></div>
<h3>Distance per day · {{ driving.week.timezone }}</h3>
<div class="gx-home__bars"><div v-for="day in driving.week.days" :key="day.date" :aria-label="day.date + ': ' + day.distance" :title="day.date + ': ' + day.distance">
<div class="gx-home__bar-track"><span :style="{ height: day.height + '%' }"></span></div><small>{{ day.label }}</small><small>{{ day.distance }}</small></div></div>
</section>
<section class="gx-card gx-home__records"><h2>Personal Records</h2>
<p v-if="!driving.records.length" class="gx-home__subtle">Records will appear as drives are saved.</p>
<div v-for="record in driving.records" :key="record.id" class="gx-home__record"><span class="gx-home__record-art" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path v-for="(path, index) in recordArt(record.id)" :key="index" :d="path" /></svg></span><span class="gx-home__record-label">{{ record.label }}</span><strong>{{ record.display }}</strong></div>
</section>
</div>
<section class="gx-card gx-home__recent"><h2>Recent Drives</h2>
<p v-if="!driving.recent.length" class="gx-home__subtle">No saved drives yet.</p>
<div v-for="drive in driving.recent" :key="drive.routeId" class="gx-home__drive" :class="{ 'is-ignored': drive.ignored }">
<div><strong>{{ drive.date }}</strong><small>{{ drive.model }}</small></div>
<div><strong>{{ drive.complete ? drive.distance : 'Analyzing' }}</strong><small>{{ drive.duration }} · {{ drive.engaged }} engaged</small></div>
<div><small>{{ drive.ignored ? 'Excluded from totals' : drive.complete ? 'Included in totals' : 'Waiting for analysis' }}</small>
<button type="button" class="gx-home__link" :disabled="drives.busy" @click="ignoreDrive(drive)">
{{ drive.ignored ? 'Include in stats' : 'Ignore drive stats' }}</button></div>
</div>
<p v-if="drives.error" role="alert">{{ drives.error }}</p>
</section>
<section class="gx-card gx-home__models"><h2>Recorded Models</h2>
<p v-if="!driving.models.length" class="gx-home__subtle">Recorded models will appear as drives are saved.</p>
<div v-else class="gx-home__model-layout"><div class="gx-home__model-donut" :style="driving.modelStyle"></div>
<div><div v-for="model in driving.models" :key="model.name" class="gx-home__model-row">
<span class="gx-home__swatch" :style="{ background: model.color }"></span>
<div><strong>{{ model.name }}</strong><small>{{ counted(model.drives, "drive") }} · {{ model.distance }}</small></div></div></div></div>
</section>
</template>
<div class="gx-home__section-title"><span></span>Your device</div>
<LocalAccess :mode="mode" :on-unauthorized="unauthorized" />
<div class="gx-home__grid">
<section class="gx-card gx-home__card">
<h2><i class="bi bi-activity"></i> System</h2>
<p v-if="!summary.system.available" role="status">Live system status unavailable.</p>
<template v-else><div class="gx-home__metrics"><div><strong>{{ summary.system.cpu }}</strong><span>CPU</span></div>
<div><strong>{{ summary.system.memory }}</strong><span>Memory</span></div></div>
<p>Storage: {{ summary.system.storage }}</p><small>Sampled {{ summary.system.observed }}</small></template>
<button type="button" class="gx-home__link" @click="go('/logs/monitor')">Open System Monitor <i class="bi bi-arrow-right"></i></button>
</section>
<section class="gx-card gx-home__card">
<h2><i class="bi bi-cpu"></i> Driving model</h2>
<p v-if="!summary.model.available" role="status">Model identity unavailable.</p>
<template v-else><p>Reported health: <strong>{{ summary.model.health }}</strong></p>
<p>Loaded variant: <strong>{{ summary.model.variant }}</strong></p>
<small v-if="summary.model.fallback">{{ summary.model.fallback }}</small></template>
<button type="button" class="gx-home__link" @click="go('/manage_models')">Open Driving Model <i class="bi bi-arrow-right"></i></button>
</section>
<section class="gx-card gx-home__card">
<h2><i class="bi bi-arrow-repeat"></i> Software</h2>
<p v-if="!summary.software.available" role="status">Installed software report unavailable.</p>
<template v-else><p>Installed: <strong>{{ summary.software.version }}</strong></p>
<p>Branch: <strong>{{ summary.software.branch }}</strong></p>
<p>Last updater report: <strong>{{ summary.software.update }}</strong></p></template>
<button type="button" class="gx-home__link" @click="go('/system')">Open Software &amp; Updates <i class="bi bi-arrow-right"></i></button>
</section>
</div>
</template>
</div>`,
}
@@ -0,0 +1,41 @@
export function installHelp({ secure = window.isSecureContext, agent = navigator.userAgent } = {}) {
if (/iPhone|iPad|iPod/.test(agent)) return 'Open Galaxy in Safari, tap Share, then Add to Home Screen. Turn on Open as Web App if shown.'
if (!secure) return 'For the full web app, open your comma at https://galaxy.firestar.link, then choose Install Galaxy. Your local address can also be saved as a browser shortcut.'
return 'Use your browser menu to choose Install app or Add to Home Screen. On a Mac in Safari, choose File → Add to Dock.'
}
export const InstallApp = {
name: 'InstallApp',
data: () => ({ prompt: null, installed: false, help: false, message: '' }),
mounted() {
this.displayMode = window.matchMedia('(display-mode: standalone)')
this.updateInstalled = () => { this.installed = this.displayMode.matches || navigator.standalone === true }
this.onPrompt = (event) => { event.preventDefault(); this.prompt = event; this.help = false }
this.onInstalled = () => { this.installed = true; this.prompt = null; this.help = false }
this.updateInstalled()
window.addEventListener('beforeinstallprompt', this.onPrompt)
window.addEventListener('appinstalled', this.onInstalled)
this.displayMode.addEventListener('change', this.updateInstalled)
},
beforeUnmount() {
window.removeEventListener('beforeinstallprompt', this.onPrompt)
window.removeEventListener('appinstalled', this.onInstalled)
this.displayMode?.removeEventListener('change', this.updateInstalled)
this.prompt = null
},
methods: {
async install() {
if (!this.prompt) { this.message = installHelp(); this.help = !this.help; return }
const prompt = this.prompt
this.prompt = null
try {
await prompt.prompt()
await prompt.userChoice
} catch { this.message = installHelp(); this.help = true }
},
},
template: `<div v-if="!installed" class="gx-nav-section">
<button type="button" class="gx-nav-item" @click="install"><i class="bi bi-download"></i><span>Install Galaxy</span></button>
<p v-if="help" class="gx-note" style="padding:0 var(--sp-3)">{{ message }}</p>
</div>`,
}
@@ -0,0 +1,96 @@
// A portable copy of the single OnroadCustomizations document, not a Params backup.
const FORMAT = "starpilot-visual-layout"
const VERSION = 1
export const MAX_LAYOUT_BACKUP_BYTES = 32768
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
])
const INITIAL = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]
const right = (value, count) => (value >>> count) | (value << (32 - count))
// Local Galaxy can be served over plain HTTP, where Web Crypto is unavailable.
function sha256Portable(bytes) {
const padded = new Uint8Array(Math.ceil((bytes.length + 9) / 64) * 64)
padded.set(bytes)
padded[bytes.length] = 0x80
const view = new DataView(padded.buffer)
const bits = bytes.length * 8
view.setUint32(padded.length - 8, Math.floor(bits / 0x100000000))
view.setUint32(padded.length - 4, bits >>> 0)
const state = INITIAL.slice()
const words = new Uint32Array(64)
for (let offset = 0; offset < padded.length; offset += 64) {
for (let i = 0; i < 16; i++) words[i] = view.getUint32(offset + i * 4)
for (let i = 16; i < 64; i++) {
const x = words[i - 15], y = words[i - 2]
const s0 = right(x, 7) ^ right(x, 18) ^ (x >>> 3)
const s1 = right(y, 17) ^ right(y, 19) ^ (y >>> 10)
words[i] = (words[i - 16] + s0 + words[i - 7] + s1) >>> 0
}
let [a, b, c, d, e, f, g, h] = state
for (let i = 0; i < 64; i++) {
const s1 = right(e, 6) ^ right(e, 11) ^ right(e, 25)
const choose = (e & f) ^ (~e & g)
const t1 = (h + s1 + choose + K[i] + words[i]) >>> 0
const s0 = right(a, 2) ^ right(a, 13) ^ right(a, 22)
const majority = (a & b) ^ (a & c) ^ (b & c)
const t2 = (s0 + majority) >>> 0
h = g; g = f; f = e; e = (d + t1) >>> 0
d = c; c = b; b = a; a = (t1 + t2) >>> 0
}
state[0] = (state[0] + a) >>> 0; state[1] = (state[1] + b) >>> 0
state[2] = (state[2] + c) >>> 0; state[3] = (state[3] + d) >>> 0
state[4] = (state[4] + e) >>> 0; state[5] = (state[5] + f) >>> 0
state[6] = (state[6] + g) >>> 0; state[7] = (state[7] + h) >>> 0
}
return state.map((word) => word.toString(16).padStart(8, "0")).join("")
}
export async function sha256(value, cryptoSource = globalThis.crypto) {
const bytes = new TextEncoder().encode(value)
if (!cryptoSource?.subtle) return sha256Portable(bytes)
const digest = await cryptoSource.subtle.digest("SHA-256", bytes)
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")
}
function body(document) {
if (!document || typeof document !== "object" || Array.isArray(document)) throw new Error("Visual layout is unavailable.")
return { format: FORMAT, version: VERSION, document }
}
export async function encodeLayoutBackup(document, cryptoSource = globalThis.crypto) {
const content = body(document)
const sha256Value = await sha256(JSON.stringify(content), cryptoSource)
const encoded = JSON.stringify({ ...content, sha256: sha256Value }) + "\n"
if (new TextEncoder().encode(encoded).length > MAX_LAYOUT_BACKUP_BYTES) throw new Error("Visual layout backup is too large.")
return encoded
}
export async function decodeLayoutBackup(text, cryptoSource = globalThis.crypto) {
if (typeof text !== "string" || new TextEncoder().encode(text).length > MAX_LAYOUT_BACKUP_BYTES) {
throw new Error("Visual layout backup is too large or unreadable.")
}
let parsed
try { parsed = JSON.parse(text) } catch { throw new Error("Visual layout backup is not valid JSON.") }
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) ||
Object.keys(parsed).join(",") !== "format,version,document,sha256" ||
parsed.format !== FORMAT || parsed.version !== VERSION ||
!/^[0-9a-f]{64}$/.test(parsed.sha256) ||
text !== JSON.stringify(parsed) + "\n") {
throw new Error("Visual layout backup has an unsupported format.")
}
const content = body(parsed.document)
if (await sha256(JSON.stringify(content), cryptoSource) !== parsed.sha256) {
throw new Error("Visual layout backup checksum does not match.")
}
return parsed.document
}
@@ -0,0 +1,105 @@
const SCENES = ["engaged", "aol", "long_only", "experimental", "braking", "cem_stop_light", "cem_lead", "cem_curve", "slc_pending"]
export const PREVIEW_SCENES = [
{ id: "engaged", label: "Engaged" },
{ id: "aol", label: "Lateral only" },
{ id: "long_only", label: "Longitudinal only" },
{ id: "experimental", label: "Manual experimental" },
{ id: "braking", label: "Braking" },
{ id: "cem_stop_light", label: "CEM · Traffic light" },
{ id: "cem_lead", label: "CEM · Stopped lead" },
{ id: "cem_curve", label: "CEM · Curve" },
{ id: "slc_pending", label: "Pending speed limit" },
]
export class LayoutPreviewFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id),
urls = URL }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer, urls })
this.active = false
this.version = 0
this.timer = null
this.request = null
this.currentUrl = null
this.pending = null
}
stop() {
this.active = false
this.version++
if (this.timer !== null) this.cancelTimer(this.timer)
this.timer = null
this.pending = null
this.inputKey = null
this.request?.abort()
this.clearImage()
}
clearImage() {
if (this.currentUrl) this.urls.revokeObjectURL(this.currentUrl)
this.currentUrl = null
this.publish({ url: null })
}
start() { this.stop(); this.active = true }
update(document, profile, scene, dragging = false) {
if (!this.active || !document || !["large", "compact", "projection"].includes(profile) || !SCENES.includes(scene)) return
const inputKey = JSON.stringify({ document, profile, scene, dragging })
if (inputKey === this.inputKey) return
this.inputKey = inputKey
this.version++
if (this.timer !== null) this.cancelTimer(this.timer)
this.timer = null
this.pending = null
this.request?.abort()
if (dragging) { this.publish({ status: "editing", error: "" }); return }
this.pending = { document: JSON.parse(JSON.stringify(document)), profile, scene, version: this.version }
this.publish({ status: "updating", error: "" })
this.timer = this.later(() => { this.timer = null; this.dispatch() }, 200)
}
retry() {
if (!this.active || !this.last) return
this.inputKey = null
this.update(this.last.document, this.last.profile, this.last.scene)
}
async dispatch() {
if (!this.active || this.request || !this.pending) return
const item = this.pending
this.pending = null
this.last = item
const request = new AbortController()
this.request = request
let timeout = this.later(() => request.abort(), 7000)
let failure = "Preview is unavailable. Try again."
try {
const response = await this.fetcher("./api/ui/layout/preview", { method: "POST", credentials: "same-origin",
cache: "no-store", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ document: item.document, profile: item.profile, scene: item.scene }), signal: request.signal })
if (!this.active || item.version !== this.version) return
if (response.status === 401) { this.stop(); this.unauthorized(); return }
if (response.status === 403) { failure = "Turn off the vehicle to preview this layout."; throw new Error() }
if (response.status === 429) { failure = "Preview is busy. Try again."; throw new Error() }
if (!response.ok) { const detail = await response.json().catch(() => null); failure = detail?.error || failure; throw new Error() }
if (!response.headers.get("Content-Type")?.toLowerCase().startsWith("image/png")) throw new Error("Preview is unavailable. Try again.")
const blob = await response.blob()
if (!this.active || item.version !== this.version) return
if (!blob.size || blob.size > 4 * 1024 * 1024) throw new Error("Preview is unavailable. Try again.")
const previousUrl = this.currentUrl
this.currentUrl = this.urls.createObjectURL(blob)
this.publish({ status: "ready", error: "", url: this.currentUrl })
if (previousUrl) this.urls.revokeObjectURL(previousUrl)
} catch {
if (this.active && item.version === this.version) {
this.inputKey = null
this.publish({ status: "unavailable", error: failure })
}
} finally {
this.cancelTimer(timeout)
if (this.request === request) this.request = null
if (this.active && this.pending && this.timer === null) this.dispatch()
}
}
}
@@ -0,0 +1,86 @@
import { reactive } from "../vendor/vue/vue.esm-browser.js"
export function validLocalAccess(value) {
if (!value || typeof value.available !== "boolean" || typeof value.reason !== "string" || value.reason.length > 1024 ||
!Array.isArray(value.addresses) || value.addresses.length > 32 || value.available !== (value.addresses.length > 0)) return false
return value.addresses.every(address => {
if (!address || !["interface", "label", "url"].every(key => typeof address[key] === "string" && address[key].length > 0 && address[key].length <= 512)) return false
try {
const url = new URL(address.url)
return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !!url.hostname
} catch { return false }
})
}
export class LocalAccessFeed {
constructor({ publish, onUnauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancel = id => clearTimeout(id) }) {
Object.assign(this, { publish, onUnauthorized, fetcher, later, cancel })
this.active = false
this.request = null
this.generation = 0
}
start(mode) {
this.stop()
this.active = true
this.mode = mode
if (mode !== "local") {
this.publish({ loading: false, data: null, error: "Local comma addresses are unavailable in sample mode." })
return
}
return this.refresh()
}
stop() {
this.active = false
this.generation++
this.request?.abort()
this.request = null
}
async refresh() {
if (!this.active || this.mode !== "local" || this.request) return
const request = new AbortController(), generation = ++this.generation
this.request = request
this.publish({ loading: true, error: "" })
const timer = this.later(() => request.abort(), 4000)
try {
const response = await this.fetcher("./api/local-access", { credentials: "same-origin", cache: "no-store", signal: request.signal })
if (!this.active || generation !== this.generation) return
if (response.status === 401) { this.stop(); this.onUnauthorized(); return }
const data = await response.json()
if (!this.active || generation !== this.generation) return
if (!response.ok || !validLocalAccess(data)) throw new Error("Local comma addresses are unavailable. Try Refresh.")
this.publish({ data, error: "" })
} catch (error) {
if (this.active && generation === this.generation)
this.publish({ data: null, error: error.name === "AbortError" ? "Reading local addresses timed out. Try Refresh." : "Local comma addresses are unavailable. Try Refresh." })
} finally {
this.cancel(timer)
if (this.active && generation === this.generation) {
this.request = null
this.publish({ loading: false })
}
}
}
}
export const LocalAccess = {
props: { mode: { type: String, required: true }, onUnauthorized: { type: Function, default: () => {} } },
setup(props) {
const state = reactive({ loading: false, data: null, error: "" })
const feed = new LocalAccessFeed({ publish: update => Object.assign(state, update), onUnauthorized: props.onUnauthorized })
return { state, feed }
},
mounted() { this.feed.start(this.mode) },
watch: { mode(value) { this.feed.start(value) } },
beforeUnmount() { this.feed.stop() },
template: `<section class="gx-card gx-local-access" style="padding:var(--sp-4)" aria-label="Local comma access">
<div class="gx-settings__subhead"><h3>Local Comma Access</h3><button class="gx-btn gx-btn--tonal" type="button" :disabled="mode !== 'local' || state.loading" @click="feed.refresh()">{{ state.loading ? 'Checking…' : 'Refresh' }}</button></div>
<p v-if="state.error" class="gx-note" role="status">{{ state.error }}</p>
<template v-else-if="state.data?.available">
<p>On the same network, open one of these comma addresses:</p>
<ul><li v-for="address in state.data.addresses" :key="address.interface + address.url"><a :href="address.url" target="_blank" rel="noopener" style="overflow-wrap:anywhere">{{ address.label }} · {{ address.url }}</a></li></ul>
<p class="gx-note">These addresses come from the comma; your browser must be able to reach its network.</p>
</template>
<p v-else-if="state.data" class="gx-note" role="status">{{ state.data.reason || 'The comma has no local network address available.' }}</p>
</section>`,
}
+71
View File
@@ -0,0 +1,71 @@
import { TroubleshootPage, TmuxPage } from "./diagnostic-tools.js"
import { navigate } from "./router.js"
import { SystemMonitor } from "./system-monitor.js"
import { CrashReportsFeed } from "./crash-reports.js"
export const Logs = {
name: "Logs",
components: { SystemMonitor, TroubleshootPage, TmuxPage },
props: { path: { type: String, required: true }, mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ crashes: { reports: [], scanIncomplete: false, listLimited: false, status: "idle", error: "", selected: null, preview: null, previewStatus: "idle" }, search: "" }),
computed: {
visibleReports() { return this.crashes.reports.filter((item) => item.name.toLowerCase().includes(this.search.toLowerCase())) },
},
created() {
this.crashFeed = new CrashReportsFeed({ publish: (update) => Object.assign(this.crashes, update), unauthorized: this.unauthorized })
},
mounted() { if (this.path === "/logs/crashes" && this.mode === "local") this.crashFeed.start() },
beforeUnmount() { this.crashFeed.stop() },
watch: {
path(next, previous) {
if (next === "/logs/crashes" && this.mode === "local") this.crashFeed.start()
else if (previous === "/logs/crashes") { this.crashFeed.stop(); this.search = "" }
},
},
methods: {
go: navigate,
openTroubleshoot() { navigate("/logs/troubleshoot") },
openTmux() { navigate("/logs/tmux") },
openMonitor() { navigate("/logs/monitor") },
openCrashes() { if (this.mode === "local") navigate("/logs/crashes") },
openLogs() { navigate("/logs") },
reportDate(seconds) { return new Date(seconds * 1000).toLocaleString() },
reportSize(bytes) { return `${(bytes / 1024).toFixed(1)} KiB` },
async copyPreview() {
if (!this.crashes.preview) return
try { await navigator.clipboard.writeText(this.crashes.preview.text) }
catch { this.crashes.error = "Copy is unavailable in this browser." }
},
},
template: `
<div class="gx-view">
<h2 v-if="path === '/logs'">Logs & Diagnostics</h2>
<div v-if="path === '/logs'" class="gx-logs-tiles">
<button type="button" class="gx-card gx-logs-entry" @click="openMonitor"><i class="bi bi-cpu"></i><strong>System Monitor</strong><span>{{ mode === 'sample' ? 'Inspect a synthetic activity sample' : 'Inspect local system activity' }}</span></button>
<button v-if="mode === 'local'" type="button" class="gx-card gx-logs-entry" @click="openCrashes"><i class="bi bi-file-text"></i><strong>Crash Reports</strong><span>Read local reports</span></button>
<div v-else class="gx-card gx-logs-entry gx-logs-entry--unavailable"><i class="bi bi-file-text"></i><strong>Crash Reports</strong><span>Unavailable in preview</span></div>
<button type="button" class="gx-card gx-logs-entry" @click="openTmux"><i class="bi bi-terminal"></i><strong>tmux Live View</strong><span>Read the launcher console tail</span></button>
<button type="button" class="gx-card gx-logs-entry" @click="openTroubleshoot"><i class="bi bi-wrench"></i><strong>Troubleshoot</strong><span>Read device and vehicle diagnostics</span></button>
</div>
<TroubleshootPage v-else-if="path === '/logs/troubleshoot'" :mode="mode" :unauthorized="unauthorized" @navigate="go" />
<TmuxPage v-else-if="path === '/logs/tmux'" :mode="mode" :unauthorized="unauthorized" @navigate="go" />
<template v-else-if="path === '/logs/monitor'"><button type="button" class="gx-btn gx-btn--tonal gx-logs-back" @click="openLogs"><i class="bi bi-arrow-left"></i> Logs & Diagnostics</button><SystemMonitor :mode="mode" :unauthorized="unauthorized" /></template>
<template v-else-if="path === '/logs/crashes' && mode === 'local'">
<button type="button" class="gx-btn gx-btn--tonal gx-logs-back" @click="openLogs"><i class="bi bi-arrow-left"></i> Logs & Diagnostics</button>
<h2>Crash Reports</h2>
<div class="gx-crash-controls"><input class="gx-field" type="search" v-model="search" placeholder="Search report names" aria-label="Search crash reports"><button type="button" class="gx-btn gx-btn--tonal" @click="crashFeed.load()">Refresh</button></div>
<p v-if="crashes.scanIncomplete" class="gx-note">Directory scan is incomplete; newer reports may be omitted.</p>
<p v-else-if="crashes.listLimited" class="gx-note">Showing the 200 newest reports; older reports are omitted.</p>
<p v-if="crashes.status === 'loading'">Loading crash reports…</p>
<p v-else-if="crashes.status === 'unavailable'" role="alert">Crash reports are unavailable.</p>
<p v-else-if="crashes.status === 'ready' && !visibleReports.length">{{ search ? 'No matching reports.' : 'No crash reports.' }}</p>
<div v-if="crashes.status === 'ready'" class="gx-crash-list">
<button v-for="report in visibleReports" :key="report.id" type="button" class="gx-card gx-crash-row" @click="crashFeed.open(report)"><strong>{{ report.name }}</strong><span>{{ reportDate(report.modifiedAt) }} · {{ reportSize(report.size) }}</span></button>
</div>
<section v-if="crashes.previewStatus === 'loading'" class="gx-card">Loading report…</section>
<section v-else-if="crashes.preview" class="gx-card gx-crash-preview"><div class="gx-crash-preview__head"><strong>{{ crashes.preview.name }}</strong><button type="button" class="gx-btn gx-btn--tonal" @click="copyPreview">Copy visible text</button></div><p v-if="crashes.preview.truncated" class="gx-note">Preview truncated to the first 256 KiB.</p><pre>{{ crashes.preview.text }}</pre></section>
<p v-if="crashes.error" role="alert">{{ crashes.error }}</p>
</template>
<div v-else class="gx-card gx-message" role="status">This Logs & Diagnostics page is unavailable in the offline preview.</div>
</div>`,
}
@@ -0,0 +1,105 @@
import { reactive } from "../vendor/vue/vue.esm-browser.js"
import { SettingsFeed } from "./settings.js"
import { GalaxySettingRow } from "./galaxy-setting-row.js"
export const PROFILES = Object.freeze(["aggressive", "standard", "relaxed"])
export const CATEGORIES = Object.freeze(["acceleration", "braking", "following"])
export function savedCurve(page, rows) {
if (!PROFILES.some((profile) => CATEGORIES.some((category) => page === `${profile}/${category}`))) return null
if (!Array.isArray(rows) || rows[0]?.label !== "Preset" || rows[0]?.value !== "custom") return null
const points = rows.slice(1)
if (points.length !== 10) return null
const unit = page.endsWith("/following") ? "s" : "m/s²"
const values = points.map((row, index) => {
const value = Number(row.value)
if (row.label !== `${index * 10} mph point` || row.unit !== unit || typeof row.value !== "string" || !row.value.trim() ||
!Number.isFinite(value) || !Number.isFinite(row.minimum) || !Number.isFinite(row.maximum)) return null
return { index: index + 1, speed: index * 10, value, unit, minimum: row.minimum, maximum: row.maximum,
available: row.available === true }
})
return values.every(Boolean) ? values : null
}
export function curveGeometry(points) {
if (!Array.isArray(points) || points.length !== 10) return null
const values = points.map((point) => point.value)
if (values.some((value) => !Number.isFinite(value))) return null
const min = Math.min(...values)
const max = Math.max(...values)
const span = Math.max(max - min, 0.1)
const bottom = min - (span - (max - min)) / 2
const top = bottom + span
const coords = points.map((point, index) => ({ x: 44 + index * 32, y: 116 - (point.value - bottom) / span * 88 }))
return { coords, line: coords.map(({ x, y }) => `${x},${y}`).join(" "), bottom, top }
}
export const LongitudinalCurvesPage = {
components: { GalaxySettingRow },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, go: { type: Function, required: true } },
setup(props) {
const state = reactive({ status: "idle", data: null, pending: null, error: "", profile: "standard", category: "acceleration" })
const feed = new SettingsFeed({ publish: (update) => Object.assign(state, update), unauthorized: props.unauthorized })
return { state, feed, profiles: PROFILES, categories: CATEGORIES }
},
computed: {
page() { return `${this.state.profile}/${this.state.category}` },
points() { return savedCurve(this.state.data?.page, this.state.data?.rows) },
graph() { return curveGeometry(this.points) },
preset() { return this.state.data?.rows?.[0] },
},
mounted() { if (this.mode === "local") this.feed.start(this.page) },
beforeUnmount() { this.feed.stop() },
methods: {
selectProfile(profile) { if (PROFILES.includes(profile) && !this.feed.saving) { this.state.profile = profile; this.feed.load(this.page) } },
selectCategory(category) { if (CATEGORIES.includes(category) && !this.feed.saving) { this.state.category = category; this.feed.load(this.page) } },
},
template: `
<section class="gx-long-curves" aria-label="Longitudinal curves">
<div class="gx-card gx-long-curves__header"><div><p class="gx-eyebrow">Saved driving preferences</p><h2>Longitudinal Curves</h2>
<p>Review acceleration, braking, and following curves for the next drive.</p></div>
<button type="button" class="gx-btn gx-btn--tonal" @click="go('/driving')">Back to Driving</button></div>
<div v-if="mode !== 'local'" class="gx-card gx-message" role="status">Local saved curves are unavailable in preview.</div>
<template v-else>
<div class="gx-long-curves__tabs" role="group" aria-label="Personality profile">
<button v-for="profile in profiles" :key="profile" type="button" class="gx-btn gx-btn--tonal" :aria-pressed="state.profile === profile"
:disabled="state.status === 'saving'" @click="selectProfile(profile)">{{ profile }}</button></div>
<div class="gx-long-curves__tabs" role="group" aria-label="Curve category">
<button v-for="category in categories" :key="category" type="button" class="gx-btn gx-btn--tonal" :aria-pressed="state.category === category"
:disabled="state.status === 'saving'" @click="selectCategory(category)">{{ category }}</button></div>
<div v-if="state.status === 'loading'" class="gx-card gx-message" role="status">Loading saved curve…</div>
<div v-else-if="state.status === 'unavailable'" class="gx-card gx-message" role="alert">Saved curve is unavailable.</div>
<div v-if="state.error" class="gx-card gx-message" role="alert">{{ state.error }}
<button type="button" class="gx-btn gx-btn--tonal" @click="feed.load(page)">Refresh</button></div>
<article v-if="state.data" class="gx-card gx-long-curves__body">
<div class="gx-long-curves__heading"><div><h3>{{ state.data.title }}</h3><p>Enable saved profiles and this personality in All profile settings to use its curve.</p></div>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="state.status === 'saving'" @click="feed.load(page)">Refresh</button></div>
<p v-if="!state.data.parked" class="gx-note">Editing requires fresh parked vehicle evidence.</p>
<GalaxySettingRow v-if="preset" :key="state.data.view + ':preset'" :row="preset" :index="0"
:disabled="!state.data.parked || state.status !== 'ready' || !!state.pending"
:save-value="(index, value) => feed.previewValue(index, value)" @review="(index, direction) => feed.preview(index, direction)" />
<template v-if="points && graph">
<svg class="gx-long-curves__graph" viewBox="0 0 370 155" role="img" :aria-label="state.data.title + ' saved curve, 0 to 90 miles per hour'">
<line x1="44" y1="28" x2="44" y2="116" class="gx-long-curves__axis"/><line x1="44" y1="116" x2="332" y2="116" class="gx-long-curves__axis"/>
<text x="5" y="32">{{ graph.top.toFixed(2) }}</text><text x="5" y="118">{{ graph.bottom.toFixed(2) }}</text>
<text x="43" y="140">0</text><text x="307" y="140">90 mph</text>
<polyline :points="graph.line" class="gx-long-curves__line"/>
<circle v-for="(point, index) in graph.coords" :key="index" :cx="point.x" :cy="point.y" r="4" class="gx-long-curves__point"/>
</svg>
<p class="gx-note">Saved custom points in {{ points[0].unit }}. Use the controls below to change one point at a time.</p>
<div class="gx-long-curves__points">
<GalaxySettingRow v-for="point in points" :key="state.data.view + ':' + point.index" :row="state.data.rows[point.index]" :index="point.index"
:disabled="!state.data.parked || state.status !== 'ready' || !!state.pending"
:save-value="(index, value) => feed.previewValue(index, value)" />
</div>
</template>
<p v-else class="gx-note">{{ preset?.value === 'custom' ? 'Saved custom points are unavailable; review the saved profile.' : 'Choose the Custom preset to edit saved points. Preset curves remain managed by the vehicle profile.' }}</p>
<button type="button" class="gx-btn gx-btn--tonal" @click="go('/driving/profiles')">All profile settings</button>
</article>
<Teleport to="body"><div v-if="state.pending" class="gx-settings__modal" role="dialog" aria-modal="true" aria-label="Confirm saved curve">
<div class="gx-card gx-settings__dialog"><h3>Confirm Saved Preference</h3><p>{{ state.pending.question }}</p>
<div class="gx-settings__controls"><button type="button" class="gx-btn gx-btn--tonal" @click="feed.cancel()">Cancel</button>
<button type="button" class="gx-btn" @click="feed.confirm()">Save</button></div></div></div></Teleport>
</template>
</section>`,
}
@@ -0,0 +1,330 @@
// One parked map operation belongs to the local owner, not to this browser tab.
const STATES = new Set(["idle", "transferring", "validating", "selecting", "completed", "canceled", "failed", "interrupted", "unavailable"])
const GENERATION = /^(?:|[0-9a-f]{64})$/
const TOKEN = /^(?:nation|us_state)\.[A-Za-z0-9_-]+$/
const ERROR_LABELS = {
canceled: "Canceled", interrupted: "Interrupted", selection_changed: "The saved selection changed",
not_parked: "Parked availability was lost", package_unavailable: "The map package is unavailable",
process_failed: "Map preparation failed", unavailable: "Map manager unavailable",
}
const ACTION_ERRORS = {
busy: "Another map download is running. Check its progress below.",
selection_changed: "The selected map changed. Refresh and review the region again.",
not_parked: "Map downloads need a fresh parked status. Refresh and try again.",
operation_changed: "This map download changed. Refresh its status before trying again.",
}
const integer = (value) => Number.isSafeInteger(value) && value >= 0
const nullableText = (value) => value === null || typeof value === "string" && value.length <= 128
const bounds = (value) => value === null || Array.isArray(value) && value.length === 4 &&
value.every((n) => typeof n === "number" && Number.isFinite(n)) &&
value[0] >= -90 && value[2] <= 90 && value[1] >= -180 && value[3] <= 180 &&
value[0] < value[2] && value[1] < value[3]
export function validCatalog(value) {
if (!Array.isArray(value?.regions) || value.regions.length > 500 ||
!integer(value.maxGroups) || value.maxGroups < 1 || !integer(value.maxTransferBytes) ||
!integer(value.maxNewDiskBytes) || !GENERATION.test(value.selectedGeneration ?? "!")) return false
const seen = new Set()
return value.regions.every((region) => {
if (!TOKEN.test(region?.token) || region.token.length > 73 || seen.has(region.token) || typeof region.name !== "string" ||
!region.name.trim() || region.name.length > 128 || (region.available && (!bounds(region.bounds) || region.bounds === null)) ||
!integer(region.groups) || typeof region.available !== "boolean" ||
!(region.unavailable == null || ["invalid_bounds", "date_line_bounds", "too_large"].includes(region.unavailable)) ||
(region.available && (region.unavailable != null || region.groups > value.maxGroups)) ||
(!region.available && !["invalid_bounds", "date_line_bounds", "too_large"].includes(region.unavailable))) return false
seen.add(region.token)
return true
})
}
export function validSetup(value) {
return value?.schemaVersion === 1 && typeof value.packageReady === "boolean" &&
["ready", "missing_binary", "missing_manifest", "invalid_package"].includes(value.packageState) &&
value.packageReady === (value.packageState === "ready") && typeof value.snapshotReady === "boolean" &&
GENERATION.test(value.selectedGeneration ?? "!") && value.snapshotReady === Boolean(value.selectedGeneration) &&
typeof value.parked === "boolean" && integer(value.freeDiskBytes) &&
integer(value.maxTransferBytes) && integer(value.maxNewDiskBytes)
}
export function validOperation(value) {
return value?.schemaVersion === 1 && STATES.has(value.state) &&
typeof value.ownerSession === "string" && value.ownerSession.length <= 128 &&
nullableText(value.operationId) && nullableText(value.regionToken) && bounds(value.bounds) &&
integer(value.completedGroups) && integer(value.totalGroups) && value.completedGroups <= value.totalGroups &&
integer(value.transferredBytes) && integer(value.transferBudgetBytes) &&
nullableText(value.preparedGeneration) && GENERATION.test(value.selectedGeneration ?? "!") &&
nullableText(value.errorCode) && typeof value.selectedForNextShadowStart === "boolean" &&
(!value.selectedForNextShadowStart || value.state === "completed" && value.selectedGeneration !== "")
}
export const formatBytes = (value) => !integer(value) ? "Unavailable" : value < 1024 ** 2 ? `${value} B` :
value < 1024 ** 3 ? `${(value / (1024 ** 2)).toFixed(1)} MiB` : `${(value / (1024 ** 3)).toFixed(1)} GiB`
export const formatBounds = (value) => bounds(value) && value !== null ?
`latitude ${value[0]}°–${value[2]}°, longitude ${value[1]}°–${value[3]}°` : "extent unavailable"
export class MapOperationsClient {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer })
this.active = false
this.generation = 0
this.requests = new Set()
this.timer = null
this.setup = null
this.catalog = null
this.operation = null
this.busy = false
this.error = ""
this.actionError = ""
}
emit(update = {}) {
this.publish({ setup: this.setup, catalog: this.catalog, operation: this.operation, busy: this.busy,
error: this.actionError || this.error, ...update })
}
stop() {
this.active = false
this.generation++
if (this.timer !== null) this.cancelTimer(this.timer)
this.timer = null
for (const request of this.requests) request.abort()
this.requests.clear()
this.setup = this.catalog = this.operation = null
this.busy = false
this.error = this.actionError = ""
this.emit()
}
start() {
this.stop()
this.active = true
this.loadCatalog()
this.loadStatus()
this.loadSetup()
}
async request(url, body = null) {
const generation = this.generation
const controller = new AbortController()
this.requests.add(controller)
const deadline = this.later(() => controller.abort(), 10000)
try {
const response = await this.fetcher(url, { credentials: "same-origin", cache: "no-store",
signal: controller.signal, ...(body === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }) })
if (!this.active || generation !== this.generation || controller.signal.aborted) return null
if (response.status === 401) { this.stop(); this.unauthorized(); return null }
if (response.status === 409) {
const body = await response.json().catch(() => null)
if (!this.active || generation !== this.generation || controller.signal.aborted) return null
const message = Object.hasOwn(ACTION_ERRORS, body?.code) ? ACTION_ERRORS[body.code] :
"Map request could not be completed. Refresh status and try again."
throw new Error(message)
}
if (response.status === 503) {
const body = await response.json().catch(() => null)
if (!this.active || generation !== this.generation || controller.signal.aborted) return null
if (["setup_required", "access_unavailable"].includes(body?.code)) {
this.stop(); this.unauthorized(); return null
}
if (body?.code === "package_unavailable")
throw new Error("Offline map package is unavailable on this device.")
throw new Error("Offline maps service is unavailable.")
}
if (!response.ok) throw new Error("Map request was rejected.")
const data = await response.json()
if (!this.active || generation !== this.generation || controller.signal.aborted) return null
return data
} finally {
this.cancelTimer(deadline)
this.requests.delete(controller)
}
}
async loadSetup() {
if (!this.active) return
try {
const data = await this.request("./api/maps/setup")
if (data === null) return
if (!validSetup(data)) throw new Error("Map setup status is unavailable.")
const wasReady = this.setup?.packageReady
this.setup = data
this.emit()
if (data.packageReady && wasReady === false) await this.loadCatalog()
} catch (error) { if (this.active) { this.error = error.message; this.emit() } }
}
async loadCatalog() {
if (!this.active) return
try {
const data = await this.request("./api/maps/catalog")
if (data === null) return
if (!validCatalog(data)) throw new Error("Map catalog is unavailable.")
this.catalog = data
if (this.operation !== null) this.error = ""
this.emit()
} catch (error) { if (this.active) { this.error = error.message; this.emit() } }
}
async loadStatus() {
if (!this.active || this.busy) return
const generation = this.generation
try {
const data = await this.request("./api/maps/operation")
if (data !== null) {
if (!validOperation(data)) throw new Error("Map operation status is unavailable.")
this.operation = data
if (this.catalog !== null) this.error = ""
this.emit()
}
} catch (error) {
if (this.active && generation === this.generation) {
this.operation = null; this.error = error.message; this.emit()
}
} finally {
if (this.active && generation === this.generation && !this.busy)
this.timer = this.later(() => { this.timer = null; this.loadStatus() }, 2500)
}
}
pausePolling() {
this.generation++
if (this.timer !== null) this.cancelTimer(this.timer)
this.timer = null
for (const request of this.requests) request.abort()
this.requests.clear()
}
async startRegion(region, expectedGeneration, expectedOwnerSession) {
if (!this.active || this.busy || !this.catalog || !this.operation || !region?.available ||
!this.catalog.regions.some((item) => item.token === region.token && item.available) ||
!["idle", "completed", "canceled", "failed", "interrupted"].includes(this.operation.state)) {
this.actionError = "Map operation changed. Refresh status and review again."
this.emit()
return
}
if (this.operation.selectedGeneration !== expectedGeneration || this.operation.ownerSession !== expectedOwnerSession) {
this.actionError = "Map selection changed. Review this region again."
this.emit()
return
}
const expected = this.operation.selectedGeneration
this.pausePolling()
const generation = this.generation
this.busy = true
this.actionError = ""
this.emit()
try {
const data = await this.request("./api/maps/start", { regionToken: region.token,
maxTransferBytes: this.catalog.maxTransferBytes, maxNewDiskBytes: this.catalog.maxNewDiskBytes,
expectedCurrentGeneration: expected })
if (data === null) return
if (!validOperation(data)) throw new Error("Map start status is unavailable. Refresh status.")
this.operation = data
this.emit()
} catch (error) { if (this.active && generation === this.generation) { this.actionError = error.message; this.emit() } }
finally { if (this.active && generation === this.generation) { this.busy = false; this.emit(); await this.loadStatus() } }
}
async cancel() {
if (!this.active || this.busy || !this.operation?.operationId ||
!["transferring", "validating", "selecting"].includes(this.operation.state)) return
const operationId = this.operation.operationId
this.pausePolling()
const generation = this.generation
this.busy = true
this.actionError = ""
this.emit()
try {
const data = await this.request("./api/maps/cancel", { operationId })
if (data === null) return
if (!validOperation(data)) throw new Error("Cancellation status is unavailable. Refresh status.")
this.operation = data
this.emit()
} catch (error) { if (this.active && generation === this.generation) { this.actionError = error.message; this.emit() } }
finally { if (this.active && generation === this.generation) { this.busy = false; this.emit(); await this.loadStatus() } }
}
}
export const MapOperationsPanel = {
name: "MapOperationsPanel",
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ setup: null, catalog: null, operation: null, busy: false, error: "", search: "", review: null }),
created() { this.client = new MapOperationsClient({ publish: (update) => {
Object.assign(this.$data, update)
if (update.operation === null || update.error) this.review = null
}, unauthorized: () => { this.review = null; this.unauthorized() } }) },
mounted() {
this.visibility = () => { if (document.hidden) { this.review = null; this.client.stop() }
else if (this.mode === "local") this.client.start() }
document.addEventListener("visibilitychange", this.visibility)
if (this.mode === "local" && !document.hidden) this.client.start()
},
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.client.stop() },
computed: {
regions() {
const query = this.search.trim().toLocaleLowerCase()
return (this.catalog?.regions || []).filter((item) => !query ||
`${item.name} ${item.token}`.toLocaleLowerCase().includes(query))
},
running() { return ["transferring", "validating", "selecting"].includes(this.operation?.state) },
},
methods: {
formatBytes, formatBounds,
errorLabel(code) { return ERROR_LABELS[code] || "Review this operation in Galaxy" },
reviewRegion(region) {
if (!this.busy && region.available && this.operation)
this.review = { region, selectedGeneration: this.operation.selectedGeneration, ownerSession: this.operation.ownerSession }
},
async confirm() {
const review = this.review
this.review = null
if (review) await this.client.startRegion(review.region, review.selectedGeneration, review.ownerSession)
},
},
template: `
<section class="gx-card gx-map-manager">
<div class="gx-section__header"><i class="bi bi-map"></i><span class="gx-section__title">Offline Maps</span></div>
<div class="gx-map-manager__body">
<p class="gx-note">Choose a region to download for offline use. Downloading another region replaces the selected map.</p>
<p v-if="mode !== 'local'">Parked map management is unavailable in the offline preview.</p>
<template v-else>
<template v-if="setup"><p role="status">{{ setup.packageReady ? 'Map service ready.' : 'Map service setup required.' }} {{ setup.snapshotReady ? 'A downloaded map is selected.' : 'Choose a region after setup to download its maps.' }}</p>
<p v-if="!setup.packageReady" class="gx-note">{{ setup.packageState === 'invalid_package' ? 'The installed map service does not match this software’s verified source package.' : 'The verified map service package is missing.' }} Install a matching Mapd package through the device software update, then retry maps. Saved map selection is retained.</p>
<p class="gx-note">{{ formatBytes(setup.freeDiskBytes) }} available storage. {{ setup.parked ? 'Parked downloads available.' : 'Park to download maps.' }}</p></template>
<p v-if="error" role="alert">{{ error }}</p>
<button v-if="(error || setup && !setup.packageReady) && !busy" type="button" class="gx-btn gx-btn--tonal" @click="review=null; client.start()">Retry maps</button>
<p v-if="!operation" role="status">{{ error ? 'Map operation status unavailable.' : 'Loading map operation…' }}</p>
<template v-else>
<dl class="gx-map-manager__status"><dt>Operation</dt><dd>{{ operation.state }}</dd>
<dt>Selected map</dt><dd>{{ operation.selectedGeneration ? 'Saved selection' : 'None' }}</dd>
<dt v-if="operation.regionToken">Region</dt><dd v-if="operation.regionToken">{{ operation.regionToken }}</dd>
<dt v-if="running">Progress</dt><dd v-if="running">{{ operation.completedGroups }} of {{ operation.totalGroups }} areas prepared</dd>
<dt v-if="running">Transferred</dt><dd v-if="running">{{ formatBytes(operation.transferredBytes) }} of {{ formatBytes(operation.transferBudgetBytes) }} cap</dd></dl>
<p v-if="operation.selectedForNextShadowStart" class="gx-note">Download complete. This map will be used the next time Maps starts.</p>
<p v-if="operation.selectedGeneration && !operation.selectedForNextShadowStart" class="gx-note">A downloaded map is selected.</p>
<p v-if="operation.errorCode" class="gx-note">{{ errorLabel(operation.errorCode) }}.</p>
<button v-if="running" type="button" class="gx-btn gx-btn--tonal" :disabled="busy" @click="client.cancel()">Cancel operation</button>
</template>
<div class="gx-map-manager__picker"><label for="map-region-search">Search regions</label>
<input id="map-region-search" class="gx-field" type="search" v-model="search" placeholder="Country or US state"></div>
<p v-if="!catalog && setup?.packageReady" role="status">{{ error ? 'Map catalog unavailable.' : 'Loading map catalog…' }}</p>
<template v-if="catalog"><p class="gx-note">{{ regions.length }} regions shown · Up to {{ formatBytes(catalog.maxTransferBytes) }} transfer · {{ formatBytes(catalog.maxNewDiskBytes) }} new storage.</p>
<div class="gx-map-manager__regions">
<div v-for="region in regions" :key="region.token" class="gx-map-manager__region">
<div><strong>{{ region.name }}</strong><small>{{ region.token }} · {{ region.groups }} groups</small>
<small v-if="!region.available">Unavailable: {{ region.unavailable.replaceAll('_', ' ') }}</small></div>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy || running || !region.available || !operation" @click="reviewRegion(region)">Review…</button>
</div>
</div></template>
</template>
</div>
<Teleport to="body"><div v-if="review" class="gx-settings__modal" role="dialog" aria-modal="true" aria-label="Review map region">
<div class="gx-card gx-settings__dialog gx-map-manager__dialog"><h3>Replace Selected Map?</h3>
<p><strong>{{ review.region.name }}</strong> covers an approximate rectangle ({{ formatBounds(review.region.bounds) }}), {{ review.region.groups }} groups. This can transfer up to {{ formatBytes(catalog.maxTransferBytes) }} and use up to {{ formatBytes(catalog.maxNewDiskBytes) }} of new disk space. The result is selected for the next map service start.</p>
<div class="gx-settings__controls"><button type="button" class="gx-btn gx-btn--tonal" @click="review=null">Cancel</button>
<button type="button" class="gx-btn" :disabled="busy || running || !operation" @click="confirm">Start download</button></div>
</div>
</div></Teleport>
</section>`,
}
@@ -0,0 +1,185 @@
import { GalaxySelect } from "./galaxy-select.js"
import { ModelManagerFeed } from "./models.js"
export const laboratoryModels = data => data?.models || []
export const laboratoryReady = data => laboratoryModels(data).filter(m => m.small === true && m.modelLabEligible === true && m.modelLabArtifactAvailable === true && m.modelLabArtifactInstalled === true)
export const laboratoryStatusLabel = model => ({
unsupported: "Not supported", unpublished: "Not published", checking: "Checking download",
missing: "Not downloaded", "runtime-unavailable": "Downloaded", "gpu-unavailable": "Connect Chestnut",
installed: "Downloaded",
})[model.modelLabStatus] || (model.modelLabArtifactInstalled ? "Downloaded" : "Not downloaded")
export function laboratorySelectionError(data, configuration) {
if (!data) return "Waiting for parked device status"
if (data.isOnroad !== false) return "Turn off the vehicle before changing the model pair"
if (!data.runtimeSupported) return data.runtimeUnavailableReason || "Model Laboratory runtime is unavailable"
if (!data.chestnutReady) return "Connect a firmware-ready Chestnut to enable a pair"
const ready = laboratoryReady(data)
if (ready.length < 2) return "Download at least two eGPU variants to compose a pair"
if (!configuration.lateralModel || !configuration.longitudinalModel) return "Choose two downloaded eGPU variants"
if (configuration.lateralModel === configuration.longitudinalModel) return "Choose two different models"
if (![configuration.lateralModel, configuration.longitudinalModel].every(id => ready.some(m => m.value === id))) return "Both models must have downloaded eGPU variants"
return ""
}
export function laboratoryActionAllowed(data, action, model, configuration = data?.configuration) {
if (!data || data.capabilities?.[action === "enable" || action === "disable" ? "configure" : action] !== true) return false
if (action === "cancel") return data.download?.downloading === true && typeof data.download.jobId === "string"
if (data.isOnroad !== false || data.download?.downloading) return false
if (action === "enable") return !laboratorySelectionError(data, configuration)
if (action === "disable") return data.configuration.enabled === true
if (action === "download") return laboratoryModels(data).some(m => m.value === model?.value && m.small === true && m.modelLabEligible === true && m.modelLabArtifactAvailable === true && !m.modelLabArtifactInstalled)
if (action === "delete") return laboratoryReady(data).some(m => m.value === model?.value) &&
!(data.configuration.enabled && [data.configuration.lateralModel, data.configuration.longitudinalModel].includes(model.value))
return action === "refresh"
}
export function laboratoryDraft(data, draft, dirty) {
const configuration = { ...data.configuration, ...(dirty ? { lateralModel: draft.lateralModel, longitudinalModel: draft.longitudinalModel } : {}) }
if (!dirty) {
const ready = laboratoryReady(data)
if (!configuration.lateralModel) configuration.lateralModel = ready[0]?.value || ""
if (!configuration.longitudinalModel) configuration.longitudinalModel = ready.find(m => m.value !== configuration.lateralModel)?.value || ""
}
return configuration
}
function validPayload(data) {
return data?.schemaVersion === 1 && typeof data.isOnroad === "boolean" && typeof data.chestnutReady === "boolean" &&
typeof data.runtimeSupported === "boolean" && typeof data.configuration?.enabled === "boolean" &&
[data.configuration.lateralModel, data.configuration.longitudinalModel].every(id => typeof id === "string") &&
data.capabilities && typeof data.capabilities === "object" && typeof data.download?.downloading === "boolean" &&
typeof data.runtime?.active === "boolean" && typeof data.runtime?.requested === "boolean" && Array.isArray(data.models) &&
data.models.every(m => typeof m?.value === "string" && typeof m.label === "string") &&
new Set(data.models.map(m => m.value)).size === data.models.length
}
export class LaboratoryFeed extends ModelManagerFeed {
stop() {
if (this.expiry != null) this.cancel(this.expiry)
this.expiry = null
super.stop()
}
async load() {
if (!this.active || this.saving) return null
const data = await this.requestJson("laboratory")
if (!this.active) return null
if (data) {
this.data = validPayload(data) ? data : null
this.publish({ loading: false, data: this.data, error: this.data ? "" : "Invalid Model Laboratory response" })
if (this.expiry != null) this.cancel(this.expiry)
this.expiry = this.later(() => {
this.data = null
this.publish({ loading: false, data: null, error: "Laboratory status refresh delayed" })
}, 6000)
}
if (this.poll === null) this.poll = this.later(() => this.load(), 2000)
return this.data
}
async action(action, model = null, configuration) {
if (!this.active || this.saving || !laboratoryActionAllowed(this.data, action, model, configuration)) return null
const paths = { enable: "laboratory", disable: "laboratory", download: "laboratory/download", delete: "laboratory/delete", cancel: "cancel", refresh: "refresh_manifest" }
const payload = action === "enable" || action === "disable" ? { ...configuration, enabled: action === "enable" } :
action === "cancel" ? { jobId: this.data.download.jobId } : model ? { model: model.value } : {}
this.saving = true
const result = await this.requestJson(paths[action], payload)
if (!this.active) return null
this.saving = false
const error = this.lastError
await this.load()
if (error && this.active) this.publish({ loading: false, data: this.data, error })
return result
}
}
export const LaboratoryPage = {
components: { GalaxySelect },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ loading: true, status: null, error: "", message: "", busy: false, dirty: false, trackingProgress: false,
configuration: { enabled: false, lateralModel: "", longitudinalModel: "" } }),
computed: {
availableModels() { return laboratoryModels(this.status) },
readyModels() { return laboratoryReady(this.status) },
candidates() { return this.readyModels.filter(m => m.value !== this.configuration.lateralModel) },
selectionError() { return laboratorySelectionError(this.status, this.configuration) },
runtimeState() { return !this.status ? "Status unavailable" : !this.status.runtimeSupported ? "Unavailable" : this.status.runtime.active ? "Pair active" : this.status.runtime.requested ? "Pair requested" : "Inactive" },
},
created() {
this.feed = new LaboratoryFeed({ unauthorized: this.unauthorized, publish: update => {
this.loading = update.loading
this.error = update.error
this.status = update.data
if (update.data) {
this.configuration = laboratoryDraft(update.data, this.configuration, this.dirty)
if (this.trackingProgress && update.data.download.progress) {
this.message = update.data.download.progress
if (!update.data.download.downloading) this.trackingProgress = false
}
}
} })
},
mounted() {
this.visibility = () => { if (document.hidden) this.feed.stop(); else if (this.mode === "local") this.feed.start() }
document.addEventListener("visibilitychange", this.visibility)
if (this.mode === "local" && !document.hidden) this.feed.start()
},
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed.stop() },
methods: {
statusLabel: laboratoryStatusLabel,
can(action, model = null) { return !this.busy && laboratoryActionAllowed(this.status, action, model, this.configuration) },
modelLabel(id) { return this.status?.models.find(m => m.value === id)?.label || id || "Not selected" },
changeLateral(value) {
this.configuration.lateralModel = value
if (this.configuration.longitudinalModel === value) this.configuration.longitudinalModel = this.candidates[0]?.value || ""
this.dirty = true
},
async act(action, model = null) {
if (!this.can(action, model)) return
if (action === "delete" && !window.confirm(`Delete the eGPU variant for "${model.label}"? The normal on-device model will not be removed.`)) return
this.busy = true
this.message = ""
try {
const result = await this.feed.action(action, model, this.configuration)
if (result) {
this.message = result.message || "Updated"
this.trackingProgress = action === "download" || action === "cancel"
if (["enable", "disable", "delete"].includes(action)) {
this.dirty = false
if (this.status) this.configuration = laboratoryDraft(this.status, this.configuration, false)
}
}
} finally { this.busy = false }
},
},
template: `
<div class="gx-settings gx-models">
<section class="gx-card">
<div class="gx-section__header"><i class="bi bi-bezier2"></i><span class="gx-section__title">Model Laboratory</span></div>
<div style="padding:var(--sp-4);display:grid;gap:var(--sp-3)"><span class="gx-row__desc">Use lateral judgment from one model and longitudinal judgment from another.</span><div style="display:flex;gap:8px;flex-wrap:wrap"><span class="gx-chip">{{ status ? (status.chestnutReady ? 'Chestnut ready' : 'Chestnut required') : 'Waiting for device status' }}</span><span v-if="status" class="gx-chip">{{ status.isOnroad ? 'Onroad' : 'Parked' }}</span></div></div>
<p v-if="mode !== 'local'" class="gx-note" style="margin:var(--sp-4)">Model Laboratory is available on the device. Preview does not configure a pair.</p>
<p v-else-if="loading" class="gx-note" style="margin:var(--sp-4)">Loading Model Laboratory…</p>
<p v-if="error" class="gx-note gx-note--danger" style="margin:var(--sp-4)" role="alert">{{ error }}</p>
<p v-if="status?.configurationError" class="gx-note gx-note--danger">{{ status.configurationError }}</p>
<p v-if="message" class="gx-note" style="margin:var(--sp-4)" role="status">{{ message }}</p>
</section>
<section class="gx-card">
<div class="gx-section__header"><i class="bi bi-download"></i><span class="gx-section__title">Available models</span><span class="gx-chip">{{ availableModels.length }} catalog models · {{ status?.summary?.published || 0 }} Chestnut versions published · {{ readyModels.length }} downloads verified</span></div>
<p class="gx-note" style="margin:var(--sp-4)">Browse Small and Big models here. Combining models requires compatible Chestnut downloads and support for running them together.</p>
<p v-if="status && !availableModels.length" class="gx-note" style="margin:var(--sp-4)">The model catalog is unavailable. Refresh to retry.</p>
<article v-for="m in availableModels" :key="m.value" class="gx-row" style="flex-wrap:wrap;gap:12px"><div class="gx-row__info" style="flex:1 1 180px;min-width:0"><span class="gx-row__label">{{ m.label }}</span><span class="gx-row__desc">{{ m.value }} · {{ m.small ? 'Small' : 'Big' }} · {{ m.series || 'Unknown series' }} · {{ m.version }}</span><span class="gx-row__desc">{{ m.modelLabReason || 'Pair artifact status unavailable' }}</span></div><div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;min-width:0"><span class="gx-chip">{{ statusLabel(m) }}</span><button v-if="m.small && m.modelLabEligible && m.modelLabArtifactAvailable && !m.modelLabArtifactInstalled" class="gx-btn gx-btn--tonal" :disabled="!can('download',m)" @click="act('download',m)">Download eGPU variant</button><button v-else-if="m.modelLabArtifactInstalled" class="gx-btn gx-btn--tonal" :disabled="!can('delete',m)" @click="act('delete',m)">Delete eGPU variant</button></div></article>
<div v-if="status?.download.downloading" class="gx-row"><div class="gx-row__info"><span class="gx-row__desc">{{ status.download.progress || 'Downloading…' }}</span></div><button class="gx-btn gx-btn--tonal" :disabled="!can('cancel')" @click="act('cancel')">Cancel download</button></div>
</section>
<section class="gx-card">
<div class="gx-section__header"><i class="bi bi-collection"></i><span class="gx-section__title">Compose a pair</span><span class="gx-chip">{{ configuration.enabled ? 'Enabled' : 'Disabled' }}</span></div>
<div style="padding:var(--sp-4);display:grid;gap:var(--sp-3);min-width:0">
<label style="display:grid;gap:4px;min-width:0"><strong>Lateral model</strong><small>Path shape, curvature, lane geometry, and driving desire</small><GalaxySelect class="gx-field" aria-label="Lateral model" :disabled="!status || busy || status.isOnroad || status.download.downloading" :value="configuration.lateralModel" @change="changeLateral($event.target.value)"><option value="">Choose a model</option><option v-for="m in readyModels" :key="m.value" :value="m.value">{{ m.label }} · {{ m.version }}</option></GalaxySelect></label>
<label style="display:grid;gap:4px;min-width:0"><strong>Longitudinal model</strong><small>Speed, acceleration, stopping, leads, and scene confidence</small><GalaxySelect class="gx-field" aria-label="Longitudinal model" :disabled="!status || busy || status.isOnroad || status.download.downloading" :value="configuration.longitudinalModel" @change="configuration.longitudinalModel=$event.target.value;dirty=true"><option value="">Choose a model</option><option v-for="m in candidates" :key="m.value" :value="m.value">{{ m.label }} · {{ m.version }}</option></GalaxySelect></label>
<p class="gx-row__desc">{{ modelLabel(configuration.lateralModel) }} steers · {{ modelLabel(configuration.longitudinalModel) }} paces</p>
<p v-if="selectionError" class="gx-row__desc">{{ selectionError }}</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;min-width:0"><button class="gx-btn" :disabled="!can('enable')" @click="act('enable')">Enable for next drive</button><button class="gx-btn gx-btn--tonal" :disabled="!can('disable')" @click="act('disable')">Disable</button><button class="gx-btn gx-btn--tonal" :disabled="!can('refresh')" @click="act('refresh')">Refresh</button></div>
</div>
</section>
<section class="gx-card"><div class="gx-section__header"><i class="bi bi-activity"></i><span class="gx-section__title">Runtime</span><span class="gx-chip">{{ runtimeState }}</span></div><div class="gx-row"><div class="gx-row__info"><span class="gx-row__desc">Lateral: {{ modelLabel(status?.runtime.lateralModel) }}</span><span class="gx-row__desc">Longitudinal: {{ modelLabel(status?.runtime.longitudinalModel) }}</span><span v-if="status?.runtime.error" class="gx-note gx-note--danger">{{ status.runtime.error }}</span><span v-if="status && !status.runtimeSupported" class="gx-row__desc">{{ status.runtimeUnavailableReason || 'Pair runtime is unavailable.' }}</span></div></div></section>
</div>`,
}
+584
View File
@@ -0,0 +1,584 @@
import { GalaxySelect } from "./galaxy-select.js"
// Read-only model identity; a saved label never proves what modeld loaded.
export class ModelStatusFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancel = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancel })
this.active = false
this.generation = 0
this.displayGeneration = 0
this.request = null
this.poll = null
this.expiry = null
}
stop() {
this.active = false
this.generation++
this.displayGeneration++
this.request?.abort()
this.request = null
if (this.poll !== null) this.cancel(this.poll)
if (this.expiry !== null) this.cancel(this.expiry)
this.poll = this.expiry = null
this.publish({ status: "idle", data: null, error: "" })
}
start() {
this.stop()
this.active = true
return this.load()
}
async load() {
if (!this.active) return
const generation = ++this.generation
this.request?.abort()
if (this.poll !== null) this.cancel(this.poll)
this.poll = null
const request = new AbortController()
this.request = request
const timeout = this.later(() => request.abort(), 2500)
try {
const response = await this.fetcher("./api/models/status", { signal: request.signal, cache: "no-store" })
if (!this.active || generation !== this.generation || request.signal.aborted) return
if (response.status === 401) {
this.stop()
this.unauthorized()
return
}
if (!response.ok) throw new Error("Model status is unavailable")
const data = await response.json()
if (!this.active || generation !== this.generation || request.signal.aborted) return
const states = ["unavailable", "loading", "identity-unavailable", "active", "stale", "failed"]
const variants = [null, "small", "chestnut"]
const digest = data?.artifactSha256
const ids = Array.isArray(data?.catalog) ? data.catalog.map(m => m?.id) : []
if (data?.schemaVersion !== 1 || !ids.length || new Set(ids).size !== ids.length ||
data.catalog.some(m => typeof m.id !== "string" || !m.id || typeof m.name !== "string" || typeof m.selectable !== "boolean") ||
!ids.includes(data.requestedId) || !(data.loadedId === null || ids.includes(data.loadedId)) || !variants.includes(data.variant) ||
!states.includes(data.health) || ![null, "chestnut-load-failed", "chestnut-run-stalled", "selected-load-failed"].includes(data.fallbackReason) ||
!(digest === null || typeof digest === "string" && /^[0-9a-f]{64}$/.test(digest)) ||
typeof data.pendingNextStart !== "boolean" ||
(data.pendingNextStart && (data.loadedId === null || data.requestedId === data.loadedId)) ||
(data.loadedId === null ? data.variant !== null || digest !== null : data.variant === null || digest === null) ||
(data.health === "active" && data.loadedId === null)) {
throw new Error("Invalid model status")
}
this.publish({ status: "ready", data, error: "" })
if (this.expiry !== null) this.cancel(this.expiry)
const displayGeneration = ++this.displayGeneration
this.expiry = this.later(() => {
if (this.active && displayGeneration === this.displayGeneration) {
this.publish({ status: "stale", data: null, error: "Model status refresh delayed" })
}
}, 1500)
} catch (error) {
if (this.active && generation === this.generation) {
this.displayGeneration++
if (this.expiry !== null) this.cancel(this.expiry)
this.expiry = null
this.publish({ status: "unavailable", data: null, error: error.message })
}
} finally {
this.cancel(timeout)
if (generation === this.generation) this.request = null
if (this.active && generation === this.generation) this.poll = this.later(() => this.load(), 1000)
}
}
}
const FILTER_KEY = "galaxy.modelManager.hardwareFilter";
const FILTERS = new Set(["both", "gpu", "comma"]);
function readHardwareFilter() {
try {
const value = localStorage.getItem(FILTER_KEY);
return FILTERS.has(value) ? value : "both";
} catch {
return "both";
}
}
function saveHardwareFilter(value) {
const filter = FILTERS.has(value) ? value : "both";
try { localStorage.setItem(FILTER_KEY, filter); } catch {}
return filter;
}
function matchesHardware(model, filter) {
if (filter === "gpu") return model?.requiresGpu === true;
if (filter === "comma") return model?.requiresGpu === false;
return true;
}
function hardwareLabel(model) {
if (model?.requiresGpu === true) return "GPU · External";
if (model?.requiresGpu === false) return "Comma · On-device";
return "Hardware unknown";
}
function bytes(value) {
return Number.isSafeInteger(value) && value > 0 ? value : null;
}
function formatBytes(value) {
return value >= 1e9 ? `${(value / 1e9).toFixed(2)} GB` : `${(value / 1e6).toFixed(1)} MB`;
}
function fileSizeText(model) {
const installed = bytes(model?.fileSizeBytes);
const declared = bytes(model?.declaredSizeBytes || model?.artifactSize);
const downloaded = bytes(model?.downloadedBytes);
if (model?.partial || model?.sizeStatus === "partial") {
return `Partial: ${downloaded ? formatBytes(downloaded) : "unknown"} / ${declared ? formatBytes(declared) : "unknown"}`;
}
if (installed && declared && installed !== declared) return `${formatBytes(installed)} · size mismatch`;
if (installed) return formatBytes(installed);
if (declared) return `${formatBytes(declared)} · declared`;
return "Unavailable";
}
const actionPaths = { "select-small": "active", "select-big": "active", favorite: "preferences", unfavorite: "preferences",
"enable-randomizer": "preferences", "disable-randomizer": "preferences", exclude: "preferences", include: "preferences",
download: "download", downloadAll: "download_all", cancel: "cancel", delete: "delete", refresh: "refresh_manifest" }
const actionCapabilities = { "select-small": "select", "select-big": "select", favorite: "favorites", unfavorite: "favorites",
"enable-randomizer": "randomizer", "disable-randomizer": "randomizer", exclude: "exclusions", include: "exclusions",
download: "download", downloadAll: "downloadAll", cancel: "cancel", delete: "delete", refresh: "refresh" }
export function fitsModelProfile(model, profile) {
return Array.isArray(model?.profiles) ? model.profiles.includes(profile) : profile === "big" ? model?.requiresGpu === true : model?.requiresGpu === false
}
export function modelActionAllowed(data, action, model = null) {
if (!data || data.capabilities?.[actionCapabilities[action]] !== true) return false
if (data.isOnroad !== false && !["favorite", "unfavorite", "cancel"].includes(action)) return false
if (["select-small", "select-big"].includes(action)) {
if (data.downloading || data.randomizer === true) return false
if (action === "select-big" && model === null) return true
return model?.installed === true && model.selectable === true && fitsModelProfile(model, action === "select-big" ? "big" : "small")
}
if (action === "download") return !data.downloading && !!model && !model.installed && model.downloadAvailable === true
if (action === "downloadAll") return !data.downloading && data.models.some(m => !m.installed && m.downloadAvailable === true)
if (action === "cancel") return data.downloading === true
if (action === "delete") return !data.downloading && !!model && !model.builtin && model.deletable !== false &&
(model.installed || model.partial) && ![data.currentModel, data.activeSmallModel, data.activeBigModel].includes(model.value)
if (["exclude", "include"].includes(action)) return !!model
if (["enable-randomizer", "disable-randomizer"].includes(action)) return !data.downloading
return ["favorite", "unfavorite"].includes(action) ? !!model : action === "refresh" && !data.downloading
}
export class ModelManagerFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancel = id => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancel })
this.active = false
this.generation = 0
this.request = this.poll = null
this.data = null
this.saving = false
this.lastError = ""
}
stop() {
this.active = false
this.generation++
this.request?.abort()
this.request = null
if (this.poll !== null) this.cancel(this.poll)
this.poll = null
this.data = null
this.saving = false
this.publish({ loading: false, data: null, error: "" })
}
start() {
this.stop()
this.active = true
return this.load()
}
async requestJson(path, payload) {
const generation = ++this.generation
this.lastError = ""
this.request?.abort()
if (this.poll !== null) this.cancel(this.poll)
this.poll = null
const request = new AbortController()
this.request = request
const timeout = this.later(() => {
if (!this.active || generation !== this.generation) return
request.abort()
this.generation++
this.request = null
this.data = null
this.saving = false
this.publish({ loading: false, data: null, error: payload ? "The result is unknown. Refresh before trying again." : "Model Manager is unavailable. Retrying…" })
this.poll = this.later(() => this.load(), 2000)
}, 10000)
try {
const response = await this.fetcher(`./api/models/${path}`, { signal: request.signal, cache: "no-store", credentials: "same-origin",
...(payload ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) } : {}) })
if (!this.active || generation !== this.generation || request.signal.aborted) return null
const data = await response.json()
if (!this.active || generation !== this.generation || request.signal.aborted) return null
if (response.status === 401 || ["access_unavailable", "setup_required"].includes(data?.code)) {
this.stop()
this.unauthorized()
return null
}
if (!response.ok) throw new Error(data?.message || data?.error || "Model request failed")
return data
} catch (error) {
if (this.active && generation === this.generation) {
this.data = null
this.lastError = error.message || "Model Manager is unavailable"
this.publish({ loading: false, data: null, error: this.lastError })
}
return null
} finally {
this.cancel(timeout)
if (this.request === request) this.request = null
}
}
async load() {
if (!this.active || this.saving) return null
if (!this.data) this.publish({ loading: true, data: null, error: "" })
const data = await this.requestJson("manager")
if (!this.active) return null
if (data) {
if (data.schemaVersion !== 1 || !Array.isArray(data.models) ||
data.models.some(m => !m || typeof m.value !== "string" || typeof m.label !== "string") ||
typeof data.isOnroad !== "boolean" || typeof data.capabilities !== "object" || data.capabilities === null) {
this.data = null
this.publish({ loading: false, data: null, error: "Invalid Model Manager response" })
} else {
this.data = data
this.publish({ loading: false, data, error: "" })
}
}
if (this.poll === null) this.poll = this.later(() => this.load(), 2000)
return this.data
}
async action(action, model = null, extra = {}) {
if (!this.active || this.saving || !this.data) return null
if (model !== null) {
model = this.data.models.find(m => m.value === model.value)
if (!model) return null
}
if (!modelActionAllowed(this.data, action, model)) return null
const data = this.data
const key = model?.value || ""
let payload = {}
if (action.startsWith("select-")) payload = { profile: action === "select-big" ? "big" : "small", model: key }
else if (["favorite", "unfavorite"].includes(action)) {
const userFavorites = data.models.filter(m => m.userFavorite && m.value !== key).map(m => m.value)
if (action === "favorite") userFavorites.push(key)
payload = { userFavorites }
} else if (["exclude", "include"].includes(action)) {
const blacklistedModels = (data.blacklistedModels || []).filter(value => value !== key)
if (action === "exclude") blacklistedModels.push(key)
payload = { blacklistedModels }
} else if (["enable-randomizer", "disable-randomizer"].includes(action)) payload = { randomizer: action === "enable-randomizer" }
else if (["download", "delete"].includes(action)) payload = { model: key }
if (["download", "downloadAll"].includes(action)) payload.allowGpuWithoutGpu = extra.allowGpuWithoutGpu === true
if (typeof data.view === "string") payload.view = data.view
this.saving = true
this.data = null
const result = await this.requestJson(actionPaths[action], payload)
if (!this.active) return null
this.saving = false
const error = this.lastError
await this.load()
if (error && this.active) this.publish({ loading: false, data: this.data, error })
return result
}
}
export const ModelsPage = {
name: "ModelsPage",
components: { GalaxySelect },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data() {
return { loading: true, error: "", message: "", trackingProgress: false, busy: "", selectionUncertain: true, disposed: false,
sortMode: "release_date", userFilter: "all", communityFilter: "all", hardwareFilter: readHardwareFilter(),
models: [], currentModel: "", activeSmallModel: "", activeBigModel: "", capabilities: {},
summary: { installed: 0, missing: 0, total: 0 }, status: { isOnroad: true },
runtime: { status: "idle", data: null, error: "" }, dialog: null }
},
computed: {
currentLabel() { return this.models.find(m => m.value === this.currentModel)?.label || this.currentModel || "none" },
installedSmallModels() { return this.models.filter(m => (m.value === this.activeSmallModel || m.installed && m.selectable) && fitsModelProfile(m, "small")) },
installedBigModels() { return this.models.filter(m => (m.value === this.activeBigModel || m.installed && m.selectable) && fitsModelProfile(m, "big")) },
sorted() {
return this.models.filter(m => matchesHardware(m, this.hardwareFilter))
.filter(m => this.userFilter === "all" || !!m.userFavorite === (this.userFilter === "yes"))
.filter(m => this.communityFilter === "all" || !!m.communityFavorite === (this.communityFilter === "yes"))
.sort((a, b) => (this.sortMode === "release_date" ? (Date.parse(b.released) || 0) - (Date.parse(a.released) || 0) : 0) ||
(a.label || a.value).localeCompare(b.label || b.value, undefined, { sensitivity: "base", numeric: true }))
},
downloadTargetLabel() { return this.status.downloadAll ? "all missing models" : this.models.find(m => m.value === this.status.modelToDownload)?.label || "a model" },
},
created() {
this.feed = new ModelStatusFeed({ publish: update => { this.runtime = update }, unauthorized: this.unauthorized })
this.manager = new ModelManagerFeed({ publish: update => {
this.loading = update.loading
this.error = update.error
this.selectionUncertain = !update.data
this.capabilities = update.data?.capabilities || {}
if (update.data) {
const p = update.data
this.models = p.models
this.currentModel = p.currentModel || ""
this.activeSmallModel = p.activeSmallModel || ""
this.activeBigModel = p.activeBigModel || ""
this.summary = p.summary || { installed: 0, missing: 0, total: 0 }
this.status = p
if (this.trackingProgress && p.progress) {
this.message = p.progress
if (!p.downloading) this.trackingProgress = false
}
}
}, unauthorized: this.unauthorized })
},
mounted() {
this.visibility = () => {
if (document.hidden) { this.finishDialog(false); this.feed.stop(); this.manager.stop() }
else if (this.mode === "local") { this.feed.start(); this.manager.start() }
}
document.addEventListener("visibilitychange", this.visibility)
if (this.mode === "local" && !document.hidden) { this.feed.start(); this.manager.start() }
},
beforeUnmount() {
this.disposed = true
this.finishDialog(false)
document.removeEventListener("visibilitychange", this.visibility)
this.feed.stop()
this.manager.stop()
},
methods: {
hardwareLabel, fileSizeText,
setHardwareFilter(value) { this.hardwareFilter = saveHardwareFilter(value) },
canAction(action, model = null) { return !this.busy && !this.selectionUncertain && modelActionAllowed(this.status, action, model) },
rowState(model) {
if ([this.currentModel, this.activeSmallModel, this.activeBigModel].includes(model.value)) return "active"
if (this.status.downloading) return this.status.downloadAll || this.status.modelToDownload === model.value ? "cancellable" : "busy"
return model.installed ? "installed" : "available"
},
confirmAction(dialog) {
return new Promise(resolve => {
this.dialog = dialog
this.resolveDialog = resolve
this.$nextTick(() => this.$refs.confirmButton?.focus())
})
},
finishDialog(value) {
this.resolveDialog?.(value)
this.resolveDialog = null
this.dialog = null
},
async runAction(action, model = null) {
if (this.disposed || !this.canAction(action, model)) return
this.busy = `${action}:${model?.value || ""}`
this.message = ""
this.trackingProgress = false
try {
let allowGpuWithoutGpu = false
if (action === "enable-randomizer" && !await this.confirmAction({ title: "Model Randomizer", message: "Choose a different installed, verified model each start. Excluded models are skipped. Favorites do not change the random pool.", confirmLabel: "Enable" })) return
if (action === "delete" && !await this.confirmAction({ title: "Delete Model", message: `Delete local files for "${model.label}"?`, confirmLabel: "Delete", danger: true })) return
const needsGpu = action === "download" ? model.requiresGpu && !model.gpuAvailable : action === "downloadAll" &&
this.models.some(m => !m.installed && m.requiresGpu && !m.gpuAvailable && m.downloadAvailable === true)
if (needsGpu) {
allowGpuWithoutGpu = await this.confirmAction({ title: "No External GPU Detected", confirmLabel: "Download Anyway",
message: "These model files require an external GPU. You can download them now, but they cannot run until a compatible GPU is connected and detected. GPU model files can be large. Downloading does not activate a model." })
if (!allowGpuWithoutGpu) return
}
if (this.disposed) return
const result = await this.manager.action(action, model, { allowGpuWithoutGpu })
if (!this.disposed && result) {
this.trackingProgress = ["download", "downloadAll", "refresh"].includes(action)
this.message = (this.trackingProgress && this.status.progress) || result.message ||
(action.startsWith("select-") ? "Selection saved for the next start." : "Saved.")
}
} finally { if (!this.disposed) this.busy = "" }
},
},
template: `
<div class="gx-view gx-model-manager">
<div v-if="mode !== 'local'" class="gx-card gx-message">Model Manager is unavailable in the offline preview.</div>
<div v-else-if="loading" class="gx-card">
<div class="gx-loading" style="padding: var(--sp-4);">Loading models...</div>
</div>
<template v-else>
<section class="gx-card">
<div class="gx-section__header">
<i aria-hidden="true" class="bi bi-cpu"></i>
<span class="gx-section__title">Model Manager</span>
</div>
<div style="padding: var(--sp-3); display:flex; flex-wrap:wrap; gap:6px;">
<span class="gx-chip">{{ summary.installed }} installed</span>
<span class="gx-chip">{{ summary.missing }} missing</span>
<span class="gx-chip">{{ summary.total }} total</span>
<span class="gx-chip" style="background:var(--primary);color:var(--on-primary);">Selected: {{ currentLabel }}</span>
</div>
<div style="padding: 0 var(--sp-3) var(--sp-3);">
<p v-if="message" role="status">{{ message }}</p>
<div v-if="error" role="alert" class="gx-alert gx-alert--warn" style="border:none; margin:0 0 8px;">
<i aria-hidden="true" class="bi bi-exclamation-triangle-fill gx-alert__icon"></i>
<div class="gx-alert__body"><strong>Model Manager</strong><span>{{ error }}</span></div>
</div>
<div v-if="status.isOnroad" class="gx-alert gx-alert--warn" style="border:none; margin:0 0 8px;">
<i aria-hidden="true" class="bi bi-car-front-fill gx-alert__icon"></i>
<div class="gx-alert__body"><span>Model changes and downloads require parked device status.</span></div>
</div>
<div v-if="status.downloading" class="gx-alert gx-alert--info" style="border:none; margin:0;">
<i aria-hidden="true" class="bi bi-arrow-repeat gx-spin gx-alert__icon"></i>
<div class="gx-alert__body">
<strong>Downloading {{ downloadTargetLabel }}</strong>
<span v-if="status.progress">{{ status.progress }}</span>
</div>
</div>
</div>
</section>
<section class="gx-card">
<div class="gx-section__header">
<i aria-hidden="true" class="bi bi-sliders"></i>
<span class="gx-section__title">Controls</span>
</div>
<p class="gx-model-reason">Selections take effect the next time the driving model starts.</p>
<div style="padding: var(--sp-3); display:grid; grid-template-columns:minmax(0, 1fr); gap:12px;">
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<button v-if="status.downloading" type="button" class="gx-btn gx-btn--danger" :disabled="!canAction('cancel')" @click="runAction('cancel')"><i aria-hidden="true" class="bi bi-stop-circle"></i> Cancel Download</button>
<button v-else type="button" class="gx-btn" :disabled="!canAction('downloadAll')" @click="runAction('downloadAll')"><i aria-hidden="true" class="bi bi-download"></i> Download Missing Models</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!canAction('refresh')" @click="runAction('refresh')"><i aria-hidden="true" v-if="busy === 'refresh:'" class="bi bi-arrow-repeat gx-spin"></i><i aria-hidden="true" v-else class="bi bi-arrow-clockwise"></i> Refresh</button>
</div>
<div class="gx-row" style="border-top:none;">
<span class="gx-row__label">Model Randomizer</span>
<button type="button" class="gx-btn gx-btn--tonal" :aria-pressed="status.randomizer === true" :disabled="!canAction(status.randomizer ? 'disable-randomizer' : 'enable-randomizer')" @click="runAction(status.randomizer ? 'disable-randomizer' : 'enable-randomizer')">{{ status.randomizer ? 'On' : 'Off' }}</button>
</div>
<p v-if="status.randomizer" class="gx-model-reason">A different verified model is chosen each start. {{ status.gpuAvailable && activeBigModel ? 'Chestnut Big' : 'Small on-device' }} models are used. {{ (status.blacklistedModels || []).length }} excluded. Favorites only mark your preferred models.</p>
<p v-if="!status.gpuAvailable" class="gx-model-reason">Chestnut is not detected. Active Small is used; your Big selection is saved for later.</p>
<div class="gx-row gx-model-control" style="border-top:none;">
<span class="gx-row__label">Active Small · On-device</span>
<GalaxySelect aria-label="Active Small" class="gx-field" :value="activeSmallModel" :disabled="!!busy || selectionUncertain || capabilities.select !== true || status.isOnroad || status.randomizer" @change="runAction('select-small', installedSmallModels.find(m => m.value === $event.target.value))">
<option v-for="m in installedSmallModels" :key="m.value" :value="m.value" :disabled="!m.installed || !m.selectable">{{ m.label || m.value }}</option>
</GalaxySelect>
</div>
<div class="gx-row gx-model-control" style="border-top:none;">
<span class="gx-row__label">Active Big · Chestnut</span>
<GalaxySelect aria-label="Active Big" class="gx-field" :value="activeBigModel" :disabled="!!busy || selectionUncertain || capabilities.select !== true || status.isOnroad || status.randomizer" @change="$event.target.value ? runAction('select-big', installedBigModels.find(m => m.value === $event.target.value)) : runAction('select-big')">
<option value="">None — always use Active Small</option>
<option v-for="m in installedBigModels" :key="m.value" :value="m.value" :disabled="!m.installed || !m.selectable">{{ m.label || m.value }}</option>
</GalaxySelect>
</div>
<div class="gx-row gx-model-control" style="border-top:none;">
<span class="gx-row__label">Sort</span>
<GalaxySelect aria-label="Sort models" class="gx-field" :value="sortMode" @change="sortMode = $event.target.value">
<option value="release_date">Release Date</option>
<option value="alphabetical">Alphabetical</option>
</GalaxySelect>
</div>
<div class="gx-row gx-model-control" style="border-top:none;">
<label class="gx-row__label" for="gx-model-hardware">Model hardware</label>
<GalaxySelect id="gx-model-hardware" class="gx-field" :value="hardwareFilter" @change="setHardwareFilter($event.target.value)">
<option value="both">Both</option>
<option value="gpu">GPU models only</option>
<option value="comma">Comma models only</option>
</GalaxySelect>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<GalaxySelect aria-label="Your Favorite filter" class="gx-field" style="flex:1; min-width:140px;" :value="userFilter" @change="userFilter = $event.target.value">
<option value="all">Your Favorite: All</option>
<option value="yes">Your Favorite: Yes</option>
<option value="no">Your Favorite: No</option>
</GalaxySelect>
<GalaxySelect aria-label="Community Favorite filter" class="gx-field" style="flex:1; min-width:140px;" :value="communityFilter" @change="communityFilter = $event.target.value">
<option value="all">Community: All</option>
<option value="yes">Community: Yes</option>
<option value="no">Community: No</option>
</GalaxySelect>
</div>
</div>
</section>
<template v-if="!sorted.length">
<div class="gx-card"><div class="gx-empty">No models match these filters. Try Both or clear the favourite filters.</div></div>
</template>
<template v-else>
<div class="gx-card-grid">
<section class="gx-card" v-for="m in sorted" :key="m.value">
<div style="display:flex; align-items:flex-start; gap:8px; padding: var(--sp-3);">
<div style="flex:1; min-width:0;">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
<strong>{{ m.label || m.value }}</strong>
<span v-if="m.userFavorite" class="gx-chip">Your Favorite</span>
<span v-if="m.communityFavorite" class="gx-chip">Community Favorite</span>
<span v-if="m.blacklisted" class="gx-chip">Excluded from randomizer</span>
</div>
<div style="margin-top:6px; display:flex; flex-wrap:wrap; gap:6px;">
<span class="gx-chip">{{ m.value }}</span>
<span v-if="m.builtin" class="gx-chip">Built-in</span>
<span class="gx-chip">{{ hardwareLabel(m) }}</span>
<span v-if="m.version" class="gx-chip">Version {{ m.version }}</span>
<span v-if="m.released" class="gx-chip">Released {{ m.released }}</span>
<span v-if="m.partial" class="gx-chip">Partial Files</span>
<span class="gx-chip">File size: {{ fileSizeText(m) }}</span>
</div>
</div>
<button type="button" class="gx-icon-btn" :disabled="!canAction(m.userFavorite ? 'unfavorite' : 'favorite', m)" :aria-label="(m.userFavorite ? 'Remove from your favorites: ' : 'Add to your favorites: ') + m.label" :title="m.userFavorite ? 'Remove from your favorites' : 'Add to your favorites'" @click="runAction(m.userFavorite ? 'unfavorite' : 'favorite', m)">
<i aria-hidden="true" class="bi" :class="m.userFavorite ? 'bi-star-fill' : 'bi-star'"></i>
</button>
</div>
<p v-if="m.unavailableReason" class="gx-model-reason">{{ m.unavailableReason }}</p>
<div style="padding: 0 var(--sp-3) var(--sp-3); display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
<button v-if="status.randomizer || m.blacklisted" type="button" class="gx-btn gx-btn--tonal" :disabled="!canAction(m.blacklisted ? 'include' : 'exclude', m)" @click="runAction(m.blacklisted ? 'include' : 'exclude', m)">{{ m.blacklisted ? 'Include in Randomizer' : 'Exclude from Randomizer' }}</button>
<template v-if="rowState(m) === 'active'">
<span class="gx-chip" style="background:var(--primary);color:var(--on-primary);">Selected model</span>
</template>
<template v-else-if="rowState(m) === 'busy'">
<span class="gx-chip"><i aria-hidden="true" class="bi bi-hourglass-split"></i> Busy</span>
</template>
<template v-else-if="rowState(m) === 'cancellable'">
<button type="button" class="gx-btn gx-btn--danger" :disabled="!canAction('cancel', m)" @click="runAction('cancel', m)"><i aria-hidden="true" class="bi bi-x-circle"></i> Cancel</button>
</template>
<template v-else-if="rowState(m) === 'installed'">
<button type="button" class="gx-btn" :disabled="!canAction(m.requiresGpu ? 'select-big' : 'select-small', m)" @click="runAction(m.requiresGpu ? 'select-big' : 'select-small', m)"><i aria-hidden="true" class="bi bi-play-fill"></i> Set Active {{ m.requiresGpu ? 'Big' : 'Small' }}</button>
<button v-if="!m.builtin" type="button" class="gx-btn gx-btn--tonal" style="color:var(--error);" :disabled="!canAction('delete', m)" @click="runAction('delete', m)"><i aria-hidden="true" class="bi bi-trash"></i> Delete</button>
</template>
<template v-else>
<button type="button" class="gx-btn" :disabled="!canAction('download', m)" @click="runAction('download', m)"><i aria-hidden="true" class="bi bi-download"></i> Download</button>
</template>
</div>
</section>
</div>
</template>
<section class="gx-card gx-model-runtime">
<div class="gx-section__header"><i aria-hidden="true" class="bi bi-cpu"></i><span class="gx-section__title">Running model</span></div>
<div class="gx-model-runtime__body" v-if="runtime.status === 'ready' && runtime.data">
<p>{{ runtime.data.health.replaceAll('-', ' ') }} · {{ runtime.data.loadedId ? (runtime.data.variant === 'chestnut' ? 'Chestnut big' : 'Small') : 'No verified load receipt' }}</p>
<p v-if="runtime.data.loadedId">{{ runtime.data.loadedId }} · {{ runtime.data.artifactSha256.slice(0,16) }}…</p>
<p v-if="runtime.data.pendingNextStart">A saved selection is waiting for the next start.</p>
<p v-if="runtime.data.fallbackReason">Fallback: {{ runtime.data.fallbackReason.replaceAll('-', ' ') }}</p>
</div>
<p v-else class="gx-model-reason">{{ runtime.error || 'Checking the running model…' }}</p>
</section>
</template>
<div v-if="dialog" class="gx-settings__modal" @click.self="finishDialog(false)" @keydown.esc="finishDialog(false)">
<section class="gx-card gx-settings__dialog" role="dialog" aria-modal="true" aria-labelledby="gx-model-dialog-title">
<h3 id="gx-model-dialog-title">{{ dialog.title }}</h3><p>{{ dialog.message }}</p>
<div class="gx-settings__controls"><button class="gx-btn gx-btn--tonal" type="button" @click="finishDialog(false)">Cancel</button><button ref="confirmButton" class="gx-btn" :class="{'gx-btn--danger': dialog.danger}" type="button" @click="finishDialog(true)">{{ dialog.confirmLabel }}</button></div>
</section>
</div>
</div>
`,
}
@@ -0,0 +1,88 @@
import { normalizeSnapshot } from "./system-monitor-data.js"
// One request at a time. The deadline starts before the request so slow replies
// never obtain a fresh lease merely by arriving late.
export class MonitorFeed {
constructor({ mode, publish, fetcher = (...args) => fetch(...args), clock = () => performance.now(),
schedule = (fn, delay) => setTimeout(fn, delay), cancel = (timer) => clearTimeout(timer), unauthorized = () => {} }) {
if (!["sample", "local"].includes(mode)) throw new Error("Unsupported monitor source")
Object.assign(this, { mode, publish, fetcher, clock, schedule, cancel, unauthorized })
this.generation = 0
this.stopped = true
this.timer = this.deadline = this.expiry = this.request = null
}
start() { this.stop(); this.stopped = false; this.refresh() }
stop() {
this.stopped = true
this.generation++
this.cancel(this.timer)
this.cancel(this.deadline)
this.cancel(this.expiry)
this.request?.abort()
this.request = null
}
async refresh() {
if (this.stopped || this.request) return
const generation = ++this.generation
const started = this.clock()
const request = new AbortController()
this.request = request
this.deadline = this.schedule(() => {
if (this.stopped || this.generation !== generation) return
this.publish({ snapshot: null, status: "unavailable", error: "System activity could not be refreshed." })
this.cancel(this.expiry)
// Retire it even if a stalled response body ignores cancellation.
this.generation++
this.request = null
request.abort()
if (this.mode === "local") this.timer = this.schedule(() => this.refresh(), 2000)
}, 4000)
try {
const url = this.mode === "local" ? "./api/system/monitor" : "./data/system-monitor.sample.json"
const response = await this.fetcher(url, { signal: request.signal, cache: "no-store" })
if (this.stopped || this.generation !== generation || request.signal.aborted) return
if (this.mode === "local" && response.status === 401) {
this.stop()
this.publish({ snapshot: null, status: "unavailable", error: "" })
this.unauthorized()
return
}
if (this.mode === "local" && response.status === 503) {
const body = await response.json().catch(() => null)
if (this.stopped || this.generation !== generation || request.signal.aborted) return
if (["access_unavailable", "setup_required"].includes(body?.code)) {
this.stop()
this.publish({ snapshot: null, status: "unavailable", error: "" })
this.unauthorized()
return
}
}
if (!response.ok) throw new Error("Monitor unavailable")
const snapshot = normalizeSnapshot(await response.json(), this.mode === "local" ? "local-runtime" : "synthetic-preview")
if (this.stopped || this.generation !== generation || request.signal.aborted) return
const elapsed = this.clock() - started
if (elapsed < 0 || elapsed >= 4000) throw new Error("Expired monitor response")
this.publish({ snapshot, status: this.mode === "local" ? "current" : "sample", error: "" })
this.cancel(this.deadline)
this.cancel(this.expiry)
if (this.mode === "local") {
this.expiry = this.schedule(() => {
if (!this.stopped) this.publish({ snapshot: null, status: "unavailable", error: "System activity is out of date." })
}, 4000 - elapsed)
}
} catch {
if (!this.stopped && this.generation === generation) {
this.cancel(this.expiry)
this.publish({ snapshot: null, status: "unavailable", error: "System activity is unavailable." })
}
} finally {
if (!this.stopped && this.generation === generation) {
this.cancel(this.deadline)
this.request = null
if (this.mode === "local") this.timer = this.schedule(() => this.refresh(), 2000)
}
}
}
}
@@ -0,0 +1,262 @@
import { markRaw } from "../vendor/vue/vue.esm-browser.js"
const SIZE = 512
const locationDeadlines = new WeakMap()
export function project(point, zoom) {
const latitude = Math.max(-85.05112878, Math.min(85.05112878, point.latitude))
const sine = Math.sin(latitude * Math.PI / 180), world = SIZE * 2 ** zoom
return [(point.longitude + 180) / 360 * world, (0.5 - Math.log((1 + sine) / (1 - sine)) / (4 * Math.PI)) * world]
}
export function unproject(x, y, zoom) {
const world = SIZE * 2 ** zoom
return {
longitude: ((x / world * 360) % 360 + 360) % 360 - 180, latitude: Math.atan(Math.sinh(Math.PI * (1 - 2 * y / world))) * 180 / Math.PI
}
}
export function relativeX(x, center, world) {
return center + ((x - center + world / 2) % world + world) % world - world / 2
}
export class RasterMap {
constructor(canvas, changed = () => {}) {
this.canvas = canvas;
this.changed = changed;
this.zoom = 2;
this.center = {
longitude: 0, latitude: 0
};
this.failed = new Set();
this.tiles = new Map();
this.pending = new Map();
this.closed = false;
this.data = {};
this.initialized = false
this.observer = new ResizeObserver(() => this.draw());
this.observer.observe(canvas)
this.down = (event) => {
this.userPanned = true
this.drag = [event.clientX, event.clientY, ...project(this.center, this.zoom)];
canvas.setPointerCapture(event.pointerId)
}
this.move = (event) => {
if (!this.drag) return;
this.center = unproject(this.drag[2] - event.clientX + this.drag[0], this.drag[3] - event.clientY + this.drag[1], this.zoom);
this.draw()
}
this.up = () => {
this.drag = null
}
this.key = (event) => {
const delta = {
ArrowLeft: [-80, 0], ArrowRight: [80, 0], ArrowUp: [0, -80], ArrowDown: [0, 80]
}
[event.key];
if (delta) {
event.preventDefault();
this.userPanned = true
const [x,y] = project(this.center,this.zoom);
this.center = unproject(x+delta[0],y+delta[1],this.zoom);
this.draw()
}
}
this.wheel = (event) => {
event.preventDefault();
this.changeZoom(event.deltaY < 0 ? 1 : -1)
}
for (const [name, listener] of [['pointerdown', this.down], ['pointermove', this.move], ['pointerup', this.up], ['pointercancel', this.up], ['wheel', this.wheel], ['keydown', this.key]]) canvas.addEventListener(name, listener, {
passive: false
})
}
update(data, stale) {
clearTimeout(this.locationTimer)
const now = performance.now()
if (data && !locationDeadlines.has(data)) {
locationDeadlines.set(data, data.location?.expiresAt ?? now + (data.location?.validForMs ?? 0))
}
const remaining = Math.max(0, (data && locationDeadlines.get(data) || 0) - now)
this.locationFresh = !stale && remaining > 0
if (this.locationFresh) {
this.locationTimer = setTimeout(() => {
this.locationFresh = false;
this.changed();
this.draw()
}, remaining)
}
const firstLiveFix = this.locationFresh && this.lastLocation?.lastKnown === true
if (data?.location && (this.locationFresh || data.location.lastKnown === true && !this.lastLocation)) {
this.lastLocation = { ...data.location, bearing: Number.isFinite(data.location.bearing) ? data.location.bearing : this.lastLocation?.bearing }
}
this.data = data || {}
this.stale = stale
if (firstLiveFix && !this.userPanned) this.recenter()
if (!this.initialized && (data?.location || data?.destination)) {
this.recenter()
this.initialized = true
}
this.changed()
this.draw()
}
recenter() {
const point = this.lastLocation || this.data.destination;
if (point) {
this.center = point;
this.zoom = this.lastLocation ? 15 : 2
}
this.draw()
}
changeZoom(delta) {
this.zoom = Math.max(0, Math.min(18, this.zoom + delta));
this.draw()
}
draw() {
if (this.closed) return
const width = Math.max(1, Math.round(this.canvas.clientWidth)), height = Math.max(1, Math.round(this.canvas.clientHeight));
this.canvas.width = width;
this.canvas.height = height
const ctx = this.canvas.getContext('2d'), [cx, cy] = project(this.center, this.zoom), n = 2 ** this.zoom, world = SIZE * n, needed = new Set()
ctx.fillStyle = '#0e0e1a';
ctx.fillRect(0, 0, width, height)
for (let ty = Math.floor((cy - height / 2) / SIZE); ty <= Math.floor((cy + height / 2) / SIZE); ty++) {
if (ty < 0 || ty >= n) continue
for (let tx = Math.floor((cx - width / 2) / SIZE); tx <= Math.floor((cx + width / 2) / SIZE); tx++) {
const x = ((tx % n) + n) % n, key = `${this.zoom}/${x}/${ty}`;
needed.add(key)
const image = this.tiles.get(key)
if (image) ctx.drawImage(image, tx * SIZE - cx + width / 2, ty * SIZE - cy + height / 2, SIZE, SIZE)
}
}
for (const [key, controller] of this.pending) if (!needed.has(key)) {
controller.abort();
this.pending.delete(key)
}
for (const key of needed) if (!this.tiles.has(key) && !this.pending.has(key) && !this.failed.has(key) && this.pending.size < 4) this.load(key)
const pixel = (point) => {
const [x, y] = project(point, this.zoom);
return [relativeX(x, cx, world) - cx + width / 2, y - cy + height / 2]
}
for (const choice of this.data.alternatives || []) {
if (choice.index === this.data.selectedRoute || !choice.geometry?.length) continue
ctx.beginPath(); choice.geometry.forEach((point,index) => { const xy=pixel(point); index ? ctx.lineTo(...xy) : ctx.moveTo(...xy) });
ctx.strokeStyle='#7885a6'; ctx.lineWidth=4; ctx.stroke()
}
if (this.data.route?.length) {
ctx.beginPath();
this.data.route.forEach((point, index) => {
const xy = pixel(point);
index ? ctx.lineTo(...xy) : ctx.moveTo(...xy)
});
ctx.strokeStyle = '#b68cff';
ctx.lineWidth = 5;
ctx.stroke()
}
for (const [point, color] of [[this.data.destination, '#ffbd59'], [this.lastLocation, this.locationFresh ? '#65c8ff' : '#9aa6bb']]) if (point) {
const xy = pixel(point);
ctx.beginPath();
ctx.arc(...xy, 7, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke()
if (point === this.lastLocation && Number.isFinite(point.bearing)) {
ctx.save(); ctx.translate(...xy); ctx.rotate(point.bearing * Math.PI / 180);
ctx.beginPath(); ctx.moveTo(0,-22); ctx.lineTo(-7,-11); ctx.lineTo(7,-11); ctx.closePath();
ctx.fillStyle = color; ctx.fill(); ctx.restore()
}
}
}
async load(key) {
const controller = new AbortController();
this.pending.set(key, controller);
let timedOut = false
const timer = setTimeout(() => {
timedOut = true;
controller.abort()
}, 8000)
try {
const response = await fetch(`./api/navigation/map/tiles/${key}.png`, {
credentials: 'same-origin', cache: 'no-store', signal: controller.signal
});
if (!response.ok) throw new Error('Map tiles unavailable')
const image = await createImageBitmap(await response.blob());
if (this.closed || controller.signal.aborted) {
image.close();
return
}
this.tiles.set(key, image);
while (this.tiles.size > 32) {
const oldest = this.tiles.keys().next().value;
this.tiles.get(oldest).close();
this.tiles.delete(oldest)
}
this.changed('')
} catch (error) {
if (timedOut || !controller.signal.aborted) {
this.failed.add(key);
if (this.failed.size > 64) this.failed.delete(this.failed.values().next().value);
this.changed('Map tiles unavailable. Check the saved key and connection, then retry.')
}
} finally {
clearTimeout(timer);
if (this.pending.get(key) === controller) this.pending.delete(key);
if (!this.closed) {
/* Paint completed tiles without continuously retrying failures. */ this.paintScheduled ??= setTimeout(() => {
this.paintScheduled = null;
this.draw()
}, 200)
}
}
}
retry() {
this.failed.clear();
this.draw()
}
close() {
this.closed = true;
clearTimeout(this.locationTimer);
this.observer.disconnect();
clearTimeout(this.paintScheduled);
for (const controller of this.pending.values()) controller.abort();
for (const image of this.tiles.values()) image.close();
this.tiles.clear();
for (const [name, listener] of [['pointerdown', this.down], ['pointermove', this.move], ['pointerup', this.up], ['pointercancel', this.up], ['wheel', this.wheel], ['keydown', this.key]]) this.canvas.removeEventListener(name, listener)
}
}
export const NavigationMap = {
props: ['data', 'stale'], data: () => ({
error: '', map: null, locationFresh: false, lastLocation: null
}),
mounted() {
this.map = markRaw(new RasterMap(this.$refs.canvas, (error) => {
if (error !== undefined) this.error = error
this.locationFresh = this.map?.locationFresh || false
this.lastLocation = this.map?.lastLocation || null
}));
this.map.update(this.data, this.stale)
},
beforeUnmount() {
this.map.close()
},
watch: {
data(value) {
this.map?.update(value, this.stale)
}, stale(value) {
this.map?.update(this.data, value)
}
},
template: `<section class="gx-navigation-map" aria-label="Interactive navigation map">
<canvas tabindex="0" ref="canvas" aria-label="Drag to pan the map" />
<div v-if="!locationFresh && lastLocation" class="gx-navigation-map__last">Last known position · waiting for GPS</div>
<div class="gx-navigation-map__controls">
<button class="gx-btn" @click="map.changeZoom(1)" aria-label="Zoom in">+</button>
<button class="gx-btn" @click="map.changeZoom(-1)" aria-label="Zoom out">−</button>
<button class="gx-btn" @click="map.recenter()">Recenter</button>
</div>
<p v-if="error" role="status">{{error}} <button @click="map.retry()">Retry</button>
</p>
<a class="gx-navigation-map__logo" href="https://www.mapbox.com/" target="_blank" rel="noopener noreferrer" aria-label="Mapbox">
</a>
<div class="gx-navigation-map__credits">
<a href="https://www.mapbox.com/about/maps/" target="_blank" rel="noopener noreferrer">© Mapbox</a> · <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">© OpenStreetMap</a> · <a href="https://apps.mapbox.com/feedback/" target="_blank" rel="noopener noreferrer">Improve this map</a>
</div>
</section>`
}
@@ -0,0 +1,47 @@
if (typeof document !== "undefined") {
const style = document.createElement("style")
style.textContent = `
.gx-navigation--fullscreen {position:fixed;inset:0;z-index:15;padding:0!important;background:#0e0e1a;overflow:hidden}
.gx-app:has(.gx-navigation--fullscreen) .gx-appbar {background:none;box-shadow:none;pointer-events:none;transform:none}
.gx-app:has(.gx-navigation--fullscreen) .gx-appbar__pill,.gx-app:has(.gx-navigation--fullscreen) .gx-theme-toggle {display:none}
.gx-app:has(.gx-navigation--fullscreen) .gx-appbar__back,.gx-app:has(.gx-navigation--fullscreen) .gx-appbar__menu {position:fixed;top:auto;bottom:16px;pointer-events:auto;background:#161630;border:1px solid #343453;border-radius:12px}
.gx-app:has(.gx-navigation--fullscreen) .gx-appbar__back {left:auto;right:72px}
.gx-app:has(.gx-navigation--fullscreen) .gx-appbar__menu {right:16px}
@media(min-width:768px) {.gx-nav-pinned .gx-navigation--fullscreen {left:320px}}
.gx-navigation--fullscreen>h2 {display:none}
.gx-navigation--fullscreen>.gx-navigation__tabs {position:absolute;bottom:16px;left:16px;z-index:4;margin:0;gap:8px}
.gx-navigation--fullscreen .gx-navigation-map {position:absolute;inset:0;margin:0;border-radius:0;background:#0e0e1a}
.gx-navigation--fullscreen .gx-navigation-map canvas {height:100%;background:#0e0e1a}
.gx-navigation__panel {position:absolute;top:16px;left:16px;width:30%;max-height:calc(100% - 88px);overflow:auto;z-index:3}
.gx-navigation__panel .gx-card {background:#0e0e1a;border:0;border-radius:16px;box-shadow:0 4px 12px #0005}
.gx-navigation__panel .gx-navigation__section {max-width:500px;margin:8px 0;padding:8px}
.gx-navigation__panel .gx-btn,.gx-navigation__tabs .gx-btn {border:1px solid #343453;border-radius:16px;background:#161630;box-shadow:0 2px 6px #0004;font-size:.85rem;min-height:44px;padding:8px 16px}
.gx-navigation__search {display:flex;flex-wrap:nowrap;gap:8px;margin:0 0 8px}
.gx-navigation__search .gx-field {min-width:0;padding:16px;border-radius:16px;border:1px solid #343453;background:#161630;font-size:.85rem;height:49px}
.gx-navigation__search .gx-btn {padding:8px;flex-shrink:0;min-width:112px;height:49px}
.gx-navigation__summary-title {margin:0 0 8px;padding:0;border-radius:12px;background:#161630;text-align:center;font-size:1.2rem;line-height:1.2}
.gx-navigation__summary>div {display:grid;grid-template-columns:32px 96px 1fr;align-items:center;font-size:1rem;font-weight:550;min-height:32px}
.gx-navigation__summary>div>span:first-child {text-align:center}
.gx-navigation__route-actions {display:flex;justify-content:center;gap:8px;margin-top:8px}
.gx-navigation__route-actions .gx-btn {background:#e05577;border:0;border-radius:16px;flex:1;color:#e8e8f0;font-weight:550;letter-spacing:1px}
.gx-navigation__panel .gx-navigation__instruction {font-size:1rem;margin:8px 0}
.gx-navigation__panel .gx-note {font-size:.85rem}
.gx-navigation__places {gap:8px;margin:8px 0}
.gx-navigation__places>li {padding:16px;border:1px solid #343453!important;background:#161630!important;border-radius:12px!important;gap:8px;font-size:.85rem}
.gx-navigation__places .gx-note {display:block;font-size:.75rem}
.gx-navigation__actions {gap:8px}
.gx-navigation__alternatives {display:flex;flex-wrap:wrap;gap:8px;margin:8px 0}
.gx-navigation-map__last {position:absolute;bottom:48px;right:16px;background:#0e0e1ae8;border-radius:12px;padding:8px 12px;font-size:12px;color:#ddd}
.gx-navigation--fullscreen .gx-navigation-map__controls {top:16px;right:16px;flex-direction:column}
.gx-navigation-map__controls .gx-btn {background:#161630;border:1px solid #343453;border-radius:12px;box-shadow:0 2px 6px #0004}
.gx-navigation-map > p {position:absolute;bottom:72px;right:16px;max-width:320px;background:#0e0e1a;border-radius:12px;padding:12px;font-size:12px}
.gx-navigation__tabs button {white-space:nowrap}
@media(max-width:768px) and (orientation:portrait) {
.gx-navigation__panel {left:8px;top:16px;width:calc(100% - 32px);max-height:55%}
.gx-navigation--fullscreen .gx-navigation-map__controls {top:auto;bottom:92px}
.gx-navigation__tabs .gx-btn {font-size:12px;padding:8px}
.gx-navigation__places>li {flex-direction:row;align-items:center}
}
`
document.head.appendChild(style)
}
@@ -0,0 +1,292 @@
import "./navigation-style.js"
import { NavigationMap } from "./navigation-map.js"
import { MapOperationsPanel } from "./map-operations.js"
export function searchUuid() {
if (typeof crypto.randomUUID === "function") return crypto.randomUUID()
const bytes = crypto.getRandomValues(new Uint8Array(16))
bytes[6] = (bytes[6] & 15) | 64
bytes[8] = (bytes[8] & 63) | 128
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("")
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
export function remainingLocationLease(lease, requestStarted, now = performance.now()) {
return Math.max(0, Math.min(2500, lease) - Math.max(0, now - requestStarted))
}
const STATUS = new Set(["disabled", "needsKey", "noDestination", "waitingForLocation", "routing", "guiding", "arrived", "routeUnavailable", "stale"])
const coordinate = (value) => !!value && Number.isFinite(value.latitude) && Math.abs(value.latitude) <= 90 &&
Number.isFinite(value.longitude) && Math.abs(value.longitude) <= 180
export const validDestination = (value) => coordinate(value) && typeof value.id === "string" && value.id.length > 0 && value.id.length <= 256 &&
typeof value.name === "string" && value.name.trim().length > 0 && value.name.length <= 256
export const validSearchResult = (value) => validDestination(value) || !!value && value.temporary === true &&
typeof value.id === "string" && value.id.length > 0 && value.id.length <= 256 &&
typeof value.name === "string" && value.name.trim().length > 0 && value.name.length <= 256 &&
(value.description === undefined || typeof value.description === "string" && value.description.length <= 512) &&
typeof value.searchId === "string" && /^[0-9a-f-]{36}$/.test(value.searchId)
export function validNavigation(value) {
const instruction = value?.instruction
if (value?.alternatives !== undefined && (!Array.isArray(value.alternatives) || value.alternatives.length > 3 ||
!value.alternatives.every((row,index) => row.index === index && Number.isFinite(row.durationSeconds) && row.durationSeconds >= 0 &&
Number.isFinite(row.distanceMeters) && row.distanceMeters >= 0 && Array.isArray(row.geometry) && row.geometry.length <= 512 && row.geometry.every(coordinate)) ||
!Number.isInteger(value.selectedRoute) || value.selectedRoute < 0 || value.selectedRoute > 2)) return false
return !!value && typeof value.enabled === "boolean" && typeof value.isMetric === "boolean" && typeof value.hasKey === "boolean" && STATUS.has(value.status) &&
typeof value.revision === "string" && value.revision.length > 0 && value.revision.length <= 128 &&
(value.destination === null || validDestination(value.destination)) && Array.isArray(value.favorites) && value.favorites.length <= 100 &&
(value.location == null || coordinate(value.location) && Number.isFinite(value.location.validForMs) && value.location.validForMs >= 0 && value.location.validForMs <= 2500) &&
value.favorites.every(validDestination) && Array.isArray(value.route) && value.route.length <= 512 && value.route.every(coordinate) &&
(instruction === null || !!instruction && ["text", "maneuverType", "maneuverModifier"].every((key) =>
typeof instruction[key] === "string" && instruction[key].length <= 1024) &&
["distanceMeters", "remainingDistanceMeters", "remainingDurationSeconds"].every((key) =>
Number.isFinite(instruction[key]) && instruction[key] >= 0))
}
export function routePath(points) {
if (!Array.isArray(points) || points.length < 2 || points.length > 512 || !points.every(coordinate)) return ""
const latitude = points.reduce((sum, point) => sum + point.latitude, 0) / points.length
const scale = Math.max(0.05, Math.cos(latitude * Math.PI / 180))
const firstLongitude = points[0].longitude
const projected = points.map((point) => [(((point.longitude - firstLongitude + 540) % 360) - 180) * scale, -point.latitude])
const xs = projected.map((point) => point[0]), ys = projected.map((point) => point[1])
const left = Math.min(...xs), top = Math.min(...ys)
const width = Math.max(...xs) - left, height = Math.max(...ys) - top
const zoom = Math.min(360 / Math.max(width, 1e-8), 180 / Math.max(height, 1e-8))
return projected.map(([x, y], index) => `${index ? "L" : "M"}${(200 + (x - left - width / 2) * zoom).toFixed(2)},${(110 + (y - top - height / 2) * zoom).toFixed(2)}`).join(" ")
}
export class NavigationClient {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (timer) => clearTimeout(timer) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer })
this.clientId = searchUuid()
this.active = false
this.generation = 0
this.controller = this.timer = null
this.data = null
this.results = []
this.searched = false
this.busy = false
this.error = ""
this.stale = false
}
emit() { this.publish({ data: this.data, results: this.results, searched: this.searched, busy: this.busy, error: this.error, stale: this.stale }) }
stop() {
if (this.searchId && this.data) {
this.fetcher("./api/navigation/action", { method: "POST", credentials: "same-origin", cache: "no-store",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "cancelSearch",
revision: this.data.revision, searchId: this.searchId }) }).catch(() => {})
}
this.searchId = null
this.active = false
this.generation++
this.controller?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
this.controller = this.timer = null
this.data = null
this.results = []
this.searched = false
this.error = ""
this.busy = this.stale = false
this.emit()
}
start() { this.stop(); this.active = true; return this.load() }
load() { return this.run("status") }
search(query) {
this.searchId = searchUuid()
return this.run("search", { query: query.trim(), searchId: this.searchId, clientId: this.clientId })
}
choose(place) {
return place.temporary ? this.action("selectPlace", { id: place.id, searchId: place.searchId }) :
this.action("select", { destination: place })
}
action(action, value = {}) {
if (!this.data || this.busy || this.stale) return Promise.resolve(null)
return this.run("action", { action, revision: this.data.revision, ...value })
}
async run(operation, body = null) {
if (!this.active || this.busy) return null
this.controller?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
this.timer = null
const requestStarted = performance.now()
const generation = ++this.generation
const controller = new AbortController()
this.controller = controller
this.busy = operation !== "status"
this.error = ""
this.emit()
const deadline = this.later(() => controller.abort(), operation === "search" ? 24000 : operation === "action" && body?.action === "selectPlace" ? 12000 : 4000)
try {
const response = await this.fetcher(`./api/navigation/${operation}`, {
credentials: "same-origin", cache: "no-store", signal: controller.signal,
...(body === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
})
if (!this.active || generation !== this.generation) return null
const payload = await response.json()
if (!this.active || generation !== this.generation) return null
if (controller.signal.aborted) throw new Error("Navigation did not respond.")
if (response.status === 401 || response.status === 503 && ["access_unavailable", "setup_required"].includes(payload?.code)) {
this.stop(); this.unauthorized(); return null
}
if (!response.ok) throw new Error(payload?.error || "Navigation could not complete this request.")
if (operation === "search") {
if (!Array.isArray(payload.results) || payload.results.length > 20 || !payload.results.every(validSearchResult))
throw new Error("Search results could not be read.")
this.results = payload.results
this.searched = true
} else {
if (!validNavigation(payload)) throw new Error("Navigation status could not be read.")
if (payload.location) {
payload.location.validForMs = remainingLocationLease(payload.location.validForMs, requestStarted)
payload.location.expiresAt = performance.now() + payload.location.validForMs
}
if (JSON.stringify(payload) !== JSON.stringify(this.data)) this.data = payload
this.stale = false
if (operation === "action" && ["select", "selectPlace", "clear"].includes(body.action)) { this.results = []; this.searched = false }
}
return payload
} catch (error) {
if (this.active && generation === this.generation) {
this.error = controller.signal.aborted ? "Navigation did not respond. Reconnecting…" : error.message
if (operation !== "search") this.stale = true
}
return null
} finally {
this.cancelTimer(deadline)
if (this.active && generation === this.generation) {
this.controller = null
this.busy = false
this.emit()
this.timer = this.later(() => { this.timer = null; this.load() }, this.data?.enabled && this.data?.hasKey ? 1000 : this.data?.status === "guiding" ? 2000 : 5000)
}
}
}
}
export const NavigationPage = {
name: "NavigationPage",
components: { MapOperationsPanel, NavigationMap },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ data: null, results: [], busy: false, error: "", stale: false, tab: "route", favoritesOpen: false, query: "", token: "", searched: false }),
computed: {
available() { return this.mode === "local" && !!this.data && !this.stale && !this.busy },
path() { return routePath(this.data?.route) },
summary() {
const instruction = this.data?.instruction, route = this.data?.alternatives?.[this.data?.selectedRoute || 0]
return instruction ? {distance:instruction.remainingDistanceMeters,duration:instruction.remainingDurationSeconds} :
route ? {distance:route.distanceMeters,duration:route.durationSeconds} : null
},
statusLabel() {
return ({ disabled: "Navigation is off", needsKey: "Add your Mapbox key to get started", noDestination: "Where would you like to go?",
waitingForLocation: "Waiting for GPS", routing: "Finding your route…", guiding: "Route guidance", arrived: "You have arrived",
routeUnavailable: "A route could not be found", stale: "Waiting for navigation" })[this.data?.status] || "Connecting to navigation…"
},
},
created() { this.client = new NavigationClient({ publish: (state) => Object.assign(this.$data, state), unauthorized: this.unauthorized }) },
mounted() {
if (this.mode !== "local") return
this.visibilityHandler = () => document.visibilityState === "hidden" ? this.client.stop() : this.client.start()
document.addEventListener("visibilitychange", this.visibilityHandler)
if (document.visibilityState !== "hidden") this.client.start()
},
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibilityHandler); this.client.stop(); this.token = "" },
methods: {
async search() { await this.client.search(this.query) },
async saveKey() { const token = this.token.trim(); this.token = ""; await this.client.action("configure", { patch: { token } }) },
distance(value) {
const metric = this.data?.isMetric !== false
return metric ? value < 1000 ? `${Math.round(value)} m` : `${(value / 1000).toFixed(1)} km` :
value < 160.9344 ? `${Math.round(value / 0.3048 / 10) * 10} ft` : `${(value / 1609.344).toFixed(1)} mi`
},
duration(value) { const minutes = Math.max(1, Math.round(value / 60)); return minutes >= 60 ? `${Math.floor(minutes / 60)} hr ${minutes % 60} min` : `${minutes} min` },
isFavorite(place) { return this.data?.favorites.some((favorite) => favorite.id === place.id) },
},
template: `
<div class="gx-view gx-navigation" :class="{'gx-navigation--fullscreen':tab==='route'}">
<h2>Navigation</h2>
<div class="gx-navigation__tabs" role="tablist" aria-label="Navigation tools">
<button v-for="item in [{id:'route',label:'Destination'},{id:'maps',label:'Offline Maps'},{id:'setup',label:'Setup'}]" :key="item.id"
type="button" class="gx-btn" :class="tab === item.id ? '' : 'gx-btn--tonal'" role="tab" :aria-selected="tab === item.id" @click="tab=item.id">{{ item.label }}</button>
</div>
<MapOperationsPanel v-if="tab === 'maps'" :mode="mode" :unauthorized="unauthorized" />
<template v-else>
<p v-if="mode !== 'local'" class="gx-note">Connect to your comma to set up navigation and choose a destination.</p>
<p v-if="error" class="gx-note gx-note--danger" role="alert">{{ error }}</p>
<p v-if="stale && data" class="gx-note">Showing the last received route. Reconnecting before accepting changes.</p>
<template v-if="tab === 'setup'">
<section class="gx-card gx-navigation__section">
<h3>Navigation</h3>
<p>Show directions on your comma. Navigation works with every driving model. Route guidance helps prepare for turns; steering and speed control follow your normal engagement settings.</p>
<label class="gx-toggle-row"><input type="checkbox" :checked="data?.enabled" :disabled="!available"
@change="client.action('configure',{patch:{enabled:$event.target.checked}})"> Enable Navigation</label>
</section>
<section class="gx-card gx-navigation__section">
<h3>Mapbox</h3>
<p>One public Mapbox access token (starting with pk.) handles maps, address search, place search and routes. A separate secret token is not needed.</p>
<p>Save your token while parked. Galaxy keeps it on your comma and never displays it after saving. Saving address favorites requires permanent geocoding: a payment method on file or an enterprise agreement with Mapbox. Place-search results are available for the current route only. Map tiles use your Mapbox quota; URL-restricted keys may reject these requests.</p>
<a href="https://account.mapbox.com/access-tokens/" target="_blank" rel="noopener noreferrer">Get a Mapbox access token</a>
<p v-if="data?.hasKey" class="gx-note">A Mapbox key is saved.</p>
<form @submit.prevent="saveKey" class="gx-navigation__search">
<label for="navigation-token" class="gx-sr-only">Public Mapbox access token</label>
<input id="navigation-token" class="gx-field" type="password" autocomplete="off" v-model="token" placeholder="pk.…" maxlength="2048" required :disabled="!available">
<button class="gx-btn" type="submit" :disabled="!available || !token.trim()">{{ data?.hasKey ? 'Replace Key' : 'Save Key' }}</button>
</form>
</section>
</template>
<template v-else>
<NavigationMap v-if="data?.enabled && data?.hasKey" :data="data" :stale="stale" />
<div class="gx-navigation__panel">
<form v-if="data?.enabled && data?.hasKey" @submit.prevent="search" class="gx-navigation__search">
<label for="navigation-search" class="gx-sr-only">Search destinations</label>
<input id="navigation-search" class="gx-field" v-model="query" placeholder="Search here" minlength="2" maxlength="200" required :disabled="!available">
<button class="gx-sr-only" type="submit" :disabled="!available || query.trim().length < 2">Search</button>
<button v-if="data?.favorites.length" class="gx-btn gx-btn--tonal" type="button" @click="favoritesOpen=!favoritesOpen" :aria-expanded="favoritesOpen">♥ Favorites</button>
</form>
<section v-if="data?.destination" class="gx-card gx-navigation__section" :class="{'gx-navigation--stale':stale}">
<h3 class="gx-navigation__summary-title">{{ data.destination.name }}</h3>
<div v-if="summary" class="gx-navigation__summary">
<div><span>🛣️</span><span>Distance:</span><span>{{distance(summary.distance)}}</span></div>
<div><span>⌛</span><span>Duration:</span><span>{{duration(summary.duration)}}</span></div>
<div><span>🕗</span><span>ETA:</span><span>{{new Date(Date.now()+summary.duration*1000).toLocaleTimeString([], {hour:'numeric',minute:'2-digit'})}}</span></div>
</div>
<p v-else class="gx-note">{{statusLabel}}</p>
<template v-if="data.instruction && !stale">
<p class="gx-navigation__instruction">{{ data.instruction.text }}</p>
<p>{{ distance(data.instruction.distanceMeters) }} · {{ duration(data.instruction.remainingDurationSeconds) }} remaining · {{ distance(data.instruction.remainingDistanceMeters) }}</p>
</template>
<div v-if="data.alternatives?.length > 1" class="gx-navigation__alternatives" aria-label="Alternative routes">
<button v-for="choice in data.alternatives" :key="choice.index" class="gx-btn" :class="choice.index===data.selectedRoute ? '' : 'gx-btn--tonal'" :aria-pressed="choice.index===data.selectedRoute" :disabled="!available" @click="client.action('selectRoute',{index:choice.index})">Route {{choice.index+1}} · {{duration(choice.durationSeconds)}} · {{distance(choice.distanceMeters)}}</button>
</div>
<div class="gx-navigation__route-actions">
<button type="button" class="gx-btn" :disabled="!available" @click="client.action('clear')">Cancel Navigation</button>
<button v-if="!data.destination.temporary" class="gx-btn" :disabled="!available" @click="isFavorite(data.destination) ? client.action('removeFavorite',{id:data.destination.id}) : client.action('favorite',{destination:data.destination})">{{isFavorite(data.destination) ? '♥ Unfavorite' : '♥ Favorite'}}</button>
</div>
</section>
<section v-if="data && !data.enabled" class="gx-card gx-navigation__section"><h3>Navigation is off</h3><p>Enable it in Setup to use destinations and directions.</p><button type="button" class="gx-btn" @click="tab='setup'">Open Setup</button></section>
<section v-else-if="data && !data.hasKey" class="gx-card gx-navigation__section"><h3>Connect Mapbox</h3><p>Add your key to search for places and plan a route.</p><button type="button" class="gx-btn" @click="tab='setup'">Open Setup</button></section>
<template v-else>
<p v-if="busy" role="status">Working…</p>
<p v-if="searched && !busy && !error && results.length === 0" class="gx-note">No places found. Try a nearby town or a more specific address.</p>
<ul v-if="results.length" class="gx-navigation__places" aria-label="Search results">
<li v-for="place in results" :key="place.id" class="gx-card">
<span>{{ place.name }}<small v-if="place.description" class="gx-note">{{ place.description }}</small><small v-if="place.temporary" class="gx-note"> For this route only; cannot be saved.</small></span><div class="gx-navigation__actions">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!available || place.temporary || isFavorite(place)" @click="client.action('favorite',{destination:place})">{{ isFavorite(place) ? 'Saved' : 'Save' }}</button>
<button type="button" class="gx-btn" :disabled="!available" @click="client.choose(place)">Go</button>
</div>
</li>
</ul>
<ul v-if="favoritesOpen && data?.favorites.length" class="gx-navigation__places" aria-label="Saved places">
<li v-for="place in data.favorites" :key="place.id" class="gx-card"><span>{{ place.name }}<small v-if="place.description" class="gx-note">{{ place.description }}</small><small v-if="place.temporary" class="gx-note"> For this route only; cannot be saved.</small></span><div class="gx-navigation__actions">
<button type="button" class="gx-icon-btn" :aria-label="'Remove '+place.name" :disabled="!available" @click="client.action('removeFavorite',{id:place.id})">Remove</button>
<button type="button" class="gx-btn" :disabled="!available" @click="client.choose(place)">Go</button>
</div></li>
</ul>
</template>
</div>
</template>
</template>
</div>`,
}
@@ -0,0 +1,862 @@
import { reactive, watch } from "../vendor/vue/vue.esm-browser.js"
import { LayoutPreviewFeed, PREVIEW_SCENES } from "./layout-preview.js"
import { editorSnapshot, projectionPayload } from "./projection-layout.js"
const PROFILES = ["large", "compact"]
const COLORS = ["cardFill", "cardBorder", "text"]
const ROAD_COLORS = ["path", "pathEdge", "laneLines"]
const PATH_MODES = ["default", "color", "rainbow", "acceleration"]
const HEX = /^#[0-9a-fA-F]{8}$/
const clone = (value) => JSON.parse(JSON.stringify(value))
const keysEqual = (value, keys) => value && typeof value === "object" && !Array.isArray(value) &&
Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key))
const positive = (value) => Number.isFinite(value) && value > 0 && value <= 8192
const object = (value) => value && typeof value === "object" && !Array.isArray(value)
export function widgetPalette(document, metadata, profile, id) {
const colors = metadata.profiles[profile].widgets[id].colors
return { ...Object.fromEntries(Object.entries(colors).map(([key, value]) => [key, value ?? document.palette[key]])),
...document.widgetColors[profile][id] }
}
export const withAlpha = (color, factor) => color.slice(0, 7) + Math.floor(parseInt(color.slice(7), 16) * factor).toString(16).padStart(2, "0")
export function placementLimits(profile, id, layout = null) {
const widget = profile.widgets[id], bounds = widget.bounds || profile.bounds
const width = widget.resizable ? layout?.[id]?.size ?? widget.width : widget.width
const height = widget.resizable ? layout?.[id]?.size ?? widget.height : widget.height
return { minX: bounds.x, maxX: bounds.x + bounds.width - width,
minY: bounds.y, maxY: bounds.y + bounds.height - height }
}
export function clampPlacement(profile, id, x, y, layout = null) {
if (!Number.isFinite(x) || !Number.isFinite(y) || !Object.hasOwn(profile.widgets, id)) return null
const { minX, maxX, minY, maxY } = placementLimits(profile, id, layout)
const position = { x: Math.max(minX, Math.min(maxX, Math.round(x))), y: Math.max(minY, Math.min(maxY, Math.round(y))) }
return overlapsReserved(profile, id, position.x, position.y, layout) ? null : position
}
export function overlapsReserved(profile, id, x, y, layout = null) {
const widget = profile.widgets[id]
if (id === "torque_bar" && widget.kind === "torque_bar" && widget.layer === "underlay") return false
const size = widget.resizable ? layout?.[id]?.size ?? widget.width : null
const width = size ?? widget.width, height = size ?? widget.height
const intersects = (left, top, otherWidth, otherHeight) => x < left + otherWidth && x + width > left &&
y < top + otherHeight && y + height > top
if (profile.reservedZones.some((zone) => intersects(zone.x, zone.y, zone.width, zone.height))) return true
const actions = profile.widgets.speed_limit_actions
if (!actions || profile.protectedWidget !== "speed_limit_actions") return false
const positions = layout || Object.fromEntries(Object.entries(profile.widgets).map(([key, item]) => [key, item.default]))
if (id === "speed_limit_actions") return Object.entries(profile.widgets).some(([key, other]) => {
const at = positions[key]
return key !== id && key !== "speed_limit" && key !== "torque_bar" && at && Number.isFinite(at.x) && Number.isFinite(at.y) &&
intersects(at.x, at.y, other.resizable ? at.size ?? other.width : other.width,
other.resizable ? at.size ?? other.height : other.height)
})
if (id === "speed_limit") return false
const at = positions.speed_limit_actions
if (!at || !Number.isFinite(at.x) || !Number.isFinite(at.y)) return true
return intersects(at.x, at.y, actions.width, actions.height)
}
export function previewPoint(event, rect, profile) {
if (!rect?.width || !rect?.height) return null
return { x: (event.clientX - rect.left) * profile.width / rect.width,
y: (event.clientY - rect.top) * profile.height / rect.height }
}
export function validDocument(document, metadata) {
if (!object(document)) return false
const profiles = metadata?.projection === true ? ["large"] : PROFILES
if (!keysEqual(document, ["version", "palette", "layouts", "widgetColors", "roadColors", ...(Object.hasOwn(document, "speedSources") ? ["speedSources"] : [])]) || document.version !== 4 || (Object.hasOwn(document, "speedSources") && typeof document.speedSources !== "boolean") ||
!keysEqual(document.palette, COLORS) || !COLORS.every((key) => HEX.test(document.palette[key])) ||
!keysEqual(document.layouts, profiles) || !keysEqual(document.widgetColors, profiles) || !keysEqual(document.roadColors, profiles)) return false
return profiles.every((name) => {
const profile = metadata.profiles[name], layout = document.layouts[name]
const road = document.roadColors[name]
if (!object(road) || !Object.entries(road).every(([key, value]) => key === "pathMode" ? PATH_MODES.includes(value) :
ROAD_COLORS.includes(key) && typeof value === "string" && HEX.test(value))) return false
const colors = document.widgetColors[name]
if (!object(colors) || !Object.entries(colors).every(([id, overrides]) => {
const fields = profile.widgets[id]?.colors
return fields && Object.keys(fields).length > 0 && object(overrides) && Object.entries(overrides).every(([key, value]) =>
Object.hasOwn(fields, key) && typeof value === "string" && HEX.test(value))
})) return false
return keysEqual(layout, Object.keys(profile.widgets)) && Object.entries(layout).every(([id, position]) => {
const widget = profile.widgets[id], limits = placementLimits(profile, id, layout)
const resizable = widget.resizable
return keysEqual(position, resizable ? ["x", "y", "enabled", "size"] : ["x", "y", "enabled"]) &&
(!resizable || (Number.isInteger(position.size) && position.size >= resizable.min && position.size <= resizable.max)) &&
typeof position.enabled === "boolean" &&
Number.isFinite(position.x) && Number.isFinite(position.y) &&
position.x >= limits.minX && position.x <= limits.maxX && position.y >= limits.minY && position.y <= limits.maxY &&
!overlapsReserved(profile, id, position.x, position.y, layout)
})
})
}
export function validSnapshot(data) {
const projection = data?.projection === true && data?.metadata?.projection === true
if ((data?.projection === true) !== (data?.metadata?.projection === true)) return false
const profiles = projection ? ["large"] : PROFILES
if (!data || typeof data.revision !== "string" || !data.revision || typeof data.editable !== "boolean" ||
typeof data.valid !== "boolean" || !keysEqual(data.metadata?.profiles, profiles) ||
!Array.isArray(data.metadata.paletteFields) || data.metadata.paletteFields.length !== COLORS.length ||
new Set(data.metadata.paletteFields.map((field) => field.id)).size !== COLORS.length ||
!data.metadata.paletteFields.every((field) => COLORS.includes(field.id) && typeof field.label === "string" && HEX.test(field.default)) ||
!Array.isArray(data.metadata.roadColorFields) || data.metadata.roadColorFields.length !== ROAD_COLORS.length ||
new Set(data.metadata.roadColorFields.map((field) => field.id)).size !== ROAD_COLORS.length ||
!data.metadata.roadColorFields.every((field) => ROAD_COLORS.includes(field.id) && typeof field.label === "string" && HEX.test(field.default)) ||
(data.activeProfile != null && !profiles.includes(data.activeProfile))) return false
for (const profile of Object.values(data.metadata.profiles)) {
const bounds = profile.bounds
if (profile.protectedWidget != null && (profile.protectedWidget !== "speed_limit_actions" ||
profile.widgets?.[profile.protectedWidget]?.kind !== "speed_limit_actions")) return false
if (!positive(profile.width) || !positive(profile.height) || typeof profile.label !== "string" ||
!bounds || !Number.isFinite(bounds.x) || !Number.isFinite(bounds.y) || bounds.x < 0 || bounds.y < 0 ||
!positive(bounds.width) || !positive(bounds.height) || bounds.x + bounds.width > profile.width ||
bounds.y + bounds.height > profile.height || !profile.widgets || Array.isArray(profile.widgets) ||
Object.keys(profile.widgets).length > 32 || !Array.isArray(profile.reservedZones) || profile.reservedZones.length > 32) return false
for (const zone of profile.reservedZones) {
if (typeof zone.label !== "string" || !Number.isFinite(zone.x) || !Number.isFinite(zone.y) ||
!positive(zone.width) || !positive(zone.height) || zone.x < bounds.x || zone.y < bounds.y ||
zone.x + zone.width > bounds.x + bounds.width || zone.y + zone.height > bounds.y + bounds.height) return false
}
for (const widget of Object.values(profile.widgets)) {
const area = widget.bounds || bounds
if (widget.visualInsetTop != null && (!Number.isFinite(widget.visualInsetTop) || widget.visualInsetTop < 0 || widget.visualInsetTop > area.height)) return false
if (!object(widget.colors) || !Object.entries(widget.colors).every(([key, value]) =>
COLORS.includes(key) && (value === null || (typeof value === "string" && HEX.test(value))))) return false
if (!Number.isFinite(area.x) || !Number.isFinite(area.y) || area.x < 0 || area.y < 0 ||
!positive(area.width) || !positive(area.height) || area.x + area.width > profile.width ||
area.y + area.height > profile.height || typeof widget.label !== "string" || typeof widget.kind !== "string" ||
!positive(widget.width) || !positive(widget.height) || widget.width > area.width || widget.height > area.height) return false
if (widget.resizable && (widget.kind !== "steering_wheel" || !Number.isInteger(widget.resizable.min) ||
!Number.isInteger(widget.resizable.default) || !Number.isInteger(widget.resizable.max) ||
widget.resizable.default !== widget.width || widget.width !== widget.height ||
widget.resizable.min < 24 || widget.resizable.max > area.width || widget.resizable.max > area.height ||
widget.resizable.min > widget.resizable.default || widget.resizable.default > widget.resizable.max)) return false
}
}
return validDocument(data.document, data.metadata) && validDocument(data.defaults, data.metadata)
}
export class OnroadLayoutFeed {
constructor({ publish, projection = false, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, projection, unauthorized, fetcher, later, cancelTimer })
this.active = false
this.generation = 0
this.request = this.timer = this.data = null
this.retryTimer = null
this.readRetries = 0
this.autoRead = false
this.needsReload = false
}
stop() {
this.active = false
this.generation++
this.request?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
if (this.retryTimer !== null) this.cancelTimer(this.retryTimer)
this.request = this.timer = null
this.retryTimer = null
}
start() { this.stop(); this.active = true; this.data = null; this.needsReload = false; return this.load() }
load() {
if (this.retryTimer !== null) this.cancelTimer(this.retryTimer)
this.retryTimer = null
this.readRetries = 0
this.autoRead = !this.data?.editable && !this.needsReload
return this.run()
}
save(document) {
if (!this.data?.editable || this.needsReload || !validDocument(document, this.data.metadata)) return
return this.run({ revision: this.data.revision, document: clone(document) })
}
async run(body = null) {
if (!this.active || this.request) return
const saving = body !== null, generation = ++this.generation, request = new AbortController()
if (saving) this.autoRead = false
this.request = request
this.publish({ status: saving ? "saving" : "loading", error: "", notice: "" })
const fail = (message) => {
this.needsReload ||= saving
this.publish({ status: this.data ? "ready" : "unavailable", error: message, needsReload: this.needsReload })
}
this.timer = this.later(() => {
if (!this.active || generation !== this.generation) return
request.abort()
this.generation++
this.request = this.timer = null
fail(saving ? "Saving timed out. Your edits are kept here, but the saved result is unknown. Reload before saving again." :
"Reading colors and layouts timed out. Try reloading.")
}, 5000)
try {
const response = await this.fetcher(this.projection ? "./api/android-auto/layout" : "./api/ui/layout", { credentials: "same-origin", cache: "no-store", signal: request.signal,
...(saving ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(this.projection ? projectionPayload(body, this.data.metadata) : body) } : {}) })
if (!this.active || generation !== this.generation) return
let data = await response.json().catch(() => null)
if (!this.active || generation !== this.generation) return
if (response.status === 401 || ["access_unavailable", "setup_required"].includes(data?.code)) {
this.stop(); this.unauthorized(); return
}
if (response.status === 409) throw new Error("Saved values or parked status changed. Your edits are kept here. Reload before saving again.")
if (!response.ok) throw new Error(saving ? "Colors and layout could not be saved. Your edits are kept here. Reload before trying again." :
"Colors and layout are unavailable. Try reloading.")
if (this.projection) data = editorSnapshot(data)
if (!validSnapshot(data)) throw new Error("The device returned an unsupported colors and layout document. Reload to try again.")
this.data = data
this.needsReload = false
if (data.editable) this.autoRead = false
this.publish({ status: "ready", data, draft: clone(data.document), error: "", needsReload: false,
notice: saving ? (this.projection ? "Android Auto layout saved." : "Colors and both layouts saved.") : "" })
if (!saving && !data.editable && this.autoRead && this.readRetries < 3) {
this.readRetries++
const retryGeneration = this.generation
this.retryTimer = this.later(() => {
if (this.generation !== retryGeneration) return
this.retryTimer = null
if (this.active && !this.needsReload && !this.data?.editable) this.run()
}, 1000)
}
} catch (error) {
if (this.active && generation === this.generation) fail(error?.message || "Colors and layout are unavailable.")
} finally {
if (this.request === request) {
if (this.timer !== null) this.cancelTimer(this.timer)
this.request = this.timer = null
}
}
}
}
export const LayoutWidgetPreview = {
props: ["widget", "palette", "profile", "scene"],
methods: { withAlpha },
template: `
<g aria-hidden="true" class="gx-layout-widget-art" :fill="palette.text || '#ffffff'">
<rect v-if="['driver_monitor', 'model_confidence', 'conditional_mode', 'following_distance'].includes(widget.kind)"
x="1" y="1" :width="widget.width - 2" :height="widget.height - 2" :rx="Math.min(widget.width, widget.height) * .1"
:fill="palette.cardFill" :stroke="palette.cardBorder" :stroke-width="profile === 'large' ? 4 : 2" />
<template v-if="widget.kind === 'current_speed'">
<text x="290" y="200" text-anchor="middle" font-size="176" font-weight="700">65</text>
<text x="290" y="285" text-anchor="middle" font-size="66" :fill="withAlpha(palette.text, 200 / 255)">mph</text>
</template>
<template v-else-if="widget.kind === 'cruise_limits'">
<g v-for="(label, index) in ['MAX', 'LIMIT']" :key="label" :transform="'translate(0 ' + index * 211 + ')'">
<rect x="2" y="2" width="172" height="192" rx="30" :fill="palette.cardFill" :stroke="palette.cardBorder" stroke-width="4" />
<text x="88" y="65" text-anchor="middle" font-size="40" font-weight="600" fill="#80d8a6">{{ label }}</text>
<text x="88" y="155" text-anchor="middle" font-size="90" font-weight="700">65</text>
</g>
</template>
<template v-else-if="widget.kind === 'max_speed'">
<defs><radialGradient id="gx-layout-speed-shadow"><stop offset="0" stop-color="#00000080" /><stop offset="1" stop-color="#00000000" /></radialGradient></defs>
<circle cx="81" cy="81" r="81" fill="url(#gx-layout-speed-shadow)" />
<text x="17" y="102" font-size="112" font-weight="700" :fill="withAlpha(palette.text, .9)">65</text>
<text x="25" y="142" font-size="36" font-weight="600" :fill="withAlpha(palette.text, .9)">MAX</text>
</template>
<template v-else-if="widget.kind === 'speed_limit'">
<rect x="10" y="8" width="100" height="116" rx="14" fill="none" stroke="white" stroke-width="2" />
<text x="60" y="18" dominant-baseline="text-before-edge" text-anchor="middle" font-size="20" font-weight="600" fill="white">SPEED</text>
<text x="60" y="36" dominant-baseline="text-before-edge" text-anchor="middle" font-size="20" font-weight="600" fill="white">LIMIT</text>
<text x="60" y="66" dominant-baseline="text-before-edge" text-anchor="middle" font-size="50" font-weight="700" fill="white">65</text>
</template>
<template v-else-if="widget.kind === 'speed_limit_actions'">
<text v-if="profile === 'compact'" x="6" :y="widget.visualHeaderY ?? -32" dominant-baseline="text-before-edge" font-size="20" font-weight="600" fill="white">NEW LIMIT 55</text>
<g v-for="(label, index) in ['ACCEPT', 'REJECT']" :key="label" :transform="'translate(' + index * (widget.width / 2 + 4) + ' 0)'">
<rect :width="widget.width / 2 - 4" :height="widget.height" rx="12" :fill="palette.cardFill" :stroke="palette.cardBorder" stroke-width="2" />
<text :x="widget.width / 4 - 2" :y="widget.height / 2 + 7" text-anchor="middle" :font-size="profile === 'large' ? 25 : 20" font-weight="600" :fill="palette.text">{{ label }}</text>
</g>
</template>
<g v-else-if="widget.kind === 'model_confidence'">
<circle cx="30" cy="40" r="18" fill="#32bc82" />
</g>
<g v-else-if="widget.kind === 'conditional_mode'">
<g v-if="scene === 'cem_stop_light'">
<rect x="17" y="11" width="26" height="58" rx="8" fill="#14181b" stroke="white" stroke-width="2" />
<circle cx="30" cy="22" r="7" fill="#070c10" /><circle cx="30" cy="22" r="5" fill="#e63442" /><circle cx="28" cy="20" r="2" fill="#ffb6b9" />
<circle cx="30" cy="40" r="7" fill="#070c10" /><circle cx="30" cy="40" r="5" fill="#45361e" />
<circle cx="30" cy="58" r="7" fill="#070c10" /><circle cx="30" cy="58" r="5" fill="#1c3f2c" />
</g>
<g v-else-if="scene === 'cem_lead'">
<path d="M13 18 H6 V25 M47 18 H54 V25 M13 62 H6 V55 M47 62 H54 V55" fill="none" stroke="#70c0d8" stroke-width="3" />
<path d="M15 39 L20 27 H40 L45 39" fill="none" stroke="white" stroke-width="3" />
<rect x="14" y="37" width="32" height="18" rx="4" fill="white" />
<path d="M18 53 V59 M42 53 V59" stroke="white" stroke-width="4" />
<path d="M18 43 H23 M37 43 H42" stroke="#c82030" stroke-width="3" />
<path d="M26 49 H34" stroke="#14181b" stroke-width="2" />
</g>
<g v-else-if="scene === 'cem_curve'" fill="none">
<path d="M12 64 C11 49 13 31 18 16 M44 64 C43 49 45 31 50 16" stroke="white" stroke-width="3" stroke-linecap="round" />
<path d="M28 64 C27 49 29 31 34 16" stroke="#70c0d8" stroke-width="4" stroke-dasharray="5 6" stroke-linecap="round" />
</g>
<g v-else>
<circle cx="30" cy="40" r="18" fill="#70c0d8" />
<text x="30" y="47" text-anchor="middle" font-size="16" font-weight="700" fill="#ffffff">{{ scene === 'experimental' ? 'E' : 'C' }}</text>
</g>
</g>
<g v-else-if="widget.kind === 'following_distance'">
<g fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 64 L17 16 M55 64 L43 16" :stroke="palette.text" stroke-width="3" />
<path d="M9 59 L12 52 H48 L51 59 M13 46 L16 39 H44 L47 46" stroke="#70c0d8" stroke-width="4" />
</g>
</g>
<g v-else-if="widget.kind === 'steering_wheel'" :transform="'scale(' + widget.width / 192 + ')'">
<circle v-if="profile === 'large'" cx="96" cy="96" r="96" :fill="palette.cardFill" />
<circle v-if="profile === 'large'" cx="96" cy="96" r="94.5" fill="none" :stroke="palette.cardBorder" stroke-width="3" />
<g fill="none" stroke="white" stroke-width="12" stroke-linecap="round">
<circle cx="96" cy="96" r="61" /><path d="M38 78 Q96 107 154 78 M96 103 V155" />
</g>
</g>
<g v-else-if="widget.kind === 'torque_bar'" fill="none" stroke="white" stroke-linecap="round">
<path :d="'M 12 ' + widget.height * .8 + ' Q ' + widget.width / 2 + ' ' + widget.height * .55 + ' ' + (widget.width - 12) + ' ' + widget.height * .8" :stroke-width="widget.height * .18" opacity=".3" />
<path :d="'M ' + widget.width / 2 + ' ' + widget.height * .68 + ' Q ' + widget.width * .65 + ' ' + widget.height * .68 + ' ' + widget.width * .8 + ' ' + widget.height * .74" :stroke-width="widget.height * .18" opacity=".9" />
</g>
<g v-else-if="widget.kind === 'driver_monitor'" :transform="'translate(' + (widget.width - (profile === 'large' ? 128 : 60)) / 2 + ' ' + (widget.height - (profile === 'large' ? 128 : 60)) / 2 + ') scale(' + (profile === 'large' ? 128 : 60) / 60 + ')'">
<circle cx="30" cy="30" r="30" fill="#000000a6" />
<path d="M30 30 L8 12 A29 29 0 0 1 52 12 Z" fill="#00ff40" />
<circle cx="30" cy="31" r="6" fill="white" />
<path d="M20 47 Q20 38 30 38 Q40 38 40 47 Z" fill="white" />
</g>
<template v-else>
<rect :width="widget.width" :height="widget.height" :fill="palette.cardFill" :stroke="palette.cardBorder" />
<text :x="widget.width / 2" :y="widget.height / 2" text-anchor="middle" :font-size="Math.min(32, widget.width / 10)">{{ widget.label }}</text>
</template>
</g>`,
}
export const OnroadLayoutPage = {
components: { LayoutWidgetPreview },
props: { projection: { type: Boolean, default: false }, mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
emits: ["close", "target"],
setup(props) {
const state = reactive({ status: "idle", data: null, draft: null, needsReload: false, error: "", notice: "",
profile: "large", scene: "engaged", selected: null, drag: null, discard: null, colorError: "", placementError: "",
showFavoriteZones: false, devicePreviewOpen: false, preview: { status: "idle", error: "", url: null },
history: { undo: [], redo: [], group: null } })
let deferred = null
const apply = (update) => {
if (state.drag) { deferred = { ...deferred, ...update }; return }
if (deferred) {
update = { ...deferred, ...update }
deferred = null
if (state.draft && state.data && JSON.stringify(state.draft) !== JSON.stringify(state.data.document)) {
update = { ...update }
delete update.draft
if (update.data && update.data.revision !== state.data.revision) {
update.needsReload = true
update.error = "Saved values changed while editing. Your edits are kept here. Reload before saving again."
}
}
}
if (update.data) {
if (!state.data && update.data.activeProfile) state.profile = update.data.activeProfile
if (!update.data.metadata.profiles[state.profile]) state.profile = Object.keys(update.data.metadata.profiles)[0]
if (!Object.hasOwn(update.data.metadata.profiles[state.profile].widgets, state.selected))
state.selected = Object.keys(update.data.metadata.profiles[state.profile].widgets)[0] || null
state.colorError = ""
state.placementError = ""
}
const draftChanged = Object.hasOwn(update, "draft") && JSON.stringify(state.draft) !== JSON.stringify(update.draft)
for (const [key, value] of Object.entries(update)) {
if (JSON.stringify(state[key]) !== JSON.stringify(value)) state[key] = value
}
if (draftChanged || ["Colors and both layouts saved.", "Android Auto layout saved."].includes(update.notice)) state.history = { undo: [], redo: [], group: null }
}
const feed = new OnroadLayoutFeed({ projection: props.projection, unauthorized: props.unauthorized, publish: apply })
watch(() => !!state.drag, (dragging) => { if (!dragging && deferred) apply({}) }, { flush: "sync" })
const previewFeed = new LayoutPreviewFeed({ unauthorized: props.unauthorized,
publish: (update) => Object.assign(state.preview, update) })
watch(() => [state.draft && JSON.stringify(state.draft), state.profile, state.scene, !!state.drag, state.data?.revision],
() => {
if (props.projection && state.draft && props.mode === 'local' && !state.devicePreviewOpen) {
state.devicePreviewOpen = true
previewFeed.start()
}
if (state.devicePreviewOpen && props.mode === "local") {
const document = props.projection ? projectionPayload({document: state.draft}, state.data.metadata).document : state.draft
previewFeed.update(document, props.projection ? 'projection' : state.profile, state.scene, !!state.drag)
}
}, { flush: "sync" })
watch(() => props.mode, (mode) => {
if (mode !== "local") { state.devicePreviewOpen = false; previewFeed.stop() }
})
return { state, feed, previewFeed, scenes: PREVIEW_SCENES }
},
computed: {
busy() { return ["loading", "saving"].includes(this.state.status) },
editable() { return !!this.state.data?.editable && !this.busy && !this.state.needsReload && !this.state.discard },
dirty() { return !!this.state.draft && JSON.stringify(this.state.draft) !== JSON.stringify(this.state.data.document) },
stockChanged() { return !!this.state.draft && JSON.stringify(this.state.draft) !== JSON.stringify(this.state.data.defaults) },
canUndo() { return this.state.history.undo.length > 0 },
canRedo() { return this.state.history.redo.length > 0 },
profile() { return this.state.data?.metadata.profiles[this.state.profile] },
layout() { return this.state.draft?.layouts[this.state.profile] },
widgets() { return Object.entries(this.profile?.widgets || {}).map(([id, widget]) => ({ id, ...widget })) },
activeWidgets() { return this.widgets.filter(({ id }) => this.layout[id].enabled) },
renderWidgets() { return this.activeWidgets.map((item) => {
const below = item.visualInsetTop && this.layout[item.id].y < item.visualInsetTop
const widget = { ...item, visualInsetTop: below ? 0 : item.visualInsetTop || 0,
visualInsetBottom: below ? item.visualInsetTop : 0, visualHeaderY: below ? item.height + 8 : -(item.visualInsetTop || 0) }
return widget.resizable ?
{ ...widget, width: this.layout[widget.id].size, height: this.layout[widget.id].size } : widget })
.sort((a, b) => (a.id === this.state.selected ? 2 : a.layer === "underlay" ? -1 : 0) -
(b.id === this.state.selected ? 2 : b.layer === "underlay" ? -1 : 0)) },
inactiveWidgets() { return this.widgets.filter(({ id }) => !this.layout[id].enabled || (this.state.drag?.fromTray && this.state.drag.id === id)) },
availableProfiles() { return this.state.data?.supportedProfiles || (this.state.data?.activeProfile ? [this.state.data.activeProfile] : []) },
selectedWidget() { return this.profile?.widgets[this.state.selected] },
selectedPosition() { return this.layout?.[this.state.selected] },
selectedColors() { return this.selectedWidget ? this.widgetColors(this.state.selected) : {} },
colorFields() { return (this.state.data?.metadata.paletteFields || []).filter((field) =>
Object.hasOwn(this.selectedWidget?.colors || {}, field.id)) },
roadStyle() { return this.state.draft?.roadColors[this.state.profile] || {} },
roadMode() { return this.roadStyle.pathMode || 'default' },
roadColors() { return { ...Object.fromEntries((this.state.data?.metadata.roadColorFields || []).map(field => [field.id, field.default])), ...this.roadStyle } },
roadGradient() {
if (this.roadMode === 'rainbow') return Array.from({ length: 12 }, (_, i) => ({ offset: i / 11,
color: 'hsl(' + i / 11 * 120 + ',100%,50%)', alpha: .5 - .4 * i / 11 }))
if (this.roadMode === 'color') return [1, .55, .10].map((factor, i) => ({ offset: 1 - i / 2,
color: this.roadColors.path.slice(0, 7), alpha: Math.floor(this.alpha(this.roadColors.path) * factor) / 255 }))
return [{ offset: 0, color: '#72ff5c', alpha: 0 }, { offset: .5, color: '#72ff5c', alpha: 89 / 255 },
{ offset: 1, color: '#0df87a', alpha: 102 / 255 }]
},
limits() { return this.selectedWidget ? placementLimits(this.profile, this.state.selected, this.layout) : null },
},
mounted() {
if (this.mode === "local") this.feed.start()
this._beforeUnload = (event) => { if (this.dirty || this.state.status === "saving") { event.preventDefault(); event.returnValue = "" } }
window.addEventListener("beforeunload", this._beforeUnload)
},
beforeUnmount() { this.cancelDrag(); this.feed.stop(); this.previewFeed.stop(); window.removeEventListener("beforeunload", this._beforeUnload) },
methods: {
finishColorEdit() { this.state.history.group = null },
recordChange(before, group = null) {
if (JSON.stringify(before) === JSON.stringify(this.state.draft)) return
const history = this.state.history
if (!group || history.group !== group) {
history.undo.push(before)
if (history.undo.length > 50) history.undo.shift()
}
history.redo = []
history.group = group
},
undo() {
if (!this.editable || this.state.drag || !this.canUndo) return
this.finishColorEdit()
const history = this.state.history
history.redo.push(clone(this.state.draft))
this.state.draft = history.undo.pop()
this.state.colorError = this.state.placementError = this.state.notice = ""
},
redo() {
if (!this.editable || this.state.drag || !this.canRedo) return
this.finishColorEdit()
const history = this.state.history
history.undo.push(clone(this.state.draft))
this.state.draft = history.redo.pop()
this.state.colorError = this.state.placementError = this.state.notice = ""
},
resetToStock() {
if (!this.editable || this.state.drag || !this.stockChanged) return
const before = clone(this.state.draft)
this.state.draft = clone(this.state.data.defaults)
this.recordChange(before)
this.state.colorError = this.state.placementError = ""
this.state.notice = "Stock StarPilot colors and both layouts are restored in your draft. Save to apply them."
},
showDevicePreview() {
if (this.mode !== "local" || !this.state.data || !this.state.draft || this.state.drag || this.state.devicePreviewOpen) return
this.state.devicePreviewOpen = true
this.previewFeed.start()
this.previewFeed.update(this.projection ? projectionPayload({document: this.state.draft}, this.state.data.metadata).document : this.state.draft, this.projection ? 'projection' : this.state.profile, this.state.scene)
},
hideDevicePreview() {
this.state.devicePreviewOpen = false
this.previewFeed.stop()
Object.assign(this.state.preview, { status: "idle", error: "", url: null })
},
selectProfile(profile) {
if (!this.availableProfiles.includes(profile) || this.state.drag) return
this.finishColorEdit()
this.state.profile = profile
this.state.selected = Object.keys(this.profile.widgets)[0] || null
this.state.placementError = ""
},
changePosition(id, x, y) {
if (!this.editable) return
const position = clampPlacement(this.profile, id, x, y, this.layout)
if (position) {
const before = this.state.drag ? null : clone(this.state.draft)
Object.assign(this.layout[id], position)
if (before) this.recordChange(before)
this.state.placementError = ""
} else this.state.placementError = "Keep widgets within the screen and clear of Speed limit actions. The last valid position is kept."
this.state.notice = ""
},
add(id) {
if (!this.editable || this.state.drag || !Object.hasOwn(this.profile.widgets, id)) return
const before = clone(this.state.draft)
this.layout[id].enabled = true
this.recordChange(before)
this.state.selected = id
this.state.notice = ""
},
remove(id = this.state.selected) {
if (!this.editable || this.state.drag || !Object.hasOwn(this.profile.widgets, id)) return
const before = clone(this.state.draft)
this.layout[id].enabled = false
this.recordChange(before)
this.state.notice = ""
},
positionInput(axis, event) {
const number = event.target.value.trim() === "" ? NaN : Number(event.target.value)
if (Number.isFinite(number)) this.changePosition(this.state.selected,
axis === "x" ? number : this.selectedPosition.x, axis === "y" ? number : this.selectedPosition.y)
event.target.value = this.selectedPosition[axis]
},
resizeSelected(event) {
if (!this.editable || this.state.drag || !this.selectedWidget?.resizable) return
const size = Number(event.target.value), range = this.selectedWidget.resizable
if (!Number.isInteger(size) || size < range.min || size > range.max) {
event.target.value = this.selectedPosition.size
return
}
const before = clone(this.state.draft), position = this.selectedPosition
position.size = size
const next = clampPlacement(this.profile, this.state.selected, position.x, position.y, this.layout)
if (next) {
Object.assign(position, next)
this.recordChange(before)
this.state.placementError = ""
} else {
Object.assign(position, before.layouts[this.state.profile][this.state.selected])
this.state.placementError = "That size would cover Speed limit actions. The last valid size is kept."
}
event.target.value = position.size
},
onKey(id, event) {
if (!this.editable) return
const movement = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key]
if (movement) {
event.preventDefault()
const step = event.shiftKey ? 10 : 1, position = this.layout[id]
this.changePosition(id, position.x + movement[0] * step, position.y + movement[1] * step)
} else if (["Delete", "Backspace"].includes(event.key)) { event.preventDefault(); this.remove(id) }
else if (["Enter", " "].includes(event.key)) { event.preventDefault(); this.state.selected = id }
},
startDrag(id, event, fromTray = false) {
if (!this.editable || event.button > 0 || this.state.drag || !Object.hasOwn(this.profile.widgets, id)) return
const point = previewPoint(event, this.$refs.preview.getBoundingClientRect(), this.profile)
if (!point) return
const position = this.layout[id], widget = this.profile.widgets[id]
const width = widget.resizable ? position.size : widget.width
const height = widget.resizable ? position.size : widget.height
this.state.selected = id
this.finishColorEdit()
this.state.drag = { id, pointerId: event.pointerId, fromTray, before: { ...position }, historyBefore: clone(this.state.draft), inside: !fromTray,
offsetX: fromTray ? width / 2 : point.x - position.x,
offsetY: fromTray ? height / 2 : point.y - position.y }
this._dragTarget = this.$refs.preview
this._dragTarget.setPointerCapture?.(event.pointerId)
event.preventDefault()
},
moveDrag(event) {
const drag = this.state.drag
if (!drag || drag.pointerId !== event.pointerId) return
const point = previewPoint(event, this.$refs.preview.getBoundingClientRect(), this.profile)
if (!point) return
drag.inside = point.x >= 0 && point.y >= 0 && point.x <= this.profile.width && point.y <= this.profile.height
if (!drag.fromTray || drag.inside) {
const x = point.x - drag.offsetX, y = point.y - drag.offsetY
if (drag.fromTray) drag.inside = clampPlacement(this.profile, drag.id, x, y, this.layout) !== null
this.changePosition(drag.id, x, y)
}
if (drag.fromTray) this.layout[drag.id].enabled = drag.inside
},
endDrag(event) {
const drag = this.state.drag
if (!drag || drag.pointerId !== event.pointerId) return
this.moveDrag(event)
if (drag.fromTray && !drag.inside) Object.assign(this.layout[drag.id], drag.before)
const before = drag.historyBefore
this.releaseDrag()
this.recordChange(before)
},
releaseDrag() {
const id = this.state.drag?.pointerId
this.state.drag = null
if (this._dragTarget?.hasPointerCapture?.(id)) this._dragTarget.releasePointerCapture(id)
this._dragTarget = null
},
cancelDrag() {
const drag = this.state.drag
if (drag) Object.assign(this.layout[drag.id], drag.before)
this.releaseDrag()
},
resetLayout() {
if (!this.editable || this.state.drag) return
const before = clone(this.state.draft)
this.state.draft.layouts[this.state.profile] = clone(this.state.data.defaults.layouts[this.state.profile])
this.recordChange(before)
this.state.placementError = ""
this.state.notice = "This layout is reset in your draft. Save to apply it."
},
resetColors() {
if (!this.editable || this.state.drag || !this.colorFields.length) return
const before = clone(this.state.draft)
const profile = this.state.profile, id = this.state.selected
delete this.state.draft.widgetColors[profile][id]
const inherited = this.selectedColors
const stock = widgetPalette(this.state.data.defaults, this.state.data.metadata, profile, id)
const overrides = Object.fromEntries(Object.entries(stock).filter(([key, value]) => value !== inherited[key]))
if (Object.keys(overrides).length) this.state.draft.widgetColors[profile][id] = overrides
this.recordChange(before)
this.state.colorError = ""
this.state.notice = "This widget’s colors are reset in your draft. Save to apply them."
},
roadPoints(points) {
const r = this.profile.bounds
return points.map(([x, y]) => (r.x + x*r.width) + ',' + (r.y + y*r.height)).join(' ')
},
roadLabel(id) {
if (id === 'pathEdge') return this.state.profile === 'large' ? 'Path border' : 'Closest lane markings'
if (id === 'laneLines') return this.state.profile === 'large' ? 'Lane lines' : 'Outer lane lines'
return 'Path'
},
setRoadMode(mode) {
if (!this.editable || this.state.drag || !PATH_MODES.includes(mode)) return
const before = clone(this.state.draft)
this.state.draft.roadColors[this.state.profile].pathMode = mode
this.recordChange(before)
},
roadColor(id, value, group = null) {
if (!this.editable || this.state.drag || !ROAD_COLORS.includes(id)) return
if (!HEX.test(value)) { this.state.colorError = "Use eight hex digits, for example #30FF9CFF."; return }
const before = clone(this.state.draft)
this.state.draft.roadColors[this.state.profile][id] = value.toUpperCase()
if (id === 'path') this.state.draft.roadColors[this.state.profile].pathMode = 'color'
this.recordChange(before, group && this.state.profile + ':road:' + group)
this.state.colorError = this.state.notice = ''
},
roadRgb(id, event) {
const alpha = this.roadColors[id].slice(7)
this.roadColor(id, event.target.value + (alpha === '00' ? 'FF' : alpha), 'rgb:' + id)
},
roadAlpha(id, event) {
const value = Number(event.target.value)
if (Number.isInteger(value) && value >= 0 && value <= 255)
this.roadColor(id, this.roadColors[id].slice(0, 7) + value.toString(16).padStart(2, '0'), 'alpha:' + id)
},
resetRoad() {
if (!this.editable || this.state.drag) return
const before = clone(this.state.draft)
this.state.draft.roadColors[this.state.profile] = {}
this.recordChange(before)
this.state.colorError = ''
},
widgetColors(id) { return widgetPalette(this.state.draft, this.state.data.metadata, this.state.profile, id) },
color(id, value, group = null) {
if (!this.editable || this.state.drag || !Object.hasOwn(this.selectedWidget?.colors || {}, id)) return
if (!HEX.test(value)) { this.state.colorError = "Use eight hex digits, for example #000000A6. The last two digits set opacity."; return }
const before = clone(this.state.draft)
const profile = this.state.profile, widget = this.state.selected
this.state.draft.widgetColors[profile][widget] ||= {}
this.state.draft.widgetColors[profile][widget][id] = value.toUpperCase()
this.recordChange(before, group && profile + ':' + widget + ':' + group)
this.state.colorError = this.state.notice = ""
},
colorText(id, event) { this.color(id, event.target.value.trim()); this.finishColorEdit(); event.target.value = this.selectedColors[id] },
colorRgb(id, event) {
const alpha = this.selectedColors[id]?.slice(7)
if (alpha) this.color(id, event.target.value + (alpha === '00' ? (id === 'cardFill' ? 'A6' : 'FF') : alpha), "rgb:" + id)
},
colorAlpha(id, event) {
const value = Number(event.target.value)
if (Number.isInteger(value) && value >= 0 && value <= 255)
this.color(id, this.selectedColors[id].slice(0, 7) + value.toString(16).padStart(2, "0"), "alpha:" + id)
},
alpha(color) { return parseInt(color.slice(7), 16) },
requestLeave(action) {
if (this.busy || this.state.drag) return
if (this.dirty) this.state.discard = action
else this.leave(action)
},
leave(action) {
this.state.discard = null
if (action === "back") { this.hideDevicePreview(); this.$emit("close") }
else if (["projection", "device"].includes(action)) { this.hideDevicePreview(); this.$emit("target", action) }
else this.feed.load()
},
save() { if (this.editable && this.dirty && !this.state.drag) return this.feed.save(this.state.draft) },
},
template: `
<section class="gx-settings gx-layout" aria-label="Colors and layout">
<div class="gx-settings__header"><div><h2>{{ projection ? 'Android Auto Layout' : 'Colors & Layout' }}</h2>
<p v-if="!projection">Choose a widget to move it or change its colors. Edit the layout available on this comma.</p><p v-else>Move widgets for the last connected Android Auto screen. The comma layout stays separate. Colors follow the comma theme. Changes apply on the next connection.</p></div>
<button class="gx-btn gx-btn--tonal" type="button" :disabled="busy || !!state.drag" @click="requestLeave('back')">Back</button>
</div>
<nav class="gx-layout__tabs" aria-label="Layout target">
<button class="gx-btn gx-btn--tonal" :aria-pressed="!projection" :disabled="busy || !!state.drag" @click="requestLeave('device')">Comma</button>
<button class="gx-btn gx-btn--tonal" :aria-pressed="projection" :disabled="busy || !!state.drag" @click="requestLeave('projection')">Android Auto</button>
</nav>
<p v-if="projection && state.data?.screen" class="gx-note">Last usable screen: {{ state.data.screen.width - state.data.screen.margin_width }} × {{ state.data.screen.height - state.data.screen.margin_height }} pixels. {{ state.data.reason || '' }}</p>
<div v-if="mode !== 'local'" class="gx-card gx-message" role="status">Connect to local Galaxy to edit this device’s colors and layouts.</div>
<template v-else>
<div class="gx-layout__savebar">
<span role="status">{{ state.status === 'saving' ? 'Saving…' : state.status === 'loading' ? 'Loading…' : !state.data ? 'No saved layout loaded' : dirty ? 'Unsaved changes' : state.notice || 'Saved on this device' }}</span>
<button class="gx-btn gx-btn--tonal" type="button" :disabled="busy || !!state.drag" @click="requestLeave('reload')">Reload saved</button>
<button class="gx-btn gx-btn--tonal" type="button" :disabled="!editable || !canUndo || !!state.drag" @click="undo">Undo</button>
<button class="gx-btn gx-btn--tonal" type="button" :disabled="!editable || !canRedo || !!state.drag" @click="redo">Redo</button>
<button class="gx-btn gx-btn--tonal gx-layout__reset" type="button" :disabled="!editable || !stockChanged || !!state.drag" @click="resetToStock">Reset to stock StarPilot</button>
<button class="gx-btn" type="button" :disabled="!editable || !dirty || !!state.drag" @click="save">Save changes</button>
</div>
<p v-if="state.error" class="gx-card gx-message" role="alert">{{ state.error }}</p>
<div v-if="state.discard" class="gx-card gx-layout__discard" role="alert">
<p>{{ ['back', 'device', 'projection'].includes(state.discard) ? 'Leave without saving your changes?' : 'Discard your edits and reload the saved colors and layouts?' }}</p>
<div class="gx-settings__controls"><button class="gx-btn gx-btn--tonal" type="button" @click="state.discard = null">Keep editing</button>
<button class="gx-btn" type="button" @click="leave(state.discard)">{{ ['back', 'device', 'projection'].includes(state.discard) ? 'Discard and leave' : 'Discard and reload' }}</button></div>
</div>
<template v-if="state.data && state.draft">
<p v-if="!state.data.editable && !(projection && state.data.reason)" class="gx-note" role="status">Park the vehicle and reload to edit. If it stays unavailable, reload saved settings.</p>
<p v-if="!state.data.valid" class="gx-note" role="status">{{ projection ? 'Default Android Auto positions are shown. Save to keep your separate layout.' : 'The saved customization is invalid. Default colors and positions are shown; save your edits to replace it.' }}</p>
<div class="gx-layout__tabs" aria-label="Layout size">
<button v-if="!projection" v-for="tab in [{id:'large',label:'Big'}, {id:'compact',label:'Small'}].filter(tab => availableProfiles.includes(tab.id))" :key="tab.id" class="gx-btn gx-btn--tonal" type="button"
:aria-pressed="state.profile === tab.id" :disabled="!!state.drag" @click="selectProfile(tab.id)">{{ tab.label }}<span v-if="state.data.activeProfile === tab.id"> · Active UI</span></button>
</div>
<div class="gx-layout__workspace">
<section class="gx-card gx-layout__preview-card" aria-label="Driving screen preview">
<div class="gx-layout__subhead"><strong>Layout preview</strong><span>{{ profile.width }} × {{ profile.height }}</span></div>
<p class="gx-note">Drag widgets to place them. Readings are samples.</p>
<label v-if="!projection && profile.inputZones?.length" class="gx-layout__zone-toggle"><input type="checkbox" v-model="state.showFavoriteZones"> Show Favorite tap areas</label>
<svg ref="preview" class="gx-layout__preview" :viewBox="'0 0 ' + profile.width + ' ' + profile.height" :width="profile.width" :height="profile.height"
@pointermove="moveDrag" @pointerup="endDrag" @pointercancel="cancelDrag" @lostpointercapture="cancelDrag"
role="group" :aria-label="projection ? 'Android Auto layout preview' : (state.profile === 'large' ? 'Big' : 'Small') + ' layout preview'">
<rect width="100%" height="100%" fill="#252b32" />
<polygon :points="roadPoints([[0,1],[.4,.45],[.55,.45],[.83,1]])" fill="#303943" />
<defs><linearGradient id="layout-road-gradient" gradientUnits="userSpaceOnUse" x1="0" :y1="profile.bounds.y" x2="0" :y2="profile.bounds.y + profile.bounds.height">
<stop v-for="stop in roadGradient" :key="stop.offset" :offset="stop.offset" :stop-color="stop.color" :stop-opacity="stop.alpha" />
</linearGradient></defs>
<defs><linearGradient id="layout-edge-gradient" gradientUnits="userSpaceOnUse" x1="0" :y1="profile.bounds.y" x2="0" :y2="profile.bounds.y + profile.bounds.height">
<stop offset="0" :stop-color="roadColors.pathEdge.slice(0,7)" stop-opacity="0" />
<stop offset=".5" :stop-color="roadColors.pathEdge.slice(0,7)" :stop-opacity="Math.floor(alpha(roadColors.pathEdge) * .35) / 255" />
<stop offset="1" :stop-color="roadColors.pathEdge.slice(0,7)" :stop-opacity="Math.floor(alpha(roadColors.pathEdge) * .4) / 255" />
</linearGradient></defs>
<polygon v-if="state.profile === 'large' && roadStyle.pathEdge" v-for="strip in [[[.18,1],[.44,.45],[.447,.45],[.231,1]], [[.639,1],[.503,.45],[.51,.45],[.69,1]]]"
:points="roadPoints(strip)" fill="url(#layout-edge-gradient)" />
<polygon :points="roadPoints(state.profile === 'large' && roadStyle.pathEdge ? [[.231,1],[.447,.45],[.503,.45],[.639,1]] : [[.18,1],[.44,.45],[.51,.45],[.69,1]])" fill="url(#layout-road-gradient)" />
<path v-for="(line,index) in [[[.16,1],[.43,.45]],[[.71,1],[.52,.45]],[[.02,1],[.38,.45]],[[.85,1],[.57,.45]]]" :key="'road-line-'+index"
:d="'M'+roadPoints(line).replace(' ', ' L')"
:stroke="state.profile === 'compact' && index < 2 ? (roadStyle.pathEdge || '#00ff40ff') : roadColors.laneLines"
opacity=".7" :stroke-width="state.profile === 'compact' ? 2 : 5" />
<rect v-if="!projection" :x="state.profile === 'large' ? 1860 : 476" y="0" :width="state.profile === 'large' ? 300 : 60" :height="profile.height" fill="#090c10" />
<g v-if="!projection && state.profile === 'large'" aria-hidden="true" pointer-events="none">
<text x="1874" y="17" font-size="14" font-weight="600" fill="#ffffffd2">SAMPLE DATA</text>
<g v-for="(sample, index) in [{label:'ACCEL',value:'1.08 ft/s²',accent:'#ffffff'}, {label:'STEER DELAY',value:'0.15000',accent:'#3b82f6'}, {label:'FRICTION',value:'0.12000',accent:'#22c55e'}, {label:'LAT ACCEL',value:'2.50000',accent:'#3b82f6'}, {label:'LATERAL %',value:'78.00%',accent:'#ffffff'}, {label:'TORQUE %',value:'42%',accent:'#ffffff'}, {label:'CHESTNUT',value:'',accent:'#ffffff'}]" :key="sample.label" :transform="'translate(1873 ' + (24 + index * 150) + ')'">
<rect width="275" height="126" rx="16" fill="#000000" stroke="#ffffff55" stroke-width="2" />
<rect x="253" y="4" width="18" height="118" rx="4" :fill="sample.accent" />
<text x="137" :y="sample.value ? 51 : 75" text-anchor="middle" :font-size="sample.label === 'STEER DELAY' ? 26 : 30" font-weight="600" fill="#ffffff">{{ sample.label }}</text>
<text v-if="sample.value" x="137" y="91" text-anchor="middle" font-size="30" font-weight="600" fill="#ffffff">{{ sample.value }}</text>
</g>
</g>
<rect :x="profile.bounds.x" :y="profile.bounds.y" :width="profile.bounds.width" :height="profile.bounds.height" fill="none" stroke="#8495a855" stroke-width="2" stroke-dasharray="8 8" />
<g v-if="!projection && state.showFavoriteZones" aria-hidden="true" pointer-events="none">
<g v-for="zone in profile.inputZones || []" :key="zone.id">
<rect :x="zone.x" :y="zone.y" :width="zone.width" :height="zone.height" fill="#b799ff16" stroke="#b799ff88" :stroke-width="state.profile === 'large' ? 3 : 1" stroke-dasharray="5 5" />
<text :x="zone.x + zone.width / 2" :y="zone.y + zone.height - 8" text-anchor="middle" :font-size="state.profile === 'large' ? 20 : 10" fill="#d8c8ff">{{ zone.label }}</text>
</g>
</g>
<g v-for="widget in renderWidgets" :key="widget.id" class="gx-layout__widget" :class="{'is-selected': state.selected === widget.id}"
:transform="'translate(' + layout[widget.id].x + ' ' + layout[widget.id].y + ')'" tabindex="0" role="button"
:aria-label="widget.label + ', x ' + layout[widget.id].x + ', y ' + layout[widget.id].y + '. Arrow keys move; Shift moves ten pixels; Delete removes.'"
:aria-pressed="state.selected === widget.id" @focus="state.selected = widget.id" @keydown="onKey(widget.id, $event)"
@pointerdown="startDrag(widget.id, $event)">
<rect class="gx-layout__hit" :y="-(widget.visualInsetTop || 0)" :width="widget.width" :height="widget.height + (widget.visualInsetTop || 0) + (widget.visualInsetBottom || 0)" fill="transparent" />
<LayoutWidgetPreview :widget="widget" :palette="widgetColors(widget.id)" :profile="state.profile" :scene="state.scene" />
<rect class="gx-layout__selection" :y="-(widget.visualInsetTop || 0)" :width="widget.width" :height="widget.height + (widget.visualInsetTop || 0) + (widget.visualInsetBottom || 0)" fill="none" stroke="#b799ff" :stroke-width="state.profile === 'large' ? 5 : 1.5" stroke-dasharray="6 4" />
</g>
</svg>
<button class="gx-btn gx-btn--tonal" type="button" :disabled="!!state.drag" @click="state.devicePreviewOpen ? hideDevicePreview() : showDevicePreview()">{{ state.devicePreviewOpen ? 'Hide Device preview' : 'Show Device preview' }}</button>
<section v-if="state.devicePreviewOpen" class="gx-layout__device" aria-label="Device preview">
<div class="gx-layout__subhead"><strong>{{ projection ? 'Android Auto renderer preview' : 'Device preview' }}</strong><span>Preview scene</span></div>
<div class="gx-layout__scenes" aria-label="Device preview scene">
<button v-for="scene in scenes" :key="scene.id" class="gx-btn gx-btn--tonal" type="button" :aria-pressed="state.scene === scene.id"
:disabled="!!state.drag" @click="state.scene = scene.id">{{ scene.label }}</button>
</div>
<div class="gx-layout__device-frame" :style="{ aspectRatio: profile.width + ' / ' + profile.height }">
<img v-if="state.preview.url" :src="state.preview.url" alt="Device-rendered sample driving screen" />
<span v-else role="status">{{ state.preview.status === 'editing' ? 'Finish moving the widget to refresh Device preview.' : state.preview.status === 'updating' ? 'Updating Device preview…' : state.preview.status === 'unavailable' ? state.preview.error : 'Device preview is waiting.' }}</span>
</div>
<button v-if="state.preview.status === 'unavailable'" class="gx-btn gx-btn--tonal" type="button" @click="previewFeed.retry()">Retry Device preview</button>
</section>
<p class="gx-note">The steering wheel can be resized in each layout. Existing display preferences and driving state still control when widgets appear.</p>
<p v-if="!projection" class="gx-note">Small speed-limit signs can overlap confirmation actions. Live confirmations hide an overlapping sign until the decision ends. Select either widget in the list to edit it.</p>
<p v-if="!projection && state.showFavoriteZones" class="gx-note">Favorite tap areas apply to enabled slots. Speed-limit controls and the steering wheel take priority when the drawer is closed.</p>
<p v-if="state.placementError" class="gx-note" role="status">{{ state.placementError }}</p>
</section>
<section class="gx-card gx-layout__inspector" aria-label="Widget placement">
<h3>Widgets</h3>
<div class="gx-layout__widget-list"><button v-for="widget in activeWidgets" :key="widget.id" class="gx-btn gx-btn--tonal" type="button"
:aria-pressed="state.selected === widget.id" @click="state.selected = widget.id">{{ widget.label }}</button></div>
<div v-if="selectedWidget && selectedPosition.enabled" class="gx-layout__position">
<strong>{{ selectedWidget.label }}</strong><span class="gx-note">{{ selectedWidget.resizable ? selectedPosition.size : selectedWidget.width }} × {{ selectedWidget.resizable ? selectedPosition.size : selectedWidget.height + (selectedWidget.visualInsetTop || 0) }} pixels</span>
<label v-if="selectedWidget.resizable">Size
<input class="gx-field" type="number" step="1" :min="selectedWidget.resizable.min" :max="selectedWidget.resizable.max"
:value="selectedPosition.size" :disabled="!editable || !!state.drag" @change="resizeSelected($event)"></label>
<p v-if="selectedWidget.defaultAnchor === 'driver_side'" class="gx-note">The default position follows the driver’s side. A moved position stays where you place it.</p>
<p v-if="selectedWidget.note" class="gx-note">{{ selectedWidget.note }}</p>
<div class="gx-layout__coordinates"><label>X<input class="gx-field" type="number" step="1" :min="limits.minX" :max="limits.maxX" :value="selectedPosition.x" :disabled="!editable || !!state.drag" @change="positionInput('x', $event)"></label>
<label>Y<input class="gx-field" type="number" step="1" :min="limits.minY" :max="limits.maxY" :value="selectedPosition.y" :disabled="!editable || !!state.drag" @change="positionInput('y', $event)"></label></div>
<span class="gx-note">Use arrow keys on a preview widget to move 1 pixel, or Shift + arrow for 10.</span>
<section v-if="!projection && colorFields.length" class="gx-layout__colors" :aria-label="selectedWidget.label + ' colors'">
<div class="gx-layout__subhead"><h4>{{ selectedWidget.label }} colors</h4><button class="gx-btn gx-btn--tonal" type="button" :disabled="!editable" @click="resetColors">Reset widget colors</button></div>
<p class="gx-note">Only this widget in the {{ state.profile === 'large' ? 'Big' : 'Small' }} layout changes. Status indicators keep their meaning. Picking a color makes a transparent fill or border visible; use Opacity to fade it.</p>
<div class="gx-layout__palette"><div v-for="field in colorFields" :key="field.id" class="gx-layout__color">
<label :for="'layout-color-' + field.id">{{ field.label }}</label><div class="gx-layout__color-values">
<input type="color" :aria-label="field.label + ' color'" :value="selectedColors[field.id].slice(0,7)" :disabled="!editable || !!state.drag" @input="colorRgb(field.id, $event)" @change="finishColorEdit" @blur="finishColorEdit">
<input :id="'layout-color-' + field.id" class="gx-field" type="text" spellcheck="false" maxlength="9" :value="selectedColors[field.id]" :disabled="!editable || !!state.drag" @change="colorText(field.id, $event)"></div>
<label :for="'layout-alpha-' + field.id">Opacity · {{ Math.round(alpha(selectedColors[field.id]) / 255 * 100) }}%</label>
<input :id="'layout-alpha-' + field.id" class="gx-slider" type="range" min="0" max="255" step="1" :value="alpha(selectedColors[field.id])" :disabled="!editable || !!state.drag" @input="colorAlpha(field.id, $event)" @change="finishColorEdit" @blur="finishColorEdit">
</div></div>
<p v-if="state.colorError" class="gx-note" role="alert">{{ state.colorError }}</p>
</section>
<p v-if="!projection && !colorFields.length" class="gx-note">This widget uses its original status colors.</p>
<button class="gx-btn gx-btn--tonal" type="button" :disabled="!editable || !!state.drag" @click="remove()">Remove from layout</button>
</div>
<div class="gx-layout__tray"><h4>Inactive Widgets</h4><p class="gx-note">Drag onto the preview, or select Add.</p>
<div v-for="widget in inactiveWidgets" :key="widget.id" class="gx-layout__tray-item">
<button class="gx-layout__drag-handle" type="button" :disabled="!editable" :aria-label="'Drag ' + widget.label + ' onto preview'"
@pointerdown="startDrag(widget.id, $event, true)"><i class="bi bi-grip-vertical" aria-hidden="true"></i>{{ widget.label }}</button>
<button class="gx-btn gx-btn--tonal" type="button" :disabled="!editable || !!state.drag" @click="add(widget.id)">Add</button>
</div>
<p v-if="!inactiveWidgets.length" class="gx-note">All available widgets are on this layout.</p>
</div>
<button class="gx-btn gx-btn--tonal gx-layout__reset" type="button" :disabled="!editable || !!state.drag" @click="resetLayout">Reset this layout</button>
<section v-if="!projection" class="gx-layout__colors" aria-label="Path and lane colors">
<div class="gx-layout__subhead"><h4>Path &amp; Lane Colors</h4><button class="gx-btn gx-btn--tonal" type="button" :disabled="!editable || !!state.drag" @click="resetRoad">Reset road colors</button></div>
<label>Path style<select class="gx-field" :value="roadMode" :disabled="!editable || !!state.drag" @change="setRoadMode($event.target.value)">
<option value="default">Follow Appearance Settings</option><option value="acceleration">Acceleration colors</option>
<option value="color">Custom color</option><option value="rainbow">Rainbow Road</option>
</select></label>
<p class="gx-note">Choose how the path is colored. Follow Appearance Settings uses your existing Rainbow Road preference. Acceleration colors change with acceleration and braking. Custom color stays one color. Rainbow Road shows a moving rainbow.</p>
<p class="gx-note">{{ state.profile === 'large' ? 'Path border colors the strips along the path. Lane lines keep their own color.' : 'Lane marking colors apply to the lines beside your lane and the outer lines separately.' }} Blue lane-centering and orange steering-effort indicators stay blue and orange.</p>
<div class="gx-layout__palette"><div v-for="field in state.data.metadata.roadColorFields" :key="field.id" class="gx-layout__color">
<label :for="'road-color-' + field.id">{{ roadLabel(field.id) }}</label><div class="gx-layout__color-values">
<input type="color" :aria-label="roadLabel(field.id) + ' color'" :value="roadColors[field.id].slice(0,7)" :disabled="!editable || !!state.drag" @input="roadRgb(field.id, $event)" @change="finishColorEdit" @blur="finishColorEdit">
<input :id="'road-color-' + field.id" class="gx-field" type="text" spellcheck="false" maxlength="9" :value="roadColors[field.id]" :disabled="!editable || !!state.drag" @change="roadColor(field.id, $event.target.value.trim())"></div>
<label :for="'road-alpha-' + field.id">Opacity · {{ Math.round(alpha(roadColors[field.id]) / 255 * 100) }}%</label>
<input :id="'road-alpha-' + field.id" class="gx-slider" type="range" min="0" max="255" step="1" :value="alpha(roadColors[field.id])" :disabled="!editable || !!state.drag" @input="roadAlpha(field.id, $event)" @change="finishColorEdit" @blur="finishColorEdit">
</div></div>
<p class="gx-note">These colors apply to this layout only. The preview uses a sample road. Follow Appearance Settings previews acceleration colors; your saved Rainbow Road preference still applies while driving.</p>
<p v-if="state.colorError" class="gx-note" role="alert">{{ state.colorError }}</p>
</section>
</section>
</div>
</template>
</template>
</section>`,
}
@@ -0,0 +1,30 @@
// Saved centers are pixels in the unmirrored cabin frame. Invert changes only
// the displayed crop pixels; vehicle-side selection always uses source sides.
export const FORMATS = [[1928, 1208], [1344, 760]]
export function displayPoint(center, width, invert) {
return center === null ? null : [invert ? width - center[0] : center[0], center[1]]
}
export function sourcePoint(clientX, clientY, rect, width, height, invert) {
if (!(rect?.width > 0 && rect.height > 0) || typeof invert !== "boolean" ||
!FORMATS.some(([w, h]) => w === width && h === height)) return null
const x = (clientX - rect.left) / rect.width
const y = (clientY - rect.top) / rect.height
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x > 1 || y < 0 || y > 1) return null
return [Math.round((invert ? 1 - x : x) * width), Math.round(y * height)]
}
const centerValid = (center, width, height, size) => center === null ||
(Array.isArray(center) && center.length === 2 && center.every(Number.isInteger) &&
center[0] - size / 2 >= 0 && center[0] + size / 2 <= width &&
center[1] - size / 2 >= 0 && center[1] + size / 2 <= height)
export function maskDraft(width, height, cropSize, centerLeft, centerRight) {
if (!FORMATS.some(([w, h]) => w === width && h === height) ||
!Number.isInteger(cropSize) || cropSize < 20 || cropSize > Math.min(width, height) ||
(centerLeft === null && centerRight === null) ||
!centerValid(centerLeft, width, height, cropSize) ||
!centerValid(centerRight, width, height, cropSize)) return null
return { width, height, crop_size: cropSize, center_left: centerLeft, center_right: centerRight }
}
+243
View File
@@ -0,0 +1,243 @@
import { reactive } from "../vendor/vue/vue.esm-browser.js"
import { CameraSnapshotFeed } from "./cameras.js"
import { SettingsFeed } from "./settings.js"
import { GalaxySettingRow } from "./galaxy-setting-row.js"
import { displayPoint, FORMATS, maskDraft, sourcePoint } from "./pip-geometry.js"
const SIDES = [
{ key: "centerRight", label: "Vehicle left / camera right", color: "#5ee5ee" },
{ key: "centerLeft", label: "Vehicle right / camera left", color: "#ffb865" },
]
export const PipPage = {
name: "PipPage",
components: { GalaxySettingRow },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
go: { type: Function, required: true } },
setup(props) {
const state = reactive({ status: "idle", data: null, pending: null, error: "", width: 1928, height: 1208,
cropSize: 580, centerLeft: null, centerRight: null, invert: null, activeSide: "centerRight",
localNote: "", imageName: "", reviewing: false })
let lastView = null
const feed = new SettingsFeed({ unauthorized: props.unauthorized, publish: (update) => {
Object.assign(state, update)
if (update.status === "ready" && update.data?.view && update.data.view !== lastView) {
lastView = update.data.view
const editor = update.data.editor
if (editor) {
state.width = editor.width
state.height = editor.height
state.cropSize = editor.cropSize
state.centerLeft = editor.centerLeft === null ? null : [...editor.centerLeft]
state.centerRight = editor.centerRight === null ? null : [...editor.centerRight]
state.invert = editor.invert
state.localNote = ""
} else {
state.invert = null
state.localNote = "Restore the invalid saved crop or mirror choice before editing."
}
}
} })
return { state, feed, FORMATS, SIDES }
},
mounted() {
this._image = null
this._imageGeneration = 0
if (this.mode === "local") this.feed.start("pip")
this._liveStopped = false
this.snapshots = new CameraSnapshotFeed({ unauthorized: this.unauthorized, publish: (update) => {
if (update.error) { this.state.localNote = update.error; this._image = null; this.state.imageName = "" }
if (!update.image) return
const generation = ++this._imageGeneration
const image = new Image()
image.onload = () => {
if (this._liveStopped || generation !== this._imageGeneration) return
this._image = image
this.state.imageName = "Live cabin camera"
this.state.localNote = ""
this.redraw()
}
image.onerror = () => { this.state.localNote = "Camera frame could not be displayed."; this._image = null }
image.src = update.image
} })
const refresh = async () => {
if (this._liveStopped) return
if (this.mode === "local" && !document.hidden) await this.snapshots.capture("cabin")
if (!this._liveStopped) this._liveTimer = setTimeout(refresh, 1500)
}
this.visibility = () => { if (document.hidden) { this.snapshots.stop(); this._image = null; this.state.imageName = "" } }
document.addEventListener("visibilitychange", this.visibility)
refresh()
this.$nextTick(() => this.redraw())
},
updated() {
this.$nextTick(() => this.redraw())
},
beforeUnmount() {
this._liveStopped = true
clearTimeout(this._liveTimer)
document.removeEventListener("visibilitychange", this.visibility)
this.snapshots?.stop()
this.feed.stop()
this.dropImage()
},
computed: {
controls() { return (this.state.data?.rows || []).map((row, index) => ({ row, index }))
.filter(({ row }) => row.label !== "Visual camera crop editor" && !row.key?.startsWith("pip:mask:")) },
canEdit() { return !!this.state.imageName && this.mode === "local" && !!this.state.data?.parked && this.state.data?.editorRow >= 0 &&
!!this.state.data.rows[this.state.data.editorRow]?.available && this.state.status === "ready" &&
!this.state.pending && !this.state.reviewing && typeof this.state.invert === "boolean" &&
FORMATS.some(([w, h]) => w === this.state.width && h === this.state.height) },
},
methods: {
dropImage() {
this._imageGeneration++
if (this._image) this._image.src = ""
this._image = null
this.state.imageName = ""
},
position(axis, event) {
if (!this.canEdit) return
const center = [...(this.state[this.state.activeSide] || [this.state.width / 2, this.state.height / 2])]
center[axis] = Number(event.target.value)
const left = this.state.activeSide === "centerLeft" ? center : this.state.centerLeft
const right = this.state.activeSide === "centerRight" ? center : this.state.centerRight
if (!maskDraft(this.state.width, this.state.height, this.state.cropSize, left, right)) return
this.state[this.state.activeSide] = center
this.redraw()
},
choose(side) { if (this.canEdit) this.state.activeSide = side },
place(event) {
if (!this.canEdit) return
const canvas = this.$refs.canvas
const point = sourcePoint(event.clientX, event.clientY, canvas?.getBoundingClientRect(),
this.state.width, this.state.height, this.state.invert)
if (!point) return
const nextLeft = this.state.activeSide === "centerLeft" ? point : this.state.centerLeft
const nextRight = this.state.activeSide === "centerRight" ? point : this.state.centerRight
if (!maskDraft(this.state.width, this.state.height, this.state.cropSize, nextLeft, nextRight)) {
this.state.localNote = "Place the center far enough from the frame edge for the whole crop."
return
}
this.state.centerLeft = nextLeft
this.state.centerRight = nextRight
this.state.localNote = "Draft only; review both centers and crop size before saving."
this.redraw()
},
clear(side) {
if (!this.canEdit) return
this.state[side] = null
this.redraw()
},
size(event) {
if (!this.canEdit) return
const value = Number(event.target.value)
if (!maskDraft(this.state.width, this.state.height, value, this.state.centerLeft, this.state.centerRight)) {
this.state.localNote = "That crop size would cross the image edge or leave no configured side."
event.target.value = String(this.state.cropSize)
return
}
this.state.cropSize = value
this.redraw()
},
async saveCrop() {
if (!this.canEdit) return
const draft = maskDraft(this.state.width, this.state.height, this.state.cropSize,
this.state.centerLeft, this.state.centerRight)
if (!draft) { this.state.localNote = "Keep at least one complete crop inside the image."; return }
this.state.reviewing = true
try { await this.feed.preview(this.state.data.editorRow, 0, draft) }
finally { this.state.reviewing = false }
},
redraw() {
const canvas = this.$refs.canvas
if (!canvas || !FORMATS.some(([w, h]) => w === this.state.width && h === this.state.height)) return
if (canvas.width !== this.state.width) canvas.width = this.state.width
if (canvas.height !== this.state.height) canvas.height = this.state.height
const ctx = canvas.getContext("2d")
if (!ctx) return
const w = canvas.width, h = canvas.height
ctx.fillStyle = "#151821"
ctx.fillRect(0, 0, w, h)
if (this._image) {
if (this.state.invert) { ctx.save(); ctx.translate(w, 0); ctx.scale(-1, 1) }
ctx.drawImage(this._image, 0, 0, w, h)
if (this.state.invert) ctx.restore()
} else {
ctx.strokeStyle = "#38404e"
ctx.beginPath(); ctx.moveTo(w / 2, 0); ctx.lineTo(w / 2, h); ctx.stroke()
}
const preview = this.$refs.preview
const center = this.state[this.state.activeSide]
if (preview && this._image && center) {
preview.width = preview.height = 300
const crop = preview.getContext("2d")
crop.save()
if (this.state.invert) { crop.translate(300, 0); crop.scale(-1, 1) }
const size = this.state.cropSize
crop.drawImage(this._image, (center[0] - size / 2) * this._image.naturalWidth / w,
(center[1] - size / 2) * this._image.naturalHeight / h,
size * this._image.naturalWidth / w, size * this._image.naturalHeight / h, 0, 0, 300, 300)
crop.restore()
}
for (const side of SIDES) {
const center = this.state[side.key]
if (!center) continue
const [x, y] = displayPoint(center, w, this.state.invert)
const half = this.state.cropSize / 2
ctx.strokeStyle = side.color
ctx.lineWidth = Math.max(2, w / 640)
ctx.strokeRect(x - half, y - half, this.state.cropSize, this.state.cropSize)
ctx.beginPath(); ctx.arc(x, y, Math.max(4, w / 320), 0, Math.PI * 2); ctx.fillStyle = side.color; ctx.fill()
}
},
},
template: `
<section class="gx-settings gx-pip" aria-label="Blind Spot Camera saved settings">
<header class="gx-card gx-settings__header"><div><p class="gx-eyebrow">Saved preferences</p><h2>Blind Spot Camera</h2>
<p>Adjust the native Blind Spot Camera crop using a live cabin preview.</p></div>
<button type="button" class="gx-btn gx-btn--tonal" @click="go('/cameras')">Back to Cameras</button></header>
<div v-if="mode !== 'local'" class="gx-card gx-message" role="status">Local saved settings are unavailable in preview.</div>
<template v-else>
<div v-if="state.status === 'loading'" class="gx-card gx-message" role="status">Loading saved settings…</div>
<div v-else-if="state.status === 'unavailable'" class="gx-card gx-message" role="alert">Saved settings are unavailable.</div>
<div v-if="state.error" class="gx-card gx-message" role="alert">{{ state.error }}
<button type="button" class="gx-btn gx-btn--tonal" @click="feed.load()">Refresh</button></div>
<div v-if="state.data" class="gx-settings__body">
<div class="gx-settings__subhead"><p>{{ state.data.subtitle }} <span v-if="!state.data.parked">Turn the vehicle off to change these settings.</span></p>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="state.status === 'saving'" @click="feed.load()">Refresh</button></div>
<section class="gx-card gx-settings__section" aria-label="Saved settings">
<GalaxySettingRow v-for="{ row, index } in controls" :key="state.data.view + ':' + index" :row="row" :index="index"
:disabled="!state.data.parked || state.status !== 'ready' || state.reviewing || !!state.pending"
:save-value="(index, value) => feed.previewValue(index, value)"
@review="(index, direction) => feed.preview(index, direction)" />
</section>
<section class="gx-card gx-vasm__editor" aria-label="Blind Spot Camera crop editor">
<h3>Live Camera Crop</h3>
<p>Choose a vehicle side and adjust horizontal position, vertical position, and crop size.</p>
<p v-if="!state.imageName" role="status">Waiting for a fresh cabin frame. Turn off the vehicle to preview and edit the crop.</p>
<p v-if="state.localNote" class="gx-note" role="status">{{ state.localNote }}</p>
<div class="gx-vasm__sides"><div v-for="side in SIDES" :key="side.key" class="gx-vasm__side">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!canEdit" :aria-pressed="state.activeSide === side.key" @click="choose(side.key)">{{ side.label }}</button>
<span>{{ state[side.key] ? state[side.key].join(', ') + ' px' : 'Not configured' }}</span>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!canEdit || !state[side.key]" :aria-label="'Clear ' + side.label" @click="clear(side.key)">Clear</button>
</div></div>
<canvas v-show="state.imageName" ref="canvas" class="gx-vasm__canvas" :aria-label="'Cabin source crop canvas, editing ' + (state.activeSide === 'centerRight' ? 'vehicle left' : 'vehicle right')" @pointerdown="place"></canvas>
<canvas v-show="state.imageName" ref="preview" aria-label="Live selected crop preview" style="max-width:300px;width:100%"></canvas>
<label>Horizontal position
<input type="range" :min="Math.ceil(state.cropSize / 2)" :max="state.width - Math.ceil(state.cropSize / 2)" :value="state[state.activeSide]?.[0] || state.width / 2" :disabled="!canEdit" @input="position(0, $event)" /></label>
<label>Vertical position
<input type="range" :min="Math.ceil(state.cropSize / 2)" :max="state.height - Math.ceil(state.cropSize / 2)" :value="state[state.activeSide]?.[1] || state.height / 2" :disabled="!canEdit" @input="position(1, $event)" /></label>
<label>Shared square crop size: {{ state.cropSize }} px
<input type="range" min="20" :max="Math.min(state.width, state.height)" step="1" :value="state.cropSize" :disabled="!canEdit" @input="size" /></label>
<div class="gx-settings__controls"><button type="button" class="gx-btn" :disabled="!canEdit" @click="saveCrop">Save Crop</button></div>
<p class="gx-note">The C3 bubble and C4 panel use this saved crop.</p>
</section>
</div>
<Teleport to="body"><div v-if="state.pending" class="gx-settings__modal" role="dialog" aria-modal="true" aria-label="Confirm Blind Spot Camera preference">
<div class="gx-card gx-settings__dialog"><h3>Confirm Saved Preference</h3><p>{{ state.pending.question }}</p>
<div class="gx-settings__controls"><button type="button" class="gx-btn gx-btn--tonal" @click="feed.cancel()">Cancel</button>
<button type="button" class="gx-btn" @click="feed.confirm()">Save</button></div></div></div></Teleport>
</template>
</section>`,
}
+249
View File
@@ -0,0 +1,249 @@
// Bounded, read-only local control history. No layout or telemetry is persisted.
export const MAX_POINTS = 240
export const POLL_MS = 750
const SIGNALS = ['desiredLateralAccel', 'actualLateralAccel', 'desiredLongitudinalAccel', 'actualLongitudinalAccel',
'lateralP', 'lateralI', 'lateralD', 'lateralF', 'longitudinalP', 'longitudinalI', 'longitudinalF', 'speedMps']
const STATES = ['current', 'stale', 'unavailable']
const SOURCES = {
lateralSource: ['unavailable', 'torqueState', 'curvature'],
longitudinalSource: ['unavailable', 'aTarget', 'pidSum'],
lateralTermsSource: ['unavailable', 'torqueState', 'pidState'],
longitudinalTermsSource: ['unavailable', 'controlsState'],
speedSource: ['unavailable', 'deviceMotion', 'carState'],
}
export function validatePlotPayload(data) {
if (data?.schemaVersion !== 1 || !/^[0-9a-f]{16}$/.test(data.sessionId) || !STATES.includes(data.state) ||
!Number.isSafeInteger(data.sampleIndex) || data.sampleIndex < 0 ||
!(data.sampleAgeSeconds === null || Number.isFinite(data.sampleAgeSeconds) && data.sampleAgeSeconds >= 0) ||
typeof data.error !== 'string' || data.error.length > 80 || typeof data.bootStabilizing !== 'boolean' ||
!(data.values === null || typeof data.values === 'object')) throw new Error('Invalid plot sample')
if (data.state !== 'current') {
if (data.values !== null) throw new Error('Stale plot values')
return data
}
if (!data.values || data.sampleAgeSeconds === null || data.sampleAgeSeconds > 1.5 ||
data.values.controlsFresh !== true || typeof data.values.poseFresh !== 'boolean' ||
typeof data.values.controlsActive !== 'boolean' || typeof data.values.lateralControlActive !== 'boolean' ||
typeof data.values.longitudinalControlActive !== 'boolean') throw new Error('Invalid plot source')
for (const key of SIGNALS) if (!(data.values[key] === null || typeof data.values[key] === 'number' && Number.isFinite(data.values[key]))) throw new Error('Invalid plot value')
for (const [key, allowed] of Object.entries(SOURCES)) if (!allowed.includes(data.values[key])) throw new Error('Invalid plot source')
if (data.values.lateralSource === 'unavailable' && (data.values.desiredLateralAccel !== null || data.values.actualLateralAccel !== null)) throw new Error('Invalid lateral source')
if (data.values.longitudinalSource === 'unavailable' && data.values.desiredLongitudinalAccel !== null) throw new Error('Invalid longitudinal source')
if (!data.values.poseFresh && data.values.actualLongitudinalAccel !== null) throw new Error('Invalid pose source')
if (data.values.speedSource === 'unavailable' && data.values.speedMps !== null) throw new Error('Invalid speed source')
return data
}
export function appendPlotSample(history, payload, observedAt) {
if (payload.state !== 'current' || !payload.values) return history
if (history.at(-1)?.sessionId !== undefined && history.at(-1).sessionId !== payload.sessionId) history = []
if (payload.sampleIndex === (history.at(-1)?.index ?? 0)) return history
if (payload.sampleIndex < (history.at(-1)?.index ?? 0)) history = []
const sample = { ...payload.values, sessionId: payload.sessionId, index: payload.sampleIndex,
at: observedAt - payload.sampleAgeSeconds * 1000 }
return [...history, sample].slice(-MAX_POINTS)
}
export function matchQuality(history, desired, actual, { minSpeed = 0, minDemand = 0, great, good, fair, activeKey = 'controlsActive',
warnError = null, severeError = null }) {
const latest = history.at(-1)?.at
if (!Number.isFinite(latest)) return { label: 'N/A', detail: 'Waiting for data' }
const errors = history.filter((sample) => sample.at >= latest - 30000 && sample[activeKey] &&
Number.isFinite(sample[desired]) && Number.isFinite(sample[actual]) &&
(sample.speedMps ?? 0) >= minSpeed && Math.max(Math.abs(sample[desired]), Math.abs(sample[actual])) >= minDemand)
.map((sample) => Math.abs(sample[desired] - sample[actual])).sort((a, b) => a - b)
if (errors.length < 8) return { label: 'N/A', detail: `${errors.length} of 8 valid driving samples` }
const percentile = (p) => { const i = (errors.length - 1) * p; const lo = Math.floor(i); return errors[lo] + (errors[Math.ceil(i)] - errors[lo]) * (i - lo) }
const value = 0.7 * percentile(0.5) + 0.3 * percentile(0.9)
const warnFraction = warnError === null ? 0 : errors.filter((error) => error > warnError).length / errors.length
const severeFraction = severeError === null ? 0 : errors.filter((error) => error > severeError).length / errors.length
const pass = (limit, maxWarn, maxSevere) => value <= limit && warnFraction <= maxWarn && severeFraction <= maxSevere
return { label: pass(great, .18, .05) ? 'Great' : pass(good, .34, .12) ? 'Good' : pass(fair, .55, .24) ? 'Fair' : 'Poor',
detail: `${errors.length} samples / 30 s · robust error ${value.toFixed(2)} m/s²` }
}
export function chartGeometry(history, keys) {
const valid = history.filter((sample) => keys.some((key) => Number.isFinite(sample[key])))
const lo = Math.min(0, ...valid.flatMap((sample) => keys.map((key) => sample[key]).filter(Number.isFinite)))
const hi = Math.max(0, ...valid.flatMap((sample) => keys.map((key) => sample[key]).filter(Number.isFinite)))
const span = Math.max(0.01, hi - lo)
const first = history[0]?.at ?? 0, elapsed = Math.max(1, (history.at(-1)?.at ?? first) - first)
const y = (value) => 132 - (value - lo) / span * 114
const duration = valid.length > 1 ? Math.max(0, (history.at(-1).at - first) / 1000) : 0
const paths = keys.map((key) => {
let points = [], paths = [], previousAt = null
for (const sample of history) {
if (previousAt !== null && sample.at - previousAt > 1500) {
if (points.length > 1) paths.push(points.join(' ')); points = []
}
previousAt = sample.at
if (!Number.isFinite(sample[key])) { if (points.length > 1) paths.push(points.join(' ')); points = []; continue }
const x = Math.max(48, Math.min(624, 48 + (sample.at - first) / elapsed * 576))
points.push(`${x.toFixed(1)},${y(sample[key]).toFixed(1)}`)
}
if (points.length > 1) paths.push(points.join(' '))
return paths
})
return { paths, valid: valid.length > 0, top: hi.toFixed(2), bottom: lo.toFixed(2), zeroY: y(0),
from: duration > 0 ? `−${duration < 10 ? duration.toFixed(1) : Math.round(duration)} s` : '0 s' }
}
export function seriesPoints(history, keys) {
return chartGeometry(history, keys).paths
}
export class PlotsFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
now = () => performance.now(), later = (fn, ms) => setTimeout(fn, ms), cancel = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, now, later, cancel })
this.active = false; this.paused = false; this.generation = 0; this.displayGeneration = 0
this.timer = this.deadline = this.expiry = this.request = null
this.history = []; this.lastData = null
}
clear() {
for (const key of ['timer', 'deadline', 'expiry']) { if (this[key] !== null) this.cancel(this[key]); this[key] = null }
this.request?.abort(); this.request = null
}
stop() {
this.active = false; this.generation++; this.displayGeneration++; this.clear(); this.history = []; this.lastData = null
this.publish({ status: 'idle', data: null, history: [], error: '' })
}
start() { this.stop(); this.active = true; if (!this.paused) return this.load() }
setPaused(paused) {
this.paused = paused
if (paused) { this.generation++; this.displayGeneration++; this.clear(); this.publish({ status: 'paused', data: this.lastData, history: this.history, error: '' }) }
else if (this.active) this.load()
}
async load() {
if (!this.active || this.paused || this.request) return
const generation = ++this.generation, start = this.now(), request = new AbortController()
this.request = request
this.deadline = this.later(() => {
if (!this.active || generation !== this.generation) return
this.generation++; this.displayGeneration++; request.abort(); this.request = null; this.clear()
this.publish({ status: this.lastData ? 'stale' : 'unavailable', data: this.lastData, history: this.history, error: 'Plot refresh timed out' })
this.timer = this.later(() => { this.timer = null; this.load() }, POLL_MS)
}, 1500)
try {
const response = await this.fetcher('./api/plots/live', { signal: request.signal, cache: 'no-store' })
if (!this.active || generation !== this.generation || request.signal.aborted) return
if (response.status === 401) { this.stop(); this.unauthorized(); return }
if (response.status === 503) {
const body = await response.json().catch(() => null)
if (!this.active || generation !== this.generation || request.signal.aborted) return
if (['access_unavailable', 'setup_required'].includes(body?.code)) { this.stop(); this.unauthorized(); return }
}
if (!response.ok) throw new Error('Live plots unavailable')
const data = validatePlotPayload(await response.json())
if (!this.active || generation !== this.generation || request.signal.aborted) return
const elapsed = this.now() - start
if (!Number.isFinite(elapsed) || elapsed < 0 || elapsed >= 1500) throw new Error('Plot sample delayed')
if (this.expiry !== null) this.cancel(this.expiry)
this.expiry = null
const displayGeneration = ++this.displayGeneration
const remaining = data.sampleAgeSeconds === null ? 0 : 1500 - data.sampleAgeSeconds * 1000 - elapsed
if (data.state === 'current' && remaining > 0) {
this.history = appendPlotSample(this.history, data, this.now() - elapsed)
this.lastData = data
this.publish({ status: 'current', data, history: this.history, error: '' })
this.expiry = this.later(() => {
if (this.active && displayGeneration === this.displayGeneration) this.publish({ status: 'stale', data: this.lastData, history: this.history, error: 'Showing the last received sample' })
}, remaining)
} else this.publish({ status: this.lastData ? 'stale' : data.state === 'unavailable' ? 'unavailable' : 'stale', data: this.lastData, history: this.history, error: data.error || 'Waiting for fresh control data' })
} catch (error) {
if (this.active && generation === this.generation) {
this.displayGeneration++
if (this.expiry !== null) this.cancel(this.expiry)
this.expiry = null
this.publish({ status: this.lastData ? 'stale' : 'unavailable', data: this.lastData, history: this.history, error: error.message })
}
} finally {
if (generation === this.generation) {
if (this.deadline !== null) this.cancel(this.deadline); this.deadline = null
this.request = null
if (this.active && !this.paused) this.timer = this.later(() => { this.timer = null; this.load() }, POLL_MS)
}
}
}
}
export const PlotGraph = {
name: 'PlotGraph',
props: { chart: { type: Object, required: true }, title: { type: String, required: true }, terms: { type: Boolean, default: false } },
template: `<svg viewBox="0 0 640 170" role="img" :aria-label="title">
<line class="gx-plot-axis" x1="48" y1="18" x2="48" y2="132" />
<line class="gx-plot-axis" x1="48" y1="132" x2="624" y2="132" />
<template v-if="chart.valid">
<line class="gx-plot-zero" x1="48" :y1="chart.zeroY" x2="624" :y2="chart.zeroY" />
<text class="gx-plot-label" x="42" y="21" text-anchor="end">{{ chart.top }}</text>
<text class="gx-plot-label" x="42" y="135" text-anchor="end">{{ chart.bottom }}</text>
<text class="gx-plot-label" x="52" y="160">{{ chart.from }}</text>
<text class="gx-plot-label" x="624" y="160" text-anchor="end">Latest</text>
</template>
<g v-for="(paths, series) in chart.paths" :key="series"><polyline v-for="(points, i) in paths" :key="i" :points="points"
:class="terms ? 'gx-plot-term-' + series : series === 0 ? 'gx-plot-desired' : 'gx-plot-actual'" /></g>
</svg>`,
}
export const PlotsPage = {
name: 'PlotsPage',
components: { PlotGraph },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ status: 'idle', data: null, history: [], error: '', paused: false, advanced: false }),
created() { this.feed = new PlotsFeed({ publish: (update) => Object.assign(this.$data, update), unauthorized: this.unauthorized }) },
mounted() {
this.visibility = () => {
if (document.hidden) this.feed.setPaused(true)
else if (!this.paused && this.mode === 'local') {
if (!this.feed.active) this.feed.start()
else this.feed.setPaused(false)
}
}
document.addEventListener('visibilitychange', this.visibility)
if (this.mode === 'local' && !document.hidden) this.feed.start()
},
beforeUnmount() { document.removeEventListener('visibilitychange', this.visibility); this.feed.stop() },
computed: {
lateralQuality() { return matchQuality(this.history, 'desiredLateralAccel', 'actualLateralAccel', { minSpeed: 0.5, minDemand: 0.008, great: 0.15, good: 0.30, fair: 0.50, activeKey: 'lateralControlActive' }) },
longQuality() { return matchQuality(this.history, 'desiredLongitudinalAccel', 'actualLongitudinalAccel', { minDemand: 0.05, great: 0.32, good: 0.52, fair: 0.78, activeKey: 'longitudinalControlActive', warnError: .52, severeError: .90 }) },
lateralChart() { return chartGeometry(this.history, ['desiredLateralAccel', 'actualLateralAccel']) },
longChart() { return chartGeometry(this.history, ['desiredLongitudinalAccel', 'actualLongitudinalAccel']) },
lateralTermsChart() { return chartGeometry(this.history, ['lateralP', 'lateralI', 'lateralD', 'lateralF']) },
longTermsChart() { return chartGeometry(this.history, ['longitudinalP', 'longitudinalI', 'longitudinalF']) },
},
methods: {
togglePause() { this.paused = !this.paused; this.feed.setPaused(this.paused) },
label(value, unit = 'm/s²') { return value === null || value === undefined ? 'Unavailable' : `${value.toFixed(2)} ${unit}` },
source(kind) { return ({ torqueState: 'Steering torque controller', curvature: 'Curvature × speed² estimate', aTarget: 'Planner target acceleration',
pidSum: 'PID term sum', pidState: 'Steering PID', controlsState: 'Longitudinal controller',
deviceMotion: 'Device motion', carState: 'Vehicle speed', unavailable: 'Unavailable' })[kind] || 'Unavailable' },
},
template: `
<div class="gx-view gx-plots">
<h2>Plots</h2><p class="gx-note">Live local control observations. Graphs and match scores are diagnostic, not driving-control checks.</p>
<a class="gx-btn gx-btn--tonal" href="#/tuning/flm">Offline tracking analysis</a>
<div v-if="mode !== 'local'" class="gx-card gx-message">Live plots require the authenticated local Galaxy service.</div>
<template v-else>
<div class="gx-plots__toolbar"><button type="button" class="gx-btn gx-btn--tonal" @click="togglePause">{{ paused ? 'Resume' : 'Pause' }}</button>
<button type="button" class="gx-btn gx-btn--tonal" :aria-pressed="advanced" @click="advanced=!advanced">{{ advanced ? 'Hide' : 'Show' }} controller terms</button>
<span role="status">{{ paused ? 'Paused' : data?.bootStabilizing ? 'Starting vehicle systems' : status === 'current' ? 'Live' : status === 'stale' ? 'Reconnecting · last received values' : status === 'unavailable' ? 'Unavailable' : 'Connecting' }}</span></div>
<p class="gx-note" role="status" style="min-height:2.8em">{{ !paused && error ? error : 'Missing samples leave a gap in the graph.' }}</p>
<div class="gx-plots__grid">
<section class="gx-card gx-plots__panel"><h3>Lateral Acceleration</h3><p class="gx-note">Desired / actual · m/s² · {{ source(data?.values?.lateralSource) }}</p>
<plot-graph :chart="lateralChart" title="Lateral acceleration history" />
<p>Desired: {{ label(data?.values?.desiredLateralAccel) }} · Actual: {{ label(data?.values?.actualLateralAccel) }}</p>
<p class="gx-note">Speed source: {{ source(data?.values?.speedSource) }}</p>
<p>30 s match: <strong>{{ !data || data?.bootStabilizing ? 'N/A' : lateralQuality.label }}</strong> · {{ data?.bootStabilizing ? 'Waiting for startup to settle' : status !== 'current' ? 'Last received window' : lateralQuality.detail }}</p></section>
<section class="gx-card gx-plots__panel"><h3>Longitudinal Acceleration</h3><p class="gx-note">Target / measured · m/s² · {{ source(data?.values?.longitudinalSource) }}; measured from {{ data?.values?.poseFresh ? 'device motion' : 'unavailable motion source' }}</p>
<plot-graph :chart="longChart" title="Longitudinal acceleration history" />
<p>Target: {{ label(data?.values?.desiredLongitudinalAccel) }} · Measured: {{ label(data?.values?.actualLongitudinalAccel) }}</p>
<p>30 s match: <strong>{{ !data || data?.bootStabilizing ? 'N/A' : longQuality.label }}</strong> · {{ data?.bootStabilizing ? 'Waiting for startup to settle' : status !== 'current' ? 'Last received window' : longQuality.detail }}</p></section>
<section v-if="advanced" class="gx-card gx-plots__panel"><h3>Lateral Controller Terms</h3><p class="gx-note">P / I / D / F · controller output units · {{ source(data?.values?.lateralTermsSource) }}</p>
<plot-graph :chart="lateralTermsChart" title="Lateral controller terms" :terms="true" /></section>
<section v-if="advanced" class="gx-card gx-plots__panel"><h3>Longitudinal Controller Terms</h3><p class="gx-note">P / I / F · m/s² · {{ source(data?.values?.longitudinalTermsSource) }}</p>
<plot-graph :chart="longTermsChart" title="Longitudinal controller terms" :terms="true" /></section>
</div><p class="gx-note">Blue is target, orange is observed. Missing or stale sources are never plotted as zero.</p>
</template>
</div>`,
}
@@ -0,0 +1,25 @@
// Translate the independent AA placement document into the existing editor model.
const clone = (value) => JSON.parse(JSON.stringify(value))
export function editorSnapshot(raw) {
if (!raw || raw.version !== 1 || !raw.screen || !raw.metadata || !raw.document || !raw.defaults)
throw new Error(raw?.reason || "Connect Android Auto once to obtain the actual screen.")
const colors = raw.colors
const document = (value) => {
if (value.version !== 1 || value.canvas?.width !== raw.metadata.width || value.canvas?.height !== raw.metadata.height)
throw new Error("The saved Android Auto layout does not match its screen. Reload before editing.")
return { version: 4, palette: clone(colors.palette), layouts: { large: clone(value.widgets) },
widgetColors: { large: clone(colors.widgetColors) }, roadColors: { large: clone(colors.roadColors) } }
}
return { ...raw, projection: true, activeProfile: "large", document: document(raw.document), defaults: document(raw.defaults),
metadata: { projection: true, profiles: { large: clone(raw.metadata) },
paletteFields: Object.entries(colors.palette).map(([id, value]) => ({ id, label: id, default: value })),
roadColorFields: [{ id: "path", label: "Path", default: "#30FF9CFF" },
{ id: "pathEdge", label: "Path edges", default: "#00FF40FF" }, { id: "laneLines", label: "Lane lines", default: "#FFFFFFFF" }] } }
}
export function projectionPayload(body, metadata) {
return { revision: body.revision, document: { version: 1,
canvas: { width: metadata.profiles.large.width, height: metadata.profiles.large.height },
widgets: clone(body.document.layouts.large) } }
}
@@ -0,0 +1,357 @@
// Local recording inventory and closed Quick road playback only.
const LOCAL = /^(?:[a-f0-9]{8}--[a-f0-9]{10}|[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{2}-[0-9]{2}-[0-9]{2}|[a-f0-9]{16}\|(?:[a-f0-9]{8}--[a-f0-9]{10}|[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{2}-[0-9]{2}-[0-9]{2}))$/
const SEGMENT = /^(?:[a-f0-9]{8}--[a-f0-9]{10}|[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{2}-[0-9]{2}-[0-9]{2}|[a-f0-9]{16}[|_](?:[a-f0-9]{8}--[a-f0-9]{10}|[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{2}-[0-9]{2}-[0-9]{2}))--[0-9]{1,6}$/
const FILES = ["rlog", "qlog", "fcamera", "dcamera", "ecamera", "qcamera"]
const LABELS = { rlog: "Full log", qlog: "Quick log", fcamera: "Road video", dcamera: "Driver video",
ecamera: "Wide video", qcamera: "Quick video" }
export function validLocalHistory(value) {
if (value?.schemaVersion !== 1 || value.source !== "local" || value.partialHistory !== true ||
typeof value.scanIncomplete !== "boolean" || !Array.isArray(value.routes) || value.routes.length > 100) return false
let count = 0
const routes = new Set()
return value.routes.every((route) => {
if (typeof route?.routeId !== "string" || !LOCAL.test(route.routeId) || routes.has(route.routeId) ||
!Array.isArray(route.segments) || route.segments.length > 64 ||
!Number.isSafeInteger(route.segmentCount) || route.segmentCount !== route.segments.length) return false
routes.add(route.routeId)
const numbers = new Set()
count += route.segmentCount
return count <= 512 && route.segments.every((segment) => {
if (!Number.isSafeInteger(segment?.number) || segment.number < 0 || segment.number > 999999 ||
typeof segment.segmentName !== "string" || !SEGMENT.test(segment.segmentName) ||
(!segment.segmentName.startsWith(route.routeId.replace('|', '_') + '--') &&
!segment.segmentName.startsWith(route.routeId + '--')) ||
Number(segment.segmentName.slice(segment.segmentName.lastIndexOf('--') + 2)) !== segment.number ||
numbers.has(segment.number) || segment.files === null || typeof segment.files !== "object" ||
Object.keys(segment.files).length !== FILES.length ||
!FILES.every((key) => typeof segment.files[key] === "boolean") ||
!FILES.some((key) => segment.files[key])) return false
numbers.add(segment.number)
return true
})
})
}
export const availableFiles = (files) => FILES.filter((key) => files?.[key]).map((key) => LABELS[key])
export const routeFiles = (route) => availableFiles(Object.fromEntries(FILES.map((key) =>
[key, route.segments.some((segment) => segment.files[key])])))
export const firstQuickVideo = (route) => route.segments.find((segment) => segment.files.qcamera) || null
export function routeDate(route, locale = undefined, timeZone = undefined) {
const validTime = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 && Number.isFinite(new Date(value * 1000).getTime())
const captured = validTime(route.startTime)
const value = captured ? route.startTime : validTime(route.fileTime) ? route.fileTime : null
if (value === null) return { label: "Date unavailable", source: "", datetime: null }
const date = new Date(value * 1000)
return { label: new Intl.DateTimeFormat(locale, { year: "numeric", month: "short", day: "numeric",
hour: "numeric", minute: "2-digit", ...(timeZone ? { timeZone } : {}) }).format(date),
source: captured ? "" : "File date", datetime: date.toISOString() }
}
export function connectRouteUrl(route) {
if (typeof route?.routeId !== "string" || !LOCAL.test(route.routeId) || typeof route.connectUrl !== "string") return null
const host = route.provider === "konik" ? "stable.konik.ai" : route.provider === undefined || route.provider === "comma" ? "connect.comma.ai" : null
if (host === null) return null
const match = /^https:\/\/(connect\.comma\.ai|stable\.konik\.ai)\/([a-f0-9]{16})\/([^/?#]+)$/.exec(route.connectUrl)
const [device, identifier] = route.routeId.includes('|') ? route.routeId.split('|') : [null, route.routeId]
return match && match[1] === host && match[3] === identifier && (device === null || match[2] === device) ? route.connectUrl : null
}
export const quickRoadUrl = (segmentName) => SEGMENT.test(segmentName) ?
`./api/recordings/media/${encodeURIComponent(segmentName)}` : null
export const segmentSummaryUrl = (segmentName) => SEGMENT.test(segmentName) ?
`./api/recordings/segment-summary?segmentName=${encodeURIComponent(segmentName)}` : null
export function validSegmentSummary(value, segmentName) {
const metric = (v) => v === null || (typeof v === "number" && Number.isFinite(v) && v >= 0)
return value?.schemaVersion === 1 && value.source === "closed_local_rlog" &&
value.segmentName === segmentName && typeof value.sourceSha256 === "string" &&
/^[a-f0-9]{64}$/.test(value.sourceSha256) &&
[value.observedCarSpanSeconds, value.estimatedDistanceMeters,
value.observedLatActiveSeconds, value.observedLongActiveSeconds].every(metric) &&
value.gaps && Number.isSafeInteger(value.gaps.carState) && value.gaps.carState >= 0 &&
Number.isSafeInteger(value.gaps.carControl) && value.gaps.carControl >= 0 &&
typeof value.sampleCoverageComplete === "boolean"
}
export const detailMetric = (seconds, unit = "s") => seconds === null ? "Unavailable" :
`${seconds.toFixed(unit === "m" ? 1 : 2)} ${unit}`
export class LocalHistoryFeed {
static endpoint = "./api/recordings/local"
static valid = validLocalHistory
static subject = "local recordings"
static unavailable = "Local recordings are unavailable."
static invalid = "Local recording inventory is unavailable."
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer })
this.active = false
this.generation = 0
this.request = null
this.timer = null
this.status = "idle"
this.data = null
this.error = ""
}
emit() { this.publish({ status: this.status, data: this.data, error: this.error }) }
stop() {
this.active = false
this.generation++
this.request?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
this.request = this.timer = null
this.status = "idle"
this.data = null
this.error = ""
this.emit()
}
start() { this.stop(); this.active = true; return this.load() }
async load() {
if (!this.active || this.request !== null) return
const generation = this.generation
const request = new AbortController()
this.request = request
this.status = "loading"
this.error = ""
this.emit()
this.timer = this.later(() => {
if (!this.active || generation !== this.generation || this.request !== request) return
request.abort()
this.generation++
this.request = this.timer = null
this.data = null
this.status = "unavailable"
this.error = `Reading ${this.constructor.subject} timed out. Refresh to try again.`
this.emit()
}, 4000)
try {
const response = await this.fetcher(this.constructor.endpoint, { credentials: "same-origin", cache: "no-store", signal: request.signal })
if (!this.active || generation !== this.generation || request.signal.aborted) return
if (response.status === 401) { this.stop(); this.unauthorized(); return }
if (response.status === 503) {
const body = await response.json().catch(() => null)
if (!this.active || generation !== this.generation || request.signal.aborted) return
if (["setup_required", "access_unavailable"].includes(body?.code)) { this.stop(); this.unauthorized(); return }
throw new Error(this.constructor.unavailable)
}
if (!response.ok) throw new Error(this.constructor.unavailable)
const data = await response.json()
if (!this.active || generation !== this.generation || request.signal.aborted) return
if (!this.constructor.valid(data)) throw new Error(this.constructor.invalid)
this.data = data
this.status = "ready"
this.emit()
} catch (error) {
if (this.active && generation === this.generation) {
this.data = null
this.status = "unavailable"
this.error = error instanceof Error ? error.message : this.constructor.unavailable
this.emit()
}
} finally {
if (generation === this.generation) {
if (this.timer !== null) this.cancelTimer(this.timer)
this.timer = null
this.request = null
}
}
}
}
export const LocalRecordingsPage = {
name: "LocalRecordingsPage",
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ status: "idle", data: null, error: "", playing: null, playerError: "", playerGeneration: 0,
details: null, detailsName: null, detailsStatus: "idle", detailsError: "", detailsGeneration: 0 }),
computed: {
routeCards() {
return (this.data?.routes || []).map((route) => ({ ...route, date: routeDate(route),
connect: connectRouteUrl(route), fileLabels: routeFiles(route), firstQuick: firstQuickVideo(route) }))
},
},
created() { this.feed = new LocalHistoryFeed({ publish: (update) => Object.assign(this.$data, update), unauthorized: this.unauthorized }) },
mounted() {
this.visibility = () => { if (document.hidden) { this.closePlayer(); this.closeDetails(); this.feed.stop() } else if (this.mode === "local") this.feed.start() }
document.addEventListener("visibilitychange", this.visibility)
if (this.mode === "local" && !document.hidden) this.feed.start()
},
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.closePlayer(); this.closeDetails(); this.feed.stop() },
watch: { mode(value) { if (value !== "local") { this.closePlayer(); this.closeDetails() } } },
methods: {
availableFiles,
quickRoadUrl,
detailMetric,
refresh() { this.closePlayer(); this.closeDetails(); return this.feed.load() },
closeDetails() {
this.detailsRequest?.abort()
if (this.detailsTimer !== undefined) clearTimeout(this.detailsTimer)
this.detailsRequest = null
this.detailsTimer = undefined
this.detailsGeneration++
this.details = null
this.detailsName = null
this.detailsStatus = "idle"
this.detailsError = ""
},
async openDetails(segment) {
if (this.mode !== "local" || this.status !== "ready" || !segment?.files?.rlog || document.hidden) return
const url = segmentSummaryUrl(segment.segmentName)
if (!url) return
this.closeDetails()
const generation = this.detailsGeneration
const controller = new AbortController()
this.detailsRequest = controller
this.detailsName = segment.segmentName
this.detailsStatus = "loading"
this.detailsTimer = setTimeout(() => {
if (generation !== this.detailsGeneration || this.detailsRequest !== controller) return
controller.abort()
this.detailsRequest = null
this.detailsGeneration++
this.detailsTimer = undefined
this.detailsStatus = "unavailable"
this.detailsError = "Reading this segment timed out. Select Details to try again."
}, 10000)
try {
const response = await fetch(url, { credentials: "same-origin", cache: "no-store", signal: controller.signal })
if (generation !== this.detailsGeneration || controller.signal.aborted) return
if (response.status === 401) { this.closeDetails(); this.unauthorized(); return }
if (response.status === 503) {
const body = await response.json().catch(() => null)
if (generation !== this.detailsGeneration || controller.signal.aborted) return
if (["setup_required", "access_unavailable"].includes(body?.code)) { this.closeDetails(); this.unauthorized(); return }
}
if (!response.ok) throw new Error("Recorded segment details are unavailable.")
const result = await response.json()
if (generation !== this.detailsGeneration || controller.signal.aborted) return
if (!validSegmentSummary(result, segment.segmentName)) throw new Error("Recorded segment details are unavailable.")
this.details = result
this.detailsStatus = "ready"
this.$nextTick?.(() => this.$refs.detailsPanel?.scrollIntoView?.({ block: "nearest", behavior: "smooth" }))
} catch (error) {
if (generation === this.detailsGeneration) {
this.detailsStatus = "unavailable"
this.detailsError = error instanceof Error ? error.message : "Recorded segment details are unavailable."
}
} finally {
if (generation === this.detailsGeneration) {
if (this.detailsTimer !== undefined) clearTimeout(this.detailsTimer)
this.detailsTimer = undefined
this.detailsRequest = null
}
}
},
openPlayer(route, segment) {
if (this.mode !== "local" || this.status !== "ready" || !segment.files.qcamera) return
const segments = route.segments.filter((item) => item.files.qcamera && quickRoadUrl(item.segmentName))
const index = segments.findIndex((item) => item.segmentName === segment.segmentName)
if (index < 0) return
this.closePlayer()
this.playing = { routeId: route.routeId, segments, index, url: quickRoadUrl(segment.segmentName) }
this.playerGeneration++
this.$nextTick?.(() => this.$refs.playerPanel?.scrollIntoView?.({ block: "nearest", behavior: "smooth" }))
},
chooseSegment(offset) {
if (!this.playing) return
const index = this.playing.index + offset
if (index < 0 || index >= this.playing.segments.length) return
this.sessionProbe?.abort()
this.sessionProbe = null
this.$refs.quickVideo?.pause()
this.$refs.quickVideo?.removeAttribute("src")
this.playing = { ...this.playing, index, url: quickRoadUrl(this.playing.segments[index].segmentName) }
this.playerError = ""
this.playerGeneration++
},
closePlayer() {
this.sessionProbe?.abort()
this.sessionProbe = null
this.$refs.quickVideo?.pause()
this.$refs.quickVideo?.removeAttribute("src")
this.$refs.quickVideo?.load()
this.playing = null
this.playerError = ""
this.playerGeneration++
},
async videoError(event) {
if (!this.playing || event.currentTarget !== this.$refs.quickVideo ||
Number(event.currentTarget.dataset.playerGeneration) !== this.playerGeneration) return
const generation = this.playerGeneration
this.playerError = "This Quick road recording could not play in this browser."
const controller = new AbortController()
this.sessionProbe?.abort()
this.sessionProbe = controller
const deadline = setTimeout(() => controller.abort(), 4000)
let revoke = false
try {
const response = await fetch("./api/auth/session", { credentials: "same-origin", cache: "no-store", signal: controller.signal })
if (!this.playing || generation !== this.playerGeneration || controller.signal.aborted) return
const state = await response.json()
if (!this.playing || generation !== this.playerGeneration || controller.signal.aborted) return
revoke = response.status === 401 || state.authenticated === false
} catch { /* A media or session request may fail; keep the explicit playback error. */ }
finally { clearTimeout(deadline); if (this.sessionProbe === controller) this.sessionProbe = null }
if (revoke && this.playing && generation === this.playerGeneration) { this.closePlayer(); this.unauthorized() }
},
},
template: `
<div class="gx-view gx-recordings">
<h2>Local Recordings</h2>
<p class="gx-note">Drives and video saved on this device. Older recordings may have been removed to free space.</p>
<p v-if="mode !== 'local'" class="gx-card gx-message">Local recordings are unavailable in the offline preview.</p>
<template v-else>
<div class="gx-recordings__toolbar">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="status === 'loading'" @click="refresh">Refresh</button>
<a class="gx-btn gx-btn--tonal" href="#/tuning/flm">Analyze driving logs</a>
</div>
<p v-if="status === 'loading'" role="status">Reading local recordings…</p>
<p v-if="status === 'unavailable'" role="alert">{{ error }}</p>
<template v-if="status === 'ready' && data">
<p v-if="data.scanIncomplete" role="status">This scan was incomplete. More local segments may exist.</p>
<p v-if="!data.routes.length" role="status">No recording segments found in this scan.</p>
<section v-if="playing" ref="playerPanel" class="gx-card gx-recordings__player" aria-label="Quick Road Video player">
<div class="gx-recordings__player-head"><div><h3>Quick Road Video</h3><p class="gx-note">Lower-resolution local road view · {{ playing.routeId }} · segment {{ playing.segments[playing.index].number }}</p></div>
<button type="button" class="gx-btn gx-btn--tonal" @click="closePlayer">Close</button></div>
<video :key="playerGeneration" ref="quickVideo" :data-player-generation="playerGeneration" controls playsinline preload="metadata" :src="playing.url" @error="videoError($event)"></video>
<p v-if="playerError" role="alert">{{ playerError }}</p>
<div class="gx-recordings__player-controls"><button type="button" class="gx-btn gx-btn--tonal" :disabled="playing.index === 0" @click="chooseSegment(-1)">Previous segment</button>
<span>{{ playing.index + 1 }} of {{ playing.segments.length }} saved Quick road segments</span>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="playing.index === playing.segments.length - 1" @click="chooseSegment(1)">Next segment</button></div>
</section>
<section v-if="detailsName" ref="detailsPanel" class="gx-card gx-recordings__details" aria-label="Recorded segment details">
<div class="gx-recordings__player-head"><h3>Recorded Segment Details</h3><button type="button" class="gx-btn gx-btn--tonal" @click="closeDetails">Close</button></div>
<p class="gx-note">{{ detailsName }} · For this saved segment only. Distance is estimated from recorded speed.</p>
<p v-if="detailsStatus === 'loading'" role="status">Reading closed full log…</p>
<p v-if="detailsStatus === 'unavailable'" role="alert">{{ detailsError }}</p>
<div v-if="detailsStatus === 'ready' && details" class="gx-recordings__metrics">
<div><strong>{{ detailMetric(details.observedCarSpanSeconds) }}</strong><span>Recorded time span</span></div>
<div><strong>{{ detailMetric(details.estimatedDistanceMeters, 'm') }}</strong><span>Estimated distance</span></div>
<div><strong>{{ detailMetric(details.observedLatActiveSeconds) }}</strong><span>Steering active</span></div>
<div><strong>{{ detailMetric(details.observedLongActiveSeconds) }}</strong><span>Longitudinal active</span></div>
</div>
<p v-if="detailsStatus === 'ready' && details && !details.sampleCoverageComplete" class="gx-note">Samples have gaps or a missing source; unavailable values were not inferred.</p>
</section>
<section v-for="route in routeCards" :key="route.routeId" class="gx-card gx-recordings__route">
<header><div><p class="gx-recordings__date"><time v-if="route.date.datetime" :datetime="route.date.datetime">{{ route.date.label }}</time><span v-else>{{ route.date.label }}</span><small v-if="route.date.source">{{ route.date.source }}</small></p><h3>{{ route.routeId }}</h3></div>
<span class="gx-recordings__count">{{ route.segmentCount }} saved {{ route.segmentCount === 1 ? 'segment' : 'segments' }}</span></header>
<ul class="gx-recordings__files" aria-label="Files saved for this drive"><li v-for="file in route.fileLabels" :key="file">{{ file }}</li></ul>
<div class="gx-recordings__route-actions"><button v-if="route.firstQuick" type="button" class="gx-btn gx-btn--tonal" @click="openPlayer(route, route.firstQuick)"><i class="bi bi-play-fill" aria-hidden="true"></i>Play Quick video</button>
<a v-if="route.connect" class="gx-btn gx-btn--tonal" :href="route.connect" target="_blank" rel="noopener noreferrer">Open in comma connect<i class="bi bi-box-arrow-up-right" aria-hidden="true"></i></a></div>
<details class="gx-recordings__expand"><summary><span>View {{ route.segmentCount }} saved {{ route.segmentCount === 1 ? 'segment' : 'segments' }}</span><span class="gx-note">Playback, downloads &amp; details</span></summary>
<ul class="gx-recordings__segments"><li v-for="segment in route.segments" :key="segment.number">
<div class="gx-recordings__segment-info"><strong>Segment {{ segment.number }}</strong>
<ul class="gx-recordings__files" aria-label="Saved files"><li v-for="file in availableFiles(segment.files)" :key="file">{{ file }}</li></ul>
</div>
<div class="gx-recordings__actions">
<button v-if="segment.files.qcamera" type="button" class="gx-btn gx-btn--tonal" @click="openPlayer(route, segment)">Play video</button>
<a v-if="segment.files.qcamera" class="gx-btn gx-btn--tonal" :href="quickRoadUrl(segment.segmentName)" :download="segment.segmentName + '-quick-road.mp4'">Download Quick video</a>
<button v-if="segment.files.rlog" type="button" class="gx-btn gx-btn--tonal" @click="openDetails(segment)">Details</button>
</div>
</li></ul></details>
</section>
</template>
</template>
</div>`,
}
@@ -0,0 +1,22 @@
import { reactive } from "../vendor/vue/vue.esm-browser.js"
// This preview deliberately has no compatibility iframe or device endpoint.
const start = () => {
const hash = location.hash.slice(1) || "/"
try { return decodeURIComponent(hash) } catch { return hash }
}
export const route = reactive({ path: start() })
export function navigate(path) {
route.path = path
location.hash = encodeURI(path)
window.scrollTo(0, 0)
}
export function startRouter() {
if (!location.hash) location.replace(`${location.pathname}${location.search}#/`)
window.addEventListener("hashchange", () => {
route.path = start()
window.scrollTo(0, 0)
})
}
@@ -0,0 +1,139 @@
export const channels = ["webPush", "discord", "webhook", "ntfy"]
export const labels = { webPush: "Browser push", discord: "Discord", webhook: "General webhook", ntfy: "ntfy" }
export function validNotifications(value) {
return value?.schemaVersion === 1 && typeof value.queueFull === "boolean" &&
Number.isInteger(value.subscriptionCount) && value.subscriptionCount >= 0 && value.subscriptionCount <= 16 &&
Array.isArray(value.subscriptions) && value.subscriptions.length === value.subscriptionCount &&
value.subscriptions.every(s => typeof s?.id === "string" && /^[0-9a-f]{32}$/.test(s.id) && Object.keys(s).length === 1) &&
typeof value.deliverySemantics === "string" && value.deliverySemantics.length < 256 &&
(value.applicationServerKey === undefined || typeof value.applicationServerKey === "string" && /^[A-Za-z0-9_-]{87}$/.test(value.applicationServerKey)) &&
channels.every(name => {
const c = value.channels?.[name]
return typeof c?.enabled === "boolean" && typeof c.configured === "boolean" && Number.isInteger(c.pending) && c.pending >= 0 && c.pending <= 512 &&
["idle", "queued", "sending", "sent", "failed", "cancelled"].includes(c.lastState) &&
["", "http", "expired", "timeout", "transport", "delivery_unknown", "stale"].includes(c.lastError)
})
}
export class NotificationClient {
constructor({ publish, unauthorized, fetcher = (...args) => fetch(...args) }) {
this.publish = publish; this.unauthorized = unauthorized; this.fetcher = fetcher
this.revision = 0; this.controller = null; this.pending = false; this.hasStatus = false; this.timer = null
}
stop() { this.revision++; this.controller?.abort(); this.controller = null; this.pending = false; clearTimeout(this.timer); this.timer = null }
async run(payload = null) {
this.stop()
const revision = this.revision
this.controller = new AbortController()
this.pending = true
const foreground = payload !== null || !this.hasStatus
if (foreground) this.publish({ notificationBusy: true, notificationError: "" })
this.timer = setTimeout(() => {
if (revision !== this.revision) return
this.stop()
this.publish({ notificationBusy: false, notificationError: "Notification request timed out. Refresh status before repeating a test." })
}, 4000)
try {
const response = await this.fetcher("./api/sentry/notifications", { credentials: "same-origin", cache: "no-store",
signal: this.controller.signal, ...(payload === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }) })
if (revision !== this.revision) return false
if (response.status === 401) { this.unauthorized(); return false }
const value = await response.json()
if (revision !== this.revision) return false
if (!response.ok) throw new Error(value?.error || "Sentry notifications are unavailable.")
if (!validNotifications(value)) throw new Error("Notification status is unavailable.")
this.hasStatus = true
this.publish({ notifications: value, notificationBusy: false, notificationError: "" })
return true
} catch (error) {
if (revision === this.revision && error.name !== "AbortError") this.publish({ notificationBusy: false, notificationError: error.message || "Notification request failed." })
return false
} finally {
if (revision === this.revision) { clearTimeout(this.timer); this.timer = null; this.pending = false; if (foreground) this.publish({ notificationBusy: false }) }
}
}
}
export const SentryNotifications = {
name: "SentryNotifications",
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ channels, labels, notifications: null, notificationBusy: false, notificationError: "",
drafts: Object.fromEntries(channels.map(name => [name, { url: "", token: "" }])) }),
created() { this.client = new NotificationClient({ publish: update => Object.assign(this.$data, update), unauthorized: this.unauthorized }) },
mounted() {
this.visibility = () => { if (document.hidden) { this.client.stop(); clearInterval(this.poll) } else this.start() }
document.addEventListener("visibilitychange", this.visibility)
this.start()
},
beforeUnmount() { this.client.stop(); clearInterval(this.poll); document.removeEventListener("visibilitychange", this.visibility) },
methods: {
start() {
clearInterval(this.poll)
if (this.mode !== "local" || document.hidden) return
this.client.run()
this.poll = setInterval(() => { if (!this.notificationBusy && !this.client.pending) this.client.run() }, 5000)
},
async save(name, enabled) {
const draft = this.drafts[name]
if (await this.client.run({ action: "configure", channel: name, enabled, url: draft.url, token: draft.token })) {
draft.url = ""; draft.token = ""
}
},
test(name) { return this.client.run({ action: "test", channel: name }) },
async forget(name) { if (await this.client.run({ action: "forget", channel: name })) { this.drafts[name].url = ""; this.drafts[name].token = "" } },
remove(id) { return this.client.run({ action: "unsubscribe", id }) },
async subscribe() {
if (!isSecureContext || !("serviceWorker" in navigator) || !("PushManager" in window)) {
this.notificationError = "Browser push needs HTTPS and a browser with Push API support."
return
}
try {
const permission = await Notification.requestPermission()
if (permission !== "granted") throw new Error("Allow browser notifications to subscribe.")
if (!await this.client.run({ action: "pushKey" })) return
const key = this.notifications.applicationServerKey
const bytes = Uint8Array.from(atob(key.replace(/-/g, "+").replace(/_/g, "/") + "="), c => c.charCodeAt(0))
const registration = await navigator.serviceWorker.register("./sentry-push-worker.js")
await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription() || await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: bytes })
const value = subscription.toJSON()
if (!await this.client.run({ action: "subscribe", subscription: { endpoint: value.endpoint, keys: value.keys } })) return
await this.save("webPush", true)
} catch (error) { this.notificationError = error.message || "Browser push subscription failed." }
},
},
template: `
<section class="gx-card gx-home__card" aria-label="Sentry notifications">
<h2>Notifications</h2>
<p class="gx-note">Receive new locally recorded motion events. Notification channels work independently.</p>
<p v-if="mode !== 'local'">Notification configuration is available on your connected device.</p>
<template v-else>
<p v-if="notificationError" role="alert">{{ notificationError }}</p>
<p v-if="notificationBusy" role="status">Updating notifications…</p>
<p v-if="notifications?.queueFull" role="alert">The notification queue is full. Some events could not be queued.</p>
<template v-if="notifications">
<p class="gx-note">{{ notifications.deliverySemantics }}</p>
<section v-for="name in channels" :key="name" class="gx-card gx-sentry-channel">
<h3>{{ labels[name] }}</h3>
<p>{{ notifications.channels[name].enabled ? 'Enabled' : 'Disabled' }} · {{ notifications.channels[name].configured ? 'Configured' : 'Not configured' }}</p>
<p role="status">Delivery: {{ notifications.channels[name].lastState }} · Pending: {{ notifications.channels[name].pending }}</p>
<p v-if="notifications.channels[name].lastError" role="alert">Delivery status: {{ notifications.channels[name].lastError.replaceAll('_', ' ') }}</p>
<div v-if="name !== 'webPush'" class="gx-sentry-channel__fields">
<label>HTTPS URL <input v-model="drafts[name].url" class="gx-field" type="password" autocomplete="off" placeholder="Leave blank to keep saved URL"></label>
<label>Bearer token (optional) <input v-model="drafts[name].token" class="gx-field" type="password" autocomplete="off" placeholder="Leave blank to keep saved token"></label>
</div>
<div class="gx-sentry-channel__actions">
<button v-if="name !== 'webPush'" class="gx-btn gx-btn--tonal" type="button" :disabled="notificationBusy" @click="save(name, true)">Save and enable</button>
<button v-else class="gx-btn gx-btn--tonal" type="button" :disabled="notificationBusy" @click="subscribe">Subscribe this browser</button>
<button class="gx-btn gx-btn--tonal" type="button" :disabled="notificationBusy || !notifications.channels[name].configured" @click="save(name, !notifications.channels[name].enabled)">{{ notifications.channels[name].enabled ? 'Disable' : 'Enable' }}</button>
<button class="gx-btn gx-btn--tonal" type="button" :disabled="notificationBusy || !notifications.channels[name].enabled || !notifications.channels[name].configured" @click="test(name)">Send test</button>
<button class="gx-btn gx-btn--tonal" type="button" :disabled="notificationBusy" @click="forget(name)">Forget configuration</button>
</div>
</section>
<p v-if="notifications.subscriptionCount">Subscribed browsers: {{ notifications.subscriptionCount }}</p>
<button v-for="(subscription, index) in notifications.subscriptions" :key="subscription.id" class="gx-btn gx-btn--tonal" type="button" :disabled="notificationBusy" @click="remove(subscription.id)">Remove browser {{ index + 1 }}</button>
</template>
</template>
</section>`,
}
@@ -0,0 +1,72 @@
import { SentryNotifications } from "./sentry-notifications.js"
import { LocalHistoryFeed } from "./record-history.js"
// Historical motion records and independently configured notifications.
const ID = /^[0-9a-f]{32}$/
export function validSentryEvents(value) {
if (value?.schemaVersion !== 1 || value.source !== "local" || typeof value.scanIncomplete !== "boolean" ||
value.capacity !== 512 || !Array.isArray(value.events) || value.events.length > 512) return false
const seen = new Set()
return value.events.every((event) => {
if (event === null || typeof event !== "object" || Object.keys(event).some((key) => !["eventId", "kind", "systemTimeMs", "images"].includes(key)) ||
(event.images !== undefined && (!Array.isArray(event.images) || event.images.length > 2 ||
new Set(event.images).size !== event.images.length || event.images.some((camera) => !["wide", "cabin"].includes(camera)))) ||
typeof event.eventId !== "string" || !ID.test(event.eventId) || seen.has(event.eventId) ||
!["warning", "alarm"].includes(event.kind) || !Number.isSafeInteger(event.systemTimeMs) ||
event.systemTimeMs <= 0 || event.systemTimeMs > 8_640_000_000_000_000) return false
seen.add(event.eventId)
return true
})
}
export const systemTime = (milliseconds) => Number.isSafeInteger(milliseconds) && milliseconds > 0 && milliseconds <= 8_640_000_000_000_000
? new Date(milliseconds).toLocaleString() : "Unavailable"
export class SentryEventsFeed extends LocalHistoryFeed {
static endpoint = "./api/sentry/events"
static valid = validSentryEvents
static subject = "local motion events"
static unavailable = "Local motion events are unavailable."
static invalid = "Local motion records are unavailable."
}
export const SentryEventsPage = {
name: "SentryEventsPage",
components: { SentryNotifications },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
go: { type: Function, required: true } },
data: () => ({ status: "idle", data: null, error: "" }),
created() { this.feed = new SentryEventsFeed({ publish: (update) => Object.assign(this.$data, update), unauthorized: this.unauthorized }) },
mounted() {
this.visibility = () => { if (document.hidden) this.feed.stop(); else if (this.mode === "local") this.feed.start() }
document.addEventListener("visibilitychange", this.visibility)
if (this.mode === "local" && !document.hidden) this.feed.start()
},
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed.stop() },
methods: { systemTime },
template: `
<div class="gx-view gx-home" aria-label="Sentry motion events">
<div class="gx-home__hero"><div><h1>Motion Events</h1>
<p class="gx-note">Local motion records only · System clock times may be inaccurate</p></div>
<button type="button" class="gx-btn gx-btn--tonal" @click="go('/cameras')">Back to Cameras</button></div>
<SentryNotifications :mode="mode" :unauthorized="unauthorized" />
<p v-if="mode !== 'local'" class="gx-card gx-message">Local motion records are unavailable in preview.</p>
<template v-else>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="status === 'loading'" @click="feed.load()">Refresh events</button>
<p v-if="status === 'loading'" role="status">Reading local motion events…</p>
<p v-if="status === 'unavailable'" role="alert">{{ error }}</p>
<template v-if="status === 'ready' && data">
<p v-if="data.scanIncomplete" role="status">This scan was incomplete. More local motion events may exist.</p>
<p v-if="!data.events.length" role="status">No local motion events found in this scan.</p>
<section v-for="event in data.events" :key="event.eventId" class="gx-card gx-home__card">
<h2>{{ event.kind === 'alarm' ? 'Alarm' : 'Warning' }}</h2>
<p>System time: {{ systemTime(event.systemTimeMs) }}</p>
<small>Event ID: {{ event.eventId }}</small>
<img v-for="camera in (event.images || [])" :key="camera" :src="'./api/sentry/image/' + event.eventId + '/' + camera" :alt="camera + ' camera at motion event'" style="max-width:100%;height:auto" />
</section>
</template>
</template>
</div>`,
}
@@ -0,0 +1,347 @@
import { reactive } from "../vendor/vue/vue.esm-browser.js"
import { GalaxySettingRow, settingControl } from "./galaxy-setting-row.js"
import { OnroadLayoutPage } from "./onroad-layout.js"
import { FavoritesPage } from "./favorites.js"
import { SoundPacks } from "./sound-packs.js"
import { CloudProviderPage } from "./cloud-provider.js"
const post = (fetcher, path, body, signal) => fetcher(path, {
method: "POST", credentials: "same-origin", cache: "no-store", signal,
headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
})
export const SETTINGS_SECTIONS = Object.freeze([
{ id: "lateral", label: "Lateral (Steering)", icon: "bi-arrows-move", pages: ["aol", "lane_change", "torque", "lane"] },
{ id: "longitudinal", label: "Longitudinal (Speed & Following)", icon: "bi-speedometer2", pages: ["conditional", "curve", "profiles", "slc", "traffic", "aggressive", "standard", "relaxed"] },
{ id: "wheel", label: "Wheel Controls", icon: "bi-controller", pages: ["wheel"] },
{ id: "visual", label: "Visual (Display & UI)", icon: "bi-eye", pages: ["appearance", "ui_layout", "favorites", "pip"] },
{ id: "sounds", label: "Sounds & Alerts", icon: "bi-volume-up", pages: ["sounds"] },
{ id: "device", label: "Device & Data", icon: "bi-hdd", pages: ["display", "data"] },
{ id: "developer", label: "Developer", icon: "bi-code-slash", pages: ["developer"] },
])
const SECTION_LINKS = {
device: [{ label: "Display", page: "display" }, { label: "Data Uploads", page: "data" }],
visual: [{ label: "Driving Screen Widgets", page: "appearance" }, { label: "Colors & Layout", page: "ui_layout" }, { label: "Quick Select", page: "favorites" }, { label: "Blind Spot Camera", page: "pip" }],
}
export class SettingsFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer })
this.active = false
this.generation = 0
this.request = null
this.timer = null
this.pollTimer = null
this.polling = false
this.saving = false
this.page = "hub"
this.data = null
this.pending = null
}
stop() {
this.active = false
this.generation++
this.request?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.request = this.timer = this.pollTimer = null
this.polling = false
this.saving = false
this.data = null
this.pending = null
this.publish({ status: "idle", data: null, pending: null, error: "" })
}
start(page = "hub") {
this.stop()
this.active = true
this.page = page
return this.load(page)
}
async run(operation, { saving = false, background = false } = {}) {
if (!this.active) return null
const generation = ++this.generation
this.request?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.pollTimer = null
const request = new AbortController()
this.request = request
this.saving = saving
this.polling = background
this.timer = this.later(() => {
if (!this.active || generation !== this.generation || this.request !== request) return
request.abort()
this.generation++
this.request = this.timer = null
this.saving = false
this.polling = false
if (!background) this.data = this.pending = null
else this.expireMonitorStatus()
this.publish({ status: background && this.data ? "ready" : "unavailable", data: this.data, pending: this.pending,
error: saving ? "Saving timed out. The result is unknown. Refresh saved values before trying again." :
"Reading settings timed out. Refresh to try again." })
}, 4000)
try {
const response = await operation(request.signal)
if (!this.active || generation !== this.generation || request.signal.aborted) return null
const accessCode = response.status === 503 ? (await response.clone().json().catch(() => null))?.code : null
if (!this.active || generation !== this.generation || request.signal.aborted) return null
if (response.status === 401 || ["access_unavailable", "setup_required"].includes(accessCode)) {
this.stop()
this.unauthorized()
return null
}
if (response.status === 409) {
const error = new Error("Saved settings changed. Refresh this page.")
error.stale = true
throw error
}
if (!response.ok) throw new Error(response.status === 503 ? "Saved settings are unavailable." : "Settings request failed.")
const data = await response.json().catch(() => { throw new Error("Galaxy could not load settings. Refresh to reconnect.") })
if (!this.active || generation !== this.generation || request.signal.aborted) return null
return data
} catch (error) {
if (this.active && generation === this.generation && !request.signal.aborted) {
this.pending = null
this.expireMonitorStatus()
if (error?.stale) this.data = null
this.publish({ status: this.data ? "ready" : "unavailable", data: this.data, pending: null,
error: error?.message || "Settings request failed." })
}
return null
} finally {
if (this.request === request) {
if (this.timer !== null) this.cancelTimer(this.timer)
this.request = this.timer = null
this.saving = false
this.polling = false
}
}
}
expireMonitorStatus() {
if (this.data?.page === "sentry") this.data = { ...this.data, subtitle: "Motion monitor status is unavailable. Refresh to check again." }
}
scheduleParkedRefresh() {
if (!this.active || this.pending || this.request || this.saving || this.pollTimer !== null ||
(this.data?.parked !== false && this.page !== "sentry") || this.data?.page !== this.page || typeof this.data.view !== "string" ||
!Array.isArray(this.data.rows)) return
const generation = this.generation
this.pollTimer = this.later(() => {
this.pollTimer = null
if (this.active && this.generation === generation && !this.pending && !this.request && !this.saving && (this.data?.parked === false || this.page === "sentry"))
this.load(this.page, { keepData: true, quiet: true })
}, 1000)
}
async load(page = this.page, { keepData = page === this.page && this.data !== null, quiet = keepData } = {}) {
if (!this.active || this.saving || (quiet && (this.pending || this.request))) return
this.page = page
this.pending = null
if (!keepData) this.data = null
this.publish({ status: quiet ? "ready" : keepData ? "saving" : "loading", data: this.data, pending: null, error: "" })
const operation = this.run((signal) => this.fetcher(`./api/settings/pages/${encodeURIComponent(page)}`,
{ credentials: "same-origin", cache: "no-store", signal }), { background: quiet })
const attempt = this.generation
const data = await operation
if (data && this.active && this.generation === attempt && this.page === page) {
this.data = data
this.publish({ status: "ready", data, pending: null, error: "" })
this.scheduleParkedRefresh()
}
}
async preview(row, direction, draft = null) {
if (!this.active || !this.data?.view || this.pending || (this.request && !this.polling)) return
const data = await this.run((signal) => post(this.fetcher, "./api/settings/preview",
{ view: this.data.view, row, direction, ...(draft === null ? {} : { draft }) }, signal))
if (data) {
this.pending = data
this.publish({ status: "ready", data: this.data, pending: data, error: "" })
}
}
async previewValue(row, value) {
if (!this.active || !this.data?.view || this.pending || (this.request && !this.polling)) return
this.publish({ status: "updating", data: this.data, pending: null, error: "" })
const data = await this.run((signal) => post(this.fetcher, "./api/settings/preview",
{ view: this.data.view, row, value }, signal))
if (data) {
this.pending = data
await this.confirm()
}
}
cancel() {
this.pending = null
if (this.active) this.publish({ status: "ready", data: this.data, pending: null, error: "" })
this.scheduleParkedRefresh()
}
async confirm() {
if (!this.active || !this.pending?.intent) return
const intent = this.pending.intent
this.pending = null
this.publish({ status: "saving", data: this.data, pending: null, error: "" })
const page = this.page
const operation = this.run((signal) => post(this.fetcher, "./api/settings/confirm",
{ intent, confirmed: true }, signal), { saving: true })
const attempt = this.generation
const result = await operation
if (this.active && this.generation === attempt) {
const reload = this.load(page, { keepData: true, quiet: false })
const refreshed = this.generation
await reload
if (result?.saved !== true && this.active && this.generation === refreshed && this.data) this.publish({ status: "ready", data: this.data, pending: null,
error: "Save could not be confirmed. Current saved values are shown; review them before trying again." })
}
}
}
export const SettingsPage = {
components: { GalaxySettingRow, OnroadLayoutPage, FavoritesPage, SoundPacks, CloudProviderPage },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
initialPage: { type: String, default: "hub" }, title: { type: String, default: "Toggles" },
initialSection: { type: String, default: null }, returnTo: { type: Function, default: null } },
setup(props) {
const state = reactive({ status: "idle", data: null, pending: null, error: "", query: "", section: "lateral", layoutOpen: false, favoritesOpen: false, soundChoicesDirty: false, developerOpen: props.initialSection === "developer" })
const feed = new SettingsFeed({ publish: (update) => Object.assign(state, update), unauthorized: props.unauthorized })
return { state, feed, sections: SETTINGS_SECTIONS }
},
computed: {
busy() { return ["updating", "saving"].includes(this.state.status) || !!this.state.pending },
activeSection() {
if (this.state.developerOpen) return this.sections.at(-1)
const page = (this.state.data?.page || "hub").split("/")[0]
return this.sections.find((section) => page === "hub" ? section.id === this.state.section : section.pages.includes(page)) || this.sections[0]
},
atSectionRoot() {
if (this.state.developerOpen) return true
return this.state.data?.page === "hub" ||
(this.activeSection.pages.length === 1 && this.state.data?.page === this.activeSection.pages[0])
},
visibleRows() {
const needle = this.state.query.trim().toLocaleLowerCase()
const rows = this.state.data?.rows || []
const inHub = this.initialPage === "hub" && this.state.data?.page === "hub"
const section = this.activeSection
const entries = inHub && SECTION_LINKS[section.id]
? SECTION_LINKS[section.id].map((row, index) => ({ row: { ...row, available: true, action: false, value: "" }, index }))
: rows.map((row, index) => ({ row, index })).filter(({ row }) => !inHub || section.pages.includes(row.page))
return entries.filter(({ row, index }) => {
const previous = rows[index - 1]
return !(["sounds", "display"].includes(this.state.data?.page) && row.repairValue === "Auto" &&
previous?.choices?.includes("Auto") && settingControl(previous) === "slider" && row.label === `Use Auto ${previous.label}`)
}).filter(({ row }) =>
!needle || `${row.label} ${row.value} ${row.reason}`.toLocaleLowerCase().includes(needle))
},
},
watch: {
"state.pending"() { this.flushSoundChoices() },
"state.status"(status, previous) {
if (previous === "saving" && status === "ready") this.state.soundChoicesDirty = false
else this.flushSoundChoices()
},
},
mounted() { if (this.mode === "local" && !this.state.developerOpen) this.feed.start(this.initialPage) },
beforeUnmount() { this.feed.stop() },
methods: {
open(page) {
this.state.query = ""
if (page === "ui_layout") { this.feed.stop(); this.state.layoutOpen = true }
else if (page === "favorites") { this.feed.stop(); this.state.favoritesOpen = true }
else this.feed.load(page)
},
rowKey(row, index) { return JSON.stringify([this.state.data.page, index, row.revision ?? this.state.data.view, row]) },
closeLayout() { this.state.layoutOpen = false; this.state.section = "visual"; this.feed.start(this.initialPage) },
closeFavorites() { this.state.favoritesOpen = false; this.state.section = "visual"; this.feed.start("hub") },
selectSection(section) {
if (this.busy) return
this.state.section = section.id
this.state.developerOpen = section.id === "developer"
if (this.state.developerOpen) { this.feed.stop(); return }
if (!this.feed.active) { this.feed.start(section.pages.length === 1 ? section.pages[0] : "hub"); return }
this.state.query = ""
const page = section.pages.length === 1 ? section.pages[0] : "hub"
if (this.state.data?.page !== page) this.feed.load(page)
},
back() {
if (this.state.developerOpen) return
const page = this.state.data?.page || this.initialPage
this.state.section = this.activeSection?.id || this.state.section
this.state.query = ""
if (page === this.initialPage && this.returnTo) { this.returnTo(); return }
if (this.initialPage !== "hub") { this.feed.load(this.initialPage); return }
this.feed.load(page.includes("/") ? page.split("/")[0] :
["aggressive", "standard", "relaxed"].includes(page) ? "profiles" : "hub")
},
refreshSoundChoices() {
this.state.soundChoicesDirty = true
this.flushSoundChoices()
},
flushSoundChoices() {
if (!this.state.soundChoicesDirty || this.state.data?.page !== "sounds" || this.feed.saving || this.state.pending) return
this.state.soundChoicesDirty = false
this.feed.load("sounds", { keepData: true })
},
},
template: `
<OnroadLayoutPage v-if="state.layoutOpen" :mode="mode" :unauthorized="unauthorized" @close="closeLayout" />
<FavoritesPage v-else-if="state.favoritesOpen" :mode="mode" :unauthorized="unauthorized" @close="closeFavorites" />
<section v-else class="gx-settings" :aria-label="title">
<div class="gx-settings__header"><div><h2>{{ title }}</h2>
<p v-if="initialPage === 'pip'">Change the saved camera settings. Live camera preview is unavailable here.</p>
<p v-else-if="(state.data?.page || initialPage) === 'sounds'">Adjust alert and chime volumes. Immediate warnings keep their safety volume ramp.</p>
<p v-else-if="(state.data?.page || initialPage) === 'display'">Adjust brightness and screen timing when custom display settings are on.</p>
<p v-else-if="initialPage === 'appearance'">Choose which driving information to show. Use Theme Maker to arrange widgets and change colors.</p>
<p v-else-if="initialPage === 'lane_change'">Close gap adjusts following distance during a lane change when StarPilot controls acceleration and braking. It is Off by default.</p>
<p v-else-if="state.data?.page === 'conditional' || state.data?.page.startsWith('conditional/')">Choose when to switch between Chill and Experimental. Applies on supported vehicles when StarPilot controls acceleration and braking.</p>
<p v-else-if="state.data?.page === 'profiles'">Braking response works without saved personality curves. A selected personality braking preset or Traffic takes priority.</p>
<p v-else-if="state.data?.page === 'traffic'">Traffic follow and jerk blend toward saved Relaxed values at higher speeds. Saved curves require Use saved profiles and the Traffic profile switch.</p>
<p v-else-if="initialPage === 'sentry'" role="status">{{ state.data?.subtitle || "Checking motion monitor…" }}</p>
</div>
<button v-if="(initialPage === 'hub' ? !atSectionRoot : state.data?.page !== initialPage) || returnTo" type="button" class="gx-btn gx-btn--tonal" :disabled="busy" @click="back">Back</button>
</div>
<div v-if="mode !== 'local'" class="gx-card gx-message" role="status">Local settings are unavailable in preview.</div>
<template v-else>
<div v-if="initialPage === 'hub'" class="gx-settings-tabs" aria-label="Settings sections">
<button v-for="section in sections" :key="section.id" type="button" class="gx-chip" :aria-pressed="section.id === activeSection.id" :disabled="busy" @click="selectSection(section)">{{ section.label }}</button>
</div>
<section v-if="state.developerOpen" class="gx-card gx-settings__section" aria-label="Developer">
<h3>Developer</h3>
<CloudProviderPage :mode="mode" :unauthorized="unauthorized" />
</section>
<p v-if="state.status === 'saving' || state.status === 'updating'" class="gx-settings__save-status" role="status">Saving preference…</p>
<div v-if="state.status === 'loading'" class="gx-card gx-message" role="status">Loading saved settings…</div>
<div v-else-if="state.status === 'unavailable'" class="gx-card gx-message" role="alert">Saved settings are unavailable.</div>
<div v-if="state.error" class="gx-card gx-message" role="alert">{{ state.error }}
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy" @click="feed.load()">Refresh</button></div>
<div v-if="state.data" class="gx-settings__body">
<button v-if="state.data.page === 'appearance'" type="button" class="gx-btn gx-btn--tonal" :disabled="busy" @click="open('ui_layout')">Open Theme Maker</button>
<p v-if="!state.data.parked && !state.data.rows.some(row => row.key && row.available)" class="gx-note">Turn the vehicle off to change these settings.</p>
<input v-model="state.query" class="gx-field" type="search" aria-label="Filter this settings page" placeholder="Search this page…">
<section class="gx-card gx-settings__section">
<div class="gx-section__header"><i class="bi" :class="activeSection.icon" aria-hidden="true"></i><span class="gx-section__title">{{ state.data.page === 'hub' ? activeSection.label : state.data.title }}</span>
<button type="button" class="gx-icon-btn" aria-label="Refresh settings" :disabled="busy" @click="feed.load()"><i class="bi bi-arrow-clockwise" aria-hidden="true"></i></button></div>
<GalaxySettingRow v-for="{ row, index } in visibleRows" :key="rowKey(row, index)" :row="row" :index="index"
:disabled="busy || !row.available" :save-value="(index, value) => feed.previewValue(index, value)"
@open="open" @review="(index, direction) => feed.preview(index, direction)" />
<div v-if="!visibleRows.length" class="gx-empty">No matching settings.</div>
</section>
<SoundPacks v-if="state.data.page === 'sounds'" :unauthorized="unauthorized" :disabled="busy" @installed="refreshSoundChoices" />
</div>
<Teleport to="body"><div v-if="state.pending" class="gx-settings__modal" role="dialog" aria-modal="true" aria-label="Confirm saved setting">
<div class="gx-card gx-settings__dialog"><h3>Confirm Saved Preference</h3><p>{{ state.pending.question }}</p>
<div class="gx-settings__controls"><button type="button" class="gx-btn gx-btn--tonal" @click="feed.cancel()">Cancel</button>
<button type="button" class="gx-btn" @click="feed.confirm()">Save</button></div></div>
</div></Teleport>
</template>
</section>`,
}
@@ -0,0 +1,456 @@
import { decodeLayoutBackup, encodeLayoutBackup, MAX_LAYOUT_BACKUP_BYTES } from "./layout-backup.js"
const ACTIONS = new Set(["check", "download", "select", "install", "preferences"])
const REQUEST_STATES = new Set(["pending", "complete", "failed"])
const UPDATER_ACTIVE = new Set(["checking...", "downloading...", "finalizing update..."])
const PRIMARY_BRANCHES = ["StarPilot", "Dom"]
const unavailableOperations = () => ({ parked: false, availableBranches: [], selectedTarget: null,
canCheck: false, canDownload: false, canSelect: false, canInstall: false,
reason: "Update controls are unavailable", request: null })
export function validSoftwareSnapshot(data) {
const text = (value) => value === null || typeof value === "string"
const flag = (value) => value === null || typeof value === "boolean"
const updater = data?.updater, operations = data?.operations, request = operations?.request
return data?.schemaVersion === 1 && !!data.installed && !!updater &&
[data.installed.version, data.installed.branch, data.installed.commit, updater.state,
updater.targetBranch, updater.lastSuccessAt, updater.lastFetchAt].every(text) &&
(data.installed.displayVersion === undefined || text(data.installed.displayVersion)) &&
[updater.targetChangeFound, updater.finalizedUpdateReady].every(flag) &&
(updater.failedCount === null || Number.isSafeInteger(updater.failedCount) && updater.failedCount >= 0) &&
(operations === undefined || !!operations && typeof operations.parked === "boolean" && Array.isArray(operations.availableBranches) &&
operations.availableBranches.length <= 256 && operations.availableBranches.every((branch) =>
typeof branch === "string" && branch.length > 0 && branch.length <= 128) &&
text(operations.selectedTarget) && text(operations.reason) &&
(operations.automaticDownloads === undefined || flag(operations.automaticDownloads)) &&
(operations.canConfigure === undefined || typeof operations.canConfigure === "boolean") &&
(operations.history === undefined || validHistory(operations.history)) &&
[operations.canCheck, operations.canDownload, operations.canSelect, operations.canInstall].every((value) => typeof value === "boolean") &&
(request === null || !!request && typeof request.id === "string" && request.id.length <= 100 &&
ACTIONS.has(request.action) && text(request.target) && REQUEST_STATES.has(request.state) && text(request.error)))
}
export function validHistory(value) {
const rows = (items) => Array.isArray(items) && items.length <= 20 && items.every((row) =>
/^[0-9a-f]{40}$/.test(row?.hash) && typeof row.date === "string" && row.date.length <= 64 &&
typeof row.subject === "string" && row.subject.length <= 512)
return !!value && rows(value.installed) && rows(value.downloaded) &&
[value.currentReleaseNotes, value.downloadedReleaseNotes].every((notes) => notes === null || typeof notes === "string" && notes.length <= 65536)
}
export class SoftwareStatusFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer })
this.active = false
this.generation = 0
this.controller = null
this.timeout = null
this.pollTimer = null
this.data = null
this.attempted = false
this.busy = false
this.mutating = false
this.blocked = false
this.uncertain = false
this.uncertainAction = null
this.actionPriorRequestId = null
this.installBaseline = null
this.notice = ""
this.error = ""
}
emit(status) { this.publish({ status, data: this.data, busy: this.busy, uncertain: this.uncertain,
notice: this.notice, error: this.error }) }
stop() {
this.active = false
this.generation++
this.controller?.abort()
if (this.timeout !== null) this.cancelTimer(this.timeout)
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.controller = this.timeout = this.pollTimer = null
this.data = this.installBaseline = this.uncertainAction = this.actionPriorRequestId = null
this.attempted = false
this.busy = this.mutating = this.blocked = this.uncertain = false
this.notice = this.error = ""
this.emit("idle")
}
start() { this.stop(); this.active = true; return this.load() }
needsRapidPoll() {
return this.uncertain || !!this.installBaseline || this.data?.operations?.parked === false ||
this.data?.operations?.request?.state === "pending" || UPDATER_ACTIVE.has(this.data?.updater?.state)
}
schedulePoll(delay = null) {
if (!this.active || this.pollTimer !== null || this.controller) return
const generation = this.generation
this.pollTimer = this.later(() => {
this.pollTimer = null
if (this.active && generation === this.generation) this.load()
}, delay ?? (this.needsRapidPoll() ? 1000 : 5000))
}
async run(body = null) {
if (!this.active || this.mutating) return null
const generation = ++this.generation
this.controller?.abort()
if (this.timeout !== null) this.cancelTimer(this.timeout)
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.pollTimer = null
const controller = new AbortController()
this.controller = controller
this.busy = body !== null || this.data === null
this.mutating = body !== null
if (body !== null) this.error = ""
this.emit(this.data ? "ready" : this.attempted ? "unavailable" : "loading")
this.timeout = this.later(() => {
if (!this.active || this.generation !== generation || this.controller !== controller) return
controller.abort()
this.generation++
this.controller = this.timeout = null
this.busy = this.mutating = false
this.attempted = true
this.blocked = true
if (body !== null) { this.uncertain = true; this.uncertainAction = body }
this.error = body !== null ? "The request timed out. Its result is unknown; wait for a fresh status before trying another action." :
"Software status timed out. Refresh to try again."
this.emit(this.data ? "ready" : "unavailable")
this.schedulePoll(2000)
}, 5000)
try {
const response = await this.fetcher(body === null ? "./api/software/status" : "./api/software/action", {
credentials: "same-origin", cache: "no-store", signal: controller.signal,
...(body === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
})
if (!this.active || generation !== this.generation || controller.signal.aborted) return null
const payload = typeof response.json === "function" ? await response.json().catch(() => null) : null
if (!this.active || generation !== this.generation || controller.signal.aborted) return null
if (response.status === 401 || response.status === 503 &&
["access_unavailable", "setup_required"].includes(payload?.code)) {
this.stop()
this.unauthorized()
return null
}
if (!response.ok) {
const error = new Error(payload?.error || payload?.message || "Software request failed. Refresh to try again.")
error.rejected = response.status >= 400 && response.status < 500
throw error
}
if (!validSoftwareSnapshot(payload)) throw new Error("Software response is unavailable. Refresh to try again.")
this.data = payload.operations === undefined ? { ...payload, operations: unavailableOperations() } : payload
this.attempted = true
this.blocked = false
if (body !== null) {
this.uncertain = false
this.uncertainAction = null
this.actionPriorRequestId = null
if (body.action === "install") {
this.installBaseline = this.installBaseline || { commit: payload.installed.commit, branch: payload.installed.branch, target: body.branch }
this.notice = "Restart requested. Waiting to reconnect and verify the installed build."
} else this.notice = body.action === "select" ? `Target branch set to ${body.branch}. Check and download the update when ready.` :
body.action === "preferences" ? "Automatic download preference saved." : ""
} else {
const observed = payload.operations.request
const requestObserved = !!observed && !!this.uncertainAction && observed.action === this.uncertainAction.action &&
observed.id !== this.actionPriorRequestId &&
(!this.uncertainAction?.branch || observed.target === this.uncertainAction.branch)
const selectionObserved = this.uncertainAction?.action === "select" &&
payload.operations.selectedTarget === this.uncertainAction.branch
const preferenceObserved = this.uncertainAction?.action === "preferences" &&
payload.operations.automaticDownloads === this.uncertainAction.automaticDownloads
if (this.uncertain && (requestObserved || selectionObserved || preferenceObserved)) {
this.uncertain = false
this.uncertainAction = null
this.actionPriorRequestId = null
}
if (this.installBaseline && payload.installed.branch === this.installBaseline.target &&
(payload.installed.commit && payload.installed.commit !== this.installBaseline.commit ||
payload.installed.branch !== this.installBaseline.branch)) {
this.installBaseline = null
this.uncertain = false
this.uncertainAction = null
this.actionPriorRequestId = null
this.notice = "Installed build verified after reconnect."
}
}
this.error = ""
this.emit("ready")
return payload
} catch (error) {
if (this.active && generation === this.generation && !controller.signal.aborted) {
this.attempted = true
this.blocked = true
if (body !== null) {
this.uncertain = !error?.rejected
this.uncertainAction = this.uncertain ? body : null
if (error?.rejected && body.action === "install") this.installBaseline = null
}
this.error = error?.message || "Software request failed. Refresh to try again."
this.emit(this.data ? "ready" : this.attempted ? "unavailable" : "loading")
}
return null
} finally {
if (this.controller === controller) {
if (this.timeout !== null) this.cancelTimer(this.timeout)
this.controller = this.timeout = null
this.busy = this.mutating = false
this.emit(this.data ? "ready" : "unavailable")
this.schedulePoll(this.error ? 2000 : null)
}
}
}
load() { return this.run() }
configureAutomaticDownloads(enabled) {
if (!this.active || this.mutating || this.blocked || this.uncertain || this.data?.operations?.canConfigure !== true ||
typeof enabled !== "boolean") return null
return this.run({ action: "preferences", automaticDownloads: enabled,
expectedAutomaticDownloads: this.data.operations.automaticDownloads })
}
action(action, branch = null) {
const operations = this.data?.operations
const capability = { check: "canCheck", download: "canDownload", select: "canSelect", install: "canInstall" }[action]
if (!this.active || !capability || this.mutating || this.blocked || this.uncertain || !operations?.parked ||
operations[capability] !== true || operations.request?.state === "pending") return null
if (action === "select" && (branch === "other:" || !operations.availableBranches.includes(branch) || branch === operations.selectedTarget)) return null
if (["download", "install"].includes(action) && (!branch || branch !== operations.selectedTarget)) return null
this.actionPriorRequestId = operations.request?.id ?? null
if (action === "install") this.installBaseline = { commit: this.data.installed.commit,
branch: this.data.installed.branch, target: branch }
return this.run(action === "check" ? { action } : { action, branch })
}
}
export const SoftwarePage = {
name: "SoftwarePage",
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ status: "idle", data: null, busy: false, uncertain: false, notice: "", error: "",
primaryChoice: "", draftBranch: "", draftTouched: false, dialog: null,
layoutBusy: false, layoutNotice: "", layoutError: "", layoutDraft: null }),
created() {
this.feed = new SoftwareStatusFeed({ publish: (update) => {
Object.assign(this.$data, update)
if (update.data && !this.draftTouched) this.syncDraftBranch(update.data.operations.selectedTarget)
}, unauthorized: this.unauthorized })
},
mounted() { if (this.mode === "local") this.feed.start() },
beforeUnmount() { this.feed.stop() },
computed: {
operations() { return this.data?.operations },
pending() { return this.operations?.request?.state === "pending" },
actionDisabled() { return this.busy || this.uncertain || this.pending || !!this.error || !this.operations?.parked },
primaryBranchHelp() {
return this.primaryChoice === "StarPilot" ? "Stable releases. Recommended for most users." :
this.primaryChoice === "Dom" ? "Latest features and fixes under development. Updates regularly and may introduce bugs." : ""
},
otherBranches() {
const available = this.operations?.availableBranches || []
const selected = this.operations?.selectedTarget
const installed = this.data?.installed?.branch
return [...new Set([selected, installed, ...available].filter((branch) => branch && branch !== "other:" && !PRIMARY_BRANCHES.includes(branch)))].map((branch) => ({
name: branch, listed: available.includes(branch), current: branch === installed,
}))
},
canStageBranch() {
return !!this.draftBranch && this.draftBranch !== "other:" &&
this.operations?.availableBranches.includes(this.draftBranch) &&
this.draftBranch !== this.operations.selectedTarget &&
(this.primaryChoice === "other:" ? !PRIMARY_BRANCHES.includes(this.draftBranch) : this.primaryChoice === this.draftBranch)
},
},
methods: {
async layoutRequest(document = undefined, revision = undefined) {
const response = await fetch("./api/ui/layout", document === undefined ? {
credentials: "same-origin", cache: "no-store",
} : {
method: "POST", credentials: "same-origin", cache: "no-store",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ revision, document }),
})
if (response.status === 401) this.unauthorized()
const value = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(value.error || "Visual layout is unavailable. Refresh and try again.")
return value
},
async exportLayout() {
if (this.layoutBusy) return
this.layoutBusy = true
this.layoutNotice = this.layoutError = ""
try {
const snapshot = await this.layoutRequest()
if (snapshot.valid !== true) throw new Error("Saved visual layout is invalid. Repair it in Theme Maker before exporting.")
const contents = await encodeLayoutBackup(snapshot.document)
const url = URL.createObjectURL(new Blob([contents], { type: "application/json" }))
try {
const link = document.createElement("a")
link.href = url
link.download = "StarPilot-visual-layout-backup.json"
document.body.append(link)
link.click()
link.remove()
} finally { setTimeout(() => URL.revokeObjectURL(url), 1000) }
this.layoutNotice = "Visual layout backup downloaded."
} catch (error) { this.layoutError = error.message || "Could not export visual layout." }
finally { this.layoutBusy = false }
},
async chooseLayoutFile(event) {
const file = event.target.files?.[0]
event.target.value = ""
if (!file || this.layoutBusy) return
this.layoutError = this.layoutNotice = ""
try {
if (file.size > MAX_LAYOUT_BACKUP_BYTES) throw new Error("Visual layout backup is too large.")
this.layoutDraft = await decodeLayoutBackup(await file.text())
this.dialog = { action: "restoreLayout", title: "Restore visual layout",
message: "Replace the saved onroad layout and colors with this backup? Turn off the vehicle first. Other settings stay as they are.",
label: "Restore layout" }
} catch (error) { this.layoutDraft = null; this.layoutError = error.message || "Could not read visual layout backup." }
},
async restoreLayout() {
const draft = this.layoutDraft
this.layoutDraft = null
if (!draft || this.layoutBusy) return
this.layoutBusy = true
this.layoutError = this.layoutNotice = ""
try {
const current = await this.layoutRequest()
if (current.editable !== true) throw new Error("Park the vehicle before restoring the visual layout.")
const saved = await this.layoutRequest(draft, current.revision)
if (saved.valid !== true) throw new Error("Visual layout could not be verified after restore.")
this.layoutNotice = "Visual layout restored."
} catch (error) { this.layoutError = error.message || "Could not restore visual layout." }
finally { this.layoutBusy = false }
},
syncDraftBranch(branch) {
this.draftBranch = branch || ""
this.primaryChoice = PRIMARY_BRANCHES.includes(this.draftBranch) ? this.draftBranch : this.draftBranch ? "other:" : ""
},
onPrimaryBranchChange() {
this.draftTouched = true
if (this.primaryChoice === "other:") {
if (!this.otherBranches.some((option) => option.name === this.draftBranch)) {
const selected = this.operations?.selectedTarget
this.draftBranch = this.otherBranches.some((option) => option.name === selected) ? selected : ""
}
} else this.draftBranch = this.primaryChoice
},
onOtherBranchChange() { this.draftTouched = true },
shown(value) { return value ?? "Unavailable" },
reported(value) { return value ? new Date(value).toLocaleString() : "Unavailable" },
shortCommit(value) { return value ? value.slice(0, 12) : "Unavailable" },
requestMessage(request) {
if (!request) return ""
if (request.state === "failed") return request.error || "Update request failed. Refresh and try again."
if (request.action === "install") return "Restart requested. Waiting to reconnect and verify the installed build."
if (request.state === "pending") return request.action === "check" ? "Checking for updates…" : "Downloading and preparing update…"
return request.action === "check" ? "Update check finished." : request.action === "download" ? "Download finished." : "Target branch saved."
},
chooseBranch() {
if (!this.operations?.canSelect || this.actionDisabled || !this.canStageBranch) return
this.dialog = { action: "select", branch: this.draftBranch, title: "Change target branch",
message: `Set ${this.draftBranch} as the target branch? This only stages the choice. Check for updates and download separately.`, label: "Set target branch" }
},
askInstall() {
if (this.actionDisabled || !this.operations?.canInstall || !this.operations.selectedTarget) return
this.dialog = { action: "install", branch: this.operations.selectedTarget, title: "Restart and install update",
message: `Restart the device to install the finalized update for ${this.operations.selectedTarget}? Keep the vehicle parked and the device accessible.`, label: "Restart & install" }
},
closeDialog() { this.dialog = null; this.layoutDraft = null },
async confirmDialog() {
const choice = this.dialog
if (!choice) return
this.dialog = null
if (choice.action === "restoreLayout") { await this.restoreLayout(); return }
const result = await this.feed.action(choice.action, choice.branch)
if (result && choice.action === "select") {
this.draftTouched = false
this.syncDraftBranch(result.operations.selectedTarget)
}
},
},
template: `
<div class="gx-view">
<h2>Software &amp; Updates</h2>
<p class="gx-note">Turn off the vehicle before checking, downloading or installing updates. Selecting a branch saves the target for the next check.</p>
<div v-if="mode !== 'local'" class="gx-card gx-message" role="status">Software updates are unavailable in preview.</div>
<template v-else>
<div class="gx-software-actions"><button type="button" class="gx-btn gx-btn--tonal" :disabled="busy" @click="feed.load()">Refresh status</button>
<button type="button" class="gx-btn" :disabled="actionDisabled || !operations?.canCheck" @click="feed.action('check')">Check for updates</button></div>
<p v-if="status === 'loading' && !data" role="status">Loading software updates…</p>
<div v-if="error" class="gx-card gx-message" role="alert">{{ error }}
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy" @click="feed.load()">Refresh</button></div>
<p v-if="notice" class="gx-card gx-message" role="status">{{ notice }}</p>
<template v-if="data">
<section class="gx-card gx-software-card"><h3>Installed Build</h3>
<dl><dt>Version</dt><dd>{{ shown(data.installed.displayVersion ?? data.installed.version) }}</dd><dt>Branch</dt><dd>{{ shown(data.installed.branch) }}</dd>
<dt>Commit</dt><dd>{{ shortCommit(data.installed.commit) }}</dd></dl></section>
<section class="gx-card gx-software-card"><h3>Target Branch</h3>
<p class="gx-note">Current target: {{ shown(operations.selectedTarget) }}</p>
<div class="gx-software-branch"><select v-model="primaryChoice" class="gx-field" aria-label="Target branch" :disabled="actionDisabled || !operations.canSelect" @change="onPrimaryBranchChange">
<option value="" disabled>Choose a branch</option>
<option value="StarPilot">StarPilot — Release</option><option value="Dom">Dom — Development</option>
<option value="other:">Other branches…</option></select>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="actionDisabled || !operations.canSelect || !canStageBranch" @click="chooseBranch">Set target branch</button></div>
<p v-if="primaryBranchHelp" class="gx-note">{{ primaryBranchHelp }}</p>
<div v-if="primaryChoice === 'other:'" class="gx-software-other-branches">
<label for="gx-other-branch" class="gx-row__label">Other branches</label>
<p class="gx-note">Additional branches from this installation's repository.</p>
<select id="gx-other-branch" v-model="draftBranch" class="gx-field" aria-label="Other branches" :disabled="actionDisabled || !operations.canSelect" @change="onOtherBranchChange">
<option value="" disabled>{{ otherBranches.length ? 'Select another branch' : 'No other branches available' }}</option>
<option v-for="branch in otherBranches" :key="branch.name" :value="branch.name" :disabled="!branch.listed">{{ branch.name }}{{ branch.current ? ' (current)' : '' }}{{ !branch.listed && !branch.current ? ' (unavailable)' : '' }}</option></select>
</div>
<p v-if="draftBranch && !operations.availableBranches.includes(draftBranch)" class="gx-note">This branch is not in the updater's available list and cannot be selected yet.</p>
<p v-if="!operations.availableBranches.length" class="gx-note">No branch list is available yet. Check for updates to refresh it.</p></section>
<section class="gx-card gx-software-card"><h3>Update</h3>
<p v-if="operations.reason" class="gx-note">{{ operations.reason }}</p>
<p v-else-if="!operations.parked" class="gx-note">Park the vehicle to change or install software.</p>
<p v-if="operations.request" :role="operations.request.state === 'failed' ? 'alert' : 'status'">{{ requestMessage(operations.request) }}</p>
<p v-else-if="data.updater.state">{{ data.updater.state }}</p>
<p v-if="data.updater.targetChangeFound === true">An update was found for the target branch.</p>
<p v-else-if="data.updater.targetChangeFound === false">No target change reported by the last check.</p>
<div class="gx-software-actions"><button type="button" class="gx-btn" :disabled="actionDisabled || !operations.canDownload || !operations.selectedTarget" @click="feed.action('download', operations.selectedTarget)">Download update</button>
<button type="button" class="gx-btn" :disabled="actionDisabled || !operations.canInstall || !operations.selectedTarget" @click="askInstall">Restart &amp; install</button></div>
<dl><dt>Last checked</dt><dd>{{ reported(data.updater.lastSuccessAt) }}</dd><dt>Last download</dt><dd>{{ reported(data.updater.lastFetchAt) }}</dd></dl></section>
<section v-if="operations.automaticDownloads !== undefined" class="gx-card gx-software-card">
<h3>Automatic Downloads</h3>
<label class="gx-toggle-row"><input type="checkbox" :checked="operations.automaticDownloads === true"
:disabled="busy || uncertain || !!error || !operations.canConfigure"
@change="feed.configureAutomaticDownloads($event.target.checked)"> Download updates automatically</label>
<p class="gx-note">Keep the selected branch ready to install. Turn this off to download updates yourself; checking for updates and manual downloads still work.</p>
<p v-if="operations.automaticDownloads === null" class="gx-note">The saved download preference could not be read. Turn off the vehicle, then choose a setting to repair it.</p>
</section>
<section class="gx-card gx-software-card">
<h3>Visual Layout Backup</h3>
<p class="gx-note">Save or restore your onroad layout and colors. This file does not include driving settings, calibration, credentials, or models.</p>
<div class="gx-software-actions">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="layoutBusy" @click="exportLayout">Download backup</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="layoutBusy" @click="$refs.layoutFile.click()">Restore from file</button>
<input ref="layoutFile" type="file" accept=".json,application/json" :disabled="layoutBusy" class="gx-sr-only" tabindex="-1" aria-label="Choose visual layout backup" @change="chooseLayoutFile">
</div>
<p v-if="layoutNotice" role="status">{{ layoutNotice }}</p>
<p v-if="layoutError" role="alert">{{ layoutError }}</p>
</section>
<section v-if="operations.history" class="gx-card gx-software-card">
<h3>Release Notes &amp; History</h3>
<details v-if="operations.history.currentReleaseNotes"><summary>Installed Release Notes</summary><pre class="gx-release-notes">{{ operations.history.currentReleaseNotes }}</pre></details>
<details v-if="operations.history.downloadedReleaseNotes"><summary>Downloaded Release Notes</summary><pre class="gx-release-notes">{{ operations.history.downloadedReleaseNotes }}</pre></details>
<details v-for="group in [{key:'installed',label:'Installed Build History'},{key:'downloaded',label:'Downloaded Build History'}]" :key="group.key">
<summary>{{ group.label }}</summary>
<p v-if="!operations.history[group.key].length" class="gx-note">No local history is available for this build.</p>
<ol v-else class="gx-build-history"><li v-for="entry in operations.history[group.key]" :key="entry.hash">
<strong>{{ entry.subject }}</strong><small>{{ reported(entry.date) }} · {{ shortCommit(entry.hash) }}</small>
</li></ol>
</details>
</section>
</template>
<Teleport to="body"><div v-if="dialog" class="gx-settings__modal" role="dialog" aria-modal="true" :aria-label="dialog.title" @click.self="closeDialog" @keydown.esc="closeDialog">
<div class="gx-card gx-settings__dialog"><h3>{{ dialog.title }}</h3><p>{{ dialog.message }}</p>
<div class="gx-settings__controls"><button type="button" class="gx-btn gx-btn--tonal" @click="closeDialog">Cancel</button>
<button type="button" class="gx-btn" @click="confirmDialog">{{ dialog.label }}</button></div></div></div></Teleport>
</template>
</div>`,
}
@@ -0,0 +1,172 @@
import { reactive } from "../vendor/vue/vue.esm-browser.js"
const RUNNING = new Set(["downloading", "verifying"])
export class SoundPacksFeed {
constructor({ publish, installed = () => {}, unauthorized = () => {},
fetcher = (...args) => fetch(...args), later = (fn, ms) => setTimeout(fn, ms),
cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, installed, unauthorized, fetcher, later, cancelTimer })
this.active = false
this.generation = 0
this.request = null
this.timeout = null
this.pollTimer = null
this.snapshot = null
this.seenComplete = new Set()
this.busy = false
this.blocked = false
}
stop() {
this.active = false
this.generation++
this.request?.abort()
if (this.timeout !== null) this.cancelTimer(this.timeout)
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.request = this.timeout = this.pollTimer = null
this.busy = false
this.blocked = false
this.snapshot = null
this.publish({ status: "idle", snapshot: null, busy: false, error: "" })
}
start() {
this.stop()
this.active = true
return this.refresh()
}
async run(path, body = null) {
if (!this.active || this.busy) return
const generation = ++this.generation
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.pollTimer = null
const request = new AbortController()
this.request = request
this.busy = true
this.publish({ status: this.snapshot ? "ready" : "loading", snapshot: this.snapshot, busy: true, error: "" })
this.timeout = this.later(() => {
if (!this.active || generation !== this.generation || this.request !== request) return
request.abort()
this.generation++
this.request = this.timeout = null
this.busy = false
this.blocked = true
this.publish({ status: this.snapshot ? "ready" : "unavailable", snapshot: this.snapshot, busy: false,
error: body ? "The request timed out. Its result is unknown; refresh the catalog before trying again." :
"The sound catalog timed out. Refresh to try again." })
}, 4000)
try {
const response = await this.fetcher(path, body === null
? { credentials: "same-origin", cache: "no-store", signal: request.signal }
: { method: "POST", credentials: "same-origin", cache: "no-store", signal: request.signal,
headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
if (!this.active || generation !== this.generation || request.signal.aborted) return
const payload = await response.json().catch(() => null)
if (!this.active || generation !== this.generation || request.signal.aborted) return
if (response.status === 401 || (response.status === 503 &&
["access_unavailable", "setup_required"].includes(payload?.code))) {
this.stop()
this.unauthorized()
return
}
if (!response.ok) throw new Error(payload?.error || payload?.message || "Sound request failed.")
if (!payload || !Array.isArray(payload.packs) || typeof payload.parked !== "boolean" ||
(payload.job !== null && typeof payload.job !== "object")) throw new Error("Sound catalog is unavailable.")
this.snapshot = payload
this.blocked = false
this.publish({ status: "ready", snapshot: payload, busy: false, error: "" })
if (payload.job?.state === "complete" && payload.job.id != null && !this.seenComplete.has(payload.job.id)) {
this.seenComplete.add(payload.job.id)
this.installed(payload.job.pack)
}
if (RUNNING.has(payload.job?.state) || !payload.parked) this.pollTimer = this.later(() => {
this.pollTimer = null
this.refresh()
}, 1000)
} catch (error) {
if (this.active && generation === this.generation && !request.signal.aborted) {
this.blocked = true
this.publish({ status: this.snapshot ? "ready" : "unavailable", snapshot: this.snapshot, busy: false,
error: error?.message || "Sound request failed." })
}
} finally {
if (this.request === request) {
if (this.timeout !== null) this.cancelTimer(this.timeout)
this.request = this.timeout = null
this.busy = false
}
}
}
refresh() { return this.run("./api/sounds") }
download(pack) {
if (!this.active || this.busy || this.blocked || !this.snapshot?.parked || !Array.isArray(this.snapshot.packs) ||
!this.snapshot.packs.some((item) => item.id === pack && item.installed === false) ||
RUNNING.has(this.snapshot.job?.state)) return
return this.run("./api/sounds/download", { pack })
}
cancel() {
const job = this.snapshot?.job
if (!this.active || this.busy || this.blocked || !this.snapshot?.parked || !RUNNING.has(job?.state) || job.id == null) return
return this.run("./api/sounds/cancel", { job: job.id })
}
}
export const SoundPacks = {
props: { unauthorized: { type: Function, required: true }, disabled: { type: Boolean, default: false } },
emits: ["installed"],
setup(props, { emit }) {
const state = reactive({ status: "idle", snapshot: null, busy: false, error: "" })
const feed = new SoundPacksFeed({ publish: (update) => Object.assign(state, update),
installed: (pack) => emit("installed", pack), unauthorized: props.unauthorized })
return { state, feed }
},
computed: {
job() { return this.state.snapshot?.job },
running() { return RUNNING.has(this.job?.state) },
progress() {
const bytes = Number(this.job?.bytes), total = Number(this.job?.total)
return Number.isFinite(bytes) && Number.isFinite(total) && total > 0
? Math.min(100, Math.max(0, Math.round(bytes / total * 100))) : null
},
},
mounted() { this.feed.start() },
beforeUnmount() { this.feed.stop() },
methods: {
canDownload(pack) { return !this.disabled && !this.state.busy && !this.state.error && this.state.status === "ready" &&
this.state.snapshot?.parked === true && !this.running && pack.installed === false },
},
template: `
<section class="gx-card gx-settings__section" aria-label="Sound pack catalog">
<div class="gx-section__header"><i class="bi bi-music-note-list" aria-hidden="true"></i>
<span class="gx-section__title">Sound pack catalog</span>
<button type="button" class="gx-icon-btn" aria-label="Refresh sound packs" :disabled="state.busy" @click="feed.refresh()"><i class="bi bi-arrow-clockwise" aria-hidden="true"></i></button>
</div>
<div class="gx-settings__grid" style="padding: 16px">
<p v-if="state.status === 'loading'" role="status">Loading sound packs…</p>
<p v-else-if="state.status === 'unavailable'" role="alert">Sound packs are unavailable.</p>
<p v-if="state.error" role="alert">{{ state.error }}</p>
<p v-if="state.snapshot && !state.snapshot.parked" class="gx-note">Park the vehicle to download sound packs.</p>
<p v-if="state.snapshot && !state.snapshot.packs.length" class="gx-note">No sound packs are available.</p>
<div v-for="pack in state.snapshot?.packs || []" :key="pack.id" class="gx-card gx-settings__row">
<strong>{{ pack.name }}</strong>
<div class="gx-settings__controls">
<span v-if="pack.installed" class="gx-chip">Installed</span>
<button v-else type="button" class="gx-btn gx-btn--tonal" :disabled="!canDownload(pack)" @click="feed.download(pack.id)">Download</button>
</div>
</div>
<div v-if="job" role="status">
<p v-if="running">{{ job.state === 'verifying' ? 'Verifying' : 'Downloading' }} {{ state.snapshot.packs.find(pack => pack.id === job.pack)?.name || 'sound pack' }}<span v-if="progress !== null"> · {{ progress }}%</span></p>
<p v-else-if="job.state === 'complete'">Sound pack installed. Choose it from the pack selector above when ready.</p>
<p v-else-if="job.state === 'cancelled'">Download cancelled.</p>
<p v-else-if="job.state === 'failed'" role="alert">Download failed: {{ job.error || 'Unknown error.' }}</p>
<progress v-if="running && progress !== null" :value="progress" max="100" :aria-label="'Sound pack download ' + progress + '%'">{{ progress }}%</progress>
<button v-if="running" type="button" class="gx-btn gx-btn--tonal" :disabled="disabled || state.busy || !!state.error || !state.snapshot.parked" @click="feed.cancel()">Cancel download</button>
</div>
</div>
</section>`,
}
@@ -0,0 +1,36 @@
export async function requestJson(url, { fetcher = (...args) => fetch(...args), timeout = 8000,
later = (fn, ms) => setTimeout(fn, ms), cancel = id => clearTimeout(id) } = {}) {
const controller = new AbortController()
let timer
try {
return await Promise.race([
(async () => {
const response = await fetcher(url, { cache: "no-store", credentials: "same-origin", signal: controller.signal })
if (!response.ok) throw new Error("Galaxy could not be reached")
return response.json()
})(),
new Promise((_, reject) => {
timer = later(() => { controller.abort(); reject(new Error("Galaxy took too long to respond")) }, timeout)
}),
])
} finally {
cancel(timer)
controller.abort()
}
}
export async function loadCatalog(options) {
const [catalog, runtime] = await Promise.all([
requestJson("./data/catalog.json", options), requestJson("./data/runtime.json", options),
])
if (runtime?.schemaVersion !== 1 || !["sample", "local"].includes(runtime.monitor)) throw new Error("Invalid Galaxy runtime")
const available = ["/system", "/navigation", "/manage_models", "/model_laboratory", "/cameras", "/appearance",
"/developer/connect", "/device-preferences", "/driving", "/tuning", "/bluetooth", "/vehicle", "/theme_maker", "/galaxy", "/android-auto"]
if (catalog?.mode !== "offline-preview" || !Array.isArray(catalog.tools) || catalog.tools.some(tool =>
typeof tool?.path !== "string" || !tool.path.startsWith("/") || typeof tool.name !== "string" ||
(tool.path === "/logs" ? tool.availability !== "partial-preview" :
available.includes(tool.path) ? tool.availability !== "local-only" : tool.availability !== "unavailable"))) {
throw new Error("Invalid Galaxy catalog")
}
return { tools: catalog.tools.slice().sort((a, b) => a.name.localeCompare(b.name)), mode: runtime.monitor }
}
@@ -0,0 +1,76 @@
// Shared boundary; the selected transport must explicitly choose its source mode.
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
const number = (value, min = 0, max = Infinity) => typeof value === "number" && Number.isFinite(value) && value >= min && value <= max ? value : null
const text = (value) => typeof value === "string" && value.trim() ? value : null
const percent = (value) => number(value, 0, 100)
const integer = (value, min = 0) => Number.isSafeInteger(value) && value >= min ? value : null
export function normalizeSnapshot(raw, expectedMode = "synthetic-preview") {
if (!["synthetic-preview", "local-runtime"].includes(expectedMode) ||
!object(raw) || raw.mode !== expectedMode || raw.schemaVersion !== 1 ||
!Array.isArray(raw.cores) || !Array.isArray(raw.processes) || !object(raw.memory) || !object(raw.storage) ||
number(raw.sampledAt, 1) === null || !text(raw.source)) {
throw new Error("Invalid System Monitor sample")
}
const cores = raw.cores.map((core) => {
if (!object(core) || !text(core.name)) throw new Error("Invalid CPU core in sample")
return { name: core.name, percent: percent(core.percent) }
})
if (new Set(cores.map((core) => core.name)).size !== cores.length) throw new Error("Duplicate CPU core in sample")
const processes = raw.processes.map((process) => {
if (!object(process) || integer(process.pid, 1) === null || !text(process.name) || !text(process.user) ||
typeof process.kernel !== "boolean" || !text(process.state)) throw new Error("Invalid process in sample")
return { pid: process.pid, name: process.name, user: process.user, kernel: process.kernel,
state: process.state, cpu: percent(process.cpu), memoryMiB: number(process.memoryMiB) }
})
if (new Set(processes.map((process) => process.pid)).size !== processes.length) throw new Error("Duplicate process PID in sample")
const memory = { totalMiB: number(raw.memory.totalMiB, 0.01), usedMiB: number(raw.memory.usedMiB),
availableMiB: number(raw.memory.availableMiB), percent: percent(raw.memory.percent) }
const storage = { usedGiB: number(raw.storage.usedGiB), totalGiB: number(raw.storage.totalGiB) }
if (memory.totalMiB !== null && (memory.usedMiB !== null && memory.usedMiB > memory.totalMiB ||
memory.availableMiB !== null && memory.availableMiB > memory.totalMiB)) {
memory.usedMiB = null; memory.availableMiB = null; memory.percent = null
}
if (storage.totalGiB !== null && storage.usedGiB !== null && storage.usedGiB > storage.totalGiB) storage.usedGiB = null
const v = object(raw.vitals) ? raw.vitals : {}
const vitals = Object.fromEntries(["cpuTempC", "gpuTempC", "hotspotTempC", "gpuEdgeTempC", "memoryUsedBytes", "memoryTotalBytes",
"onboardMaxAgeMs", "maxAgeMs", "memoryMaxAgeMs"].map((key) => [key, number(v[key])]))
return { source: raw.source, sampledAt: raw.sampledAt, cpuPercent: percent(raw.cpuPercent), cores, memory, storage,
uptimeSeconds: number(raw.uptimeSeconds), processCount: integer(raw.processCount), processes, vitals }
}
const FEATURE_LABELS = new Map([
["selfdrive.controls.controlsd", "Steering and speed control"],
["selfdrive.modeld.modeld", "Driving model"],
["system.hardware.hardwared", "Power and temperature management"],
["system.loggerd.uploader", "Log uploads"],
["system.manager.manager", "Process manager"],
])
export const processFeature = (process) => process.kernel ? "" : FEATURE_LABELS.get(process.name.replace(/^openpilot\./, "")) || ""
const PROCESS_STATES = new Map([["R", "Running"], ["S", "Sleeping"], ["D", "Waiting"], ["T", "Stopped"],
["t", "Tracing"], ["Z", "Zombie"], ["I", "Idle"]])
export const processState = (state) => PROCESS_STATES.get(state) || state
export const displayNumber = (value, suffix = "") => value === null || value === undefined ? "—" : `${value.toFixed(1)}${suffix}`
export function vital(snapshot, field, budget, elapsedMs = 0) {
const v = snapshot?.vitals
if ((field === "memoryUsedBytes" || field === "memoryTotalBytes") && v?.memoryUsedBytes != null && v?.memoryTotalBytes != null &&
v.memoryUsedBytes > v.memoryTotalBytes) return null
return v && v[budget] > 0 && elapsedMs >= 0 && elapsedMs < v[budget] ? v[field] ?? null : null
}
export function processRows(snapshot, { query = "", scope = "comma", sort = "cpu", descending = true } = {}) {
const q = query.trim().toLowerCase()
const keys = new Set(["name", "pid", "cpu", "memoryMiB", "user", "state"])
const key = keys.has(sort) ? sort : "cpu"
return (snapshot?.processes || []).filter((p) =>
(scope === "all" || (scope === "users" ? !p.kernel : p.user === "comma")) &&
(!q || `${p.name} ${processFeature(p)} ${p.pid} ${p.user} ${processState(p.state)}`.toLowerCase().includes(q))
).slice().sort((a, b) => {
const av = a[key], bv = b[key]
if (av === null) return bv === null ? a.pid - b.pid : 1
if (bv === null) return -1
const cmp = typeof av === "number" ? av - bv : String(av).localeCompare(String(bv))
return (descending ? -cmp : cmp) || a.pid - b.pid
})
}
@@ -0,0 +1,60 @@
import { displayNumber, processFeature, processRows, processState, vital } from "./system-monitor-data.js"
import { MonitorFeed } from "./monitor-feed.js"
export const SystemMonitor = {
name: "SystemMonitor",
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ snapshot: null, status: "loading", error: "", query: "", scope: "comma", sort: "cpu", descending: true }),
mounted() {
this.feed = new MonitorFeed({ mode: this.mode, publish: (state) => Object.assign(this, state), unauthorized: this.unauthorized })
this.feed.start()
},
beforeUnmount() { this.feed?.stop() },
computed: {
rows() { return processRows(this.snapshot, this) },
captured() { return this.snapshot ? new Date(this.snapshot.sampledAt * 1000).toLocaleString() : "—" },
uptime() {
const s = this.snapshot?.uptimeSeconds
return s === null || s === undefined ? "—" : `${Math.floor(s / 3600)}h ${Math.floor(s / 60) % 60}m`
},
vramUsed() { return vital(this.snapshot, "memoryUsedBytes", "memoryMaxAgeMs") },
vramTotal() { return vital(this.snapshot, "memoryTotalBytes", "memoryMaxAgeMs") },
},
methods: {
number: displayNumber,
feature: processFeature,
state: processState,
sensor(field, budget) { return vital(this.snapshot, field, budget) },
sortBy(key) { if (this.sort === key) this.descending = !this.descending; else { this.sort = key; this.descending = ["cpu", "memoryMiB"].includes(key) } },
arrow(key) { return this.sort === key ? (this.descending ? " ↓" : " ↑") : "" },
ariaSort(key) { return this.sort !== key ? "none" : this.descending ? "descending" : "ascending" },
},
template: `
<div class="gx-monitor">
<div class="gx-monitor__toolbar"><div><h2>System Monitor</h2><div class="gx-note">{{ mode === 'sample' ? 'Synthetic sample' : 'Local system' }} · Captured {{ captured }}</div></div><span class="gx-chip">{{ mode === 'sample' ? 'Offline preview' : status === 'current' ? 'Live' : 'Unavailable' }}</span></div>
<p v-if="mode === 'sample'" class="gx-note">Illustrative values only. No device was read.</p>
<p v-else class="gx-note">Read-only system activity. Updates every few seconds.</p>
<p v-if="error" class="gx-note gx-note--danger" role="alert">{{ error }}</p>
<div v-else-if="!snapshot" class="gx-loading">Loading system activity…</div>
<template v-else>
<div class="gx-monitor__summary">
<section class="gx-card gx-monitor__metric"><span>CPU</span><strong>{{ number(snapshot.cpuPercent, '%') }}</strong><small>{{ snapshot.cores.length }} cores · overall usage</small></section>
<section class="gx-card gx-monitor__metric"><span>Memory</span><strong>{{ number(snapshot.memory.percent, '%') }}</strong><small>{{ snapshot.memory.usedMiB == null ? '—' : number(snapshot.memory.usedMiB / 1024) }} / {{ snapshot.memory.totalMiB == null ? '—' : number(snapshot.memory.totalMiB / 1024) }} GiB</small><progress v-if="snapshot.memory.percent != null" :value="snapshot.memory.percent" max="100" aria-label="Memory usage"></progress></section>
<section class="gx-card gx-monitor__metric"><span>Processes</span><strong>{{ snapshot.processCount ?? '—' }}</strong><small>Uptime {{ uptime }}</small></section>
<section class="gx-card gx-monitor__metric"><span>Storage</span><strong>{{ number(snapshot.storage.usedGiB) }} GiB</strong><small>{{ number(snapshot.storage.totalGiB) }} GiB total</small></section>
<section class="gx-card gx-monitor__metric"><span>Onboard CPU temperature</span><strong>{{ number(sensor('cpuTempC', 'onboardMaxAgeMs'), ' °C') }}</strong></section>
<section class="gx-card gx-monitor__metric"><span>Onboard GPU temperature</span><strong>{{ number(sensor('gpuTempC', 'onboardMaxAgeMs'), ' °C') }}</strong></section>
<section class="gx-card gx-monitor__metric"><span>eGPU hotspot temperature</span><strong>{{ number(sensor('hotspotTempC', 'maxAgeMs'), ' °C') }}</strong></section>
<section class="gx-card gx-monitor__metric"><span>eGPU temperature</span><strong>{{ number(sensor('gpuEdgeTempC', 'maxAgeMs'), ' °C') }}</strong></section>
<section class="gx-card gx-monitor__metric"><span>eGPU VRAM</span><strong>{{ vramUsed == null ? '—' : number(vramUsed / 1073741824) + ' GiB' }}</strong><small v-if="vramTotal != null">{{ number(vramTotal / 1073741824) }} GiB total</small><progress v-if="vramTotal > 0 && vramUsed != null" :value="vramUsed" :max="vramTotal" aria-label="eGPU VRAM usage"></progress></section>
</div>
<details class="gx-card gx-monitor__cores"><summary>CPU cores</summary><div><span v-for="core in snapshot.cores" :key="core.name">{{ core.name.toUpperCase() }} <b>{{ number(core.percent, '%') }}</b><progress v-if="core.percent != null" :value="core.percent" max="100" :aria-label="core.name + ' usage'"></progress></span></div></details>
<div class="gx-monitor__filters"><input class="gx-field" type="search" v-model="query" aria-label="Search processes" placeholder="Search feature, process, PID or user…"><select class="gx-field" v-model="scope" aria-label="Process group"><option value="comma">Comma processes</option><option value="users">Apps and services</option><option value="all">All processes</option></select></div>
<p class="gx-note">{{ rows.length }} processes shown. {{ snapshot.cpuPercent == null ? 'CPU sample unavailable.' : '' }}</p>
<section class="gx-card gx-monitor__table" tabindex="0" aria-label="Process table; scroll horizontally for more columns">
<table><thead><tr><th v-for="column in [['name','Process'],['pid','PID'],['cpu','CPU'],['memoryMiB','Memory'],['user','User'],['state','Status']]" :key="column[0]" :aria-sort="ariaSort(column[0])"><button type="button" @click="sortBy(column[0])">{{ column[1] }}{{ arrow(column[0]) }}</button></th></tr></thead>
<tbody><tr v-for="process in rows" :key="process.pid"><td :title="process.name">{{ process.name }}<span v-if="feature(process)" class="gx-monitor__feature"> ({{ feature(process) }})</span></td><td>{{ process.pid }}</td><td>{{ number(process.cpu, '%') }}</td><td>{{ number(process.memoryMiB) }} MiB</td><td>{{ process.user }}</td><td><span class="gx-chip">{{ state(process.state) }}</span></td></tr><tr v-if="!rows.length"><td colspan="6" class="gx-empty">No matching processes.</td></tr></tbody></table>
</section>
</template>
</div>`,
}
@@ -0,0 +1,159 @@
import { reactive } from "../vendor/vue/vue.esm-browser.js"
import { SETTINGS_SECTIONS } from "./settings.js"
const SPECIAL_PAGES = new Set(["ui_layout", "favorites"])
const PAGE_SECTIONS = new Map(SETTINGS_SECTIONS.flatMap((section) =>
section.pages.filter((page) => !SPECIAL_PAGES.has(page)).map((page) => [page, section.label])))
const INITIAL_PAGES = [...PAGE_SECTIONS.keys()]
const searchable = (row, title, section) => [row.label, row.value, row.reason, row.unit, title, section]
.filter((value) => typeof value === "string").join(" ").toLocaleLowerCase()
export function searchSettings(entries, query, limit = 30) {
const words = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
if (!words.length) return []
return entries.filter((entry) => words.every((word) => entry.search.includes(word)))
.sort((a, b) => Number(b.label.toLocaleLowerCase().startsWith(words[0])) - Number(a.label.toLocaleLowerCase().startsWith(words[0])) ||
a.section.localeCompare(b.section) || a.label.localeCompare(b.label)).slice(0, limit)
}
export class SettingsSearchIndex {
constructor({ fetcher = (...args) => fetch(...args), publish = () => {}, unauthorized = () => {} } = {}) {
Object.assign(this, { fetcher, publish, unauthorized })
this.entries = []
this.status = "idle"
this.generation = 0
this.controller = null
this.pending = null
}
stop() {
this.generation++
this.controller?.abort()
this.controller = this.pending = null
this.entries = []
this.status = "idle"
this.publish({ status: "idle", entries: [], failed: 0 })
}
async load() {
if (this.pending) return this.pending
if (this.status === "ready") return
const generation = ++this.generation
const controller = new AbortController()
this.controller = controller
this.status = "loading"
this.entries = []
this.publish({ status: "loading", entries: [], failed: 0 })
this.pending = this.collect(generation, controller).finally(() => {
if (this.generation === generation) this.pending = null
})
return this.pending
}
async collect(generation, controller) {
const seen = new Set(INITIAL_PAGES)
let queue = [...INITIAL_PAGES]
let failed = 0
while (queue.length && this.generation === generation) {
const batch = queue
queue = []
let cursor = 0
const worker = async () => {
while (cursor < batch.length && this.generation === generation) {
const page = batch[cursor++]
let timer
const timed = new AbortController()
const abort = () => timed.abort()
controller.signal.addEventListener("abort", abort, { once: true })
try {
timer = setTimeout(abort, 5000)
const response = await this.fetcher(`./api/settings/pages/${encodeURIComponent(page)}`,
{ credentials: "same-origin", cache: "no-store", signal: timed.signal })
if (this.generation !== generation) return
if (response.status === 401) {
this.stop()
this.unauthorized()
return
}
if (!response.ok) throw new Error("Settings unavailable")
const data = await response.json()
if (this.generation !== generation) return
if (data?.page !== page || !Array.isArray(data.rows) || typeof data.title !== "string") throw new Error("Invalid settings page")
const section = PAGE_SECTIONS.get(page.split("/")[0]) || "Toggles"
for (const row of data.rows) {
if (typeof row?.label !== "string" || !row.label) continue
if (typeof row.page === "string" && row.page && !seen.has(row.page) && seen.size < 64) {
seen.add(row.page)
queue.push(row.page)
}
if (row.page) continue
this.entries.push({ page, label: row.label, title: data.title, section,
search: searchable(row, data.title, section) })
}
this.publish({ status: "loading", entries: [...this.entries], failed })
} catch {
if (this.generation === generation) failed++
} finally {
clearTimeout(timer)
controller.signal.removeEventListener("abort", abort)
}
}
}
await Promise.all(Array.from({ length: Math.min(4, batch.length) }, worker))
}
if (this.generation !== generation) return
this.status = failed ? "partial" : "ready"
this.publish({ status: this.status, entries: [...this.entries], failed })
}
}
export const ToggleSearch = {
props: { enabled: { type: Boolean, required: true }, unauthorized: { type: Function, required: true },
openPage: { type: Function, required: true } },
setup(props) {
const state = reactive({ query: "", open: false, active: 0, status: "idle", entries: [], failed: 0 })
const index = new SettingsSearchIndex({ publish: (update) => Object.assign(state, update), unauthorized: props.unauthorized })
return { state, index }
},
computed: {
results() { return searchSettings(this.state.entries, this.state.query) },
showResults() { return this.enabled && this.state.open && !!this.state.query.trim() },
},
watch: {
enabled(value) { if (!value) { this.index.stop(); this.state.query = ""; this.state.open = false } },
},
beforeUnmount() { this.index.stop() },
methods: {
onInput() { this.state.open = true; this.state.active = 0; if (this.enabled) this.index.load() },
onFocus() { if (this.state.query.trim()) { this.state.open = true; this.index.load() } },
onBlur(event) { if (!event.currentTarget.contains(event.relatedTarget)) this.state.open = false },
onKey(event) {
if (event.key === "Escape") { this.state.query = ""; this.state.open = false; event.preventDefault(); return }
if (!this.showResults) return
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault()
this.state.active = (this.state.active + (event.key === "ArrowDown" ? 1 : -1) + this.results.length) % Math.max(1, this.results.length)
} else if (event.key === "Enter" && this.results.length) { event.preventDefault(); this.choose(this.results[this.state.active] || this.results[0]) }
},
choose(hit) { this.state.query = ""; this.state.open = false; this.openPage(hit) },
},
template: `
<div class="gx-searchwrap" @focusout="onBlur">
<input v-model="state.query" class="gx-search gx-appbar__search" type="search" placeholder="Search toggles…"
aria-label="Search toggles" :disabled="!enabled" role="combobox" aria-autocomplete="list"
:aria-expanded="showResults" aria-controls="gx-toggle-search-results"
:aria-activedescendant="showResults && results.length ? 'gx-toggle-result-' + state.active : undefined"
@input="onInput" @focus="onFocus" @keydown="onKey">
<div v-if="showResults" id="gx-toggle-search-results" class="gx-search-results" role="listbox" aria-label="Toggle search results">
<p v-if="state.status === 'loading'" class="gx-search-results__status" role="status">Searching saved settings…</p>
<button v-for="(hit, index) in results" :key="hit.page + ':' + hit.label" type="button" role="option"
:id="'gx-toggle-result-' + index" class="gx-search-results__item" :class="{active:index === state.active}"
:aria-selected="index === state.active" @click="choose(hit)">
<strong>{{ hit.label }}</strong><span>{{ hit.section }} · {{ hit.title }}</span>
</button>
<p v-if="state.status === 'partial'" class="gx-search-results__status" role="status">Some settings could not be searched. Try again.</p>
<p v-if="state.status === 'ready' && !results.length" class="gx-search-results__status">No matching saved settings.</p>
</div>
</div>`,
}
@@ -0,0 +1,20 @@
import { navigate } from "./router.js"
export const Tools = {
name: "Tools",
props: { tools: { type: Array, required: true }, mode: { type: String, required: true } },
methods: { open(tool) { navigate(tool.path) } },
template: `
<div>
<h2 style="margin-top:0">Tools</h2>
<div class="gx-grid">
<button v-for="tool in tools" :key="tool.path" type="button" class="gx-tile" @click="open(tool)">
<i class="bi" :class="tool.icon" aria-hidden="true"></i>
<span>{{ tool.name }}</span>
<small>{{ tool.description }}</small>
<small v-if="mode !== 'local'" class="gx-availability">{{ tool.availability === 'partial-preview' ? 'Sample available' : 'Connect to your device to use this tool' }}</small>
</button>
</div>
</div>
`,
}
@@ -0,0 +1,50 @@
// Coordinates are saved in the unmirrored source camera frame. The editor is mirrored for the driver.
export const FORMATS = [[1928, 1208], [1344, 760]]
export const displaySide = (cameraSide) => cameraSide === "cameraRight" ? "Vehicle left (camera right)" :
cameraSide === "cameraLeft" ? "Vehicle right (camera left)" : ""
export function sourcePoint(clientX, clientY, rect, width, height) {
if (!(rect?.width > 0 && rect.height > 0) || !FORMATS.some(([w, h]) => w === width && h === height)) return null
const x = (clientX - rect.left) / rect.width
const y = (clientY - rect.top) / rect.height
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x > 1 || y < 0 || y > 1) return null
return [Math.round((1 - x) * width), Math.round(y * height)]
}
const orient = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
const between = (a, b, c) => b >= Math.min(a, c) && b <= Math.max(a, c)
function intersects(a, b, c, d) {
const abc = orient(a, b, c), abToD = orient(a, b, d), cda = orient(c, d, a), cdb = orient(c, d, b)
const on = (p, q, r) => orient(p, q, r) === 0 && between(p[0], q[0], r[0]) && between(p[1], q[1], r[1])
return on(a, c, b) || on(a, d, b) || on(c, a, d) || on(c, b, d) ||
((abc > 0) !== (abToD > 0) && (cda > 0) !== (cdb > 0))
}
export function validPolygon(points, width, height) {
if (!Array.isArray(points) || points.length < 3 || points.length > 32) return false
if (points.some((point) => !Array.isArray(point) || point.length !== 2 ||
point.some((v) => !Number.isFinite(v)) || point[0] < 0 || point[0] > width ||
point[1] < 0 || point[1] > height)) return false
if (new Set(points.map((p) => `${p[0]},${p[1]}`)).size !== points.length) return false
const area = points.reduce((sum, a, i) => {
const b = points[(i + 1) % points.length]
return sum + a[0] * b[1] - b[0] * a[1]
}, 0)
if (Math.abs(area) <= 1e-9 * width * height) return false
for (let i = 0; i < points.length; i++) {
for (let j = i + 2; j < points.length; j++) {
if (i === 0 && j === points.length - 1) continue
if (intersects(points[i], points[(i + 1) % points.length], points[j], points[(j + 1) % points.length])) return false
}
}
return true
}
export function annotationDraft(width, height, cameraLeft, cameraRight) {
if (!FORMATS.some(([w, h]) => w === width && h === height)) return null
if (!Array.isArray(cameraLeft) || !Array.isArray(cameraRight) ||
(!cameraLeft.length && !cameraRight.length) ||
(cameraLeft.length && !validPolygon(cameraLeft, width, height)) ||
(cameraRight.length && !validPolygon(cameraRight, width, height))) return null
return { version: 1, width, height, poly_left: cameraLeft, poly_right: cameraRight }
}
+274
View File
@@ -0,0 +1,274 @@
import { reactive } from "../vendor/vue/vue.esm-browser.js"
import { SettingsFeed } from "./settings.js"
import { GalaxySettingRow } from "./galaxy-setting-row.js"
import { FORMATS, annotationDraft, displaySide, sourcePoint } from "./vasm-geometry.js"
const MAX_STILL_BYTES = 8 * 1024 * 1024
export const VasmPage = {
name: "VasmPage",
components: { GalaxySettingRow },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
go: { type: Function, required: true } },
setup(props) {
const state = reactive({ status: "idle", data: null, pending: null, error: "", width: 1928, height: 1208,
cameraLeft: [], cameraRight: [], activeSide: "cameraRight", localNote: "", imageName: "", reviewing: false })
let lastView = null
const feed = new SettingsFeed({ unauthorized: props.unauthorized, publish: (update) => {
Object.assign(state, update)
if (update.status === "ready" && update.data?.view && update.data.view !== lastView) {
lastView = update.data.view
const editor = update.data.editor
state.width = editor.width
state.height = editor.height
state.cameraLeft = editor.cameraLeft.map((point) => [...point])
state.cameraRight = editor.cameraRight.map((point) => [...point])
state.localNote = FORMATS.some(([w, h]) => w === editor.width && h === editor.height) ? "" :
"This saved camera size cannot be edited here. Choose a supported size and redraw the regions."
}
} })
return { state, feed, FORMATS, displaySide }
},
mounted() {
this._image = null
this._imageUrl = null
this._imageView = null
this._imageGeneration = 0
this._dragIndex = -1
this._dragSide = null
if (this.mode === "local") this.feed.start("vasm")
this.$nextTick(() => this.redraw())
},
updated() {
if (this._imageView && this._imageView !== this.state.data?.view) this.dropImage()
this.$nextTick(() => this.redraw())
},
beforeUnmount() {
this.feed.stop()
this._imageGeneration++
if (this._image) this._image.src = ""
if (this._imageUrl) URL.revokeObjectURL(this._imageUrl)
this._image = this._imageUrl = null
},
computed: {
controls() { return (this.state.data?.rows || []).map((row, index) => ({ row, index }))
.filter(({ index }) => index !== this.state.data?.editorRow && !["Saved spot-monitor settings"].includes(this.state.data.rows[index].label)) },
canEdit() { return this.mode === "local" && !!this.state.data?.parked && this.state.data?.editorRow >= 0 &&
!!this.state.data.rows[this.state.data.editorRow]?.available && this.state.status === "ready" &&
!this.state.pending && !this.state.reviewing },
format() { return `${this.state.width}x${this.state.height}` },
supportedFormat() { return FORMATS.some(([w, h]) => w === this.state.width && h === this.state.height) },
canDraw() { return this.canEdit && this.supportedFormat },
},
methods: {
changeFormat(event) {
const [width, height] = String(event.target.value).split("x").map(Number)
if (!FORMATS.some(([w, h]) => w === width && h === height)) return
if (width === this.state.width && height === this.state.height) return
this.state.width = width
this.state.height = height
this.state.cameraLeft = []
this.state.cameraRight = []
this.state.localNote = "Format changed in this draft. Draw at least one region; nothing is saved until confirmation."
this.dropImage()
this.redraw()
},
dropImage() {
this._imageGeneration++
if (this._image) this._image.src = ""
if (this._imageUrl) URL.revokeObjectURL(this._imageUrl)
this._image = this._imageUrl = null
this._imageView = null
this.state.imageName = ""
},
selectImage(event) {
const file = event.target.files?.[0]
event.target.value = ""
if (!file) return
if (!["image/jpeg", "image/png"].includes(file.type) || file.size > MAX_STILL_BYTES || file.size === 0) {
this.state.localNote = "Choose a PNG or JPEG still under 8 MB."
return
}
this.dropImage()
const generation = this._imageGeneration
const selectedWidth = this.state.width, selectedHeight = this.state.height
const selectedView = this.state.data?.view
const url = URL.createObjectURL(file)
const image = new Image()
this._imageUrl = url
image.onload = () => {
if (generation !== this._imageGeneration) return
if (selectedView !== this.state.data?.view || selectedWidth !== this.state.width ||
selectedHeight !== this.state.height) {
this.dropImage()
return
}
if (image.naturalWidth !== selectedWidth || image.naturalHeight !== selectedHeight) {
this.state.localNote = `Still image must match ${selectedWidth} × ${selectedHeight}.`
this.dropImage()
return
}
this._image = image
this._imageView = selectedView
this.state.imageName = file.name
this.state.localNote = "This still stays in browser memory and is never uploaded."
this.redraw()
}
image.onerror = () => {
if (generation !== this._imageGeneration) return
this.state.localNote = "Still image could not be decoded."
this.dropImage()
}
image.src = url
},
point(event) {
const canvas = this.$refs.canvas
return canvas ? sourcePoint(event.clientX, event.clientY, canvas.getBoundingClientRect(),
this.state.width, this.state.height) : null
},
pointerDown(event) {
if (!this.canDraw || !this.state.activeSide) return
const point = this.point(event)
if (!point) return
const side = this.state.activeSide
const points = this.state[side]
const rect = this.$refs.canvas.getBoundingClientRect()
const radius = 16 * this.state.width / rect.width
const index = points.findIndex(([x, y]) => Math.hypot(x - point[0], y - point[1]) <= radius)
if (index >= 0) {
this._dragIndex = index
this._dragSide = side
event.currentTarget.setPointerCapture?.(event.pointerId)
} else if (points.length < 32) {
this.state[side] = [...points, point]
this.state.localNote = "Draft only; review the regions before saving."
} else this.state.localNote = "Each side allows at most 32 vertices."
this.redraw()
},
pointerMove(event) {
if (this._dragIndex < 0 || !this._dragSide) return
if (!this.canDraw) { this._dragIndex = -1; this._dragSide = null; return }
const point = this.point(event)
if (!point) return
const points = [...this.state[this._dragSide]]
points[this._dragIndex] = point
this.state[this._dragSide] = points
this.redraw()
},
pointerUp(event) {
this._dragIndex = -1
this._dragSide = null
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId)
},
undo(side) {
this.state[side] = this.state[side].slice(0, -1)
this.redraw()
},
clear(side) {
this.state[side] = []
this.redraw()
},
async saveRegions() {
if (!this.canDraw) return
const draft = annotationDraft(this.state.width, this.state.height,
this.state.cameraLeft, this.state.cameraRight)
if (!draft) {
this.state.localNote = "Draw 3–32 vertices around at least one window. Regions cannot cross or leave the frame."
return
}
this.state.localNote = ""
this.state.reviewing = true
try {
await this.feed.preview(this.state.data.editorRow, 0, draft)
} finally {
this.state.reviewing = false
}
},
redraw() {
const canvas = this.$refs.canvas
if (!canvas || !FORMATS.some(([w, h]) => w === this.state.width && h === this.state.height)) return
if (canvas.width !== this.state.width) canvas.width = this.state.width
if (canvas.height !== this.state.height) canvas.height = this.state.height
const ctx = canvas.getContext("2d")
if (!ctx) return
const w = canvas.width, h = canvas.height
ctx.fillStyle = "#151821"
ctx.fillRect(0, 0, w, h)
if (this._image) {
ctx.save()
ctx.translate(w, 0)
ctx.scale(-1, 1)
ctx.drawImage(this._image, 0, 0, w, h)
ctx.restore()
}
for (const [side, color] of [["cameraRight", "#5ee5ee"], ["cameraLeft", "#ffb865"]]) {
const points = this.state[side]
if (!points.length) continue
ctx.beginPath()
ctx.moveTo(w - points[0][0], points[0][1])
for (const [x, y] of points.slice(1)) ctx.lineTo(w - x, y)
if (points.length >= 3) ctx.closePath()
ctx.lineWidth = Math.max(2, w / 640)
ctx.strokeStyle = color
ctx.fillStyle = side === "cameraRight" ? "#5ee5ee38" : "#ffb86538"
if (points.length >= 3) ctx.fill()
ctx.stroke()
for (const [x, y] of points) {
ctx.beginPath()
ctx.arc(w - x, y, Math.max(5, w / 320), 0, Math.PI * 2)
ctx.fillStyle = color
ctx.fill()
}
}
},
},
template: `
<section class="gx-settings gx-vasm" aria-label="V-ASM saved settings">
<header class="gx-card gx-settings__header"><div><p class="gx-eyebrow">Saved preferences</p><h2>V-ASM Spot Monitoring</h2>
<p>Draw camera window regions for a saved visual warning preference. Camera capture, model qualification, and current warnings are unavailable here.</p></div>
<button type="button" class="gx-btn gx-btn--tonal" @click="go('/cameras')">Back to Cameras</button></header>
<div v-if="mode !== 'local'" class="gx-card gx-message" role="status">Local saved settings are unavailable in preview.</div>
<template v-else>
<div v-if="state.status === 'loading'" class="gx-card gx-message" role="status">Loading saved settings…</div>
<div v-else-if="state.status === 'unavailable'" class="gx-card gx-message" role="alert">Saved settings are unavailable.</div>
<div v-if="state.error" class="gx-card gx-message" role="alert">{{ state.error }}
<button type="button" class="gx-btn gx-btn--tonal" @click="feed.load()">Refresh</button></div>
<div v-if="state.data" class="gx-settings__body">
<div class="gx-settings__subhead"><p>{{ state.data.subtitle }} <span v-if="!state.data.parked">Turn the vehicle off to change these settings.</span></p>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="state.status === 'saving'" @click="feed.load()">Refresh</button></div>
<section class="gx-card gx-settings__section" aria-label="Saved settings">
<GalaxySettingRow v-for="{ row, index } in controls" :key="state.data.view + ':' + index" :row="row" :index="index"
:disabled="!state.data.parked || state.status !== 'ready' || state.reviewing || !!state.pending"
:save-value="(index, value) => feed.previewValue(index, value)"
@review="(index, direction) => feed.preview(index, direction)" />
</section>
<section class="gx-card gx-vasm__editor" aria-label="Camera window region editor">
<h3>Camera Window Regions</h3>
<p>The display is mirrored: vehicle left is camera right; vehicle right is camera left. Trace visible side glass, leaving pillars and interior out.</p>
<label>Camera frame size <select class="gx-field" :value="format" :disabled="!canEdit" @change="changeFormat">
<option v-if="!supportedFormat" :value="format" disabled>Saved {{ state.width }} × {{ state.height }} — choose a supported size</option>
<option v-for="[w,h] in FORMATS" :key="w" :value="w + 'x' + h">{{ w }} × {{ h }}</option></select></label>
<label>Optional local still (PNG/JPEG, exact selected size, under 8 MB)
<input type="file" accept="image/png,image/jpeg" :disabled="!canDraw" @change="selectImage"></label>
<button v-if="state.imageName" type="button" class="gx-btn gx-btn--tonal" @click="dropImage(); redraw()">Remove local still</button>
<p class="gx-note">{{ state.imageName ? 'Local still: ' + state.imageName : 'Empty dimension-matched canvas; no camera image is requested.' }}</p>
<div class="gx-vasm__sides"><div v-for="side in ['cameraRight', 'cameraLeft']" :key="side" class="gx-vasm__side">
<button type="button" class="gx-btn" :class="{'gx-btn--tonal':state.activeSide !== side}" :disabled="!canDraw" @click="state.activeSide=side">{{ displaySide(side) }}</button>
<span>{{ state[side].length }} vertices</span>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!canDraw || !state[side].length" @click="undo(side)">Undo last</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!canDraw || !state[side].length" @click="clear(side)">Clear side</button>
</div></div>
<canvas ref="canvas" class="gx-vasm__canvas" :aria-label="'Mirrored camera window canvas, editing ' + displaySide(state.activeSide)"
@pointerdown="pointerDown" @pointermove="pointerMove" @pointerup="pointerUp" @pointercancel="pointerUp"></canvas>
<p v-if="state.localNote" class="gx-note" role="status">{{ state.localNote }}</p>
<button type="button" class="gx-btn" :disabled="!canDraw" @click="saveRegions">Review saved regions…</button>
<p class="gx-note">A saved choice alone does not activate monitoring. The selected image never leaves this browser.</p>
</section>
</div>
<Teleport to="body"><div v-if="state.pending" class="gx-settings__modal" role="dialog" aria-modal="true" aria-label="Confirm V-ASM preference">
<div class="gx-card gx-settings__dialog"><h3>Confirm Saved Preference</h3><p>{{ state.pending.question }}</p>
<div class="gx-settings__controls"><button type="button" class="gx-btn gx-btn--tonal" @click="feed.cancel()">Cancel</button>
<button type="button" class="gx-btn" @click="feed.confirm()">Save</button></div></div></div></Teleport>
</template>
</section>`,
}
@@ -0,0 +1,202 @@
import { reactive } from "../vendor/vue/vue.esm-browser.js"
import { SettingsPage } from "./settings.js"
export function validVehiclePage(data) {
return data?.version === 1 && typeof data.parked === "boolean" && typeof data.readable === "boolean" &&
typeof data.valid === "boolean" && (data.selected === null || typeof data.selected === "string") &&
typeof data.selectedLabel === "string" && data.selectedLabel.length <= 120 &&
(data.reported === null || (typeof data.reported?.platform === "string" && typeof data.reported?.label === "string" &&
typeof data.reported?.make === "string")) &&
(data.view === null || typeof data.view === "string" && data.view.length <= 64) &&
Array.isArray(data.choices) && data.choices.length <= 1000 && data.choices.every((item) =>
typeof item?.platform === "string" && item.platform.length <= 100 && item.platform !== "MOCK" &&
typeof item.make === "string" && item.make.length <= 100 && typeof item.label === "string" && item.label.length <= 120) &&
(!data.readable || data.view !== null) && (!data.valid || data.selected === null ||
data.choices.some((item) => item.platform === data.selected))
}
export class VehicleSelectionFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer })
this.active = false
this.generation = 0
this.request = null
this.timer = null
this.pollTimer = null
this.data = null
this.pending = null
this.busy = false
this.error = ""
}
emit(status, error = this.error) { this.error = error; this.publish({ status, data: this.data, pending: this.pending, busy: this.busy, error }) }
stop() {
this.active = false
this.generation++
this.request?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.request = this.timer = this.pollTimer = null
this.data = this.pending = null
this.busy = false
this.emit("idle", "")
}
start() { this.stop(); this.active = true; return this.refresh() }
async run(path, body = null) {
if (!this.active || this.request) return null
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.pollTimer = null
const generation = this.generation
const request = new AbortController()
this.request = request
this.busy = true
this.emit(body === null ? (this.data ? "ready" : "loading") : "saving")
this.timer = this.later(() => {
if (!this.active || this.generation !== generation || this.request !== request) return
this.request = null
this.timer = null
this.generation++
this.busy = false
this.data = this.pending = null
request.abort()
this.emit("unavailable", "Vehicle selection timed out. Refresh saved values before trying again.")
}, 8000)
try {
const response = await this.fetcher(path, { credentials: "same-origin", cache: "no-store", signal: request.signal,
...(body === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }) })
if (!this.active || this.generation !== generation || this.request !== request || request.signal.aborted) return null
const result = await response.json()
if (!this.active || this.generation !== generation || this.request !== request || request.signal.aborted) return null
if (response.status === 401 || response.status === 503 && ["setup_required", "access_unavailable"].includes(result?.code)) {
this.stop(); this.unauthorized(); return null
}
if (response.status === 409) {
this.data = this.pending = null
this.emit("unavailable", result?.code === "unverified" ?
"Save could not be confirmed. Refresh saved values before trying again." :
"Saved vehicle changed. Refresh before choosing again.")
return null
}
if (!response.ok) throw new Error("Vehicle selection is unavailable. Refresh to try again.")
this.error = ""
return result
} catch (error) {
if (this.active && this.generation === generation && this.request === request && !request.signal.aborted) {
this.data = this.pending = null
this.emit("unavailable", error?.message || "Vehicle selection is unavailable.")
}
return null
} finally {
if (this.request === request) {
if (this.timer !== null) this.cancelTimer(this.timer)
this.request = this.timer = null
this.busy = false
this.emit(this.data ? "ready" : "unavailable")
}
}
}
async refresh() {
if (!this.active || this.request) return
this.pending = null
const generation = this.generation
const data = await this.run("./api/vehicle-selection")
if (!this.active || this.generation !== generation) return
if (data && validVehiclePage(data)) {
this.data = data
this.emit("ready")
if (!data.parked && data.readable) this.pollTimer = this.later(() => {
this.pollTimer = null
this.refresh()
}, 1000)
} else if (data) {
this.data = null
this.emit("unavailable", "Vehicle selection response was invalid.")
}
}
async preview(platform) {
if (!this.active || this.request || this.pending || !this.data?.parked || !this.data.readable ||
(!this.data.valid && platform !== null) ||
(platform !== null && !this.data.choices.some((item) => item.platform === platform))) return
const result = await this.run("./api/vehicle-selection/preview", { view: this.data.view, platform })
if (result && typeof result.intent === "string" && result.intent.length <= 64 && typeof result.question === "string") {
this.pending = result
this.emit("ready")
}
}
cancel() { this.pending = null; if (this.active) this.emit(this.data ? "ready" : "unavailable") }
async confirm() {
if (!this.active || this.request || !this.pending?.intent) return
const intent = this.pending.intent
this.pending = null
this.data = null
const result = await this.run("./api/vehicle-selection/confirm", { intent, confirmed: true })
if (!this.active) return
if (result?.saved !== true) {
if (!this.error) this.emit("unavailable", "Save could not be confirmed. Refresh saved values before trying again.")
return
}
await this.refresh()
}
}
export const VehicleControlsPage = {
components: { SettingsPage },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
setup(props) {
const state = reactive({ status: "idle", data: null, pending: null, busy: false, error: "", query: "", make: "" })
const feed = new VehicleSelectionFeed({ publish: (update) => Object.assign(state, update), unauthorized: props.unauthorized })
return { state, feed }
},
computed: {
makes() { return [...new Set((this.state.data?.choices || []).map((item) => item.make))] },
models() {
const needle = this.state.query.trim().toLocaleLowerCase()
return (this.state.data?.choices || []).filter((item) => (!this.state.make || item.make === this.state.make) &&
(!needle || `${item.make} ${item.label} ${item.platform}`.toLocaleLowerCase().includes(needle)))
},
},
mounted() { if (this.mode === "local") this.feed.start() },
beforeUnmount() { this.feed.stop() },
methods: { choose(platform) { this.feed.preview(platform) } },
template: `
<section class="gx-vehicle" aria-label="Vehicle Controls">
<div class="gx-card gx-vehicle__header"><p class="gx-eyebrow">Vehicle Controls</p><h2>Vehicle Selection</h2>
<p>Auto detects your car. A manual choice is saved for the next start; it does not change the car reported now.</p></div>
<div v-if="mode !== 'local'" class="gx-card gx-message" role="status">Local vehicle selection is unavailable in preview.</div>
<template v-else>
<div v-if="state.error" class="gx-card gx-message" role="alert">{{ state.error }}
<button type="button" class="gx-btn gx-btn--tonal" :disabled="state.busy" @click="feed.refresh()">Refresh</button></div>
<div v-if="state.status === 'loading'" class="gx-card gx-message" role="status">Loading vehicle selection…</div>
<article v-if="state.data" class="gx-card gx-vehicle__body">
<div class="gx-vehicle__summary"><div><small>Saved for next start</small><strong>{{ state.data.selectedLabel }}</strong>
<p v-if="!state.data.valid">Saved choice needs review. Only Auto can repair it.</p></div>
<div><small>Last identified vehicle</small><strong>{{ state.data.reported?.label || 'Not available' }}</strong>
<p>This is a cached report, not current vehicle confirmation.</p></div></div>
<p v-if="!state.data.parked" class="gx-note">Changes require fresh parked vehicle evidence.</p>
<div class="gx-vehicle__actions"><button type="button" class="gx-btn gx-btn--tonal" :disabled="state.busy" @click="feed.refresh()">Refresh</button>
<button type="button" class="gx-btn" :disabled="state.busy || !state.data.parked || !state.data.readable" @click="choose(null)">Choose Auto detection</button></div>
<template v-if="state.data.valid">
<h3>Choose a Vehicle</h3>
<div class="gx-vehicle__filters"><select v-model="state.make" class="gx-field" aria-label="Vehicle make">
<option value="">All makes</option><option v-for="make in makes" :key="make" :value="make">{{ make }}</option></select>
<input v-model="state.query" class="gx-field" type="search" aria-label="Search vehicle models" placeholder="Search models"></div>
<div class="gx-vehicle__models"><button v-for="item in models" :key="item.platform" type="button" class="gx-btn gx-btn--tonal"
:aria-pressed="state.data.selected === item.platform" :disabled="state.busy || !state.data.parked || !state.data.readable" @click="choose(item.platform)">
<strong>{{ item.label }}</strong><small>{{ item.make }}</small></button></div>
</template>
</article>
<SettingsPage :mode="mode" :unauthorized="unauthorized" initial-page="vehicle" title="Vehicle settings" />
<Teleport to="body"><div v-if="state.pending" class="gx-settings__modal" role="dialog" aria-modal="true" aria-label="Confirm vehicle selection">
<div class="gx-card gx-settings__dialog"><h3>Confirm Vehicle Selection</h3><p>{{ state.pending.question }}</p>
<p>The choice takes effect after the next start.</p>
<div class="gx-settings__controls"><button type="button" class="gx-btn gx-btn--tonal" @click="feed.cancel()">Cancel</button>
<button type="button" class="gx-btn" @click="feed.confirm()">Save</button></div></div></div></Teleport>
</template>
</section>`,
}
@@ -0,0 +1,30 @@
{
"name": "StarPilot Galaxy",
"short_name": "Galaxy",
"id": "./",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#0e0a1a",
"theme_color": "#6f42a6",
"icons": [
{
"src": "./assets/galaxy-icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "./assets/galaxy-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "./assets/galaxy-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
@@ -0,0 +1,20 @@
self.addEventListener("push", event => {
event.waitUntil((async () => {
let value
try { value = event.data.json() } catch { value = {} }
const id = typeof value?.eventId === "string" && /^[0-9a-f]{32}$/.test(value.eventId) ? value.eventId : "sentry"
const body = typeof value?.body === "string" ? value.body.slice(0, 256) : "Parked motion detected."
await self.registration.showNotification("StarPilot Sentry Mode", { body, tag: id,
data: { url: new URL("./#/cameras/events", self.registration.scope).href } })
})())
})
self.addEventListener("notificationclick", event => {
event.notification.close()
event.waitUntil((async () => {
const url = new URL("./#/cameras/events", self.registration.scope).href
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true })
const existing = windows.find(client => client.url.startsWith(self.registration.scope))
if (existing) { await existing.navigate(url); return existing.focus() }
return self.clients.openWindow(url)
})())
})
@@ -0,0 +1,2 @@
# Preserve the exact upstream Vue bundle, including its existing whitespace.
vue/vue.esm-browser.js -text whitespace=-blank-at-eol
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019-2024 The Bootstrap Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018-present Yuxi (Evan) You and Vue contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because it is too large Load Diff