dualsense-ts
Version:
A natural interface for your DualSense controller, with Typescript
382 lines (352 loc) • 14 kB
text/typescript
import type { HID } from "node-hid";
import { InputId } from "../id";
import { ByteArray } from "./byte_array";
export * from "../id";
/** Maps a HID input of 0...n to -1...1 */
export function mapAxis(value: number, max: number = 255): number {
return (2 / max) * Math.max(0, Math.min(max, value)) - 1;
}
/** Maps a HID input of 0...255 to 0...1 */
export function mapTrigger(value: number): number {
return (1 / 255) * Math.max(0, Math.min(255, value));
}
/**
* Maps a HID input for either gyroscope or acceleration.
* Adapted from https://github.com/nondebug/dualsense
*/
export function mapGyroAccel(v0: number, v1: number): number {
let v = (v1 << 8) | v0;
if (v > 0x7fff) v -= 0x10000;
return mapAxis(v + 0x7fff, 0xffff);
}
/** Describes an observation of the input state of a Dualsense controller */
export interface DualsenseHIDState {
[]: number;
[]: number;
[]: number;
[]: number;
[]: number;
[]: number;
[]: boolean;
[]: boolean;
[]: boolean;
[]: boolean;
[]: number;
[]: boolean;
[]: boolean;
[]: boolean;
[]: boolean;
[]: boolean;
[]: boolean;
[]: boolean;
[]: boolean;
[]: boolean;
[]: boolean;
[]: boolean;
[]: boolean;
[]: boolean;
[]: boolean;
[]: boolean;
[]: boolean;
[]: number;
[]: number;
[]: boolean;
[]: number;
[]: number;
[]: number;
[]: boolean;
[]: number;
[]: number;
[]: number;
[]: number;
[]: number;
[]: number;
[]: number;
}
/** Default values for all inputs */
export const DefaultDualsenseHIDState: DualsenseHIDState = {
[]: 0,
[]: 0,
[]: 0,
[]: 0,
[]: 0,
[]: 0,
[]: false,
[]: false,
[]: false,
[]: false,
[]: 0,
[]: false,
[]: false,
[]: false,
[]: false,
[]: false,
[]: false,
[]: false,
[]: false,
[]: false,
[]: false,
[]: false,
[]: false,
[]: false,
[]: false,
[]: false,
[]: false,
[]: 0,
[]: 0,
[]: false,
[]: 0,
[]: 0,
[]: 0,
[]: false,
[]: 0,
[]: 0,
[]: 0,
[]: 0,
[]: 0,
[]: 0,
[]: 0,
};
/** Supports a connection to a physical or virtual Dualsense device */
export abstract class HIDProvider {
/** HID vendorId for a Dualsense controller */
static readonly vendorId: number = 1356;
/** HID productId for a Dualsense controller */
static readonly productId: number = 3302;
/** HID usagePage for a Dualsense controller */
static readonly usagePage: number = 0x0001;
/** HID usage for a Dualsense controller */
static readonly usage: number = 0x0005;
/** Callback to use for new input events */
public onData: (state: DualsenseHIDState) => void = () => {};
/** Callback to use for Error events */
public onError: (error: Error) => void = () => {};
/** Search for a controller and connect to it */
abstract connect(): void;
/** Stop accepting input from the controller */
abstract disconnect(): void;
/** Returns true if a device is currently connected and working */
abstract get connected(): boolean;
/** The underlying HID device handle */
abstract device?: HIDDevice | HID;
/** Returns true if a device is connected wirelessly */
abstract wireless?: boolean;
/** Debug: The most recent HID report buffer */
abstract buffer?: Buffer | DataView;
/** Converts the HID report to a simpler format */
abstract process(input: unknown): DualsenseHIDState;
/** Write to the HID device */
abstract write(data: Uint8Array): Promise<void>;
/** If true, gyroscope, touchpad, accelerometer are disabled */
public limited?: boolean;
/**
* Sselects the correct method for reading the report.
* @param buffer HID report buffer
*/
protected processReport(buffer: ByteArray): DualsenseHIDState {
const reportId = buffer.readUint8(0);
switch (reportId) {
case 0x01:
return this.wireless
? this.processBluetoothInputReport01(buffer)
: this.processUsbInputReport01(buffer);
case 0x31:
return this.processBluetoothInputReport31(buffer);
default:
this.onError(
new Error(`Cannot process report, unexpected report id: ${reportId}`)
);
this.disconnect();
return { ...DefaultDualsenseHIDState };
}
}
/**
* Reset the HIDProvider state when the device is disconnected
*/
protected reset(): void {
this.device = undefined;
this.wireless = undefined;
this.buffer = undefined;
this.limited = undefined;
this.onData(DefaultDualsenseHIDState);
}
/**
* Process a bluetooth input report of type 01.
* @param buffer the report
*/
protected processBluetoothInputReport01(
buffer: ByteArray
): DualsenseHIDState {
this.limited = true;
const buttonsAndDpad = buffer.readUint8(5);
const buttons = buttonsAndDpad >> 4;
const dpad = buttonsAndDpad & 0b1111;
const miscButtons = buffer.readUint8(6);
const lastButtons = buffer.readUint8(7);
return {
...DefaultDualsenseHIDState,
[]: mapAxis(buffer.readUint8(1)),
[]: -mapAxis(buffer.readUint8(2)),
[]: mapAxis(buffer.readUint8(3)),
[]: -mapAxis(buffer.readUint8(4)),
[]: mapTrigger(buffer.readUint8(8)),
[]: mapTrigger(buffer.readUint8(9)),
[]: (buttons & 8) > 0,
[]: (buttons & 4) > 0,
[]: (buttons & 2) > 0,
[]: (buttons & 1) > 0,
[]: dpad,
[]: dpad < 2 || dpad === 7,
[]: dpad > 2 && dpad < 6,
[]: dpad > 4 && dpad < 8,
[]: dpad > 0 && dpad < 4,
[]: (miscButtons & 4) > 0,
[]: (miscButtons & 8) > 0,
[]: (miscButtons & 1) > 0,
[]: (miscButtons & 2) > 0,
[]: (miscButtons & 16) > 0,
[]: (miscButtons & 32) > 0,
[]: (miscButtons & 64) > 0,
[]: (miscButtons & 128) > 0,
[]: (lastButtons & 1) > 0,
[]: (lastButtons & 2) > 0,
// See https://github.com/nondebug/dualsense/blob/main/dualsense-explorer.html#L338
//
// "By default, bluetooth-connected DualSense only sends input report 0x01 which omits motion and touchpad data.
// Reading feature report 0x05 causes it to start sending input report 0x31.
//
// Note: The Gamepad API will do this for us if it enumerates the gamepad.
// Other applications like Steam may have also done this already."
};
}
/** Process bluetooth input report of type 31 */
protected processBluetoothInputReport31(buffer: ByteArray) {
this.limited = false;
const buttonsAndDpad = buffer.readUint8(9);
const buttons = buttonsAndDpad >> 4;
const dpad = buttonsAndDpad & 0b1111;
const miscButtons = buffer.readUint8(10);
const lastButtons = buffer.readUint8(11);
return {
[]: mapAxis(buffer.readUint8(2)),
[]: -mapAxis(buffer.readUint8(3)),
[]: mapAxis(buffer.readUint8(4)),
[]: -mapAxis(buffer.readUint8(5)),
[]: mapTrigger(buffer.readUint8(6)),
[]: mapTrigger(buffer.readUint8(7)),
[]: (buttons & 8) > 0,
[]: (buttons & 4) > 0,
[]: (buttons & 2) > 0,
[]: (buttons & 1) > 0,
[]: dpad,
[]: dpad < 2 || dpad === 7,
[]: dpad > 2 && dpad < 6,
[]: dpad > 4 && dpad < 8,
[]: dpad > 0 && dpad < 4,
[]: (miscButtons & 4) > 0,
[]: (miscButtons & 8) > 0,
[]: (miscButtons & 1) > 0,
[]: (miscButtons & 2) > 0,
[]: (miscButtons & 16) > 0,
[]: (miscButtons & 32) > 0,
[]: (miscButtons & 64) > 0,
[]: (miscButtons & 128) > 0,
[]: (lastButtons & 1) > 0,
[]: (lastButtons & 2) > 0,
[]: (lastButtons & 4) > 0,
[]: mapGyroAccel(buffer.readUint8(17), buffer.readUint8(18)),
[]: mapGyroAccel(buffer.readUint8(19), buffer.readUint8(20)),
[]: mapGyroAccel(buffer.readUint8(21), buffer.readUint8(22)),
[]: mapGyroAccel(
buffer.readUint8(23),
buffer.readUint8(24)
),
[]: mapGyroAccel(
buffer.readUint8(25),
buffer.readUint8(26)
),
[]: mapGyroAccel(
buffer.readUint8(27),
buffer.readUint8(28)
),
[]: buffer.readUint8(34) & 0x7f,
[]: (buffer.readUint8(34) & 0x80) === 0,
[]: mapAxis((buffer.readUint16LE(35) << 20) >> 20, 1920),
[]: mapAxis(buffer.readUint16LE(36) >> 4, 1080),
[]: buffer.readUint8(38) & 0x7f,
[]: (buffer.readUint8(38) & 0x80) === 0,
[]: mapAxis((buffer.readUint16LE(39) << 20) >> 20, 1920),
[]: mapAxis(buffer.readUint16LE(40) >> 4, 1080),
[]: (buffer.readUint8(55) & 4) > 0,
};
}
/**
* Process a USB input report of type 01.
* @param buffer the report
*/
protected processUsbInputReport01(buffer: ByteArray): DualsenseHIDState {
this.limited = false;
const buttonsAndDpad = buffer.readUint8(8);
const buttons = buttonsAndDpad >> 4;
const dpad = buttonsAndDpad & 0b1111;
const miscButtons = buffer.readUint8(9);
const lastButtons = buffer.readUint8(10);
return {
[]: mapAxis(buffer.readUint8(1)),
[]: -mapAxis(buffer.readUint8(2)),
[]: mapAxis(buffer.readUint8(3)),
[]: -mapAxis(buffer.readUint8(4)),
[]: mapTrigger(buffer.readUint8(5)),
[]: mapTrigger(buffer.readUint8(6)),
[]: (buttons & 8) > 0,
[]: (buttons & 4) > 0,
[]: (buttons & 2) > 0,
[]: (buttons & 1) > 0,
[]: dpad,
[]: dpad < 2 || dpad === 7,
[]: dpad > 2 && dpad < 6,
[]: dpad > 4 && dpad < 8,
[]: dpad > 0 && dpad < 4,
[]: (miscButtons & 4) > 0,
[]: (miscButtons & 8) > 0,
[]: (miscButtons & 1) > 0,
[]: (miscButtons & 2) > 0,
[]: (miscButtons & 16) > 0,
[]: (miscButtons & 32) > 0,
[]: (miscButtons & 64) > 0,
[]: (miscButtons & 128) > 0,
[]: (lastButtons & 1) > 0,
[]: (lastButtons & 2) > 0,
[]: (lastButtons & 4) > 0,
// The other 5 bits are unused
// 5 reserved bytes
[]: mapGyroAccel(buffer.readUint8(16), buffer.readUint8(17)),
[]: mapGyroAccel(buffer.readUint8(18), buffer.readUint8(19)),
[]: mapGyroAccel(buffer.readUint8(20), buffer.readUint8(21)),
[]: mapGyroAccel(
buffer.readUint8(22),
buffer.readUint8(23)
),
[]: mapGyroAccel(
buffer.readUint8(24),
buffer.readUint8(25)
),
[]: mapGyroAccel(
buffer.readUint8(26),
buffer.readUint8(27)
),
// 4 bytes for sensor timestamp (32LE)
// 1 reserved byte
[]: buffer.readUint8(33) & 0x7f,
[]: (buffer.readUint8(33) & 0x80) === 0,
[]: mapAxis((buffer.readUint16LE(34) << 20) >> 20, 1920),
[]: mapAxis(buffer.readUint16LE(35) >> 4, 1080),
[]: buffer.readUint8(37) & 0x7f,
[]: (buffer.readUint8(37) & 0x80) === 0,
[]: mapAxis((buffer.readUint16LE(38) << 20) >> 20, 1920),
[]: mapAxis(buffer.readUint16LE(39) >> 4, 1080),
// 12 reserved bytes
[]: (buffer.readUint8(54) & 4) > 0,
};
}
}