dualsense-ts
Version:
The natural interface for your DualSense and DualSense Access controllers, with Typescript
271 lines • 11.2 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebHIDProvider = void 0;
const hid_provider_1 = require("./hid_provider");
const bt_checksum_1 = require("./bt_checksum");
const calibration_1 = require("./calibration");
class WebHIDProvider extends hid_provider_1.HIDProvider {
constructor(options = {}) {
super();
if (!navigator.hid)
throw new Error("WebHID not supported by this browser");
this.targetDevice = options.device;
navigator.hid.addEventListener("disconnect", ({ device }) => {
if (device === this.device) {
// Let disconnect() → reset() handle nulling this.device so that
// reset() can detect the device was attached and fire onDisconnect.
this.disconnect();
}
});
navigator.hid.addEventListener("connect", ({ device }) => {
if (!this.device && !this.targetDevice)
this.attach(device);
});
// If a specific device was provided, attach immediately
if (this.targetDevice) {
this.attach(this.targetDevice);
}
}
/**
* WebHID API doesn't indicate whether we are connected through the controller's
* USB or Bluetooth interface. The protocol is different depending on the connection
* type so we will try to detect it based on the collection information.
*/
detectConnectionType() {
this.wireless = undefined;
if (!this.device) {
return;
}
for (const c of this.device.collections) {
if (c.usagePage !== hid_provider_1.HIDProvider.usagePage ||
c.usage !== hid_provider_1.HIDProvider.usage) {
continue;
}
// Compute the maximum input report byte length and compare against known values.
const maxInputReportBytes = (c.inputReports ?? []).reduce((max, report) => {
return Math.max(max, (report.items ?? []).reduce((sum, item) => {
return sum + (item.reportSize ?? 0) * (item.reportCount ?? 0);
}, 0));
}, 0);
if (maxInputReportBytes == 504) {
this.wireless = false;
}
else if (maxInputReportBytes == 616) {
this.wireless = true;
}
}
}
/** Derive a stable identity string for a WebHID device */
static deviceKey(device) {
// WebHID does not expose serial numbers or paths directly.
// We use the product name + vendor/product ids as a coarse key, but
// because multiple identical controllers may be connected, append the
// device object's own identity via its collections fingerprint.
const collections = device.collections
.map((c) => `${String(c.usagePage)}:${String(c.usage)}`)
.join(";");
return `${device.vendorId}:${device.productId}:${collections}:${device.productName}`;
}
attach(device) {
const key = WebHIDProvider.deviceKey(device);
const openPromise = device.opened ? Promise.resolve() : device.open();
openPromise
.then(() => {
this.device = device;
this.deviceId = key;
this.detectConnectionType();
// Enable accelerometer, gyro, touchpad — and capture IMU calibration
return this.device.receiveFeatureReport(0x05);
})
.then((calView) => {
try {
const calBuf = new Uint8Array(calView.buffer, calView.byteOffset, calView.byteLength);
this.calibration = (0, calibration_1.resolveCalibration)((0, calibration_1.parseIMUCalibration)(calBuf));
}
catch { /* use default calibration */ }
if (!this.device)
throw Error("Controller disconnected before setup");
this.device.addEventListener("inputreport", ({ reportId, data }) => {
this.buffer = data;
this.onData(this.process({ reportId, buffer: data }));
});
this.onConnect();
})
.catch((err) => {
this.onError(err);
this.disconnect();
});
}
/**
* Detach the current HIDDevice (if any) and attach a different one in place.
* Used by the manager to transplant a freshly-discovered device into an
* existing slot's provider after identity matching, so the consumer's
* Dualsense reference survives reconnection.
*
* The new device must already be open (or openable) — we close the old one,
* release its claim, and run the standard attach() flow on the new one.
*/
replaceDevice(device) {
// Tear down the existing device without firing the disconnect cascade
// (we don't want subscribers to see a disconnect/reconnect blip).
if (this.device) {
const old = this.device;
const oldKey = this.deviceId;
this.device = undefined;
if (oldKey)
hid_provider_1.HIDProvider.claimedDevices.delete(oldKey);
// Best-effort close; failures are non-fatal.
old.close().catch(() => { });
}
this.attach(device);
}
/**
* You need to get HID device permissions from an interactive
* component, like a button. This returns a callback for triggering
* the permissions request.
*/
getRequest() {
return () => navigator.hid
.requestDevice({
filters: [
{
vendorId: hid_provider_1.HIDProvider.vendorId,
productId: hid_provider_1.HIDProvider.productId,
usagePage: hid_provider_1.HIDProvider.usagePage,
usage: hid_provider_1.HIDProvider.usage,
},
],
})
.then((devices) => {
if (devices.length === 0) {
return this.onError(new Error(`No controllers available`));
}
this.attach(devices[0]);
})
.catch((err) => {
this.onError(err);
});
}
/**
* Request permission for multiple devices at once. Returns a callback
* suitable for use as an onClick handler. The `onDevice` callback is
* invoked once per selected device with the raw HIDDevice handle.
*/
static getMultiRequest(onDevice, onError) {
return () => navigator.hid
.requestDevice({
filters: [
{
vendorId: hid_provider_1.HIDProvider.vendorId,
productId: hid_provider_1.HIDProvider.productId,
usagePage: hid_provider_1.HIDProvider.usagePage,
usage: hid_provider_1.HIDProvider.usage,
},
],
})
.then((devices) => {
devices.forEach((device) => onDevice(device));
})
.catch((err) => {
onError?.(err);
});
}
/** List already-permitted Dualsense devices */
static async enumerate() {
const all = await navigator.hid.getDevices();
return all.filter((d) => d.vendorId === hid_provider_1.HIDProvider.vendorId &&
d.productId === hid_provider_1.HIDProvider.productId);
}
connect() {
// Nothing to be done.
}
get connected() {
return this.device !== undefined;
}
disconnect() {
if (this.device) {
const dev = this.device;
// Reset synchronously so claimedDevices is freed immediately —
// otherwise a rapid disconnect/reconnect can race: the browser's
// connect event arrives before close() resolves, and attach() sees
// the key still claimed and silently bails out.
this.reset();
dev.close().catch(() => { });
}
else {
this.reset();
}
}
async readFeatureReport(reportId) {
if (!this.device)
throw new Error("No device connected");
const view = await this.device.receiveFeatureReport(reportId);
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
}
async sendFeatureReport(reportId, data) {
if (!this.device)
return;
// WebHID sendFeatureReport takes the report ID separately.
// data[0] is the report ID (for node-hid compat); strip it for WebHID.
const rawPayload = data.slice(1);
// Pad to the expected payload length from the HID descriptor
const expectedLength = this.getFeatureReportLength(reportId);
const payload = expectedLength > 0 && rawPayload.length < expectedLength
? new Uint8Array(expectedLength)
: new Uint8Array(rawPayload);
if (expectedLength > rawPayload.length) {
payload.set(rawPayload);
}
// Bluetooth requires CRC-32 in the last 4 bytes of the payload
if (this.wireless) {
const crc = (0, bt_checksum_1.computeFeatureReportChecksum)(reportId, payload);
const off = payload.length - 4;
payload[off] = crc & 0xff;
payload[off + 1] = (crc >>> 8) & 0xff;
payload[off + 2] = (crc >>> 16) & 0xff;
payload[off + 3] = (crc >>> 24) & 0xff;
}
await this.device.sendFeatureReport(reportId, payload);
}
/** Query the HID descriptor for the expected payload length of a feature report */
getFeatureReportLength(reportId) {
if (!this.device)
return 0;
for (const c of this.device.collections) {
const report = (c.featureReports ?? []).find((r) => r.reportId === reportId);
if (report) {
return (report.items ?? []).reduce((sum, item) => sum + Math.ceil(((item.reportSize ?? 0) * (item.reportCount ?? 0)) / 8), 0);
}
}
return 0;
}
async write(data) {
if (!this.device)
return;
const reportId = data[0];
const payload = data.slice(1);
return this.device.sendReport(reportId, payload);
}
process({ reportId, buffer, }) {
// DataView does not report the first byte (the report id), we simulate it
const report = {
length: buffer.byteLength + 1,
readUint8(offset) {
return offset > 0 ? buffer.getUint8(offset - 1) : reportId;
},
readUint16LE(offset) {
return offset > 0
? buffer.getUint16(offset - 1, true)
: (reportId << 8) | buffer.getUint8(0);
},
readUint32LE(offset) {
return offset > 0
? buffer.getUint32(offset - 1, true)
: (reportId << 24) | (buffer.getUint8(2) << 16) | buffer.getUint16(0, true);
},
};
return this.processReport(report);
}
}
exports.WebHIDProvider = WebHIDProvider;
//# sourceMappingURL=web_hid_provider.js.map