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
140 changes: 140 additions & 0 deletions src/backend/editor/hardware/__tests__/serial-port-list.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { mergeSerialPortList, toCalloutPath } from '../serial-port-list'

const boardMap = (entries: Array<[string, string | undefined]>) => new Map<string, string | undefined>(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' },
])
})
})
})
79 changes: 68 additions & 11 deletions src/backend/editor/hardware/hardware-module.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -12,8 +14,11 @@
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<T> {
// success: boolean
// data?: T
Expand Down Expand Up @@ -94,22 +99,74 @@

// ++ ============================= Getters ================================ ++
async getAvailableSerialPorts(): Promise<SerialPort[]> {
// 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<Map<string, string | undefined>> {
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<Map<string, string | undefined>> {
const boardNamesByPath = new Map<string, string | undefined>()
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
}

/**
Expand Down Expand Up @@ -189,7 +246,7 @@
.map((pin) => pin.trim())
.filter(Boolean) ?? [],
},
...(boardData.debug ? { debug: boardData.debug } : {}),

Check warning on line 249 in src/backend/editor/hardware/hardware-module.ts

View workflow job for this annotation

GitHub Actions / lint / Lint Check

Unsafe assignment of an `any` value
})
})
}
Expand Down
82 changes: 82 additions & 0 deletions src/backend/editor/hardware/serial-port-list.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>): Map<string, string | undefined> {
const result = new Map<string, string | undefined>()
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<string, string | undefined>,
manufacturersByPath: Map<string, string | undefined>,
): 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<string>([...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,
}
})
}
Loading