diff --git a/apps/overlay/electron/main.js b/apps/overlay/electron/main.js
index 54ccb4e..e0ed3ba 100644
--- a/apps/overlay/electron/main.js
+++ b/apps/overlay/electron/main.js
@@ -18,6 +18,67 @@ let clickThrough = false;
// main renderer can forward profile-change events to it via IPC without
// re-opening or duplicating.
let buttonHudWindow = null;
+let inventoryWindow = null;
+
+// ── Controller inventory: HID serial source (main process) ──
+// The renderer's WebHID can't read serial numbers (Chromium blocklists the
+// MAC/serial feature reports), but the MAIN process's HID device events DO
+// carry `serialNumber` — over Bluetooth that's the controller's MAC, a stable
+// per-unit id (and its OUI reveals a spoofed clone). We capture those events
+// into a map and push it to the inventory window. No native module needed
+// (avoids the node-hid/Electron-ABI rebuild); inventory degrades to the
+// Gamepad API if serials don't come through.
+const INVENTORY_VIDS = new Set([0x054c, 0x057e, 0x28de, 0x045e, 0x3537, 0x2dc8, 0x0f0d]);
+const hidControllers = new Map(); // key -> { vendorId, productId, name, serialNumber, connected }
+
+// Drop XInput slot indices ("01"/"02") and other too-short non-serials so they
+// aren't mistaken for a per-unit id.
+function sanitizeSerial(s) {
+ const t = s ? String(s).trim() : '';
+ return t.length >= 6 ? t : null;
+}
+function hidKey(d) { return d.guid || d.deviceId || `${d.vendorId}:${d.productId}`; }
+
+function upsertHidController(d, connected) {
+ if (!d || !INVENTORY_VIDS.has(d.vendorId)) return;
+ const key = hidKey(d);
+ const prev = hidControllers.get(key) || {};
+ hidControllers.set(key, {
+ vendorId: d.vendorId,
+ productId: d.productId,
+ name: d.name || d.productName || prev.name || null,
+ // Keep a serial once we've seen one for this device, even if a later event omits it.
+ serialNumber: sanitizeSerial(d.serialNumber) || prev.serialNumber || null,
+ connected,
+ });
+ console.log('[inventory] HID', connected ? 'present' : 'gone', '-',
+ (d.name || d.productId), 'serial=', d.serialNumber || '(none)');
+}
+
+function hidControllerList() { return [...hidControllers.values()]; }
+
+function broadcastHidControllers() {
+ if (inventoryWindow && !inventoryWindow.isDestroyed()) {
+ inventoryWindow.webContents.send('hid-controllers-snapshot', hidControllerList());
+ }
+}
+
+function openInventoryWindow() {
+ if (inventoryWindow && !inventoryWindow.isDestroyed()) { inventoryWindow.focus(); return; }
+ inventoryWindow = new BrowserWindow({
+ width: 1040, height: 640,
+ title: 'Controller Inventory',
+ backgroundColor: '#14141f',
+ webPreferences: {
+ nodeIntegration: false,
+ contextIsolation: true,
+ preload: path.join(__dirname, 'preload.js'),
+ },
+ });
+ inventoryWindow.loadFile(path.join(__dirname, '..', 'src', 'inventory.html'));
+ inventoryWindow.webContents.once('did-finish-load', () => broadcastHidControllers());
+ inventoryWindow.on('closed', () => { inventoryWindow = null; });
+}
function createWindow() {
const useMulti = process.env.OVERLAY_MULTI === '1' || process.argv.includes('--multi');
@@ -101,6 +162,8 @@ function updateTrayMenu() {
},
},
{ type: 'separator' },
+ { label: 'Controller Inventory…', click: openInventoryWindow },
+ { type: 'separator' },
{ label: 'Quit', click: () => app.quit() },
]);
tray.setContextMenu(menu);
@@ -122,6 +185,10 @@ app.whenReady().then(() => {
// Handle quit from renderer
ipcMain.on('quit-app', () => app.quit());
+ // Controller inventory window + its HID serial source (node-hid in main).
+ ipcMain.handle('open-inventory-window', () => { openInventoryWindow(); return { opened: true }; });
+ ipcMain.handle('list-hid-controllers', () => hidControllerList());
+
// Open the Button HUD popout window — spawns a small frameless,
// transparent, always-on-top BrowserWindow that loads
// src/button-hud-window.html. Only one popout at a time; if already
@@ -240,6 +307,18 @@ app.on('web-contents-created', (_, contents) => {
contents.session.on('select-hid-device', (event, details, callback) => {
event.preventDefault();
console.log('select-hid-device: deviceList length =', details.deviceList?.length || 0);
+ // This deviceList IS the set of currently-present HID devices, so a Scan
+ // doubles as an authoritative refresh: capture each present device's serial,
+ // then mark anything we knew about that ISN'T present now as disconnected.
+ // (hid-device-removed is unreliable over Bluetooth / for ungranted Puck
+ // interfaces, so this manual reconcile is how the inventory's connected
+ // state stays honest without a native module.)
+ const present = new Set();
+ for (const d of details.deviceList || []) { upsertHidController(d, true); present.add(hidKey(d)); }
+ for (const [key, c] of hidControllers) {
+ if (!present.has(key) && c.connected) hidControllers.set(key, { ...c, connected: false });
+ }
+ broadcastHidControllers();
if (details.deviceList && details.deviceList.length > 0) {
if (selectTimeout) { clearTimeout(selectTimeout); selectTimeout = null; }
// Prefer a device we haven't picked yet in this session. Falls back to
@@ -266,9 +345,13 @@ app.on('web-contents-created', (_, contents) => {
// This fires when a device matching an active requestDevice() filter appears.
contents.session.on('hid-device-added', (event, device) => {
console.log('hid-device-added:', device.name || device.productId);
+ upsertHidController(device, true);
+ broadcastHidControllers();
});
contents.session.on('hid-device-removed', (event, device) => {
console.log('hid-device-removed:', device.name || device.productId);
+ upsertHidController(device, false);
+ broadcastHidControllers();
});
});
diff --git a/apps/overlay/electron/preload.js b/apps/overlay/electron/preload.js
index 97bbe12..f6231a8 100644
--- a/apps/overlay/electron/preload.js
+++ b/apps/overlay/electron/preload.js
@@ -30,4 +30,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('button-hud-state-update', (_, state) => callback(state)),
// Used by the popout window's close button to close itself cleanly.
closeWindow: () => ipcRenderer.send('close-this-window'),
+
+ // ── Controller inventory ──
+ // Open the inventory window (from the main overlay or tray).
+ openInventoryWindow: () => ipcRenderer.invoke('open-inventory-window'),
+ // One-shot current HID controller list (with serials, from node-hid in main).
+ listHidControllers: () => ipcRenderer.invoke('list-hid-controllers'),
+ // Periodic snapshot of currently-present HID controllers (with serials).
+ onHidControllersSnapshot: (callback) =>
+ ipcRenderer.on('hid-controllers-snapshot', (_, list) => callback(list)),
});
diff --git a/apps/overlay/src/inventory.html b/apps/overlay/src/inventory.html
new file mode 100644
index 0000000..677621c
--- /dev/null
+++ b/apps/overlay/src/inventory.html
@@ -0,0 +1,52 @@
+
+
+
+
+
+Controller Inventory
+
+
+
+
Controller Inventory
+
+ Every controller this machine has seen, with its connection lifecycle and capabilities. Bluetooth pads expose a
+ real per-unit serial (MAC) via the desktop's HID layer — that's the stable per-unit id (it distinguishes even two
+ identical pads). USB-only pads and Gamepad-API-only pads (Xbox) have no serial and key by VID:PID. History
+ persists across launches.
+
+
+
+
+ Click Scan / Refresh to update which controllers are currently connected.
+
+
+
+
+
+
+
diff --git a/apps/overlay/src/js/inventory.js b/apps/overlay/src/js/inventory.js
new file mode 100644
index 0000000..d25dc5e
--- /dev/null
+++ b/apps/overlay/src/js/inventory.js
@@ -0,0 +1,114 @@
+// Controller Inventory window (Electron). Drives ControllerInventory from two
+// sources: the main process's HID device events (devices WITH serials, pushed
+// as snapshots over IPC) and the renderer's Gamepad API (covers pads without
+// HID serials, e.g. Xbox). Renders the table; persists to localStorage. The
+// MAC (when present, over Bluetooth) is the per-unit identity; we don't try to
+// classify genuine-vs-clone by OUI (unreliable — see controller-identity memo).
+
+import { ControllerInventory, formatSerial, isMacSerial } from '@usersfirst/controller-core/controller-inventory';
+
+const api = window.electronAPI;
+const LS_KEY = 'controller-inventory';
+const inv = new ControllerInventory();
+try { const s = localStorage.getItem(LS_KEY); if (s) inv.loadJSON(JSON.parse(s)); } catch {}
+const save = () => { try { localStorage.setItem(LS_KEY, JSON.stringify(inv.toJSON())); } catch {} };
+
+const status = document.getElementById('status');
+const fmt = (t) => (t ? new Date(t).toLocaleString() : '—');
+
+// ── HID source (main process; carries serials) ──
+// Main sends the full controller map (each with a `connected` flag) whenever it
+// changes. We apply it directly — connected → observeConnect, else disconnect.
+function applyHidSnapshot(list) {
+ if (!Array.isArray(list)) return;
+ for (const c of list) {
+ const desc = { source: 'hid', vendorId: c.vendorId, productId: c.productId, productName: c.name, serialNumber: c.serialNumber };
+ if (c.connected) inv.observeConnect(desc); else inv.observeDisconnect(desc);
+ }
+ save();
+ render();
+}
+
+if (api) {
+ api.onHidControllersSnapshot(applyHidSnapshot);
+ api.listHidControllers().then(applyHidSnapshot).catch(() => {});
+} else {
+ status.textContent = 'Not running in Electron — HID serials unavailable (Gamepad API only).';
+}
+
+// Serials populate when the HID device list is enumerated, which needs a user
+// gesture. The Scan button triggers it; main captures the serials and pushes
+// them back. (No chooser appears — the main process auto-handles selection.)
+const scanBtn = document.getElementById('scan');
+if (scanBtn) {
+ scanBtn.onclick = async () => {
+ if (!navigator.hid) { status.textContent = 'WebHID unavailable.'; return; }
+ try {
+ status.textContent = 'Scanning…';
+ await navigator.hid.requestDevice({ filters: [] });
+ status.textContent = '';
+ } catch (e) { status.textContent = e.message; }
+ };
+}
+
+// ── Gamepad API source (no serials; covers Xbox & any pad we lack HID for) ──
+const seenPads = new Map();
+function pollGamepads() {
+ const pads = (navigator.getGamepads && navigator.getGamepads()) || [];
+ const live = new Set();
+ for (const gp of pads) {
+ if (!gp) continue;
+ live.add(gp.index);
+ if (!seenPads.has(gp.index)) {
+ seenPads.set(gp.index, gp.id);
+ inv.observeConnect({ source: 'gamepad', gamepadId: gp.id });
+ save(); render();
+ }
+ }
+ for (const [i, id] of seenPads) {
+ if (!live.has(i)) { seenPads.delete(i); inv.observeDisconnect({ source: 'gamepad', gamepadId: id }); save(); render(); }
+ }
+}
+setInterval(pollGamepads, 600);
+
+// ── Render ──
+function transportOf(rec) {
+ const list = rec.transports instanceof Set ? [...rec.transports] : (rec.transports || []);
+ const hasHid = list.includes('hid');
+ if (hasHid && isMacSerial(rec.serialNumber)) return 'Bluetooth';
+ if (hasHid) return 'USB / HID';
+ return 'Gamepad';
+}
+
+function render() {
+ const rows = inv.list();
+ const wrap = document.getElementById('tableWrap');
+ if (!rows.length) {
+ wrap.innerHTML = '
No controllers seen yet. Connect a pad over Bluetooth or USB; press a button on a Gamepad-API-only pad (Xbox) to register it.
`;
+}
+
+document.getElementById('clear').onclick = () => { inv.clear(); save(); render(); };
+
+render();
+setInterval(render, 1000); // keep timestamps fresh
diff --git a/package.json b/package.json
index b325696..653ad6e 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,10 @@
"scripts": {
"test": "npm run test --workspaces --if-present",
"face-painter": "npx -y serve tools/face-painter",
- "split-glb": "node tools/split-glb.js"
+ "split-glb": "node tools/split-glb.js",
+ "hid-probe": "npx -y serve tools/hid-probe",
+ "hid-dump": "node tools/hid-serial-dump.mjs",
+ "inventory-preview": "npx -y serve ."
},
"devDependencies": {
"@gltf-transform/core": "^4.1.0",
diff --git a/packages/core/package.json b/packages/core/package.json
index 8b19893..59200f4 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -9,6 +9,7 @@
"exports": {
".": "./src/index.js",
"./manager": "./src/manager.js",
+ "./controller-inventory": "./src/controller-inventory.js",
"./sensor-fusion": "./src/sensor-fusion.js",
"./imu-analysis": "./src/imu-analysis.js",
"./drivers/*": "./src/drivers/*.js"
diff --git a/packages/core/src/controller-inventory.js b/packages/core/src/controller-inventory.js
new file mode 100644
index 0000000..7580074
--- /dev/null
+++ b/packages/core/src/controller-inventory.js
@@ -0,0 +1,264 @@
+// ============================================================
+// CONTROLLER INVENTORY — registry of every controller we've seen
+// ============================================================
+//
+// A headless, dependency-free record of each controller the app has
+// observed: identity, capabilities, and connection lifecycle (first
+// seen / last connected / last disconnected / connected now). Feeds the
+// overlay's "all controllers" table. No `three`, no DOM — runs in the
+// browser, Electron, and node:test alike (and so stays out of the
+// `three` import graph, keeping CI dependency-free).
+//
+// IDENTITY — best available wins:
+// 1. serialNumber — a true per-unit id. In the browser this is
+// unavailable (Chromium blocklists the MAC/serial feature reports);
+// in Electron the main process's HID device events DO expose it, so
+// desktop can tell two identical pads apart. (Verified: even a
+// GameSir Super Nova reports a unique MAC via the OS HID layer.)
+// 2. vendorId:productId — merges identical pads when no serial is
+// available (the browser case).
+// 3. productName — last resort for an unparseable Gamepad-API id.
+//
+// SOURCES — a controller can be seen via WebHID and/or the Gamepad API
+// (e.g. an Xbox pad is Gamepad-API-only). Observations from both fold
+// into one record by vid:pid when unambiguous, with `transports`
+// recording which APIs saw it. Two *identical* pads that each have a
+// serial stay separate (correct); a serial-less Gamepad-API sighting
+// can't be correlated to a specific one of them, so it stays its own
+// row (honest about the limit).
+
+import { ControllerRegistry } from './drivers/controller-registry.js';
+
+const hex4 = (n) => (n & 0xffff).toString(16).padStart(4, '0');
+
+/** "DualSense … (… Vendor: 054c Product: 0ce6)" → "DualSense …". */
+function stripGamepadIdSuffix(id) {
+ return String(id).replace(/\s*\((?:STANDARD GAMEPAD\s*)?Vendor:.*$/i, '').trim();
+}
+
+/**
+ * Normalize a raw observation into a common shape. Accepts either an
+ * HID-style descriptor ({vendorId, productId, productName, serialNumber})
+ * or a Gamepad-API one ({gamepadId}); resolves the dictionary entry.
+ * @param {object} d
+ * @param {'hid'|'gamepad'} [d.source]
+ */
+export function normalizeDescriptor(d) {
+ let { source = 'hid', vendorId = null, productId = null, productName = null, serialNumber = null, gamepadId = null } = d || {};
+ if ((vendorId == null || productId == null) && gamepadId) {
+ const vp = ControllerRegistry.parseGamepadVendorProduct(gamepadId);
+ if (vp) { vendorId = vp.vendorId; productId = vp.productId; }
+ if (!productName) productName = stripGamepadIdSuffix(gamepadId);
+ }
+ const entry = (vendorId != null && productId != null)
+ ? ControllerRegistry.getEntry(vendorId, productId)
+ : null;
+ return {
+ source,
+ vendorId,
+ productId,
+ productName: productName || entry?.name || null,
+ serialNumber: serialNumber || null,
+ entry: entry || null,
+ };
+}
+
+/** Stable identity key for a normalized observation. */
+export function identityKey(n) {
+ if (n.serialNumber) return `serial:${n.serialNumber}`;
+ if (n.vendorId != null && n.productId != null) return `vidpid:${hex4(n.vendorId)}:${hex4(n.productId)}`;
+ if (n.productName) return `name:${n.productName.toLowerCase()}`;
+ return 'unknown';
+}
+
+/**
+ * Capability snapshot for a dictionary entry. Counts come from the entry
+ * when present (devices.js `trackpadCount` / `haptics`) and fall back to
+ * deriving presence from the capability/feature booleans.
+ */
+export function capabilitiesFor(entry) {
+ const caps = entry?.capabilities || {};
+ const feat = entry?.features || {};
+ const touchpad = !!caps.touchpad;
+ const trackpadCount = entry?.trackpadCount ?? (touchpad ? 1 : 0);
+ let haptics = entry?.haptics ?? null;
+ if (haptics == null && feat.rumble) haptics = { count: null, type: 'rumble' };
+ return {
+ gyro: !!caps.gyro,
+ accel: !!caps.accel,
+ touchpad,
+ trackpadCount,
+ haptics, // { count, type } | null
+ lightbar: !!feat.lightbar,
+ };
+}
+
+/**
+ * If `serial` is a 12-hex-digit Bluetooth MAC, return its OUI — the first 3
+ * bytes (6 hex chars, lowercase). Else null (e.g. the Steam Controller's
+ * "FXB99…" product serial isn't a MAC). Used to tell a Bluetooth pad (has a
+ * MAC) from a USB one (no serial) for the transport column — NOT for vendor
+ * lookup: genuine PlayStation pads carry a Foxconn OUI, not Sony's, and the
+ * GameSir clones' OUIs aren't in any registry, so OUI→vendor is unreliable
+ * (see [[controller-identity]] memory). Per-unit identity comes from the full
+ * MAC, which is solid.
+ */
+function macHex(serial) {
+ if (!serial) return null;
+ const s = String(serial).trim();
+ // Accept ONLY a bare 12-hex string or a separated aa:bb:.. / aa-bb-.. form.
+ // Don't strip-then-measure: a product serial like "FXB9960202571" reduces to
+ // 12 hex chars by accident and must NOT be mistaken for a MAC.
+ if (/^[0-9a-fA-F]{12}$/.test(s)) return s.toLowerCase();
+ if (/^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/.test(s)) return s.replace(/[:-]/g, '').toLowerCase();
+ return null;
+}
+
+export function macOui(serial) {
+ const hex = macHex(serial);
+ return hex ? hex.slice(0, 6) : null;
+}
+
+/** True if this serial is a Bluetooth MAC (vs a USB/product serial or none). */
+export function isMacSerial(serial) {
+ return macHex(serial) != null;
+}
+
+/** Format a Bluetooth MAC serial as aa:bb:cc:dd:ee:ff; pass non-MAC serials through. */
+export function formatSerial(serial) {
+ if (!serial) return null;
+ const hex = macHex(serial);
+ return hex ? hex.match(/../g).join(':') : String(serial);
+}
+
+// NOTE: OUI→vendor clone detection was evaluated and DROPPED — genuine
+// PlayStation controllers carry a Foxconn (Hon Hai) OUI, not Sony's, and the
+// GameSir clones' OUIs aren't in any public registry, so the heuristic isn't
+// worth its weight. The full MAC remains a solid per-unit identifier. If a
+// vendor lookup is ever wanted, inject one (see [[controller-identity]] for
+// the layered design + the parked "OUI web API" idea).
+
+export class ControllerInventory {
+ /** @param {{now?: () => number}} [opts] inject a clock for deterministic tests */
+ constructor({ now = () => Date.now() } = {}) {
+ this._now = now;
+ this._records = new Map(); // key -> record
+ }
+
+ /**
+ * Find an existing record this observation should merge into — the same
+ * physical controller seen via another transport, or a vid:pid sighting
+ * for a pad we already know by serial. Returns null when it should be a
+ * new record (including the ambiguous identical-pads case).
+ */
+ _findMergeTarget(n, candidateKey) {
+ const exact = this._records.get(candidateKey);
+ if (exact) return exact;
+ if (n.vendorId == null || n.productId == null) return null;
+ const sameVp = [...this._records.values()].filter(
+ (r) => r.vendorId === n.vendorId && r.productId === n.productId,
+ );
+ if (sameVp.length !== 1) return null; // none, or ambiguous (identical pads)
+ const cand = sameVp[0];
+ // Never merge two DIFFERENT serials — those are distinct physical units.
+ if (n.serialNumber && cand.serialNumber && n.serialNumber !== cand.serialNumber) return null;
+ return cand;
+ }
+
+ /** Re-key a record (e.g. a vid:pid record gains a serial → becomes per-unit). */
+ _rekey(rec, newKey) {
+ if (rec.key === newKey) return;
+ this._records.delete(rec.key);
+ rec.key = newKey;
+ this._records.set(newKey, rec);
+ }
+
+ /** Record that a controller is connected (new or returning). */
+ observeConnect(descriptor) {
+ const n = normalizeDescriptor(descriptor);
+ const candidateKey = identityKey(n);
+ const t = this._now();
+ const target = this._findMergeTarget(n, candidateKey);
+
+ if (!target) {
+ const rec = {
+ key: candidateKey,
+ vendorId: n.vendorId,
+ productId: n.productId,
+ name: n.productName,
+ serialNumber: n.serialNumber,
+ capabilities: capabilitiesFor(n.entry),
+ transports: new Set([n.source]),
+ firstSeen: t,
+ lastConnected: t,
+ lastDisconnected: null,
+ connected: true,
+ connectCount: 1,
+ };
+ this._records.set(candidateKey, rec);
+ return rec;
+ }
+
+ // Merge into the existing record.
+ target.connected = true;
+ target.lastConnected = t;
+ target.connectCount += 1;
+ target.transports.add(n.source);
+ if (n.serialNumber && !target.serialNumber) {
+ target.serialNumber = n.serialNumber;
+ this._rekey(target, `serial:${n.serialNumber}`); // upgrade to per-unit identity
+ }
+ if (n.productName && (!target.name || target.name === 'unknown')) target.name = n.productName;
+ if (n.entry) target.capabilities = capabilitiesFor(n.entry);
+ return target;
+ }
+
+ /** Record that a controller disconnected. */
+ observeDisconnect(descriptor) {
+ const n = normalizeDescriptor(descriptor);
+ const target = this._findMergeTarget(n, identityKey(n));
+ if (!target) return null;
+ target.connected = false;
+ target.lastDisconnected = this._now();
+ return target;
+ }
+
+ get(key) { return this._records.get(key) || null; }
+
+ /** All records; connected first, then most-recently-connected. */
+ list({ connectedFirst = true } = {}) {
+ const arr = [...this._records.values()];
+ arr.sort((a, b) => {
+ if (connectedFirst && a.connected !== b.connected) return a.connected ? -1 : 1;
+ return (b.lastConnected || 0) - (a.lastConnected || 0);
+ });
+ return arr;
+ }
+
+ /** Serializable snapshot for persistence (localStorage / userData file). */
+ toJSON() {
+ return {
+ version: 1,
+ records: [...this._records.values()].map((r) => ({ ...r, transports: [...r.transports] })),
+ };
+ }
+
+ /**
+ * Restore history. Loaded records start DISCONNECTED — nothing is live
+ * until a fresh observeConnect this session — but keep their firstSeen /
+ * lastConnected / lastDisconnected / connectCount.
+ */
+ loadJSON(data) {
+ if (!data || !Array.isArray(data.records)) return;
+ for (const r of data.records) {
+ if (!r || !r.key) continue;
+ this._records.set(r.key, {
+ ...r,
+ transports: new Set(r.transports || []),
+ connected: false,
+ });
+ }
+ }
+
+ clear() { this._records.clear(); }
+}
diff --git a/packages/core/src/devices.js b/packages/core/src/devices.js
index 28eeacd..fadc2e5 100644
--- a/packages/core/src/devices.js
+++ b/packages/core/src/devices.js
@@ -115,11 +115,26 @@ const GAMESIR_DS4_FEATURES = {
backPaddles: true, lightbar: false, rumble: true,
};
+// ── Haptics inventory (best-effort; `count` = physical force-feedback
+// actuators). Surfaced by the controller inventory UI alongside trackpad
+// counts. Kept per-entry (not in the shared *_FEATURES presets) because
+// DualSense's adaptive triggers make it differ from a DualShock 4 that
+// otherwise shares PS_FEATURES.
+const HAPTICS = {
+ ds5: { count: 4, type: 'dual voice-coil + 2 adaptive triggers' },
+ ds4: { count: 2, type: 'dual ERM rumble' },
+ gamesirDs4: { count: 2, type: 'dual rumble' },
+ switchPro: { count: 2, type: 'HD rumble (linear actuators)' },
+ xbox: { count: 4, type: '2 body rumble + 2 impulse triggers' },
+ xbox360: { count: 2, type: 'dual ERM rumble' },
+ steam: { count: 2, type: 'trackpad haptic actuators' },
+};
+
export const DEVICES = [
// ── Sony DualSense (PS5) ──
// imuSignature: 'sony-ds5' — IMU at byte 15 (DualSense layout)
- { name: 'Sony DualSense', vendorId: 0x054c, productId: 0x0ce6, protocol: 'dualsense', mode: 'ds5', imuSignature: 'sony-ds5', capabilities: PS_CAPS, features: PS_FEATURES, gamepadIdPattern: PLAYSTATION_ID },
- { name: 'Sony DualSense Edge', vendorId: 0x054c, productId: 0x0df2, protocol: 'dualsense', mode: 'ds5', imuSignature: 'sony-ds5', capabilities: PS_CAPS, features: PS_EDGE_FEATURES, gamepadIdPattern: PLAYSTATION_ID },
+ { name: 'Sony DualSense', vendorId: 0x054c, productId: 0x0ce6, protocol: 'dualsense', mode: 'ds5', imuSignature: 'sony-ds5', capabilities: PS_CAPS, features: PS_FEATURES, trackpadCount: 1, haptics: HAPTICS.ds5, gamepadIdPattern: PLAYSTATION_ID },
+ { name: 'Sony DualSense Edge', vendorId: 0x054c, productId: 0x0df2, protocol: 'dualsense', mode: 'ds5', imuSignature: 'sony-ds5', capabilities: PS_CAPS, features: PS_EDGE_FEATURES, trackpadCount: 1, haptics: HAPTICS.ds5, gamepadIdPattern: PLAYSTATION_ID },
// ── Sony DualShock 4 (PS4) ──
// Same protocol class as DualSense; mode='ds4' selects the DS4 input-report
@@ -130,8 +145,8 @@ export const DEVICES = [
// default offset, overriding only when it scores implausibly. imuSignature
// 'sony-ds4' is retained but cannot distinguish a real DS4 from a GameSir at
// the same vid:pid (identical IMU layout).
- { name: 'Sony DualShock 4 v1', vendorId: 0x054c, productId: 0x05c4, protocol: 'dualsense', mode: 'ds4', imuSignature: 'sony-ds4', capabilities: PS_CAPS, features: PS_FEATURES, gamepadIdPattern: PLAYSTATION_ID },
- { name: 'Sony DualShock 4 v2', vendorId: 0x054c, productId: 0x09cc, protocol: 'dualsense', mode: 'ds4', imuSignature: 'sony-ds4', capabilities: PS_CAPS, features: PS_FEATURES, gamepadIdPattern: PLAYSTATION_ID },
+ { name: 'Sony DualShock 4 v1', vendorId: 0x054c, productId: 0x05c4, protocol: 'dualsense', mode: 'ds4', imuSignature: 'sony-ds4', capabilities: PS_CAPS, features: PS_FEATURES, trackpadCount: 1, haptics: HAPTICS.ds4, gamepadIdPattern: PLAYSTATION_ID },
+ { name: 'Sony DualShock 4 v2', vendorId: 0x054c, productId: 0x09cc, protocol: 'dualsense', mode: 'ds4', imuSignature: 'sony-ds4', capabilities: PS_CAPS, features: PS_FEATURES, trackpadCount: 1, haptics: HAPTICS.ds4, gamepadIdPattern: PLAYSTATION_ID },
// ── GameSir DS4-mode family ──
// Both Super Nova and Cyclone 2 spoof Sony's DS4 v2 USB identity
@@ -153,6 +168,7 @@ export const DEVICES = [
protocol: 'dualsense', mode: 'ds4', imuSignature: 'gamesir-ds4',
capabilities: PS_CAPS,
features: GAMESIR_DS4_FEATURES,
+ trackpadCount: 1, haptics: HAPTICS.gamesirDs4,
gamepadIdPattern: PLAYSTATION_ID,
spoofs: { of: 'Sony DualShock 4 v2', vendorId: 0x054c, productId: 0x09cc },
// Photogrammetry-sourced GLB; monolithic mesh so gyro rotation works
@@ -167,13 +183,14 @@ export const DEVICES = [
protocol: 'dualsense', mode: 'ds4', imuSignature: 'gamesir-ds4',
capabilities: PS_CAPS,
features: { ...GAMESIR_DS4_FEATURES, backPaddles: false },
+ trackpadCount: 1, haptics: HAPTICS.gamesirDs4,
gamepadIdPattern: PLAYSTATION_ID,
spoofs: { of: 'Sony DualShock 4 v2', vendorId: 0x054c, productId: 0x09cc },
notes: 'GameSir clone reporting Sony 054c:09cc. IMU layout matches Sony DS4 (offsets 12/18). Has physical back paddles BUT they rebind to A/B at the firmware level — no independent HID bits, so the wizard skips the back-paddles step.',
},
// ── Nintendo Switch Pro ──
- { name: 'Nintendo Switch Pro', vendorId: 0x057e, productId: 0x2009, protocol: 'switch-pro', capabilities: SWITCH_CAPS, features: SWITCH_FEATURES, gamepadIdPattern: SWITCH_PRO_ID },
+ { name: 'Nintendo Switch Pro', vendorId: 0x057e, productId: 0x2009, protocol: 'switch-pro', capabilities: SWITCH_CAPS, features: SWITCH_FEATURES, trackpadCount: 0, haptics: HAPTICS.switchPro, gamepadIdPattern: SWITCH_PRO_ID },
// ── GameSir Cyclone (Switch mode) ──
// Same vid:pid as Switch Pro but reports gamepad.id as "Gamepad" rather
@@ -185,6 +202,7 @@ export const DEVICES = [
protocol: 'switch-pro',
capabilities: SWITCH_CAPS,
features: { ...SWITCH_FEATURES, backPaddles: true },
+ trackpadCount: 0, haptics: HAPTICS.switchPro,
gamepadIdPattern: SWITCH_PRO_ID,
gamepadIdMatch: /^Gamepad/i,
spoofs: { of: 'Nintendo Switch Pro', vendorId: 0x057e, productId: 0x2009 },
@@ -193,11 +211,11 @@ export const DEVICES = [
},
// ── Xbox family (identity-only — Gamepad API handles input) ──
- { name: 'Xbox Wireless (BT)', vendorId: 0x045e, productId: 0x0b12, protocol: 'xbox', capabilities: NO_CAPS, features: XBOX_FEATURES, gamepadIdPattern: XBOX_ID },
- { name: 'Xbox Series X|S', vendorId: 0x045e, productId: 0x0b13, protocol: 'xbox', capabilities: NO_CAPS, features: XBOX_FEATURES, gamepadIdPattern: XBOX_ID },
- { name: 'Xbox Elite v2', vendorId: 0x045e, productId: 0x02fd, protocol: 'xbox', capabilities: NO_CAPS, features: { ...XBOX_FEATURES, backPaddles: true }, gamepadIdPattern: XBOX_ID },
- { name: 'Xbox One', vendorId: 0x045e, productId: 0x02e0, protocol: 'xbox', capabilities: NO_CAPS, features: XBOX_FEATURES, gamepadIdPattern: XBOX_ID },
- { name: 'Xbox 360', vendorId: 0x045e, productId: 0x028e, protocol: 'xbox', capabilities: NO_CAPS, features: XBOX_FEATURES, gamepadIdPattern: XBOX_ID },
+ { name: 'Xbox Wireless (BT)', vendorId: 0x045e, productId: 0x0b12, protocol: 'xbox', capabilities: NO_CAPS, features: XBOX_FEATURES, trackpadCount: 0, haptics: HAPTICS.xbox, gamepadIdPattern: XBOX_ID },
+ { name: 'Xbox Series X|S', vendorId: 0x045e, productId: 0x0b13, protocol: 'xbox', capabilities: NO_CAPS, features: XBOX_FEATURES, trackpadCount: 0, haptics: HAPTICS.xbox, gamepadIdPattern: XBOX_ID },
+ { name: 'Xbox Elite v2', vendorId: 0x045e, productId: 0x02fd, protocol: 'xbox', capabilities: NO_CAPS, features: { ...XBOX_FEATURES, backPaddles: true }, trackpadCount: 0, haptics: HAPTICS.xbox, gamepadIdPattern: XBOX_ID },
+ { name: 'Xbox One', vendorId: 0x045e, productId: 0x02e0, protocol: 'xbox', capabilities: NO_CAPS, features: XBOX_FEATURES, trackpadCount: 0, haptics: HAPTICS.xbox, gamepadIdPattern: XBOX_ID },
+ { name: 'Xbox 360', vendorId: 0x045e, productId: 0x028e, protocol: 'xbox', capabilities: NO_CAPS, features: XBOX_FEATURES, trackpadCount: 0, haptics: HAPTICS.xbox360, gamepadIdPattern: XBOX_ID },
// ── Steam Controller (2026) — two USB identities for one physical pad ──
//
@@ -241,6 +259,7 @@ export const DEVICES = [
protocol: 'steam-controller',
capabilities: PS_CAPS,
features: { faceButtons: true, systemButtons: true, triggers: 'analog', shoulders: true, sticks: 2, dpad: true, gyro: true, accel: true, touchpad: true, backPaddles: true, lightbar: false, rumble: true },
+ trackpadCount: 2, haptics: HAPTICS.steam,
gamepadIdPattern: STEAM_ID,
controllerProfile: 'steam-controller',
notes: 'Controller body plugged in directly over USB-C. One HID interface; Steam Input HID format flows on connect, no mode switch needed. Visualizer GLB is CC BY-NC-SA 4.0; see packages/visualizer/assets/controllers/STEAM_CONTROLLER_ATTRIBUTION.md.',
@@ -251,6 +270,7 @@ export const DEVICES = [
protocol: 'steam-controller',
capabilities: PS_CAPS,
features: { faceButtons: true, systemButtons: true, triggers: 'analog', shoulders: true, sticks: 2, dpad: true, gyro: true, accel: true, touchpad: true, backPaddles: true, lightbar: false, rumble: true },
+ trackpadCount: 2, haptics: HAPTICS.steam,
gamepadIdPattern: STEAM_ID,
controllerProfile: 'steam-controller',
notes: 'Wireless Puck dongle. Driver sends CLEAR_DIGITAL_MAPPINGS feature report on init + every 800ms to keep the controller out of keyboard/mouse fallback. Only one of the Puck\'s 5 HID interfaces (iface[3]) emits 53-byte STATE reports.',
diff --git a/packages/core/src/index.js b/packages/core/src/index.js
index 3e6db17..5a47228 100644
--- a/packages/core/src/index.js
+++ b/packages/core/src/index.js
@@ -35,3 +35,13 @@ export { SteamControllerDriver } from './drivers/steam-controller-driver.js';
export { DEVICES, PENDING_DEVICES, PROTOCOLS } from './devices.js';
export { analyzeImuStep } from './imu-analysis.js';
+
+export {
+ ControllerInventory,
+ normalizeDescriptor,
+ identityKey,
+ capabilitiesFor,
+ macOui,
+ formatSerial,
+ isMacSerial,
+} from './controller-inventory.js';
diff --git a/packages/core/test/controller-inventory.test.js b/packages/core/test/controller-inventory.test.js
new file mode 100644
index 0000000..369c183
--- /dev/null
+++ b/packages/core/test/controller-inventory.test.js
@@ -0,0 +1,192 @@
+// ============================================================
+// ControllerInventory — identity, lifecycle, capabilities, persistence
+// ============================================================
+//
+// Headless: imports only the inventory + registry (no `three`, no DOM),
+// so it runs under CI's dependency-free `node --test`.
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ ControllerInventory,
+ normalizeDescriptor,
+ identityKey,
+ capabilitiesFor,
+ macOui,
+ formatSerial,
+ isMacSerial,
+} from '../src/controller-inventory.js';
+import { ControllerRegistry } from '../src/drivers/controller-registry.js';
+
+// A controllable clock so timestamps are deterministic.
+function clock(start = 1000) {
+ let t = start;
+ const now = () => t;
+ now.advance = (ms) => { t += ms; return t; };
+ now.set = (v) => { t = v; };
+ return now;
+}
+
+// Descriptor builders.
+const hid = (o) => ({ source: 'hid', ...o });
+const gamepad = (id) => ({ source: 'gamepad', gamepadId: id });
+const DUALSENSE_GP = 'DualSense Wireless Controller (STANDARD GAMEPAD Vendor: 054c Product: 0ce6)';
+
+test('normalizeDescriptor parses a Gamepad-API id into vid:pid + name + entry', () => {
+ const n = normalizeDescriptor(gamepad(DUALSENSE_GP));
+ assert.equal(n.vendorId, 0x054c);
+ assert.equal(n.productId, 0x0ce6);
+ assert.equal(n.productName, 'DualSense Wireless Controller');
+ assert.ok(n.entry, 'resolves the dictionary entry');
+ assert.equal(n.entry.name, 'Sony DualSense');
+});
+
+test('identityKey prefers serial, then vid:pid, then name', () => {
+ assert.equal(identityKey({ serialNumber: 'a05a5ef610cb', vendorId: 0x054c, productId: 0x05c4 }), 'serial:a05a5ef610cb');
+ assert.equal(identityKey({ vendorId: 0x054c, productId: 0x05c4 }), 'vidpid:054c:05c4');
+ assert.equal(identityKey({ productName: 'Mystery Pad' }), 'name:mystery pad');
+});
+
+test('capabilitiesFor surfaces gyro/trackpadCount/haptics from the dictionary', () => {
+ const ds = capabilitiesFor(ControllerRegistry.getEntry(0x054c, 0x0ce6)); // DualSense
+ assert.equal(ds.gyro, true);
+ assert.equal(ds.touchpad, true);
+ assert.equal(ds.trackpadCount, 1);
+ assert.equal(ds.haptics.count, 4);
+ assert.equal(ds.lightbar, true);
+
+ const xb = capabilitiesFor(ControllerRegistry.getEntry(0x045e, 0x0b13)); // Xbox Series
+ assert.equal(xb.gyro, false);
+ assert.equal(xb.trackpadCount, 0);
+ assert.equal(xb.haptics.count, 4);
+
+ const steam = capabilitiesFor(ControllerRegistry.getEntry(0x28de, 0x1302));
+ assert.equal(steam.trackpadCount, 2);
+});
+
+test('connect creates a record with lifecycle + capabilities', () => {
+ const now = clock(5000);
+ const inv = new ControllerInventory({ now });
+ const rec = inv.observeConnect(hid({ vendorId: 0x054c, productId: 0x05c4, productName: 'Wireless Controller', serialNumber: 'a05a5ef610cb' }));
+ assert.equal(rec.key, 'serial:a05a5ef610cb');
+ assert.equal(rec.connected, true);
+ assert.equal(rec.firstSeen, 5000);
+ assert.equal(rec.lastConnected, 5000);
+ assert.equal(rec.lastDisconnected, null);
+ assert.equal(rec.connectCount, 1);
+ assert.equal(rec.capabilities.gyro, true);
+ assert.deepEqual([...rec.transports], ['hid']);
+});
+
+test('reconnect keeps firstSeen, bumps lastConnected + connectCount', () => {
+ const now = clock(1000);
+ const inv = new ControllerInventory({ now });
+ inv.observeConnect(hid({ vendorId: 0x054c, productId: 0x05c4, serialNumber: 'AA' }));
+ now.advance(500);
+ inv.observeDisconnect(hid({ vendorId: 0x054c, productId: 0x05c4, serialNumber: 'AA' }));
+ now.advance(2000);
+ const rec = inv.observeConnect(hid({ vendorId: 0x054c, productId: 0x05c4, serialNumber: 'AA' }));
+ assert.equal(rec.firstSeen, 1000);
+ assert.equal(rec.lastDisconnected, 1500);
+ assert.equal(rec.lastConnected, 3500);
+ assert.equal(rec.connected, true);
+ assert.equal(rec.connectCount, 2);
+});
+
+test('WebHID(serial) + Gamepad-API(vid:pid) fold into ONE record', () => {
+ const now = clock();
+ const inv = new ControllerInventory({ now });
+ inv.observeConnect(hid({ vendorId: 0x054c, productId: 0x0ce6, serialNumber: 'DEAD' }));
+ inv.observeConnect(gamepad(DUALSENSE_GP)); // same vid:pid, no serial
+ const list = inv.list();
+ assert.equal(list.length, 1, 'one physical controller, one row');
+ assert.equal(list[0].key, 'serial:DEAD');
+ assert.deepEqual([...list[0].transports].sort(), ['gamepad', 'hid']);
+});
+
+test('Gamepad-API first, then a serial sighting upgrades the key to per-unit', () => {
+ const now = clock();
+ const inv = new ControllerInventory({ now });
+ inv.observeConnect(gamepad(DUALSENSE_GP)); // vidpid key
+ assert.ok(inv.get('vidpid:054c:0ce6'));
+ inv.observeConnect(hid({ vendorId: 0x054c, productId: 0x0ce6, serialNumber: 'BEEF' }));
+ assert.equal(inv.get('vidpid:054c:0ce6'), null, 're-keyed away from vid:pid');
+ const rec = inv.get('serial:BEEF');
+ assert.ok(rec, 'now keyed by serial');
+ assert.deepEqual([...rec.transports].sort(), ['gamepad', 'hid']);
+});
+
+test('two identical pads with distinct serials stay separate', () => {
+ const now = clock();
+ const inv = new ControllerInventory({ now });
+ inv.observeConnect(hid({ vendorId: 0x054c, productId: 0x0ce6, serialNumber: 'PAD1' }));
+ inv.observeConnect(hid({ vendorId: 0x054c, productId: 0x0ce6, serialNumber: 'PAD2' }));
+ assert.equal(inv.list().length, 2);
+ // A serial-less Gamepad-API sighting can't be correlated to either → its own row, honestly.
+ inv.observeConnect(gamepad(DUALSENSE_GP));
+ assert.equal(inv.list().length, 3, 'ambiguous gamepad sighting is not silently merged');
+});
+
+test('list() puts connected first, then most-recently-connected', () => {
+ const now = clock(1000);
+ const inv = new ControllerInventory({ now });
+ inv.observeConnect(hid({ vendorId: 0x054c, productId: 0x0ce6, serialNumber: 'A' }));
+ now.advance(100);
+ inv.observeConnect(hid({ vendorId: 0x054c, productId: 0x05c4, serialNumber: 'B' }));
+ now.advance(100);
+ inv.observeDisconnect(hid({ vendorId: 0x054c, productId: 0x0ce6, serialNumber: 'A' }));
+ const keys = inv.list().map((r) => r.key);
+ assert.deepEqual(keys, ['serial:B', 'serial:A'], 'connected B first, disconnected A last');
+});
+
+test('persistence round-trips history; restored records start disconnected', () => {
+ const now = clock(2000);
+ const inv = new ControllerInventory({ now });
+ inv.observeConnect(hid({ vendorId: 0x054c, productId: 0x05c4, serialNumber: 'S1' }));
+ const json = JSON.parse(JSON.stringify(inv.toJSON())); // simulate localStorage
+
+ const now2 = clock(9000);
+ const restored = new ControllerInventory({ now: now2 });
+ restored.loadJSON(json);
+ const rec = restored.get('serial:S1');
+ assert.ok(rec);
+ assert.equal(rec.connected, false, 'nothing is live until a fresh connect');
+ assert.equal(rec.firstSeen, 2000, 'history preserved');
+ assert.ok(rec.transports instanceof Set, 'transports restored as a Set');
+
+ // A fresh connect after restore keeps the original firstSeen.
+ const back = restored.observeConnect(hid({ vendorId: 0x054c, productId: 0x05c4, serialNumber: 'S1' }));
+ assert.equal(back.firstSeen, 2000);
+ assert.equal(back.connected, true);
+ assert.equal(back.lastConnected, 9000);
+});
+
+test('macOui / formatSerial: MAC serials yield an OUI, product serials do not', () => {
+ // GameSir Super Nova's real captured MAC.
+ assert.equal(macOui('a05a5ef610cb'), 'a05a5e');
+ assert.equal(formatSerial('a05a5ef610cb'), 'a0:5a:5e:f6:10:cb');
+ // Steam Controller's product serial is not a MAC.
+ assert.equal(macOui('FXB9960202571'), null);
+ assert.equal(formatSerial('FXB9960202571'), 'FXB9960202571');
+ // Already-colon-formatted MACs work too.
+ assert.equal(macOui('A0:5A:5E:F6:10:CB'), 'a05a5e');
+ assert.equal(macOui(null), null);
+});
+
+test('isMacSerial: a Bluetooth MAC is one; product serials and XInput slots are not', () => {
+ assert.equal(isMacSerial('a05a5ef610cb'), true); // GameSir Super Nova MAC
+ assert.equal(isMacSerial('90:fb:a6:ba:59:1c'), true); // colon-formatted
+ assert.equal(isMacSerial('FXB9960202571'), false); // Steam product serial
+ assert.equal(isMacSerial('01'), false); // Xbox 360 XInput slot
+ assert.equal(isMacSerial(null), false);
+});
+
+test('Xbox (Gamepad-API only) is recorded with limited capabilities', () => {
+ const now = clock();
+ const inv = new ControllerInventory({ now });
+ const rec = inv.observeConnect(gamepad('Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)'));
+ assert.equal(rec.key, 'vidpid:045e:0b13');
+ assert.equal(rec.capabilities.gyro, false);
+ assert.deepEqual([...rec.transports], ['gamepad']);
+});
diff --git a/tools/hid-probe/index.html b/tools/hid-probe/index.html
new file mode 100644
index 0000000..52e1821
--- /dev/null
+++ b/tools/hid-probe/index.html
@@ -0,0 +1,192 @@
+
+
+
+
+
+HID Fingerprint Probe
+
+
+
+
HID Fingerprint Probe
+
+ Dumps a controller's identity: VID:PID, product name, declared report IDs, and the raw bytes of every
+ readable feature report — including the ones that carry a Bluetooth MAC / serial (e.g. DualShock 4
+ 0x12, DualSense 0x09). Use it to see whether a given pad exposes a stable per-unit
+ fingerprint. Nothing leaves this page.
+
+
+
+
+
+
+
+
+
No device selected. Click “Pick controller…”, choose your pad in the browser prompt, then read the dump below.
+WebHID needs a secure context — run via npm run hid-probe (localhost) or open in the Electron overlay.
+
+
+
+
diff --git a/tools/hid-serial-dump.mjs b/tools/hid-serial-dump.mjs
new file mode 100644
index 0000000..46caef5
--- /dev/null
+++ b/tools/hid-serial-dump.mjs
@@ -0,0 +1,82 @@
+#!/usr/bin/env node
+// ============================================================
+// hid-serial-dump.mjs — list every HID device + its serialNumber
+// ============================================================
+//
+// The browser (WebHID / Gamepad API) deliberately hides controller serial
+// numbers. Node's `node-hid`, run in a real process (or Electron's main
+// process), surfaces a `serialNumber` field — and for Bluetooth controllers
+// that's typically the MAC address: a stable, per-unit identifier we can use
+// to tell two identical pads apart.
+//
+// Run:
+// npm i node-hid # ships prebuilt binaries for Win/macOS/Linux
+// node tools/hid-serial-dump.mjs # game controllers only
+// node tools/hid-serial-dump.mjs --all # every HID interface
+//
+// ESM (.mjs) to match the rest of the codebase. node-hid is a CommonJS
+// native module, so it's pulled in via a dynamic import + default interop —
+// which also lets us fail gracefully when it isn't installed.
+
+let HID;
+try {
+ HID = (await import('node-hid')).default;
+} catch {
+ console.error(
+ 'node-hid is not installed.\n\n' +
+ 'Install it (prebuilt binaries — no compiler needed on Win/macOS/Linux):\n' +
+ ' npm i node-hid\n\n' +
+ 'then re-run:\n' +
+ ' node tools/hid-serial-dump.mjs\n',
+ );
+ process.exit(1);
+}
+
+const KNOWN = {
+ 0x054c: 'Sony', 0x057e: 'Nintendo', 0x28de: 'Valve',
+ 0x045e: 'Microsoft', 0x3537: 'GameSir', 0x2dc8: '8BitDo', 0x0f0d: 'Hori',
+};
+
+const showAll = process.argv.includes('--all');
+const hx = (n, w = 4) => '0x' + ((n || 0) >>> 0).toString(16).padStart(w, '0');
+
+let devices;
+try {
+ devices = HID.devices();
+} catch (e) {
+ console.error('HID.devices() failed:', e.message);
+ process.exit(1);
+}
+
+const isController = (d) => Object.prototype.hasOwnProperty.call(KNOWN, d.vendorId);
+const controllers = devices.filter(isController);
+const others = devices.filter((d) => !isController(d));
+
+function printDev(d) {
+ const tag = KNOWN[d.vendorId] ? ` [${KNOWN[d.vendorId]}]` : '';
+ console.log(` ${hx(d.vendorId)}:${hx(d.productId).slice(2)}${tag} ${d.product || '(no product)'}`);
+ console.log(` manufacturer : ${d.manufacturer || '-'}`);
+ console.log(` serialNumber : ${d.serialNumber ? '>>> ' + d.serialNumber + ' <<<' : '(none)'}`);
+ console.log(` interface=${d.interface} usagePage=${hx(d.usagePage)} usage=${hx(d.usage)} release=${d.release}`);
+ console.log(` path : ${d.path}`);
+}
+
+console.log(`\nnode-hid sees ${devices.length} HID interface(s).`);
+console.log('Note: one physical controller can expose several interfaces (the Steam Puck shows up to 5).\n');
+
+console.log(`=== Game controllers (${controllers.length}) ===`);
+if (!controllers.length) console.log(' (none found — is the controller connected & paired?)');
+controllers.forEach(printDev);
+
+if (showAll) {
+ console.log(`\n=== Other HID devices (${others.length}) ===`);
+ others.forEach(printDev);
+} else {
+ console.log(`\n(${others.length} other HID devices hidden — pass --all to show keyboards/mice/etc.)`);
+}
+
+console.log(
+ '\nKey field: serialNumber. For Bluetooth controllers it is usually the MAC address\n' +
+ '(a stable per-unit id that distinguishes even two identical pads). "(none)" means\n' +
+ 'this device/transport exposes no per-unit id — fall back to VID:PID.\n',
+);
diff --git a/tools/inventory-preview/index.html b/tools/inventory-preview/index.html
new file mode 100644
index 0000000..93f59d2
--- /dev/null
+++ b/tools/inventory-preview/index.html
@@ -0,0 +1,135 @@
+
+
+
+
+
+Controller Inventory — live preview
+
+
+
+
Controller Inventory — live preview
+
+ Drives the real ControllerInventory from @usersfirst/controller-core off your browser's
+ live WebHID + Gamepad-API events. History persists in localStorage. Browser limit: no per-unit
+ serial (Chromium blocklist) — rows key by VID:PID and identical pads merge. The shipped desktop overlay adds the
+ Electron serialNumber bridge for true per-unit rows. Click Pair HID device… once per controller
+ to grant WebHID access; Gamepad-API pads appear after you press a button.
+