UNPKG

dualsense-ts

Version:

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

324 lines 13.8 kB
"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