From 65ed84c71a7bac569bbc6c41f6cc8bca81b92e66 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 4 Sep 2026 16:23:56 -0500 Subject: [PATCH 1/3] feat(canopen web): VID-only filter + dispatcher module-presence check Bring the can_bridge and ds402 WebUSB consoles in line with the ota / coredump / haptics consoles: - default device filter is now VID-only (any espp device, VID 0x1209) instead of VID+PID, so a multi-module device is offered in every console; "show all devices" uses {filters:[{}]}. Removes the invalid acceptAllDevices (a Web Bluetooth option that throws "Required member filters is undefined" on WebUSB). - on connect each console queries the reserved dispatcher discovery module (0xFF ListModules, reusing Dispatcher::describe's TLV) and warns if the CAN bridge module (5) is not advertised, then continues anyway; firmware without the discovery module simply never replies and is tolerated silently. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../canopen/web/can_bridge_console.html | 57 ++++++++++++++++++- components/canopen/web/ds402_panel.html | 55 +++++++++++++++++- 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/components/canopen/web/can_bridge_console.html b/components/canopen/web/can_bridge_console.html index b93917d114..236b939829 100644 --- a/components/canopen/web/can_bridge_console.html +++ b/components/canopen/web/can_bridge_console.html @@ -380,6 +380,10 @@

Log

const MAX_PAYLOAD = 4096; // resync cap; interleaved protocols may use up to this const MAX_FRAME = HEADER_SIZE + CORRELATION_SIZE + MAX_PAYLOAD + CRC_SIZE; const MODULE_CAN = 5; // the CAN bridge is module 5 + // Reserved dispatcher discovery module: a device running an espp::Dispatcher + // answers a ListModules query on 0xFF with the modules it serves, so we can + // confirm the CAN bridge module is present before driving it. + const MODULE_DISCOVERY = 0xFF, T_LIST_MODULES = 0x00; const FLAGS_VERSION = 1; // protocol version lives in flags bits 4-7 const FLAGS_REQUEST = (FLAGS_VERSION << 4) | 0; // host->device request: 0x10 (reply bit 0) const FLAGS_REPLY_BIT = 0x01; // bit0 set on device->host replies/events @@ -612,14 +616,17 @@

Log

// =================================================================== let transport = null; // active transport or null let connectTime = 0; // performance.now() at connect, for relative timestamps + // A pending checkModulePresent() parks its resolver here; onIncoming calls it + // when the 0xFF ListModules reply arrives. + let discoveryResolve = null; // ---- WebUSB transport ------------------------------------------------- class UsbTransport { constructor() { this.device = null; this.iface = null; this.epIn = null; this.epOut = null; this.inSize = 64; this.reading = false; } async open(anyDevice) { const options = anyDevice - ? { acceptAllDevices: true } - : { filters: [{ vendorId: DEFAULT_VID, productId: DEFAULT_PID }] }; + ? { filters: [{}] } + : { filters: [{ vendorId: DEFAULT_VID }] }; this.device = await navigator.usb.requestDevice(options); await this.device.open(); if (this.device.configuration === null) await this.device.selectConfiguration(1); @@ -790,6 +797,9 @@

Log

if (transport === t) { logLine("err", "Read loop error: " + (e && e.message ? e.message : e)); onLinkLost(); } }); + // Best-effort: warn if this device does not advertise the CAN bridge module. + checkModulePresent(); + // Poll STATUS and request an immediate one. requestStatus(); statusTimer = setInterval(requestStatus, STATUS_POLL_MS); @@ -859,6 +869,43 @@

Log

} function requestStatus() { sendFrame(T_GET_STATUS, null); } + // ---- discovery-based module presence check -------------------------------- + // Decode the ListModules TLV (see espp::Dispatcher::describe). + function parseDiscovery(payload) { + let i = 0; + const need = (n) => { if (i + n > payload.length) throw new Error("truncated discovery payload"); }; + const u8 = () => { need(1); return payload[i++]; }; + const str = () => { const n = u8(); need(n); const s = new TextDecoder().decode(payload.subarray(i, i + n)); i += n; return s; }; + const version = u8(); u8(); // version, reserved + const device = str(), fw = str(); + const count = u8(); + const modules = []; + for (let m = 0; m < count; m++) modules.push({ id: u8(), name: str(), app: str(), desc: str() }); + return { version, device, fw, modules }; + } + // Ask the device which dispatcher modules it serves and warn (best-effort) if + // the CAN bridge module is not among them. No discovery reply (older firmware) + // -> stay quiet; normal STATUS polling still surfaces a real missing module. + async function checkModulePresent() { + if (!transport) return; + const payload = await new Promise((resolve) => { + const timer = setTimeout(() => { discoveryResolve = null; resolve(null); }, 800); + discoveryResolve = (p) => { clearTimeout(timer); resolve(p); }; + transport.send(buildFrame(MODULE_DISCOVERY, T_LIST_MODULES)) + .catch(() => { clearTimeout(timer); discoveryResolve = null; resolve(null); }); + }); + if (!payload) return; + let info; + try { info = parseDiscovery(payload); } catch (_) { return; } + if (info.modules.some((m) => m.id === MODULE_CAN)) { + logLine("sys", "Device advertises " + info.modules.length + " module(s); CAN bridge module present."); + return; + } + const names = info.modules.map((m) => m.name + " (#" + m.id + ")").join(", ") || "none"; + logLine("err", "This device does not advertise the CAN bridge module. Detected: " + names + + ". You may have selected the wrong device, or its firmware lacks CAN bridge support."); + } + els.applyBtn.addEventListener("click", () => { const baud = parseInt(els.baud.value, 10); const mode = parseInt(els.mode.value, 10); @@ -948,6 +995,12 @@

Log

function onIncoming(chunk) { const frames = parser.feed(chunk); for (const f of frames) { + // Discovery reply (reserved module 0xFF): hand the TLV to a pending module + // probe before the CAN-module matching below ignores it. + if (f.module === MODULE_DISCOVERY && f.reply && f.type === T_LIST_MODULES) { + if (discoveryResolve) { discoveryResolve(f.payload); discoveryResolve = null; } + continue; + } // Only CAN bridge (module 5) device->host reply/event frames we know. if (f.module !== MODULE_CAN || !f.reply || !KNOWN_RX.has(f.type)) { // Frame belonging to another module/protocol on the shared stream: ignore. diff --git a/components/canopen/web/ds402_panel.html b/components/canopen/web/ds402_panel.html index 47a98cb9b0..73c569ee7f 100644 --- a/components/canopen/web/ds402_panel.html +++ b/components/canopen/web/ds402_panel.html @@ -456,6 +456,10 @@

Log const MAX_PAYLOAD = 4096; const MAX_FRAME = HEADER_SIZE + CORRELATION_SIZE + MAX_PAYLOAD + CRC_SIZE; const MODULE_CAN = 5; + // Reserved dispatcher discovery module: a device running an espp::Dispatcher + // answers a ListModules query on 0xFF with the modules it serves, so we can + // confirm the CAN bridge module is present before driving it. + const MODULE_DISCOVERY = 0xFF, T_LIST_MODULES = 0x00; const FLAGS_VERSION = 1; const FLAGS_REQUEST = (FLAGS_VERSION << 4) | 0; const FLAGS_REPLY_BIT = 0x01; @@ -656,7 +660,7 @@

Log class UsbTransport { constructor() { this.device = null; this.iface = null; this.epIn = null; this.epOut = null; this.inSize = 64; this.reading = false; } async open(anyDevice) { - const options = anyDevice ? { acceptAllDevices: true } : { filters: [{ vendorId: DEFAULT_VID, productId: DEFAULT_PID }] }; + const options = anyDevice ? { filters: [{}] } : { filters: [{ vendorId: DEFAULT_VID }] }; this.device = await navigator.usb.requestDevice(options); await this.device.open(); if (this.device.configuration === null) await this.device.selectConfiguration(1); @@ -1139,6 +1143,48 @@

Log } } + // =================================================================== + // Discovery-based module presence check + // =================================================================== + // A pending checkModulePresent() parks its resolver here; onIncoming calls it + // when the 0xFF ListModules reply arrives. + let discoveryResolve = null; + // Decode the ListModules TLV (see espp::Dispatcher::describe). + function parseDiscovery(payload) { + let i = 0; + const need = (n) => { if (i + n > payload.length) throw new Error("truncated discovery payload"); }; + const u8 = () => { need(1); return payload[i++]; }; + const str = () => { const n = u8(); need(n); const s = new TextDecoder().decode(payload.subarray(i, i + n)); i += n; return s; }; + const version = u8(); u8(); // version, reserved + const device = str(), fw = str(); + const count = u8(); + const modules = []; + for (let m = 0; m < count; m++) modules.push({ id: u8(), name: str(), app: str(), desc: str() }); + return { version, device, fw, modules }; + } + // Ask the device which dispatcher modules it serves and warn (best-effort) if + // the CAN bridge module is not among them. No discovery reply (older firmware) + // -> stay quiet; the normal bus-status poll still surfaces a real missing module. + async function checkModulePresent() { + if (!transport) return; + const payload = await new Promise((resolve) => { + const timer = setTimeout(() => { discoveryResolve = null; resolve(null); }, 800); + discoveryResolve = (p) => { clearTimeout(timer); resolve(p); }; + transport.send(buildFrame(MODULE_DISCOVERY, T_LIST_MODULES)) + .catch(() => { clearTimeout(timer); discoveryResolve = null; resolve(null); }); + }); + if (!payload) return; + let info; + try { info = parseDiscovery(payload); } catch (_) { return; } + if (info.modules.some((m) => m.id === MODULE_CAN)) { + logLine("sys", "Device advertises " + info.modules.length + " module(s); CAN bridge module present."); + return; + } + const names = info.modules.map((m) => m.name + " (#" + m.id + ")").join(", ") || "none"; + logLine("err", "This device does not advertise the CAN bridge module. Detected: " + names + + ". You may have selected the wrong device, or its firmware lacks CAN bridge support."); + } + // =================================================================== // Incoming frame handling // =================================================================== @@ -1147,6 +1193,12 @@

Log try { frames = parser.feed(chunk); } catch (e) { logLine("err", "parse error: " + (e && e.message ? e.message : e)); return; } for (const f of frames) { + // Discovery reply (reserved module 0xFF): hand the TLV to a pending module + // probe before the CAN-module matching below ignores it. + if (f.module === MODULE_DISCOVERY && f.reply && f.type === T_LIST_MODULES) { + if (discoveryResolve) { discoveryResolve(f.payload); discoveryResolve = null; } + continue; + } if (f.module !== MODULE_CAN) continue; // ignore interleaved other-module frames if (f.type === T_CAN_RX) { const cf = decodeCanFrame(f.payload); @@ -1198,6 +1250,7 @@

Log els.devInfo.textContent = name; logLine("sys", "Connected: " + name); t.readLoop(onIncoming).catch((e) => { if (transport === t) { logLine("err", "Read loop error: " + (e && e.message ? e.message : e)); onLinkLost(); } }); + checkModulePresent(); // best-effort: warn if this device lacks the CAN bridge module requestStatus(); statusTimer = setInterval(requestStatus, STATUS_POLL_MS); if (els.livePoll.checked) startLivePoll(); // honor a pre-ticked Live poll From bcc2e0f9c00b55f917548a3992263b35f7931b29 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 4 Sep 2026 20:00:28 -0500 Subject: [PATCH 2/3] =?UTF-8?q?fix(canopen=20web):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20race-safe=20discovery=20probe=20+=20accurate=20hint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - checkModulePresent(): the 800ms timeout unconditionally nulled the shared discoveryResolve, so a stale probe's timer could cancel a newer probe. Guard with a settle() that only clears the resolver when it is still the active one. - pull the 800ms magic number into a named DISCOVERY_TIMEOUT_MS constant. - fire-and-forget call now has .catch(() => {}) (no unhandled rejection). - "Default filter" hint text corrected to VID-only (was VID+PID). Co-Authored-By: Claude Opus 4.8 (1M context) --- components/canopen/web/can_bridge_console.html | 18 +++++++++++------- components/canopen/web/ds402_panel.html | 18 +++++++++++------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/components/canopen/web/can_bridge_console.html b/components/canopen/web/can_bridge_console.html index 236b939829..d44e4916ce 100644 --- a/components/canopen/web/can_bridge_console.html +++ b/components/canopen/web/can_bridge_console.html @@ -250,7 +250,7 @@

Device

-

Not connected. WebUSB default filter: VID 0x1209 / PID 0x0d32 (espp default); the vendor (0xFF) interface is discovered from the descriptors at runtime.

+

Not connected. WebUSB default filter: any espp device (VID 0x1209); the vendor (0xFF) interface is discovered from the descriptors at runtime.

@@ -384,6 +384,7 @@

Log

// answers a ListModules query on 0xFF with the modules it serves, so we can // confirm the CAN bridge module is present before driving it. const MODULE_DISCOVERY = 0xFF, T_LIST_MODULES = 0x00; + const DISCOVERY_TIMEOUT_MS = 800; // module-presence probe (0xFF ListModules) timeout const FLAGS_VERSION = 1; // protocol version lives in flags bits 4-7 const FLAGS_REQUEST = (FLAGS_VERSION << 4) | 0; // host->device request: 0x10 (reply bit 0) const FLAGS_REPLY_BIT = 0x01; // bit0 set on device->host replies/events @@ -798,7 +799,7 @@

Log

}); // Best-effort: warn if this device does not advertise the CAN bridge module. - checkModulePresent(); + checkModulePresent().catch(() => {}); // Poll STATUS and request an immediate one. requestStatus(); @@ -847,7 +848,7 @@

Log

els.stopBtn.disabled = !connected; els.sendBtn.disabled = !connected; if (!connected) { - els.devInfo.textContent = "Not connected. WebUSB default filter: VID 0x1209 / PID 0x0d32 (espp default); the vendor (0xFF) interface is discovered from the descriptors at runtime."; + els.devInfo.textContent = "Not connected. WebUSB default filter: any espp device (VID 0x1209); the vendor (0xFF) interface is discovered from the descriptors at runtime."; setStatusLine(null); } } @@ -889,10 +890,13 @@

Log

async function checkModulePresent() { if (!transport) return; const payload = await new Promise((resolve) => { - const timer = setTimeout(() => { discoveryResolve = null; resolve(null); }, 800); - discoveryResolve = (p) => { clearTimeout(timer); resolve(p); }; - transport.send(buildFrame(MODULE_DISCOVERY, T_LIST_MODULES)) - .catch(() => { clearTimeout(timer); discoveryResolve = null; resolve(null); }); + let timer = null; + // settle guards the shared resolver: only clear it if it is still ours, + // so a prior probe's timeout can't cancel a newer probe's resolver. + const settle = (p) => { clearTimeout(timer); if (discoveryResolve === settle) discoveryResolve = null; resolve(p); }; + timer = setTimeout(() => settle(null), DISCOVERY_TIMEOUT_MS); + discoveryResolve = settle; + transport.send(buildFrame(MODULE_DISCOVERY, T_LIST_MODULES)).catch(() => settle(null)); }); if (!payload) return; let info; diff --git a/components/canopen/web/ds402_panel.html b/components/canopen/web/ds402_panel.html index 73c569ee7f..f409e557fa 100644 --- a/components/canopen/web/ds402_panel.html +++ b/components/canopen/web/ds402_panel.html @@ -248,7 +248,7 @@

Device

-

Not connected. WebUSB default filter: VID 0x1209 / PID 0x0d32 (espp default); the vendor (0xFF) interface is discovered from the descriptors at runtime.

+

Not connected. WebUSB default filter: any espp device (VID 0x1209); the vendor (0xFF) interface is discovered from the descriptors at runtime.

@@ -460,6 +460,7 @@

Log // answers a ListModules query on 0xFF with the modules it serves, so we can // confirm the CAN bridge module is present before driving it. const MODULE_DISCOVERY = 0xFF, T_LIST_MODULES = 0x00; + const DISCOVERY_TIMEOUT_MS = 800; // module-presence probe (0xFF ListModules) timeout const FLAGS_VERSION = 1; const FLAGS_REQUEST = (FLAGS_VERSION << 4) | 0; const FLAGS_REPLY_BIT = 0x01; @@ -1168,10 +1169,13 @@

Log async function checkModulePresent() { if (!transport) return; const payload = await new Promise((resolve) => { - const timer = setTimeout(() => { discoveryResolve = null; resolve(null); }, 800); - discoveryResolve = (p) => { clearTimeout(timer); resolve(p); }; - transport.send(buildFrame(MODULE_DISCOVERY, T_LIST_MODULES)) - .catch(() => { clearTimeout(timer); discoveryResolve = null; resolve(null); }); + let timer = null; + // settle guards the shared resolver: only clear it if it is still ours, + // so a prior probe's timeout can't cancel a newer probe's resolver. + const settle = (p) => { clearTimeout(timer); if (discoveryResolve === settle) discoveryResolve = null; resolve(p); }; + timer = setTimeout(() => settle(null), DISCOVERY_TIMEOUT_MS); + discoveryResolve = settle; + transport.send(buildFrame(MODULE_DISCOVERY, T_LIST_MODULES)).catch(() => settle(null)); }); if (!payload) return; let info; @@ -1250,7 +1254,7 @@

Log els.devInfo.textContent = name; logLine("sys", "Connected: " + name); t.readLoop(onIncoming).catch((e) => { if (transport === t) { logLine("err", "Read loop error: " + (e && e.message ? e.message : e)); onLinkLost(); } }); - checkModulePresent(); // best-effort: warn if this device lacks the CAN bridge module + checkModulePresent().catch(() => {}); // best-effort: warn if this device lacks the CAN bridge module requestStatus(); statusTimer = setInterval(requestStatus, STATUS_POLL_MS); if (els.livePoll.checked) startLivePoll(); // honor a pre-ticked Live poll @@ -1295,7 +1299,7 @@

Log els.anyDevice.disabled = connected; for (const id of CONNECTED_CONTROLS) els[id].disabled = !connected; if (!connected) { - els.devInfo.textContent = "Not connected. WebUSB default filter: VID 0x1209 / PID 0x0d32 (espp default); the vendor (0xFF) interface is discovered from the descriptors at runtime."; + els.devInfo.textContent = "Not connected. WebUSB default filter: any espp device (VID 0x1209); the vendor (0xFF) interface is discovered from the descriptors at runtime."; setStatusLine(null); setDs402State(null); els.livePoll.checked = false; From ff43dac623ebe52c8519c1c03f14a8eb8a16848a Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 4 Sep 2026 22:09:21 -0500 Subject: [PATCH 3/3] fix(canopen web): empty-filter fallback + non-fatal warn tier - requestUsbDevice(): "show all" uses an empty filter {} with a VID-only fallback if a WebUSB impl rejects it. - log the missing-module message under a non-fatal "warn" tier (.warn) instead of the error tier. Matches the ota/coredump/haptics/mcp266 consoles. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../canopen/web/can_bridge_console.html | 24 +++++++++++++++---- components/canopen/web/ds402_panel.html | 21 +++++++++++++--- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/components/canopen/web/can_bridge_console.html b/components/canopen/web/can_bridge_console.html index d44e4916ce..e22672d157 100644 --- a/components/canopen/web/can_bridge_console.html +++ b/components/canopen/web/can_bridge_console.html @@ -221,6 +221,7 @@ .log .tx { color: var(--tx-color); } .log .rx { color: var(--rx-color); } .log .err { color: var(--err-color); } + .log .warn { color: #d08770; } .log .sys { color: var(--sys-color); } footer { color: var(--text-muted); font-size: 12px; margin-top: 10px; } #unsupported { @@ -621,14 +622,27 @@

Log

// when the 0xFF ListModules reply arrives. let discoveryResolve = null; + // WebUSB requires a `filters` member (there is no acceptAllDevices). The + // "show all" path uses one empty filter `{}` (matches everything); a few + // WebUSB implementations reject an empty filter object, so fall back to a + // VID-only filter in that case. + async function requestUsbDevice(anyDevice) { + const showAll = { filters: [{}] }; + const vidOnly = { filters: [{ vendorId: DEFAULT_VID }] }; + try { + return await navigator.usb.requestDevice(anyDevice ? showAll : vidOnly); + } catch (e) { + if (anyDevice && e && e.name === "TypeError") + return await navigator.usb.requestDevice(vidOnly); + throw e; + } + } + // ---- WebUSB transport ------------------------------------------------- class UsbTransport { constructor() { this.device = null; this.iface = null; this.epIn = null; this.epOut = null; this.inSize = 64; this.reading = false; } async open(anyDevice) { - const options = anyDevice - ? { filters: [{}] } - : { filters: [{ vendorId: DEFAULT_VID }] }; - this.device = await navigator.usb.requestDevice(options); + this.device = await requestUsbDevice(anyDevice); await this.device.open(); if (this.device.configuration === null) await this.device.selectConfiguration(1); const config = this.device.configuration; @@ -906,7 +920,7 @@

Log

return; } const names = info.modules.map((m) => m.name + " (#" + m.id + ")").join(", ") || "none"; - logLine("err", "This device does not advertise the CAN bridge module. Detected: " + names + + logLine("warn", "This device does not advertise the CAN bridge module. Detected: " + names + ". You may have selected the wrong device, or its firmware lacks CAN bridge support."); } diff --git a/components/canopen/web/ds402_panel.html b/components/canopen/web/ds402_panel.html index f409e557fa..3f52fcebff 100644 --- a/components/canopen/web/ds402_panel.html +++ b/components/canopen/web/ds402_panel.html @@ -219,6 +219,7 @@ .log .tx { color: var(--tx-color); } .log .rx { color: var(--rx-color); } .log .err { color: var(--err-color); } + .log .warn { color: #d08770; } .log .sys { color: var(--sys-color); } footer { color: var(--text-muted); font-size: 12px; margin-top: 10px; } #unsupported { @@ -658,11 +659,25 @@

Log // Transport: WebUSB + Web Serial (identical to can_bridge_console.html) // =================================================================== let transport = null; + // WebUSB requires a `filters` member (there is no acceptAllDevices). The + // "show all" path uses one empty filter `{}` (matches everything); a few + // WebUSB implementations reject an empty filter object, so fall back to a + // VID-only filter in that case. + async function requestUsbDevice(anyDevice) { + const showAll = { filters: [{}] }; + const vidOnly = { filters: [{ vendorId: DEFAULT_VID }] }; + try { + return await navigator.usb.requestDevice(anyDevice ? showAll : vidOnly); + } catch (e) { + if (anyDevice && e && e.name === "TypeError") + return await navigator.usb.requestDevice(vidOnly); + throw e; + } + } class UsbTransport { constructor() { this.device = null; this.iface = null; this.epIn = null; this.epOut = null; this.inSize = 64; this.reading = false; } async open(anyDevice) { - const options = anyDevice ? { filters: [{}] } : { filters: [{ vendorId: DEFAULT_VID }] }; - this.device = await navigator.usb.requestDevice(options); + this.device = await requestUsbDevice(anyDevice); await this.device.open(); if (this.device.configuration === null) await this.device.selectConfiguration(1); const config = this.device.configuration; @@ -1185,7 +1200,7 @@

Log return; } const names = info.modules.map((m) => m.name + " (#" + m.id + ")").join(", ") || "none"; - logLine("err", "This device does not advertise the CAN bridge module. Detected: " + names + + logLine("warn", "This device does not advertise the CAN bridge module. Detected: " + names + ". You may have selected the wrong device, or its firmware lacks CAN bridge support."); }