dualsense-ts
Version:
The natural interface for your DualSense and DualSense Access controllers, with Typescript
418 lines โข 19.6 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Dualsense = void 0;
const elements_1 = require("./elements");
const input_1 = require("./input");
const hid_1 = require("./hid");
const motion_1 = require("./motion");
/** Represents a Dualsense controller */
class Dualsense extends input_1.Input {
/**
* Firmware and hardware information.
* Contains sensible defaults until the device reports its actual values.
*/
get firmwareInfo() {
return this.hid.firmwareInfo;
}
/**
* Factory information (serial number, body color, board revision).
* Contains sensible defaults until the device reports its actual values.
*/
get factoryInfo() {
return this.hid.factoryInfo;
}
/**
* IMU calibration factors derived from the controller's factory calibration
* data (Feature Report 0x05). Applied automatically to gyroscope and
* accelerometer readings โ exposed here for inspection and diagnostics.
*/
get calibration() {
return this.hid.calibration;
}
/** True if any input at all is active or changing */
get active() {
return Object.values(this).some((input) => input !== this && input instanceof input_1.Input && input.active);
}
/** Returns `true` if the controller is connected via Bluetooth */
get wireless() {
return this.hid.wireless;
}
/** Body color of the controller */
get color() {
const { colorCode } = this.hid.factoryInfo;
if (colorCode in hid_1.DualsenseColorMap)
return hid_1.DualsenseColorMap[colorCode];
return hid_1.DualsenseColor.Unknown;
}
/** Factory-stamped serial number of the controller */
get serialNumber() {
return this.hid.factoryInfo.serialNumber;
}
constructor(params = {}) {
super(params);
this.state = this;
/** The RGB light bar at the top of the controller */
this.lightbar = new elements_1.Lightbar();
/** The 5 white player indicator LEDs */
this.playerLeds = new elements_1.PlayerLeds();
/** Audio volume, routing, and microphone controls */
this.audio = new elements_1.Audio();
/** Per-subsystem power save controls (disable touch, motion, haptics, audio) */
this.powerSave = new elements_1.PowerSaveControl();
/** Monotonic sensor timestamp in microseconds from the controller's clock.
* Updated with each input report โ useful for correlating motion sensor
* readings with other inputs across frames. Wraps at 2^32 (~71.6 minutes). */
this.sensorTimestamp = 0;
/** Previous sensor timestamp for computing dt (microseconds). */
this.prevSensorTimestamp = 0;
/** Active interval timers, cleared on dispose */
this.timers = [];
/**
* Buffered battery reading, sampled on a slow cadence
* Battery readings are prone to flip-flopping, so we buffer them
*/
this.pendingBattery = {
peakLevel: 0,
status: hid_1.ChargeStatus.Discharging,
};
this.ps = new elements_1.Momentary({
icon: "ใฐ",
name: "Home",
...(params.ps ?? {}),
});
this.mute = new elements_1.Mute({
icon: "๐ฉ",
name: "Mute",
...(params.mute ?? {}),
});
this.microphone = new elements_1.Momentary({ icon: "๐ค", name: "Microphone" });
this.headphone = new elements_1.Momentary({ icon: "๐ง", name: "Headphone" });
this.options = new elements_1.Momentary({
icon: "โฏ",
name: "Options",
...(params.options ?? {}),
});
this.create = new elements_1.Momentary({
icon: "๐",
name: "Create",
...(params.create ?? {}),
});
this.triangle = new elements_1.Momentary({
icon: "๐",
name: "Triangle",
...(params.triangle ?? {}),
});
this.circle = new elements_1.Momentary({
icon: "โ",
name: "Circle",
...(params.circle ?? {}),
});
this.cross = new elements_1.Momentary({
icon: "โฎฟ",
name: "Cross",
...(params.cross ?? {}),
});
this.square = new elements_1.Momentary({
icon: "๐",
name: "Square",
...(params.square ?? {}),
});
this.dpad = new elements_1.Dpad({
icon: "D",
name: "D-pad",
...(params.dpad ?? {}),
});
this.left = new elements_1.Unisense({
icon: "L",
name: "Left",
...(params.left ?? {}),
});
this.right = new elements_1.Unisense({
icon: "R",
name: "Right",
...(params.right ?? {}),
});
this.touchpad = new elements_1.Touchpad({
icon: "โ",
name: "Touchpad",
...(params.touchpad ?? {}),
});
this.connection = new elements_1.Momentary({
icon: "๐",
name: "Connected",
...(params.connection ?? {}),
});
this.gyroscope = new elements_1.Gyroscope({
icon: "โ",
name: "Gyroscope",
threshold: 0.01,
...(params.gyroscope ?? {}),
});
this.accelerometer = new elements_1.Accelerometer({
icon: "โคฒ",
name: "Accelerometer",
threshold: 0.01,
...(params.accelerometer ?? {}),
});
this.battery = new elements_1.Battery({
icon: "๐",
name: "Battery",
...(params.battery ?? {}),
});
this.orientation = new motion_1.Orientation(params.orientation);
this.shake = new motion_1.ShakeDetector(params.shake);
this.connection[input_1.InputSet](false);
// If a HID instance was supplied externally (e.g. by DualsenseManager),
// the owner is responsible for driving discovery + reconnection.
// `hid: null` creates a headless instance with no provider โ useful for
// placeholder controllers in UIs where WebHID may not be available.
// Otherwise, construct a default platform provider and run our own
// discovery loop.
const externallyManaged = params.hid !== undefined;
this.hid =
params.hid === null
? new hid_1.DualsenseHID(new hid_1.NullHIDProvider())
: (params.hid ?? new hid_1.DualsenseHID(new hid_1.PlatformHIDProvider()));
this.hid.register((state) => {
this.processHID(state);
});
const rumbleMemo = { left: -1, right: -1 };
const triggerFeedbackMemo = { left: "", right: "" };
const lightbarMemo = { key: "" };
const playerLedsMemo = { key: "" };
const muteLedMemo = { mode: undefined };
// Seed audio memo with the initial state so we don't override the
// controller's firmware-managed routing on connect. Audio commands
// are only sent once the user explicitly changes a setting.
const initialAudioKey = this.audio.toKey();
const audioMemo = { key: initialAudioKey, userChanged: false };
const powerSaveMemo = { key: "" };
// Mirror transport-level connect/disconnect into the connection Momentary,
// and invalidate output memos on rising-edge connect so the output loop
// re-pushes desired state to the new device.
this.hid.onConnectionChange((connected) => {
this.connection[input_1.InputSet](connected);
if (connected) {
triggerFeedbackMemo.left = "";
triggerFeedbackMemo.right = "";
lightbarMemo.key = "";
playerLedsMemo.key = "";
muteLedMemo.mode = undefined;
powerSaveMemo.key = "";
if (audioMemo.userChanged)
audioMemo.key = "";
}
// Reset motion helpers on both connect and disconnect so
// orientation doesn't carry stale state across sessions.
this.orientation.reset();
this.shake.reset();
this.prevSensorTimestamp = 0;
});
// Seed the initial state in case the provider was already attached.
this.connection[input_1.InputSet](this.hid.provider.connected);
// Standalone mode: poll for devices and reconnect on drop. In managed
// mode the manager owns this and we must NOT race with it.
if (!externallyManaged) {
this.timers.push(setInterval(() => {
if (!this.hid.provider.connected) {
void Promise.resolve(this.hid.provider.connect()).catch(() => {
/* surfaced via onError */
});
}
}, 200));
}
/** Refresh battery state on a slow cadence to filter transient glitches */
this.timers.push(setInterval(() => {
if (!this.connection.active)
return;
this.battery.level[input_1.InputSet](this.pendingBattery.peakLevel);
this.battery.status[input_1.InputSet](this.pendingBattery.status);
this.pendingBattery.peakLevel = 0;
}, 1000));
/** Refresh output state (rumble + trigger feedback) */
this.timers.push(setInterval(() => {
if (!this.connection.active)
return;
const leftRumble = this.left.rumble();
const rightRumble = this.right.rumble();
// Always resend when rumble is active. The controller has an internal
// keepalive timeout (~5s) that stops the motors if not refreshed.
if (leftRumble > 0 ||
rightRumble > 0 ||
leftRumble !== rumbleMemo.left ||
rightRumble !== rumbleMemo.right) {
this.hid.setRumble(leftRumble * 255, rightRumble * 255);
rumbleMemo.left = leftRumble;
rumbleMemo.right = rightRumble;
}
const leftFeedback = this.left.trigger.feedback;
const rightFeedback = this.right.trigger.feedback;
const leftKey = leftFeedback.toKey();
const rightKey = rightFeedback.toKey();
const feedbackChanged = leftKey !== triggerFeedbackMemo.left ||
rightKey !== triggerFeedbackMemo.right;
if (feedbackChanged) {
// Force rumble into the same batch so MotorPower scope bit is always present.
this.hid.setRumble(leftRumble * 255, rightRumble * 255);
rumbleMemo.left = leftRumble;
rumbleMemo.right = rightRumble;
}
if (leftKey !== triggerFeedbackMemo.left) {
this.hid.setLeftTriggerFeedback(leftFeedback.toBytes());
triggerFeedbackMemo.left = leftKey;
}
if (rightKey !== triggerFeedbackMemo.right) {
this.hid.setRightTriggerFeedback(rightFeedback.toBytes());
triggerFeedbackMemo.right = rightKey;
}
const lightbarKey = this.lightbar.toKey();
if (lightbarKey !== lightbarMemo.key) {
const { r, g, b } = this.lightbar.color;
this.hid.setLightbar(r, g, b);
lightbarMemo.key = lightbarKey;
}
const pulse = this.lightbar.consumePulse();
if (pulse !== hid_1.PulseOptions.Off) {
this.hid.setLightbar(0, 0, 0, pulse);
}
const playerLedsKey = this.playerLeds.toKey();
if (playerLedsKey !== playerLedsMemo.key) {
this.hid.setPlayerLeds(this.playerLeds.bitmask, this.playerLeds.brightness);
playerLedsMemo.key = playerLedsKey;
}
const muteLedMode = this.mute.ledMode;
if (muteLedMode !== muteLedMemo.mode) {
if (muteLedMode !== undefined) {
this.hid.setMicrophoneLED(muteLedMode);
}
muteLedMemo.mode = muteLedMode;
}
const audioKey = this.audio.toKey();
if (audioKey !== audioMemo.key) {
audioMemo.userChanged = true;
this.hid.setHeadphoneVolume(this.audio.headphoneVolumeRaw);
this.hid.setSpeakerVolume(this.audio.speakerVolumeRaw);
this.hid.setMicrophoneVolume(this.audio.microphoneVolumeRaw);
this.hid.setAudioFlags(this.audio.audioFlags);
this.hid.setSpeakerPreamp(this.audio.preampGain, this.audio.beamForming);
audioMemo.key = audioKey;
}
// Power save byte 10 combines audio mute flags with subsystem disable flags.
// Send when either source changes.
const psKey = `${this.audio.powerSaveFlags}|${this.powerSave.toKey()}`;
if (psKey !== powerSaveMemo.key) {
this.hid.setPowerSave(this.audio.powerSaveFlags | this.powerSave.flags);
powerSaveMemo.key = psKey;
}
}, 1000 / 30));
}
/** Stop all internal timers and release resources. */
dispose() {
this.timers.forEach((timer) => { clearInterval(timer); });
this.timers.length = 0;
this.hid.dispose();
}
/** Average rumble strength across both halves of the controller. */
get rumbleIntensity() {
return (this.left.rumble() + this.right.rumble()) / 2;
}
/** Reset adaptive trigger feedback on both sides to the default linear feel */
resetTriggerFeedback() {
this.left.trigger.feedback.reset();
this.right.trigger.feedback.reset();
}
/**
* Play a built-in test tone via the onboard DSP.
* Works over both USB and Bluetooth. Call `stopTestTone()` to stop.
* @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") {
return this.hid.startTestTone(target, tone);
}
/** Stop the DSP test tone */
async stopTestTone() {
return this.hid.stopTestTone();
}
/** Check or adjust rumble intensity evenly across both sides of the controller */
rumble(intensity) {
this.left.rumble(intensity);
this.right.rumble(intensity);
return this.rumbleIntensity;
}
/** Distributes HID event values to the controller's public inputs. */
processHID(state) {
this.ps[input_1.InputSet](state[hid_1.InputId.Playstation]);
this.options[input_1.InputSet](state[hid_1.InputId.Options]);
this.create[input_1.InputSet](state[hid_1.InputId.Create]);
this.mute[input_1.InputSet](state[hid_1.InputId.Mute]);
this.mute.status[input_1.InputSet](state[hid_1.InputId.MuteLed]);
this.microphone[input_1.InputSet](state[hid_1.InputId.Microphone]);
this.headphone[input_1.InputSet](state[hid_1.InputId.Headphone]);
this.triangle[input_1.InputSet](state[hid_1.InputId.Triangle]);
this.circle[input_1.InputSet](state[hid_1.InputId.Circle]);
this.cross[input_1.InputSet](state[hid_1.InputId.Cross]);
this.square[input_1.InputSet](state[hid_1.InputId.Square]);
this.dpad.up[input_1.InputSet](state[hid_1.InputId.Up]);
this.dpad.down[input_1.InputSet](state[hid_1.InputId.Down]);
this.dpad.right[input_1.InputSet](state[hid_1.InputId.Right]);
this.dpad.left[input_1.InputSet](state[hid_1.InputId.Left]);
this.touchpad.button[input_1.InputSet](state[hid_1.InputId.TouchButton]);
this.touchpad.left.x[input_1.InputSet](state[hid_1.InputId.TouchX0]);
this.touchpad.left.y[input_1.InputSet](state[hid_1.InputId.TouchY0]);
this.touchpad.left.contact[input_1.InputSet](state[hid_1.InputId.TouchContact0]);
this.touchpad.left.tracker[input_1.InputSet](state[hid_1.InputId.TouchId0]);
this.touchpad.right.x[input_1.InputSet](state[hid_1.InputId.TouchX1]);
this.touchpad.right.y[input_1.InputSet](state[hid_1.InputId.TouchY1]);
this.touchpad.right.contact[input_1.InputSet](state[hid_1.InputId.TouchContact1]);
this.touchpad.right.tracker[input_1.InputSet](state[hid_1.InputId.TouchId1]);
this.left.analog.x[input_1.InputSet](state[hid_1.InputId.LeftAnalogX]);
this.left.analog.y[input_1.InputSet](state[hid_1.InputId.LeftAnalogY]);
this.left.analog.button[input_1.InputSet](state[hid_1.InputId.LeftAnalogButton]);
this.left.bumper[input_1.InputSet](state[hid_1.InputId.LeftBumper]);
this.left.trigger[input_1.InputSet](state[hid_1.InputId.LeftTrigger]);
this.left.trigger.button[input_1.InputSet](state[hid_1.InputId.LeftTriggerButton]);
this.right.analog.x[input_1.InputSet](state[hid_1.InputId.RightAnalogX]);
this.right.analog.y[input_1.InputSet](state[hid_1.InputId.RightAnalogY]);
this.right.analog.button[input_1.InputSet](state[hid_1.InputId.RightAnalogButton]);
this.right.bumper[input_1.InputSet](state[hid_1.InputId.RightBumper]);
this.right.trigger[input_1.InputSet](state[hid_1.InputId.RightTrigger]);
this.right.trigger.button[input_1.InputSet](state[hid_1.InputId.RightTriggerButton]);
this.sensorTimestamp = state[hid_1.InputId.SensorTimestamp];
this.gyroscope.x[input_1.InputSet](state[hid_1.InputId.GyroX]);
this.gyroscope.y[input_1.InputSet](state[hid_1.InputId.GyroY]);
this.gyroscope.z[input_1.InputSet](state[hid_1.InputId.GyroZ]);
this.accelerometer.x[input_1.InputSet](state[hid_1.InputId.AccelX]);
this.accelerometer.y[input_1.InputSet](state[hid_1.InputId.AccelY]);
this.accelerometer.z[input_1.InputSet](state[hid_1.InputId.AccelZ]);
// Compute dt from the controller's hardware clock (microseconds).
// The timestamp wraps at 2^32 (~71.6 minutes).
const ts = state[hid_1.InputId.SensorTimestamp];
if (this.prevSensorTimestamp !== 0 && ts !== 0) {
const dtMicro = ts >= this.prevSensorTimestamp
? ts - this.prevSensorTimestamp
: 0xFFFFFFFF - this.prevSensorTimestamp + ts + 1;
const dt = dtMicro / 1000000;
// Sanity check: skip if dt is unreasonable (>0.5s means we
// probably missed reports or just reconnected).
if (dt > 0 && dt < 0.5) {
const gx = state[hid_1.InputId.GyroX];
const gy = state[hid_1.InputId.GyroY];
const gz = state[hid_1.InputId.GyroZ];
const ax = state[hid_1.InputId.AccelX];
const ay = state[hid_1.InputId.AccelY];
const az = state[hid_1.InputId.AccelZ];
this.orientation.update(gx, gy, gz, ax, ay, az, dt);
this.shake.update(ax, ay, az, dt);
}
}
this.prevSensorTimestamp = ts;
const level = state[hid_1.InputId.BatteryLevel];
if (level > this.pendingBattery.peakLevel) {
this.pendingBattery.peakLevel = level;
}
this.pendingBattery.status = state[hid_1.InputId.BatteryStatus];
}
}
exports.Dualsense = Dualsense;
//# sourceMappingURL=dualsense.js.map