mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-03 11:09:55 +08:00
Galaxy web presentation
Add Galaxy web assets and client settings interfaces with their bundled vendor notices.
This commit is contained in:
@@ -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}
|
||||
}
|
||||
@@ -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>`,
|
||||
}
|
||||
@@ -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 & 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>`,
|
||||
}
|
||||
@@ -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 & 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 & 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 & 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>`,
|
||||
}
|
||||
@@ -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>
|
||||
`,
|
||||
}
|
||||
@@ -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 & 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>`,
|
||||
}
|
||||
@@ -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>`,
|
||||
}
|
||||
@@ -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 & 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 }
|
||||
}
|
||||
@@ -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>`,
|
||||
}
|
||||
@@ -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 & 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 & 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 & 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 & 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 }
|
||||
}
|
||||
@@ -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
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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
Reference in New Issue
Block a user