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.
'; + return; + } + const head = ['Name', 'Serial / MAC', 'Transport', 'Status', 'Gyro', 'Trackpads', 'Haptics', 'First seen', 'Last connected', 'Last disconnected', '#']; + const row = (r) => { + const c = r.capabilities; + const transports = (r.transports instanceof Set ? [...r.transports] : (r.transports || [])).map((t) => `${t}`).join(' '); + return ` + ${r.name || '(unknown)'} ${r.vendorId != null ? r.vendorId.toString(16).padStart(4, '0') + ':' + r.productId.toString(16).padStart(4, '0') : ''} + ${r.serialNumber ? formatSerial(r.serialNumber) : '—'} + ${transportOf(r)} ${transports} + ${r.connected ? 'connected' : 'offline'} + ${c.gyro ? '✓' : '—'} + ${c.trackpadCount || '—'} + ${c.haptics ? (c.haptics.count ?? '?') + ' ' + (c.haptics.type || '') + '' : ''} + ${fmt(r.firstSeen)} + ${fmt(r.lastConnected)} + ${fmt(r.lastDisconnected)} + ${r.connectCount} + `; + }; + wrap.innerHTML = `${head.map((h) => ``).join('')}${rows.map(row).join('')}
${h}
`; +} + +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. +

+
+ + + +
+
+ + + +