@mcesystems/usb-device-listener 1.0.76 → 1.0.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -125,6 +125,10 @@ Start monitoring USB device events.
125
125
  - `logicalPortMap` (Object, optional): Map physical locations to logical port numbers
126
126
  - Key: Location string (platform-specific format)
127
127
  - Value: Logical port number (integer)
128
+ - `logicalPortMapByHub` (Object, optional): Map physical port to logical port per hub (alternative to logicalPortMap)
129
+ - Key: Hub identifier `"${vid}-${pid}"` (hex, uppercase 4-char, e.g. `"1A2C-3B4D"`)
130
+ - Value: Object mapping physical port number to logical port number (e.g. `{ 1: 4, 4: 2, 3: 3, 2: 1 }`)
131
+ - When both `logicalPortMap` and `logicalPortMapByHub` could apply, `logicalPortMapByHub` takes precedence when the device has hub data (`portPath`, `parentHubVid`, `parentHubPid`)
128
132
  - `targetDevices` (Array, optional): Filter specific devices by VID/PID
129
133
  - Each element: `{ vid: string, pid: string }` (hex strings, e.g., "04E8")
130
134
  - Empty array = monitor all devices
@@ -203,6 +207,9 @@ Register callback for device connection events.
203
207
  - `pid` (number): Product ID (decimal)
204
208
  - `locationInfo` (string): Physical port location (platform-specific format)
205
209
  - `logicalPort` (number|null): Mapped logical port or null
210
+ - `portPath` (number[]|undefined): Port path from root to device (e.g. [2, 3, 1]); present when native layer provides it (e.g. Windows)
211
+ - `parentHubVid` (number|undefined): VID of hub the device is directly connected to (0 if on root)
212
+ - `parentHubPid` (number|undefined): PID of that hub
206
213
 
207
214
  **Example:**
208
215
  ```javascript
@@ -277,18 +284,20 @@ Use the included `list-devices.js` utility:
277
284
  node list-devices.js
278
285
  ```
279
286
 
280
- **Windows output:**
287
+ **Windows output** (when port path is available, `Port path (tree)` shows the chain, e.g. 2/3/1 = port 2, then 3, then 1):
281
288
  ```
282
289
  Device 1:
283
290
  Device ID: USB\VID_04E8&PID_6860\R58NC2971AJ
284
291
  VID: 0x04E8
285
292
  PID: 0x6860
293
+ Port path (tree): 2/3/1
286
294
  Location Info (mapping key): Port_#0005.Hub_#0002
287
295
 
288
296
  Device 2:
289
297
  Device ID: USB\VID_27C6&PID_6594\UID0014C59F
290
298
  VID: 0x27C6
291
299
  PID: 0x6594
300
+ Port path (tree): 2/7
292
301
  Location Info (mapping key): Port_#0007.Hub_#0002
293
302
  ```
294
303
 
@@ -307,7 +316,9 @@ Device 2:
307
316
  Location Info (mapping key): Port_#14100000
308
317
  ```
309
318
 
310
- Copy the "Location Info" values to use in your `logicalPortMap`.
319
+ Copy 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`.
320
+
321
+ **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.
311
322
 
312
323
  ### Device Filtering
313
324
 
@@ -6,7 +6,7 @@ import type { DeviceInfo, ListenerConfig } from "./types";
6
6
  * 1. ignoredDevices - if device matches, always return false
7
7
  * 2. listenOnlyDevices - if specified, device must match at least one
8
8
  * 3. targetDevices - if specified, device must match at least one
9
- * 4. logicalPortMap - if specified, device location must be in the map
9
+ * 4. logicalPortMap / logicalPortMapByHub - if specified, device must be in the map (hub map takes precedence when device has hub data)
10
10
  *
11
11
  * @param device - The device information from the native addon
12
12
  * @param config - The listener configuration
@@ -14,7 +14,8 @@ import type { DeviceInfo, ListenerConfig } from "./types";
14
14
  */
15
15
  export declare function shouldNotifyDevice(device: DeviceInfo, config: ListenerConfig): boolean;
16
16
  /**
17
- * Apply logical port mapping to a device if configured
17
+ * Apply logical port mapping to a device if configured.
18
+ * logicalPortMapByHub takes precedence when device has parent hub and port path.
18
19
  *
19
20
  * @param device - The device information from the native addon
20
21
  * @param config - The listener configuration
@@ -1 +1 @@
1
- {"version":3,"file":"device-filter.d.ts","sourceRoot":"","sources":["../src/device-filter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAgB,MAAM,SAAS,CAAC;AA4BxE;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,cAAc,GAAG,OAAO,CA8BtF;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,cAAc,GAAG,UAAU,CAQ9F"}
1
+ {"version":3,"file":"device-filter.d.ts","sourceRoot":"","sources":["../src/device-filter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAgB,MAAM,SAAS,CAAC;AAoDxE;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,cAAc,GAAG,OAAO,CAuCtF;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,cAAc,GAAG,UAAU,CA2B9F"}
@@ -0,0 +1,10 @@
1
+ import type { DeviceInfo } from "./types.js";
2
+ /**
3
+ * Format device port path for display (e.g. "2/3/1" for port 2, then 3, then 1).
4
+ * When portPath is available, returns slash-separated port numbers; otherwise returns locationInfo.
5
+ *
6
+ * @param device - Device info from the listener
7
+ * @returns Port path string (e.g. "2/3/1") or locationInfo fallback
8
+ */
9
+ export declare function formatPortPath(device: DeviceInfo): string;
10
+ //# sourceMappingURL=formatPortPath.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"formatPortPath.d.ts","sourceRoot":"","sources":["../src/formatPortPath.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,UAAU,GAAG,MAAM,CAKzD"}
package/dist/index.d.ts CHANGED
@@ -49,5 +49,6 @@ export default class UsbDeviceListener implements UsbDeviceListenerI {
49
49
  */
50
50
  listDevices(): DeviceInfo[];
51
51
  }
52
+ export { formatPortPath } from "./formatPortPath.js";
52
53
  export type { DeviceInfo, DeviceAddCallback, DeviceRemoveCallback, ListenerConfig, TargetDevice, } from "./types";
53
54
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACX,iBAAiB,EACjB,UAAU,EACV,oBAAoB,EACpB,cAAc,EACd,kBAAkB,EAClB,MAAM,SAAS,CAAC;AAEjB,MAAM,WAAW,WAAW;IAC3B,cAAc,IAAI,IAAI,CAAC;IACvB,aAAa,IAAI,IAAI,CAAC;IACtB,WAAW,CAAC,QAAQ,EAAE,iBAAiB,GAAG,IAAI,CAAC;IAC/C,cAAc,CAAC,QAAQ,EAAE,oBAAoB,GAAG,IAAI,CAAC;IACrD,WAAW,IAAI,UAAU,EAAE,CAAC;CAC5B;AAUD;;;GAGG;AACH,MAAM,CAAC,OAAO,OAAO,iBAAkB,YAAW,kBAAkB;IACnE,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAc;IACpC,OAAO,CAAC,MAAM,CAAsB;IACpC,OAAO,CAAC,eAAe,CAAkC;IACzD,OAAO,CAAC,kBAAkB,CAAqC;gBAEnD,KAAK,CAAC,EAAE,WAAW;IAI/B;;OAEG;IACI,cAAc,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI;IAQnD;;OAEG;IACI,aAAa,IAAI,IAAI;IAI5B;;;;OAIG;IACI,YAAY,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI;IAOjD;;;OAGG;IACI,gBAAgB,IAAI,cAAc;IAIzC;;OAEG;IACI,WAAW,CAAC,QAAQ,EAAE,iBAAiB,GAAG,IAAI;IAerD;;OAEG;IACI,cAAc,CAAC,QAAQ,EAAE,oBAAoB,GAAG,IAAI;IAe3D;;;OAGG;IACI,WAAW,IAAI,UAAU,EAAE;CAKlC;AAED,YAAY,EACX,UAAU,EACV,iBAAiB,EACjB,oBAAoB,EACpB,cAAc,EACd,YAAY,GACZ,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACX,iBAAiB,EACjB,UAAU,EACV,oBAAoB,EACpB,cAAc,EACd,kBAAkB,EAClB,MAAM,SAAS,CAAC;AAEjB,MAAM,WAAW,WAAW;IAC3B,cAAc,IAAI,IAAI,CAAC;IACvB,aAAa,IAAI,IAAI,CAAC;IACtB,WAAW,CAAC,QAAQ,EAAE,iBAAiB,GAAG,IAAI,CAAC;IAC/C,cAAc,CAAC,QAAQ,EAAE,oBAAoB,GAAG,IAAI,CAAC;IACrD,WAAW,IAAI,UAAU,EAAE,CAAC;CAC5B;AAUD;;;GAGG;AACH,MAAM,CAAC,OAAO,OAAO,iBAAkB,YAAW,kBAAkB;IACnE,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAc;IACpC,OAAO,CAAC,MAAM,CAAsB;IACpC,OAAO,CAAC,eAAe,CAAkC;IACzD,OAAO,CAAC,kBAAkB,CAAqC;gBAEnD,KAAK,CAAC,EAAE,WAAW;IAI/B;;OAEG;IACI,cAAc,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI;IAQnD;;OAEG;IACI,aAAa,IAAI,IAAI;IAI5B;;;;OAIG;IACI,YAAY,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI;IAOjD;;;OAGG;IACI,gBAAgB,IAAI,cAAc;IAIzC;;OAEG;IACI,WAAW,CAAC,QAAQ,EAAE,iBAAiB,GAAG,IAAI;IAerD;;OAEG;IACI,cAAc,CAAC,QAAQ,EAAE,oBAAoB,GAAG,IAAI;IAe3D;;;OAGG;IACI,WAAW,IAAI,UAAU,EAAE;CAKlC;AAED,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,YAAY,EACX,UAAU,EACV,iBAAiB,EACjB,oBAAoB,EACpB,cAAc,EACd,YAAY,GACZ,MAAM,SAAS,CAAC"}
package/dist/index.js CHANGED
@@ -31,7 +31,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  // src/index.ts
32
32
  var index_exports = {};
33
33
  __export(index_exports, {
34
- default: () => UsbDeviceListener
34
+ default: () => UsbDeviceListener,
35
+ formatPortPath: () => formatPortPath
35
36
  });
36
37
  module.exports = __toCommonJS(index_exports);
37
38
  var import_node_module = require("node:module");
@@ -52,6 +53,22 @@ function matchesDevice(device, target) {
52
53
  function matchesAnyDevice(device, targets) {
53
54
  return targets.some((target) => matchesDevice(device, target));
54
55
  }
56
+ function hubKeyFromParent(vid, pid) {
57
+ return `${vid.toString(16).toUpperCase().padStart(4, "0")}-${pid.toString(16).toUpperCase().padStart(4, "0")}`;
58
+ }
59
+ function isMappedInLogicalPortMapByHub(device, config) {
60
+ const map = config.logicalPortMapByHub;
61
+ if (!map || Object.keys(map).length === 0) return false;
62
+ const vid = device.parentHubVid;
63
+ const pid = device.parentHubPid;
64
+ const path2 = device.portPath;
65
+ if (vid === void 0 || pid === void 0 || !path2 || path2.length === 0) return false;
66
+ const key = hubKeyFromParent(vid, pid);
67
+ const portMap = map[key];
68
+ if (!portMap) return false;
69
+ const physicalPort = path2[path2.length - 1];
70
+ return physicalPort in portMap;
71
+ }
55
72
  function shouldNotifyDevice(device, config) {
56
73
  if (config.ignoredDevices && config.ignoredDevices.length > 0) {
57
74
  if (matchesAnyDevice(device, config.ignoredDevices)) {
@@ -68,14 +85,34 @@ function shouldNotifyDevice(device, config) {
68
85
  return false;
69
86
  }
70
87
  }
71
- if (config.logicalPortMap && Object.keys(config.logicalPortMap).length > 0) {
88
+ const hasHubMap = config.logicalPortMapByHub && Object.keys(config.logicalPortMapByHub).length > 0;
89
+ const hasLocationMap = config.logicalPortMap && Object.keys(config.logicalPortMap).length > 0;
90
+ if (hasHubMap && device.parentHubVid !== void 0 && device.parentHubPid !== void 0 && device.portPath && device.portPath.length > 0) {
91
+ if (!isMappedInLogicalPortMapByHub(device, config)) {
92
+ return false;
93
+ }
94
+ } else if (hasLocationMap && config.logicalPortMap) {
72
95
  if (!(device.locationInfo in config.logicalPortMap)) {
73
96
  return false;
74
97
  }
98
+ } else if (hasHubMap) {
99
+ return false;
75
100
  }
76
101
  return true;
77
102
  }
78
103
  function applyLogicalPortMapping(device, config) {
104
+ const mapByHub = config.logicalPortMapByHub;
105
+ if (mapByHub && Object.keys(mapByHub).length > 0 && device.parentHubVid !== void 0 && device.parentHubPid !== void 0 && device.portPath && device.portPath.length > 0) {
106
+ const key = hubKeyFromParent(device.parentHubVid, device.parentHubPid);
107
+ const portMap = mapByHub[key];
108
+ const physicalPort = device.portPath[device.portPath.length - 1];
109
+ if (portMap && physicalPort in portMap) {
110
+ return {
111
+ ...device,
112
+ logicalPort: portMap[physicalPort]
113
+ };
114
+ }
115
+ }
79
116
  if (config.logicalPortMap && device.locationInfo in config.logicalPortMap) {
80
117
  return {
81
118
  ...device,
@@ -85,6 +122,14 @@ function applyLogicalPortMapping(device, config) {
85
122
  return device;
86
123
  }
87
124
 
125
+ // src/formatPortPath.ts
126
+ function formatPortPath(device) {
127
+ if (device.portPath && device.portPath.length > 0) {
128
+ return device.portPath.join("/");
129
+ }
130
+ return device.locationInfo;
131
+ }
132
+
88
133
  // src/index.ts
89
134
  function loadDefaultAddon() {
90
135
  const require2 = (0, import_node_module.createRequire)(__importMetaUrl);
@@ -174,5 +219,9 @@ var UsbDeviceListener = class {
174
219
  return devices.map((device) => applyLogicalPortMapping(device, this.config));
175
220
  }
176
221
  };
222
+ // Annotate the CommonJS export names for ESM import in node:
223
+ 0 && (module.exports = {
224
+ formatPortPath
225
+ });
177
226
  module.exports = Object.assign(module.exports.default, module.exports);
178
227
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["../src/index.ts", "../src/device-filter.ts"],
4
- "sourcesContent": ["import { createRequire } from \"node:module\";\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { applyLogicalPortMapping, shouldNotifyDevice } from \"./device-filter\";\nimport type {\n\tDeviceAddCallback,\n\tDeviceInfo,\n\tDeviceRemoveCallback,\n\tListenerConfig,\n\tUsbDeviceListenerI,\n} from \"./types\";\n\nexport interface NativeAddon {\n\tstartListening(): void;\n\tstopListening(): void;\n\tonDeviceAdd(callback: DeviceAddCallback): void;\n\tonDeviceRemove(callback: DeviceRemoveCallback): void;\n\tlistDevices(): DeviceInfo[];\n}\n\nfunction loadDefaultAddon(): NativeAddon {\n\tconst require = createRequire(import.meta.url);\n\tconst __filename = fileURLToPath(import.meta.url);\n\tconst __dirname = path.dirname(__filename);\n\tconst packageRoot = path.join(__dirname, \"..\");\n\treturn require(\"node-gyp-build\")(packageRoot);\n}\n\n/**\n * USB Device Listener implementation\n * Provides a type-safe wrapper around the native C++ addon\n */\nexport default class UsbDeviceListener implements UsbDeviceListenerI {\n\tprivate readonly addon: NativeAddon;\n\tprivate config: ListenerConfig = {};\n\tprivate userAddCallback: DeviceAddCallback | null = null;\n\tprivate userRemoveCallback: DeviceRemoveCallback | null = null;\n\n\tconstructor(addon?: NativeAddon) {\n\t\tthis.addon = addon ?? loadDefaultAddon();\n\t}\n\n\t/**\n\t * Start listening for USB device events\n\t */\n\tpublic startListening(config: ListenerConfig): void {\n\t\tif (typeof config !== \"object\" || config === null) {\n\t\t\tthrow new TypeError(\"Config must be an object\");\n\t\t}\n\t\tthis.config = config;\n\t\tthis.addon.startListening();\n\t}\n\n\t/**\n\t * Stop listening for USB device events\n\t */\n\tpublic stopListening(): void {\n\t\tthis.addon.stopListening();\n\t}\n\n\t/**\n\t * Update the listener config at runtime.\n\t * When listening, subsequent device events and listDevices() use the new config.\n\t * When not listening, only listDevices() uses it until the next startListening().\n\t */\n\tpublic updateConfig(config: ListenerConfig): void {\n\t\tif (typeof config !== \"object\" || config === null) {\n\t\t\tthrow new TypeError(\"Config must be an object\");\n\t\t}\n\t\tthis.config = config;\n\t}\n\n\t/**\n\t * Return a shallow copy of the current config.\n\t * Mutating the returned object does not affect the listener's internal config.\n\t */\n\tpublic getCurrentConfig(): ListenerConfig {\n\t\treturn { ...this.config };\n\t}\n\n\t/**\n\t * Register callback for device connection events\n\t */\n\tpublic onDeviceAdd(callback: DeviceAddCallback): void {\n\t\tif (typeof callback !== \"function\") {\n\t\t\tthrow new TypeError(\"Callback must be a function\");\n\t\t}\n\t\tthis.userAddCallback = callback;\n\n\t\t// Set up internal callback that filters devices\n\t\tthis.addon.onDeviceAdd((device: DeviceInfo) => {\n\t\t\tif (shouldNotifyDevice(device, this.config)) {\n\t\t\t\tconst enrichedDevice = applyLogicalPortMapping(device, this.config);\n\t\t\t\tthis.userAddCallback?.(enrichedDevice);\n\t\t\t}\n\t\t});\n\t}\n\n\t/**\n\t * Register callback for device disconnection events\n\t */\n\tpublic onDeviceRemove(callback: DeviceRemoveCallback): void {\n\t\tif (typeof callback !== \"function\") {\n\t\t\tthrow new TypeError(\"Callback must be a function\");\n\t\t}\n\t\tthis.userRemoveCallback = callback;\n\n\t\t// Set up internal callback that filters devices\n\t\tthis.addon.onDeviceRemove((device: DeviceInfo) => {\n\t\t\tif (shouldNotifyDevice(device, this.config)) {\n\t\t\t\tconst enrichedDevice = applyLogicalPortMapping(device, this.config);\n\t\t\t\tthis.userRemoveCallback?.(enrichedDevice);\n\t\t\t}\n\t\t});\n\t}\n\n\t/**\n\t * List all currently connected USB devices\n\t * Applies logical port mapping from config if set\n\t */\n\tpublic listDevices(): DeviceInfo[] {\n\t\tconst devices = this.addon.listDevices();\n\t\t// Apply logical port mapping to each device\n\t\treturn devices.map((device) => applyLogicalPortMapping(device, this.config));\n\t}\n}\n\nexport type {\n\tDeviceInfo,\n\tDeviceAddCallback,\n\tDeviceRemoveCallback,\n\tListenerConfig,\n\tTargetDevice,\n} from \"./types\";\n", "import type { DeviceInfo, ListenerConfig, TargetDevice } from \"./types\";\n\n/**\n * Convert a decimal VID/PID to uppercase hex string for comparison\n */\nfunction toHexString(value: number): string {\n\treturn value.toString(16).toUpperCase().padStart(4, \"0\");\n}\n\n/**\n * Check if a device matches a target device filter by VID/PID\n */\nfunction matchesDevice(device: DeviceInfo, target: TargetDevice): boolean {\n\tconst deviceVid = toHexString(device.vid);\n\tconst devicePid = toHexString(device.pid);\n\tconst targetVid = target.vid.toUpperCase();\n\tconst targetPid = target.pid.toUpperCase();\n\n\treturn deviceVid === targetVid && devicePid === targetPid;\n}\n\n/**\n * Check if a device matches any device in a list of target devices\n */\nfunction matchesAnyDevice(device: DeviceInfo, targets: TargetDevice[]): boolean {\n\treturn targets.some((target) => matchesDevice(device, target));\n}\n\n/**\n * Determine if a device notification should be sent based on the configuration.\n *\n * Filter priority (highest to lowest):\n * 1. ignoredDevices - if device matches, always return false\n * 2. listenOnlyDevices - if specified, device must match at least one\n * 3. targetDevices - if specified, device must match at least one\n * 4. logicalPortMap - if specified, device location must be in the map\n *\n * @param device - The device information from the native addon\n * @param config - The listener configuration\n * @returns true if the device should trigger a notification, false otherwise\n */\nexport function shouldNotifyDevice(device: DeviceInfo, config: ListenerConfig): boolean {\n\t// Priority 1: Check ignoredDevices (highest priority - always blocks)\n\tif (config.ignoredDevices && config.ignoredDevices.length > 0) {\n\t\tif (matchesAnyDevice(device, config.ignoredDevices)) {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t// Priority 2: Check listenOnlyDevices (if specified, device must match)\n\tif (config.listenOnlyDevices && config.listenOnlyDevices.length > 0) {\n\t\tif (!matchesAnyDevice(device, config.listenOnlyDevices)) {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t// Priority 3: Check targetDevices (if specified, device must match)\n\tif (config.targetDevices && config.targetDevices.length > 0) {\n\t\tif (!matchesAnyDevice(device, config.targetDevices)) {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t// Priority 4: Check logicalPortMap (if specified, device must be mapped)\n\tif (config.logicalPortMap && Object.keys(config.logicalPortMap).length > 0) {\n\t\tif (!(device.locationInfo in config.logicalPortMap)) {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\treturn true;\n}\n\n/**\n * Apply logical port mapping to a device if configured\n *\n * @param device - The device information from the native addon\n * @param config - The listener configuration\n * @returns Device info with logicalPort set if mapped, otherwise unchanged\n */\nexport function applyLogicalPortMapping(device: DeviceInfo, config: ListenerConfig): DeviceInfo {\n\tif (config.logicalPortMap && device.locationInfo in config.logicalPortMap) {\n\t\treturn {\n\t\t\t...device,\n\t\t\tlogicalPort: config.logicalPortMap[device.locationInfo],\n\t\t};\n\t}\n\treturn device;\n}\n"],
5
- "mappings": "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",
6
- "names": ["require", "path"]
3
+ "sources": ["../src/index.ts", "../src/device-filter.ts", "../src/formatPortPath.ts"],
4
+ "sourcesContent": ["import { createRequire } from \"node:module\";\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { applyLogicalPortMapping, shouldNotifyDevice } from \"./device-filter\";\nimport type {\n\tDeviceAddCallback,\n\tDeviceInfo,\n\tDeviceRemoveCallback,\n\tListenerConfig,\n\tUsbDeviceListenerI,\n} from \"./types\";\n\nexport interface NativeAddon {\n\tstartListening(): void;\n\tstopListening(): void;\n\tonDeviceAdd(callback: DeviceAddCallback): void;\n\tonDeviceRemove(callback: DeviceRemoveCallback): void;\n\tlistDevices(): DeviceInfo[];\n}\n\nfunction loadDefaultAddon(): NativeAddon {\n\tconst require = createRequire(import.meta.url);\n\tconst __filename = fileURLToPath(import.meta.url);\n\tconst __dirname = path.dirname(__filename);\n\tconst packageRoot = path.join(__dirname, \"..\");\n\treturn require(\"node-gyp-build\")(packageRoot);\n}\n\n/**\n * USB Device Listener implementation\n * Provides a type-safe wrapper around the native C++ addon\n */\nexport default class UsbDeviceListener implements UsbDeviceListenerI {\n\tprivate readonly addon: NativeAddon;\n\tprivate config: ListenerConfig = {};\n\tprivate userAddCallback: DeviceAddCallback | null = null;\n\tprivate userRemoveCallback: DeviceRemoveCallback | null = null;\n\n\tconstructor(addon?: NativeAddon) {\n\t\tthis.addon = addon ?? loadDefaultAddon();\n\t}\n\n\t/**\n\t * Start listening for USB device events\n\t */\n\tpublic startListening(config: ListenerConfig): void {\n\t\tif (typeof config !== \"object\" || config === null) {\n\t\t\tthrow new TypeError(\"Config must be an object\");\n\t\t}\n\t\tthis.config = config;\n\t\tthis.addon.startListening();\n\t}\n\n\t/**\n\t * Stop listening for USB device events\n\t */\n\tpublic stopListening(): void {\n\t\tthis.addon.stopListening();\n\t}\n\n\t/**\n\t * Update the listener config at runtime.\n\t * When listening, subsequent device events and listDevices() use the new config.\n\t * When not listening, only listDevices() uses it until the next startListening().\n\t */\n\tpublic updateConfig(config: ListenerConfig): void {\n\t\tif (typeof config !== \"object\" || config === null) {\n\t\t\tthrow new TypeError(\"Config must be an object\");\n\t\t}\n\t\tthis.config = config;\n\t}\n\n\t/**\n\t * Return a shallow copy of the current config.\n\t * Mutating the returned object does not affect the listener's internal config.\n\t */\n\tpublic getCurrentConfig(): ListenerConfig {\n\t\treturn { ...this.config };\n\t}\n\n\t/**\n\t * Register callback for device connection events\n\t */\n\tpublic onDeviceAdd(callback: DeviceAddCallback): void {\n\t\tif (typeof callback !== \"function\") {\n\t\t\tthrow new TypeError(\"Callback must be a function\");\n\t\t}\n\t\tthis.userAddCallback = callback;\n\n\t\t// Set up internal callback that filters devices\n\t\tthis.addon.onDeviceAdd((device: DeviceInfo) => {\n\t\t\tif (shouldNotifyDevice(device, this.config)) {\n\t\t\t\tconst enrichedDevice = applyLogicalPortMapping(device, this.config);\n\t\t\t\tthis.userAddCallback?.(enrichedDevice);\n\t\t\t}\n\t\t});\n\t}\n\n\t/**\n\t * Register callback for device disconnection events\n\t */\n\tpublic onDeviceRemove(callback: DeviceRemoveCallback): void {\n\t\tif (typeof callback !== \"function\") {\n\t\t\tthrow new TypeError(\"Callback must be a function\");\n\t\t}\n\t\tthis.userRemoveCallback = callback;\n\n\t\t// Set up internal callback that filters devices\n\t\tthis.addon.onDeviceRemove((device: DeviceInfo) => {\n\t\t\tif (shouldNotifyDevice(device, this.config)) {\n\t\t\t\tconst enrichedDevice = applyLogicalPortMapping(device, this.config);\n\t\t\t\tthis.userRemoveCallback?.(enrichedDevice);\n\t\t\t}\n\t\t});\n\t}\n\n\t/**\n\t * List all currently connected USB devices\n\t * Applies logical port mapping from config if set\n\t */\n\tpublic listDevices(): DeviceInfo[] {\n\t\tconst devices = this.addon.listDevices();\n\t\t// Apply logical port mapping to each device\n\t\treturn devices.map((device) => applyLogicalPortMapping(device, this.config));\n\t}\n}\n\nexport { formatPortPath } from \"./formatPortPath.js\";\nexport type {\n\tDeviceInfo,\n\tDeviceAddCallback,\n\tDeviceRemoveCallback,\n\tListenerConfig,\n\tTargetDevice,\n} from \"./types\";\n", "import type { DeviceInfo, ListenerConfig, TargetDevice } from \"./types\";\n\n/**\n * Convert a decimal VID/PID to uppercase hex string for comparison\n */\nfunction toHexString(value: number): string {\n\treturn value.toString(16).toUpperCase().padStart(4, \"0\");\n}\n\n/**\n * Check if a device matches a target device filter by VID/PID\n */\nfunction matchesDevice(device: DeviceInfo, target: TargetDevice): boolean {\n\tconst deviceVid = toHexString(device.vid);\n\tconst devicePid = toHexString(device.pid);\n\tconst targetVid = target.vid.toUpperCase();\n\tconst targetPid = target.pid.toUpperCase();\n\n\treturn deviceVid === targetVid && devicePid === targetPid;\n}\n\n/**\n * Check if a device matches any device in a list of target devices\n */\nfunction matchesAnyDevice(device: DeviceInfo, targets: TargetDevice[]): boolean {\n\treturn targets.some((target) => matchesDevice(device, target));\n}\n\n/**\n * Build hub key \"${vid}-${pid}\" from device's parent hub (uppercase 4-char hex).\n */\nfunction hubKeyFromParent(vid: number, pid: number): string {\n\treturn `${vid.toString(16).toUpperCase().padStart(4, \"0\")}-${pid.toString(16).toUpperCase().padStart(4, \"0\")}`;\n}\n\n/**\n * True if device has parent hub data and is mapped in logicalPortMapByHub.\n */\nfunction isMappedInLogicalPortMapByHub(device: DeviceInfo, config: ListenerConfig): boolean {\n\tconst map = config.logicalPortMapByHub;\n\tif (!map || Object.keys(map).length === 0) return false;\n\tconst vid = device.parentHubVid;\n\tconst pid = device.parentHubPid;\n\tconst path = device.portPath;\n\tif (vid === undefined || pid === undefined || !path || path.length === 0) return false;\n\tconst key = hubKeyFromParent(vid, pid);\n\tconst portMap = map[key];\n\tif (!portMap) return false;\n\tconst physicalPort = path[path.length - 1];\n\treturn physicalPort in portMap;\n}\n\n/**\n * Determine if a device notification should be sent based on the configuration.\n *\n * Filter priority (highest to lowest):\n * 1. ignoredDevices - if device matches, always return false\n * 2. listenOnlyDevices - if specified, device must match at least one\n * 3. targetDevices - if specified, device must match at least one\n * 4. logicalPortMap / logicalPortMapByHub - if specified, device must be in the map (hub map takes precedence when device has hub data)\n *\n * @param device - The device information from the native addon\n * @param config - The listener configuration\n * @returns true if the device should trigger a notification, false otherwise\n */\nexport function shouldNotifyDevice(device: DeviceInfo, config: ListenerConfig): boolean {\n\t// Priority 1: Check ignoredDevices (highest priority - always blocks)\n\tif (config.ignoredDevices && config.ignoredDevices.length > 0) {\n\t\tif (matchesAnyDevice(device, config.ignoredDevices)) {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t// Priority 2: Check listenOnlyDevices (if specified, device must match)\n\tif (config.listenOnlyDevices && config.listenOnlyDevices.length > 0) {\n\t\tif (!matchesAnyDevice(device, config.listenOnlyDevices)) {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t// Priority 3: Check targetDevices (if specified, device must match)\n\tif (config.targetDevices && config.targetDevices.length > 0) {\n\t\tif (!matchesAnyDevice(device, config.targetDevices)) {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t// Priority 4: Check logicalPortMap / logicalPortMapByHub (if specified, device must be mapped)\n\tconst hasHubMap = config.logicalPortMapByHub && Object.keys(config.logicalPortMapByHub).length > 0;\n\tconst hasLocationMap = config.logicalPortMap && Object.keys(config.logicalPortMap).length > 0;\n\tif (hasHubMap && device.parentHubVid !== undefined && device.parentHubPid !== undefined && device.portPath && device.portPath.length > 0) {\n\t\tif (!isMappedInLogicalPortMapByHub(device, config)) {\n\t\t\treturn false;\n\t\t}\n\t} else if (hasLocationMap && config.logicalPortMap) {\n\t\tif (!(device.locationInfo in config.logicalPortMap)) {\n\t\t\treturn false;\n\t\t}\n\t} else if (hasHubMap) {\n\t\t// logicalPortMapByHub is set but device has no hub data - cannot be in map\n\t\treturn false;\n\t}\n\n\treturn true;\n}\n\n/**\n * Apply logical port mapping to a device if configured.\n * logicalPortMapByHub takes precedence when device has parent hub and port path.\n *\n * @param device - The device information from the native addon\n * @param config - The listener configuration\n * @returns Device info with logicalPort set if mapped, otherwise unchanged\n */\nexport function applyLogicalPortMapping(device: DeviceInfo, config: ListenerConfig): DeviceInfo {\n\tconst mapByHub = config.logicalPortMapByHub;\n\tif (\n\t\tmapByHub &&\n\t\tObject.keys(mapByHub).length > 0 &&\n\t\tdevice.parentHubVid !== undefined &&\n\t\tdevice.parentHubPid !== undefined &&\n\t\tdevice.portPath &&\n\t\tdevice.portPath.length > 0\n\t) {\n\t\tconst key = hubKeyFromParent(device.parentHubVid, device.parentHubPid);\n\t\tconst portMap = mapByHub[key];\n\t\tconst physicalPort = device.portPath[device.portPath.length - 1];\n\t\tif (portMap && physicalPort in portMap) {\n\t\t\treturn {\n\t\t\t\t...device,\n\t\t\t\tlogicalPort: portMap[physicalPort],\n\t\t\t};\n\t\t}\n\t}\n\tif (config.logicalPortMap && device.locationInfo in config.logicalPortMap) {\n\t\treturn {\n\t\t\t...device,\n\t\t\tlogicalPort: config.logicalPortMap[device.locationInfo],\n\t\t};\n\t}\n\treturn device;\n}\n", "import type { DeviceInfo } from \"./types.js\";\n\n/**\n * Format device port path for display (e.g. \"2/3/1\" for port 2, then 3, then 1).\n * When portPath is available, returns slash-separated port numbers; otherwise returns locationInfo.\n *\n * @param device - Device info from the listener\n * @returns Port path string (e.g. \"2/3/1\") or locationInfo fallback\n */\nexport function formatPortPath(device: DeviceInfo): string {\n\tif (device.portPath && device.portPath.length > 0) {\n\t\treturn device.portPath.join(\"/\");\n\t}\n\treturn device.locationInfo;\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["path", "require", "path"]
7
7
  }
package/dist/index.mjs CHANGED
@@ -17,6 +17,22 @@ function matchesDevice(device, target) {
17
17
  function matchesAnyDevice(device, targets) {
18
18
  return targets.some((target) => matchesDevice(device, target));
19
19
  }
20
+ function hubKeyFromParent(vid, pid) {
21
+ return `${vid.toString(16).toUpperCase().padStart(4, "0")}-${pid.toString(16).toUpperCase().padStart(4, "0")}`;
22
+ }
23
+ function isMappedInLogicalPortMapByHub(device, config) {
24
+ const map = config.logicalPortMapByHub;
25
+ if (!map || Object.keys(map).length === 0) return false;
26
+ const vid = device.parentHubVid;
27
+ const pid = device.parentHubPid;
28
+ const path2 = device.portPath;
29
+ if (vid === void 0 || pid === void 0 || !path2 || path2.length === 0) return false;
30
+ const key = hubKeyFromParent(vid, pid);
31
+ const portMap = map[key];
32
+ if (!portMap) return false;
33
+ const physicalPort = path2[path2.length - 1];
34
+ return physicalPort in portMap;
35
+ }
20
36
  function shouldNotifyDevice(device, config) {
21
37
  if (config.ignoredDevices && config.ignoredDevices.length > 0) {
22
38
  if (matchesAnyDevice(device, config.ignoredDevices)) {
@@ -33,14 +49,34 @@ function shouldNotifyDevice(device, config) {
33
49
  return false;
34
50
  }
35
51
  }
36
- if (config.logicalPortMap && Object.keys(config.logicalPortMap).length > 0) {
52
+ const hasHubMap = config.logicalPortMapByHub && Object.keys(config.logicalPortMapByHub).length > 0;
53
+ const hasLocationMap = config.logicalPortMap && Object.keys(config.logicalPortMap).length > 0;
54
+ if (hasHubMap && device.parentHubVid !== void 0 && device.parentHubPid !== void 0 && device.portPath && device.portPath.length > 0) {
55
+ if (!isMappedInLogicalPortMapByHub(device, config)) {
56
+ return false;
57
+ }
58
+ } else if (hasLocationMap && config.logicalPortMap) {
37
59
  if (!(device.locationInfo in config.logicalPortMap)) {
38
60
  return false;
39
61
  }
62
+ } else if (hasHubMap) {
63
+ return false;
40
64
  }
41
65
  return true;
42
66
  }
43
67
  function applyLogicalPortMapping(device, config) {
68
+ const mapByHub = config.logicalPortMapByHub;
69
+ if (mapByHub && Object.keys(mapByHub).length > 0 && device.parentHubVid !== void 0 && device.parentHubPid !== void 0 && device.portPath && device.portPath.length > 0) {
70
+ const key = hubKeyFromParent(device.parentHubVid, device.parentHubPid);
71
+ const portMap = mapByHub[key];
72
+ const physicalPort = device.portPath[device.portPath.length - 1];
73
+ if (portMap && physicalPort in portMap) {
74
+ return {
75
+ ...device,
76
+ logicalPort: portMap[physicalPort]
77
+ };
78
+ }
79
+ }
44
80
  if (config.logicalPortMap && device.locationInfo in config.logicalPortMap) {
45
81
  return {
46
82
  ...device,
@@ -50,6 +86,14 @@ function applyLogicalPortMapping(device, config) {
50
86
  return device;
51
87
  }
52
88
 
89
+ // src/formatPortPath.ts
90
+ function formatPortPath(device) {
91
+ if (device.portPath && device.portPath.length > 0) {
92
+ return device.portPath.join("/");
93
+ }
94
+ return device.locationInfo;
95
+ }
96
+
53
97
  // src/index.ts
54
98
  function loadDefaultAddon() {
55
99
  const require2 = createRequire(import.meta.url);
@@ -140,6 +184,7 @@ var UsbDeviceListener = class {
140
184
  }
141
185
  };
142
186
  export {
143
- UsbDeviceListener as default
187
+ UsbDeviceListener as default,
188
+ formatPortPath
144
189
  };
145
190
  //# sourceMappingURL=index.mjs.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["../src/index.ts", "../src/device-filter.ts"],
4
- "sourcesContent": ["import { createRequire } from \"node:module\";\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { applyLogicalPortMapping, shouldNotifyDevice } from \"./device-filter\";\nimport type {\n\tDeviceAddCallback,\n\tDeviceInfo,\n\tDeviceRemoveCallback,\n\tListenerConfig,\n\tUsbDeviceListenerI,\n} from \"./types\";\n\nexport interface NativeAddon {\n\tstartListening(): void;\n\tstopListening(): void;\n\tonDeviceAdd(callback: DeviceAddCallback): void;\n\tonDeviceRemove(callback: DeviceRemoveCallback): void;\n\tlistDevices(): DeviceInfo[];\n}\n\nfunction loadDefaultAddon(): NativeAddon {\n\tconst require = createRequire(import.meta.url);\n\tconst __filename = fileURLToPath(import.meta.url);\n\tconst __dirname = path.dirname(__filename);\n\tconst packageRoot = path.join(__dirname, \"..\");\n\treturn require(\"node-gyp-build\")(packageRoot);\n}\n\n/**\n * USB Device Listener implementation\n * Provides a type-safe wrapper around the native C++ addon\n */\nexport default class UsbDeviceListener implements UsbDeviceListenerI {\n\tprivate readonly addon: NativeAddon;\n\tprivate config: ListenerConfig = {};\n\tprivate userAddCallback: DeviceAddCallback | null = null;\n\tprivate userRemoveCallback: DeviceRemoveCallback | null = null;\n\n\tconstructor(addon?: NativeAddon) {\n\t\tthis.addon = addon ?? loadDefaultAddon();\n\t}\n\n\t/**\n\t * Start listening for USB device events\n\t */\n\tpublic startListening(config: ListenerConfig): void {\n\t\tif (typeof config !== \"object\" || config === null) {\n\t\t\tthrow new TypeError(\"Config must be an object\");\n\t\t}\n\t\tthis.config = config;\n\t\tthis.addon.startListening();\n\t}\n\n\t/**\n\t * Stop listening for USB device events\n\t */\n\tpublic stopListening(): void {\n\t\tthis.addon.stopListening();\n\t}\n\n\t/**\n\t * Update the listener config at runtime.\n\t * When listening, subsequent device events and listDevices() use the new config.\n\t * When not listening, only listDevices() uses it until the next startListening().\n\t */\n\tpublic updateConfig(config: ListenerConfig): void {\n\t\tif (typeof config !== \"object\" || config === null) {\n\t\t\tthrow new TypeError(\"Config must be an object\");\n\t\t}\n\t\tthis.config = config;\n\t}\n\n\t/**\n\t * Return a shallow copy of the current config.\n\t * Mutating the returned object does not affect the listener's internal config.\n\t */\n\tpublic getCurrentConfig(): ListenerConfig {\n\t\treturn { ...this.config };\n\t}\n\n\t/**\n\t * Register callback for device connection events\n\t */\n\tpublic onDeviceAdd(callback: DeviceAddCallback): void {\n\t\tif (typeof callback !== \"function\") {\n\t\t\tthrow new TypeError(\"Callback must be a function\");\n\t\t}\n\t\tthis.userAddCallback = callback;\n\n\t\t// Set up internal callback that filters devices\n\t\tthis.addon.onDeviceAdd((device: DeviceInfo) => {\n\t\t\tif (shouldNotifyDevice(device, this.config)) {\n\t\t\t\tconst enrichedDevice = applyLogicalPortMapping(device, this.config);\n\t\t\t\tthis.userAddCallback?.(enrichedDevice);\n\t\t\t}\n\t\t});\n\t}\n\n\t/**\n\t * Register callback for device disconnection events\n\t */\n\tpublic onDeviceRemove(callback: DeviceRemoveCallback): void {\n\t\tif (typeof callback !== \"function\") {\n\t\t\tthrow new TypeError(\"Callback must be a function\");\n\t\t}\n\t\tthis.userRemoveCallback = callback;\n\n\t\t// Set up internal callback that filters devices\n\t\tthis.addon.onDeviceRemove((device: DeviceInfo) => {\n\t\t\tif (shouldNotifyDevice(device, this.config)) {\n\t\t\t\tconst enrichedDevice = applyLogicalPortMapping(device, this.config);\n\t\t\t\tthis.userRemoveCallback?.(enrichedDevice);\n\t\t\t}\n\t\t});\n\t}\n\n\t/**\n\t * List all currently connected USB devices\n\t * Applies logical port mapping from config if set\n\t */\n\tpublic listDevices(): DeviceInfo[] {\n\t\tconst devices = this.addon.listDevices();\n\t\t// Apply logical port mapping to each device\n\t\treturn devices.map((device) => applyLogicalPortMapping(device, this.config));\n\t}\n}\n\nexport type {\n\tDeviceInfo,\n\tDeviceAddCallback,\n\tDeviceRemoveCallback,\n\tListenerConfig,\n\tTargetDevice,\n} from \"./types\";\n", "import type { DeviceInfo, ListenerConfig, TargetDevice } from \"./types\";\n\n/**\n * Convert a decimal VID/PID to uppercase hex string for comparison\n */\nfunction toHexString(value: number): string {\n\treturn value.toString(16).toUpperCase().padStart(4, \"0\");\n}\n\n/**\n * Check if a device matches a target device filter by VID/PID\n */\nfunction matchesDevice(device: DeviceInfo, target: TargetDevice): boolean {\n\tconst deviceVid = toHexString(device.vid);\n\tconst devicePid = toHexString(device.pid);\n\tconst targetVid = target.vid.toUpperCase();\n\tconst targetPid = target.pid.toUpperCase();\n\n\treturn deviceVid === targetVid && devicePid === targetPid;\n}\n\n/**\n * Check if a device matches any device in a list of target devices\n */\nfunction matchesAnyDevice(device: DeviceInfo, targets: TargetDevice[]): boolean {\n\treturn targets.some((target) => matchesDevice(device, target));\n}\n\n/**\n * Determine if a device notification should be sent based on the configuration.\n *\n * Filter priority (highest to lowest):\n * 1. ignoredDevices - if device matches, always return false\n * 2. listenOnlyDevices - if specified, device must match at least one\n * 3. targetDevices - if specified, device must match at least one\n * 4. logicalPortMap - if specified, device location must be in the map\n *\n * @param device - The device information from the native addon\n * @param config - The listener configuration\n * @returns true if the device should trigger a notification, false otherwise\n */\nexport function shouldNotifyDevice(device: DeviceInfo, config: ListenerConfig): boolean {\n\t// Priority 1: Check ignoredDevices (highest priority - always blocks)\n\tif (config.ignoredDevices && config.ignoredDevices.length > 0) {\n\t\tif (matchesAnyDevice(device, config.ignoredDevices)) {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t// Priority 2: Check listenOnlyDevices (if specified, device must match)\n\tif (config.listenOnlyDevices && config.listenOnlyDevices.length > 0) {\n\t\tif (!matchesAnyDevice(device, config.listenOnlyDevices)) {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t// Priority 3: Check targetDevices (if specified, device must match)\n\tif (config.targetDevices && config.targetDevices.length > 0) {\n\t\tif (!matchesAnyDevice(device, config.targetDevices)) {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t// Priority 4: Check logicalPortMap (if specified, device must be mapped)\n\tif (config.logicalPortMap && Object.keys(config.logicalPortMap).length > 0) {\n\t\tif (!(device.locationInfo in config.logicalPortMap)) {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\treturn true;\n}\n\n/**\n * Apply logical port mapping to a device if configured\n *\n * @param device - The device information from the native addon\n * @param config - The listener configuration\n * @returns Device info with logicalPort set if mapped, otherwise unchanged\n */\nexport function applyLogicalPortMapping(device: DeviceInfo, config: ListenerConfig): DeviceInfo {\n\tif (config.logicalPortMap && device.locationInfo in config.logicalPortMap) {\n\t\treturn {\n\t\t\t...device,\n\t\t\tlogicalPort: config.logicalPortMap[device.locationInfo],\n\t\t};\n\t}\n\treturn device;\n}\n"],
5
- "mappings": "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",
6
- "names": ["require"]
3
+ "sources": ["../src/index.ts", "../src/device-filter.ts", "../src/formatPortPath.ts"],
4
+ "sourcesContent": ["import { createRequire } from \"node:module\";\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { applyLogicalPortMapping, shouldNotifyDevice } from \"./device-filter\";\nimport type {\n\tDeviceAddCallback,\n\tDeviceInfo,\n\tDeviceRemoveCallback,\n\tListenerConfig,\n\tUsbDeviceListenerI,\n} from \"./types\";\n\nexport interface NativeAddon {\n\tstartListening(): void;\n\tstopListening(): void;\n\tonDeviceAdd(callback: DeviceAddCallback): void;\n\tonDeviceRemove(callback: DeviceRemoveCallback): void;\n\tlistDevices(): DeviceInfo[];\n}\n\nfunction loadDefaultAddon(): NativeAddon {\n\tconst require = createRequire(import.meta.url);\n\tconst __filename = fileURLToPath(import.meta.url);\n\tconst __dirname = path.dirname(__filename);\n\tconst packageRoot = path.join(__dirname, \"..\");\n\treturn require(\"node-gyp-build\")(packageRoot);\n}\n\n/**\n * USB Device Listener implementation\n * Provides a type-safe wrapper around the native C++ addon\n */\nexport default class UsbDeviceListener implements UsbDeviceListenerI {\n\tprivate readonly addon: NativeAddon;\n\tprivate config: ListenerConfig = {};\n\tprivate userAddCallback: DeviceAddCallback | null = null;\n\tprivate userRemoveCallback: DeviceRemoveCallback | null = null;\n\n\tconstructor(addon?: NativeAddon) {\n\t\tthis.addon = addon ?? loadDefaultAddon();\n\t}\n\n\t/**\n\t * Start listening for USB device events\n\t */\n\tpublic startListening(config: ListenerConfig): void {\n\t\tif (typeof config !== \"object\" || config === null) {\n\t\t\tthrow new TypeError(\"Config must be an object\");\n\t\t}\n\t\tthis.config = config;\n\t\tthis.addon.startListening();\n\t}\n\n\t/**\n\t * Stop listening for USB device events\n\t */\n\tpublic stopListening(): void {\n\t\tthis.addon.stopListening();\n\t}\n\n\t/**\n\t * Update the listener config at runtime.\n\t * When listening, subsequent device events and listDevices() use the new config.\n\t * When not listening, only listDevices() uses it until the next startListening().\n\t */\n\tpublic updateConfig(config: ListenerConfig): void {\n\t\tif (typeof config !== \"object\" || config === null) {\n\t\t\tthrow new TypeError(\"Config must be an object\");\n\t\t}\n\t\tthis.config = config;\n\t}\n\n\t/**\n\t * Return a shallow copy of the current config.\n\t * Mutating the returned object does not affect the listener's internal config.\n\t */\n\tpublic getCurrentConfig(): ListenerConfig {\n\t\treturn { ...this.config };\n\t}\n\n\t/**\n\t * Register callback for device connection events\n\t */\n\tpublic onDeviceAdd(callback: DeviceAddCallback): void {\n\t\tif (typeof callback !== \"function\") {\n\t\t\tthrow new TypeError(\"Callback must be a function\");\n\t\t}\n\t\tthis.userAddCallback = callback;\n\n\t\t// Set up internal callback that filters devices\n\t\tthis.addon.onDeviceAdd((device: DeviceInfo) => {\n\t\t\tif (shouldNotifyDevice(device, this.config)) {\n\t\t\t\tconst enrichedDevice = applyLogicalPortMapping(device, this.config);\n\t\t\t\tthis.userAddCallback?.(enrichedDevice);\n\t\t\t}\n\t\t});\n\t}\n\n\t/**\n\t * Register callback for device disconnection events\n\t */\n\tpublic onDeviceRemove(callback: DeviceRemoveCallback): void {\n\t\tif (typeof callback !== \"function\") {\n\t\t\tthrow new TypeError(\"Callback must be a function\");\n\t\t}\n\t\tthis.userRemoveCallback = callback;\n\n\t\t// Set up internal callback that filters devices\n\t\tthis.addon.onDeviceRemove((device: DeviceInfo) => {\n\t\t\tif (shouldNotifyDevice(device, this.config)) {\n\t\t\t\tconst enrichedDevice = applyLogicalPortMapping(device, this.config);\n\t\t\t\tthis.userRemoveCallback?.(enrichedDevice);\n\t\t\t}\n\t\t});\n\t}\n\n\t/**\n\t * List all currently connected USB devices\n\t * Applies logical port mapping from config if set\n\t */\n\tpublic listDevices(): DeviceInfo[] {\n\t\tconst devices = this.addon.listDevices();\n\t\t// Apply logical port mapping to each device\n\t\treturn devices.map((device) => applyLogicalPortMapping(device, this.config));\n\t}\n}\n\nexport { formatPortPath } from \"./formatPortPath.js\";\nexport type {\n\tDeviceInfo,\n\tDeviceAddCallback,\n\tDeviceRemoveCallback,\n\tListenerConfig,\n\tTargetDevice,\n} from \"./types\";\n", "import type { DeviceInfo, ListenerConfig, TargetDevice } from \"./types\";\n\n/**\n * Convert a decimal VID/PID to uppercase hex string for comparison\n */\nfunction toHexString(value: number): string {\n\treturn value.toString(16).toUpperCase().padStart(4, \"0\");\n}\n\n/**\n * Check if a device matches a target device filter by VID/PID\n */\nfunction matchesDevice(device: DeviceInfo, target: TargetDevice): boolean {\n\tconst deviceVid = toHexString(device.vid);\n\tconst devicePid = toHexString(device.pid);\n\tconst targetVid = target.vid.toUpperCase();\n\tconst targetPid = target.pid.toUpperCase();\n\n\treturn deviceVid === targetVid && devicePid === targetPid;\n}\n\n/**\n * Check if a device matches any device in a list of target devices\n */\nfunction matchesAnyDevice(device: DeviceInfo, targets: TargetDevice[]): boolean {\n\treturn targets.some((target) => matchesDevice(device, target));\n}\n\n/**\n * Build hub key \"${vid}-${pid}\" from device's parent hub (uppercase 4-char hex).\n */\nfunction hubKeyFromParent(vid: number, pid: number): string {\n\treturn `${vid.toString(16).toUpperCase().padStart(4, \"0\")}-${pid.toString(16).toUpperCase().padStart(4, \"0\")}`;\n}\n\n/**\n * True if device has parent hub data and is mapped in logicalPortMapByHub.\n */\nfunction isMappedInLogicalPortMapByHub(device: DeviceInfo, config: ListenerConfig): boolean {\n\tconst map = config.logicalPortMapByHub;\n\tif (!map || Object.keys(map).length === 0) return false;\n\tconst vid = device.parentHubVid;\n\tconst pid = device.parentHubPid;\n\tconst path = device.portPath;\n\tif (vid === undefined || pid === undefined || !path || path.length === 0) return false;\n\tconst key = hubKeyFromParent(vid, pid);\n\tconst portMap = map[key];\n\tif (!portMap) return false;\n\tconst physicalPort = path[path.length - 1];\n\treturn physicalPort in portMap;\n}\n\n/**\n * Determine if a device notification should be sent based on the configuration.\n *\n * Filter priority (highest to lowest):\n * 1. ignoredDevices - if device matches, always return false\n * 2. listenOnlyDevices - if specified, device must match at least one\n * 3. targetDevices - if specified, device must match at least one\n * 4. logicalPortMap / logicalPortMapByHub - if specified, device must be in the map (hub map takes precedence when device has hub data)\n *\n * @param device - The device information from the native addon\n * @param config - The listener configuration\n * @returns true if the device should trigger a notification, false otherwise\n */\nexport function shouldNotifyDevice(device: DeviceInfo, config: ListenerConfig): boolean {\n\t// Priority 1: Check ignoredDevices (highest priority - always blocks)\n\tif (config.ignoredDevices && config.ignoredDevices.length > 0) {\n\t\tif (matchesAnyDevice(device, config.ignoredDevices)) {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t// Priority 2: Check listenOnlyDevices (if specified, device must match)\n\tif (config.listenOnlyDevices && config.listenOnlyDevices.length > 0) {\n\t\tif (!matchesAnyDevice(device, config.listenOnlyDevices)) {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t// Priority 3: Check targetDevices (if specified, device must match)\n\tif (config.targetDevices && config.targetDevices.length > 0) {\n\t\tif (!matchesAnyDevice(device, config.targetDevices)) {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t// Priority 4: Check logicalPortMap / logicalPortMapByHub (if specified, device must be mapped)\n\tconst hasHubMap = config.logicalPortMapByHub && Object.keys(config.logicalPortMapByHub).length > 0;\n\tconst hasLocationMap = config.logicalPortMap && Object.keys(config.logicalPortMap).length > 0;\n\tif (hasHubMap && device.parentHubVid !== undefined && device.parentHubPid !== undefined && device.portPath && device.portPath.length > 0) {\n\t\tif (!isMappedInLogicalPortMapByHub(device, config)) {\n\t\t\treturn false;\n\t\t}\n\t} else if (hasLocationMap && config.logicalPortMap) {\n\t\tif (!(device.locationInfo in config.logicalPortMap)) {\n\t\t\treturn false;\n\t\t}\n\t} else if (hasHubMap) {\n\t\t// logicalPortMapByHub is set but device has no hub data - cannot be in map\n\t\treturn false;\n\t}\n\n\treturn true;\n}\n\n/**\n * Apply logical port mapping to a device if configured.\n * logicalPortMapByHub takes precedence when device has parent hub and port path.\n *\n * @param device - The device information from the native addon\n * @param config - The listener configuration\n * @returns Device info with logicalPort set if mapped, otherwise unchanged\n */\nexport function applyLogicalPortMapping(device: DeviceInfo, config: ListenerConfig): DeviceInfo {\n\tconst mapByHub = config.logicalPortMapByHub;\n\tif (\n\t\tmapByHub &&\n\t\tObject.keys(mapByHub).length > 0 &&\n\t\tdevice.parentHubVid !== undefined &&\n\t\tdevice.parentHubPid !== undefined &&\n\t\tdevice.portPath &&\n\t\tdevice.portPath.length > 0\n\t) {\n\t\tconst key = hubKeyFromParent(device.parentHubVid, device.parentHubPid);\n\t\tconst portMap = mapByHub[key];\n\t\tconst physicalPort = device.portPath[device.portPath.length - 1];\n\t\tif (portMap && physicalPort in portMap) {\n\t\t\treturn {\n\t\t\t\t...device,\n\t\t\t\tlogicalPort: portMap[physicalPort],\n\t\t\t};\n\t\t}\n\t}\n\tif (config.logicalPortMap && device.locationInfo in config.logicalPortMap) {\n\t\treturn {\n\t\t\t...device,\n\t\t\tlogicalPort: config.logicalPortMap[device.locationInfo],\n\t\t};\n\t}\n\treturn device;\n}\n", "import type { DeviceInfo } from \"./types.js\";\n\n/**\n * Format device port path for display (e.g. \"2/3/1\" for port 2, then 3, then 1).\n * When portPath is available, returns slash-separated port numbers; otherwise returns locationInfo.\n *\n * @param device - Device info from the listener\n * @returns Port path string (e.g. \"2/3/1\") or locationInfo fallback\n */\nexport function formatPortPath(device: DeviceInfo): string {\n\tif (device.portPath && device.portPath.length > 0) {\n\t\treturn device.portPath.join(\"/\");\n\t}\n\treturn device.locationInfo;\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["path", "require"]
7
7
  }
package/dist/types.d.ts CHANGED
@@ -43,9 +43,22 @@ export interface DeviceInfo {
43
43
  locationInfo: string;
44
44
  /**
45
45
  * User-defined logical port number from configuration
46
- * null if not mapped in logicalPortMap
46
+ * null if not mapped in logicalPortMap or logicalPortMapByHub
47
47
  */
48
48
  logicalPort: number | null;
49
+ /**
50
+ * Port path from root to device (e.g. [2, 3, 1] means port 2, then 3, then 1).
51
+ * Present when the native layer provides it (e.g. Windows); undefined or empty otherwise.
52
+ */
53
+ portPath?: number[];
54
+ /**
55
+ * VID of the hub the device is directly connected to (0 or undefined if on root).
56
+ */
57
+ parentHubVid?: number;
58
+ /**
59
+ * PID of the hub the device is directly connected to.
60
+ */
61
+ parentHubPid?: number;
49
62
  }
50
63
  /**
51
64
  * Target device filter by VID/PID
@@ -79,6 +92,18 @@ export interface ListenerConfig {
79
92
  * }
80
93
  */
81
94
  logicalPortMap?: Record<string, number>;
95
+ /**
96
+ * Map physical port to logical port per hub (alternative to logicalPortMap).
97
+ * Key: hub identifier "${vid}-${pid}" (hex, uppercase 4-char, e.g. "1A2C-3B4D").
98
+ * Value: map from physical port number to logical port number.
99
+ * When both logicalPortMap and logicalPortMapByHub could apply, logicalPortMapByHub takes precedence.
100
+ *
101
+ * @example
102
+ * {
103
+ * "1A2C-3B4D": { 1: 4, 4: 2, 3: 3, 2: 1 }
104
+ * }
105
+ */
106
+ logicalPortMapByHub?: Record<string, Record<number, number>>;
82
107
  /**
83
108
  * Filter to monitor only specific devices by VID/PID
84
109
  *
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;GAEG;AACH,MAAM,WAAW,UAAU;IAC1B;;;;;OAKG;IACH,QAAQ,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,YAAY,EAAE,MAAM,CAAC;IAErB;;;;OAIG;IACH,UAAU,EAAE,MAAM,CAAC;IAEnB;;;OAGG;IACH,GAAG,EAAE,MAAM,CAAC;IAEZ;;;OAGG;IACH,GAAG,EAAE,MAAM,CAAC;IAEZ;;;;OAIG;IACH,YAAY,EAAE,MAAM,CAAC;IAErB;;;OAGG;IACH,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC5B;;OAEG;IACH,GAAG,EAAE,MAAM,CAAC;IAEZ;;OAEG;IACH,GAAG,EAAE,MAAM,CAAC;CACZ;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC9B;;;;;;;;;;;;;OAaG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAExC;;;;;;;;;;OAUG;IACH,aAAa,CAAC,EAAE,YAAY,EAAE,CAAC;IAE/B;;;;;;;;;;;OAWG;IACH,cAAc,CAAC,EAAE,YAAY,EAAE,CAAC;IAEhC;;;;;;;;;;OAUG;IACH,iBAAiB,CAAC,EAAE,YAAY,EAAE,CAAC;CACnC;AAED;;GAEG;AACH,MAAM,MAAM,iBAAiB,GAAG,CAAC,UAAU,EAAE,UAAU,KAAK,IAAI,CAAC;AAEjE;;GAEG;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,UAAU,EAAE,UAAU,KAAK,IAAI,CAAC;AAEpE;;GAEG;AACH,MAAM,WAAW,kBAAkB;IAClC;;;;;;;;;;;;;;;;OAgBG;IACH,cAAc,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI,CAAC;IAE7C;;;;;;;;OAQG;IACH,aAAa,IAAI,IAAI,CAAC;IAEtB;;;;;;;;;;;OAWG;IACH,YAAY,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI,CAAC;IAE3C;;;;;;;;;;;OAWG;IACH,gBAAgB,IAAI,cAAc,CAAC;IAEnC;;;;;;;;;;;;;;OAcG;IACH,WAAW,CAAC,QAAQ,EAAE,iBAAiB,GAAG,IAAI,CAAC;IAE/C;;;;;;;;;;;;;OAaG;IACH,cAAc,CAAC,QAAQ,EAAE,oBAAoB,GAAG,IAAI,CAAC;IAErD;;;;;;;;;;;;;;OAcG;IACH,WAAW,IAAI,UAAU,EAAE,CAAC;CAC5B"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;GAEG;AACH,MAAM,WAAW,UAAU;IAC1B;;;;;OAKG;IACH,QAAQ,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,YAAY,EAAE,MAAM,CAAC;IAErB;;;;OAIG;IACH,UAAU,EAAE,MAAM,CAAC;IAEnB;;;OAGG;IACH,GAAG,EAAE,MAAM,CAAC;IAEZ;;;OAGG;IACH,GAAG,EAAE,MAAM,CAAC;IAEZ;;;;OAIG;IACH,YAAY,EAAE,MAAM,CAAC;IAErB;;;OAGG;IACH,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAE3B;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IAEpB;;OAEG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;OAEG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC5B;;OAEG;IACH,GAAG,EAAE,MAAM,CAAC;IAEZ;;OAEG;IACH,GAAG,EAAE,MAAM,CAAC;CACZ;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC9B;;;;;;;;;;;;;OAaG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAExC;;;;;;;;;;OAUG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAE7D;;;;;;;;;;OAUG;IACH,aAAa,CAAC,EAAE,YAAY,EAAE,CAAC;IAE/B;;;;;;;;;;;OAWG;IACH,cAAc,CAAC,EAAE,YAAY,EAAE,CAAC;IAEhC;;;;;;;;;;OAUG;IACH,iBAAiB,CAAC,EAAE,YAAY,EAAE,CAAC;CACnC;AAED;;GAEG;AACH,MAAM,MAAM,iBAAiB,GAAG,CAAC,UAAU,EAAE,UAAU,KAAK,IAAI,CAAC;AAEjE;;GAEG;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,UAAU,EAAE,UAAU,KAAK,IAAI,CAAC;AAEpE;;GAEG;AACH,MAAM,WAAW,kBAAkB;IAClC;;;;;;;;;;;;;;;;OAgBG;IACH,cAAc,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI,CAAC;IAE7C;;;;;;;;OAQG;IACH,aAAa,IAAI,IAAI,CAAC;IAEtB;;;;;;;;;;;OAWG;IACH,YAAY,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI,CAAC;IAE3C;;;;;;;;;;;OAWG;IACH,gBAAgB,IAAI,cAAc,CAAC;IAEnC;;;;;;;;;;;;;;OAcG;IACH,WAAW,CAAC,QAAQ,EAAE,iBAAiB,GAAG,IAAI,CAAC;IAE/C;;;;;;;;;;;;;OAaG;IACH,cAAc,CAAC,QAAQ,EAAE,oBAAoB,GAAG,IAAI,CAAC;IAErD;;;;;;;;;;;;;;OAcG;IACH,WAAW,IAAI,UAAU,EAAE,CAAC;CAC5B"}
package/native/addon.cc CHANGED
@@ -95,15 +95,22 @@ Napi::Value ListDevices(const Napi::CallbackInfo& info) {
95
95
 
96
96
  Napi::Array result = Napi::Array::New(env, devices.size());
97
97
  for (size_t i = 0; i < devices.size(); i++) {
98
+ const DeviceInfo& d = devices[i];
98
99
  Napi::Object obj = Napi::Object::New(env);
99
- obj.Set("deviceId", Napi::String::New(env, devices[i].deviceId));
100
- obj.Set("serialNumber", Napi::String::New(env, devices[i].serialNumber));
101
- obj.Set("deviceName", Napi::String::New(env, devices[i].deviceName));
102
- obj.Set("vid", Napi::Number::New(env, devices[i].vid));
103
- obj.Set("pid", Napi::Number::New(env, devices[i].pid));
104
- obj.Set("locationInfo", Napi::String::New(env, devices[i].locationInfo));
105
- // logicalPort is always null from native - it's set by JavaScript based on config
100
+ obj.Set("deviceId", Napi::String::New(env, d.deviceId));
101
+ obj.Set("serialNumber", Napi::String::New(env, d.serialNumber));
102
+ obj.Set("deviceName", Napi::String::New(env, d.deviceName));
103
+ obj.Set("vid", Napi::Number::New(env, d.vid));
104
+ obj.Set("pid", Napi::Number::New(env, d.pid));
105
+ obj.Set("locationInfo", Napi::String::New(env, d.locationInfo));
106
106
  obj.Set("logicalPort", env.Null());
107
+ Napi::Array pathArr = Napi::Array::New(env, d.portPath.size());
108
+ for (size_t j = 0; j < d.portPath.size(); j++) {
109
+ pathArr[j] = Napi::Number::New(env, d.portPath[j]);
110
+ }
111
+ obj.Set("portPath", pathArr);
112
+ obj.Set("parentHubVid", Napi::Number::New(env, d.parentHubVid));
113
+ obj.Set("parentHubPid", Napi::Number::New(env, d.parentHubPid));
107
114
  result[i] = obj;
108
115
  }
109
116
 
@@ -21,6 +21,9 @@ struct DeviceInfo {
21
21
  uint16_t pid{0}; // Product ID
22
22
  std::string locationInfo; // Physical port location (platform-specific format)
23
23
  int logicalPort{-1}; // User-defined logical port number (-1 if not mapped)
24
+ std::vector<int> portPath; // Port numbers from root to device (e.g. [2, 3, 1])
25
+ uint16_t parentHubVid{0}; // VID of hub device is directly connected to (0 if on root)
26
+ uint16_t parentHubPid{0}; // PID of that hub
24
27
  };
25
28
 
26
29
  /**
@@ -197,6 +197,13 @@ void USBListener::HandleDeviceChange(WPARAM wParam, LPARAM lParam) {
197
197
  } else {
198
198
  obj.Set("logicalPort", env.Null());
199
199
  }
200
+ Napi::Array pathArr = Napi::Array::New(env, data->portPath.size());
201
+ for (size_t j = 0; j < data->portPath.size(); j++) {
202
+ pathArr[j] = Napi::Number::New(env, data->portPath[j]);
203
+ }
204
+ obj.Set("portPath", pathArr);
205
+ obj.Set("parentHubVid", Napi::Number::New(env, data->parentHubVid));
206
+ obj.Set("parentHubPid", Napi::Number::New(env, data->parentHubPid));
200
207
  jsCallback.Call({obj});
201
208
  delete data;
202
209
  };
@@ -215,6 +222,13 @@ void USBListener::HandleDeviceChange(WPARAM wParam, LPARAM lParam) {
215
222
  } else {
216
223
  obj.Set("logicalPort", env.Null());
217
224
  }
225
+ Napi::Array pathArr = Napi::Array::New(env, data->portPath.size());
226
+ for (size_t j = 0; j < data->portPath.size(); j++) {
227
+ pathArr[j] = Napi::Number::New(env, data->portPath[j]);
228
+ }
229
+ obj.Set("portPath", pathArr);
230
+ obj.Set("parentHubVid", Napi::Number::New(env, data->parentHubVid));
231
+ obj.Set("parentHubPid", Napi::Number::New(env, data->parentHubPid));
218
232
  jsCallback.Call({obj});
219
233
  delete data;
220
234
  };
@@ -325,6 +339,7 @@ bool USBListener::GetDeviceInfo(const std::string& devicePath, DeviceInfo& info)
325
339
  // Get device name
326
340
  info.deviceName = GetDeviceName(deviceInfoSet, devInfoData);
327
341
 
342
+ FillPortPathAndParentHub(devInst, info);
328
343
  SetupDiDestroyDeviceInfoList(deviceInfoSet);
329
344
  return true;
330
345
  }
@@ -349,6 +364,87 @@ bool USBListener::GetLocationInfo(DEVINST devInst, std::string& locationInfo) {
349
364
  return true;
350
365
  }
351
366
 
367
+ /**
368
+ * Parse port number from Windows location string.
369
+ * Supports "Port_#0005.Hub_#0002" (returns 5) and dot-separated format (last non-zero segment).
370
+ */
371
+ bool USBListener::ParsePortFromLocation(const std::string& locationInfo, int& port) {
372
+ if (locationInfo.empty()) {
373
+ return false;
374
+ }
375
+ // Format: Port_#0005.Hub_#0002 or Port_#0005
376
+ size_t pos = locationInfo.find("Port_#");
377
+ if (pos != std::string::npos) {
378
+ pos += 6; // length of "Port_#"
379
+ size_t end = pos;
380
+ while (end < locationInfo.size() && (std::isxdigit(static_cast<unsigned char>(locationInfo[end])) || locationInfo[end] == '.')) {
381
+ if (locationInfo[end] == '.') break;
382
+ ++end;
383
+ }
384
+ if (end > pos) {
385
+ try {
386
+ port = std::stoi(locationInfo.substr(pos, end - pos), nullptr, 10);
387
+ return true;
388
+ } catch (...) {
389
+ // fall through to dot-separated
390
+ }
391
+ }
392
+ }
393
+ // Dot-separated format (e.g. 0000.001a.0000.001.003)
394
+ size_t last = locationInfo.rfind('.');
395
+ std::string segment = (last != std::string::npos && last + 1 < locationInfo.size())
396
+ ? locationInfo.substr(last + 1) : locationInfo;
397
+ if (!segment.empty()) {
398
+ try {
399
+ port = std::stoi(segment, nullptr, 10);
400
+ return true;
401
+ } catch (...) {}
402
+ }
403
+ return false;
404
+ }
405
+
406
+ /**
407
+ * Walk device tree from device to root, collect port numbers, set portPath (root-to-device) and parent hub VID/PID.
408
+ */
409
+ void USBListener::FillPortPathAndParentHub(DEVINST devInst, DeviceInfo& info) {
410
+ info.portPath.clear();
411
+ info.parentHubVid = 0;
412
+ info.parentHubPid = 0;
413
+ std::vector<int> pathDeviceToRoot;
414
+ DEVINST current = devInst;
415
+ bool firstParent = true;
416
+ for (;;) {
417
+ std::string location;
418
+ if (!GetLocationInfo(current, location)) {
419
+ break;
420
+ }
421
+ int portNum = 0;
422
+ if (ParsePortFromLocation(location, portNum)) {
423
+ pathDeviceToRoot.push_back(portNum);
424
+ }
425
+ DEVINST parentInst = 0;
426
+ CONFIGRET cr = CM_Get_Parent(&parentInst, current, 0);
427
+ if (cr != CR_SUCCESS || parentInst == 0 || parentInst == current) {
428
+ break;
429
+ }
430
+ if (firstParent) {
431
+ firstParent = false;
432
+ WCHAR deviceIdBuffer[256];
433
+ deviceIdBuffer[0] = 0;
434
+ if (CM_Get_Device_IDW(parentInst, deviceIdBuffer, 256, 0) == CR_SUCCESS) {
435
+ std::wstring idW = deviceIdBuffer;
436
+ std::string parentId(idW.begin(), idW.end());
437
+ GetVidPid(parentId, info.parentHubVid, info.parentHubPid);
438
+ }
439
+ }
440
+ current = parentInst;
441
+ }
442
+ // Reverse so portPath is root-to-device
443
+ for (auto it = pathDeviceToRoot.rbegin(); it != pathDeviceToRoot.rend(); ++it) {
444
+ info.portPath.push_back(*it);
445
+ }
446
+ }
447
+
352
448
  bool USBListener::GetVidPid(const std::string& deviceId, uint16_t& vid, uint16_t& pid) {
353
449
  size_t vidPos = deviceId.find("VID_");
354
450
  size_t pidPos = deviceId.find("PID_");
@@ -451,6 +547,7 @@ void USBListener::EnumerateConnectedDevices() {
451
547
  // Get device name
452
548
  info.deviceName = GetDeviceName(deviceInfoSet, devInfoData);
453
549
 
550
+ FillPortPathAndParentHub(devInst, info);
454
551
  // Cache device with exclusive lock
455
552
  {
456
553
  std::unique_lock lock(m_cacheMutex);
@@ -471,6 +568,13 @@ void USBListener::EnumerateConnectedDevices() {
471
568
  } else {
472
569
  obj.Set("logicalPort", env.Null());
473
570
  }
571
+ Napi::Array pathArr = Napi::Array::New(env, data->portPath.size());
572
+ for (size_t j = 0; j < data->portPath.size(); j++) {
573
+ pathArr[j] = Napi::Number::New(env, data->portPath[j]);
574
+ }
575
+ obj.Set("portPath", pathArr);
576
+ obj.Set("parentHubVid", Napi::Number::New(env, data->parentHubVid));
577
+ obj.Set("parentHubPid", Napi::Number::New(env, data->parentHubPid));
474
578
  jsCallback.Call({obj});
475
579
  delete data;
476
580
  };
@@ -520,6 +624,7 @@ std::vector<DeviceInfo> USBListener::ListAllDevices() {
520
624
  // Get device name
521
625
  info.deviceName = GetDeviceName(deviceInfoSet, devInfoData);
522
626
 
627
+ FillPortPathAndParentHub(devInst, info);
523
628
  devices.push_back(info);
524
629
  }
525
630
  }
@@ -74,6 +74,8 @@ private:
74
74
  bool GetDeviceInfo(const std::string& devicePath, DeviceInfo& info); // Get device info from Windows API (for connections)
75
75
  bool GetDeviceInfoFromPath(const std::string& devicePath, DeviceInfo& info); // Parse device info from path (for disconnections)
76
76
  bool GetLocationInfo(DEVINST devInst, std::string& locationInfo); // Get physical port location
77
+ bool ParsePortFromLocation(const std::string& locationInfo, int& port); // Parse port number from location string
78
+ void FillPortPathAndParentHub(DEVINST devInst, DeviceInfo& info); // Walk device tree, set portPath and parentHubVid/Pid
77
79
  std::string GetSerialNumber(HDEVINFO deviceInfoSet, SP_DEVINFO_DATA& devInfoData); // Get USB serial number
78
80
  std::string GetDeviceName(HDEVINFO deviceInfoSet, SP_DEVINFO_DATA& devInfoData); // Get device description/name
79
81
  bool GetVidPid(const std::string& deviceId, uint16_t& vid, uint16_t& pid); // Parse VID/PID from device ID string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcesystems/usb-device-listener",
3
- "version": "1.0.76",
3
+ "version": "1.0.78",
4
4
  "description": "Native cross-platform USB device listener for Windows and macOS",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",