From f58740767a7b78bd169e3ef266011312692ee23b Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 20 Jul 2026 11:04:48 -0400 Subject: [PATCH 1/4] fix(hardware): restore informative serial-port names (Arduino IDE parity) Serial ports have shown only the vendor/manufacturer string since 4.2.3 (the xml2st retirement, #843), collapsing every port of the same vendor to an identical label. The old xml2st path used pyserial's `description` (friendly name, distinct per port); the rewrite mapped `name` to `serialport`'s `manufacturer` field instead, which drops the per-port distinction. Mimic the Arduino IDE: the device path is always the primary, unique, cross-platform label, enriched with a descriptor in parentheses when available. `getAvailableSerialPorts()` now runs two best-effort scans in parallel and merges them by path: - arduino-cli `board list --format json` identifies the connected board from the installed core's VID/PID (boards.txt) -> "COM3 (Arduino Uno)". Reuses the module's existing binary path + --config-file, so it sees the same cores as compile/upload. - serialport supplies the reliable port set plus a manufacturer fallback -> "COM6 (com0com - serial port emulator)"; bare path when unknown. Each scan degrades independently (empty map on failure), so a missing binary / no cores / malformed JSON still yields plain serialport output. The merge/label rules live in a pure `mergeSerialPortList()` helper with unit tests. No shared surface, port interface, or {name,address} shape changed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/serial-port-list.test.ts | 68 ++++++++++++++++ .../editor/hardware/hardware-module.ts | 79 ++++++++++++++++--- .../editor/hardware/serial-port-list.ts | 44 +++++++++++ 3 files changed, 180 insertions(+), 11 deletions(-) create mode 100644 src/backend/editor/hardware/__tests__/serial-port-list.test.ts create mode 100644 src/backend/editor/hardware/serial-port-list.ts diff --git a/src/backend/editor/hardware/__tests__/serial-port-list.test.ts b/src/backend/editor/hardware/__tests__/serial-port-list.test.ts new file mode 100644 index 000000000..67e91f1b3 --- /dev/null +++ b/src/backend/editor/hardware/__tests__/serial-port-list.test.ts @@ -0,0 +1,68 @@ +import { mergeSerialPortList } from '../serial-port-list' + +const boardMap = (entries: Array<[string, string | undefined]>) => new Map(entries) + +describe('mergeSerialPortList', () => { + it('labels a port with the arduino-cli board name when identified', () => { + const boards = boardMap([['/dev/cu.usbmodem1', 'Arduino Uno']]) + const manufacturers = boardMap([['/dev/cu.usbmodem1', 'Arduino LLC']]) + + expect(mergeSerialPortList(boards, manufacturers)).toEqual([ + { name: '/dev/cu.usbmodem1 (Arduino Uno)', address: '/dev/cu.usbmodem1' }, + ]) + }) + + it('prefers the board name over the manufacturer when both are present', () => { + const boards = boardMap([['COM1', 'Opta']]) + const manufacturers = boardMap([['COM1', 'Arduino']]) + + expect(mergeSerialPortList(boards, manufacturers)[0].name).toBe('COM1 (Opta)') + }) + + it('falls back to the manufacturer when the board is detected but not identified', () => { + const boards = boardMap([['COM6', undefined]]) + const manufacturers = boardMap([['COM6', 'com0com - serial port emulator']]) + + expect(mergeSerialPortList(boards, manufacturers)).toEqual([ + { name: 'COM6 (com0com - serial port emulator)', address: 'COM6' }, + ]) + }) + + it('uses the bare path when neither board nor manufacturer is known', () => { + const boards = boardMap([]) + const manufacturers = boardMap([['/dev/ttyUSB0', undefined]]) + + expect(mergeSerialPortList(boards, manufacturers)).toEqual([ + { name: '/dev/ttyUSB0', address: '/dev/ttyUSB0' }, + ]) + }) + + it('treats an empty-string descriptor as absent', () => { + const boards = boardMap([['COM1', '']]) + const manufacturers = boardMap([['COM1', '']]) + + expect(mergeSerialPortList(boards, manufacturers)).toEqual([{ name: 'COM1', address: 'COM1' }]) + }) + + it('unions both scans, keeps serialport ordering, and dedupes by path', () => { + // COM3/COM4 come from serialport; arduino-cli enriches COM4 and adds COM9. + const manufacturers = boardMap([ + ['COM3', 'FTDI'], + ['COM4', undefined], + ]) + const boards = boardMap([ + ['COM4', 'Arduino Mega'], + ['COM9', 'Arduino Nano'], + ]) + + expect(mergeSerialPortList(boards, manufacturers)).toEqual([ + { name: 'COM3 (FTDI)', address: 'COM3' }, + { name: 'COM4 (Arduino Mega)', address: 'COM4' }, + { name: 'COM9 (Arduino Nano)', address: 'COM9' }, + ]) + }) + + it('returns an empty list when both scans are empty', () => { + expect(mergeSerialPortList(boardMap([]), boardMap([]))).toEqual([]) + }) +}) diff --git a/src/backend/editor/hardware/hardware-module.ts b/src/backend/editor/hardware/hardware-module.ts index c51b361d2..ea312170a 100644 --- a/src/backend/editor/hardware/hardware-module.ts +++ b/src/backend/editor/hardware/hardware-module.ts @@ -1,6 +1,8 @@ +import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { join, resolve as pathResolve, sep as pathSep } from 'node:path' +import { promisify } from 'node:util' import { app as electronApp } from 'electron' import { produce } from 'immer' @@ -12,8 +14,11 @@ import { PackageManagerModule } from '../package-manager' import { logger } from '../services/logger-service' import { assertPathContained } from '../utils/path-containment' import { orderBoardsByVppGroup } from './order-boards-by-vpp-group' +import { mergeSerialPortList } from './serial-port-list' import type { AvailableBoards, HalsFile, SerialPort } from './types' +const execFileAsync = promisify(execFile) + // interface MethodsResult { // success: boolean // data?: T @@ -94,22 +99,74 @@ class HardwareModule { // ++ ============================= Getters ================================ ++ async getAvailableSerialPorts(): Promise { - // Native `serialport` package replaces the legacy `xml2st - // --list-ports` subprocess (xml2st was retired when the JSON - // transpiler landed in-process; see - // `editor-compiler-platform-port.transpileToSt`). `NodeSerialPort.list()` - // returns each port's `path` plus optional vendor metadata; map - // it onto the `{name, address}` shape the renderer expects. + // Two independent, best-effort scans merged by device path. The path is + // always the primary, unique label (mirrors the Arduino IDE); the + // parenthetical descriptor is the arduino-cli-identified board name when + // known, falling back to `serialport`'s manufacturer/vendor string. See + // `mergeSerialPortList` for the labelling rules. Running both scans is + // cheap here: the list is static after build and only re-scanned on an + // explicit user refresh. + const [boardNamesByPath, manufacturersByPath] = await Promise.all([ + this.#identifyBoardsByPath(), + this.#listSerialPortManufacturers(), + ]) + return mergeSerialPortList(boardNamesByPath, manufacturersByPath) + } + + /** + * `serialport` enumeration → `path → manufacturer`. This is the reliable, + * instant, cross-platform source for the *set* of ports; arduino-cli only + * enriches it. Best-effort: any failure yields an empty map (never throws) + * so arduino-cli-discovered ports still come through. + */ + async #listSerialPortManufacturers(): Promise> { try { const ports = await NodeSerialPort.list() - return ports.map((port) => ({ - name: port.manufacturer ?? port.path, - address: port.path, - })) + return new Map(ports.map((port) => [port.path, port.manufacturer])) } catch (error: unknown) { logger.error(`Failed to enumerate serial ports: ${String(error)}`) - return [] + return new Map() + } + } + + /** + * `arduino-cli board list --format json` → `path → board name`. arduino-cli + * matches each port's USB VID/PID against the installed cores' `boards.txt` + * — the exact identification the Arduino IDE surfaces (e.g. `Arduino Uno`, + * `Opta`). A detected-but-unmatched port maps to `undefined` (it will fall + * back to the manufacturer descriptor). Best-effort: a missing binary, no + * installed cores, a spawn error, or malformed JSON all yield an empty map + * so plain `serialport` enumeration still works. Reuses the same binary and + * `--config-file` as compile/upload, so it sees the same installed cores. + */ + async #identifyBoardsByPath(): Promise> { + const boardNamesByPath = new Map() + try { + let binaryPath = this.arduinoCliBinaryPath + if (HardwareModule.HOST_PLATFORM === 'win32') binaryPath += '.exe' + + const { stdout } = await execFileAsync( + binaryPath, + ['board', 'list', '--format', 'json', ...this.arduinoCliBaseParameters], + { timeout: 15_000, maxBuffer: 16 * 1024 * 1024 }, + ) + + const parsed = JSON.parse(stdout) as { + detected_ports?: Array<{ + matching_boards?: Array<{ name?: string }> + port?: { address?: string } + }> + } + + for (const detected of parsed.detected_ports ?? []) { + const address = detected.port?.address + if (!address) continue + boardNamesByPath.set(address, detected.matching_boards?.[0]?.name) + } + } catch (error: unknown) { + logger.warn(`arduino-cli board list failed; serial ports will show without board names: ${String(error)}`) } + return boardNamesByPath } /** diff --git a/src/backend/editor/hardware/serial-port-list.ts b/src/backend/editor/hardware/serial-port-list.ts new file mode 100644 index 000000000..38db3dcdb --- /dev/null +++ b/src/backend/editor/hardware/serial-port-list.ts @@ -0,0 +1,44 @@ +import type { SerialPort } from './types' + +/** + * Merge the two independent serial-port scans into the `{ name, address }` + * shape the renderer's communication-port dropdown expects. + * + * The device path (`address`) is ALWAYS the primary, guaranteed-unique + * label — this mirrors the Arduino IDE, which keys every entry on the + * port path (`COM3`, `/dev/ttyACM0`, `/dev/cu.usbmodem…`) and never + * collapses ports to a shared vendor string. A descriptor is appended + * in parentheses when available, in order of usefulness: + * + * 1. the arduino-cli-identified board name (from the connected core's + * `boards.txt` VID/PID — e.g. `COM3 (Arduino Uno)`), else + * 2. the OS manufacturer/vendor string reported by `serialport` + * (e.g. `COM6 (com0com - serial port emulator)`), else + * 3. nothing — just the bare path. + * + * `serialport` provides the reliable, instant set of ports and is listed + * first so its ordering is preserved; arduino-cli only enriches those + * entries and may contribute additional ports it discovered on its own. + * Ports present in both scans are deduped by path. + * + * @param boardNamesByPath path → arduino-cli board name (`undefined` when + * the port was detected but no board matched) + * @param manufacturersByPath path → `serialport` manufacturer/vendor string + */ +export function mergeSerialPortList( + boardNamesByPath: Map, + manufacturersByPath: Map, +): SerialPort[] { + // serialport ordering first, then any arduino-cli-only ports. A Set keeps + // insertion order and dedupes ports seen by both scans. + const addresses = new Set([...manufacturersByPath.keys(), ...boardNamesByPath.keys()]) + + return [...addresses].map((address) => { + // `||` (not `??`) so an empty-string descriptor falls through too. + const descriptor = boardNamesByPath.get(address) || manufacturersByPath.get(address) + return { + name: descriptor ? `${address} (${descriptor})` : address, + address, + } + }) +} From ce53b00e0154f8ac6d10584ff6e92aeb5040f6dd Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 20 Jul 2026 11:10:41 -0400 Subject: [PATCH 2/4] fix(hardware): dedupe macOS tty./cu. serial nodes into one cu. entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS exposes each serial device twice — a call-in node (/dev/tty.*) and a call-out node (/dev/cu.*). serialport reports the tty.* node (with the manufacturer) while arduino-cli reports the cu.* node (with the board id), so the two scans keyed on different paths and the same device appeared twice in the dropdown (e.g. "/dev/tty.usbmodem11301 (Arduino)" AND "/dev/cu.usbmodem11301 (Opta)"). Group the two scans on the shared suffix after the tty./cu. prefix and emit a single entry per device, displaying the call-out (cu.*) node — the one used for talking to a device and the one the Arduino IDE selects. Board name still wins over the manufacturer descriptor. Linux (/dev/ttyUSB0) and Windows (COM3) paths don't match the dotted pattern and are never merged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/serial-port-list.test.ts | 63 ++++++++++++++++ .../editor/hardware/serial-port-list.ts | 72 ++++++++++++++++--- 2 files changed, 125 insertions(+), 10 deletions(-) diff --git a/src/backend/editor/hardware/__tests__/serial-port-list.test.ts b/src/backend/editor/hardware/__tests__/serial-port-list.test.ts index 67e91f1b3..ba6fa122c 100644 --- a/src/backend/editor/hardware/__tests__/serial-port-list.test.ts +++ b/src/backend/editor/hardware/__tests__/serial-port-list.test.ts @@ -65,4 +65,67 @@ describe('mergeSerialPortList', () => { it('returns an empty list when both scans are empty', () => { expect(mergeSerialPortList(boardMap([]), boardMap([]))).toEqual([]) }) + + describe('macOS tty./cu. reconciliation', () => { + it('collapses the tty. (serialport) and cu. (arduino-cli) nodes of one device, preferring cu. + board name', () => { + const manufacturers = boardMap([['/dev/tty.usbmodem11301', 'Arduino']]) + const boards = boardMap([['/dev/cu.usbmodem11301', 'Opta']]) + + expect(mergeSerialPortList(boards, manufacturers)).toEqual([ + { name: '/dev/cu.usbmodem11301 (Opta)', address: '/dev/cu.usbmodem11301' }, + ]) + }) + + it('keeps the cu. node when only arduino-cli reported the device', () => { + const boards = boardMap([['/dev/cu.usbmodem11301', 'Opta']]) + + expect(mergeSerialPortList(boards, boardMap([]))).toEqual([ + { name: '/dev/cu.usbmodem11301 (Opta)', address: '/dev/cu.usbmodem11301' }, + ]) + }) + + it('falls back to the tty. node when no cu. node was reported', () => { + const manufacturers = boardMap([['/dev/tty.usbserial-99', 'FTDI']]) + + expect(mergeSerialPortList(boardMap([]), manufacturers)).toEqual([ + { name: '/dev/tty.usbserial-99 (FTDI)', address: '/dev/tty.usbserial-99' }, + ]) + }) + + it('reproduces the reported duplicate-ports scenario as a single deduped, cu.-based list', () => { + // serialport reports tty.* with manufacturers; arduino-cli reports cu.* with board id. + const manufacturers = boardMap([ + ['/dev/tty.debug-console', undefined], + ['/dev/tty.Bluetooth-Incoming-Port', undefined], + ['/dev/tty.usbserial-1140', 'Prolific Technology Inc.'], + ['/dev/tty.usbmodem11301', 'Arduino'], + ]) + const boards = boardMap([ + ['/dev/cu.debug-console', undefined], + ['/dev/cu.Bluetooth-Incoming-Port', undefined], + ['/dev/cu.usbserial-1140', undefined], + ['/dev/cu.usbmodem11301', 'Opta'], + ]) + + expect(mergeSerialPortList(boards, manufacturers)).toEqual([ + { name: '/dev/cu.debug-console', address: '/dev/cu.debug-console' }, + { name: '/dev/cu.Bluetooth-Incoming-Port', address: '/dev/cu.Bluetooth-Incoming-Port' }, + { name: '/dev/cu.usbserial-1140 (Prolific Technology Inc.)', address: '/dev/cu.usbserial-1140' }, + { name: '/dev/cu.usbmodem11301 (Opta)', address: '/dev/cu.usbmodem11301' }, + ]) + }) + + it('does not merge Linux tty paths (no dotted tty./cu. prefix)', () => { + const manufacturers = boardMap([ + ['/dev/ttyUSB0', 'FTDI'], + ['/dev/ttyACM0', undefined], + ]) + const boards = boardMap([['/dev/ttyACM0', 'Arduino Uno']]) + + expect(mergeSerialPortList(boards, manufacturers)).toEqual([ + { name: '/dev/ttyUSB0 (FTDI)', address: '/dev/ttyUSB0' }, + { name: '/dev/ttyACM0 (Arduino Uno)', address: '/dev/ttyACM0' }, + ]) + }) + }) }) diff --git a/src/backend/editor/hardware/serial-port-list.ts b/src/backend/editor/hardware/serial-port-list.ts index 38db3dcdb..808f4d456 100644 --- a/src/backend/editor/hardware/serial-port-list.ts +++ b/src/backend/editor/hardware/serial-port-list.ts @@ -1,5 +1,34 @@ import type { SerialPort } from './types' +/** + * macOS exposes every serial device twice: a call-in node (`/dev/tty.*`) + * and a call-out node (`/dev/cu.*`). Our two scans disagree on which they + * report — `serialport` lists the `tty.*` node, arduino-cli lists the + * `cu.*` node — so without reconciliation the same device shows up twice. + * + * Both nodes share the suffix after the prefix (`tty.usbmodem11301` and + * `cu.usbmodem11301` → `usbmodem11301`), so we group on that and emit a + * single entry per device, displaying the call-out (`cu.*`) node: that is + * the one used for talking to a device (non-blocking, no carrier-detect + * wait) and the one the Arduino IDE selects. + * + * Linux (`/dev/ttyUSB0`, `/dev/ttyACM0`) and Windows (`COM3`) paths don't + * match the `tty.`/`cu.` (dotted) pattern, so they key on their full path + * and are never merged. + */ +const MACOS_SERIAL_NODE = /^\/dev\/(?:tty|cu)\.(.+)$/ + +/** Canonical per-device key: the shared suffix on macOS, else the path itself. */ +function deviceKey(address: string): string { + const match = MACOS_SERIAL_NODE.exec(address) + return match ? match[1] : address +} + +/** Prefer the macOS call-out (`/dev/cu.*`) node when a device has several. */ +function preferCallout(addresses: string[]): string { + return addresses.find((address) => address.startsWith('/dev/cu.')) ?? addresses[0] +} + /** * Merge the two independent serial-port scans into the `{ name, address }` * shape the renderer's communication-port dropdown expects. @@ -7,7 +36,7 @@ import type { SerialPort } from './types' * The device path (`address`) is ALWAYS the primary, guaranteed-unique * label — this mirrors the Arduino IDE, which keys every entry on the * port path (`COM3`, `/dev/ttyACM0`, `/dev/cu.usbmodem…`) and never - * collapses ports to a shared vendor string. A descriptor is appended + * collapses ports to a shared vendor string. A descriptor is appended * in parentheses when available, in order of usefulness: * * 1. the arduino-cli-identified board name (from the connected core's @@ -16,10 +45,11 @@ import type { SerialPort } from './types' * (e.g. `COM6 (com0com - serial port emulator)`), else * 3. nothing — just the bare path. * - * `serialport` provides the reliable, instant set of ports and is listed - * first so its ordering is preserved; arduino-cli only enriches those + * `serialport` provides the reliable, instant set of ports and is folded + * in first so its ordering is preserved; arduino-cli only enriches those * entries and may contribute additional ports it discovered on its own. - * Ports present in both scans are deduped by path. + * Ports that resolve to the same device (by path, or by macOS `tty.`/`cu.` + * pairing) are collapsed to a single entry. * * @param boardNamesByPath path → arduino-cli board name (`undefined` when * the port was detected but no board matched) @@ -29,13 +59,35 @@ export function mergeSerialPortList( boardNamesByPath: Map, manufacturersByPath: Map, ): SerialPort[] { - // serialport ordering first, then any arduino-cli-only ports. A Set keeps - // insertion order and dedupes ports seen by both scans. - const addresses = new Set([...manufacturersByPath.keys(), ...boardNamesByPath.keys()]) + type DeviceGroup = { addresses: string[]; boardName?: string; manufacturer?: string } + const groups = new Map() + + const groupFor = (address: string): DeviceGroup => { + const key = deviceKey(address) + let group = groups.get(key) + if (!group) { + group = { addresses: [] } + groups.set(key, group) + } + if (!group.addresses.includes(address)) group.addresses.push(address) + return group + } + + // serialport first so its ordering drives the list, then arduino-cli. + for (const [address, manufacturer] of manufacturersByPath) { + const group = groupFor(address) + // `if (manufacturer)` (not `?? `) so an empty-string descriptor is ignored. + if (manufacturer && !group.manufacturer) group.manufacturer = manufacturer + } + for (const [address, boardName] of boardNamesByPath) { + const group = groupFor(address) + if (boardName && !group.boardName) group.boardName = boardName + } - return [...addresses].map((address) => { - // `||` (not `??`) so an empty-string descriptor falls through too. - const descriptor = boardNamesByPath.get(address) || manufacturersByPath.get(address) + return [...groups.values()].map((group) => { + const address = preferCallout(group.addresses) + // Board name (more specific) wins over the manufacturer/vendor string. + const descriptor = group.boardName || group.manufacturer return { name: descriptor ? `${address} (${descriptor})` : address, address, From 83912abb08f12618ab22929d60280ce53fad7cbc Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 20 Jul 2026 11:17:31 -0400 Subject: [PATCH 3/4] refactor(hardware): canonicalize serial paths to macOS cu. at the source Replace the fragile tty./cu. cross-node grouping with a single canonicalization: `toCalloutPath` rewrites `/dev/tty.` -> `/dev/cu.` before merging. serialport's native macOS binding hardcodes the dial-in (tty.*) node while arduino-cli reports the call-out (cu.*) node; rewriting tty. -> cu. at the source makes both scans agree on one path, so the merge collapses back to a plain union deduped by path. The call-out (cu.*) node is the one callers must use to talk to a device and the one the Arduino IDE selects; IOKit always publishes both nodes for a serial service, so the rewrite is guaranteed to resolve to a real device. node-serialport itself can't be configured to emit cu.* (the path is hardcoded in the compiled binding, `darwin_list.cpp` kIODialinDeviceKey; serialport#1729), so canonicalizing in JS is the build-free equivalent. Linux (/dev/ttyUSB0) and Windows (COM3) paths don't match the dotted pattern and pass through untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/serial-port-list.test.ts | 37 +++++--- .../editor/hardware/serial-port-list.ts | 86 ++++++++----------- 2 files changed, 60 insertions(+), 63 deletions(-) diff --git a/src/backend/editor/hardware/__tests__/serial-port-list.test.ts b/src/backend/editor/hardware/__tests__/serial-port-list.test.ts index ba6fa122c..4fe2bea17 100644 --- a/src/backend/editor/hardware/__tests__/serial-port-list.test.ts +++ b/src/backend/editor/hardware/__tests__/serial-port-list.test.ts @@ -1,7 +1,26 @@ -import { mergeSerialPortList } from '../serial-port-list' +import { mergeSerialPortList, toCalloutPath } from '../serial-port-list' const boardMap = (entries: Array<[string, string | undefined]>) => new Map(entries) +describe('toCalloutPath', () => { + it('rewrites a macOS dial-in (tty.) node to its call-out (cu.) node', () => { + expect(toCalloutPath('/dev/tty.usbmodem11301')).toBe('/dev/cu.usbmodem11301') + }) + + it('leaves an already-call-out (cu.) path unchanged', () => { + expect(toCalloutPath('/dev/cu.usbmodem11301')).toBe('/dev/cu.usbmodem11301') + }) + + it('leaves Linux paths unchanged (no dotted tty. prefix)', () => { + expect(toCalloutPath('/dev/ttyUSB0')).toBe('/dev/ttyUSB0') + expect(toCalloutPath('/dev/ttyACM0')).toBe('/dev/ttyACM0') + }) + + it('leaves Windows COM paths unchanged', () => { + expect(toCalloutPath('COM3')).toBe('COM3') + }) +}) + describe('mergeSerialPortList', () => { it('labels a port with the arduino-cli board name when identified', () => { const boards = boardMap([['/dev/cu.usbmodem1', 'Arduino Uno']]) @@ -66,8 +85,8 @@ describe('mergeSerialPortList', () => { expect(mergeSerialPortList(boardMap([]), boardMap([]))).toEqual([]) }) - describe('macOS tty./cu. reconciliation', () => { - it('collapses the tty. (serialport) and cu. (arduino-cli) nodes of one device, preferring cu. + board name', () => { + describe('macOS tty./cu. canonicalization', () => { + it('collapses the tty. (serialport) and cu. (arduino-cli) nodes of one device into a single cu. entry', () => { const manufacturers = boardMap([['/dev/tty.usbmodem11301', 'Arduino']]) const boards = boardMap([['/dev/cu.usbmodem11301', 'Opta']]) @@ -76,19 +95,11 @@ describe('mergeSerialPortList', () => { ]) }) - it('keeps the cu. node when only arduino-cli reported the device', () => { - const boards = boardMap([['/dev/cu.usbmodem11301', 'Opta']]) - - expect(mergeSerialPortList(boards, boardMap([]))).toEqual([ - { name: '/dev/cu.usbmodem11301 (Opta)', address: '/dev/cu.usbmodem11301' }, - ]) - }) - - it('falls back to the tty. node when no cu. node was reported', () => { + it('canonicalizes a tty.-only device (from serialport) to its cu. node', () => { const manufacturers = boardMap([['/dev/tty.usbserial-99', 'FTDI']]) expect(mergeSerialPortList(boardMap([]), manufacturers)).toEqual([ - { name: '/dev/tty.usbserial-99 (FTDI)', address: '/dev/tty.usbserial-99' }, + { name: '/dev/cu.usbserial-99 (FTDI)', address: '/dev/cu.usbserial-99' }, ]) }) diff --git a/src/backend/editor/hardware/serial-port-list.ts b/src/backend/editor/hardware/serial-port-list.ts index 808f4d456..3a925a310 100644 --- a/src/backend/editor/hardware/serial-port-list.ts +++ b/src/backend/editor/hardware/serial-port-list.ts @@ -1,32 +1,36 @@ import type { SerialPort } from './types' /** - * macOS exposes every serial device twice: a call-in node (`/dev/tty.*`) - * and a call-out node (`/dev/cu.*`). Our two scans disagree on which they - * report — `serialport` lists the `tty.*` node, arduino-cli lists the - * `cu.*` node — so without reconciliation the same device shows up twice. + * Canonicalize a serial-port path to the macOS call-out (`/dev/cu.*`) node. * - * Both nodes share the suffix after the prefix (`tty.usbmodem11301` and - * `cu.usbmodem11301` → `usbmodem11301`), so we group on that and emit a - * single entry per device, displaying the call-out (`cu.*`) node: that is - * the one used for talking to a device (non-blocking, no carrier-detect - * wait) and the one the Arduino IDE selects. + * macOS exposes each serial device as a paired dial-in node (`/dev/tty.*`) + * and call-out node (`/dev/cu.*`) that differ only by that prefix. + * `serialport`'s native binding hardcodes the dial-in (`tty.*`) name + * (`@serialport/bindings-cpp` `darwin_list.cpp` reads `kIODialinDeviceKey`), + * but callers must use the call-out (`cu.*`) node to actually talk to a + * device — and that is the name arduino-cli and the Arduino IDE report. So + * we rewrite `tty.` → `cu.` at the source: both scans then agree on one path + * and no cross-node reconciliation is needed. IOKit always publishes both + * nodes for a serial service, so the rewritten path is guaranteed to exist. * - * Linux (`/dev/ttyUSB0`, `/dev/ttyACM0`) and Windows (`COM3`) paths don't - * match the `tty.`/`cu.` (dotted) pattern, so they key on their full path - * and are never merged. + * The pattern is macOS-specific (dotted prefix): Linux (`/dev/ttyUSB0`, + * `/dev/ttyACM0`) and Windows (`COM3`) paths don't match and pass through + * unchanged. Already-`cu.*` paths are left as-is. */ -const MACOS_SERIAL_NODE = /^\/dev\/(?:tty|cu)\.(.+)$/ - -/** Canonical per-device key: the shared suffix on macOS, else the path itself. */ -function deviceKey(address: string): string { - const match = MACOS_SERIAL_NODE.exec(address) - return match ? match[1] : address +export function toCalloutPath(address: string): string { + return address.replace(/^\/dev\/tty\./, '/dev/cu.') } -/** Prefer the macOS call-out (`/dev/cu.*`) node when a device has several. */ -function preferCallout(addresses: string[]): string { - return addresses.find((address) => address.startsWith('/dev/cu.')) ?? addresses[0] +/** Re-key a scan's map onto canonical call-out paths, keeping the first defined descriptor per device. */ +function toCalloutMap(byPath: Map): Map { + const result = new Map() + for (const [address, descriptor] of byPath) { + const key = toCalloutPath(address) + // `existing || descriptor` keeps the first non-empty descriptor seen for + // a device (e.g. if it somehow surfaced under both nodes). + result.set(key, result.get(key) || descriptor) + } + return result } /** @@ -45,11 +49,12 @@ function preferCallout(addresses: string[]): string { * (e.g. `COM6 (com0com - serial port emulator)`), else * 3. nothing — just the bare path. * + * Both scans are first canonicalized to the macOS call-out node + * (`toCalloutPath`), so a device is keyed identically regardless of which + * scan reported it; the merge is then a plain union deduped by path. * `serialport` provides the reliable, instant set of ports and is folded * in first so its ordering is preserved; arduino-cli only enriches those * entries and may contribute additional ports it discovered on its own. - * Ports that resolve to the same device (by path, or by macOS `tty.`/`cu.` - * pairing) are collapsed to a single entry. * * @param boardNamesByPath path → arduino-cli board name (`undefined` when * the port was detected but no board matched) @@ -59,35 +64,16 @@ export function mergeSerialPortList( boardNamesByPath: Map, manufacturersByPath: Map, ): SerialPort[] { - type DeviceGroup = { addresses: string[]; boardName?: string; manufacturer?: string } - const groups = new Map() + const boardNames = toCalloutMap(boardNamesByPath) + const manufacturers = toCalloutMap(manufacturersByPath) - const groupFor = (address: string): DeviceGroup => { - const key = deviceKey(address) - let group = groups.get(key) - if (!group) { - group = { addresses: [] } - groups.set(key, group) - } - if (!group.addresses.includes(address)) group.addresses.push(address) - return group - } - - // serialport first so its ordering drives the list, then arduino-cli. - for (const [address, manufacturer] of manufacturersByPath) { - const group = groupFor(address) - // `if (manufacturer)` (not `?? `) so an empty-string descriptor is ignored. - if (manufacturer && !group.manufacturer) group.manufacturer = manufacturer - } - for (const [address, boardName] of boardNamesByPath) { - const group = groupFor(address) - if (boardName && !group.boardName) group.boardName = boardName - } + // serialport ordering first, then any arduino-cli-only ports. A Set keeps + // insertion order and dedupes ports seen by both scans. + const addresses = new Set([...manufacturers.keys(), ...boardNames.keys()]) - return [...groups.values()].map((group) => { - const address = preferCallout(group.addresses) - // Board name (more specific) wins over the manufacturer/vendor string. - const descriptor = group.boardName || group.manufacturer + return [...addresses].map((address) => { + // Board name (more specific) wins; `||` so an empty descriptor falls through. + const descriptor = boardNames.get(address) || manufacturers.get(address) return { name: descriptor ? `${address} (${descriptor})` : address, address, From 2fe1f20421a666cbbefd90d14cd7e1f843261f8d Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 20 Jul 2026 11:21:22 -0400 Subject: [PATCH 4/4] style(hardware): prettier line-wrap in serial-port-list test Format Check (Prettier) collapsed a single-object toEqual([...]) that fits within the 120-col width onto one line. No behavioural change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/hardware/__tests__/serial-port-list.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/backend/editor/hardware/__tests__/serial-port-list.test.ts b/src/backend/editor/hardware/__tests__/serial-port-list.test.ts index 4fe2bea17..be8c99782 100644 --- a/src/backend/editor/hardware/__tests__/serial-port-list.test.ts +++ b/src/backend/editor/hardware/__tests__/serial-port-list.test.ts @@ -51,9 +51,7 @@ describe('mergeSerialPortList', () => { const boards = boardMap([]) const manufacturers = boardMap([['/dev/ttyUSB0', undefined]]) - expect(mergeSerialPortList(boards, manufacturers)).toEqual([ - { name: '/dev/ttyUSB0', address: '/dev/ttyUSB0' }, - ]) + expect(mergeSerialPortList(boards, manufacturers)).toEqual([{ name: '/dev/ttyUSB0', address: '/dev/ttyUSB0' }]) }) it('treats an empty-string descriptor as absent', () => {