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