From 5a7b286f41ccf2fef84b561db9cb369e125c194e Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:30:42 -0700 Subject: [PATCH] Prompt for duplicate serial devices --- .../src/core/connections/transports.test.ts | 51 +++++++++++++++++++ apps/web/src/core/connections/transports.ts | 13 ++++- 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/core/connections/transports.test.ts diff --git a/apps/web/src/core/connections/transports.test.ts b/apps/web/src/core/connections/transports.test.ts new file mode 100644 index 000000000..f78efb229 --- /dev/null +++ b/apps/web/src/core/connections/transports.test.ts @@ -0,0 +1,51 @@ +import { TransportWebSerial } from "@meshtastic/transport-web-serial"; +import { Result } from "better-result"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { openTransport } from "./transports.ts"; + +vi.mock("@meshtastic/transport-web-serial", () => ({ + SerialConnectError: class SerialConnectError extends Error {}, + TransportWebSerial: { createFromPort: vi.fn() }, +})); + +describe("openTransport serial selection", () => { + const info = { usbVendorId: 0x303a, usbProductId: 0x1001 }; + const portA = { getInfo: () => info } as SerialPort; + const portB = { getInfo: () => info } as SerialPort; + const selectedPort = { getInfo: () => info } as SerialPort; + const requestPort = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + requestPort.mockResolvedValue(selectedPort); + Object.defineProperty(navigator, "serial", { + configurable: true, + value: { + getPorts: vi.fn().mockResolvedValue([portA, portB]), + requestPort, + }, + }); + vi.mocked(TransportWebSerial.createFromPort).mockResolvedValue( + Result.ok({} as never), + ); + }); + + it("prompts when multiple permitted ports share the saved VID/PID", async () => { + await openTransport( + { + id: 1, + name: "Serial: 303a:1001", + type: "serial", + status: "disconnected", + usbVendorId: info.usbVendorId, + usbProductId: info.usbProductId, + }, + { allowPrompt: true }, + ); + + expect(requestPort).toHaveBeenCalledOnce(); + expect(TransportWebSerial.createFromPort).toHaveBeenCalledWith( + selectedPort, + ); + }); +}); diff --git a/apps/web/src/core/connections/transports.ts b/apps/web/src/core/connections/transports.ts index 9dfc81594..47f8147e1 100644 --- a/apps/web/src/core/connections/transports.ts +++ b/apps/web/src/core/connections/transports.ts @@ -160,7 +160,7 @@ async function openSerial( const ports = await serial.getPorts(); log.debug("openSerial: getPorts", { count: ports.length }); if (ports && conn.usbVendorId && conn.usbProductId) { - port = ports.find((p: SerialPort) => { + const matchingPorts = ports.filter((p: SerialPort) => { const info = ( p as SerialPort & { @@ -172,6 +172,17 @@ async function openSerial( info.usbProductId === conn.usbProductId ); }); + if (matchingPorts.length === 1) { + port = matchingPorts[0]; + } else if (matchingPorts.length > 1) { + log.info( + "openSerial: multiple permitted ports share VID/PID; user selection required", + { count: matchingPorts.length }, + ); + if (opts.allowPrompt) { + port = await serial.requestPort({}); + } + } } } if (!port && opts.allowPrompt) {