dualsense-ts
Version:
The natural interface for your DualSense and DualSense Access controllers, with Typescript
499 lines • 20.1 kB
JavaScript
"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