Skip to content

Controller inventory: core data model + HID fingerprint probes (#241 foundation)#34

Merged
petegordon merged 10 commits into
mainfrom
feat-controller-inventory-core
Jun 7, 2026
Merged

Controller inventory: core data model + HID fingerprint probes (#241 foundation)#34
petegordon merged 10 commits into
mainfrom
feat-controller-inventory-core

Conversation

@petegordon

Copy link
Copy Markdown
Member

First half of the controller-inventory feature (#241) — core module only; the overlay table UI is the next step (we agreed: build & review the data model first).

What the probes established

Using the two diagnostics in tools/ against real hardware:

  • Browser is VID:PID-only. Chromium blocklists the MAC/serial feature reports (DualShock-4 0x12, DualSense 0x09NotAllowedError) for real Sony pads and clones alike. The other readable reports are stale input buffers / uninitialized memory, not identity.
  • The OS HID layer exposes a real per-unit serial. node-hid (and, in production, Electron's main-process HID events) returned a unique MAC even for a GameSir Super Nova (a05a5ef610cb) and a Valve serial for the Steam Controller (FXB9960202571, identical across all its interfaces).

Per-unit identity is a desktop (Electron) capability; the web overlay merges by vid:pid. No node-hid needed in the shipped app — Electron's select-hid-device / hid-device-added events carry serialNumber.

packages/core/src/controller-inventory.js

Headless, dependency-free (no three/DOM):

  • Identity (best wins): serialNumbervid:pid (merge identicals) → productName. Folds WebHID + Gamepad-API sightings of one pad into a single record; upgrades a vid:pid record to per-unit when a serial later arrives; keeps two distinct serials separate; leaves an un-correlatable serial-less sighting as its own row (honest).
  • Lifecycle: firstSeen / lastConnected / lastDisconnected / connected / connectCount (injectable clock).
  • Capabilities: gyro/accel/touchpad + trackpadCount + haptics {count,type} + lightbar, from the dictionary.
  • Persistence: toJSON() / loadJSON() (localStorage on web, userData JSON in Electron); restored records start disconnected.

devices.js

Added trackpadCount + haptics {count,type} to every entry (per-entry — DualSense's adaptive triggers differ from a DS4 that shares PS_FEATURES).

Tests

controller-inventory.test.js — 11 tests (identity precedence, gamepad-id parsing, capability enrichment, connect/reconnect/disconnect lifecycle, both cross-source merge directions, identical-pads-stay-separate, sort order, persistence round-trip, Xbox limited-caps). Core suite: 67 pass.

Next (after review)

Overlay table view in apps/overlay (WebHID + navigator.getGamepads() wiring + Electron serial bridge via IPC).

🤖 Generated with Claude Code

petegordon and others added 10 commits June 7, 2026 15:37
…undation)

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…D events)

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@petegordon petegordon merged commit 67d3854 into main Jun 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant