Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions docs/connection-recovery.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions docs/next-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
103 changes: 103 additions & 0 deletions scripts/test-connection-recovery.cjs
Original file line number Diff line number Diff line change
@@ -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; });
7 changes: 6 additions & 1 deletion src/js/first_scan_guide.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
Expand All @@ -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 },
],
Expand Down
38 changes: 35 additions & 3 deletions src/js/live_data_bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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) {
Expand All @@ -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;
}

Expand All @@ -128,13 +129,20 @@ 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]) {
peaks[gaugeKey] = numeric;
}
}

// 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
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 4 additions & 3 deletions src/js/live_data_source_wiring.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,18 +95,19 @@ 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 {
applySweep,
start,
stop,
reset,
markStale: () => bridge?.markStale(),
getKdcanSource: () => kdcanSource,
getBridge: () => bridge,
};
Expand Down
20 changes: 18 additions & 2 deletions src/js/live_gauges.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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") {
Expand All @@ -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]) {
Expand All @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions src/js/live_gauges.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Loading
Loading