diff --git a/openpilot/starpilot/galaxy/flm_training_results.py b/openpilot/starpilot/galaxy/flm_training_results.py index 4ff04b96ef..999a2a7133 100644 --- a/openpilot/starpilot/galaxy/flm_training_results.py +++ b/openpilot/starpilot/galaxy/flm_training_results.py @@ -5,13 +5,51 @@ from openpilot.starpilot.flm.training_report import read_report, report_path class FlmTrainingResults: - def __init__(self, path=None, *, clock=time.monotonic_ns): + def __init__(self, path=None, *, clock=time.monotonic_ns, params=None): self.clock = clock + self.params = params try: self.path = report_path() if path is None else path except (OSError, ValueError): self.path = None def snapshot(self): - return read_report(self.path, now_ns=self.clock()) if self.path is not None else { + result = read_report(self.path, now_ns=self.clock()) if self.path is not None else { "version": 1, "current": None, "history": []} + if self.params is not None: + result['preference'] = self.preference() + return result + + def preference(self): + from capnp import KjException + from opendbc.car.structs import car + from openpilot.starpilot.lateral.learning_preferences import KEY, MAX_BYTES, LEGACY_KEY, read_preference + from openpilot.starpilot.saved_source import read_saved + from openpilot.starpilot.schema_cache import inspect_cache + + unknown = {'version': 1, 'valid': False, 'enabled': None, 'parked': None, 'vehicle': None} + try: + offroad = read_saved(self.params, 'IsOffroad', 1) + if not offroad[1] or offroad[0] not in (b'0', b'1'): + return unknown + parked = offroad[0] == b'1' + key = 'CarParamsPersistent' if parked else 'CarParams' + snapshots = {name: read_saved(self.params, name, limit) for name, limit in ( + (key, 1024 * 1024), (KEY, MAX_BYTES), (LEGACY_KEY, 1))} + raw, readable = snapshots[key] + if raw is None or not readable: + return {**unknown, 'parked': parked} + payload = inspect_cache(key, raw).payload if parked else raw + if payload is None: + return {**unknown, 'parked': parked} + with car.CarParams.from_bytes(payload, traversal_limit_in_words=1024 * 1024 // 8) as cp: + choice = read_preference(self.params, cp, preference_snapshot=snapshots[KEY], legacy_snapshot=snapshots[LEGACY_KEY]) + vehicle, not_car = str(cp.carFingerprint), bool(cp.notCar) + # A mode, vehicle or preference change invalidates this read as a whole. + if (not_car or not choice.valid or offroad != read_saved(self.params, 'IsOffroad', 1) or + any(snapshot != read_saved(self.params, name, 1024 * 1024 if name == key else MAX_BYTES if name == KEY else 1) + for name, snapshot in snapshots.items())): + return {**unknown, 'parked': parked} + return {'version': 1, 'valid': True, 'enabled': choice.enabled, 'parked': parked, 'vehicle': vehicle} + except (OSError, RuntimeError, AttributeError, TypeError, ValueError, OverflowError, KjException): + return unknown diff --git a/openpilot/starpilot/galaxy/server.py b/openpilot/starpilot/galaxy/server.py index 32bbb15a36..9de5434177 100644 --- a/openpilot/starpilot/galaxy/server.py +++ b/openpilot/starpilot/galaxy/server.py @@ -102,7 +102,10 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe from openpilot.starpilot.galaxy.companion import CompanionRegistry, APK_PATH from openpilot.starpilot.gps.phone import PhoneGpsPublisher companion_source = companion if companion is not None else CompanionRegistry() - training_results_source = training_results if training_results is not None else FlmTrainingResults() + if training_results is None: + from openpilot.common.params import Params + training_results = FlmTrainingResults(params=Params()) + training_results_source = training_results phone_location_source = phone_location if phone_location is not None else PhoneGpsPublisher() cloud = cloud_provider hotspot_source = hotspot diff --git a/openpilot/starpilot/galaxy/test_flm_training_results.py b/openpilot/starpilot/galaxy/test_flm_training_results.py index 6e6ff539f9..63720351ab 100644 --- a/openpilot/starpilot/galaxy/test_flm_training_results.py +++ b/openpilot/starpilot/galaxy/test_flm_training_results.py @@ -125,3 +125,83 @@ def test_unknown_fit_window_counts_are_not_reported_as_zero(tmp_path): def test_invalid_namespace_only_disables_report(monkeypatch): monkeypatch.setenv('OPENPILOT_PREFIX', '../not-a-report-namespace') assert FlmTrainingResults(clock=lambda: NOW).snapshot() == {'version': 1, 'current': None, 'history': []} + + +class ChoiceParams: + def __init__(self, root): + self.root = root + + def get_param_path(self, key): + return str(self.root / key) + + def put(self, key, value, **_kwargs): + (self.root / key).write_bytes(value) + + +def choice_fixture(tmp_path, *, parked=True, enabled=True): + from opendbc.car.structs import car + from openpilot.starpilot.schema_cache import put_cache + from openpilot.starpilot.lateral.learning_preferences import KEY, replace_preference + params = ChoiceParams(tmp_path) + cp = car.CarParams.new_message(brand='hyundai', carFingerprint='HYUNDAI_IONIQ_6') + params.put('IsOffroad', b'1' if parked else b'0') + params.put(KEY, replace_preference(None, cp, enabled)) + if parked: + put_cache(params, 'CarParamsPersistent', cp) + else: + params.put('CarParams', cp.to_bytes()) + return params, cp + + +@pytest.mark.parametrize('parked', (True, False)) +@pytest.mark.parametrize('enabled', (True, False)) +def test_choice_is_independent_of_a_missing_or_stopped_learner(tmp_path, parked, enabled): + params, _cp = choice_fixture(tmp_path, parked=parked, enabled=enabled) + before = {p.name: (p.read_bytes(), p.stat().st_mtime_ns) for p in tmp_path.iterdir()} + snapshot = FlmTrainingResults(tmp_path / 'missing-report', params=params).snapshot() + assert snapshot['current'] is None + assert snapshot['preference'] == {'version': 1, 'valid': True, 'enabled': enabled, 'parked': parked, + 'vehicle': 'HYUNDAI_IONIQ_6'} + assert before == {p.name: (p.read_bytes(), p.stat().st_mtime_ns) for p in tmp_path.iterdir()} + + +def test_scoped_on_is_not_overridden_by_legacy_off(tmp_path): + params, _cp = choice_fixture(tmp_path) + params.put('ForceAutoTuneOff', b'1') + assert FlmTrainingResults(tmp_path / 'missing', params=params).snapshot()['preference']['enabled'] is True + + +@pytest.mark.parametrize('key,raw', (('IsOffroad', b'wat'), ('CarParamsPersistent', b'bad'), + ('SteeringLearningPreferences', b'{'), ('CarParamsPersistent', b'x' * (1024 * 1024 + 1)))) +def test_unreadable_choice_is_unknown_not_off(tmp_path, key, raw): + params, _cp = choice_fixture(tmp_path) + params.put(key, raw) + actual = FlmTrainingResults(tmp_path / 'missing', params=params).snapshot()['preference'] + assert actual['valid'] is False and actual['enabled'] is None + + +def test_driving_without_current_vehicle_does_not_borrow_parked_identity(tmp_path): + params, _cp = choice_fixture(tmp_path) + params.put('IsOffroad', b'0') + actual = FlmTrainingResults(tmp_path / 'missing', params=params).snapshot()['preference'] + assert actual['valid'] is False and actual['enabled'] is None and actual['parked'] is False + + +@pytest.mark.parametrize('key', ('IsOffroad', 'SteeringLearningPreferences', 'CarParamsPersistent')) +def test_mode_or_choice_change_during_read_is_unknown(tmp_path, monkeypatch, key): + from openpilot.starpilot import saved_source + params, _cp = choice_fixture(tmp_path) + original = saved_source.read_saved + calls = 0 + + def changed(owner, name, limit): + nonlocal calls + if name == key: + calls += 1 + if calls == 2: + return b'changed-during-read', True + return original(owner, name, limit) + + monkeypatch.setattr(saved_source, 'read_saved', changed) + actual = FlmTrainingResults(tmp_path / 'missing', params=params).snapshot()['preference'] + assert actual['valid'] is False and actual['enabled'] is None diff --git a/openpilot/starpilot/galaxy/tests/test_driving.mjs b/openpilot/starpilot/galaxy/tests/test_driving.mjs index e9bb4b8c6b..19942e5493 100644 --- a/openpilot/starpilot/galaxy/tests/test_driving.mjs +++ b/openpilot/starpilot/galaxy/tests/test_driving.mjs @@ -18,8 +18,8 @@ const app = readFileSync(new URL("../web/js/app.js", import.meta.url), "utf8") assert.match(app, /drivingSettingsPage\(\) \{ return drivingPage\(route\.path\) \}/) assert.match(app, /]* v-else-if="drivingSettingsPage"[^>]*:initial-page="drivingSettingsPage"/) assert.match(app, /]* v-else-if="route\.path === '\/driving'"/) -assert(app.includes("
({ version: 1, history: [], current: { fresh: true, ageSeconds: .1, enabled: true, status: "collecting", reason: "capturing_steering_evidence", progress: { samples: 120, blocks: 3, requiredBlocks: 16 }, @@ -55,36 +55,37 @@ assert.equal(coordinateText(null), "Not reported") assert.equal(coordinateText(.005), "+0.005") compile(FlmPage.template, { decodeEntities: value => value.replaceAll("&", "&") }) assert.doesNotMatch(FlmPage.template, /v-html|Analyze selected|GM Trial|Choose Full Logs/) -assert.match(FlmPage.template, /Currently applied/) +assert.match(FlmPage.template, /Applied now/) assert.match(FlmPage.template, /PID unsupported/) +assert.deepEqual(SURFACE_GROUPS.flatMap(group => group.names).sort(), [...SURFACE_NAMES].sort()) const app = readFileSync(new URL("../web/js/app.js", import.meta.url), "utf8") -assert(app.includes("['/tuning', '/tuning/plots', '/tuning/flm'].includes(route.path)")) -const analysis = app.match(/
/)?.[0] -assert(analysis, 'Existing analysis route and old bookmarks share one page') -assert.match(analysis, / { throw error }, decodeEntities: value => value.replaceAll("&", "&") }) -assert.doesNotMatch(app, /Recorded-drive analysis/) -const settings = readFileSync(new URL("../web/js/settings.js", import.meta.url), "utf8") -assert.doesNotMatch(settings, /FLMTrainingResults|View FLM training results/) +assert(app.includes("['/tuning', '/tuning/flm'].includes(route.path)")) +assert.match(app, / tool.path === "/tuning/plots")) -assert(!catalog.tools.some(tool => tool.path === "/tuning/flm")) +assert(catalog.tools.some(tool => tool.path === "/tuning" && tool.name === "StarPilot Tuner")) assert.equal(catalog.tools.filter(tool => tool.path.startsWith('/tuning')).length, 1) -const plots = readFileSync(new URL('../web/js/plots.js', import.meta.url), 'utf8') -assert.doesNotMatch(plots, /href="#\/tuning\/flm"/) const driving = readFileSync(new URL('../web/js/driving.js', import.meta.url), 'utf8') +assert.match(driving, /go\('\/tuning'\)/) assert.match(driving, /go\('\/tuning\/plots'\)/) -assert.doesNotMatch(driving, /\/tuning\/flm/) -assert.match(FlmPage.template, /
]*>
.*Live steering response/) -assert.doesNotMatch(FlmPage.template, /href="#\/tuning\/plots"/) -const route = readFileSync(new URL("../web/js/route-workspace.js", import.meta.url), "utf8") -assert.doesNotMatch(route, /Analyze driving logs/) +assert.doesNotMatch(readFileSync(new URL('../web/js/flm.js', import.meta.url), 'utf8'), /PlotsFeed|api\/plots/) +for (const view of ['overview', 'tune', 'activity']) assert(FlmPage.template.includes(`view === '${view}'`)) +const choice = { version: 1, valid: true, enabled: true, parked: true, vehicle: 'HYUNDAI_IONIQ_6' } +assert(validLearningPreference(choice)) +assert.equal(tunerStatus({ ...value, preference: choice }, false, true).key, 'parked') +assert.equal(tunerStatus({ ...value, preference: { ...choice, enabled: false } }, true, true).key, 'off') +assert.equal(tunerStatus({ ...value, preference: choice }, false, false).key, 'stale') +assert.equal(tunerStatus({ ...value, preference: choice }, true, true, 'offline').key, 'offline') +for (const invalid of [null, {}, { ...choice, version: true }, { ...choice, valid: false, enabled: true }, + { ...choice, enabled: 'yes' }, { ...choice, parked: 1 }, { ...choice, vehicle: 'x'.repeat(257) }]) { + assert(!validLearningPreference(invalid)) + assert(!validTrainingResults({ ...value, preference: invalid })) +} function instance() { const vm = { ...FlmPage.data(), activePage: true, mode: "local", unauthorized: () => vm.auth++, auth: 0, - feed: { stop() {} }, poller: { stop() {} } } + poller: { stop() {} } } for (const [name, method] of Object.entries(FlmPage.methods)) vm[name] = method.bind(vm) for (const [name, method] of Object.entries(FlmPage.computed)) Object.defineProperty(vm, name, { get: () => method.call(vm) }) return vm @@ -92,16 +93,11 @@ function instance() { const originalFetch = globalThis.fetch, originalDocument = globalThis.document try { globalThis.document = { hidden: false, addEventListener() {}, removeEventListener() {} } - for (const embedded of [true, false]) { - const page = { ...FlmPage.data(), embedded, mode: 'local', unauthorized() {}, load() {} } - let plotStarts = 0 - FlmPage.created.call(page) - page.feed.start = () => { plotStarts++ } - page.feed.stop = () => {} - FlmPage.mounted.call(page) - assert.equal(plotStarts, embedded ? 0 : 1, 'Embedded results reuse the existing live plots') - FlmPage.beforeUnmount.call(page) - } + const mounted = instance() + mounted.load = () => {} + FlmPage.mounted.call(mounted) + assert.equal(mounted.feed, undefined, 'Tuner does not start a second raw-plot feed') + FlmPage.beforeUnmount.call(mounted) const calls = [] globalThis.fetch = async (url, options) => { calls.push({ url, options }); return { ok: true, json: async () => value } } const vm = instance(); await vm.load() @@ -113,6 +109,18 @@ try { vm.now = vm.receivedAt + 2100 assert(!vm.currentApplied) assert(vm.rows.every(row => row.applied === null)) + vm.report = { ...value, preference: { ...choice, enabled: false, parked: false } } + vm.now = vm.receivedAt + assert(!vm.currentApplied, 'Authoritative current OFF suppresses earlier RAM application') + vm.report = { ...value, preference: choice } + assert(!vm.currentApplied, 'Parked report does not claim a current steering correction') + vm.report = value + const focused = [] + const event = { key: 'ArrowRight', preventDefault() {}, currentTarget: { parentElement: { querySelectorAll: () => + ['overview', 'tune', 'activity'].map(name => ({ focus: () => focused.push(name) })) } } } + vm.tabKey(event); assert.equal(vm.view, 'tune'); assert.deepEqual(focused, ['tune']) + event.key = 'End'; vm.tabKey(event); assert.equal(vm.view, 'activity') + event.key = 'Home'; vm.tabKey(event); assert.equal(vm.view, 'overview') globalThis.fetch = async () => { throw new TypeError("offline") } await vm.load(); assert(!vm.currentApplied); assert.equal(vm.report, value) globalThis.fetch = async () => ({ ok: false, status: 401, json: async () => ({ error: "Sign in" }) }) diff --git a/openpilot/starpilot/galaxy/tests/test_flm_tracking_results.mjs b/openpilot/starpilot/galaxy/tests/test_flm_tracking_results.mjs index 1d3be1a9d2..8034704117 100644 --- a/openpilot/starpilot/galaxy/tests/test_flm_tracking_results.mjs +++ b/openpilot/starpilot/galaxy/tests/test_flm_tracking_results.mjs @@ -173,15 +173,14 @@ for (const mutate of [value => value.version = 'unknown', value => value.binding const invalid = report(); invalid.current.trackingPlot = plot(); mutate(invalid.current.trackingPlot) assert(!validTrackingPlot(invalid.current.trackingPlot)); assert(!validTrainingResults(invalid)); assert.equal(trackingPlotView(invalid), null) } -assert.match(FlmPage.template, /Latest completed delay-paired steering episode/) -assert.match(FlmPage.template, /not actuator-delay aligned/) -assert.match(FlmPage.template, /Pending trial target/) -assert.match(FlmPage.template, /Accepted baseline/) -assert.match(FlmPage.template, /No delay-paired steering episode plot/) +assert.match(FlmPage.template, /measured delay/) +assert.match(FlmPage.template, /Completed window/) +assert.match(FlmPage.template, /Applied now/) +assert.match(FlmPage.template, /saving is separate/) +assert.match(FlmPage.template, /No interpolation/) compile(FlmPage.template, { onError: error => { throw error }, decodeEntities: value => value.replaceAll('&', '&') }) -assert.match(FlmPage.template, /tracking\?\.label \|\| summary/) -assert.match(FlmPage.template, /Future trial episodes/) -assert.match(FlmPage.template, /Passing future checks does not itself confirm/) +assert.match(FlmPage.template, /Comparable trial windows/) +assert.match(FlmPage.template, /not a confirmed improvement/) assert.doesNotMatch(FlmPage.template, /v-html/) console.log('Optional versioned FLM tracking report, paired observed metrics, honest trial acknowledgments and old report compatibility passed') @@ -196,5 +195,5 @@ assert(!liveText.includes('Collection & fitting'), 'Legacy fitter cards must not delete ctx.report.current.tracking const legacyText = text(render.call(ctx,ctx,[])) assert(legacyText.toLowerCase().includes('invalid applied output')) -assert(legacyText.includes('Collection & fitting')) +assert(legacyText.includes('Collecting driving samples')) console.log('Actual Vue render keeps live learner progress separate and preserves legacy report fallback') diff --git a/openpilot/starpilot/galaxy/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css index 6eb1491c66..1b167d526e 100644 --- a/openpilot/starpilot/galaxy/web/css/galaxy.css +++ b/openpilot/starpilot/galaxy/web/css/galaxy.css @@ -1859,3 +1859,139 @@ progress { max-width: 100%; accent-color: var(--primary); } } .gx-starport-checks .gx-starport-check--active { padding: var(--sp-3); border-radius: var(--radius-sm); color: var(--primary); background: color-mix(in srgb, var(--primary) 12%, transparent); font-weight: var(--fw-bold); } .gx-starport-checks .gx-starport-check--active::before { border-right-color: var(--secondary, var(--primary)); } + +/* StarPilot Tuner shares Galaxy surfaces, spacing and source-aware state colors. */ +.gx-tuner { display: grid; gap: var(--sp-5); min-width: 0; padding-bottom: var(--sp-4); } +.gx-tuner h2, .gx-tuner h3, .gx-tuner h4, .gx-tuner p { margin: 0; } +.gx-tuner__heading h2 { font-size: clamp(1.8rem, 4vw, 2.3rem); letter-spacing: -.04em; margin: 5px 0; } +.gx-tuner__heading > div > p:last-child { color: var(--on-surface-variant); } +.gx-tuner__eyebrow { display: flex; align-items: center; gap: 8px; font-size: .66rem; line-height: 1.5; font-weight: 600; letter-spacing: .13em; color: var(--on-surface-variant); } +.gx-card.gx-tuner__hero { --tuner-accent: var(--text-muted); display: grid; grid-template-columns: 78px minmax(0, 1fr) auto; align-items: center; gap: var(--sp-5); padding: clamp(20px, 3vw, 32px); background: linear-gradient(115deg, var(--primary-container), transparent 65%), var(--surface); border-color: color-mix(in srgb, var(--primary) 25%, var(--glass-border)); } +.gx-tuner__hero[data-tone="active"] { --tuner-accent: var(--primary); } +.gx-tuner__hero[data-tone="success"], .gx-tuner__hero[data-tone="ready"] { --tuner-accent: var(--secondary); } +.gx-tuner__hero[data-tone="warning"] { --tuner-accent: var(--warning); } +.gx-tuner__hero-icon { display: grid; place-items: center; width: 78px; height: 78px; border: 1px solid var(--glass-border); border-radius: 24px; color: var(--tuner-accent); background: var(--surface-container); font-size: 2rem; } +.gx-tuner__hero-copy { display: grid; gap: 8px; } +.gx-tuner__hero-copy h3 { font-size: clamp(1.3rem, 2.7vw, 1.8rem); line-height: 1.2; letter-spacing: -.03em; } +.gx-tuner__hero-copy > p { max-width: 65ch; color: var(--on-surface-variant); font-size: .88rem; line-height: 1.6; } +.gx-tuner__status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--tuner-accent); box-shadow: 0 0 12px color-mix(in srgb, var(--tuner-accent) 35%, transparent); } +.gx-tuner__hero-meta { display: grid; gap: 7px; max-width: 170px; border-left: 1px solid var(--glass-border); padding-left: 24px; font-size: .75rem; line-height: 1.5; } +.gx-tuner__hero-meta small { color: var(--text-muted); } +.gx-tuner__current-job { display: flex; gap: 8px; color: var(--on-primary-container); } +.gx-tuner__tabs { display: flex; align-items: center; gap: 4px; border-bottom: 1px solid var(--glass-border); } +.gx-tuner__tabs > button { position: relative; min-height: 48px; padding: 0 16px 14px; border: 0; background: transparent; color: var(--on-surface-variant); font: inherit; font-size: .86rem; cursor: pointer; white-space: nowrap; } +.gx-tuner__tabs > button[aria-selected="true"] { color: var(--on-surface); font-weight: 600; } +.gx-tuner__tabs > button[aria-selected="true"]::after { position: absolute; content: ""; bottom: -1px; left: 15px; right: 15px; height: 3px; border-radius: 3px 3px 0 0; background: var(--primary); } +.gx-tuner__tabs > button:focus-visible, .gx-tuner__groups > button:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; border-radius: var(--radius-sm); } +.gx-tuner__tabs > .gx-tuner__plots-link { margin-left: auto; padding-right: 0; color: var(--on-surface-variant); font-size: .78rem; } +.gx-tuner__count { display: inline-block; margin-left: 7px; padding: 1px 6px; border: 1px solid var(--glass-border); border-radius: var(--radius-full); font-size: .62rem; color: var(--on-surface-variant); vertical-align: middle; } +.gx-tuner__panel { display: grid; gap: var(--sp-5); min-width: 0; } +.gx-tuner__metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--sp-4); } +.gx-card.gx-tuner__metric { display: grid; gap: 9px; padding: 20px 24px; min-width: 0; } +.gx-tuner__metric > span { font-size: .77rem; color: var(--on-surface-variant); } +.gx-tuner__metric > strong { font-size: 1.9rem; line-height: 1.15; font-weight: 500; letter-spacing: -.04em; font-variant-numeric: tabular-nums; } +.gx-tuner__metric > strong small { font-size: .8rem; color: var(--text-muted); } +.gx-tuner__metric p { color: var(--text-muted); font-size: .68rem; line-height: 1.5; } +.gx-tuner__overview-grid { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: var(--sp-4); } +.gx-card.gx-tuner__section { padding: clamp(18px, 2.5vw, 24px); min-width: 0; } +.gx-tuner__section > p { color: var(--on-surface-variant); font-size: .82rem; line-height: 1.6; margin: 16px 0; } +.gx-tuner__section-heading { display: flex; align-items: start; justify-content: space-between; gap: var(--sp-4); margin-bottom: 18px; } +.gx-tuner__section-heading h3 { font-size: 1.02rem; font-weight: 600; letter-spacing: -.02em; margin-top: 3px; } +.gx-tuner__section-heading p:not(.gx-tuner__eyebrow) { font-size: .8rem; color: var(--on-surface-variant); margin-top: 6px; line-height: 1.5; } +.gx-tuner__pill { flex: 0 0 auto; display: inline-flex; align-items: center; padding: 5px 8px; font-size: .6rem; color: var(--on-surface-variant); border: 1px solid var(--glass-border); border-radius: var(--radius-full); white-space: nowrap; } +.gx-tuner__response svg, .gx-tuner__disclosure svg { width: 100%; height: auto; min-height: 150px; display: block; margin: 20px 0; } +.gx-tuner svg polyline { fill: none; stroke-width: 2.5; vector-effect: non-scaling-stroke; stroke-linejoin: round; stroke-linecap: round; } +.gx-tuner .gx-plot-axis { stroke: var(--on-surface-variant); opacity: .35; stroke-width: 1; } +.gx-tuner .gx-plot-zero { stroke: var(--on-surface-variant); opacity: .2; stroke-width: 1; stroke-dasharray: 4 4; } +.gx-tuner .gx-plot-label { fill: var(--on-surface-variant); font-size: 11px; } +.gx-tuner__legend { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; font-size: .73rem; color: var(--on-surface-variant); } +.gx-tuner__legend > span::before { content: ""; width: 14px; height: 3px; display: inline-block; border-radius: 2px; margin-right: 7px; vertical-align: middle; } +.gx-tuner__requested::before { background: #45a9ff; } +.gx-tuner__measured::before { background: #ffa352; } +.gx-tuner__legend small { margin-left: auto; color: var(--text-muted); font-size: .65rem; } +.gx-tuner p.gx-tuner__caption, .gx-tuner__caption { color: var(--text-muted); font-size: .68rem; line-height: 1.6; margin-top: 13px; } +.gx-tuner__empty { display: grid; justify-items: center; align-content: center; gap: 10px; text-align: center; min-height: 215px; padding: 24px; } +.gx-tuner__empty > i { font-size: 1.6rem; color: var(--primary); padding-bottom: 6px; } +.gx-tuner__empty > h4 { font-size: .88rem; font-weight: 500; } +.gx-tuner__empty > p { color: var(--text-muted); font-size: .76rem; line-height: 1.65; max-width: 37ch; } +.gx-tuner__comparison { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 18px; margin: 25px 0 0; } +.gx-tuner__comparison > div { display: grid; gap: 8px; } +.gx-tuner__comparison span { color: var(--text-muted); font-size: .7rem; } +.gx-tuner__comparison strong { font-size: 1.15rem; font-weight: 500; font-variant-numeric: tabular-nums; } +.gx-tuner__comparison > i { color: var(--text-muted); font-size: .85rem; } +.gx-tuner p.gx-tuner__observed { font-size: .9rem; color: var(--on-surface); } +.gx-tuner__observed small { display: block; margin-top: 4px; font-size: .65rem; color: var(--text-muted); } +.gx-tuner__quota { margin-top: 20px; } +.gx-tuner__quota > div:first-child { display: flex; justify-content: space-between; gap: 10px; font-size: .7rem; } +.gx-tuner__quota strong { font-weight: 500; font-variant-numeric: tabular-nums; } +.gx-tuner__progress { margin-top: 10px; height: 5px; border-radius: 5px; background: var(--outline); overflow: hidden; } +.gx-tuner__progress > span { display: block; height: 100%; background: var(--primary); border-radius: inherit; } +.gx-tuner__tune-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); gap: var(--sp-4); align-items: start; } +.gx-tuner__groups { display: grid; gap: 8px; } +.gx-tuner__groups > button { display: flex; align-items: center; gap: 13px; min-height: 70px; padding: 14px; border: 1px solid var(--glass-border); border-radius: var(--radius-sm); background: var(--surface); color: var(--on-surface-variant); text-align: left; cursor: pointer; font: inherit; font-size: .78rem; } +.gx-tuner__groups > button[aria-pressed="true"] { background: var(--primary-container); border-color: var(--primary); color: var(--on-surface); } +.gx-tuner__groups > button > i:first-child { width: 22px; text-align: center; font-size: 1rem; color: var(--primary); } +.gx-tuner__groups > button > i:last-child { margin-left: auto; font-size: .65rem; } +.gx-tuner__groups small { display: block; margin-top: 5px; font-size: .61rem; color: var(--text-muted); } +.gx-tuner__parameter-head, .gx-tuner__parameter { display: grid; grid-template-columns: minmax(140px, 1.5fr) repeat(3, minmax(75px, .75fr)); gap: 10px; align-items: center; } +.gx-tuner__parameter-head { color: var(--text-muted); font-size: .61rem; padding: 0 0 12px; } +.gx-tuner__parameter-head > span:not(:first-child), .gx-tuner__parameter > div:not(:first-child) { text-align: right; } +.gx-tuner__parameter { padding: 17px 0; border-top: 1px solid var(--outline-variant); } +.gx-tuner__parameter h4 { font-size: .77rem; line-height: 1.5; font-weight: 500; } +.gx-tuner__parameter strong { font-size: .72rem; font-weight: 500; font-variant-numeric: tabular-nums; } +.gx-tuner__parameter small { display: block; font-size: .6rem; color: var(--text-muted); margin-top: 5px; } +.gx-tuner__mobile-label { display: none; } +.gx-tuner__pending { color: var(--warning); } +.gx-tuner__applied { color: var(--secondary); } +.gx-tuner__disclosure { margin-top: 16px; color: var(--on-surface-variant); font-size: .77rem; } +.gx-tuner__disclosure > summary { padding: 8px 0; min-height: 36px; cursor: pointer; color: var(--on-surface); } +.gx-tuner__disclosure > p { margin-top: 12px; font-size: .74rem; line-height: 1.7; } +.gx-tuner__timeline { list-style: none; margin: 8px 0 18px; padding: 0; } +.gx-tuner__timeline li { position: relative; display: grid; grid-template-columns: 12px 1fr; gap: 18px; padding: 13px 0 20px; } +.gx-tuner__timeline li:not(:last-child)::before { content: ""; position: absolute; top: 22px; left: 4px; bottom: -13px; width: 1px; background: var(--glass-border); } +.gx-tuner__timeline-dot { position: relative; width: 9px; height: 9px; border-radius: 50%; border: 2px solid var(--primary); margin-top: 4px; background: var(--background); } +.gx-tuner__timeline-dot--applied { border-color: var(--secondary); background: var(--secondary); } +.gx-tuner__timeline strong { font-size: .85rem; font-weight: 500; } +.gx-tuner__timeline p { margin-top: 6px; color: var(--on-surface-variant); font-size: .77rem; line-height: 1.5; } +.gx-tuner__timeline small { display: block; margin-top: 7px; color: var(--text-muted); font-size: .64rem; } +.gx-tuner__diagnostics { display: grid; grid-template-columns: minmax(100px, 1fr) minmax(0, 2fr); gap: 10px 20px; overflow-wrap: anywhere; margin-top: 16px; } +.gx-tuner__diagnostics dt { color: var(--text-muted); } +.gx-tuner__diagnostics dd { margin: 0; font-variant-numeric: tabular-nums; } +@media (max-width: 900px) { + .gx-card.gx-tuner__hero { grid-template-columns: 62px minmax(0, 1fr); gap: 18px; } + .gx-tuner__hero-icon { width: 62px; height: 62px; border-radius: 20px; font-size: 1.7rem; } + .gx-tuner__hero-meta { grid-column: 2; border: 0; padding: 0; max-width: none; display: flex; flex-wrap: wrap; gap: 8px 16px; } + .gx-tuner__overview-grid { grid-template-columns: 1fr; } + .gx-tuner__tune-layout { grid-template-columns: 1fr; } + .gx-tuner__groups { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .gx-tuner__groups > button { padding: 12px; gap: 9px; } + .gx-tuner__groups > button > i:last-child { display: none; } +} +@media (max-width: 560px) { + .gx-tuner { gap: 18px; } + .gx-tuner__heading { gap: 12px; } + .gx-tuner__heading > button { padding: 9px 12px; font-size: .72rem; min-width: 44px; } + .gx-tuner__heading > button > i { display: none; } + .gx-card.gx-tuner__hero { grid-template-columns: minmax(0, 1fr); } + .gx-tuner__hero-icon { display: none; } + .gx-tuner__hero-meta { grid-column: 1; font-size: .64rem; } + .gx-tuner__tabs > button { padding: 0 11px 12px; font-size: .76rem; } + .gx-tuner__tabs > .gx-tuner__plots-link { font-size: .68rem; } + .gx-tuner__count { font-size: .56rem; margin-left: 4px; padding: 1px 4px; } + .gx-tuner__metrics { gap: 8px; } + .gx-card.gx-tuner__metric { padding: 15px 11px; gap: 8px; } + .gx-tuner__metric > span { font-size: .75rem; } + .gx-tuner__metric > strong { font-size: 1.5rem; } + .gx-tuner__metric > strong small { font-size: .6rem; } + .gx-tuner__metric p { font-size: .7rem; } + .gx-tuner__pill { font-size: .54rem; padding: 4px 6px; } + .gx-tuner__groups { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .gx-tuner__parameter-head { display: none; } + .gx-tuner__parameter { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px 8px; padding: 15px 0; } + .gx-tuner__parameter > div:first-child { grid-column: 1 / -1; } + .gx-tuner__parameter > div:not(:first-child) { text-align: left; } + .gx-tuner__mobile-label { display: block; color: var(--text-muted); font-size: .59rem; margin-bottom: 6px; } + .gx-tuner__parameter strong { font-size: .72rem; } + .gx-tuner__diagnostics { grid-template-columns: 1fr; gap: 5px; } + .gx-tuner__diagnostics dd { margin-bottom: 12px; } +} diff --git a/openpilot/starpilot/galaxy/web/data/catalog.json b/openpilot/starpilot/galaxy/web/data/catalog.json index 1284caa22c..fe267ca235 100644 --- a/openpilot/starpilot/galaxy/web/data/catalog.json +++ b/openpilot/starpilot/galaxy/web/data/catalog.json @@ -163,10 +163,10 @@ "availability": "local-only" }, { - "name": "Plots & Analysis", - "path": "/tuning/plots", - "icon": "bi-sign-turn-right", - "description": "Live control plots and StarPilot Auto Learn results", + "name": "StarPilot Tuner", + "path": "/tuning", + "icon": "bi-sliders2", + "description": "Automatic steering tuning, measured corrections and live plots", "availability": "local-only" }, { diff --git a/openpilot/starpilot/galaxy/web/js/app.js b/openpilot/starpilot/galaxy/web/js/app.js index c012784c57..fe0351369d 100644 --- a/openpilot/starpilot/galaxy/web/js/app.js +++ b/openpilot/starpilot/galaxy/web/js/app.js @@ -267,10 +267,8 @@ createApp({ -
- - -
+ + This capability is unavailable in this build. No operation was attempted.
diff --git a/openpilot/starpilot/galaxy/web/js/driving.js b/openpilot/starpilot/galaxy/web/js/driving.js index 74802abadb..9b9a16d2ec 100644 --- a/openpilot/starpilot/galaxy/web/js/driving.js +++ b/openpilot/starpilot/galaxy/web/js/driving.js @@ -51,7 +51,7 @@ export const DrivingPage = {
-
+
`, } diff --git a/openpilot/starpilot/galaxy/web/js/flm-training-results.js b/openpilot/starpilot/galaxy/web/js/flm-training-results.js index 876efac941..c1c10d147f 100644 --- a/openpilot/starpilot/galaxy/web/js/flm-training-results.js +++ b/openpilot/starpilot/galaxy/web/js/flm-training-results.js @@ -7,6 +7,7 @@ const detailValue = (value, depth = 0) => value === null || typeof value === "bo Object.entries(value).every(([key, item]) => text(key, 80) && detailValue(item, depth + 1))) export function validTrainingResults(value) { if (!value || value.version !== 1 || !Array.isArray(value.history) || value.history.length > 20) return false + if (value.preference !== undefined && !validLearningPreference(value.preference)) return false if (!value.history.every(row => row && text(row.time, 80) && text(row.status, 64) && text(row.reason) && typeof row.applied === "boolean")) return false const row = value.current if (row === null) return true @@ -110,6 +111,41 @@ export function surfaceRows(report, live = false) { export const coordinateText = value => value === null ? "Not reported" : value === 0 ? "0" : Math.abs(value) < 1e-6 ? value.toExponential(3) : (value > 0 ? "+" : "") + value.toFixed(6).replace(/0+$/, "").replace(/\.$/, "") +export function validLearningPreference(value) { + return value !== null && typeof value === "object" && !Array.isArray(value) && value.version === 1 && + typeof value.valid === "boolean" && [true, false, null].includes(value.enabled) && + [true, false, null].includes(value.parked) && (value.vehicle === null || text(value.vehicle, 256)) && + (value.valid ? typeof value.enabled === "boolean" && typeof value.parked === "boolean" && value.vehicle !== null : value.enabled === null) +} + +export const SURFACE_GROUPS = Object.freeze([ + { id: "balance", label: "Steering balance", icon: "bi-arrow-left-right", description: "Response to left and right steering requests.", names: ["ff_gain_left", "ff_gain_right", "turn_in_boost_left", "turn_in_boost_right", "unwind_taper_left", "unwind_taper_right"] }, + { id: "center", label: "Straight & center", icon: "bi-signpost-split", description: "Small corrections and straight-road steadiness.", names: ["center_taper_max", "highway_center_taper_max", ...SURFACE_NAMES.filter(name => name.startsWith("center_deadband_"))] }, + { id: "low", label: "Low speed", icon: "bi-speedometer", description: "Crawl-speed turn-in and angle assistance.", names: ["crawl_turn_in_ff_boost_left", "crawl_turn_in_ff_boost_right", "low_speed_angle_assist_max_torque"] }, + { id: "entry", label: "Corner entry", icon: "bi-arrow-up-right", description: "Friction thresholds and turn-in trimming.", names: ["turn_in_threshold_reduction_left", "turn_in_threshold_reduction_right", "curvy_turn_in_trim_left", "curvy_turn_in_trim_right"] }, + { id: "exit", label: "Corner exit", icon: "bi-arrow-return-right", description: "Friction and relief as steering unwinds.", names: ["unwind_threshold_increase_left", "unwind_threshold_increase_right", "curvy_unwind_floor_relief_left", "curvy_unwind_floor_relief_right", "curvy_unwind_extra_reduction_left", "curvy_unwind_extra_reduction_right"] }, + { id: "speed", label: "Speed ranges", icon: "bi-sliders", description: "Where turn-in and unwind shaping take effect.", names: ["curvy_speed_min", "curvy_speed_max", "curvy_turn_in_trim_speed_min", "curvy_turn_in_trim_speed_max"] }, +]) + +export function tunerStatus(report, live = false, choiceLive = false, error = "") { + const preference = choiceLive && validLearningPreference(report?.preference) ? report.preference : null + const current = report?.current + if (error) return { key: "offline", tone: "muted", title: "Connection interrupted", description: "Earlier results are available below. Current steering activity is unknown." } + if (preference?.valid && preference.enabled === false || !preference && live && current?.enabled === false) + return { key: "off", tone: "muted", title: "Auto Learn is off", description: "No driving samples are being collected by the learner. Turn it on in steering settings to begin." } + if (preference?.valid && preference.enabled && preference.parked) + return { key: "parked", tone: "ready", title: "Auto Learn is on", description: "Collection starts while driving. No new correction is being applied while parked." } + if (!current) return { key: "waiting", tone: "muted", title: "Waiting for the learner", description: preference?.enabled ? "Auto Learn is on, but no learner report has arrived yet." : preference ? "The device has not identified a current vehicle and Auto Learn setting." : "Connect to your device to see its learning activity and steering tune." } + if (!live) return { key: "stale", tone: "muted", title: "No current learner report", description: "These are earlier observations. They do not establish a correction being applied now." } + const tracking = trackingView(report, live) + if (tracking?.acknowledged && tracking.status === "confirmed") return { key: "confirmed", tone: "success", title: "Correction applied", description: "The comparison passed and the controller acknowledged this correction. Disk saving is a separate result." } + if (tracking?.acknowledged && tracking.status === "trial_active") return { key: "testing", tone: "active", title: "Testing a small correction", description: "The controller acknowledged the trial. Comparable new driving windows decide whether to keep it." } + if (tracking?.status === "trial_pending") return { key: "pending", tone: "active", title: "Waiting for the controller", description: "A trial was offered. It has not yet been acknowledged as applied." } + if (tracking?.status === "rollback") return { key: "rollback", tone: "warning", title: "Returning to the baseline", description: tracking.acknowledged ? "The controller acknowledged the rollback." : "A rollback was requested. Current application is not yet acknowledged." } + if (tracking?.status === "paused" || current.status === "unavailable") return { key: "paused", tone: "warning", title: "Learning paused", description: readable(tracking?.reason || current.reason) } + return { key: "collecting", tone: "active", title: tracking?.status === "calibrating" ? "Measuring steering response" : "Collecting driving samples", description: readable(tracking?.reason || current.reason) } +} + const TRACKING_STATES = ['calibrating', 'collecting', 'not_identifiable', 'ready', 'trial_pending', 'trial_active', 'confirmed', 'rollback', 'paused'] const optionalMeasurement = value => value === null || typeof value === 'number' && Number.isFinite(value) && value >= 0 diff --git a/openpilot/starpilot/galaxy/web/js/flm.js b/openpilot/starpilot/galaxy/web/js/flm.js index 7f834c2da5..fe3c81f707 100644 --- a/openpilot/starpilot/galaxy/web/js/flm.js +++ b/openpilot/starpilot/galaxy/web/js/flm.js @@ -2,65 +2,105 @@ import { GxState } from "./state.js" import { GxNotice } from "./notice.js" import { PollTimer, connectionError } from "./polling.js" import { requestJson } from "./startup.js" -import { PlotGraph, PlotsFeed, chartGeometry } from "./plots.js" -import { validTrainingResults, trainingSummary, readable, detailText, surfaceRows, coordinateText, trackingView, measurementText, validTrackingValues, trackingPlotView, sourceSeconds } from "./flm-training-results.js" +import { PlotGraph, chartGeometry } from "./plots.js" +import { validTrainingResults, readable, detailText, surfaceRows, coordinateText, trackingView, measurementText, validTrackingValues, trackingPlotView, sourceSeconds, validLearningPreference, SURFACE_GROUPS, tunerStatus } from "./flm-training-results.js" + +const VIEWS = ["overview", "tune", "activity"] +const PARAMETER_LABELS = { + ff_gain_left: "Left steering gain", ff_gain_right: "Right steering gain", + turn_in_boost_left: "Left turn-in boost", turn_in_boost_right: "Right turn-in boost", + unwind_taper_left: "Left unwind taper", unwind_taper_right: "Right unwind taper", + center_taper_max: "Center taper", highway_center_taper_max: "Highway center taper", + center_deadband_crawl_deg: "Crawl center deadband", center_deadband_low_deg: "Low-speed center deadband", + center_deadband_mid_deg: "Mid-speed center deadband", center_deadband_fast_deg: "Fast center deadband", + center_deadband_highway_deg: "Highway center deadband", + turn_in_threshold_reduction_left: "Left turn-in threshold", turn_in_threshold_reduction_right: "Right turn-in threshold", + unwind_threshold_increase_left: "Left unwind threshold", unwind_threshold_increase_right: "Right unwind threshold", + crawl_turn_in_ff_boost_left: "Left crawl turn-in boost", crawl_turn_in_ff_boost_right: "Right crawl turn-in boost", + low_speed_angle_assist_max_torque: "Low-speed angle assistance", + curvy_speed_min: "Unwind range · lower speed", curvy_speed_max: "Unwind range · upper speed", + curvy_turn_in_trim_speed_min: "Turn-in range · lower speed", curvy_turn_in_trim_speed_max: "Turn-in range · upper speed", + curvy_turn_in_trim_left: "Left turn-in trim", curvy_turn_in_trim_right: "Right turn-in trim", + curvy_unwind_floor_relief_left: "Left unwind floor relief", curvy_unwind_floor_relief_right: "Right unwind floor relief", + curvy_unwind_extra_reduction_left: "Left extra unwind relief", curvy_unwind_extra_reduction_right: "Right extra unwind relief", +} export const FlmPage = { name: "FlmPage", components: { GxState, GxNotice, PlotGraph }, - props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, embedded: { type: Boolean, default: false } }, + props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, + go: { type: Function, default: path => { window.location.hash = path } } }, data: () => ({ report: null, error: "", loading: false, request: null, receivedAt: 0, now: 0, - status: "idle", data: null, history: [], plotError: "", showPlots: true }), - created() { - if (this.embedded) this.showPlots = false - this.feed = new PlotsFeed({ publish: update => { - const { error, ...values } = update - Object.assign(this.$data, values, { plotError: error }) - }, unauthorized: this.unauthorized }) - }, + view: "overview", group: "balance", showAllHistory: false, groups: SURFACE_GROUPS }), mounted() { this.activePage = true this.poller = new PollTimer({ read: () => this.load(), interval: 1000 }) this.visibility = () => { this.now = Date.now() - if (document.hidden) { this.request?.abort(); this.feed.stop() } - else { this.load(); if (this.showPlots && this.mode === "local") this.feed.start() } + if (document.hidden) this.request?.abort() + else this.load() } document.addEventListener("visibilitychange", this.visibility) - if (this.mode === "local") { this.poller.start(); this.load(); if (this.showPlots && !document.hidden) this.feed.start() } + if (this.mode === "local") { this.poller.start(); this.load() } }, beforeUnmount() { - this.activePage = false; clearTimeout(this.freshnessTimer); this.poller?.stop(); this.request?.abort(); this.request = null; this.feed.stop() + this.activePage = false; clearTimeout(this.freshnessTimer); this.poller?.stop(); this.request?.abort(); this.request = null document.removeEventListener("visibilitychange", this.visibility) }, computed: { current() { return this.report?.current }, - live() { return !this.error && !!this.current?.fresh && this.current.ageSeconds + Math.max(0, this.now - this.receivedAt) / 1000 <= 2 }, - summary() { return trainingSummary(this.report, this.error || (this.current && !this.live ? "stale" : "")) }, - currentApplied() { return this.live && this.current?.application?.applied === true }, + preference() { return validLearningPreference(this.report?.preference) ? this.report.preference : null }, + choiceLive() { return !this.error && !!this.preference && Math.max(0, this.now - this.receivedAt) <= 2000 }, + live() { return !this.error && !!this.current?.fresh && this.current.ageSeconds + Math.max(0, this.now - this.receivedAt) / 1000 <= 2 && + !(this.choiceLive && (this.preference.enabled === false || this.preference.parked === true)) }, + state() { return tunerStatus(this.report, this.live, this.choiceLive, this.error) }, + vehicle() { return this.preference?.vehicle ? this.preference.vehicle.replaceAll("_", " ").toLowerCase().replace(/\b\w/g, c => c.toUpperCase()) : "Your steering, measured" }, + currentApplied() { return this.live && this.current?.enabled === true && this.current?.application?.applied === true }, rows() { return surfaceRows(this.report, this.live) }, tracking() { return trackingView(this.report, this.live) }, trackingValues() { return validTrackingValues(this.current?.trackingValues) ? this.current.trackingValues : null }, trackingPlot() { return trackingPlotView(this.report, this.now - this.receivedAt) }, - pairedTrackingChart() { return chartGeometry(this.trackingPlot?.points || [], ['desiredForceAccel', 'measuredForceAccel']) }, - pairedErrorChart() { return chartGeometry(this.trackingPlot?.points || [], ['errorForceAccel']) }, - readiness() { return this.current?.details?.["Snapshot readiness"] }, + pairedTrackingChart() { return chartGeometry(this.trackingPlot?.points || [], ["desiredForceAccel", "measuredForceAccel"]) }, + pairedErrorChart() { return chartGeometry(this.trackingPlot?.points || [], ["errorForceAccel"]) }, details() { return Object.entries(this.current?.details || {}) }, + reportAge() { return this.current ? this.current.ageSeconds + Math.max(0, this.now - this.receivedAt) / 1000 : null }, + appliedCount() { return this.currentApplied && this.rows.every(row => row.applied !== null) ? this.rows.filter(row => row.applied !== 0).length : null }, + acceptedCount() { return this.trackingValues ? this.rows.filter(row => row.accepted !== null && row.accepted !== 0).length : null }, + groupRows() { return this.rows.filter(row => this.selectedGroup.names.includes(row.name)) }, + selectedGroup() { return this.groups.find(group => group.id === this.group) || this.groups[0] }, + historyRows() { return this.showAllHistory ? this.report?.history || [] : (this.report?.history || []).slice(0, 6) }, + improvement() { + const before = this.tracking?.baselineErrorRms, after = this.tracking?.futureErrorRms + return Number.isFinite(before) && before > 0 && Number.isFinite(after) ? (before - after) / before * 100 : null + }, + quota() { + const required = this.tracking?.requiredEpisodes, completed = this.tracking?.futureEpisodes + return Number.isSafeInteger(required) && required > 0 ? { required, completed, percent: Math.min(100, completed / required * 100) } : null + }, losses() { const d = this.current?.details || {}, baseline = d["Fit baseline loss"], learned = d["Fit candidate loss"] return typeof baseline === "number" && Number.isFinite(baseline) && baseline >= 0 && typeof learned === "number" && Number.isFinite(learned) && learned >= 0 ? { baseline, learned } : null }, - lateralChart() { return chartGeometry(this.history, ["desiredLateralAccel", "actualLateralAccel"]) }, - termsChart() { return chartGeometry(this.history, ["lateralP", "lateralI", "lateralD", "lateralF"]) }, }, methods: { readable, detailText, coordinateText, measurementText, sourceSeconds, - amount(value) { return value === null || value === undefined ? "Not reported" : value.toLocaleString() }, - togglePlots() { - this.showPlots = !this.showPlots - if (this.showPlots && this.mode === "local" && !document.hidden) this.feed.start() - else this.feed.stop() + amount(value) { return value === null || value === undefined ? "—" : value.toLocaleString() }, + parameterLabel(name) { return PARAMETER_LABELS[name] || readable(name) }, + groupChanges(group) { + if (!this.trackingValues) return "Not reported" + const count = this.rows.filter(row => group.names.includes(row.name) && row.accepted !== null && row.accepted !== 0).length + return count ? `${count} accepted` : "Baseline" + }, + selectView(view) { if (VIEWS.includes(view)) this.view = view }, + tabKey(event) { + const delta = { ArrowRight: 1, ArrowLeft: -1, Home: -VIEWS.length, End: VIEWS.length }[event.key] + if (delta === undefined) return + event.preventDefault() + const index = VIEWS.indexOf(this.view) + const next = event.key === "Home" ? 0 : event.key === "End" ? VIEWS.length - 1 : (index + delta + VIEWS.length) % VIEWS.length + this.view = VIEWS[next] + event.currentTarget.parentElement.querySelectorAll('[role="tab"]')[next]?.focus() }, async load() { this.now = Date.now() @@ -73,8 +113,8 @@ export const FlmPage = { if (!validTrainingResults(value)) throw new Error("The device returned an unsupported training report.") this.report = value; this.error = ""; this.receivedAt = this.now = Date.now() clearTimeout(this.freshnessTimer) - if (value.current?.fresh) this.freshnessTimer = setTimeout(() => { this.now = Date.now() }, - Math.max(0, 2000 - value.current.ageSeconds * 1000) + 1) + const age = value.current?.fresh ? value.current.ageSeconds * 1000 : 0 + this.freshnessTimer = setTimeout(() => { this.now = Date.now() }, Math.max(0, 2000 - age) + 1) } catch (error) { if (this.request !== controller || !this.activePage) return if (error.status === 401 || ["access_unavailable", "setup_required"].includes(error.code)) this.unauthorized() @@ -84,84 +124,56 @@ export const FlmPage = { } }, }, - template: `
-

StarPilot Auto Learn

StarPilot Auto Learn

-

Learned steering corrections, collection progress and current application.

-
- Auto Learn results require authenticated local Galaxy access. + template: `
+

STEERING · AUTO LEARN

StarPilot Tuner

{{ vehicle }}

+
+ Connect to your device to see its steering tune.
`, diff --git a/prebuilt.json b/prebuilt.json index 96e82af1f3..82ba0bcb63 100644 --- a/prebuilt.json +++ b/prebuilt.json @@ -1 +1 @@ -{"protected_inventory_sha256": "a9f0267198b34ea37835792ba6838da6cbc6ecd63fb7e96fab256692f2e4bede", "schema_version": 1} +{"protected_inventory_sha256": "bbc66f14ace8a765de855992d1eea2c26dc6aac72c1e07815711ccc03d1915e9", "schema_version": 1}