UNPKG

dualsense-ts

Version:

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

509 lines 22 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.DualsenseHID = void 0; const command_1 = require("./command"); const hid_provider_1 = require("./hid_provider"); const bt_checksum_1 = require("./bt_checksum"); const firmware_info_1 = require("./firmware_info"); const factory_info_1 = require("./factory_info"); const pairing_info_1 = require("./pairing_info"); const SCOPE_A = 1; const SCOPE_B = 2; /** Coordinates a HIDProvider and tracks the latest HID state */ class DualsenseHID { /** * Precomputed IMU calibration factors, populated during provider connection. * Applied automatically to gyro and accelerometer readings in each input report. */ get calibration() { return this.provider.calibration; } constructor(provider, refreshRate = 30) { this.provider = provider; /** Subscribers waiting for HID state updates */ this.subscribers = new Set(); /** Subscribers waiting for error updates */ this.errorSubscribers = new Set(); /** Subscribers waiting for firmware/factory info to become available */ this.readySubscribers = new Set(); /** Subscribers tracking transport-level connect/disconnect events */ this.connectionSubscribers = new Set(); /** True once firmware/factory info has been loaded for the current connection */ this.identityResolved = false; /** Number of identity-load attempts made for the current connection */ this.identityRetryCount = 0; /** Queue of pending HID commands */ this.pendingCommands = []; /** Most recent HID state of the device */ this.state = { ...hid_provider_1.DefaultDualsenseHIDState }; /** Firmware and hardware information, populated after connection */ this.firmwareInfo = firmware_info_1.DefaultFirmwareInfo; /** Factory information (serial, color, board revision), populated after connection */ this.factoryInfo = factory_info_1.DefaultFactoryInfo; provider.onData = this.set.bind(this); provider.onError = this.handleError.bind(this); provider.onConnect = () => { // Keep cached firmware/factory info from the prior session so that // consumers see identity details immediately on a reconnection // event. The background loadIdentity() call will verify and refresh // the cache — if the hardware identity turns out different (e.g. a // different controller grabbed the same slot), the fields get // overwritten then. this.firmwareFetch = undefined; this.factoryFetch = undefined; this.identityResolved = false; this.cancelIdentityRetry(); this.connectionSubscribers.forEach((cb) => cb(true)); void this.loadIdentity(); }; provider.onDisconnect = () => { this.cancelIdentityRetry(); this.connectionSubscribers.forEach((cb) => cb(false)); }; this.commandTimer = setInterval(() => { if (this.pendingCommands.length > 0) { (async () => { const command = [...this.pendingCommands]; this.pendingCommands = []; await provider.write(DualsenseHID.buildFeatureReport(command, Boolean(provider.wireless))); })().catch((err) => { this.handleError(new Error(`HID write failed: ${JSON.stringify(err)}`)); }); } }, 1000 / refreshRate); } /** Stop the command flush loop and cancel pending identity retries */ dispose() { if (this.commandTimer) { clearInterval(this.commandTimer); this.commandTimer = undefined; } this.cancelIdentityRetry(); } get wireless() { return this.provider.wireless ?? false; } /** Register a handler for HID state updates */ register(callback) { this.subscribers.add(callback); } /** Cancel a previously registered handler */ unregister(callback) { this.subscribers.delete(callback); } /** Add a subscriber for errors */ on(type, callback) { if (type === "error") this.errorSubscribers.add(callback); } /** * Subscribe to notification when firmware/factory info finishes loading * after a connect. Fires once per connection — either when identity has * been resolved, or when we've given up retrying. If identity is already * resolved at the time of subscription, the callback fires synchronously. */ onReady(callback) { if (this.identityResolved) { callback(); return () => { }; } this.readySubscribers.add(callback); return () => this.readySubscribers.delete(callback); } /** True if firmware/factory info has been loaded (or given up on) for the current connection */ get ready() { return this.identityResolved; } /** * Subscribe to transport-level connect/disconnect events. Useful for * mirroring connection state into an Input without polling. Returns * an unsubscribe function. */ onConnectionChange(callback) { this.connectionSubscribers.add(callback); return () => this.connectionSubscribers.delete(callback); } /** * Stable hardware identity for this controller, derived from the most * trustworthy info available. Prefers the Bluetooth MAC address (from * Feature Report 0x09, works on every transport and platform), then * falls back to the factory serial, then firmware deviceInfo. * Returns undefined until identity info has been read. */ get identity() { if (this.macAddress) { return `mac:${this.macAddress}`; } if (this.factoryInfo.serialNumber !== factory_info_1.DefaultFactoryInfo.serialNumber) { return `serial:${this.factoryInfo.serialNumber}`; } if (this.firmwareInfo.deviceInfo !== firmware_info_1.DefaultFirmwareInfo.deviceInfo) { return `device:${this.firmwareInfo.deviceInfo}`; } return undefined; } /** Update the HID state and pass it along to all state subscribers */ set(state) { this.state = state; this.subscribers.forEach((callback) => callback(state)); } /** Pass errors along to all error subscribers */ handleError(error) { this.errorSubscribers.forEach((callback) => callback(error)); } /** * Attempt to read firmware + factory info for the current connection, * with retry on failure. Marks identity as resolved on success or after * exhausting all attempts (so consumers don't wait forever). * * If cached firmware/factory info exists from a prior session, identity * is resolved immediately (so the connection event has full details), * then a background verification re-reads the device to confirm. */ async loadIdentity() { if (!this.provider.connected) return; // Fast path: if we already have cached identity from a prior session, // mark resolved immediately so consumers see it on the connection event. // Then continue to the verification read below. const hadCachedIdentity = this.identity !== undefined; if (hadCachedIdentity) { this.markIdentityResolved(); } this.identityRetryCount += 1; try { // Read MAC address first — simple feature report, no firmware gate. const mac = await (0, pairing_info_1.readMacAddress)(this.provider); if (mac) this.macAddress = mac; // Always read fresh from the device (bypass the idempotency cache). const fw = await (0, firmware_info_1.readFirmwareInfo)(this.provider); if (fw) { this.firmwareInfo = fw; this.firmwareFetch = Promise.resolve(fw); const fi = await (0, factory_info_1.readFactoryInfo)(this.provider, fw.hardwareInfo, fw.mainFirmwareVersionRaw); this.factoryInfo = fi ?? factory_info_1.DefaultFactoryInfo; this.factoryFetch = Promise.resolve(this.factoryInfo); if (!hadCachedIdentity) { this.markIdentityResolved(); } return; } } catch { // Treat throws the same as undefined — fall through to retry logic. } // Failure — clear in-flight promises so the next attempt can retry. this.firmwareFetch = undefined; this.factoryFetch = undefined; if (this.identityRetryCount >= DualsenseHID.IDENTITY_MAX_ATTEMPTS || !this.provider.connected) { this.markIdentityResolved(); return; } const delay = DualsenseHID.IDENTITY_BACKOFF_MS[Math.min(this.identityRetryCount - 1, DualsenseHID.IDENTITY_BACKOFF_MS.length - 1)]; this.identityRetryTimer = setTimeout(() => { this.identityRetryTimer = undefined; void this.loadIdentity(); }, delay); } /** Mark identity loading as complete and notify subscribers */ markIdentityResolved() { if (this.identityResolved) return; this.identityResolved = true; this.identityRetryCount = 0; const callbacks = Array.from(this.readySubscribers); this.readySubscribers.clear(); callbacks.forEach((cb) => cb()); } /** Cancel any pending identity-load retry */ cancelIdentityRetry() { if (this.identityRetryTimer) { clearTimeout(this.identityRetryTimer); this.identityRetryTimer = undefined; } this.identityRetryCount = 0; } /** * Read firmware info from the controller (Feature Report 0x20). * Idempotent: returns the cached value if already fetched, or the * in-flight promise if a fetch is already underway. */ fetchFirmwareInfo() { if (this.firmwareInfo !== firmware_info_1.DefaultFirmwareInfo) return Promise.resolve(this.firmwareInfo); if (this.firmwareFetch) return this.firmwareFetch; this.firmwareFetch = (0, firmware_info_1.readFirmwareInfo)(this.provider).then((info) => { this.firmwareInfo = info ?? firmware_info_1.DefaultFirmwareInfo; return this.firmwareInfo; }); return this.firmwareFetch; } /** * Read factory info (serial number, body color, board revision) from the controller. * Requires firmware info to be fetched first for feature gating. * Idempotent across the lifetime of a single connection. */ fetchFactoryInfo() { if (this.factoryInfo !== factory_info_1.DefaultFactoryInfo) return Promise.resolve(this.factoryInfo); if (this.factoryFetch) return this.factoryFetch; if (this.firmwareInfo === firmware_info_1.DefaultFirmwareInfo) return Promise.resolve(factory_info_1.DefaultFactoryInfo); const fwInfo = this.firmwareInfo; this.factoryFetch = (0, factory_info_1.readFactoryInfo)(this.provider, fwInfo.hardwareInfo, fwInfo.mainFirmwareVersionRaw).then((info) => { this.factoryInfo = info ?? factory_info_1.DefaultFactoryInfo; return this.factoryInfo; }); return this.factoryFetch; } /** Condense all pending commands into one HID feature report */ static buildFeatureReport(events, wireless) { const usbReport = new Uint8Array(48).fill(0); usbReport[0] = 0x2; usbReport[1] = events .filter(({ scope: { index } }) => index === SCOPE_A) .reduce((acc, { scope: { value } }) => { return acc | value; }, 0x00); usbReport[2] = events .filter(({ scope: { index } }) => index === SCOPE_B) .reduce((acc, { scope: { value } }) => { return acc | value; }, 0x00); events.forEach(({ values }) => { values.forEach(({ index, value }) => { usbReport[index] = value; }); }); if (!wireless) return usbReport; // Bluetooth output report (0x31) layout differs from USB: // - Adds a constant byte at index 1 (0x02) // - Shifts the USB payload indices by +1 // - Appends a checksum at bytes 74..77 (little-endian) const btReport = new Uint8Array(78).fill(0); btReport[0] = 0x31; btReport[1] = 0x02; // Copy USB scopes + payload, shifted by +1. btReport[2] = usbReport[1]; btReport[3] = usbReport[2]; for (let i = 3; i < usbReport.length; i++) { btReport[i + 1] = usbReport[i]; } const crc = (0, bt_checksum_1.computeBluetoothReportChecksum)(btReport); btReport[74] = crc & 0xff; btReport[75] = (crc >>> 8) & 0xff; btReport[76] = (crc >>> 16) & 0xff; btReport[77] = (crc >>> 24) & 0xff; return btReport; } /** Set intensity for left and right rumbles */ setRumble(left, right) { this.pendingCommands.push({ scope: { index: SCOPE_A, value: command_1.CommandScopeA.PrimaryRumble | command_1.CommandScopeA.HapticRumble, }, values: [ { index: 3, value: right }, { index: 4, value: left }, ], }); this.pendingCommands.push({ scope: { index: SCOPE_B, value: command_1.CommandScopeB.MotorPower }, values: [], }); } /** Set left trigger effect from an 11-byte effect block */ setLeftTriggerFeedback(block) { this.pendingCommands.push({ scope: { index: SCOPE_A, value: command_1.CommandScopeA.LeftTriggerFeedback }, values: Array.from(block, (value, i) => ({ index: 22 + i, value })), }); } /** Set right trigger effect from an 11-byte effect block */ setRightTriggerFeedback(block) { this.pendingCommands.push({ scope: { index: SCOPE_A, value: command_1.CommandScopeA.RightTriggerFeedback }, values: Array.from(block, (value, i) => ({ index: 11 + i, value })), }); } /** Set microphone mute LED mode */ setMicrophoneLED(mode) { this.pendingCommands.push({ scope: { index: SCOPE_B, value: command_1.CommandScopeB.MicrophoneLED }, values: [{ index: 9, value: mode }], }); } /** Set player indicator LEDs from a 5-bit bitmask and brightness */ setPlayerLeds(bitmask, brightness = command_1.Brightness.High) { this.pendingCommands.push({ scope: { index: SCOPE_B, value: command_1.CommandScopeB.PlayerLeds }, values: [ { index: 44, value: bitmask & 0x1f }, { index: 43, value: brightness }, { index: 39, value: command_1.LedOptions.PlayerLedBrightness }, ], }); } /** Set headphone volume (0x00–0x7F) */ setHeadphoneVolume(volume) { this.pendingCommands.push({ scope: { index: SCOPE_A, value: command_1.CommandScopeA.HeadphoneVolume }, values: [{ index: 5, value: Math.min(0x7f, Math.max(0, volume | 0)) }], }); } /** Set internal speaker volume (0x00–0x64, where 0x64 is full volume) */ setSpeakerVolume(volume) { this.pendingCommands.push({ scope: { index: SCOPE_A, value: command_1.CommandScopeA.SpeakerVolume }, values: [{ index: 6, value: Math.min(0x64, Math.max(0, volume | 0)) }], }); } /** Set internal microphone volume (0x00–0x40) */ setMicrophoneVolume(volume) { this.pendingCommands.push({ scope: { index: SCOPE_A, value: command_1.CommandScopeA.MicrophoneVolume }, values: [{ index: 7, value: Math.min(0x40, Math.max(0, volume | 0)) }], }); } /** Set audio output routing and microphone flags */ setAudioFlags(flags) { this.pendingCommands.push({ scope: { index: SCOPE_A, value: command_1.CommandScopeA.AudioFlags }, values: [{ index: 8, value: flags & 0xff }], }); } /** Set power save control bitfield (per-subsystem mute/disable) */ setPowerSave(flags) { this.pendingCommands.push({ scope: { index: SCOPE_B, value: command_1.CommandScopeB.PowerSave }, values: [{ index: 10, value: flags & 0xff }], }); } /** Set speaker preamp gain (0–7) and optional beam forming */ setSpeakerPreamp(gain, beamForming = false) { const value = (Math.min(7, Math.max(0, gain | 0)) & 0x07) | (beamForming ? 0x10 : 0x00); this.pendingCommands.push({ scope: { index: SCOPE_B, value: command_1.CommandScopeB.AudioFlags2 }, values: [{ index: 37, value }], }); } /** Set the light bar color and pulse effect */ setLightbar(r, g, b, pulse = command_1.PulseOptions.Off) { this.pendingCommands.push({ scope: { index: SCOPE_B, value: command_1.CommandScopeB.TouchpadLeds }, values: [ { index: 45, value: r }, { index: 46, value: g }, { index: 47, value: b }, ], }); // Override firmware animation to take direct control of the light bar this.pendingCommands.push({ scope: { index: SCOPE_B, value: command_1.CommandScopeB.PlayerLeds }, values: [ { index: 39, value: command_1.LedOptions.Both }, { index: 42, value: pulse }, ], }); } /** * Build a feature report 0x80 payload. * Format: [reportId, deviceId, actionId, ...params] */ static buildTestCommand(deviceId, actionId, params) { const paramLen = params?.length ?? 0; const buf = new Uint8Array(2 + paramLen + 1); // +1 for report ID prefix buf[0] = DualsenseHID.TEST_REPORT_ID; buf[1] = deviceId; buf[2] = actionId; if (params) buf.set(params, 3); return buf; } /** * Start a DSP test tone on speaker or headphone. * Sets volume routing via the standard output report before triggering. * * @param target Output destination — "speaker" (default) or "headphone" * @param tone Which tone to play — "1khz" (default), "100hz", or "both" */ async startTestTone(target = "speaker", tone = "1khz") { // Set volume routing so the tone reaches the correct output if (target === "headphone") { this.setHeadphoneVolume(0x41); // ~65 this.setSpeakerVolume(0); this.setAudioFlags(command_1.AudioOutput.Headphone); } else { this.setSpeakerVolume(0x55); // ~85 this.setHeadphoneVolume(0); this.setAudioFlags(command_1.AudioOutput.Speaker); } // Step 1: configure DSP output routing const routeParams = new Uint8Array(20); if (target === "headphone") { routeParams[4] = 4; routeParams[6] = 6; } else { routeParams[2] = 8; } await this.provider.sendFeatureReport(DualsenseHID.TEST_REPORT_ID, DualsenseHID.buildTestCommand(DualsenseHID.DSP_DEVICE_AUDIO, DualsenseHID.DSP_ACTION_CONFIGURE, routeParams)); // Step 2: start waveform const controlParams = new Uint8Array(3); controlParams[0] = 1; // start controlParams[1] = tone === "100hz" ? 0 : 1; controlParams[2] = tone === "1khz" ? 0 : 1; await this.provider.sendFeatureReport(DualsenseHID.TEST_REPORT_ID, DualsenseHID.buildTestCommand(DualsenseHID.DSP_DEVICE_AUDIO, DualsenseHID.DSP_ACTION_WAVEOUT, controlParams)); } /** Stop the DSP test tone */ async stopTestTone() { const controlParams = new Uint8Array(3); controlParams[0] = 0; // stop controlParams[1] = 1; await this.provider.sendFeatureReport(DualsenseHID.TEST_REPORT_ID, DualsenseHID.buildTestCommand(DualsenseHID.DSP_DEVICE_AUDIO, DualsenseHID.DSP_ACTION_WAVEOUT, controlParams)); } /** * Send a raw DSP test command (Feature Report 0x80). * For experimentation — lets you send arbitrary device/action/params. */ async sendTestCommand(deviceId, actionId, params) { await this.provider.sendFeatureReport(DualsenseHID.TEST_REPORT_ID, DualsenseHID.buildTestCommand(deviceId, actionId, params)); } /** * Read the DSP test response (Feature Report 0x81). * Returns the raw response bytes, or undefined if not available. */ async readTestResponse() { try { return await this.provider.readFeatureReport(DualsenseHID.TEST_RESPONSE_ID, 64); } catch { return undefined; } } } exports.DualsenseHID = DualsenseHID; /** Maximum identity-load retry attempts per connection */ DualsenseHID.IDENTITY_MAX_ATTEMPTS = 5; /** Backoff schedule (ms) for identity-load retries */ DualsenseHID.IDENTITY_BACKOFF_MS = [500, 1500, 3000, 5000]; // --- DSP test tone (Feature Report 0x80) --- /** Feature report ID for DSP test commands */ DualsenseHID.TEST_REPORT_ID = 0x80; /** DSP device ID for audio subsystem */ DualsenseHID.DSP_DEVICE_AUDIO = 0x06; /** Action ID: configure output routing before waveout */ DualsenseHID.DSP_ACTION_CONFIGURE = 0x04; /** Action ID: start or stop waveform playback */ DualsenseHID.DSP_ACTION_WAVEOUT = 0x02; /** Feature report ID for DSP test responses */ DualsenseHID.TEST_RESPONSE_ID = 0x81; //# sourceMappingURL=dualsense_hid.js.map