diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 88732fc..e6376e9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -70,6 +70,7 @@ jobs: - run: npm install --no-save --package-lock=false --ignore-scripts playwright@1.62.1 - run: npx playwright install --with-deps chromium - run: node scripts/test-plugins-browser.cjs + - run: node scripts/test-connection-recovery.cjs # ─── App-shell chrome + dark-theme regression guard ─── # Every interactive control and chrome surface in src/index.html must be diff --git a/CHANGELOG.md b/CHANGELOG.md index 84625d1..4936e68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Show adapter-specific connection recovery, reject missing K+DCAN ports and + prevent duplicate connection attempts. Restore local controls when disconnect + reports an error, while reporting that transport closure is unconfirmed. +- Live Gauges marks failed or five-second-old channels stale, clears cached + values across profile/session changes and listener restarts, and ignores + live-read responses from an earlier connection or profile. +- Add a real app-shell recovery regression to the browser CI job. + ### Documentation - Reconcile the v5.0.0 README and roadmap baseline; document installed-plugin diff --git a/docs/connection-recovery.md b/docs/connection-recovery.md new file mode 100644 index 0000000..e7df32e --- /dev/null +++ b/docs/connection-recovery.md @@ -0,0 +1,35 @@ +# Connection recovery — first implementation slice + +The Connect button is disabled while a connect/disconnect request is pending. +The adapter selector stays locked during an active connection. A K+DCAN +connection with no selected port is rejected before invoking the backend. +Failed connections show cable-specific recovery in the First BMW scan guide; +raw errors remain in the status log. A disconnect error clears the local +session but explicitly leaves transport closure unconfirmed. + +## Live Gauges freshness + +The DID-backed Live Gauges source tracks the timestamp of each successful +numeric or enum reading independently. An explicit parameter error marks that +channel stale immediately; otherwise a channel becomes stale at five seconds. +This is a presentation threshold, not a change to transport timeouts or polling. +The dial shows Stale, Waiting, Stopped or No data instead of presenting a cached +number as current. Stale enum labels are removed from the status line. + +Profile changes, connection changes and listener restarts clear cached readings +and peaks. The existing live-data polling path allows one request at a time and +discards responses from an earlier profile/session or a stopped poll cycle. +No automatic reconnection or replay of diagnostic operations is introduced. + +## Verification and remaining work + +`scripts/test-connection-recovery.cjs` exercises the shipping app shell with a +mocked Tauri boundary: duplicate clicks, failure/retry, missing ports, +disconnect errors, late read responses, canvas stale states and recovery. +Unit tests exercise per-channel age, errors, labels, reset and peak behavior. +This does not validate cable hardware or certify a chassis/ECU combination. + +This slice covers the DID-backed Live Gauges source and the first-scan flow. +The separate profile gauge grid, logging and explorer polling paths still need +a coordinated freshness review before update 2 is considered complete. +Physical adapter health probes and automatic reconnect are not implemented. diff --git a/docs/next-updates.md b/docs/next-updates.md index 014063b..bb79637 100644 --- a/docs/next-updates.md +++ b/docs/next-updates.md @@ -20,3 +20,18 @@ Version assignment and release publication are separate maintainer decisions. Hardware evidence collection for update 3 can run alongside application work. Do not label a cycle complete until its acceptance target is verified. Use [feature status](feature-status.md) to avoid rebuilding existing modules. + +## Development progress + +- Update 1: baseline documentation merged in PR #374. +- Update 2: first connection-recovery slice documented in + [connection-recovery.md](connection-recovery.md). The cycle remains open + until the other polling paths and real-cable recovery behavior are checked. +- Updates 3–10: acceptance targets above remain outstanding. + +Follow-up baseline finding: the About modal in `src/index.html` still carries +an old version string and repository spelling. Correct its displayed metadata +in a separate focused change; do not treat that as a release version bump. + +GitHub Issues is disabled in this repository as of 2026-10-19, so this document +and focused PRs carry the development status instead of a separate issue tracker. diff --git a/scripts/test-connection-recovery.cjs b/scripts/test-connection-recovery.cjs new file mode 100644 index 0000000..330d145 --- /dev/null +++ b/scripts/test-connection-recovery.cjs @@ -0,0 +1,103 @@ +/* Real app shell with a mocked Tauri boundary; no vehicle commands leave the page. */ +const { chromium } = require('playwright'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const fs = require('node:fs'); + +(async () => { + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + page.on("pageerror", error => console.error("APP:", error.message)); + page.setDefaultTimeout(10000); + await page.addInitScript(() => { + window.connectionCalls = 0; + window.connectFails = true; + window.__TAURI__ = { core: { invoke: async (cmd) => { + if (cmd === 'connect') { + window.connectionCalls++; + await new Promise(resolve => { window.finishConnect = resolve; }); + if (window.connectFails) throw new Error('Adapter unavailable'); + return { transport_name: 'Test adapter', vin: '' }; + } + if (cmd === 'disconnect') throw new Error('Cable already removed'); + if (cmd === 'load_workspace') return {}; + if (cmd === 'list_profiles') return [{ id: 'test', label: 'Test profile' }]; + if (cmd === 'read_live_data') { + if (window.deferLive) { + await new Promise(resolve => { window.finishLive = resolve; }); + return { values: [{ id: 'rpm', value: 3000, label: 'RPM', min: 0, max: 8000, unit: 'rpm' }], errors: [] }; + } + return { values: [], errors: [] }; + } + return []; + } } }; + }); + await page.goto('file://' + path.resolve(__dirname, '../src/index.html')); + await page.waitForFunction(() => document.querySelector('#first-scan-guide-body').textContent.includes('Choose an adapter')); + if (await page.locator('#modal-accept').isVisible()) await page.locator('#modal-accept').click(); + await page.locator('#conn-kind').selectOption('enet'); + await page.locator('#btn-connect').click(); + await page.evaluate(() => document.querySelector('#btn-connect').dispatchEvent(new Event('click'))); + assert.equal(await page.evaluate(() => window.connectionCalls), 1); + assert.equal(await page.locator('#btn-connect').isDisabled(), true); + await page.evaluate(() => window.finishConnect()); + await page.waitForFunction(() => !document.querySelector('#btn-connect').disabled); + assert.match(await page.locator('#first-scan-guide-body').innerText(), /Connection failed.*Discover/s); + if (process.env.RECOVERY_SCREENSHOTS) { + fs.mkdirSync(process.env.RECOVERY_SCREENSHOTS, { recursive: true }); + await page.screenshot({ path: path.join(process.env.RECOVERY_SCREENSHOTS, 'recovery-desktop.png') }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ path: path.join(process.env.RECOVERY_SCREENSHOTS, 'recovery-mobile.png') }); + await page.setViewportSize({ width: 1440, height: 1000 }); + } + await page.evaluate(() => { window.connectFails = false; }); + await page.locator('#btn-connect').click(); + await page.evaluate(() => window.finishConnect()); + await page.waitForFunction(() => document.querySelector('#btn-connect').textContent === 'Disconnect'); + assert.equal(await page.locator('#conn-kind').isDisabled(), true); + // A late live read must not repopulate a disconnected session. + await page.evaluate(() => { + stopTriggerPoll(); + window.deferLive = true; + window.pendingRead = pollOnce(); + }); + await page.waitForFunction(() => typeof window.finishLive === 'function'); + await page.locator('#btn-connect').click(); + await page.waitForFunction(() => document.querySelector('#btn-connect').textContent === 'Connect' && !document.querySelector('#btn-connect').disabled); + assert.equal(await page.locator('#conn-kind').isDisabled(), false); + const lateCount = await page.evaluate(async () => { + window.finishLive(); + await window.pendingRead; + return gauges.size; + }); + assert.equal(lateCount, 0, 'late response cannot restore disconnected readings'); + await page.locator('#conn-kind').selectOption('kdcan'); + await page.locator('#btn-connect').click(); + assert.equal(await page.evaluate(() => window.connectionCalls), 2, 'missing port must not call backend connect'); + assert.match(await page.locator('#first-scan-guide-body').innerText(), /available port.*1 ms/s); + // Render actual canvas gauges with per-channel freshness, then recover. + const states = await page.evaluate(() => { + const source = kdcanDataSource.getKdcanSource(); + const bridge = kdcanDataSource.getBridge(); + const controller = window.beeemuuLiveGauges.controller; + source.start(); + source.applySweepFromTauri([{ id: 'rpm', value: 1200 }], []); + controller.render(); + const live = document.querySelector('#live-can-status').textContent; + bridge.markStale(); + controller.render(); + const stale = controller.gauges.rpm.textOverride; + source.applySweepFromTauri([{ id: 'rpm', value: 800 }], []); + controller.render(); + const recovered = controller.gauges.rpm.textOverride; + bridge.reset(); + controller.render(); + const cleared = controller.gauges.rpm.textOverride; + source.stop(); + return { live, stale, recovered, cleared }; + }); + assert.deepEqual(states, { live: 'Live', stale: 'Stale', recovered: null, cleared: 'Waiting' }); + console.log('PASS: connection retry, duplicate guard, disconnect failure cleanup and Live Gauges freshness'); + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/src/js/first_scan_guide.js b/src/js/first_scan_guide.js index 0c4d8b3..5187317 100644 --- a/src/js/first_scan_guide.js +++ b/src/js/first_scan_guide.js @@ -12,6 +12,11 @@ function buildFirstScanGuide(state) { : "Simulator selected. This is a safe place to learn the scan flow without changing a vehicle."; const moduleCount = Number.isFinite(input.moduleCount) ? input.moduleCount : 0; const selectedModule = String(input.selectedModuleName || "").trim(); + const recovery = kind === "kdcan" + ? "Check the cable and ignition, choose an available port in Connection settings, and confirm the FTDI latency timer is 1 ms. Then try Connect again." + : kind === "enet" + ? "Check the ENET cable and ignition, use Discover in Connection settings, and try Connect again. Check that your firewall allows vehicle discovery." + : "Try Connect again to restart the simulator."; const faultDetail = input.faultMemoryRead ? input.faultCount > 0 ? `${input.faultCount} fault${input.faultCount === 1 ? "" : "s"} read. Select one to see its plain-language detail and any guided checks.` @@ -24,7 +29,7 @@ function buildFirstScanGuide(state) { intro: "This guide only reads information. It does not clear faults, code modules, or program the vehicle.", steps: [ { title: "Choose an adapter", detail: adapterDetail, complete: true }, - { title: "Connect", detail: input.connected ? "Connected. You can now identify the vehicle and its control units." : "Connect before starting a vehicle test.", complete: !!input.connected }, + { title: "Connect", detail: input.connectionError ? "Connection failed. " + recovery : input.connected ? "Connected. You can now identify the vehicle and its control units." : "Connect before starting a vehicle test.", complete: !!input.connected }, { title: "Run a vehicle test", detail: moduleCount > 0 ? `${moduleCount} control unit${moduleCount === 1 ? "" : "s"} found.` : "The vehicle test identifies control units; it does not change them.", complete: moduleCount > 0 }, { title: "Read fault memory", detail: faultDetail, complete: !!input.faultMemoryRead }, ], diff --git a/src/js/live_data_bridge.js b/src/js/live_data_bridge.js index 1c00ce8..8c41544 100644 --- a/src/js/live_data_bridge.js +++ b/src/js/live_data_bridge.js @@ -89,10 +89,11 @@ const DECODE_FN = { * passthrough. */ function createDIDBridge(options = {}) { - const { decodeFn = null } = options; + const { decodeFn = null, now = Date.now } = options; const cache = {}; const texts = {}; const peaks = {}; + const observedAt = {}; let lastSweepAt = null; /** @@ -106,7 +107,7 @@ function createDIDBridge(options = {}) { * `latestValues()` will return). */ function applySweep(values, errors) { - lastSweepAt = Date.now(); + lastSweepAt = now(); if (!Array.isArray(values)) return cache; for (const v of values) { @@ -117,7 +118,7 @@ function createDIDBridge(options = {}) { // they are kept here instead of dropped. See `latestText()`. if (v.text !== undefined && v.text !== null) { const label = String(v.text); - if (label) texts[v.id] = label; + if (label) { texts[v.id] = label; observedAt[v.id] = lastSweepAt; } continue; } @@ -128,6 +129,7 @@ function createDIDBridge(options = {}) { if (!Number.isFinite(numeric)) continue; cache[gaugeKey] = numeric; + observedAt[gaugeKey] = lastSweepAt; // Track peak across the session (mirrors live_gauges.js). if (peaks[gaugeKey] === undefined || numeric > peaks[gaugeKey]) { @@ -135,6 +137,12 @@ function createDIDBridge(options = {}) { } } + // Preserve history but mark failed parameters unavailable immediately. + for (const error of Array.isArray(errors) ? errors : []) { + if (error && typeof error.id === "string") { + delete observedAt[PARAM_TO_GAUGE[error.id] || error.id]; + } + } // Log errors for debugging. Failed PIDs keep their last value. if (Array.isArray(errors) && errors.length > 0) { // eslint-disable-next-line no-console @@ -202,7 +210,31 @@ function createDIDBridge(options = {}) { return { ...texts }; } + // Five seconds is a display freshness limit, not a transport timeout. + // Each channel ages independently; successful unrelated reads cannot refresh it. + function freshness(maxAgeMs = 5000) { + const at = now(); + const keys = [...Object.keys(cache), ...Object.keys(texts)]; + const staleKeys = keys.filter(key => !Object.hasOwn(observedAt, key) || + at < observedAt[key] || at - observedAt[key] >= maxAgeMs); + return { state: keys.length === 0 ? "waiting" : staleKeys.length ? "stale" : "live", staleKeys }; + } + + function markStale() { + for (const key of Object.keys(observedAt)) delete observedAt[key]; + } + + function reset() { + for (const object of [cache, texts, peaks, observedAt]) { + for (const key of Object.keys(object)) delete object[key]; + } + lastSweepAt = null; + } + return { + freshness, + markStale, + reset, applySweep, latestValues, latestText, diff --git a/src/js/live_data_source_wiring.js b/src/js/live_data_source_wiring.js index 374a8b2..e979918 100644 --- a/src/js/live_data_source_wiring.js +++ b/src/js/live_data_source_wiring.js @@ -95,11 +95,11 @@ function initKdcanDataSource({ invoke, log }) { } /** - * Reset bridge peaks. Call on profile change or session reset. + * Clear cached readings, labels and peaks without stopping the listener. + * Call on profile change or session reset. */ function reset() { - stop(); - bridge?.resetPeaks(); + bridge?.reset(); } return { @@ -107,6 +107,7 @@ function initKdcanDataSource({ invoke, log }) { start, stop, reset, + markStale: () => bridge?.markStale(), getKdcanSource: () => kdcanSource, getBridge: () => bridge, }; diff --git a/src/js/live_gauges.js b/src/js/live_gauges.js index 20742de..5d3cbc0 100644 --- a/src/js/live_gauges.js +++ b/src/js/live_gauges.js @@ -94,9 +94,20 @@ function createLiveGaugesController(options) { } function render() { + const health = sourceHolder.source?.readingState?.(); + if (health) { + const labels = { live: "Live", waiting: "Waiting for live data", stale: "Stale readings — check connection", stopped: "Live data stopped" }; + status.textContent = labels[health.state] || "Live data unavailable"; + status.classList.toggle("live-can-off", health.state !== "live"); + } if (sourceHolder.source && typeof sourceHolder.source.latestValues === "function") { const fresh = sourceHolder.source.latestValues(); if (fresh && typeof fresh === "object") { + if (health) { + for (const definition of GAUGE_DEFINITIONS) { + if (!Number.isFinite(fresh[definition.key])) delete values[definition.key]; + } + } setValues(fresh); // Gear + the flag keys are not dials, so they never reach `values`; // read them from the source's live cache instead. The K+DCAN DID path @@ -105,7 +116,9 @@ function createLiveGaugesController(options) { const labels = typeof sourceHolder.source.latestText === "function" ? sourceHolder.source.latestText() : null; - statusLine.textContent = liveStatusText(fresh, labels); + const availableLabels = { ...labels }; + for (const key of health?.staleKeys || []) delete availableLabels[key]; + statusLine.textContent = liveStatusText(fresh, availableLabels); } } if (typeof onSourceTick === "function") { @@ -114,7 +127,9 @@ function createLiveGaugesController(options) { } for (const definition of GAUGE_DEFINITIONS) { const value = values[definition.key]; - if (Number.isFinite(value)) { + if (health && (health.state === "waiting" || health.state === "stopped" || health.staleKeys.includes(definition.key) || !Number.isFinite(value))) { + gauges[definition.key].set(0, health.state === "waiting" ? "Waiting" : health.state === "stopped" ? "Stopped" : !Number.isFinite(value) ? "No data" : "Stale"); + } else if (Number.isFinite(value)) { gauges[definition.key].set(value); // Track peak across the session. Reset on `start()`. if (peaks[definition.key] === undefined || value > peaks[definition.key]) { @@ -135,6 +150,7 @@ function createLiveGaugesController(options) { function start() { if (timer) return timer; + resetPeaks(); if (sourceHolder.source && typeof sourceHolder.source.start === "function") { sourceHolder.source.start(); sourceRunning = true; diff --git a/src/js/live_gauges.test.js b/src/js/live_gauges.test.js index cc0d6b7..dcbfd63 100644 --- a/src/js/live_gauges.test.js +++ b/src/js/live_gauges.test.js @@ -308,3 +308,24 @@ test("the status line is refreshed on each render from the values cache", () => assert.equal(h.statusLine.textContent, "Gear 3 · Cruise on"); h.controller.stop(); }); + +test("stale readings and old-profile cache never appear as current dials", () => { + let data = { rpm: 2000, coolant: 90 }; + let health = { state: "live", staleKeys: [] }; + const h = harness({ source: { latestValues: () => data, readingState: () => health } }); + h.controller.render(); + assert.equal(h.controller.peakFor('rpm'), 2000); + health = { state: 'stale', staleKeys: ['rpm'] }; + data = { rpm: 9000, coolant: 91 }; + h.controller.render(); + assert.equal(h.controller.peakFor('rpm'), 2000, 'stale values cannot inflate peaks'); + assert.match(h.status.textContent, /Stale/); + assert.equal(h.controller.gauges.rpm.calls.at(-1), 0); + data = {}; + health = { state: 'waiting', staleKeys: [] }; + h.controller.render(); + data = { rpm: 800 }; + health = { state: 'live', staleKeys: [] }; + h.controller.render(); + assert.equal(h.controller.gauges.coolant.calls.at(-1), 0, 'previous profile coolant is cleared'); +}); diff --git a/src/js/live_kdcan_source.js b/src/js/live_kdcan_source.js index e6720b0..6365fc2 100644 --- a/src/js/live_kdcan_source.js +++ b/src/js/live_kdcan_source.js @@ -46,6 +46,7 @@ function createKdcanSource(bridge, options = {}) { */ function start() { if (running) return; + bridge.reset?.(); running = true; frameCount = 0; lastFrameAt = null; @@ -119,7 +120,13 @@ function createKdcanSource(bridge, options = {}) { bridge.resetPeaks(); } + function readingState() { + if (!running) return { state: "stopped", staleKeys: Object.keys(bridge.latestValues()) }; + return typeof bridge.freshness === "function" ? bridge.freshness() : { state: "live", staleKeys: [] }; + } + return { + readingState, start, stop, latestValues, diff --git a/src/js/main.js b/src/js/main.js index 3e7a612..21b6844 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -5,6 +5,10 @@ const invoke = window.__TAURI__.core.invoke; const $ = (id) => document.getElementById(id); let connected = false; +let connectionBusy = false; +let connectionError = false; +let liveReadEpoch = 0; +let liveReadBusy = false; let modules = []; let selectedAddress = null; let faultMemoryRead = false; @@ -309,6 +313,7 @@ function renderFirstScanGuide() { if (!body || !api || typeof api.buildFirstScanGuide !== "function") return; const selected = modules.find((module) => module.address === selectedAddress); const guide = api.buildFirstScanGuide({ + connectionError, connectionKind: $("conn-kind").value, connected, moduleCount: modules.filter((module) => module.present).length, @@ -466,7 +471,7 @@ $("btn-enet-discover").addEventListener("click", async () => { const targets = await invoke("discover_enet_targets"); if (!targets.length) { sel.classList.add("hidden"); - setStatus("No vehicle answered discovery — enter the IP manually (typically 169.254.x.x)."); + setStatus("No vehicle answered discovery. Check the cable, ignition and firewall, then use Discover again."); return; } // textContent/value only — discovered strings never touch innerHTML. @@ -492,83 +497,109 @@ $("enet-discovered").addEventListener("change", () => { }); $("btn-connect").addEventListener("click", async () => { - if (connected) { - stopPolling(); - stopWatch(); - clearSecCountdown(); - unlockStates.clear(); - await invoke("disconnect"); - connected = false; - sessionReplay = false; - modules = []; - selectedAddress = null; - lastVehicleInfo = null; - lastTraffic = []; - faultMemoryRead = false; - $("btn-connect").textContent = "Connect"; - $("btn-connect").classList.add("btn-primary"); - $("vehicle-banner").innerHTML = "No vehicle connected"; - setStatus("Disconnected"); - renderTree(); - refreshFrmCodingCard(); - renderFirstScanGuide(); - renderBeginnerFaultSummary(); - window.beeemuuV3._notify(); - return; - } - if (sessionReplay) { - sessionReplay = false; - modules = []; - selectedAddress = null; - lastVehicleInfo = null; - lastTraffic = []; - faultMemoryRead = false; - window.beeemuuV3._notify(); - $("ecu-tree").innerHTML = "
  • Connect and run a vehicle test to identify control units.
  • "; - $("fault-rows").innerHTML = "Select a control unit."; - $("info-body").innerHTML = "

    Connect and click 'Read vehicle' to read VIN, decode it, and read mileage.

    "; - $("traffic-rows").innerHTML = "No traffic yet. Connect and interact with the car."; - $("btn-connect").textContent = "Connect"; - $("btn-connect").classList.add("btn-primary"); - $("vehicle-banner").innerHTML = "No vehicle connected"; - setStatus("Disconnected"); - refreshFrmCodingCard(); - renderFirstScanGuide(); - renderBeginnerFaultSummary(); - return; - } + if (connectionBusy) return; + connectionBusy = true; + connectionError = false; + const connectButton = $("btn-connect"); + connectButton.disabled = true; + $("conn-kind").disabled = true; + liveReadEpoch++; + kdcanDataSource?.reset(); + window.beeemuuLiveGauges?.controller?.resetPeaks(); try { - setStatus("Connecting…", false); - const info = await invoke("connect", { config: connConfig() }); - connected = true; - $("btn-connect").textContent = "Disconnect"; - const vin = info.vin ? `VIN ${info.vin}` : "VIN unavailable"; - $("vehicle-banner").innerHTML = - `${escapeHtml(info.transport_name)}  ·  ${escapeHtml(vin)}`; - setStatus("Connected via " + info.transport_name); - log(""); - // Auto-select suggested profile if available - if (info.suggested_profile) { - const liveSel = $("live-profile"); - const logSel = $("log-profile"); - // Only switch if the profile exists in the dropdown - const exists = Array.from(liveSel.options).some((o) => o.value === info.suggested_profile); - if (exists) { - liveSel.value = info.suggested_profile; - logSel.value = info.suggested_profile; - // Trigger change to rebuild gauges / log params - liveSel.dispatchEvent(new Event("change")); - log("Auto-selected profile: " + info.suggested_profile); + if (connected) { + stopPolling(); + stopWatch(); + clearSecCountdown(); + unlockStates.clear(); + let disconnectFailed = false; + try { + await invoke("disconnect"); + } catch (error) { + disconnectFailed = true; + log("Disconnect could not be confirmed: " + error + ". Check the cable before reconnecting."); } + connected = false; + sessionReplay = false; + modules = []; + selectedAddress = null; + lastVehicleInfo = null; + lastTraffic = []; + faultMemoryRead = false; + $("btn-connect").textContent = "Connect"; + $("btn-connect").classList.add("btn-primary"); + $("vehicle-banner").innerHTML = "No vehicle connected"; + setStatus(disconnectFailed ? "Disconnected locally — cable closure unconfirmed" : "Disconnected"); + renderTree(); + refreshFrmCodingCard(); + renderFirstScanGuide(); + renderBeginnerFaultSummary(); + window.beeemuuV3._notify(); + return; } - saveSettings(); - refreshFrmCodingCard(); - renderFirstScanGuide(); - renderBeginnerFaultSummary(); - } catch (e) { - setStatus("Disconnected"); - log("Connect failed: " + e); - renderFirstScanGuide(); + if (sessionReplay) { + sessionReplay = false; + modules = []; + selectedAddress = null; + lastVehicleInfo = null; + lastTraffic = []; + faultMemoryRead = false; + window.beeemuuV3._notify(); + $("ecu-tree").innerHTML = "
  • Connect and run a vehicle test to identify control units.
  • "; + $("fault-rows").innerHTML = "Select a control unit."; + $("info-body").innerHTML = "

    Connect and click 'Read vehicle' to read VIN, decode it, and read mileage.

    "; + $("traffic-rows").innerHTML = "No traffic yet. Connect and interact with the car."; + $("btn-connect").textContent = "Connect"; + $("btn-connect").classList.add("btn-primary"); + $("vehicle-banner").innerHTML = "No vehicle connected"; + setStatus("Disconnected"); + refreshFrmCodingCard(); + renderFirstScanGuide(); + renderBeginnerFaultSummary(); + return; + } + try { + setStatus("Connecting…", false); + const config = connConfig(); + if ((config.kind === "kdcan" || config.kind === "kdcan_auto") && !config.port) { + throw new Error("No cable port selected."); + } + const info = await invoke("connect", { config }); + connected = true; + $("btn-connect").textContent = "Disconnect"; + const vin = info.vin ? `VIN ${info.vin}` : "VIN unavailable"; + $("vehicle-banner").innerHTML = + `${escapeHtml(info.transport_name)}  ·  ${escapeHtml(vin)}`; + setStatus("Connected via " + info.transport_name); + log(""); + // Auto-select suggested profile if available + if (info.suggested_profile) { + const liveSel = $("live-profile"); + const logSel = $("log-profile"); + // Only switch if the profile exists in the dropdown + const exists = Array.from(liveSel.options).some((o) => o.value === info.suggested_profile); + if (exists) { + liveSel.value = info.suggested_profile; + logSel.value = info.suggested_profile; + // Trigger change to rebuild gauges / log params + liveSel.dispatchEvent(new Event("change")); + log("Auto-selected profile: " + info.suggested_profile); + } + } + saveSettings(); + refreshFrmCodingCard(); + renderFirstScanGuide(); + renderBeginnerFaultSummary(); + } catch (e) { + setStatus("Disconnected"); + log("Connect failed: " + e); + connectionError = true; + renderFirstScanGuide(); + } + } finally { + connectionBusy = false; + connectButton.disabled = false; + $("conn-kind").disabled = connected; } }); @@ -1556,6 +1587,9 @@ async function loadProfiles() { } $("live-profile").addEventListener("change", () => { + liveReadEpoch++; + kdcanDataSource?.reset(); + window.beeemuuLiveGauges?.controller?.resetPeaks(); // different profile = different parameter set: rebuild gauges gauges.clear(); $("gauge-grid").innerHTML = ""; @@ -1669,6 +1703,9 @@ function feedColdStartMonitor(values) { } async function pollOnce() { + if (liveReadBusy || !connected) return; + const epoch = liveReadEpoch; + liveReadBusy = true; let result = { values: [], errors: [] }; try { // v0.14.3 slice 3b — `read_live_data` now returns @@ -1680,6 +1717,7 @@ async function pollOnce() { result = connected ? await invoke("read_live_data", { profile: $("live-profile").value }) : result; + if (epoch !== liveReadEpoch || !connected) return; // v0.15.0 slice 2c — feed the same sweep into the K+DCAN // bridge so the Live Gauges panel can render real values // (RPM, coolant, oil temp, etc.) from the K+DCAN cable. @@ -1694,6 +1732,8 @@ async function pollOnce() { // which the sweep already carries, so this costs nothing extra. feedColdStartMonitor(result.values || []); } catch (e) { + if (epoch !== liveReadEpoch) return; + kdcanDataSource?.markStale?.(); // Systemic failure (no transport, unknown profile, poisoned state // lock) — `read_live_data` returns `Err(_)` for these. Same // v0.14.2 slice 2 surface: parse the error string, log the @@ -1705,7 +1745,10 @@ async function pollOnce() { log("Live data: " + e); } stopPolling(); + setStatus("Live data stopped. Check the cable and ignition; disconnect and reconnect if needed."); return; + } finally { + liveReadBusy = false; } for (const v of result.values) { // Enum params (gear, engine state, etc.) have v.text set and @@ -1831,6 +1874,7 @@ function startPolling() { pollOnce(); } function stopPolling() { + liveReadEpoch++; clearInterval(pollTimer); pollTimer = null; $("live-poll").checked = false; diff --git a/src/js/test/connectionRecovery.test.cjs b/src/js/test/connectionRecovery.test.cjs new file mode 100644 index 0000000..6feb076 --- /dev/null +++ b/src/js/test/connectionRecovery.test.cjs @@ -0,0 +1,48 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { createDIDBridge } = require('../live_data_bridge.js'); +const { createKdcanSource } = require('../live_kdcan_source.js'); +const { buildFirstScanGuide } = require('../first_scan_guide.js'); + +test('channel age expires independently and PID errors invalidate immediately', () => { + let time = 0; + const bridge = createDIDBridge({ now: () => time }); + assert.equal(bridge.freshness().state, 'waiting'); + bridge.applySweep([{ id: 'rpm', value: 750 }, { id: 'gear', value: 0, text: 'D3' }], []); + time = 4000; + bridge.applySweep([{ id: 'coolant', value: 90 }], []); + time = 5000; + assert.deepEqual(bridge.freshness(), { state: 'stale', staleKeys: ['rpm', 'gear'] }); + bridge.applySweep([], [{ id: 'coolant' }]); + assert.ok(bridge.freshness().staleKeys.includes('coolant')); + assert.equal(bridge.latestValues().coolant, 90, 'history remains separate from freshness'); + bridge.applySweep([{ id: 'rpm', value: 800 }, { id: 'coolant', value: 91 }, { id: 'gear', text: 'D4' }], []); + assert.equal(bridge.freshness().state, 'live'); + bridge.markStale(); + assert.equal(bridge.freshness().state, 'stale'); +}); + +test('reset and listener restart cannot reuse readings from a previous session', () => { + const bridge = createDIDBridge(); + const source = createKdcanSource(bridge); + try { + source.start(); + source.applySweepFromTauri([{ id: 'rpm', value: 2000 }, { id: 'gear', text: 'D3' }], []); + assert.equal(source.readingState().state, 'live'); + source.stop(); + assert.equal(source.readingState().state, 'stopped'); + source.start(); + assert.deepEqual(source.latestValues(), {}); + assert.deepEqual(source.latestText(), {}); + assert.equal(source.readingState().state, 'waiting'); + assert.equal(bridge.peakFor('rpm'), undefined); + } finally { source.stop(); } +}); + +test('failed connection guide offers adapter-specific recovery', () => { + for (const [kind, expected] of [['kdcan', /available port.*1 ms/], ['enet', /Discover.*firewall/], ['sim', /restart the simulator/]]) { + const guide = buildFirstScanGuide({ connectionKind: kind, connectionError: true }); + assert.match(guide.steps[1].detail, expected); + assert.equal(guide.steps[1].complete, false); + } +});