dualsense-ts
Version:
The natural interface for your DualSense Classic and DualSense Access controllers, with Typescript
324 lines • 13.8 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AccessHID = void 0;
const access_hid_1 = require("../access_hid");
const access_hid_provider_1 = require("./access_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");
// ── Identity loading adapters ──
// The existing readFirmwareInfo/readMacAddress/readFactoryInfo accept
// HIDProvider. AccessHIDProvider has the same readFeatureReport/sendFeatureReport
// signatures. We cast through `unknown` to reuse the existing implementations
// without duplicating their parsing logic.
function readMacAddress(provider) {
return (0, pairing_info_1.readMacAddress)(provider);
}
function readFirmwareInfo(provider) {
return (0, firmware_info_1.readFirmwareInfo)(provider);
}
function readFactoryInfo(provider, hardwareInfo, mainFwVersionRaw) {
return (0, factory_info_1.readFactoryInfo)(provider, hardwareInfo, mainFwVersionRaw);
}
// ── Output report builder types ──
const MUTATOR = 1;
const SCOPE_B = 2;
/** Coordinates an AccessHIDProvider and tracks the latest HID state */
class AccessHID {
constructor(provider, refreshRate = 30) {
this.provider = provider;
this.subscribers = new Set();
this.errorSubscribers = new Set();
this.readySubscribers = new Set();
this.connectionSubscribers = new Set();
this.identityResolved = false;
this.identityRetryCount = 0;
this.pendingCommands = [];
this.state = { ...access_hid_provider_1.DefaultAccessHIDState };
this.firmwareInfo = firmware_info_1.DefaultFirmwareInfo;
this.factoryInfo = factory_info_1.DefaultFactoryInfo;
provider.onData = this.set.bind(this);
provider.onError = this.handleError.bind(this);
provider.onConnect = () => {
this.firmwareFetch = undefined;
this.factoryFetch = undefined;
this.identityResolved = false;
this.cancelIdentityRetry();
// Over BT, the controller starts with a firmware "fade to blue"
// animation that holds the lightbar. Dismiss it so the host can
// set RGB values. Harmless over USB (no-op if no animation).
this.dismissLedAnimation();
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(AccessHID.buildOutputReport(command, Boolean(provider.wireless)));
})().catch((err) => {
this.handleError(new Error(`HID write failed: ${JSON.stringify(err)}`));
});
}
}, 1000 / refreshRate);
}
dispose() {
if (this.commandTimer) {
clearInterval(this.commandTimer);
this.commandTimer = undefined;
}
this.cancelIdentityRetry();
}
get wireless() {
return this.provider.wireless ?? false;
}
register(callback) {
this.subscribers.add(callback);
}
unregister(callback) {
this.subscribers.delete(callback);
}
on(type, callback) {
if (type === "error")
this.errorSubscribers.add(callback);
}
onReady(callback) {
if (this.identityResolved) {
callback();
return () => { };
}
this.readySubscribers.add(callback);
return () => this.readySubscribers.delete(callback);
}
get ready() {
return this.identityResolved;
}
onConnectionChange(callback) {
this.connectionSubscribers.add(callback);
return () => this.connectionSubscribers.delete(callback);
}
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;
}
set(state) {
this.state = state;
this.subscribers.forEach((callback) => callback(state));
}
handleError(error) {
this.errorSubscribers.forEach((callback) => callback(error));
}
async loadIdentity() {
if (!this.provider.connected)
return;
const hadCachedIdentity = this.identity !== undefined;
if (hadCachedIdentity)
this.markIdentityResolved();
this.identityRetryCount += 1;
try {
const mac = await readMacAddress(this.provider);
if (mac)
this.macAddress = mac;
const fw = await readFirmwareInfo(this.provider);
if (fw) {
this.firmwareInfo = fw;
this.firmwareFetch = Promise.resolve(fw);
const fi = await 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 {
// Fall through to retry logic
}
this.firmwareFetch = undefined;
this.factoryFetch = undefined;
if (this.identityRetryCount >= AccessHID.IDENTITY_MAX_ATTEMPTS ||
!this.provider.connected) {
this.markIdentityResolved();
return;
}
const delay = AccessHID.IDENTITY_BACKOFF_MS[Math.min(this.identityRetryCount - 1, AccessHID.IDENTITY_BACKOFF_MS.length - 1)];
this.identityRetryTimer = setTimeout(() => {
this.identityRetryTimer = undefined;
void this.loadIdentity();
}, delay);
}
markIdentityResolved() {
if (this.identityResolved)
return;
this.identityResolved = true;
this.identityRetryCount = 0;
const callbacks = Array.from(this.readySubscribers);
this.readySubscribers.clear();
callbacks.forEach((cb) => cb());
}
cancelIdentityRetry() {
if (this.identityRetryTimer) {
clearTimeout(this.identityRetryTimer);
this.identityRetryTimer = undefined;
}
this.identityRetryCount = 0;
}
fetchFirmwareInfo() {
if (this.firmwareInfo !== firmware_info_1.DefaultFirmwareInfo)
return Promise.resolve(this.firmwareInfo);
if (this.firmwareFetch)
return this.firmwareFetch;
this.firmwareFetch = readFirmwareInfo(this.provider).then((info) => {
this.firmwareInfo = info ?? firmware_info_1.DefaultFirmwareInfo;
return this.firmwareInfo;
});
return this.firmwareFetch;
}
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 = readFactoryInfo(this.provider, fwInfo.hardwareInfo, fwInfo.mainFirmwareVersionRaw).then((info) => {
this.factoryInfo = info ?? factory_info_1.DefaultFactoryInfo;
return this.factoryInfo;
});
return this.factoryFetch;
}
// ── Output report builder ──
/**
* Build an Access output report from pending commands.
*
* USB: 32 bytes, report ID 0x02
* BT: 78 bytes, report ID 0x31, constant 0x02 at [1], CRC32 at [74-77]
*
* USB layout: [0]=reportId, [1]=mutator, [2]=scopeB, [3..31]=payload
* BT layout: [0]=0x31, [1]=0x02, [2]=mutator, [3]=scopeB, [4..73]=payload, [74-77]=CRC
*/
static buildOutputReport(events, wireless) {
const usbReport = new Uint8Array(access_hid_1.AccessOutput.USB_SIZE).fill(0);
usbReport[0] = access_hid_1.AccessOutput.REPORT_ID_USB;
usbReport[MUTATOR] = events
.filter(({ scope: { index } }) => index === MUTATOR)
.reduce((acc, { scope: { value } }) => acc | value, 0x00);
usbReport[SCOPE_B] = events
.filter(({ scope: { index } }) => index === SCOPE_B)
.reduce((acc, { scope: { value } }) => acc | value, 0x00);
events.forEach(({ values }) => {
values.forEach(({ index, value }) => {
usbReport[index] = value;
});
});
if (!wireless)
return usbReport;
// BT: shift USB bytes +1, prepend report ID 0x31 and constant 0x02
const btReport = new Uint8Array(access_hid_1.AccessOutput.BT_SIZE).fill(0);
btReport[0] = access_hid_1.AccessOutput.REPORT_ID_BT;
btReport[1] = access_hid_1.AccessOutput.BT_CONSTANT;
// Copy mutator + scope B + payload, shifted by +1
btReport[2] = usbReport[1]; // mutator
btReport[3] = usbReport[2]; // scope B
for (let i = 3; i < usbReport.length; i++) {
btReport[i + 1] = usbReport[i];
}
// BT requires scope B LED flag for lightbar over BT
if (usbReport[MUTATOR] & access_hid_1.AccessMutator.LED) {
btReport[3] |= access_hid_1.AccessScopeB.LED;
}
const crc = (0, bt_checksum_1.computeBluetoothReportChecksum)(btReport);
btReport[access_hid_1.AccessOutput.BT_CRC_OFFSET] = crc & 0xff;
btReport[access_hid_1.AccessOutput.BT_CRC_OFFSET + 1] = (crc >>> 8) & 0xff;
btReport[access_hid_1.AccessOutput.BT_CRC_OFFSET + 2] = (crc >>> 16) & 0xff;
btReport[access_hid_1.AccessOutput.BT_CRC_OFFSET + 3] = (crc >>> 24) & 0xff;
return btReport;
}
// ── Output methods ──
/** Set the lightbar RGB color */
setLightbar(r, g, b) {
this.pendingCommands.push({
scope: { index: MUTATOR, value: access_hid_1.AccessMutator.LED },
values: [
{ index: access_hid_1.AccessOutput.LIGHTBAR_R, value: r },
{ index: access_hid_1.AccessOutput.LIGHTBAR_G, value: g },
{ index: access_hid_1.AccessOutput.LIGHTBAR_B, value: b },
],
});
}
/** Set the profile LED animation mode */
setProfileLeds(mode) {
this.pendingCommands.push({
scope: { index: MUTATOR, value: access_hid_1.AccessMutator.STATUS_LED },
values: [
{
index: access_hid_1.AccessOutput.LED_FLAGS_1,
value: access_hid_1.AccessLedFlags1.PROFILE_AND_STATUS,
},
{ index: access_hid_1.AccessOutput.LED_FLAGS_2, value: mode },
],
});
}
/** Set the player indicator pattern */
setPlayerIndicator(pattern) {
this.pendingCommands.push({
scope: { index: MUTATOR, value: access_hid_1.AccessMutator.PLAYER_INDICATOR_LED },
values: [{ index: access_hid_1.AccessOutput.PLAYER_INDICATOR, value: pattern }],
});
}
/** Set the status LED on or off */
setStatusLed(on) {
this.pendingCommands.push({
scope: { index: MUTATOR, value: access_hid_1.AccessMutator.STATUS_LED },
values: [
{
index: access_hid_1.AccessOutput.LED_FLAGS_1,
value: access_hid_1.AccessLedFlags1.PROFILE_AND_STATUS,
},
{ index: access_hid_1.AccessOutput.STATUS_LED, value: on ? 1 : 0 },
],
});
}
/**
* Dismiss the BT firmware "fade to blue" LED animation.
*
* The Access controller starts a blue fade animation on BT connect that
* holds the lightbar until the host explicitly takes control. This sends
* a report with all mutator/scope bits set to release the lightbar for
* host-driven RGB commands. Equivalent to the DualSense's
* LedOptions.Both + PulseOptions.FadeOut flow.
*/
dismissLedAnimation() {
if (!this.provider.wireless)
return;
const report = new Uint8Array(access_hid_1.AccessOutput.BT_SIZE).fill(0);
report[0] = access_hid_1.AccessOutput.REPORT_ID_BT;
report[1] = access_hid_1.AccessOutput.BT_CONSTANT;
// Set all mutator + scope B bits to dismiss firmware animation
report[2] = 0xff;
report[3] = 0xff;
const crc = (0, bt_checksum_1.computeBluetoothReportChecksum)(report);
report[access_hid_1.AccessOutput.BT_CRC_OFFSET] = crc & 0xff;
report[access_hid_1.AccessOutput.BT_CRC_OFFSET + 1] = (crc >>> 8) & 0xff;
report[access_hid_1.AccessOutput.BT_CRC_OFFSET + 2] = (crc >>> 16) & 0xff;
report[access_hid_1.AccessOutput.BT_CRC_OFFSET + 3] = (crc >>> 24) & 0xff;
this.provider.write(report).catch(() => { });
}
}
exports.AccessHID = AccessHID;
// ── Identity loading ──
AccessHID.IDENTITY_MAX_ATTEMPTS = 5;
AccessHID.IDENTITY_BACKOFF_MS = [500, 1500, 3000, 5000];
//# sourceMappingURL=access_hid.js.map