@mcesystems/usb-device-listener
v1.0.97
Published
Native cross-platform USB device listener for Windows and macOS
Downloads
2,827
Maintainers
Readme
USB Device Listener
A high-performance native Node.js addon for monitoring USB device connections and disconnections on Windows and macOS. Built with N-API for stability across Node.js versions.
Features
- ⚡ Real-time monitoring - Immediate notification of USB device events via native OS APIs
- 🎯 Device filtering - Monitor specific devices by VID/PID or hub location
- 📍 Physical port mapping - Map USB ports to logical port numbers for consistent device identification
- 🧵 Thread-safe - Runs in separate thread without blocking Node.js event loop
- 💪 Production-ready - Memory-safe, handles multiple simultaneous device changes
- 🔍 Device enumeration - List all currently connected devices
- 🖥️ Cross-platform - Supports both Windows and macOS
Installation
npm installRequirements
- Node.js: v18+ (ESM support required)
Windows
- OS: Windows 10/11
- Build tools: Visual Studio 2022 with C++ build tools
macOS
- OS: macOS 10.15 (Catalina) or later
- Build tools: Xcode Command Line Tools (
xcode-select --install)
Quick Start
Examples below use @mcesystems/tool-debug-g4 for formatted logs.
import usbListener from "usb-device-listener";
import {
logDataObject,
logErrorObject,
logHeader,
logNamespace,
setLogLevel
} from "@mcesystems/tool-debug-g4";
logNamespace("usb");
setLogLevel("debug");
// Define configuration
const config = {
logicalPortMap: {
// Windows format: "Port_#0005.Hub_#0002"
// macOS format: "Port_#14200000"
"Port_#0005.Hub_#0002": 1, // Map physical port to logical port 1
"Port_#0006.Hub_#0002": 2
},
targetDevices: [
{ vid: "04E8", pid: "6860" } // Only monitor Samsung devices
],
ignoredHubs: [],
listenOnlyHubs: []
};
function handleDeviceAdd(device) {
logDataObject("Device connected", {
locationInfo: device.locationInfo,
vid: device.vid.toString(16).toUpperCase().padStart(4, "0"),
pid: device.pid.toString(16).toUpperCase().padStart(4, "0"),
logicalPort: device.logicalPort ?? "<unmapped>"
});
}
function handleDeviceRemove(device) {
logDataObject("Device disconnected", {
locationInfo: device.locationInfo,
logicalPort: device.logicalPort ?? "<unmapped>"
});
}
// Register event handlers
usbListener.onDeviceAdd(handleDeviceAdd);
usbListener.onDeviceRemove(handleDeviceRemove);
// Start listening
try {
usbListener.startListening(config);
logHeader("Listening for USB events");
} catch (error) {
logErrorObject(error, "Failed to start");
}
// Graceful shutdown
process.on('SIGINT', () => {
logHeader("Stopping USB listener");
usbListener.stopListening();
process.exit(0);
});Example output:
usb:info ================================================================================
usb:info Listening for USB events
usb:info ================================================================================
usb:detail ================================================================================
usb:detail *Device connected* handleDeviceAdd
usb:detail ================================================================================
usb:detail
usb:detail # locationInfo: Port_#0005.Hub_#0002
usb:detail # vid: 04E8
usb:detail # pid: 6860
usb:detail # logicalPort: 1
usb:detail
usb:detail ================================================================================Application ID registry (multiple configs)
When multiple parts of the same process need separate USB device filters and callbacks (e.g. one app for Samsung devices, another for Arduino), use UsbDeviceListenerRegistry. You register an application ID with a config, get a scoped listener for that ID, and only receive events that match that app's config. One native listener is shared; the registry fans out events per app.
When to use:
- Multiple logical "apps" or modules in one process, each with its own
ListenerConfig - You want each app to call
onDeviceAdd/onDeviceRemoveand only see devices matching its filters - You want
listDevices()per app to return only devices that match that app's config
Example: See src/examples/example-registry.ts. Run with pnpm example:registry.
import { UsbDeviceListenerRegistry } from "@mcesystems/usb-device-listener";
const registry = new UsbDeviceListenerRegistry();
registry.register("app-samsung", { targetDevices: [{ vid: "04E8", pid: "6860" }] });
registry.register("app-arduino", { targetDevices: [{ vid: "2341", pid: "0043" }] });
const listenerSamsung = registry.getListener("app-samsung");
const listenerArduino = registry.getListener("app-arduino");
listenerSamsung.onDeviceAdd((device) => { /* only Samsung devices */ });
listenerArduino.onDeviceAdd((device) => { /* only Arduino devices */ });
listenerSamsung.startListening({ targetDevices: [{ vid: "04E8", pid: "6860" }] });
listenerArduino.startListening({ targetDevices: [{ vid: "2341", pid: "0043" }] });
// Later: unregister and stop native listener when last app unregisters
registry.unregister("app-samsung");
registry.unregister("app-arduino");Registry API:
register(appId, config)— Register or update config for an application ID.getListener(appId)— Return a listener (implementsUsbDeviceListenerI) scoped to that app; events andlistDevices()are filtered by the app's config.unregister(appId)— Remove the app and stop the native listener if it was the last one started.
API Reference
startListening(config)
Start monitoring USB device events.
Parameters:
config(Object): Configuration objectlogicalPortMap(Object, optional): Map physical locations to logical port numbers- Key: Location string (platform-specific format)
- Value: Logical port number (integer)
logicalPortMapByHub(Object, optional): Map physical port to logical port per hub (alternative to logicalPortMap)- Key: Hub identifier
"${vid}-${pid}"(hex, uppercase 4-char, e.g."1A2C-3B4D") - Value: Object mapping physical port number to logical port number (e.g.
{ 1: 4, 4: 2, 3: 3, 2: 1 }) - When both
logicalPortMapandlogicalPortMapByHubcould apply,logicalPortMapByHubtakes precedence when the device has hub data (portPath,parentHubVid,parentHubPid)
- Key: Hub identifier
targetDevices(Array, optional): Filter specific devices by VID/PID- Each element:
{ vid: string, pid: string }(hex strings, e.g., "04E8") - Empty array = monitor all devices
- Each element:
ignoredHubs(Array, optional): Hub location strings to ignorelistenOnlyHubs(Array, optional): Only monitor these hub locations
Throws:
TypeErrorif config is not an objectErrorif listener is already running
Example:
usbListener.startListening({
logicalPortMap: {
"Port_#0005.Hub_#0002": 1 // Windows
// "Port_#14200000": 1 // macOS
},
targetDevices: [], // Monitor all devices
ignoredHubs: ["Port_#0001.Hub_#0001"], // Ignore this hub
listenOnlyHubs: [] // No restriction
});stopListening()
Stop monitoring and clean up resources. Safe to call multiple times.
Example:
usbListener.stopListening();updateConfig(config)
Update the listener config at runtime. When listening, subsequent device events and listDevices() use the new config. When not listening, only listDevices() uses it until the next startListening(). Config is fully replaced (use getCurrentConfig() to merge).
Parameters:
config(Object): Same shape asstartListening(config)(logicalPortMap, targetDevices, ignoredDevices, listenOnlyDevices)
Throws:
TypeErrorif config is not an object
Example:
// Replace config entirely
usbListener.updateConfig({ logicalPortMap: newMap, targetDevices: [] });
// Merge with current config
usbListener.updateConfig({ ...usbListener.getCurrentConfig(), logicalPortMap: newMap });getCurrentConfig()
Return a copy of the current config. Mutating the returned object does not affect the listener's internal config. Use with updateConfig() to merge partial changes.
Returns: Shallow copy of the current config object (ListenerConfig)
Example:
const current = usbListener.getCurrentConfig();
usbListener.updateConfig({
...current,
targetDevices: [...(current.targetDevices ?? []), { vid: "04E8", pid: "6860" }]
});onDeviceAdd(callback)
Register callback for device connection events.
Parameters:
callback(Function): Called when device is connecteddeviceInfo(Object):deviceId(string): Platform-specific device instance IDvid(number): Vendor ID (decimal)pid(number): Product ID (decimal)locationInfo(string): Physical port location (platform-specific format)logicalPort(number|null): Mapped logical port or nullportPath(number[]|undefined): Port path from root to device (e.g. [2, 3, 1]); present when native layer provides it (e.g. Windows)parentHubVid(number|undefined): VID of hub the device is directly connected to (0 if on root)parentHubPid(number|undefined): PID of that hub
Example:
import { logDataObject } from "@mcesystems/tool-debug-g4";
usbListener.onDeviceAdd((device) => {
const vidHex = device.vid.toString(16).toUpperCase().padStart(4, "0");
const pidHex = device.pid.toString(16).toUpperCase().padStart(4, "0");
logDataObject("Device connected", {
device: `${vidHex}:${pidHex}`,
port: device.logicalPort ?? "<unmapped>"
});
});onDeviceRemove(callback)
Register callback for device disconnection events. Device info format same as onDeviceAdd.
Example:
import { logDataObject } from "@mcesystems/tool-debug-g4";
usbListener.onDeviceRemove((device) => {
logDataObject("Device disconnected", {
port: device.logicalPort ?? "<unmapped>"
});
});listDevices()
Get list of all currently connected USB devices.
Returns: Array of device objects (same format as callback parameter)
Example:
import { logDataObject } from "@mcesystems/tool-debug-g4";
const devices = usbListener.listDevices();
devices.forEach((device) => {
logDataObject("Device", {
locationInfo: device.locationInfo,
vid: device.vid.toString(16).toUpperCase().padStart(4, "0"),
pid: device.pid.toString(16).toUpperCase().padStart(4, "0")
});
});Platform-Specific Details
Physical Port Location
Each platform assigns unique location strings to USB ports:
Windows
- Format:
Port_#XXXX.Hub_#YYYY - Example:
Port_#0005.Hub_#0002 - Source: Windows Device Management API
macOS
- Format:
Port_#XXXXXXXX(hexadecimal location ID) - Example:
Port_#14200000 - Source: IOKit locationID property (encodes bus/port path)
Getting Location Strings
Use the included list-devices.js utility:
node list-devices.jsWindows output (when port path is available, Port path (tree) shows the chain, e.g. 2/3/1 = port 2, then 3, then 1):
Device 1:
Device ID: USB\VID_04E8&PID_6860\R58NC2971AJ
VID: 0x04E8
PID: 0x6860
Port path (tree): 2/3/1
Location Info (mapping key): Port_#0005.Hub_#0002
Device 2:
Device ID: USB\VID_27C6&PID_6594\UID0014C59F
VID: 0x27C6
PID: 0x6594
Port path (tree): 2/7
Location Info (mapping key): Port_#0007.Hub_#0002macOS output:
Device 1:
Device ID: USB\VID_04E8&PID_6860\Port_#14200000
VID: 0x04E8
PID: 0x6860
Location Info (mapping key): Port_#14200000
Device 2:
Device ID: USB\VID_05AC&PID_8262\Port_#14100000
VID: 0x05AC
PID: 0x8262
Location Info (mapping key): Port_#14100000Copy the "Location Info" values to use in your logicalPortMap. When available, use Port path (tree) and the parent hub (from list output or parentHubVid/parentHubPid) with logicalPortMapByHub.
Helper: formatPortPath(device) returns the port path as a string (e.g. "2/3/1") or falls back to locationInfo when port path is not available.
Device Filtering
By VID/PID:
targetDevices: [
{ vid: "2341", pid: "0043" }, // Arduino Uno
{ vid: "0483", pid: "5740" } // STM32
]By Hub:
listenOnlyHubs: ["Hub_#0002"] // Only monitor this hub (Windows)
// or
ignoredHubs: ["Hub_#0001"] // Ignore this hubPerformance & Scalability
Handling Many Devices
The listener is designed to handle multiple simultaneous device events efficiently:
✅ Thread-safe: Device cache protected by mutex
✅ Non-blocking: Runs in separate thread, doesn't block Node.js
✅ Efficient: Only processes filtered devices
✅ Memory-safe: Automatic cleanup on disconnect
Tested Scenarios
- Multiple rapid connect/disconnect cycles
- Simultaneous connection of 10+ devices
- Hub with many devices
- Long-running processes (hours/days)
Best Practices
Use device filtering when possible to reduce CPU usage:
targetDevices: [{ vid: "04E8", pid: "6860" }] // Better than monitoring allKeep callbacks fast - offload heavy processing:
onDeviceAdd((device) => { // Good: Quick database write db.logConnection(device); // Bad: Long synchronous operation // processLargeFile(device); // Use setTimeout or worker thread instead });Handle errors gracefully:
onDeviceAdd((device) => { try { processDevice(device); } catch (error) { console.error('Device processing failed:', error); } });Clean shutdown:
process.on('SIGINT', () => { usbListener.stopListening(); // Wait briefly for cleanup setTimeout(() => process.exit(0), 100); });
Architecture
Windows
┌─────────────────────────────────────────┐
│ Node.js Application │
│ ┌─────────────────────────────────┐ │
│ │ JavaScript API │ │
│ │ (index.js - documented) │ │
│ └────────────┬────────────────────┘ │
│ │ │
│ ┌────────────▼────────────────────┐ │
│ │ N-API Addon (addon.cc) │ │
│ │ - Converts JS ↔ C++ types │ │
│ │ - ThreadSafeFunction callbacks │ │
│ └────────────┬────────────────────┘ │
└───────────────┼─────────────────────────┘
│
┌───────────────▼─────────────────────────┐
│ USBListener (usb_listener_win.cc) │
│ ┌───────────────────────────────────┐ │
│ │ Listener Thread (MessageLoop) │ │
│ │ - Hidden message-only window │ │
│ │ - RegisterDeviceNotification │ │
│ │ - Receives WM_DEVICECHANGE │ │
│ └──────────┬────────────────────────┘ │
│ │ │
│ ┌──────────▼──────────────────────┐ │
│ │ Windows Device Management API │ │
│ │ - SetupDi* functions │ │
│ │ - CM_Get_DevNode_* │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────┘macOS
┌─────────────────────────────────────────┐
│ Node.js Application │
│ ┌─────────────────────────────────┐ │
│ │ JavaScript API │ │
│ │ (index.js - documented) │ │
│ └────────────┬────────────────────┘ │
│ │ │
│ ┌────────────▼────────────────────┐ │
│ │ N-API Addon (addon.cc) │ │
│ │ - Converts JS ↔ C++ types │ │
│ │ - ThreadSafeFunction callbacks │ │
│ └────────────┬────────────────────┘ │
└───────────────┼─────────────────────────┘
│
┌───────────────▼─────────────────────────┐
│ USBListener (usb_listener_mac.cc) │
│ ┌───────────────────────────────────┐ │
│ │ Listener Thread (CFRunLoop) │ │
│ │ - IONotificationPortRef │ │
│ │ - kIOFirstMatchNotification │ │
│ │ - kIOTerminatedNotification │ │
│ └──────────┬────────────────────────┘ │
│ │ │
│ ┌──────────▼──────────────────────┐ │
│ │ IOKit Framework │ │
│ │ - IOServiceMatching │ │
│ │ - IORegistryEntryCreateCFProperty│ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────┘Troubleshooting
Windows
Build errors:
- Ensure Visual Studio C++ build tools are installed
- Run from "x64 Native Tools Command Prompt"
macOS
Build errors:
- Install Xcode Command Line Tools:
xcode-select --install - Ensure you have the latest Xcode version
Permission issues:
- USB access doesn't require special permissions on macOS
- If using App Sandbox, ensure proper entitlements
General
No events firing:
- Check
targetDevicesfilter isn't too restrictive - Verify callbacks are registered before calling
startListening() - Use
listDevices()to confirm devices are visible
Incorrect location info:
- Location strings are generated by the OS
- Different USB controllers may use different formats
- Always use
listDevices()to get actual location strings
License
MIT
Contributing
Issues and pull requests welcome!
