Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions apps/overlay/electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -101,6 +162,8 @@ function updateTrayMenu() {
},
},
{ type: 'separator' },
{ label: 'Controller Inventory…', click: openInventoryWindow },
{ type: 'separator' },
{ label: 'Quit', click: () => app.quit() },
]);
tray.setContextMenu(menu);
Expand All @@ -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
Expand Down Expand Up @@ -240,6 +307,18 @@ app.on('web-contents-created', (_, contents) => {
contents.session.on('select-hid-device', (event, details, callback) => {
event.preventDefault();
console.log('select-hid-device: deviceList length =', details.deviceList?.length || 0);
// This deviceList IS the set of currently-present HID devices, so a Scan
// doubles as an authoritative refresh: capture each present device's serial,
// then mark anything we knew about that ISN'T present now as disconnected.
// (hid-device-removed is unreliable over Bluetooth / for ungranted Puck
// interfaces, so this manual reconcile is how the inventory's connected
// state stays honest without a native module.)
const present = new Set();
for (const d of details.deviceList || []) { upsertHidController(d, true); present.add(hidKey(d)); }
for (const [key, c] of hidControllers) {
if (!present.has(key) && c.connected) hidControllers.set(key, { ...c, connected: false });
}
broadcastHidControllers();
if (details.deviceList && details.deviceList.length > 0) {
if (selectTimeout) { clearTimeout(selectTimeout); selectTimeout = null; }
// Prefer a device we haven't picked yet in this session. Falls back to
Expand All @@ -266,9 +345,13 @@ app.on('web-contents-created', (_, contents) => {
// This fires when a device matching an active requestDevice() filter appears.
contents.session.on('hid-device-added', (event, device) => {
console.log('hid-device-added:', device.name || device.productId);
upsertHidController(device, true);
broadcastHidControllers();
});

contents.session.on('hid-device-removed', (event, device) => {
console.log('hid-device-removed:', device.name || device.productId);
upsertHidController(device, false);
broadcastHidControllers();
});
});
9 changes: 9 additions & 0 deletions apps/overlay/electron/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
});
52 changes: 52 additions & 0 deletions apps/overlay/src/inventory.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Controller Inventory</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; padding: 18px; background: #14141f; color: #e6e6f0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
h1 { font-size: 18px; margin: 0 0 2px; }
p.sub { margin: 0 0 14px; color: #9a9ab0; font-size: 12.5px; max-width: 900px; line-height: 1.5; }
button { background: #2a2a3e; color: #fff; border: 1.5px solid #44445e; border-radius: 9px;
padding: 7px 13px; font-size: 13px; font-weight: 600; cursor: pointer; margin: 0 8px 12px 0; }
button:hover { background: #383852; border-color: #6a6a8e; }
table { border-collapse: collapse; width: 100%; font-size: 12.5px; }
th, td { text-align: left; padding: 7px 9px; border-bottom: 1px solid #2a2a3e; white-space: nowrap; }
th { color: #9a9ab0; font-weight: 600; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.04em; }
td.dim { color: #7a7a92; }
.pill { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 11px; font-weight: 700; }
.on { background: #14432a; color: #8fffc1; }
.off { background: #3a1f1f; color: #ff9f9f; }
.ok { color: #8fffc1; font-weight: 700; } .bad { color: #ff7f7f; font-weight: 700; }
.warn { color: #ffd27f; font-weight: 700; }
.yes { color: #8fffc1; } .no { color: #555; }
.tag { display:inline-block; background:#2a2a3e; color:#cfcfe6; border-radius:6px; padding:1px 6px; font-size:10.5px; }
code { background: #2a2a3e; padding: 1px 5px; border-radius: 4px; font-size: 12px; }
.empty { color:#7a7a92; padding: 24px 0; }
</style>
</head>
<body>
<h1>Controller Inventory</h1>
<p class="sub">
Every controller this machine has seen, with its connection lifecycle and capabilities. Bluetooth pads expose a
real per-unit serial (MAC) via the desktop's HID layer — that's the stable per-unit id (it distinguishes even two
identical pads). USB-only pads and Gamepad-API-only pads (Xbox) have no serial and key by VID:PID. History
persists across launches.
</p>
<div>
<button id="scan">Scan / Refresh</button>
<button id="clear">Clear history</button>
<span id="status" class="dim" style="font-size:12px">Click Scan / Refresh to update which controllers are currently connected.</span>
</div>
<div id="tableWrap"></div>

<script type="importmap">
{ "imports": { "@usersfirst/controller-core/controller-inventory": "./lib/controller-core/controller-inventory.js" } }
</script>
<script type="module" src="js/inventory.js"></script>
</body>
</html>
114 changes: 114 additions & 0 deletions apps/overlay/src/js/inventory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Controller Inventory window (Electron). Drives ControllerInventory from two
// sources: the main process's HID device events (devices WITH serials, pushed
// as snapshots over IPC) and the renderer's Gamepad API (covers pads without
// HID serials, e.g. Xbox). Renders the table; persists to localStorage. The
// MAC (when present, over Bluetooth) is the per-unit identity; we don't try to
// classify genuine-vs-clone by OUI (unreliable — see controller-identity memo).

import { ControllerInventory, formatSerial, isMacSerial } from '@usersfirst/controller-core/controller-inventory';

const api = window.electronAPI;
const LS_KEY = 'controller-inventory';
const inv = new ControllerInventory();
try { const s = localStorage.getItem(LS_KEY); if (s) inv.loadJSON(JSON.parse(s)); } catch {}
const save = () => { try { localStorage.setItem(LS_KEY, JSON.stringify(inv.toJSON())); } catch {} };

const status = document.getElementById('status');
const fmt = (t) => (t ? new Date(t).toLocaleString() : '—');

// ── HID source (main process; carries serials) ──
// Main sends the full controller map (each with a `connected` flag) whenever it
// changes. We apply it directly — connected → observeConnect, else disconnect.
function applyHidSnapshot(list) {
if (!Array.isArray(list)) return;
for (const c of list) {
const desc = { source: 'hid', vendorId: c.vendorId, productId: c.productId, productName: c.name, serialNumber: c.serialNumber };
if (c.connected) inv.observeConnect(desc); else inv.observeDisconnect(desc);
}
save();
render();
}

if (api) {
api.onHidControllersSnapshot(applyHidSnapshot);
api.listHidControllers().then(applyHidSnapshot).catch(() => {});
} else {
status.textContent = 'Not running in Electron — HID serials unavailable (Gamepad API only).';
}

// Serials populate when the HID device list is enumerated, which needs a user
// gesture. The Scan button triggers it; main captures the serials and pushes
// them back. (No chooser appears — the main process auto-handles selection.)
const scanBtn = document.getElementById('scan');
if (scanBtn) {
scanBtn.onclick = async () => {
if (!navigator.hid) { status.textContent = 'WebHID unavailable.'; return; }
try {
status.textContent = 'Scanning…';
await navigator.hid.requestDevice({ filters: [] });
status.textContent = '';
} catch (e) { status.textContent = e.message; }
};
}

// ── Gamepad API source (no serials; covers Xbox & any pad we lack HID for) ──
const seenPads = new Map();
function pollGamepads() {
const pads = (navigator.getGamepads && navigator.getGamepads()) || [];
const live = new Set();
for (const gp of pads) {
if (!gp) continue;
live.add(gp.index);
if (!seenPads.has(gp.index)) {
seenPads.set(gp.index, gp.id);
inv.observeConnect({ source: 'gamepad', gamepadId: gp.id });
save(); render();
}
}
for (const [i, id] of seenPads) {
if (!live.has(i)) { seenPads.delete(i); inv.observeDisconnect({ source: 'gamepad', gamepadId: id }); save(); render(); }
}
}
setInterval(pollGamepads, 600);

// ── Render ──
function transportOf(rec) {
const list = rec.transports instanceof Set ? [...rec.transports] : (rec.transports || []);
const hasHid = list.includes('hid');
if (hasHid && isMacSerial(rec.serialNumber)) return 'Bluetooth';
if (hasHid) return 'USB / HID';
return 'Gamepad';
}

function render() {
const rows = inv.list();
const wrap = document.getElementById('tableWrap');
if (!rows.length) {
wrap.innerHTML = '<div class="empty">No controllers seen yet. Connect a pad over Bluetooth or USB; press a button on a Gamepad-API-only pad (Xbox) to register it.</div>';
return;
}
const head = ['Name', 'Serial / MAC', 'Transport', 'Status', 'Gyro', 'Trackpads', 'Haptics', 'First seen', 'Last connected', 'Last disconnected', '#'];
const row = (r) => {
const c = r.capabilities;
const transports = (r.transports instanceof Set ? [...r.transports] : (r.transports || [])).map((t) => `<span class="tag">${t}</span>`).join(' ');
return `<tr>
<td>${r.name || '(unknown)'} <span class="dim">${r.vendorId != null ? r.vendorId.toString(16).padStart(4, '0') + ':' + r.productId.toString(16).padStart(4, '0') : ''}</span></td>
<td class="dim">${r.serialNumber ? formatSerial(r.serialNumber) : '—'}</td>
<td>${transportOf(r)} <span class="dim">${transports}</span></td>
<td><span class="pill ${r.connected ? 'on' : 'off'}">${r.connected ? 'connected' : 'offline'}</span></td>
<td class="${c.gyro ? 'yes' : 'no'}">${c.gyro ? '✓' : '—'}</td>
<td class="${c.trackpadCount ? 'yes' : 'no'}">${c.trackpadCount || '—'}</td>
<td>${c.haptics ? (c.haptics.count ?? '?') + ' <span class="dim">' + (c.haptics.type || '') + '</span>' : '<span class="no">—</span>'}</td>
<td class="dim">${fmt(r.firstSeen)}</td>
<td class="dim">${fmt(r.lastConnected)}</td>
<td class="dim">${fmt(r.lastDisconnected)}</td>
<td class="dim">${r.connectCount}</td>
</tr>`;
};
wrap.innerHTML = `<table><thead><tr>${head.map((h) => `<th>${h}</th>`).join('')}</tr></thead><tbody>${rows.map(row).join('')}</tbody></table>`;
}

document.getElementById('clear').onclick = () => { inv.clear(); save(); render(); };

render();
setInterval(render, 1000); // keep timestamps fresh
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
"scripts": {
"test": "npm run test --workspaces --if-present",
"face-painter": "npx -y serve tools/face-painter",
"split-glb": "node tools/split-glb.js"
"split-glb": "node tools/split-glb.js",
"hid-probe": "npx -y serve tools/hid-probe",
"hid-dump": "node tools/hid-serial-dump.mjs",
"inventory-preview": "npx -y serve ."
},
"devDependencies": {
"@gltf-transform/core": "^4.1.0",
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading