From 454ea4e771039d1d0099bc7d5cb8b514c0af67be Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Sun, 7 Jun 2026 15:37:59 -0400 Subject: [PATCH 01/10] Add ControllerInventory core module + HID fingerprint probes (#241 foundation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data model for the "all controllers seen" table (#241): a headless, dependency-free registry of every controller observed, with identity, capabilities, and connection lifecycle. No `three`/DOM, so it stays out of the three import graph and runs under CI's dependency-free node:test. packages/core/src/controller-inventory.js — ControllerInventory: - Identity, best available wins: serialNumber (per-unit) > vid:pid (merges identicals) > productName. Folds WebHID + Gamepad-API sightings of one physical pad into a single record (re-keys a vid:pid record to per-unit when a serial later arrives); keeps two identical-but-distinct serials separate; leaves an un-correlatable serial-less sighting honest. - Lifecycle: firstSeen / lastConnected / lastDisconnected / connected / connectCount, via an injectable clock. - capabilitiesFor(): gyro/accel/touchpad + trackpadCount + haptics + lightbar from the dictionary. - toJSON()/loadJSON() persistence; restored records start disconnected. devices.js — added trackpadCount + haptics{count,type} to every entry (per-entry, since DualSense's adaptive triggers differ from a DS4 sharing PS_FEATURES). index.js/package.json export the module + subpath. tools/hid-probe/ (WebHID feature-report reader) and tools/hid-serial-dump.cjs (node-hid serial dumper) — the diagnostics that established the identity model: Chromium blocklists the MAC reports (DS4 0x12 / DualSense 0x09 → NotAllowedError) so the browser is VID:PID-only, but the OS HID layer (node-hid, and Electron's main-process HID events) exposes a real per-unit serial/MAC — even for a GameSir Super Nova. So per-unit identity is a desktop(Electron) capability; the web overlay merges by vid:pid. +11 tests. Core suite: 67 pass. UI (overlay table) is the next step. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 4 +- packages/core/package.json | 1 + packages/core/src/controller-inventory.js | 219 ++++++++++++++++++ packages/core/src/devices.js | 40 +++- packages/core/src/index.js | 7 + .../core/test/controller-inventory.test.js | 169 ++++++++++++++ tools/hid-probe/index.html | 192 +++++++++++++++ tools/hid-serial-dump.cjs | 80 +++++++ 8 files changed, 701 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/controller-inventory.js create mode 100644 packages/core/test/controller-inventory.test.js create mode 100644 tools/hid-probe/index.html create mode 100644 tools/hid-serial-dump.cjs diff --git a/package.json b/package.json index b325696..55cba8c 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "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.cjs" }, "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..62e2c22 --- /dev/null +++ b/packages/core/src/controller-inventory.js @@ -0,0 +1,219 @@ +// ============================================================ +// 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, + }; +} + +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..604b106 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -35,3 +35,10 @@ 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, +} 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..365885d --- /dev/null +++ b/packages/core/test/controller-inventory.test.js @@ -0,0 +1,169 @@ +// ============================================================ +// 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, +} 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('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.cjs b/tools/hid-serial-dump.cjs new file mode 100644 index 0000000..62d4b13 --- /dev/null +++ b/tools/hid-serial-dump.cjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +// ============================================================ +// hid-serial-dump.cjs — 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.cjs # game controllers only +// node tools/hid-serial-dump.cjs --all # every HID interface +// +// .cjs so it uses require() regardless of the repo's "type": "module". + +let HID; +try { + HID = require('node-hid'); +} catch (e) { + 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.cjs\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' +); From 6a0f111b5480c7ccb91f0e8000dd163e4f029c96 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Sun, 7 Jun 2026 15:44:59 -0400 Subject: [PATCH 02/10] Add live inventory preview harness (tools/inventory-preview) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A one-file page that drives the real ControllerInventory off the browser's WebHID + Gamepad-API events and renders the table, so the core module can be exercised before the production overlay UI exists. History persists to localStorage. Browser limit (VID:PID, no serial) is stated in the UI. Run: npm run inventory-preview → open /tools/inventory-preview/. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 3 +- tools/inventory-preview/index.html | 135 +++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 tools/inventory-preview/index.html diff --git a/package.json b/package.json index 55cba8c..75bf625 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "face-painter": "npx -y serve tools/face-painter", "split-glb": "node tools/split-glb.js", "hid-probe": "npx -y serve tools/hid-probe", - "hid-dump": "node tools/hid-serial-dump.cjs" + "hid-dump": "node tools/hid-serial-dump.cjs", + "inventory-preview": "npx -y serve ." }, "devDependencies": { "@gltf-transform/core": "^4.1.0", 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. +

+
+ + + +
+
+ + + + From 07d68f0a01f96beb3e4a15db367b98731797562d Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Sun, 7 Jun 2026 15:51:09 -0400 Subject: [PATCH 03/10] Convert hid-serial-dump to ESM (.mjs) to match the codebase The product code is ESM (packages/core etc. set "type":"module"); the .cjs outlier was only chosen because node-hid is CommonJS. Use a dynamic import + default interop in an .mjs instead (still fails gracefully when node-hid isn't installed). Update the hid-dump npm script. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- ...hid-serial-dump.cjs => hid-serial-dump.mjs} | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) rename tools/{hid-serial-dump.cjs => hid-serial-dump.mjs} (84%) diff --git a/package.json b/package.json index 75bf625..653ad6e 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "face-painter": "npx -y serve tools/face-painter", "split-glb": "node tools/split-glb.js", "hid-probe": "npx -y serve tools/hid-probe", - "hid-dump": "node tools/hid-serial-dump.cjs", + "hid-dump": "node tools/hid-serial-dump.mjs", "inventory-preview": "npx -y serve ." }, "devDependencies": { diff --git a/tools/hid-serial-dump.cjs b/tools/hid-serial-dump.mjs similarity index 84% rename from tools/hid-serial-dump.cjs rename to tools/hid-serial-dump.mjs index 62d4b13..46caef5 100644 --- a/tools/hid-serial-dump.cjs +++ b/tools/hid-serial-dump.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node // ============================================================ -// hid-serial-dump.cjs — list every HID device + its serialNumber +// hid-serial-dump.mjs — list every HID device + its serialNumber // ============================================================ // // The browser (WebHID / Gamepad API) deliberately hides controller serial @@ -11,21 +11,23 @@ // // Run: // npm i node-hid # ships prebuilt binaries for Win/macOS/Linux -// node tools/hid-serial-dump.cjs # game controllers only -// node tools/hid-serial-dump.cjs --all # every HID interface +// node tools/hid-serial-dump.mjs # game controllers only +// node tools/hid-serial-dump.mjs --all # every HID interface // -// .cjs so it uses require() regardless of the repo's "type": "module". +// 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 = require('node-hid'); -} catch (e) { + 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.cjs\n' + ' node tools/hid-serial-dump.mjs\n', ); process.exit(1); } @@ -76,5 +78,5 @@ if (showAll) { 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' + 'this device/transport exposes no per-unit id — fall back to VID:PID.\n', ); From 621cb89aeb64d5758f73829ac0a71645fe14471b Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Sun, 7 Jun 2026 15:54:56 -0400 Subject: [PATCH 04/10] Add macOui/formatSerial helpers for MAC-based clone detection macOui() extracts the OUI (vendor block) from a 12-hex Bluetooth MAC serial; formatSerial() pretty-prints it. Strict MAC matching (exactly 12 hex, or colon/hyphen separated) so a product serial like the Steam Controller's "FXB9960202571" isn't mistaken for a MAC. Foundation for flagging a pad that claims one vendor's USB VID but carries another vendor's MAC OUI (GameSir-Super-Nova-as-DualShock). +1 test; core 68. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/controller-inventory.js | 30 +++++++++++++++++++ packages/core/src/index.js | 2 ++ .../core/test/controller-inventory.test.js | 14 +++++++++ 3 files changed, 46 insertions(+) diff --git a/packages/core/src/controller-inventory.js b/packages/core/src/controller-inventory.js index 62e2c22..5151127 100644 --- a/packages/core/src/controller-inventory.js +++ b/packages/core/src/controller-inventory.js @@ -93,6 +93,36 @@ export function capabilitiesFor(entry) { }; } +/** + * If `serial` is a 12-hex-digit Bluetooth MAC, return its OUI — the first 3 + * bytes (6 hex chars, lowercase), i.e. the vendor block. Else null (e.g. the + * Steam Controller's "FXB99…" product serial isn't a MAC). Lets the inventory + * flag a pad that claims one vendor's USB VID but carries another vendor's MAC + * OUI — the GameSir-Super-Nova-spoofing-a-DualShock case. + */ +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; +} + +/** 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); +} + export class ControllerInventory { /** @param {{now?: () => number}} [opts] inject a clock for deterministic tests */ constructor({ now = () => Date.now() } = {}) { diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 604b106..064dc8b 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -41,4 +41,6 @@ export { normalizeDescriptor, identityKey, capabilitiesFor, + macOui, + formatSerial, } from './controller-inventory.js'; diff --git a/packages/core/test/controller-inventory.test.js b/packages/core/test/controller-inventory.test.js index 365885d..9c8c3fa 100644 --- a/packages/core/test/controller-inventory.test.js +++ b/packages/core/test/controller-inventory.test.js @@ -13,6 +13,8 @@ import { normalizeDescriptor, identityKey, capabilitiesFor, + macOui, + formatSerial, } from '../src/controller-inventory.js'; import { ControllerRegistry } from '../src/drivers/controller-registry.js'; @@ -159,6 +161,18 @@ test('persistence round-trips history; restored records start disconnected', () 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('Xbox (Gamepad-API only) is recorded with limited capabilities', () => { const now = clock(); const inv = new ControllerInventory({ now }); From 5979fedf438c3c2bec7a36d14d0da4c3bb461941 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Sun, 7 Jun 2026 16:05:02 -0400 Subject: [PATCH 05/10] Add analyzeIdentity: detect spoofed controllers by MAC OUI (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed from hardware (both DS4s over BT): a real Sony DualShock 4 has MAC OUI 90:fb:a6 (Sony), while a GameSir Super Nova spoofing the same Sony VID 054c has OUI a0:5a:5e (GameSir). analyzeIdentity() cross-checks the claimed USB vendor against the MAC's OUI and returns genuine / clone / unverified / no-mac / no-serial. Seeded OUI table with confirmed entries. Honestly handles non-MAC serials (Steam "FXB99…" product serial, Xbox 360 XInput slot "01"/"02") and USB DS4 (no serial). +2 tests; core 70. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/controller-inventory.js | 41 +++++++++++++++++++ packages/core/src/index.js | 1 + .../core/test/controller-inventory.test.js | 24 +++++++++++ 3 files changed, 66 insertions(+) diff --git a/packages/core/src/controller-inventory.js b/packages/core/src/controller-inventory.js index 5151127..e87b771 100644 --- a/packages/core/src/controller-inventory.js +++ b/packages/core/src/controller-inventory.js @@ -123,6 +123,47 @@ export function formatSerial(serial) { return hex ? hex.match(/../g).join(':') : String(serial); } +// ── Clone detection by MAC OUI (#12) ── +// A controller's USB VID can be spoofed (the GameSir Super Nova advertises +// Sony's 054c), but over Bluetooth its MAC's OUI (first 3 bytes = the IEEE +// vendor block) reveals who actually made it. Seeded from confirmed hardware; +// extend as new controllers are captured. Only used to LABEL, never to reject. +// 90:fb:a6 — confirmed: a real Sony DualShock 4 v1 (BT) +// a0:5a:5e — confirmed: a GameSir Super Nova (BT) spoofing Sony DS4 +// 28:0d:fc — widely-documented Sony Interactive PS4/PS5 controller OUI +const OUI_VENDOR = { + '90fba6': 'Sony', + '280dfc': 'Sony', + 'a05a5e': 'GameSir', +}; + +// USB VID → the vendor that registered it. +const VID_VENDOR = { + 0x054c: 'Sony', 0x057e: 'Nintendo', 0x045e: 'Microsoft', 0x28de: 'Valve', 0x3537: 'GameSir', +}; + +/** + * Cross-check a controller's claimed USB vendor against the OUI of its + * Bluetooth MAC. Returns { mac, oui, ouiVendor, vidVendor, verdict }: + * 'genuine' — OUI vendor matches the VID vendor (real article) + * 'clone' — OUI vendor is a known DIFFERENT vendor than the VID claims + * (a spoof, e.g. VID Sony but MAC OUI GameSir) + * 'unverified' — has a MAC but the OUI isn't catalogued yet + * 'no-mac' — a serial that isn't a MAC (USB feature serial / product serial) + * 'no-serial' — no serial at all (USB DS4, or any browser/blocklisted path) + */ +export function analyzeIdentity({ vendorId = null, serialNumber = null } = {}) { + const oui = macOui(serialNumber); + const ouiVendor = oui ? (OUI_VENDOR[oui] || null) : null; + const vidVendor = (vendorId != null) ? (VID_VENDOR[vendorId] || null) : null; + let verdict; + if (!oui) verdict = serialNumber ? 'no-mac' : 'no-serial'; + else if (!ouiVendor) verdict = 'unverified'; + else if (vidVendor && ouiVendor !== vidVendor) verdict = 'clone'; + else verdict = 'genuine'; + return { mac: formatSerial(serialNumber), oui, ouiVendor, vidVendor, verdict }; +} + export class ControllerInventory { /** @param {{now?: () => number}} [opts] inject a clock for deterministic tests */ constructor({ now = () => Date.now() } = {}) { diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 064dc8b..4c0c162 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -43,4 +43,5 @@ export { capabilitiesFor, macOui, formatSerial, + analyzeIdentity, } from './controller-inventory.js'; diff --git a/packages/core/test/controller-inventory.test.js b/packages/core/test/controller-inventory.test.js index 9c8c3fa..d230a04 100644 --- a/packages/core/test/controller-inventory.test.js +++ b/packages/core/test/controller-inventory.test.js @@ -15,6 +15,7 @@ import { capabilitiesFor, macOui, formatSerial, + analyzeIdentity, } from '../src/controller-inventory.js'; import { ControllerRegistry } from '../src/drivers/controller-registry.js'; @@ -173,6 +174,29 @@ test('macOui / formatSerial: MAC serials yield an OUI, product serials do not', assert.equal(macOui(null), null); }); +test('analyzeIdentity: OUI distinguishes a real DS4 from a Super Nova (real captured MACs)', () => { + // Both advertise Sony VID 054c; their Bluetooth MAC OUI tells them apart. + const real = analyzeIdentity({ vendorId: 0x054c, serialNumber: '90fba6ba591c' }); + assert.equal(real.ouiVendor, 'Sony'); + assert.equal(real.verdict, 'genuine'); + + const clone = analyzeIdentity({ vendorId: 0x054c, serialNumber: 'a05a5ef610cb' }); + assert.equal(clone.ouiVendor, 'GameSir'); + assert.equal(clone.verdict, 'clone', 'GameSir OUI under a Sony VID = spoof'); + assert.equal(clone.mac, 'a0:5a:5e:f6:10:cb'); +}); + +test('analyzeIdentity: non-MAC and missing serials degrade honestly', () => { + // Steam product serial — not a MAC, can't OUI-check. + assert.equal(analyzeIdentity({ vendorId: 0x28de, serialNumber: 'FXB9960202571' }).verdict, 'no-mac'); + // Xbox 360 reports the XInput slot ("01"/"02") as serial — not a MAC. + assert.equal(analyzeIdentity({ vendorId: 0x045e, serialNumber: '01' }).verdict, 'no-mac'); + // USB DS4 / browser: no serial at all. + assert.equal(analyzeIdentity({ vendorId: 0x054c, serialNumber: null }).verdict, 'no-serial'); + // A MAC whose OUI we haven't catalogued. + assert.equal(analyzeIdentity({ vendorId: 0x054c, serialNumber: '001122334455' }).verdict, 'unverified'); +}); + test('Xbox (Gamepad-API only) is recorded with limited capabilities', () => { const now = clock(); const inv = new ControllerInventory({ now }); From 555404a1ae03cc116a911c73984aab674a6f4514 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Sun, 7 Jun 2026 16:12:20 -0400 Subject: [PATCH 06/10] Add Electron Controller Inventory window (serials via main-process HID events) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop inventory table (#241). Main process captures serialNumber from its HID device events (select-hid-device deviceList + hid-device-added/ removed) — which expose the serial the renderer's blocklisted WebHID can't — and pushes the controller map to a new inventory window. No native module (avoids the node-hid/Electron-ABI rebuild); inventory degrades to Gamepad API if serials don't come through. Logs each device's serial so we can confirm Electron surfaces it. - electron/main.js: hidControllers map + upsert/broadcast from session HID events; openInventoryWindow(); ipc open-inventory-window / list-hid- controllers; tray "Controller Inventory…" item. - electron/preload.js: openInventoryWindow / listHidControllers / onHidControllersSnapshot bridge. - src/inventory.html + src/js/inventory.js: table (Genuine?/Serial/OUI/ Transport/lifecycle/caps) driven by ControllerInventory; Scan button triggers enumeration; localStorage persistence; Gamepad-API merge. Open via the tray menu after `npm start`. analyzeIdentity flags the Super Nova as a clone vs a real DS4 by MAC OUI. Core suite still 70. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/overlay/electron/main.js | 75 +++++++++++++++++++ apps/overlay/electron/preload.js | 9 +++ apps/overlay/src/inventory.html | 52 +++++++++++++ apps/overlay/src/js/inventory.js | 125 +++++++++++++++++++++++++++++++ 4 files changed, 261 insertions(+) create mode 100644 apps/overlay/src/inventory.html create mode 100644 apps/overlay/src/js/inventory.js diff --git a/apps/overlay/electron/main.js b/apps/overlay/electron/main.js index 54ccb4e..2240b7a 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,10 @@ 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); + // Capture every candidate's serial for the inventory (this list carries + // serialNumber even though the renderer's WebHID objects don't). + for (const d of details.deviceList || []) upsertHidController(d, true); + 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 +337,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..181881e --- /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, so the Genuine/Clone column can tell a real + DualShock from a GameSir spoof by MAC OUI. USB-only pads and Gamepad-API-only pads (Xbox) have no serial and key + by VID:PID. History persists across launches. +

+
+ + + +
+
+ + + + + diff --git a/apps/overlay/src/js/inventory.js b/apps/overlay/src/js/inventory.js new file mode 100644 index 0000000..49e2000 --- /dev/null +++ b/apps/overlay/src/js/inventory.js @@ -0,0 +1,125 @@ +// Controller Inventory window (Electron). Drives ControllerInventory from two +// sources: node-hid in the main process (HID devices WITH serials, pushed as +// periodic snapshots over IPC) and the renderer's Gamepad API (covers pads +// without HID serials, e.g. Xbox). Renders the table; persists to localStorage. + +import { ControllerInventory, analyzeIdentity, formatSerial } 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 ── +const VERDICT = { + genuine: { label: 'genuine', cls: 'ok' }, + clone: { label: '⚠ clone', cls: 'bad' }, + unverified: { label: 'unverified', cls: 'warn' }, + 'no-mac': { label: '—', cls: 'dim' }, + 'no-serial': { label: '—', cls: 'dim' }, +}; + +function transportOf(rec) { + const list = rec.transports instanceof Set ? [...rec.transports] : (rec.transports || []); + const hasHid = list.includes('hid'); + const isMac = !!analyzeIdentity({ serialNumber: rec.serialNumber }).oui; + if (hasHid && isMac) 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', 'Genuine?', 'Serial / MAC', 'OUI', 'Transport', 'Status', 'Gyro', 'Trackpads', 'Haptics', 'First seen', 'Last connected', 'Last disconnected', '#']; + const row = (r) => { + const c = r.capabilities; + const id = analyzeIdentity({ vendorId: r.vendorId, serialNumber: r.serialNumber }); + const v = VERDICT[id.verdict] || VERDICT['no-serial']; + 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') : ''} + ${v.label}${id.ouiVendor ? ' (' + id.ouiVendor + ')' : ''} + ${r.serialNumber ? formatSerial(r.serialNumber) : '—'} + ${id.oui || '—'} + ${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 From e3e8741eb630b2ec563447136811bb4445079794 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Sun, 7 Jun 2026 16:14:56 -0400 Subject: [PATCH 07/10] Catalogue GameSir Cyclone 2 OUI (d0:56:80) for clone detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed from hardware: a GameSir Cyclone 2 in DS4 v2 mode (advertises Sony 054c:09cc) has Bluetooth MAC OUI d0:56:80 — a different GameSir block than the Super Nova's a0:5a:5e. Added so it's flagged as a clone instead of "unverified". GameSir uses multiple OUIs; catalogue each as captured. +test. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/controller-inventory.js | 5 ++++- packages/core/test/controller-inventory.test.js | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/core/src/controller-inventory.js b/packages/core/src/controller-inventory.js index e87b771..1db42d8 100644 --- a/packages/core/src/controller-inventory.js +++ b/packages/core/src/controller-inventory.js @@ -129,12 +129,15 @@ export function formatSerial(serial) { // vendor block) reveals who actually made it. Seeded from confirmed hardware; // extend as new controllers are captured. Only used to LABEL, never to reject. // 90:fb:a6 — confirmed: a real Sony DualShock 4 v1 (BT) -// a0:5a:5e — confirmed: a GameSir Super Nova (BT) spoofing Sony DS4 +// a0:5a:5e — confirmed: a GameSir Super Nova (BT) spoofing Sony DS4 (PID 05c4) +// d0:56:80 — confirmed: a GameSir Cyclone 2 (BT) spoofing Sony DS4 (PID 09cc) // 28:0d:fc — widely-documented Sony Interactive PS4/PS5 controller OUI +// (GameSir ships under several OUI blocks — catalogue each as it's captured.) const OUI_VENDOR = { '90fba6': 'Sony', '280dfc': 'Sony', 'a05a5e': 'GameSir', + 'd05680': 'GameSir', }; // USB VID → the vendor that registered it. diff --git a/packages/core/test/controller-inventory.test.js b/packages/core/test/controller-inventory.test.js index d230a04..9641488 100644 --- a/packages/core/test/controller-inventory.test.js +++ b/packages/core/test/controller-inventory.test.js @@ -184,6 +184,13 @@ test('analyzeIdentity: OUI distinguishes a real DS4 from a Super Nova (real capt assert.equal(clone.ouiVendor, 'GameSir'); assert.equal(clone.verdict, 'clone', 'GameSir OUI under a Sony VID = spoof'); assert.equal(clone.mac, 'a0:5a:5e:f6:10:cb'); + + // GameSir Cyclone 2 (DS4 v2 / 09cc) uses a DIFFERENT GameSir OUI block; + // it still advertises Sony's VID 054c, so it's flagged too. + const cyclone = analyzeIdentity({ vendorId: 0x054c, serialNumber: 'd05680459747' }); + assert.equal(cyclone.ouiVendor, 'GameSir'); + assert.equal(cyclone.verdict, 'clone', 'second GameSir OUI block also flagged'); + assert.equal(cyclone.mac, 'd0:56:80:45:97:47'); }); test('analyzeIdentity: non-MAC and missing serials degrade honestly', () => { From 6dbfc5871d38c71bc48574dbdcecf178aa762909 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Sun, 7 Jun 2026 16:29:56 -0400 Subject: [PATCH 08/10] Catalogue real DualSense OUI (50:ee:32) as Sony MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed from hardware: a genuine DualSense (054c:0ce6) has MAC OUI 50:ee:32 → now labelled genuine instead of unverified. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/controller-inventory.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/core/src/controller-inventory.js b/packages/core/src/controller-inventory.js index 1db42d8..5c50e54 100644 --- a/packages/core/src/controller-inventory.js +++ b/packages/core/src/controller-inventory.js @@ -134,10 +134,11 @@ export function formatSerial(serial) { // 28:0d:fc — widely-documented Sony Interactive PS4/PS5 controller OUI // (GameSir ships under several OUI blocks — catalogue each as it's captured.) const OUI_VENDOR = { - '90fba6': 'Sony', - '280dfc': 'Sony', - 'a05a5e': 'GameSir', - 'd05680': 'GameSir', + '90fba6': 'Sony', // confirmed: real DualShock 4 v1 + '50ee32': 'Sony', // confirmed: real DualSense (054c:0ce6) + '280dfc': 'Sony', // widely-documented Sony Interactive PS controller OUI + 'a05a5e': 'GameSir', // confirmed: GameSir Super Nova (spoofs 054c:05c4) + 'd05680': 'GameSir', // confirmed: GameSir Cyclone 2 (spoofs 054c:09cc) }; // USB VID → the vendor that registered it. From 2aad78bb258ee5c8edd8a4da6f52455bf3848664 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Sun, 7 Jun 2026 17:49:07 -0400 Subject: [PATCH 09/10] Drop OUI clone-detection; keep MAC as per-unit identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evaluation showed OUI→vendor is unreliable for this: genuine PlayStation controllers carry a Foxconn (Hon Hai) OUI — NOT Sony's (90:fb:a6, 50:ee:32 both resolve to Hon Hai in the 88k IEEE/Wireshark/nmap master DB) — and the GameSir clone OUIs (a0:5a:5e, d0:56:80) aren't in any registry at all. So an allowlist would false-flag real pads and a denylist is a treadmill. Removed analyzeIdentity + the OUI/VID vendor tables and the Genuine?/OUI columns. Kept the solid part: the full MAC is the per-unit identity (distinguishes identical pads over Bluetooth). macOui/formatSerial retained; added isMacSerial (used for BT-vs-USB transport inference). If a vendor lookup is ever wanted it can be injected — design + the parked "OUI web API" idea are in the controller-identity memo. Core suite 69. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/overlay/src/inventory.html | 6 +- apps/overlay/src/js/inventory.js | 27 +++----- packages/core/src/controller-inventory.js | 66 +++++-------------- packages/core/src/index.js | 2 +- .../core/test/controller-inventory.test.js | 36 ++-------- 5 files changed, 37 insertions(+), 100 deletions(-) diff --git a/apps/overlay/src/inventory.html b/apps/overlay/src/inventory.html index 181881e..46804e2 100644 --- a/apps/overlay/src/inventory.html +++ b/apps/overlay/src/inventory.html @@ -33,9 +33,9 @@

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, so the Genuine/Clone column can tell a real - DualShock from a GameSir spoof by MAC OUI. USB-only pads and Gamepad-API-only pads (Xbox) have no serial and key - by VID:PID. History persists across launches. + 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.

diff --git a/apps/overlay/src/js/inventory.js b/apps/overlay/src/js/inventory.js index 49e2000..d25dc5e 100644 --- a/apps/overlay/src/js/inventory.js +++ b/apps/overlay/src/js/inventory.js @@ -1,9 +1,11 @@ // Controller Inventory window (Electron). Drives ControllerInventory from two -// sources: node-hid in the main process (HID devices WITH serials, pushed as -// periodic snapshots over IPC) and the renderer's Gamepad API (covers pads -// without HID serials, e.g. Xbox). Renders the table; persists to localStorage. +// 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, analyzeIdentity, formatSerial } from '@usersfirst/controller-core/controller-inventory'; +import { ControllerInventory, formatSerial, isMacSerial } from '@usersfirst/controller-core/controller-inventory'; const api = window.electronAPI; const LS_KEY = 'controller-inventory'; @@ -70,19 +72,10 @@ function pollGamepads() { setInterval(pollGamepads, 600); // ── Render ── -const VERDICT = { - genuine: { label: 'genuine', cls: 'ok' }, - clone: { label: '⚠ clone', cls: 'bad' }, - unverified: { label: 'unverified', cls: 'warn' }, - 'no-mac': { label: '—', cls: 'dim' }, - 'no-serial': { label: '—', cls: 'dim' }, -}; - function transportOf(rec) { const list = rec.transports instanceof Set ? [...rec.transports] : (rec.transports || []); const hasHid = list.includes('hid'); - const isMac = !!analyzeIdentity({ serialNumber: rec.serialNumber }).oui; - if (hasHid && isMac) return 'Bluetooth'; + if (hasHid && isMacSerial(rec.serialNumber)) return 'Bluetooth'; if (hasHid) return 'USB / HID'; return 'Gamepad'; } @@ -94,17 +87,13 @@ function render() { 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', 'Genuine?', 'Serial / MAC', 'OUI', 'Transport', 'Status', 'Gyro', 'Trackpads', 'Haptics', 'First seen', 'Last connected', 'Last disconnected', '#']; + const head = ['Name', 'Serial / MAC', 'Transport', 'Status', 'Gyro', 'Trackpads', 'Haptics', 'First seen', 'Last connected', 'Last disconnected', '#']; const row = (r) => { const c = r.capabilities; - const id = analyzeIdentity({ vendorId: r.vendorId, serialNumber: r.serialNumber }); - const v = VERDICT[id.verdict] || VERDICT['no-serial']; 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') : ''} - ${v.label}${id.ouiVendor ? ' (' + id.ouiVendor + ')' : ''} ${r.serialNumber ? formatSerial(r.serialNumber) : '—'} - ${id.oui || '—'} ${transportOf(r)} ${transports} ${r.connected ? 'connected' : 'offline'} ${c.gyro ? '✓' : '—'} diff --git a/packages/core/src/controller-inventory.js b/packages/core/src/controller-inventory.js index 5c50e54..7580074 100644 --- a/packages/core/src/controller-inventory.js +++ b/packages/core/src/controller-inventory.js @@ -95,10 +95,13 @@ export function capabilitiesFor(entry) { /** * If `serial` is a 12-hex-digit Bluetooth MAC, return its OUI — the first 3 - * bytes (6 hex chars, lowercase), i.e. the vendor block. Else null (e.g. the - * Steam Controller's "FXB99…" product serial isn't a MAC). Lets the inventory - * flag a pad that claims one vendor's USB VID but carries another vendor's MAC - * OUI — the GameSir-Super-Nova-spoofing-a-DualShock case. + * 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; @@ -116,6 +119,11 @@ export function macOui(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; @@ -123,50 +131,12 @@ export function formatSerial(serial) { return hex ? hex.match(/../g).join(':') : String(serial); } -// ── Clone detection by MAC OUI (#12) ── -// A controller's USB VID can be spoofed (the GameSir Super Nova advertises -// Sony's 054c), but over Bluetooth its MAC's OUI (first 3 bytes = the IEEE -// vendor block) reveals who actually made it. Seeded from confirmed hardware; -// extend as new controllers are captured. Only used to LABEL, never to reject. -// 90:fb:a6 — confirmed: a real Sony DualShock 4 v1 (BT) -// a0:5a:5e — confirmed: a GameSir Super Nova (BT) spoofing Sony DS4 (PID 05c4) -// d0:56:80 — confirmed: a GameSir Cyclone 2 (BT) spoofing Sony DS4 (PID 09cc) -// 28:0d:fc — widely-documented Sony Interactive PS4/PS5 controller OUI -// (GameSir ships under several OUI blocks — catalogue each as it's captured.) -const OUI_VENDOR = { - '90fba6': 'Sony', // confirmed: real DualShock 4 v1 - '50ee32': 'Sony', // confirmed: real DualSense (054c:0ce6) - '280dfc': 'Sony', // widely-documented Sony Interactive PS controller OUI - 'a05a5e': 'GameSir', // confirmed: GameSir Super Nova (spoofs 054c:05c4) - 'd05680': 'GameSir', // confirmed: GameSir Cyclone 2 (spoofs 054c:09cc) -}; - -// USB VID → the vendor that registered it. -const VID_VENDOR = { - 0x054c: 'Sony', 0x057e: 'Nintendo', 0x045e: 'Microsoft', 0x28de: 'Valve', 0x3537: 'GameSir', -}; - -/** - * Cross-check a controller's claimed USB vendor against the OUI of its - * Bluetooth MAC. Returns { mac, oui, ouiVendor, vidVendor, verdict }: - * 'genuine' — OUI vendor matches the VID vendor (real article) - * 'clone' — OUI vendor is a known DIFFERENT vendor than the VID claims - * (a spoof, e.g. VID Sony but MAC OUI GameSir) - * 'unverified' — has a MAC but the OUI isn't catalogued yet - * 'no-mac' — a serial that isn't a MAC (USB feature serial / product serial) - * 'no-serial' — no serial at all (USB DS4, or any browser/blocklisted path) - */ -export function analyzeIdentity({ vendorId = null, serialNumber = null } = {}) { - const oui = macOui(serialNumber); - const ouiVendor = oui ? (OUI_VENDOR[oui] || null) : null; - const vidVendor = (vendorId != null) ? (VID_VENDOR[vendorId] || null) : null; - let verdict; - if (!oui) verdict = serialNumber ? 'no-mac' : 'no-serial'; - else if (!ouiVendor) verdict = 'unverified'; - else if (vidVendor && ouiVendor !== vidVendor) verdict = 'clone'; - else verdict = 'genuine'; - return { mac: formatSerial(serialNumber), oui, ouiVendor, vidVendor, verdict }; -} +// 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 */ diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 4c0c162..5a47228 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -43,5 +43,5 @@ export { capabilitiesFor, macOui, formatSerial, - analyzeIdentity, + isMacSerial, } from './controller-inventory.js'; diff --git a/packages/core/test/controller-inventory.test.js b/packages/core/test/controller-inventory.test.js index 9641488..369c183 100644 --- a/packages/core/test/controller-inventory.test.js +++ b/packages/core/test/controller-inventory.test.js @@ -15,7 +15,7 @@ import { capabilitiesFor, macOui, formatSerial, - analyzeIdentity, + isMacSerial, } from '../src/controller-inventory.js'; import { ControllerRegistry } from '../src/drivers/controller-registry.js'; @@ -174,34 +174,12 @@ test('macOui / formatSerial: MAC serials yield an OUI, product serials do not', assert.equal(macOui(null), null); }); -test('analyzeIdentity: OUI distinguishes a real DS4 from a Super Nova (real captured MACs)', () => { - // Both advertise Sony VID 054c; their Bluetooth MAC OUI tells them apart. - const real = analyzeIdentity({ vendorId: 0x054c, serialNumber: '90fba6ba591c' }); - assert.equal(real.ouiVendor, 'Sony'); - assert.equal(real.verdict, 'genuine'); - - const clone = analyzeIdentity({ vendorId: 0x054c, serialNumber: 'a05a5ef610cb' }); - assert.equal(clone.ouiVendor, 'GameSir'); - assert.equal(clone.verdict, 'clone', 'GameSir OUI under a Sony VID = spoof'); - assert.equal(clone.mac, 'a0:5a:5e:f6:10:cb'); - - // GameSir Cyclone 2 (DS4 v2 / 09cc) uses a DIFFERENT GameSir OUI block; - // it still advertises Sony's VID 054c, so it's flagged too. - const cyclone = analyzeIdentity({ vendorId: 0x054c, serialNumber: 'd05680459747' }); - assert.equal(cyclone.ouiVendor, 'GameSir'); - assert.equal(cyclone.verdict, 'clone', 'second GameSir OUI block also flagged'); - assert.equal(cyclone.mac, 'd0:56:80:45:97:47'); -}); - -test('analyzeIdentity: non-MAC and missing serials degrade honestly', () => { - // Steam product serial — not a MAC, can't OUI-check. - assert.equal(analyzeIdentity({ vendorId: 0x28de, serialNumber: 'FXB9960202571' }).verdict, 'no-mac'); - // Xbox 360 reports the XInput slot ("01"/"02") as serial — not a MAC. - assert.equal(analyzeIdentity({ vendorId: 0x045e, serialNumber: '01' }).verdict, 'no-mac'); - // USB DS4 / browser: no serial at all. - assert.equal(analyzeIdentity({ vendorId: 0x054c, serialNumber: null }).verdict, 'no-serial'); - // A MAC whose OUI we haven't catalogued. - assert.equal(analyzeIdentity({ vendorId: 0x054c, serialNumber: '001122334455' }).verdict, 'unverified'); +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', () => { From 8187f847e0c775e4b52707a3a5355a619bb1565c Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Sun, 7 Jun 2026 19:04:18 -0400 Subject: [PATCH 10/10] Inventory: make Scan an authoritative refresh of connected state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scan's select-hid-device deviceList is the set of currently-present HID devices, so on each Scan we now also mark any known controller NOT in that list as disconnected. This gives an accurate on-demand refresh of connected/offline + Last disconnected without a native module — chosen over node-hid polling to keep the no-dependency, browser/web-compatible path (node-hid auto-polling remains a possible future upgrade). Button relabeled "Scan / Refresh". Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/overlay/electron/main.js | 14 +++++++++++--- apps/overlay/src/inventory.html | 4 ++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/overlay/electron/main.js b/apps/overlay/electron/main.js index 2240b7a..e0ed3ba 100644 --- a/apps/overlay/electron/main.js +++ b/apps/overlay/electron/main.js @@ -307,9 +307,17 @@ 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); - // Capture every candidate's serial for the inventory (this list carries - // serialNumber even though the renderer's WebHID objects don't). - for (const d of details.deviceList || []) upsertHidController(d, true); + // 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; } diff --git a/apps/overlay/src/inventory.html b/apps/overlay/src/inventory.html index 46804e2..677621c 100644 --- a/apps/overlay/src/inventory.html +++ b/apps/overlay/src/inventory.html @@ -38,9 +38,9 @@

Controller Inventory

persists across launches.

- + - + Click Scan / Refresh to update which controllers are currently connected.