@mcesystems/usb-device-listener 1.0.15 → 1.0.17

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/binding.gyp ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "targets": [
3
+ {
4
+ "target_name": "usb_device_listener",
5
+ "sources": [
6
+ "native/addon.cc",
7
+ "native/usb_listener.cc"
8
+ ],
9
+ "include_dirs": [
10
+ "<!@(node -p \"require('node-addon-api').include\")"
11
+ ],
12
+ "dependencies": [
13
+ "<!(node -p \"require('node-addon-api').gyp\")"
14
+ ],
15
+ "defines": [
16
+ "NAPI_DISABLE_CPP_EXCEPTIONS",
17
+ "UNICODE",
18
+ "_UNICODE"
19
+ ],
20
+ "conditions": [
21
+ [
22
+ "OS=='win'",
23
+ {
24
+ "libraries": [
25
+ "Setupapi.lib",
26
+ "Cfgmgr32.lib"
27
+ ],
28
+ "msvs_settings": {
29
+ "VCCLCompilerTool": {
30
+ "ExceptionHandling": 1,
31
+ "AdditionalOptions": [
32
+ "/std:c++20"
33
+ ]
34
+ }
35
+ }
36
+ }
37
+ ]
38
+ ]
39
+ }
40
+ ]
41
+ }
package/dist/index.cjs ADDED
@@ -0,0 +1,155 @@
1
+ const __importMetaUrl = require("url").pathToFileURL(__filename).href;
2
+ if (typeof import.meta === "undefined") {
3
+ Object.defineProperty(globalThis, "import", { value: { meta: { url: __importMetaUrl } } });
4
+ }
5
+ "use strict";
6
+ var __create = Object.create;
7
+ var __defProp = Object.defineProperty;
8
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
9
+ var __getOwnPropNames = Object.getOwnPropertyNames;
10
+ var __getProtoOf = Object.getPrototypeOf;
11
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
12
+ var __export = (target, all) => {
13
+ for (var name in all)
14
+ __defProp(target, name, { get: all[name], enumerable: true });
15
+ };
16
+ var __copyProps = (to, from, except, desc) => {
17
+ if (from && typeof from === "object" || typeof from === "function") {
18
+ for (let key of __getOwnPropNames(from))
19
+ if (!__hasOwnProp.call(to, key) && key !== except)
20
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
21
+ }
22
+ return to;
23
+ };
24
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
25
+ // If the importer is in node compatibility mode or this is not an ESM
26
+ // file that has been converted to a CommonJS file using a Babel-
27
+ // compatible transform (i.e. "__esModule" has not been set), then set
28
+ // "default" to the CommonJS "module.exports" for node compatibility.
29
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
30
+ mod
31
+ ));
32
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
33
+
34
+ // src/index.ts
35
+ var index_exports = {};
36
+ __export(index_exports, {
37
+ default: () => UsbDeviceListener
38
+ });
39
+ module.exports = __toCommonJS(index_exports);
40
+ var import_node_module = require("node:module");
41
+ var import_node_path = __toESM(require("node:path"), 1);
42
+ var import_node_url = require("node:url");
43
+
44
+ // src/device-filter.ts
45
+ function toHexString(value) {
46
+ return value.toString(16).toUpperCase().padStart(4, "0");
47
+ }
48
+ function matchesDevice(device, target) {
49
+ const deviceVid = toHexString(device.vid);
50
+ const devicePid = toHexString(device.pid);
51
+ const targetVid = target.vid.toUpperCase();
52
+ const targetPid = target.pid.toUpperCase();
53
+ return deviceVid === targetVid && devicePid === targetPid;
54
+ }
55
+ function matchesAnyDevice(device, targets) {
56
+ return targets.some((target) => matchesDevice(device, target));
57
+ }
58
+ function shouldNotifyDevice(device, config) {
59
+ if (config.ignoredDevices && config.ignoredDevices.length > 0) {
60
+ if (matchesAnyDevice(device, config.ignoredDevices)) {
61
+ return false;
62
+ }
63
+ }
64
+ if (config.listenOnlyDevices && config.listenOnlyDevices.length > 0) {
65
+ if (!matchesAnyDevice(device, config.listenOnlyDevices)) {
66
+ return false;
67
+ }
68
+ }
69
+ if (config.targetDevices && config.targetDevices.length > 0) {
70
+ if (!matchesAnyDevice(device, config.targetDevices)) {
71
+ return false;
72
+ }
73
+ }
74
+ if (config.logicalPortMap && Object.keys(config.logicalPortMap).length > 0) {
75
+ if (!(device.locationInfo in config.logicalPortMap)) {
76
+ return false;
77
+ }
78
+ }
79
+ return true;
80
+ }
81
+ function applyLogicalPortMapping(device, config) {
82
+ if (config.logicalPortMap && device.locationInfo in config.logicalPortMap) {
83
+ return {
84
+ ...device,
85
+ logicalPort: config.logicalPortMap[device.locationInfo]
86
+ };
87
+ }
88
+ return device;
89
+ }
90
+
91
+ // src/index.ts
92
+ var __filename = (0, import_node_url.fileURLToPath)(__importMetaUrl);
93
+ var __dirname = import_node_path.default.dirname(__filename);
94
+ var require2 = (0, import_node_module.createRequire)(__importMetaUrl);
95
+ var packageRoot = import_node_path.default.join(__dirname, "..");
96
+ var addon = require2("node-gyp-build")(packageRoot);
97
+ var { listDevices, onDeviceAdd, onDeviceRemove, startListening, stopListening } = addon;
98
+ var UsbDeviceListener = class {
99
+ config = {};
100
+ userAddCallback = null;
101
+ userRemoveCallback = null;
102
+ /**
103
+ * Start listening for USB device events
104
+ */
105
+ startListening(config) {
106
+ if (typeof config !== "object" || config === null) {
107
+ throw new TypeError("Config must be an object");
108
+ }
109
+ this.config = config;
110
+ startListening();
111
+ }
112
+ /**
113
+ * Stop listening for USB device events
114
+ */
115
+ stopListening() {
116
+ stopListening();
117
+ }
118
+ /**
119
+ * Register callback for device connection events
120
+ */
121
+ onDeviceAdd(callback) {
122
+ if (typeof callback !== "function") {
123
+ throw new TypeError("Callback must be a function");
124
+ }
125
+ this.userAddCallback = callback;
126
+ onDeviceAdd((device) => {
127
+ if (shouldNotifyDevice(device, this.config)) {
128
+ const enrichedDevice = applyLogicalPortMapping(device, this.config);
129
+ this.userAddCallback?.(enrichedDevice);
130
+ }
131
+ });
132
+ }
133
+ /**
134
+ * Register callback for device disconnection events
135
+ */
136
+ onDeviceRemove(callback) {
137
+ if (typeof callback !== "function") {
138
+ throw new TypeError("Callback must be a function");
139
+ }
140
+ this.userRemoveCallback = callback;
141
+ onDeviceRemove((device) => {
142
+ if (shouldNotifyDevice(device, this.config)) {
143
+ const enrichedDevice = applyLogicalPortMapping(device, this.config);
144
+ this.userRemoveCallback?.(enrichedDevice);
145
+ }
146
+ });
147
+ }
148
+ /**
149
+ * List all currently connected USB devices
150
+ */
151
+ listDevices() {
152
+ return listDevices();
153
+ }
154
+ };
155
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1,7 @@
1
+ {
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\ninterface NativeAddon {\n\tstartListening(): void;\n\tstopListening(): void;\n\tonDeviceAdd(callback: DeviceAddCallback): void;\n\tonDeviceRemove(callback: DeviceRemoveCallback): void;\n\tlistDevices(): DeviceInfo[];\n}\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\n// Load native addon using require (CJS) from ESM context\nconst require = createRequire(import.meta.url);\nconst packageRoot = path.join(__dirname, \"..\");\nconst addon: NativeAddon = require(\"node-gyp-build\")(packageRoot);\n\nconst { listDevices, onDeviceAdd, onDeviceRemove, startListening, stopListening } = addon;\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 config: ListenerConfig = {};\n\tprivate userAddCallback: DeviceAddCallback | null = null;\n\tprivate userRemoveCallback: DeviceRemoveCallback | null = null;\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\tstartListening();\n\t}\n\n\t/**\n\t * Stop listening for USB device events\n\t */\n\tpublic stopListening(): void {\n\t\tstopListening();\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\tonDeviceAdd((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\tonDeviceRemove((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 */\n\tpublic listDevices(): DeviceInfo[] {\n\t\treturn listDevices();\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": ["path", "require"]
7
+ }
package/dist/index.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/index.ts", "../src/device-filter.ts"],
4
- "sourcesContent": ["import { createRequire } from \"node:module\";\r\nimport path from \"node:path\";\r\nimport { fileURLToPath } from \"node:url\";\r\nimport { applyLogicalPortMapping, shouldNotifyDevice } from \"./device-filter\";\r\nimport type {\r\n\tDeviceAddCallback,\r\n\tDeviceInfo,\r\n\tDeviceRemoveCallback,\r\n\tListenerConfig,\r\n\tUsbDeviceListenerI,\r\n} from \"./types\";\r\n\r\ninterface NativeAddon {\r\n\tstartListening(): void;\r\n\tstopListening(): void;\r\n\tonDeviceAdd(callback: DeviceAddCallback): void;\r\n\tonDeviceRemove(callback: DeviceRemoveCallback): void;\r\n\tlistDevices(): DeviceInfo[];\r\n}\r\n\r\nconst __filename = fileURLToPath(import.meta.url);\r\nconst __dirname = path.dirname(__filename);\r\n\r\n// Load native addon using require (CJS) from ESM context\r\nconst require = createRequire(import.meta.url);\r\nconst packageRoot = path.join(__dirname, \"..\");\r\nconst addon: NativeAddon = require(\"node-gyp-build\")(packageRoot);\r\n\r\nconst { listDevices, onDeviceAdd, onDeviceRemove, startListening, stopListening } = addon;\r\n\r\n/**\r\n * USB Device Listener implementation\r\n * Provides a type-safe wrapper around the native C++ addon\r\n */\r\nexport default class UsbDeviceListener implements UsbDeviceListenerI {\r\n\tprivate config: ListenerConfig = {};\r\n\tprivate userAddCallback: DeviceAddCallback | null = null;\r\n\tprivate userRemoveCallback: DeviceRemoveCallback | null = null;\r\n\r\n\t/**\r\n\t * Start listening for USB device events\r\n\t */\r\n\tpublic startListening(config: ListenerConfig): void {\r\n\t\tif (typeof config !== \"object\" || config === null) {\r\n\t\t\tthrow new TypeError(\"Config must be an object\");\r\n\t\t}\r\n\t\tthis.config = config;\r\n\t\tstartListening();\r\n\t}\r\n\r\n\t/**\r\n\t * Stop listening for USB device events\r\n\t */\r\n\tpublic stopListening(): void {\r\n\t\tstopListening();\r\n\t}\r\n\r\n\t/**\r\n\t * Register callback for device connection events\r\n\t */\r\n\tpublic onDeviceAdd(callback: DeviceAddCallback): void {\r\n\t\tif (typeof callback !== \"function\") {\r\n\t\t\tthrow new TypeError(\"Callback must be a function\");\r\n\t\t}\r\n\t\tthis.userAddCallback = callback;\r\n\r\n\t\t// Set up internal callback that filters devices\r\n\t\tonDeviceAdd((device: DeviceInfo) => {\r\n\t\t\tif (shouldNotifyDevice(device, this.config)) {\r\n\t\t\t\tconst enrichedDevice = applyLogicalPortMapping(device, this.config);\r\n\t\t\t\tthis.userAddCallback?.(enrichedDevice);\r\n\t\t\t}\r\n\t\t});\r\n\t}\r\n\r\n\t/**\r\n\t * Register callback for device disconnection events\r\n\t */\r\n\tpublic onDeviceRemove(callback: DeviceRemoveCallback): void {\r\n\t\tif (typeof callback !== \"function\") {\r\n\t\t\tthrow new TypeError(\"Callback must be a function\");\r\n\t\t}\r\n\t\tthis.userRemoveCallback = callback;\r\n\r\n\t\t// Set up internal callback that filters devices\r\n\t\tonDeviceRemove((device: DeviceInfo) => {\r\n\t\t\tif (shouldNotifyDevice(device, this.config)) {\r\n\t\t\t\tconst enrichedDevice = applyLogicalPortMapping(device, this.config);\r\n\t\t\t\tthis.userRemoveCallback?.(enrichedDevice);\r\n\t\t\t}\r\n\t\t});\r\n\t}\r\n\r\n\t/**\r\n\t * List all currently connected USB devices\r\n\t */\r\n\tpublic listDevices(): DeviceInfo[] {\r\n\t\treturn listDevices();\r\n\t}\r\n}\r\n\r\nexport type {\r\n\tDeviceInfo,\r\n\tDeviceAddCallback,\r\n\tDeviceRemoveCallback,\r\n\tListenerConfig,\r\n\tTargetDevice,\r\n} from \"./types\";\r\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"],
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\ninterface NativeAddon {\n\tstartListening(): void;\n\tstopListening(): void;\n\tonDeviceAdd(callback: DeviceAddCallback): void;\n\tonDeviceRemove(callback: DeviceRemoveCallback): void;\n\tlistDevices(): DeviceInfo[];\n}\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\n// Load native addon using require (CJS) from ESM context\nconst require = createRequire(import.meta.url);\nconst packageRoot = path.join(__dirname, \"..\");\nconst addon: NativeAddon = require(\"node-gyp-build\")(packageRoot);\n\nconst { listDevices, onDeviceAdd, onDeviceRemove, startListening, stopListening } = addon;\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 config: ListenerConfig = {};\n\tprivate userAddCallback: DeviceAddCallback | null = null;\n\tprivate userRemoveCallback: DeviceRemoveCallback | null = null;\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\tstartListening();\n\t}\n\n\t/**\n\t * Stop listening for USB device events\n\t */\n\tpublic stopListening(): void {\n\t\tstopListening();\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\tonDeviceAdd((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\tonDeviceRemove((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 */\n\tpublic listDevices(): DeviceInfo[] {\n\t\treturn listDevices();\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
5
  "mappings": "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",
6
6
  "names": ["require"]
7
7
  }
@@ -0,0 +1,118 @@
1
+ #include <napi.h>
2
+ #include "usb_listener.h"
3
+ #include <memory>
4
+
5
+ static std::unique_ptr<USBListener> g_listener;
6
+
7
+ Napi::Value StartListening(const Napi::CallbackInfo& info) {
8
+ Napi::Env env = info.Env();
9
+
10
+ if (!g_listener) {
11
+ g_listener = std::make_unique<USBListener>();
12
+ }
13
+
14
+ std::string errorMsg;
15
+ if (!g_listener->StartListening(errorMsg)) {
16
+ Napi::Error::New(env, errorMsg).ThrowAsJavaScriptException();
17
+ return env.Undefined();
18
+ }
19
+
20
+ return env.Undefined();
21
+ }
22
+
23
+ Napi::Value StopListening(const Napi::CallbackInfo& info) {
24
+ Napi::Env env = info.Env();
25
+
26
+ if (g_listener) {
27
+ g_listener->StopListening();
28
+ }
29
+
30
+ return env.Undefined();
31
+ }
32
+
33
+ Napi::Value OnDeviceAdd(const Napi::CallbackInfo& info) {
34
+ Napi::Env env = info.Env();
35
+
36
+ if (info.Length() < 1 || !info[0].IsFunction()) {
37
+ Napi::TypeError::New(env, "Callback function expected").ThrowAsJavaScriptException();
38
+ return env.Undefined();
39
+ }
40
+
41
+ Napi::Function callback = info[0].As<Napi::Function>();
42
+
43
+ if (!g_listener) {
44
+ g_listener = std::make_unique<USBListener>();
45
+ }
46
+
47
+ Napi::ThreadSafeFunction tsfn = Napi::ThreadSafeFunction::New(
48
+ env,
49
+ callback,
50
+ "DeviceAddCallback",
51
+ 0,
52
+ 1
53
+ );
54
+
55
+ g_listener->SetAddCallback(tsfn);
56
+
57
+ return env.Undefined();
58
+ }
59
+
60
+ Napi::Value OnDeviceRemove(const Napi::CallbackInfo& info) {
61
+ Napi::Env env = info.Env();
62
+
63
+ if (info.Length() < 1 || !info[0].IsFunction()) {
64
+ Napi::TypeError::New(env, "Callback function expected").ThrowAsJavaScriptException();
65
+ return env.Undefined();
66
+ }
67
+
68
+ Napi::Function callback = info[0].As<Napi::Function>();
69
+
70
+ if (!g_listener) {
71
+ g_listener = std::make_unique<USBListener>();
72
+ }
73
+
74
+ Napi::ThreadSafeFunction tsfn = Napi::ThreadSafeFunction::New(
75
+ env,
76
+ callback,
77
+ "DeviceRemoveCallback",
78
+ 0,
79
+ 1
80
+ );
81
+
82
+ g_listener->SetRemoveCallback(tsfn);
83
+
84
+ return env.Undefined();
85
+ }
86
+
87
+ Napi::Value ListDevices(const Napi::CallbackInfo& info) {
88
+ Napi::Env env = info.Env();
89
+
90
+ if (!g_listener) {
91
+ g_listener = std::make_unique<USBListener>();
92
+ }
93
+
94
+ std::vector<DeviceInfo> devices = g_listener->ListAllDevices();
95
+
96
+ Napi::Array result = Napi::Array::New(env, devices.size());
97
+ for (size_t i = 0; i < devices.size(); i++) {
98
+ Napi::Object obj = Napi::Object::New(env);
99
+ obj.Set("deviceId", Napi::String::New(env, devices[i].deviceId));
100
+ obj.Set("vid", Napi::Number::New(env, devices[i].vid));
101
+ obj.Set("pid", Napi::Number::New(env, devices[i].pid));
102
+ obj.Set("locationInfo", Napi::String::New(env, devices[i].locationInfo));
103
+ result[i] = obj;
104
+ }
105
+
106
+ return result;
107
+ }
108
+
109
+ Napi::Object Init(Napi::Env env, Napi::Object exports) {
110
+ exports.Set("startListening", Napi::Function::New(env, StartListening));
111
+ exports.Set("stopListening", Napi::Function::New(env, StopListening));
112
+ exports.Set("onDeviceAdd", Napi::Function::New(env, OnDeviceAdd));
113
+ exports.Set("onDeviceRemove", Napi::Function::New(env, OnDeviceRemove));
114
+ exports.Set("listDevices", Napi::Function::New(env, ListDevices));
115
+ return exports;
116
+ }
117
+
118
+ NODE_API_MODULE(usb_device_listener, Init)
@@ -0,0 +1,414 @@
1
+ #include "usb_listener.h"
2
+ #include <devguid.h>
3
+ #include <usbiodef.h> // For GUID_DEVINTERFACE_USB_DEVICE
4
+ #include <sstream>
5
+ #include <iomanip>
6
+ #include <algorithm>
7
+
8
+ USBListener::USBListener() = default;
9
+
10
+ USBListener::~USBListener() {
11
+ StopListening();
12
+ }
13
+
14
+ /**
15
+ * Start the USB device listener
16
+ * Creates a separate thread that runs a Windows message loop to receive device notifications
17
+ */
18
+ bool USBListener::StartListening(std::string& errorMsg) {
19
+ bool expected = false;
20
+ if (!m_running.compare_exchange_strong(expected, true)) {
21
+ errorMsg = "Listener already running";
22
+ return false;
23
+ }
24
+
25
+ // Create thread with RAII wrapper
26
+ HANDLE threadHandle = CreateThread(nullptr, 0, ListenerThreadProc, this, 0, nullptr);
27
+ if (!threadHandle) {
28
+ m_running = false;
29
+ errorMsg = "Failed to create listener thread";
30
+ return false;
31
+ }
32
+ m_thread = ThreadHandle(threadHandle);
33
+
34
+ // Brief wait for thread initialization
35
+ Sleep(100);
36
+ EnumerateConnectedDevices();
37
+
38
+ return true;
39
+ }
40
+
41
+ void USBListener::StopListening() {
42
+ bool expected = true;
43
+ if (!m_running.compare_exchange_strong(expected, false)) {
44
+ return; // Already stopped
45
+ }
46
+
47
+ // Signal message loop to quit
48
+ if (m_hwnd) {
49
+ PostMessage(m_hwnd, WM_QUIT, 0, 0);
50
+ }
51
+
52
+ // Thread cleanup is automatic via ThreadHandle RAII
53
+ // Destructor will wait and close handle
54
+ m_thread = ThreadHandle(); // Reset to trigger cleanup
55
+
56
+ // Release ThreadSafeFunction callbacks
57
+ if (m_addCallback) {
58
+ m_addCallback.Release();
59
+ }
60
+ if (m_removeCallback) {
61
+ m_removeCallback.Release();
62
+ }
63
+
64
+ // Clear device cache
65
+ std::unique_lock lock(m_cacheMutex);
66
+ m_deviceCache.clear();
67
+ }
68
+
69
+ void USBListener::SetAddCallback(Napi::ThreadSafeFunction callback) {
70
+ m_addCallback = callback;
71
+ }
72
+
73
+ void USBListener::SetRemoveCallback(Napi::ThreadSafeFunction callback) {
74
+ m_removeCallback = callback;
75
+ }
76
+
77
+ DWORD WINAPI USBListener::ListenerThreadProc(LPVOID param) {
78
+ USBListener* listener = static_cast<USBListener*>(param);
79
+ listener->MessageLoop();
80
+ return 0;
81
+ }
82
+
83
+ void USBListener::MessageLoop() {
84
+ WNDCLASSEX wc = {0};
85
+ wc.cbSize = sizeof(WNDCLASSEX);
86
+ wc.lpfnWndProc = WindowProc;
87
+ wc.hInstance = GetModuleHandle(nullptr);
88
+ wc.lpszClassName = L"USBListenerWindow";
89
+
90
+ if (!RegisterClassEx(&wc)) {
91
+ return;
92
+ }
93
+
94
+ m_hwnd = CreateWindowEx(0, L"USBListenerWindow", L"USBListener", 0, 0, 0, 0, 0, HWND_MESSAGE, nullptr, GetModuleHandle(nullptr), nullptr);
95
+ if (!m_hwnd) {
96
+ return;
97
+ }
98
+
99
+ SetWindowLongPtr(m_hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(this));
100
+
101
+ DEV_BROADCAST_DEVICEINTERFACE notificationFilter = {0};
102
+ notificationFilter.dbcc_size = sizeof(DEV_BROADCAST_DEVICEINTERFACE);
103
+ notificationFilter.dbcc_devicetype = DBT_DEVTYP_DEVICEINTERFACE;
104
+ notificationFilter.dbcc_classguid = GUID_DEVINTERFACE_USB_DEVICE;
105
+
106
+ m_notifyHandle = RegisterDeviceNotification(m_hwnd, &notificationFilter, DEVICE_NOTIFY_WINDOW_HANDLE);
107
+
108
+ MSG msg;
109
+ while (m_running && GetMessage(&msg, nullptr, 0, 0)) {
110
+ TranslateMessage(&msg);
111
+ DispatchMessage(&msg);
112
+ }
113
+
114
+ if (m_notifyHandle) {
115
+ UnregisterDeviceNotification(m_notifyHandle);
116
+ m_notifyHandle = nullptr;
117
+ }
118
+
119
+ if (m_hwnd) {
120
+ DestroyWindow(m_hwnd);
121
+ m_hwnd = nullptr;
122
+ }
123
+
124
+ UnregisterClass(L"USBListenerWindow", GetModuleHandle(nullptr));
125
+ }
126
+
127
+ LRESULT CALLBACK USBListener::WindowProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
128
+ if (msg == WM_DEVICECHANGE) {
129
+ USBListener* listener = reinterpret_cast<USBListener*>(GetWindowLongPtr(hwnd, GWLP_USERDATA));
130
+ if (listener) {
131
+ listener->HandleDeviceChange(wParam, lParam);
132
+ }
133
+ }
134
+ return DefWindowProc(hwnd, msg, wParam, lParam);
135
+ }
136
+
137
+ /**
138
+ * Handle WM_DEVICECHANGE messages
139
+ * For connections: queries Windows API for full device info
140
+ * For disconnections: retrieves cached info since device is already gone
141
+ */
142
+ void USBListener::HandleDeviceChange(WPARAM wParam, LPARAM lParam) {
143
+ // Only handle device arrival and removal events
144
+ if (wParam != DBT_DEVICEARRIVAL && wParam != DBT_DEVICEREMOVECOMPLETE) {
145
+ return;
146
+ }
147
+
148
+ // Extract device interface information
149
+ DEV_BROADCAST_DEVICEINTERFACE* devInterface = reinterpret_cast<DEV_BROADCAST_DEVICEINTERFACE*>(lParam);
150
+ if (!devInterface || devInterface->dbcc_devicetype != DBT_DEVTYP_DEVICEINTERFACE) {
151
+ return;
152
+ }
153
+
154
+ // Convert device path to string
155
+ std::wstring devicePathW = devInterface->dbcc_name;
156
+ std::string devicePath(devicePathW.begin(), devicePathW.end());
157
+
158
+ DeviceInfo info;
159
+ bool gotInfo = false;
160
+
161
+ if (wParam == DBT_DEVICEARRIVAL) {
162
+ // For connections, query Windows API for full device details
163
+ gotInfo = GetDeviceInfo(devicePath, info);
164
+ } else if (wParam == DBT_DEVICEREMOVECOMPLETE) {
165
+ // For disconnections, device is gone - retrieve from cache
166
+ DeviceInfo pathInfo;
167
+ if (GetDeviceInfoFromPath(devicePath, pathInfo)) {
168
+ std::unique_lock lock(m_cacheMutex); // Exclusive lock for write
169
+ if (auto it = m_deviceCache.find(pathInfo.deviceId); it != m_deviceCache.end()) {
170
+ info = std::move(it->second); // Move for efficiency
171
+ m_deviceCache.erase(it);
172
+ gotInfo = true;
173
+ }
174
+ }
175
+ }
176
+
177
+ if (!gotInfo) {
178
+ return;
179
+ }
180
+
181
+ if (wParam == DBT_DEVICEARRIVAL && m_addCallback) {
182
+ // Cache device info for disconnect events
183
+ {
184
+ std::unique_lock lock(m_cacheMutex); // Exclusive lock for write
185
+ m_deviceCache[info.deviceId] = info;
186
+ }
187
+ auto callback = [](Napi::Env env, Napi::Function jsCallback, DeviceInfo* data) {
188
+ Napi::Object obj = Napi::Object::New(env);
189
+ obj.Set("deviceId", Napi::String::New(env, data->deviceId));
190
+ obj.Set("vid", Napi::Number::New(env, data->vid));
191
+ obj.Set("pid", Napi::Number::New(env, data->pid));
192
+ obj.Set("locationInfo", Napi::String::New(env, data->locationInfo));
193
+ if (data->logicalPort >= 0) {
194
+ obj.Set("logicalPort", Napi::Number::New(env, data->logicalPort));
195
+ } else {
196
+ obj.Set("logicalPort", env.Null());
197
+ }
198
+ jsCallback.Call({obj});
199
+ delete data;
200
+ };
201
+ m_addCallback.BlockingCall(new DeviceInfo(info), callback);
202
+ } else if (wParam == DBT_DEVICEREMOVECOMPLETE && m_removeCallback) {
203
+ auto callback = [](Napi::Env env, Napi::Function jsCallback, DeviceInfo* data) {
204
+ Napi::Object obj = Napi::Object::New(env);
205
+ obj.Set("deviceId", Napi::String::New(env, data->deviceId));
206
+ obj.Set("vid", Napi::Number::New(env, data->vid));
207
+ obj.Set("pid", Napi::Number::New(env, data->pid));
208
+ obj.Set("locationInfo", Napi::String::New(env, data->locationInfo));
209
+ if (data->logicalPort >= 0) {
210
+ obj.Set("logicalPort", Napi::Number::New(env, data->logicalPort));
211
+ } else {
212
+ obj.Set("logicalPort", env.Null());
213
+ }
214
+ jsCallback.Call({obj});
215
+ delete data;
216
+ };
217
+ m_removeCallback.BlockingCall(new DeviceInfo(info), callback);
218
+ }
219
+ }
220
+
221
+ bool USBListener::GetDeviceInfoFromPath(const std::string& devicePath, DeviceInfo& info) {
222
+ size_t hashPos = devicePath.rfind('#');
223
+ if (hashPos == std::string::npos) {
224
+ return false;
225
+ }
226
+
227
+ std::string deviceIdStr = devicePath.substr(4, hashPos - 4);
228
+ std::replace(deviceIdStr.begin(), deviceIdStr.end(), '#', '\\');
229
+
230
+ info.deviceId = deviceIdStr;
231
+
232
+ if (!GetVidPid(info.deviceId, info.vid, info.pid)) {
233
+ return false;
234
+ }
235
+
236
+ size_t lastHash = deviceIdStr.rfind('\\');
237
+ if (lastHash != std::string::npos) {
238
+ info.locationInfo = deviceIdStr.substr(lastHash + 1);
239
+ } else {
240
+ info.locationInfo = "";
241
+ }
242
+
243
+ return true;
244
+ }
245
+
246
+ bool USBListener::GetDeviceInfo(const std::string& devicePath, DeviceInfo& info) {
247
+ size_t hashPos = devicePath.rfind('#');
248
+ if (hashPos == std::string::npos) {
249
+ return false;
250
+ }
251
+
252
+ std::string deviceIdStr = devicePath.substr(4, hashPos - 4);
253
+ std::replace(deviceIdStr.begin(), deviceIdStr.end(), '#', '\\');
254
+
255
+ std::wstring deviceIdW(deviceIdStr.begin(), deviceIdStr.end());
256
+
257
+ HDEVINFO deviceInfoSet = SetupDiGetClassDevs(&GUID_DEVINTERFACE_USB_DEVICE, nullptr, nullptr, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE);
258
+ if (deviceInfoSet == INVALID_HANDLE_VALUE) {
259
+ return false;
260
+ }
261
+
262
+ SP_DEVINFO_DATA devInfoData = {0};
263
+ devInfoData.cbSize = sizeof(SP_DEVINFO_DATA);
264
+
265
+ for (DWORD i = 0; SetupDiEnumDeviceInfo(deviceInfoSet, i, &devInfoData); i++) {
266
+ WCHAR instanceId[MAX_PATH];
267
+ if (SetupDiGetDeviceInstanceId(deviceInfoSet, &devInfoData, instanceId, MAX_PATH, nullptr)) {
268
+ if (_wcsicmp(instanceId, deviceIdW.c_str()) == 0) {
269
+ std::wstring idW = instanceId;
270
+ info.deviceId = std::string(idW.begin(), idW.end());
271
+
272
+ if (!GetVidPid(info.deviceId, info.vid, info.pid)) {
273
+ SetupDiDestroyDeviceInfoList(deviceInfoSet);
274
+ return false;
275
+ }
276
+
277
+ DEVINST devInst = devInfoData.DevInst;
278
+ if (!GetLocationInfo(devInst, info.locationInfo)) {
279
+ SetupDiDestroyDeviceInfoList(deviceInfoSet);
280
+ return false;
281
+ }
282
+
283
+ SetupDiDestroyDeviceInfoList(deviceInfoSet);
284
+ return true;
285
+ }
286
+ }
287
+ }
288
+
289
+ SetupDiDestroyDeviceInfoList(deviceInfoSet);
290
+ return false;
291
+ }
292
+
293
+ bool USBListener::GetLocationInfo(DEVINST devInst, std::string& locationInfo) {
294
+ WCHAR buffer[MAX_PATH];
295
+ ULONG size = sizeof(buffer);
296
+
297
+ CONFIGRET ret = CM_Get_DevNode_Registry_Property(devInst, CM_DRP_LOCATION_INFORMATION, nullptr, buffer, &size, 0);
298
+ if (ret != CR_SUCCESS) {
299
+ return false;
300
+ }
301
+
302
+ std::wstring locationW = buffer;
303
+ locationInfo = std::string(locationW.begin(), locationW.end());
304
+ return true;
305
+ }
306
+
307
+ bool USBListener::GetVidPid(const std::string& deviceId, uint16_t& vid, uint16_t& pid) {
308
+ size_t vidPos = deviceId.find("VID_");
309
+ size_t pidPos = deviceId.find("PID_");
310
+
311
+ if (vidPos == std::string::npos || pidPos == std::string::npos) {
312
+ return false;
313
+ }
314
+
315
+ try {
316
+ std::string vidStr = deviceId.substr(vidPos + 4, 4);
317
+ std::string pidStr = deviceId.substr(pidPos + 4, 4);
318
+
319
+ vid = static_cast<uint16_t>(std::stoul(vidStr, nullptr, 16));
320
+ pid = static_cast<uint16_t>(std::stoul(pidStr, nullptr, 16));
321
+ return true;
322
+ } catch (...) {
323
+ return false;
324
+ }
325
+ }
326
+
327
+ void USBListener::EnumerateConnectedDevices() {
328
+ HDEVINFO deviceInfoSet = SetupDiGetClassDevs(&GUID_DEVINTERFACE_USB_DEVICE, nullptr, nullptr, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE);
329
+ if (deviceInfoSet == INVALID_HANDLE_VALUE) {
330
+ return;
331
+ }
332
+
333
+ SP_DEVINFO_DATA devInfoData = {0};
334
+ devInfoData.cbSize = sizeof(SP_DEVINFO_DATA);
335
+
336
+ for (DWORD i = 0; SetupDiEnumDeviceInfo(deviceInfoSet, i, &devInfoData); i++) {
337
+ WCHAR instanceId[MAX_PATH];
338
+ if (SetupDiGetDeviceInstanceId(deviceInfoSet, &devInfoData, instanceId, MAX_PATH, nullptr)) {
339
+ DeviceInfo info;
340
+ std::wstring idW = instanceId;
341
+ info.deviceId = std::string(idW.begin(), idW.end());
342
+
343
+ if (!GetVidPid(info.deviceId, info.vid, info.pid)) {
344
+ continue;
345
+ }
346
+
347
+ DEVINST devInst = devInfoData.DevInst;
348
+ if (!GetLocationInfo(devInst, info.locationInfo)) {
349
+ continue;
350
+ }
351
+
352
+ // Cache device with exclusive lock
353
+ {
354
+ std::unique_lock lock(m_cacheMutex);
355
+ m_deviceCache[info.deviceId] = info;
356
+ }
357
+
358
+ if (m_addCallback) {
359
+ auto callback = [](Napi::Env env, Napi::Function jsCallback, DeviceInfo* data) {
360
+ Napi::Object obj = Napi::Object::New(env);
361
+ obj.Set("deviceId", Napi::String::New(env, data->deviceId));
362
+ obj.Set("vid", Napi::Number::New(env, data->vid));
363
+ obj.Set("pid", Napi::Number::New(env, data->pid));
364
+ obj.Set("locationInfo", Napi::String::New(env, data->locationInfo));
365
+ if (data->logicalPort >= 0) {
366
+ obj.Set("logicalPort", Napi::Number::New(env, data->logicalPort));
367
+ } else {
368
+ obj.Set("logicalPort", env.Null());
369
+ }
370
+ jsCallback.Call({obj});
371
+ delete data;
372
+ };
373
+ m_addCallback.BlockingCall(new DeviceInfo(info), callback);
374
+ }
375
+ }
376
+ }
377
+
378
+ SetupDiDestroyDeviceInfoList(deviceInfoSet);
379
+ }
380
+
381
+ std::vector<DeviceInfo> USBListener::ListAllDevices() {
382
+ std::vector<DeviceInfo> devices;
383
+
384
+ HDEVINFO deviceInfoSet = SetupDiGetClassDevs(&GUID_DEVINTERFACE_USB_DEVICE, nullptr, nullptr, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE);
385
+ if (deviceInfoSet == INVALID_HANDLE_VALUE) {
386
+ return devices;
387
+ }
388
+
389
+ SP_DEVINFO_DATA devInfoData = {0};
390
+ devInfoData.cbSize = sizeof(SP_DEVINFO_DATA);
391
+
392
+ for (DWORD i = 0; SetupDiEnumDeviceInfo(deviceInfoSet, i, &devInfoData); i++) {
393
+ WCHAR instanceId[MAX_PATH];
394
+ if (SetupDiGetDeviceInstanceId(deviceInfoSet, &devInfoData, instanceId, MAX_PATH, nullptr)) {
395
+ DeviceInfo info;
396
+ std::wstring idW = instanceId;
397
+ info.deviceId = std::string(idW.begin(), idW.end());
398
+
399
+ if (!GetVidPid(info.deviceId, info.vid, info.pid)) {
400
+ continue;
401
+ }
402
+
403
+ DEVINST devInst = devInfoData.DevInst;
404
+ if (!GetLocationInfo(devInst, info.locationInfo)) {
405
+ continue;
406
+ }
407
+
408
+ devices.push_back(info);
409
+ }
410
+ }
411
+
412
+ SetupDiDestroyDeviceInfoList(deviceInfoSet);
413
+ return devices;
414
+ }
@@ -0,0 +1,113 @@
1
+ #ifndef USB_LISTENER_H
2
+ #define USB_LISTENER_H
3
+
4
+ #include <napi.h>
5
+ #include <windows.h>
6
+ #include <initguid.h>
7
+ #include <setupapi.h>
8
+ #include <cfgmgr32.h>
9
+ #include <dbt.h>
10
+ #include <string>
11
+ #include <vector>
12
+ #include <unordered_map>
13
+ #include <memory>
14
+ #include <atomic>
15
+ #include <shared_mutex>
16
+
17
+ /**
18
+ * Device information structure
19
+ * Contains all relevant USB device identification and location data
20
+ */
21
+ struct DeviceInfo {
22
+ std::string deviceId; // Windows device instance ID
23
+ uint16_t vid{0}; // Vendor ID
24
+ uint16_t pid{0}; // Product ID
25
+ std::string locationInfo; // Physical port location
26
+ int logicalPort{-1}; // User-defined logical port number (-1 if not mapped)
27
+ };
28
+
29
+ /**
30
+ * RAII wrapper for Windows thread handle
31
+ */
32
+ class ThreadHandle {
33
+ public:
34
+ ThreadHandle() noexcept = default;
35
+ explicit ThreadHandle(HANDLE h) noexcept : handle(h) {}
36
+ ~ThreadHandle() { if (handle) { WaitForSingleObject(handle, 5000); CloseHandle(handle); } }
37
+
38
+ ThreadHandle(const ThreadHandle&) = delete;
39
+ ThreadHandle& operator=(const ThreadHandle&) = delete;
40
+ ThreadHandle(ThreadHandle&& other) noexcept : handle(std::exchange(other.handle, nullptr)) {}
41
+ ThreadHandle& operator=(ThreadHandle&& other) noexcept {
42
+ if (this != &other) {
43
+ if (handle) { WaitForSingleObject(handle, 5000); CloseHandle(handle); }
44
+ handle = std::exchange(other.handle, nullptr);
45
+ }
46
+ return *this;
47
+ }
48
+
49
+ HANDLE get() const noexcept { return handle; }
50
+ explicit operator bool() const noexcept { return handle != nullptr; }
51
+
52
+ private:
53
+ HANDLE handle{nullptr};
54
+ };
55
+
56
+ /**
57
+ * USB Device Listener - Windows native implementation
58
+ *
59
+ * Monitors USB device connection/disconnection events using Windows WM_DEVICECHANGE messages.
60
+ * Runs a hidden message-only window in a separate thread to receive device notifications.
61
+ * Thread-safe: callbacks are invoked on the Node.js thread using Napi::ThreadSafeFunction.
62
+ */
63
+ class USBListener {
64
+ public:
65
+ USBListener();
66
+ ~USBListener();
67
+
68
+ // Start monitoring USB device events
69
+ bool StartListening(std::string& errorMsg);
70
+
71
+ // Stop monitoring and clean up resources
72
+ void StopListening();
73
+
74
+ // Set callback for device connection events
75
+ void SetAddCallback(Napi::ThreadSafeFunction callback);
76
+
77
+ // Set callback for device disconnection events
78
+ void SetRemoveCallback(Napi::ThreadSafeFunction callback);
79
+
80
+ // Get list of all currently connected USB devices
81
+ std::vector<DeviceInfo> ListAllDevices();
82
+
83
+ private:
84
+ // Windows message handling
85
+ static LRESULT CALLBACK WindowProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
86
+ static DWORD WINAPI ListenerThreadProc(LPVOID param);
87
+
88
+ // Core functionality
89
+ void MessageLoop(); // Main message loop in listener thread
90
+ void HandleDeviceChange(WPARAM wParam, LPARAM lParam); // Process WM_DEVICECHANGE messages
91
+ bool GetDeviceInfo(const std::string& devicePath, DeviceInfo& info); // Get device info from Windows API (for connections)
92
+ bool GetDeviceInfoFromPath(const std::string& devicePath, DeviceInfo& info); // Parse device info from path (for disconnections)
93
+ bool GetLocationInfo(DEVINST devInst, std::string& locationInfo); // Get physical port location
94
+ bool GetVidPid(const std::string& deviceId, uint16_t& vid, uint16_t& pid); // Parse VID/PID from device ID string
95
+ void EnumerateConnectedDevices(); // Enumerate devices on startup
96
+
97
+ // Windows handles (managed resources)
98
+ HWND m_hwnd{nullptr}; // Hidden message-only window
99
+ HDEVNOTIFY m_notifyHandle{nullptr}; // Device notification handle
100
+ ThreadHandle m_thread; // Listener thread handle (RAII)
101
+ std::atomic<bool> m_running{false}; // Thread control flag
102
+
103
+ // Device cache with shared_mutex for reader-writer lock pattern
104
+ // Multiple readers (enumerate) or single writer (add/remove)
105
+ mutable std::shared_mutex m_cacheMutex; // C++17 shared_mutex for better concurrency
106
+ std::unordered_map<std::string, DeviceInfo> m_deviceCache; // Fast lookup with hash
107
+
108
+ // Callbacks
109
+ Napi::ThreadSafeFunction m_addCallback; // Device add callback
110
+ Napi::ThreadSafeFunction m_removeCallback; // Device remove callback
111
+ };
112
+
113
+ #endif // USB_LISTENER_H
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "@mcesystems/usb-device-listener",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "Native Windows USB device listener using PnP notifications without custom drivers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/types.d.ts",
8
8
  "exports": {
9
9
  ".": {
10
+ "types": "./dist/types.d.ts",
10
11
  "import": "./dist/index.js",
11
- "types": "./dist/types.d.ts"
12
+ "require": "./dist/index.cjs"
12
13
  }
13
14
  },
14
15
  "keywords": [
@@ -52,14 +53,16 @@
52
53
  "dist",
53
54
  "prebuilds",
54
55
  "build/Release/*.node",
55
- "README.md"
56
+ "README.md",
57
+ "native",
58
+ "binding.gyp"
56
59
  ],
57
60
  "gypfile": true,
58
61
  "os": [
59
62
  "win32"
60
63
  ],
61
64
  "scripts": {
62
- "prebuild:gyp": "prebuildify --napi --strip --target 25.2.1",
65
+ "prebuild:gyp": "prebuildify --napi --electron --strip --target 25.2.1",
63
66
  "build": "tsx esbuild.config.ts && tsc --emitDeclarationOnly",
64
67
  "rebuild": "node-gyp rebuild",
65
68
  "install": "node-gyp-build",