-
Version 2.19 (2025-10-18) - Support this project
+
Version 2.20 (2025-11-16) - Support this project
diff --git a/js/controllers/base-controller.js b/js/controllers/base-controller.js
index 868835a..69263e5 100644
--- a/js/controllers/base-controller.js
+++ b/js/controllers/base-controller.js
@@ -35,6 +35,10 @@ class BaseController {
return this.finetuneMaxValue;
}
+ getNumberOfSticks() {
+ return 0;
+ }
+
/**
* Set input report handler
* @param {Function} handler Input report handler function
diff --git a/js/controllers/controller-factory.js b/js/controllers/controller-factory.js
index 706993a..7b50e09 100644
--- a/js/controllers/controller-factory.js
+++ b/js/controllers/controller-factory.js
@@ -3,6 +3,7 @@
import DS4Controller from './ds4-controller.js';
import DS5Controller from './ds5-controller.js';
import DS5EdgeController from './ds5-edge-controller.js';
+import VR2Controller from './vr2-controller.js';
import { dec2hex } from '../utils.js';
/**
@@ -14,7 +15,9 @@ class ControllerFactory {
const ds4v2 = { vendorId: 0x054c, productId: 0x09cc };
const ds5 = { vendorId: 0x054c, productId: 0x0ce6 };
const ds5edge = { vendorId: 0x054c, productId: 0x0df2 };
- return [ds4v1, ds4v2, ds5, ds5edge];
+ const vr2_left = { vendorId: 0x054c, productId: 0x0e45 };
+ const vr2_right = { vendorId: 0x054c, productId: 0x0e46 };
+ return [ds4v1, ds4v2, ds5, ds5edge, vr2_left, vr2_right];
}
@@ -35,6 +38,12 @@ class ControllerFactory {
case 0x0df2: // DS5 Edge
return new DS5EdgeController(device);
+ case 0x0e45: // VR2 Left
+ return new VR2Controller(device, true);
+
+ case 0x0e46: // VR2 Right
+ return new VR2Controller(device, false);
+
default:
throw new Error(`Unsupported device: ${dec2hex(device.vendorId)}:${dec2hex(device.productId)}`);
}
@@ -55,6 +64,10 @@ class ControllerFactory {
return "Sony DualSense";
case 0x0df2:
return "Sony DualSense Edge";
+ case 0x0e45:
+ return "VR2 Left Controller";
+ case 0x0e46:
+ return "VR2 Right Controller";
default:
return "Unknown Device";
}
@@ -87,6 +100,16 @@ class ControllerFactory {
showQuickCalib: true
};
+ case 0x0e45: // VR2 Left Controller
+ case 0x0e46: // VR2 Right Controller
+ return {
+ showInfo: true,
+ showFinetune: false,
+ showInfoTab: true,
+ showFourStepCalib: true,
+ showQuickCalib: false
+ };
+
default:
return {
showInfo: false,
diff --git a/js/controllers/ds4-controller.js b/js/controllers/ds4-controller.js
index 89be4a4..17c3a8a 100644
--- a/js/controllers/ds4-controller.js
+++ b/js/controllers/ds4-controller.js
@@ -691,6 +691,10 @@ class DS4Controller extends BaseController {
}
}
+ getNumberOfSticks() {
+ return 2;
+ }
+
/**
* Get the list of supported quick tests for DS4 controller
* DS4 does not support adaptive triggers, speaker, or microphone
diff --git a/js/controllers/ds5-controller.js b/js/controllers/ds5-controller.js
index c500c72..9954f83 100644
--- a/js/controllers/ds5-controller.js
+++ b/js/controllers/ds5-controller.js
@@ -830,6 +830,10 @@ class DS5Controller extends BaseController {
}
}
+ getNumberOfSticks() {
+ return 2;
+ }
+
/**
* Parse DS5 battery status from input data
*/
diff --git a/js/controllers/ds5-edge-controller.js b/js/controllers/ds5-edge-controller.js
index 50bc985..3a20c8e 100644
--- a/js/controllers/ds5-edge-controller.js
+++ b/js/controllers/ds5-edge-controller.js
@@ -242,6 +242,11 @@ class DS5EdgeController extends DS5Controller {
const pkg = data.reduce((acc, val) => acc.concat([val & 0xff, val >> 8]), [12, 1]);
await this.sendFeatureReport(0x80, pkg)
}
+
+ getNumberOfSticks() {
+ return 2;
+ }
+
}
export default DS5EdgeController;
diff --git a/js/controllers/vr2-controller.js b/js/controllers/vr2-controller.js
new file mode 100644
index 0000000..1eab45e
--- /dev/null
+++ b/js/controllers/vr2-controller.js
@@ -0,0 +1,638 @@
+'use strict';
+
+import BaseController from './base-controller.js';
+import {
+ sleep,
+ buf2hex,
+ dec2hex,
+ dec2hex32,
+ dec2hex8,
+ format_mac_from_view,
+ reverse_str,
+ la,
+} from '../utils.js';
+import { l } from '../translations.js';
+
+// DS5 Button mapping configuration
+const DS5_BUTTON_MAP = [
+ { name: 'up', byte: 0, mask: 0x0 }, // Disabled
+ { name: 'right', byte: 0, mask: 0x0 }, // Disabled
+ { name: 'down', byte: 0, mask: 0x0 }, // Disabled
+ { name: 'left', byte: 0, mask: 0x0 }, // Disabled
+ { name: 'square', byte: 9, mask: 0x10, svg: 'Square' },
+ { name: 'cross', byte: 9, mask: 0x20, svg: 'Cross' },
+ { name: 'circle', byte: 9, mask: 0x40, svg: 'Circle' },
+ { name: 'triangle', byte: 9, mask: 0x80, svg: 'Triangle' },
+ { name: 'l1', byte: 9, mask: 0x10, svg: 'L1' },
+ { name: 'l2', byte: 4, mask: 0xff }, // analog handled separately
+ { name: 'r1', byte: 9, mask: 0x20, svg: 'R1' },
+ { name: 'r2', byte: 4, mask: 0xff }, // analog handled separately
+ { name: 'create', byte: 10, mask: 0x01, svg: 'Create' },
+ { name: 'options', byte: 10, mask: 0x02, svg: 'Options' },
+ { name: 'l3', byte: 10, mask: 0x04, svg: 'L3' },
+ { name: 'r3', byte: 10, mask: 0x08, svg: 'R3' },
+ { name: 'ps', byte: 10, mask: 0x10, svg: 'PS' },
+ { name: 'touchpad', byte: 0, mask: 0x00, svg: 'Trackpad' },
+ { name: 'mute', byte: 0, mask: 0x00, svg: 'Mute' },
+];
+
+// DS5 Input processing configuration
+const DS5_INPUT_CONFIG = {
+ buttonMap: DS5_BUTTON_MAP,
+ dpadByte: 7,
+ l2AnalogByte: 4,
+ r2AnalogByte: 4,
+ touchpadOffset: 32,
+};
+
+// DS5 Adaptive Trigger Effect Modes
+const DS5_TRIGGER_EFFECT_MODE = {
+ OFF: 0x00, // No effect
+ RESISTANCE: 0x01, // Constant resistance
+ TRIGGER: 0x02, // Single-trigger effect with release
+ AUTO_TRIGGER: 0x06, // Automatic trigger with vibration
+};
+
+// DS5 Output Report Constants
+const DS5_OUTPUT_REPORT = {
+ USB_REPORT_ID: 0x02,
+ BT_REPORT_ID: 0x31,
+}
+
+const DS5_VALID_FLAG0 = {
+ RIGHT_VIBRATION: 0x01, // Bit 0 for right vibration motor
+ LEFT_VIBRATION: 0x02, // Bit 1 for left vibration motor
+ LEFT_TRIGGER: 0x04, // Bit 2 for left adaptive trigger
+ RIGHT_TRIGGER: 0x08, // Bit 3 for right adaptive trigger
+ HEADPHONE_VOLUME: 0x10, // Bit 4 for headphone volume control
+ SPEAKER_VOLUME: 0x20, // Bit 5 for speaker volume control
+ MIC_VOLUME: 0x40, // Bit 6 for microphone volume control
+ AUDIO_CONTROL: 0x80, // Bit 7 for audio control
+};
+
+const DS5_VALID_FLAG1 = {
+ MUTE_LED: 0x01, // Bit 0 for mute LED control
+ POWER_SAVE_MUTE: 0x02, // Bit 1 for power-save mute control
+ LIGHTBAR_COLOR: 0x04, // Bit 2 for lightbar color control
+ RESERVED_BIT_3: 0x08, // Bit 3 (reserved)
+ PLAYER_INDICATOR: 0x10, // Bit 4 for player indicator LED control
+ LED_BRIGHTNESS: 0x20, // Bit 6 for LED brightness control
+ LIGHTBAR_SETUP: 0x40, // Bit 6 for lightbar setup control
+ RESERVED_BIT_7: 0x80, // Bit 7 (reserved)
+}
+
+const DS5_VALID_FLAG2 = {
+ LED_BRIGHTNESS: 0x01, // Bit 0 for LED brightness control
+ LIGHTBAR_SETUP: 0x02, // Bit 1 for lightbar setup control
+};
+
+// Basic DS5 Output Structure for adaptive trigger control
+class DS5OutputStruct {
+ constructor(currentState = null) {
+ // Create a 47-byte buffer for DS5 output report (USB)
+ this.buffer = new ArrayBuffer(47);
+ this.view = new DataView(this.buffer);
+
+ // Control flags
+ this.validFlag0 = currentState.validFlag0 || 0;
+ this.validFlag1 = currentState.validFlag1 || 0;
+ this.validFlag2 = currentState.validFlag2 || 0;
+
+ // Vibration motors
+ this.bcVibrationRight = currentState.bcVibrationRight || 0;
+ this.bcVibrationLeft = currentState.bcVibrationLeft || 0;
+
+ // Audio control
+ this.headphoneVolume = currentState.headphoneVolume || 0;
+ this.speakerVolume = currentState.speakerVolume || 0;
+ this.micVolume = currentState.micVolume || 0;
+ this.audioControl = currentState.audioControl || 0;
+ this.audioControl2 = currentState.audioControl2 || 0;
+
+ // LED and indicator control
+ this.muteLedControl = currentState.muteLedControl || 0;
+ this.powerSaveMuteControl = currentState.powerSaveMuteControl || 0;
+ this.lightbarSetup = currentState.lightbarSetup || 0;
+ this.ledBrightness = currentState.ledBrightness || 0;
+ this.playerIndicator = currentState.playerIndicator || 0;
+ this.ledCRed = currentState.ledCRed || 0;
+ this.ledCGreen = currentState.ledCGreen || 0;
+ this.ledCBlue = currentState.ledCBlue || 0;
+
+ // Adaptive trigger parameters
+ this.adaptiveTriggerLeftMode = currentState.adaptiveTriggerLeftMode || 0;
+ this.adaptiveTriggerLeftParam0 = currentState.adaptiveTriggerLeftParam0 || 0;
+ this.adaptiveTriggerLeftParam1 = currentState.adaptiveTriggerLeftParam1 || 0;
+ this.adaptiveTriggerLeftParam2 = currentState.adaptiveTriggerLeftParam2 || 0;
+
+ this.adaptiveTriggerRightMode = currentState.adaptiveTriggerRightMode || 0;
+ this.adaptiveTriggerRightParam0 = currentState.adaptiveTriggerRightParam0 || 0;
+ this.adaptiveTriggerRightParam1 = currentState.adaptiveTriggerRightParam1 || 0;
+ this.adaptiveTriggerRightParam2 = currentState.adaptiveTriggerRightParam2 || 0;
+
+ // Haptic feedback
+ this.hapticVolume = currentState.hapticVolume || 0;
+ }
+
+ // Pack the data into the output buffer
+ pack() {
+ // Based on DS5 output report structure from HID descriptor
+ // Byte 0-1: Control flags (16-bit little endian)
+ this.view.setUint16(0, (this.validFlag1 << 8) | this.validFlag0, true);
+
+ // Byte 2-3: Vibration motors
+ this.view.setUint8(2, this.bcVibrationRight);
+ this.view.setUint8(3, this.bcVibrationLeft);
+
+ // Bytes 4-7: Audio control (reserved for now)
+ this.view.setUint8(4, this.headphoneVolume);
+ this.view.setUint8(5, this.speakerVolume);
+ this.view.setUint8(6, this.micVolume);
+ this.view.setUint8(7, this.audioControl);
+
+ // Byte 8: Mute LED control
+ this.view.setUint8(8, this.muteLedControl);
+
+ // Byte 9: Reserved
+ this.view.setUint8(9, 0);
+
+ // Bytes 10-20: Right adaptive trigger
+ this.view.setUint8(10, this.adaptiveTriggerRightMode);
+ this.view.setUint8(11, this.adaptiveTriggerRightParam0);
+ this.view.setUint8(12, this.adaptiveTriggerRightParam1);
+ this.view.setUint8(13, this.adaptiveTriggerRightParam2);
+ // Additional trigger parameters (bytes 14-20 reserved for extended params)
+ for (let i = 14; i <= 20; i++) {
+ this.view.setUint8(i, 0);
+ }
+
+ // Bytes 21-31: Left adaptive trigger
+ this.view.setUint8(21, this.adaptiveTriggerLeftMode);
+ this.view.setUint8(22, this.adaptiveTriggerLeftParam0);
+ this.view.setUint8(23, this.adaptiveTriggerLeftParam1);
+ this.view.setUint8(24, this.adaptiveTriggerLeftParam2);
+ // Additional trigger parameters (bytes 25-31 reserved for extended params)
+ for (let i = 25; i <= 31; i++) {
+ this.view.setUint8(i, 0);
+ }
+
+ // Bytes 32-42: Reserved
+ for (let i = 32; i <= 42; i++) {
+ this.view.setUint8(i, 0);
+ }
+
+ // Byte 43: Player LED indicator
+ this.view.setUint8(43, this.playerIndicator);
+
+ // Bytes 44-46: Lightbar RGB
+ this.view.setUint8(44, this.ledCRed);
+ this.view.setUint8(45, this.ledCGreen);
+ this.view.setUint8(46, this.ledCBlue);
+
+ return this.buffer;
+ }
+}
+
+/**
+* VR2 Controller implementation
+*/
+class VR2Controller extends BaseController {
+ constructor(device, isLeft) {
+ super(device);
+ this.model = "VR2";
+ this.finetuneMaxValue = 65535; // 16-bit max value for DS5
+
+ // Initialize current output state to track controller settings
+ this.currentOutputState = {
+ validFlag0: 0,
+ validFlag1: 0,
+ validFlag2: 0,
+ bcVibrationRight: 0,
+ bcVibrationLeft: 0,
+ headphoneVolume: 0,
+ speakerVolume: 0,
+ micVolume: 0,
+ audioControl: 0,
+ audioControl2: 0,
+ muteLedControl: 0,
+ powerSaveMuteControl: 0,
+ lightbarSetup: 0,
+ ledBrightness: 0,
+ playerIndicator: 0,
+ ledCRed: 0,
+ ledCGreen: 0,
+ ledCBlue: 0,
+ adaptiveTriggerLeftMode: 0,
+ adaptiveTriggerLeftParam0: 0,
+ adaptiveTriggerLeftParam1: 0,
+ adaptiveTriggerLeftParam2: 0,
+ adaptiveTriggerRightMode: 0,
+ adaptiveTriggerRightParam0: 0,
+ adaptiveTriggerRightParam1: 0,
+ adaptiveTriggerRightParam2: 0,
+ hapticVolume: 0
+ };
+ }
+
+ getInputConfig() {
+ return DS5_INPUT_CONFIG;
+ }
+
+ async getInfo() {
+ return this._getInfo(false);
+ }
+
+ async _getInfo(is_edge) {
+ // Device-only: collect info and return a common structure; do not touch the DOM
+ try {
+ console.log("Fetching controller info...");
+ const view = await this.receiveFeatureReport(0x20);
+ console.log("Got VR2 info report:", buf2hex(view.buffer));
+ const cmd = view.getUint8(0, true);
+ if(cmd != 0x20 || view.buffer.byteLength != 64)
+ return { ok: false, error: new Error("Invalid response for ds5_info") };
+
+ const build_date = new TextDecoder().decode(view.buffer.slice(1, 1+11));
+ const build_time = new TextDecoder().decode(view.buffer.slice(12, 20));
+
+ const fwtype = view.getUint16(20, true);
+ const swseries = view.getUint16(22, true);
+ const hwinfo = view.getUint32(24, true);
+ const fwversion = view.getUint32(28, true);
+
+ const updversion = view.getUint16(44, true);
+ const unk = view.getUint8(46, true);
+
+ const fwversion1 = view.getUint32(48, true);
+ const fwversion2 = view.getUint32(52, true);
+ const fwversion3 = view.getUint32(56, true);
+
+ const serial_number = await this.getSystemInfo(1, 19, 17);
+ const infoItems = [
+ { key: l("Serial Number"), value: serial_number, cat: "hw" },
+ { key: l("MCU Unique ID"), value: await this.getSystemInfo(1, 9, 9, false), cat: "hw", isExtra: true },
+ { key: l("PCBA ID"), value: reverse_str(await this.getSystemInfo(1, 17, 14)), cat: "hw", isExtra: true },
+ { key: l("Battery Barcode"), value: await this.getSystemInfo(1, 24, 23), cat: "hw", isExtra: true },
+ { key: l("VCM Left Barcode"), value: await this.getSystemInfo(1, 26, 16), cat: "hw", isExtra: true },
+ { key: l("VCM Right Barcode"), value: await this.getSystemInfo(1, 28, 16), cat: "hw", isExtra: true },
+
+ ...(is_edge ? [] : [{ key: l("Board Model"), value: this.hwToBoardModel(hwinfo), cat: "hw", addInfoIcon: 'board' }]),
+
+ { key: l("FW Build Date"), value: build_date + " " + build_time, cat: "fw" },
+ { key: l("FW Type"), value: "0x" + dec2hex(fwtype), cat: "fw", isExtra: true },
+ { key: l("FW Series"), value: "0x" + dec2hex(swseries), cat: "fw", isExtra: true },
+ { key: l("HW Model"), value: "0x" + dec2hex32(hwinfo), cat: "hw", isExtra: true },
+ { key: l("FW Version"), value: "0x" + dec2hex32(fwversion), cat: "fw", isExtra: true },
+ { key: l("FW Update"), value: "0x" + dec2hex(updversion), cat: "fw", isExtra: true },
+ { key: l("FW Update Info"), value: "0x" + dec2hex8(unk), cat: "fw", isExtra: true },
+ { key: l("SBL FW Version"), value: "0x" + dec2hex32(fwversion1), cat: "fw", isExtra: true },
+ { key: l("Venom FW Version"), value: "0x" + dec2hex32(fwversion2), cat: "fw", isExtra: true },
+ { key: l("Spider FW Version"), value: "0x" + dec2hex32(fwversion3), cat: "fw", isExtra: true },
+
+ { key: l("Touchpad ID"), value: await this.getSystemInfo(5, 2, 8, false), cat: "hw", isExtra: true },
+ { key: l("Touchpad FW Version"), value: await this.getSystemInfo(5, 4, 8, false), cat: "fw", isExtra: true },
+ ];
+
+ const old_controller = build_date.search(/ 2020| 2021/);
+ let disable_bits = 0;
+ if(old_controller != -1) {
+ la("vr2_info_error", {"r": "old"})
+ disable_bits |= 2; // 2: outdated firmware
+ }
+
+ const nv = await this.queryNvStatus();
+ const bd_addr = await this.getBdAddr();
+ infoItems.push({ key: l("Bluetooth Address"), value: bd_addr, cat: "hw", isExtra: true });
+
+ const pending_reboot = (nv?.status === 'pending_reboot');
+
+ return { ok: true, infoItems, nv, disable_bits, pending_reboot };
+ } catch(error) {
+ la("vr2_info_error", {"r": error})
+ return { ok: false, error, disable_bits: 1 };
+ }
+ }
+
+ async flash(progressCallback = null) {
+ la("vr2_flash");
+ try {
+ await this.nvsUnlock();
+ const lockRes = await this.nvsLock();
+ if(!lockRes.ok) throw (lockRes.error || new Error("NVS lock failed"));
+
+ return { success: true, message: l("Changes saved successfully") };
+ } catch(error) {
+ throw new Error(l("Error while saving changes"), { cause: error });
+ }
+ }
+
+ async reset() {
+ la("vr2_reset");
+ try {
+ await this.sendFeatureReport(0x80, [1,1]);
+ } catch(error) {
+ }
+ }
+
+ async nvsLock() {
+ // la("vr2_nvlock");
+ try {
+ await this.sendFeatureReport(0x80, [3,1]);
+ await this.receiveFeatureReport(0x81);
+ return { ok: true };
+ } catch(error) {
+ return { ok: false, error };
+ }
+ }
+
+ async nvsUnlock() {
+ // la("vr2_nvunlock");
+ try {
+ await this.sendFeatureReport(0x80, [3,2, 101, 50, 64, 12]);
+ const data = await this.receiveFeatureReport(0x81);
+ } catch(error) {
+ await sleep(500);
+ throw new Error(l("NVS Unlock failed"), { cause: error });
+ }
+ }
+
+ async getBdAddr() {
+ await this.sendFeatureReport(0x80, [9,2]);
+ const data = await this.receiveFeatureReport(0x81);
+ return format_mac_from_view(data, 4);
+ }
+
+ async getSystemInfo(base, num, length, decode = true) {
+ await this.sendFeatureReport(128, [base,num])
+ const pcba_id = await this.receiveFeatureReport(129);
+ if(pcba_id.getUint8(1) != base || pcba_id.getUint8(2) != num || pcba_id.getUint8(3) != 2) {
+ return l("error");
+ }
+ if(decode)
+ return new TextDecoder().decode(pcba_id.buffer.slice(4, 4+length));
+
+ return buf2hex(pcba_id.buffer.slice(4, 4+length));
+ }
+
+ async calibrateSticksBegin() {
+ la("vr2_calibrate_sticks_begin");
+ try {
+ // Begin
+ await this.sendFeatureReport(0x82, [1,1,1]);
+
+ // Assert
+ const data = await this.receiveFeatureReport(0x83);
+ if(data.getUint32(0, false) != 0x83010101) {
+ const d1 = dec2hex32(data.getUint32(0, false));
+ la("vr2_calibrate_sticks_begin_failed", {"d1": d1});
+ throw new Error(`Stick center calibration begin failed: ${d1}`);
+ }
+ return { ok: true };
+ } catch(error) {
+ la("vr2_calibrate_sticks_begin_failed", {"r": error});
+ return { ok: false, error };
+ }
+ }
+
+ async calibrateSticksSample() {
+ la("vr2_calibrate_sticks_sample");
+ try {
+ // Sample
+ await this.sendFeatureReport(0x82, [3,1,1]);
+
+ // Assert
+ const data = await this.receiveFeatureReport(0x83);
+ if(data.getUint32(0, false) != 0x83010101) {
+ const d1 = dec2hex32(data.getUint32(0, false));
+ la("vr2_calibrate_sticks_sample_failed", {"d1": d1});
+ throw new Error(`Stick center calibration sample failed: ${d1}`);
+ }
+ return { ok: true };
+ } catch(error) {
+ la("vr2_calibrate_sticks_sample_failed", {"r": error});
+ return { ok: false, error };
+ }
+ }
+
+ async calibrateSticksEnd() {
+ la("vr2_calibrate_sticks_end");
+ try {
+ // Write
+ await this.sendFeatureReport(0x82, [2,1,1]);
+
+ const data = await this.receiveFeatureReport(0x83);
+
+ if(data.getUint32(0, false) != 0x83010102) {
+ const d1 = dec2hex32(data.getUint32(0, false));
+ la("vr2_calibrate_sticks_failed", {"s": 3, "d1": d1});
+ throw new Error(`Stick center calibration end failed: ${d1}`);
+ }
+
+ return { ok: true };
+ } catch(error) {
+ la("vr2_calibrate_sticks_end_failed", {"r": error});
+ return { ok: false, error };
+ }
+ }
+
+ async calibrateRangeBegin() {
+ la("vr2_calibrate_range_begin");
+ try {
+ // Begin
+ await this.sendFeatureReport(0x82, [1,1,2]);
+
+ // Assert
+ const data = await this.receiveFeatureReport(0x83);
+ if(data.getUint32(0, false) != 0x83010201) {
+ const d1 = dec2hex32(data.getUint32(0, false));
+ la("vr2_calibrate_range_begin_failed", {"d1": d1});
+ throw new Error(`Stick range calibration begin failed: ${d1}`);
+ }
+ return { ok: true };
+ } catch(error) {
+ la("vr2_calibrate_range_begin_failed", {"r": error});
+ return { ok: false, error };
+ }
+ }
+
+ async calibrateRangeEnd() {
+ la("vr2_calibrate_range_end");
+ try {
+ // Write
+ await this.sendFeatureReport(0x82, [2,1,2]);
+
+ // Assert
+ const data = await this.receiveFeatureReport(0x83);
+
+ if(data.getUint32(0, false) != 0x83010202) {
+ const d1 = dec2hex32(data.getUint32(0, false));
+ la("vr2_calibrate_range_end_failed", {"d1": d1});
+ throw new Error(`Stick range calibration end failed: ${d1}`);
+ }
+
+ return { ok: true };
+ } catch(error) {
+ la("vr2_calibrate_range_end_failed", {"r": error});
+ return { ok: false, error };
+ }
+ }
+
+ async queryNvStatus() {
+ try {
+ await this.sendFeatureReport(0x80, [3,3]);
+ const data = await this.receiveFeatureReport(0x81);
+ const ret = data.getUint32(1, false);
+ if (ret === 0x15010100) {
+ return { device: 'ds5', status: 'pending_reboot', locked: null, code: 4, raw: ret };
+ }
+ if (ret === 0x03030201) {
+ return { device: 'ds5', status: 'locked', locked: true, mode: 'temporary', code: 1, raw: ret };
+ }
+ if (ret === 0x03030200) {
+ return { device: 'ds5', status: 'unlocked', locked: false, mode: 'permanent', code: 0, raw: ret };
+ }
+ if (ret === 1 || ret === 2) {
+ return { device: 'ds5', status: 'unknown', locked: null, code: 2, raw: ret };
+ }
+ return { device: 'ds5', status: 'unknown', locked: null, code: ret, raw: ret };
+ } catch (error) {
+ return { device: 'ds5', status: 'error', locked: null, code: 2, error };
+ }
+ }
+
+ hwToBoardModel(hw_ver) {
+ return l("Unknown");
+ }
+
+ async getInMemoryModuleData() {
+ // DualSense
+ await this.sendFeatureReport(0x80, [12, 2]);
+ await sleep(100);
+ const data = await this.receiveFeatureReport(0x81);
+ const cmd = data.getUint8(0, true);
+ const [p1, p2, p3] = [1, 2, 3].map(i => data.getUint8(i, true));
+
+ if(cmd != 129 || p1 != 12 || (p2 != 2 && p2 != 4) || p3 != 2)
+ return null;
+
+ return Array.from({ length: 12 }, (_, i) => data.getUint16(4 + i * 2, true));
+ }
+
+ async writeFinetuneData(data) {
+ const pkg = data.reduce((acc, val) => acc.concat([val & 0xff, val >> 8]), [12, 1]);
+ await this.sendFeatureReport(0x80, pkg);
+ }
+
+ /**
+ * Send output report to the DS5 controller
+ * @param {ArrayBuffer} data - The output report data
+ */
+ async sendOutputReport(data, reason = "") {
+ if (!this.device?.opened) {
+ throw new Error('Device is not opened');
+ }
+ try {
+ console.log(`Sending output report${ reason ? ` to ${reason}` : '' }:`, DS5_OUTPUT_REPORT.USB_REPORT_ID, buf2hex(data));
+ await this.device.sendReport(DS5_OUTPUT_REPORT.USB_REPORT_ID, new Uint8Array(data));
+ } catch (error) {
+ throw new Error(`Failed to send output report: ${error.message}`);
+ }
+ }
+
+ /**
+ * Update the current output state with values from an OutputStruct
+ * @param {DS5OutputStruct} outputStruct - The output structure to copy state from
+ */
+ updateCurrentOutputState(outputStruct) {
+ this.currentOutputState = { ...outputStruct };
+ }
+
+ /**
+ * Get a copy of the current output state
+ * @returns {Object} A copy of the current output state
+ */
+ getCurrentOutputState() {
+ return { ...this.currentOutputState };
+ }
+
+ /**
+ * Initialize the current output state when the controller is first connected.
+ * Since DS5 controllers don't provide a way to read the current output state,
+ * this method sets up reasonable defaults and attempts to detect any current settings.
+ */
+ async initializeCurrentOutputState() {
+ try {
+ // Reset all output state to known defaults
+ this.currentOutputState = {
+ ...this.getCurrentOutputState(),
+ validFlag1: 0b1111_0111,
+ ledCRed: 0,
+ ledCGreen: 0,
+ ledCBlue: 255,
+ };
+
+ // Send a "reset" output report to ensure the controller is in a known state
+ // This will turn off any existing effects and set the controller to defaults
+ const resetOutputStruct = new DS5OutputStruct(this.currentOutputState);
+ await this.sendOutputReport(resetOutputStruct.pack(), 'init default states');
+
+ // Update our state to reflect what we just sent
+ this.updateCurrentOutputState(resetOutputStruct);
+ } catch (error) {
+ console.warn("Failed to initialize DS5 output state:", error);
+ // Even if the reset fails, we still have the default state initialized
+ }
+ }
+
+ /**
+ * Parse DS5 battery status from input data
+ */
+ parseBatteryStatus(data) {
+ const bat = data.getUint8(52); // DS5 battery byte is at position 52
+
+ // DS5: bat_charge = low 4 bits, bat_status = high 4 bits
+ const bat_charge = bat & 0x0f;
+ const bat_status = bat >> 4;
+
+ let bat_capacity = 0;
+ let cable_connected = false;
+ let is_charging = false;
+ let is_error = false;
+
+ switch (bat_status) {
+ case 0:
+ // On battery power
+ bat_capacity = Math.min(bat_charge * 10 + 5, 100);
+ break;
+ case 1:
+ // Charging
+ bat_capacity = Math.min(bat_charge * 10 + 5, 100);
+ is_charging = true;
+ cable_connected = true;
+ break;
+ case 2:
+ // Fully charged
+ bat_capacity = 100;
+ cable_connected = true;
+ break;
+ case 15:
+ // Battery is flat
+ bat_capacity = 0;
+ is_charging = true;
+ cable_connected = true;
+ break;
+ default:
+ // Error state
+ is_error = true;
+ break;
+ }
+
+ return { bat_capacity, cable_connected, is_charging, is_error };
+ }
+
+ getNumberOfSticks() {
+ return 1;
+ }
+}
+
+export default VR2Controller;
diff --git a/js/core.js b/js/core.js
index 616fc10..50cf159 100644
--- a/js/core.js
+++ b/js/core.js
@@ -267,6 +267,17 @@ async function continue_connection({data, device}) {
const model = controllerInstance.getModel();
+ const numOfSticks = controllerInstance.getNumberOfSticks();
+ if(numOfSticks == 2) {
+ $("#stick-item-rx").show();
+ $("#stick-item-ry").show();
+ } else if(numOfSticks == 1) {
+ $("#stick-item-rx").hide();
+ $("#stick-item-ry").hide();
+ } else {
+ throw new Error(`Invalid number of sticks: ${numOfSticks}`);
+ }
+
// Initialize SVG controller based on model
await init_svg_controller(model);
@@ -304,6 +315,10 @@ async function continue_connection({data, device}) {
if(model == "DS5_Edge") {
show_edge_modal();
}
+
+ if(model == "VR2") {
+ show_popup(l("Support for PS VR2 controllers is minimal and highly experimental.
I currently don't own these controllers, so I cannot verify the calibration process myself.
If you'd like to help improve full support, you can contribute with a donation or even send the controllers for testing.
Feel free to contact me on Discord (the_al) or by email at ds4@the.al .
Thank you for your support!
"), true)
+ }
} catch(err) {
await disconnect();
throw err;
@@ -406,6 +421,10 @@ async function init_svg_controller(model) {
svgFileName = 'dualshock-controller.svg';
} else if (model === 'DS5' || model === 'DS5_Edge') {
svgFileName = 'dualsense-controller.svg';
+ } else if (model === 'VR2') {
+ // Disable SVG controller for VR2
+ svgContainer.innerHTML = '';
+ return;
} else {
throw new Error(`Unknown controller model: ${model}`);
}
@@ -481,35 +500,42 @@ function reset_circularity_mode() {
function refresh_stick_pos() {
if(!controller) return;
+ const hasSingleStick = (controller.currentController?.getNumberOfSticks() == 1);
+
const c = document.getElementById("stickCanvas");
const ctx = c.getContext("2d");
const sz = 60;
- const hb = 20 + sz;
const yb = 15 + sz;
const w = c.width;
+ const hb = hasSingleStick ? w / 2 : 20 + sz;
ctx.clearRect(0, 0, c.width, c.height);
const { left: { x: plx, y: ply }, right: { x: prx, y: pry } } = controller.button_states.sticks;
const enable_zoom_center = center_zoom_checked();
const enable_circ_test = circ_checked();
+
// Draw left stick
draw_stick_position(ctx, hb, yb, sz, plx, ply, {
circularity_data: enable_circ_test ? ll_data : null,
enable_zoom_center,
});
- // Draw right stick
- draw_stick_position(ctx, w-hb, yb, sz, prx, pry, {
- circularity_data: enable_circ_test ? rr_data : null,
- enable_zoom_center,
- });
+ if(!hasSingleStick) {
+ // Draw right stick
+ draw_stick_position(ctx, w-hb, yb, sz, prx, pry, {
+ circularity_data: enable_circ_test ? rr_data : null,
+ enable_zoom_center,
+ });
+ }
const precision = enable_zoom_center ? 3 : 2;
$("#lx-lbl").text(float_to_str(plx, precision));
$("#ly-lbl").text(float_to_str(ply, precision));
- $("#rx-lbl").text(float_to_str(prx, precision));
- $("#ry-lbl").text(float_to_str(pry, precision));
+ if(!hasSingleStick) {
+ $("#rx-lbl").text(float_to_str(prx, precision));
+ $("#ry-lbl").text(float_to_str(pry, precision));
+ }
// Move L3 and R3 SVG elements according to stick position
try {
@@ -1094,4 +1120,4 @@ window.show_quick_test_modal = () => {
};
// Auto-initialize the application when the module loads
-gboot();
\ No newline at end of file
+gboot();
diff --git a/js/modals/calib-range-modal.js b/js/modals/calib-range-modal.js
index 71a4288..13aaa2d 100644
--- a/js/modals/calib-range-modal.js
+++ b/js/modals/calib-range-modal.js
@@ -36,6 +36,8 @@ export class CalibRangeModal {
this.allDonePromiseResolve = undefined;
this.doneCallback = doneCallback;
+
+ this.hasSingleStick = (this.controller.currentController.getNumberOfSticks() == 1);
}
async open() {
@@ -107,7 +109,8 @@ export class CalibRangeModal {
// Every second, update countdown
this.countdownInterval = setInterval(() => {
this.countdownSeconds--;
- if (this.countdownSeconds <= 0 || this.leftCycleProgress + this.rightCycleProgress >= 100) {
+ // If there is only one stick, sum two times leftCycleProgress, so that it can reach 100.
+ if (this.countdownSeconds <= 0 || this.leftCycleProgress + (this.hasSingleStick ? this.leftCycleProgress : this.rightCycleProgress) >= 100) {
this.stopCountdown();
$('#range-calibration-alert').hide();
@@ -160,19 +163,29 @@ export class CalibRangeModal {
this.ll_data.fill(0);
}
- const rightNonZeroCount = this.rr_data.filter(v => v > JOYSTICK_EXTREME_THRESHOLD).length;
- const rightFillRatio = rightNonZeroCount / CIRCULARITY_DATA_SIZE;
- if (rightFillRatio >= CIRCLE_FILL_THRESHOLD) {
- this.rightFullCycles++;
- this.rr_data.fill(0);
+ if(this.hasSingleStick) {
+ // Update progress if counts changed
+ if (leftNonZeroCount !== this.leftNonZeroCount) {
+ this.leftNonZeroCount = leftNonZeroCount;
+ this.updateProgress();
+ }
+ } else {
+ const rightNonZeroCount = this.rr_data.filter(v => v > JOYSTICK_EXTREME_THRESHOLD).length;
+ const rightFillRatio = rightNonZeroCount / CIRCULARITY_DATA_SIZE;
+ if (rightFillRatio >= CIRCLE_FILL_THRESHOLD) {
+ this.rightFullCycles++;
+ this.rr_data.fill(0);
+ }
+
+ // Update progress if counts changed
+ if (leftNonZeroCount !== this.leftNonZeroCount || rightNonZeroCount !== this.rightNonZeroCount) {
+ this.leftNonZeroCount = leftNonZeroCount;
+ this.rightNonZeroCount = rightNonZeroCount;
+ this.updateProgress();
+ }
+
}
- // Update progress if counts changed
- if (leftNonZeroCount !== this.leftNonZeroCount || rightNonZeroCount !== this.rightNonZeroCount) {
- this.leftNonZeroCount = leftNonZeroCount;
- this.rightNonZeroCount = rightNonZeroCount;
- this.updateProgress();
- }
}
/**
@@ -191,8 +204,11 @@ export class CalibRangeModal {
const rightCurrentProgress = (this.rightNonZeroCount / CIRCULARITY_DATA_SIZE) * (50 / this.requiredFullCycles);
const totalProgress = Math.round(
- Math.min(50, leftCycleProgress + leftCurrentProgress) +
- Math.min(50, rightCycleProgress + rightCurrentProgress)
+ this.hasSingleStick ?
+ Math.min(100, 2*(leftCycleProgress + leftCurrentProgress)) : (
+ Math.min(50, leftCycleProgress + leftCurrentProgress) +
+ Math.min(50, rightCycleProgress + rightCurrentProgress)
+ )
);
const $progressBar = $('#range-progress-bar');
@@ -202,14 +218,19 @@ export class CalibRangeModal {
.css('width', `${totalProgress}%`)
.attr('aria-valuenow', totalProgress);
- $progressText.text(`${totalProgress}% (L:${this.leftFullCycles}/${this.requiredFullCycles}, R:${this.rightFullCycles}/${this.requiredFullCycles})`);
+ if(!this.hasSingleStick) {
+ $progressText.text(`${totalProgress}% (L:${this.leftFullCycles}/${this.requiredFullCycles}, R:${this.rightFullCycles}/${this.requiredFullCycles})`);
+ } else {
+ $progressText.text(`${totalProgress}% (L:${this.leftFullCycles}/${this.requiredFullCycles})`);
+ }
}
checkAndEnhanceAlert() {
const secondsElapsed = SECONDS_UNTIL_UNLOCK - this.countdownSeconds;
const alertIsVisible = $('#range-calibration-alert').is(":visible")
- const progressBelowThreshold = this.leftCycleProgress < 10 || this.rightCycleProgress < 10;
+ const progressBelowThreshold = this.leftCycleProgress < 10 || (this.hasSingleStick ? false : this.rightCycleProgress < 10);
+
if (secondsElapsed >= 5 && progressBelowThreshold && !alertIsVisible) {
$('#range-calibration-alert').show();
}
@@ -250,4 +271,4 @@ async function calibrate_range_on_close() {
}
// Expose functions to window for HTML onclick handlers
-window.calibrate_range_on_close = calibrate_range_on_close;
\ No newline at end of file
+window.calibrate_range_on_close = calibrate_range_on_close;