UNPKG

dualsense-ts

Version:

The natural interface for your DualSense and DualSense Access controllers, with Typescript

499 lines 20.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.DualsenseManager = void 0; const dualsense_1 = require("./dualsense"); const input_1 = require("./input"); const command_1 = require("./hid/command"); const dualsense_hid_1 = require("./hid/dualsense_hid"); const hid_provider_1 = require("./hid/hid_provider"); const node_hid_provider_1 = require("./hid/node_hid_provider"); const web_hid_provider_1 = require("./hid/web_hid_provider"); /** * Default player LED patterns. * Indices 0–3 match the PS5 console convention (PlayerID enum). * Indices 4–30 fill the remaining 5-bit patterns for players 5–31. */ const DEFAULT_PLAYER_PATTERNS = [ // Players 1–4: PS5 standard command_1.PlayerID.Player1, // 0x04 = 00100 command_1.PlayerID.Player2, // 0x0a = 01010 command_1.PlayerID.Player3, // 0x15 = 10101 command_1.PlayerID.Player4, // 0x1b = 11011 // Players 5–31: remaining patterns, ordered for visual distinctiveness 0x01, // 00001 0x02, // 00010 0x08, // 01000 0x10, // 10000 0x11, // 10001 0x03, // 00011 0x18, // 11000 0x05, // 00101 0x14, // 10100 0x09, // 01001 0x12, // 10010 0x06, // 00110 0x0c, // 01100 0x07, // 00111 0x1c, // 11100 0x0e, // 01110 0x19, // 11001 0x13, // 10011 0x0b, // 01011 0x1a, // 11010 0x0d, // 01101 0x16, // 10110 0x17, // 10111 0x1d, // 11101 0x0f, // 01111 0x1e, // 11110 command_1.PlayerID.All, // 0x1f = 11111 ]; /** * Manages multiple Dualsense controllers. Automatically discovers devices, * assigns player LEDs, and provides indexed access to each controller. * * Extends Input so that events from all managed controllers bubble up: * - `change` fires when the controller count changes or any controller input changes * - `press` / `release` bubble from any managed controller (including connection state) */ class DualsenseManager extends input_1.Input { constructor(params = {}) { super({ name: "DualsenseManager", icon: "🎮", ...params, }); /** Current manager state: active count and player map */ this.state = { active: 0, players: new Map(), }; /** Player LED bitmask patterns indexed by slot number (0–30) */ this.playerPatterns = [...DEFAULT_PLAYER_PATTERNS]; /** All controller slots, indexed by slot number */ this.slots = []; /** Map from node-hid serial to slot index — best-effort, used as a fallback */ this.serialToSlot = new Map(); /** Map from canonical hardware identity to slot index — preferred when available */ this.identityToSlot = new Map(); /** Whether we're running in a browser environment */ this.isBrowser = typeof window !== "undefined"; // --- Private --- /** Previous state snapshot, for deduplication */ this.lastActive = 0; this.lastPlayerCount = 0; /** Fingerprint of the last published player set (slot indices + connected flags) */ this.lastPlayerKey = ""; /** HIDDevice objects we've already handed to a provider */ this.knownWebDevices = new WeakSet(); this.autoAssignPlayerLeds = params.autoAssignPlayerLeds ?? true; if (this.isBrowser) { this.startWebDiscovery(); } else { this.startNodeDiscovery(params.discoveryInterval ?? 2000); } } get active() { return this.state.active > 0; } /** * All managed controller instances (including disconnected ones awaiting * reconnection). Excludes provisional slots whose identity is still being * resolved — those become visible only after firmware info loads, to * avoid surfacing controllers that may be merged into an existing slot. */ get controllers() { return this.publicSlots().map((s) => s.controller); } /** Number of managed controllers (including disconnected ones awaiting reconnection) */ get count() { return this.publicSlots().length; } /** Get a controller by slot index */ get(index) { const slot = this.slots[index]; if (!slot || slot.provisional) return undefined; return slot.controller; } /** Iterate over all managed controllers */ [Symbol.iterator]() { return this.publicSlots().map((s) => s.controller).values(); } /** Slots that are visible to the consumer (i.e. identity has been resolved) */ publicSlots() { return this.slots.filter((s) => !s.provisional); } /** * True while at least one controller has been discovered but is still * waiting for firmware info to load. Useful for showing a "connecting" * state in the UI without surfacing the unresolved slot itself. */ get pending() { return this.slots.some((s) => s.provisional); } /** * Override the player LED pattern for a given slot index. * @param index Slot index (0-based) * @param bitmask 5-bit LED bitmask (0x00–0x1f) */ setPlayerPattern(index, bitmask) { this.playerPatterns[index] = bitmask & 0x1f; // Apply immediately if a controller occupies this slot (index may be out of bounds) const slot = this.slots[index]; if (slot && this.autoAssignPlayerLeds) { slot.controller.playerLeds.set(this.playerPatterns[index]); } } /** Get the player LED pattern for a given slot index */ getPlayerPattern(index) { return this.playerPatterns[index] ?? 0; } /** * Release a controller slot, freeing it for reuse. * If the controller is still connected, it will be disconnected. * @param index Slot index to release */ release(index) { const slot = this.slots[index]; if (!slot) return; // Disconnect the HID provider and release the claimed device slot.controller.hid.provider.disconnect(); // Remove identity / serial mappings if (slot.identity) { this.identityToSlot.delete(slot.identity); } if (slot.serial) { this.serialToSlot.delete(slot.serial); } // Remove the slot (shift remaining slots down) this.slots.splice(index, 1); // Re-index identity / serial mappings after splice for (let i = index; i < this.slots.length; i++) { const s = this.slots[i]; s.index = i; if (s.identity) { this.identityToSlot.set(s.identity, i); } if (s.serial) { this.serialToSlot.set(s.serial, i); } // Update player LEDs for shifted slots if (this.autoAssignPlayerLeds) { s.controller.playerLeds.set(this.playerPatterns[i] ?? 0); } } this.updateState(); } /** * Release all disconnected controller slots. * Connected controllers are not affected. */ releaseDisconnected() { // Iterate in reverse to avoid index shifting issues for (let i = this.slots.length - 1; i >= 0; i--) { if (!this.slots[i].controller.connection.active) { this.release(i); } } } /** * Stop discovery and disconnect all controllers. */ dispose() { if (this.discoveryTimer) { clearInterval(this.discoveryTimer); this.discoveryTimer = undefined; } // Disconnect all controllers in reverse order for (let i = this.slots.length - 1; i >= 0; i--) { this.release(i); } } /** * For WebHID: returns a click handler that opens the device picker, * allowing the user to select multiple controllers at once. */ getRequest() { if (!this.isBrowser) { return () => Promise.resolve(); } return web_hid_provider_1.WebHIDProvider.getMultiRequest((device) => { this.addWebDevice(device); }); } /** Build a new state snapshot and push it through InputSet */ updateState() { const players = new Map(); let activeCount = 0; let key = ""; for (const slot of this.slots) { if (slot.provisional) continue; players.set(slot.index, slot.controller); const connected = slot.controller.connection.active; if (connected) activeCount += 1; key += `${slot.index}:${connected ? "1" : "0"},`; } // Suppress no-op publishes (e.g. provisional slots churning without // changing the visible state) to avoid noisy change events. if (activeCount === this.lastActive && players.size === this.lastPlayerCount && key === this.lastPlayerKey) { return; } this.lastActive = activeCount; this.lastPlayerCount = players.size; this.lastPlayerKey = key; this[input_1.InputSet]({ active: activeCount, players }); } /** * Create a Dualsense instance and register it in a (provisional) slot. * The caller is responsible for opening the device on the provider — the * manager treats this as the *only* path that opens new devices, so * identity matching can run before the slot becomes visible. * * Note: identity is the sole reconnection key. We do NOT key on node-hid's * serialNumber because it can be missing or wrong. Path is tracked only * so we can re-target the same device on transplant. */ createSlot(provider, serial, path) { const hid = new dualsense_hid_1.DualsenseHID(provider); const controller = new dualsense_1.Dualsense({ hid }); const index = this.slots.length; const slot = { controller, serial, path, index, // Hide from public state until firmware info has been read and any // identity-based merge has had a chance to run. provisional: true, }; this.slots.push(slot); if (serial) { this.serialToSlot.set(serial, index); } // Assign player LEDs — skip for provisional slots (they may get // transplanted to a different index). Re-apply on every connect. const applyPlayerLeds = () => { if (this.autoAssignPlayerLeds && !slot.provisional) { controller.playerLeds.set(this.playerPatterns[slot.index] ?? 0); } }; applyPlayerLeds(); // Track connection changes controller.connection.on("change", () => { if (controller.connection.active) { applyPlayerLeds(); } this.updateState(); }); // Hook firmware-info readiness so we can perform identity-based slot // matching once the controller's hardware identity is known. This is // far more reliable than node-hid's serial number, which can be // missing or wrong. if (!slot.readyHooked) { slot.readyHooked = true; hid.onReady(() => this.handleSlotReady(slot)); } this.updateState(); return slot; } /** * Called when a slot's HID layer has finished reading firmware/factory info. * If the resolved identity matches a *different* (disconnected) slot, the * underlying device is transplanted into that slot's existing provider so * the consumer's Dualsense reference is preserved across reconnect. */ handleSlotReady(slot) { const identity = slot.controller.hid.identity; // No identity at all (firmware read failed completely after retries) — // promote the slot anyway so the consumer can still use it. We just // won't be able to merge it on reconnect. if (!identity) { this.promoteSlot(slot); return; } const existingIndex = this.identityToSlot.get(identity); // First time we've seen this identity — claim it for this slot. if (existingIndex === undefined) { slot.identity = identity; this.identityToSlot.set(identity, slot.index); this.promoteSlot(slot); return; } // We already have a slot for this identity — make sure it's not just us. if (existingIndex === slot.index) { this.promoteSlot(slot); return; } const existingSlot = this.slots[existingIndex]; if (!existingSlot) { // Stale mapping — overwrite. slot.identity = identity; this.identityToSlot.set(identity, slot.index); this.promoteSlot(slot); return; } // If the existing slot is currently connected, both slots map to the // same hardware (shouldn't normally happen). Prefer the older slot // and drop the new one without ever publishing it. if (existingSlot.controller.connection.active) { this.dropSlot(slot); return; } // Existing slot is disconnected — transplant the new device into it. // The new (provisional) slot is dropped before any state is published, // so the consumer only ever sees the original slot reconnect in place. this.transplant(slot, existingSlot); } /** Mark a slot as visible to consumers and publish state */ promoteSlot(slot) { if (!slot.provisional) return; slot.provisional = false; if (this.autoAssignPlayerLeds) { slot.controller.playerLeds.set(this.playerPatterns[slot.index] ?? 0); } this.updateState(); } /** * Move the device handle from `from` into `into`'s existing provider so * the existing Dualsense instance reconnects in place. Then remove `from`. */ transplant(from, into) { const fromProvider = from.controller.hid.provider; const intoProvider = into.controller.hid.provider; if (fromProvider instanceof web_hid_provider_1.WebHIDProvider && intoProvider instanceof web_hid_provider_1.WebHIDProvider && fromProvider.device) { // Move the open HIDDevice handle from the source provider to the // destination. We can't close + reopen here — that would race with // the destination's attach() call. Instead we abandon the source // provider in place (its slot is about to be dropped) and let the // destination take over the same handle. The source's input listener // will be GC'd along with the source provider once nothing else // references the dropped slot's Dualsense. const device = fromProvider.device; fromProvider.device = undefined; if (fromProvider.deviceId) { hid_provider_1.HIDProvider.claimedDevices.delete(fromProvider.deviceId); fromProvider.deviceId = undefined; } intoProvider.replaceDevice(device); } else if (fromProvider instanceof node_hid_provider_1.NodeHIDProvider && intoProvider instanceof node_hid_provider_1.NodeHIDProvider) { // node-hid HID handles can't be moved between providers, so we close // the source (releasing its path claim) and re-open the same path on // the destination provider — preserving the existing Dualsense // instance and its subscribers. const newPath = from.path; const newSerial = from.serial; fromProvider.disconnect(); intoProvider.targetPath = newPath; intoProvider.targetSerial = newSerial; into.path = newPath; into.serial = newSerial; void Promise.resolve(intoProvider.connect()).catch(() => { /* errors surface via provider.onError */ }); } this.dropSlot(from); } /** Remove a slot without firing the player-LED reshuffle */ dropSlot(slot) { const idx = this.slots.indexOf(slot); if (idx === -1) return; if (slot.identity) this.identityToSlot.delete(slot.identity); if (slot.serial) this.serialToSlot.delete(slot.serial); this.slots.splice(idx, 1); // Re-index the trailing slots for (let i = idx; i < this.slots.length; i++) { const s = this.slots[i]; s.index = i; if (s.identity) this.identityToSlot.set(s.identity, i); if (s.serial) this.serialToSlot.set(s.serial, i); } this.updateState(); } // --- Node.js discovery --- startNodeDiscovery(intervalMs) { const poll = async () => { try { const devices = await node_hid_provider_1.NodeHIDProvider.enumerate(); for (const device of devices) { this.processDiscoveredDevice(device); } } catch { // Enumeration failed, will retry next interval } }; // Initial scan void poll(); this.discoveryTimer = setInterval(() => void poll(), intervalMs); } /** * Handle a newly discovered device from enumeration. Opens the device on * a fresh provider, which adds it to `claimedDevices` so subsequent polls * skip it. Identity matching (and any merge into a disconnected slot) * happens later, once firmware info has been read. */ processDiscoveredDevice(device) { if (hid_provider_1.HIDProvider.claimedDevices.has(device.path)) return; const provider = new node_hid_provider_1.NodeHIDProvider({ devicePath: device.path, serialNumber: device.serialNumber, }); this.createSlot(provider, device.serialNumber, device.path); // Drive the connection. The Dualsense instance no longer polls in // managed mode, so the manager owns this. claimedDevices is added // synchronously inside connect() on success, preventing duplicate // discovery on the next poll tick. void Promise.resolve(provider.connect()).catch(() => { /* errors surface via provider.onError */ }); } // --- WebHID discovery --- startWebDiscovery() { if (typeof navigator !== "undefined" && navigator.hid) { navigator.hid.addEventListener("connect", ({ device }) => { this.addWebDevice(device); }); // Poll for permitted devices. The WebHID connect event only fires // for newly-permitted devices, not for already-permitted devices // that physically reconnect. Periodic enumeration catches those. const poll = () => { void web_hid_provider_1.WebHIDProvider.enumerate().then((devices) => { for (const device of devices) { this.addWebDevice(device); } }); }; poll(); this.discoveryTimer = setInterval(poll, 2000); } } addWebDevice(device) { // WeakSet tracks object identity — enumerate() returns the same objects // for still-connected devices, so this deduplicates across polls. // On reconnect, the browser provides a fresh HIDDevice object, so it // passes this check and creates a new provisional slot. if (this.knownWebDevices.has(device)) return; this.knownWebDevices.add(device); const provider = new web_hid_provider_1.WebHIDProvider({ device }); this.createSlot(provider, undefined, undefined); } } exports.DualsenseManager = DualsenseManager; //# sourceMappingURL=manager.js.map