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..be8c99782 --- /dev/null +++ b/src/backend/editor/hardware/__tests__/serial-port-list.test.ts @@ -0,0 +1,140 @@ +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']]) + 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([]) + }) + + 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']]) + + expect(mergeSerialPortList(boards, manufacturers)).toEqual([ + { name: '/dev/cu.usbmodem11301 (Opta)', address: '/dev/cu.usbmodem11301' }, + ]) + }) + + 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/cu.usbserial-99 (FTDI)', address: '/dev/cu.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/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..3a925a310 --- /dev/null +++ b/src/backend/editor/hardware/serial-port-list.ts @@ -0,0 +1,82 @@ +import type { SerialPort } from './types' + +/** + * Canonicalize a serial-port path to the macOS call-out (`/dev/cu.*`) node. + * + * 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. + * + * 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. + */ +export function toCalloutPath(address: string): string { + return address.replace(/^\/dev\/tty\./, '/dev/cu.') +} + +/** 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 +} + +/** + * 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. + * + * 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. + * + * @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[] { + const boardNames = toCalloutMap(boardNamesByPath) + const manufacturers = toCalloutMap(manufacturersByPath) + + // 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 [...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, + } + }) +}