From b240d1d65c956713a97198d5d42ad16c313ca055 Mon Sep 17 00:00:00 2001 From: Mathias Malmqvist Date: Sun, 21 Sep 2025 01:20:35 +0200 Subject: [PATCH] Add adaptive trigger support to the DS5 --- index.html | 16 ++ js/controller-manager.js | 118 +++++++++ js/controllers/base-controller.js | 4 + js/controllers/ds5-controller.js | 374 ++++++++++++++++++++++++++++- js/core.js | 381 ++++++++++++++++++++++++++---- 5 files changed, 840 insertions(+), 53 deletions(-) diff --git a/index.html b/index.html index 056d432..4a2f1a7 100644 --- a/index.html +++ b/index.html @@ -150,6 +150,22 @@ + + + +
diff --git a/js/controller-manager.js b/js/controller-manager.js index 0573825..ccff057 100644 --- a/js/controller-manager.js +++ b/js/controller-manager.js @@ -293,6 +293,124 @@ class ControllerManager { } } + /** + * Disable left adaptive trigger effects (DS5 only) + * @returns {Promise} Result object with success status and message + */ + async disableLeftAdaptiveTrigger() { + if (!this.currentController) { + throw new Error(this.l("No controller connected")); + } + + // Check if the controller supports adaptive triggers (DS5 only) + if (this.getModel() !== "DS5") { + throw new Error(this.l("Adaptive triggers are only supported on DualSense controllers")); + } + + // Check if the controller has the disableLeftAdaptiveTrigger method + if (typeof this.currentController.disableLeftAdaptiveTrigger !== 'function') { + throw new Error(this.l("Controller does not support adaptive trigger control")); + } + + try { + const result = await this.currentController.disableLeftAdaptiveTrigger(); + return result; + } catch (error) { + throw new Error(this.l("Failed to disable adaptive trigger"), { cause: error }); + } + } + + /** + * Set left adaptive trigger with preset configurations (DS5 only) + * @param {string} preset - Preset name: 'light', 'medium', 'heavy', 'custom' + * @param {Object} customParams - Custom parameters for 'custom' preset {start, end, force} + * @returns {Promise} Result object with success status and message + */ + async setAdaptiveTriggerPreset({left, right}/* , customParams = {} */) { + const presets = { + 'off': { start: 0, end: 0, force: 0, mode: 'off' }, + 'light': { start: 10, end: 80, force: 150, mode: 'single'}, + 'medium': { start: 15, end: 100, force: 200, mode: 'single' }, + 'heavy': { start: 20, end: 120, force: 255, mode: 'single' }, + // 'custom': customParams + }; + + if (!presets[left] || !presets[right]) { + throw new Error(`Invalid preset. Available presets: light, medium, heavy, custom. Got "${left}" and "${right}".`); + } + + const leftPreset = presets[left]; + const rightPreset = presets[right]; + + // if (preset === 'custom') { + // // Validate custom parameters + // if (typeof start !== 'number' || typeof end !== 'number' || typeof force !== 'number') { + // throw new Error(this.l("Custom preset requires start, end, and force parameters")); + // } + // } + + return await this.currentController.setAdaptiveTrigger(leftPreset, rightPreset); + } + + /** + * Set vibration motors for haptic feedback (DS5 only) + * @param {number} leftMotor - Left motor intensity (0-255) + * @param {number} rightMotor - Right motor intensity (0-255) + * @param {number} duration - Duration in milliseconds (optional) + * @param {Function} doneCb - Callback function called when vibration ends (optional) + */ + async setVibration(leftMotor, rightMotor, duration = 0, doneCb = ({success}) => {}) { + try { + await this.currentController.setVibration(leftMotor, rightMotor); + + // If duration is specified, automatically turn off vibration after the duration + if (duration > 0) { + setTimeout(async () => { + if(!this.currentController) return doneCb({success: true}); + await this.currentController.setVibration(0, 0); // Turn off vibration + doneCb({success: true}); + }, duration); + } + } catch (error) { + if(duration) doneCb({ success: false}); + throw new Error(this.l("Failed to set vibration"), { cause: error }); + } + } + + /** + * Test speaker tone (DS5 only) + * @param {number} duration - Duration in milliseconds (optional) + * @param {Function} doneCb - Callback function called when tone ends (optional) + */ + async setSpeakerTone(duration = 1000, doneCb = ({success}) => {}) { + try { + if (!this.currentController.setSpeakerTone) { + throw new Error(this.l("Speaker tone not supported on this controller")); + } + + await this.currentController.setSpeakerTone(); + + // If duration is specified, automatically reset speaker after the duration + if (duration > 0) { + setTimeout(async () => { + if(!this.currentController) return doneCb({success: true}); + // Reset speaker settings to default by calling setSpeakerTone with reset parameters + try { + if (this.currentController.resetSpeakerSettings) { + await this.currentController.resetSpeakerSettings(); + } + } catch (resetError) { + console.warn("Failed to reset speaker settings:", resetError); + } + doneCb({success: true}); + }, duration); + } + } catch (error) { + if(duration) doneCb({ success: false}); + throw new Error(this.l("Failed to set speaker tone"), { cause: error }); + } + } + /** * Helper function to check if stick positions have changed */ diff --git a/js/controllers/base-controller.js b/js/controllers/base-controller.js index 7b7e31a..a1babaa 100644 --- a/js/controllers/base-controller.js +++ b/js/controllers/base-controller.js @@ -143,6 +143,10 @@ class BaseController { parseBatteryStatus(data) { throw new Error('parseBatteryStatus() must be implemented by subclass'); } + + setAdaptiveTrigger(left, right) { + throw new Error('setAdaptiveTriggerSingleMode() must be implemented by subclass'); + } } export default BaseController; diff --git a/js/controllers/ds5-controller.js b/js/controllers/ds5-controller.js index dbab6c8..3b53d10 100644 --- a/js/controllers/ds5-controller.js +++ b/js/controllers/ds5-controller.js @@ -45,6 +45,154 @@ const DS5_INPUT_CONFIG = { 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; + } +} + function ds5_color(x) { const colorMap = { '00': 'White', @@ -81,6 +229,37 @@ class DS5Controller extends BaseController { super(device, uiDependencies); this.model = "DS5"; 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() { @@ -355,19 +534,12 @@ class DS5Controller extends BaseController { hwToBoardModel(hw_ver) { const a = (hw_ver >> 8) & 0xff; - if(a == 0x03) { - return "BDM-010"; - } else if(a == 0x04) { - return "BDM-020"; - } else if(a == 0x05) { - return "BDM-030"; - } else if(a == 0x06) { - return "BDM-040"; - } else if(a == 0x07 || a == 0x08) { - return "BDM-050"; - } else { - return this.l("Unknown"); - } + if(a == 0x03) return "BDM-010"; + if(a == 0x04) return "BDM-020"; + if(a == 0x05) return "BDM-030"; + if(a == 0x06) return "BDM-040"; + if(a == 0x07 || a == 0x08) return "BDM-050"; + return this.l("Unknown"); } async getInMemoryModuleData() { @@ -389,6 +561,182 @@ class DS5Controller extends BaseController { await this.sendFeatureReport(0x80, pkg); } + /** + * Send output report to the DS5 controller + * @param {ArrayBuffer} data - The output report data + */ + async sendOutputReport(data, reason = "") { + 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 + } + } + + /** + * Set left adaptive trigger to single-trigger mode + */ + async setAdaptiveTrigger(left, right) { + try { + const modeMap = { + 'off': DS5_TRIGGER_EFFECT_MODE.OFF, + 'single': DS5_TRIGGER_EFFECT_MODE.TRIGGER, + 'auto': DS5_TRIGGER_EFFECT_MODE.AUTO_TRIGGER, + 'resistance': DS5_TRIGGER_EFFECT_MODE.RESISTANCE, + } + + // Create output structure with current controller state + const { validFlag0 } = this.currentOutputState; + const outputStruct = new DS5OutputStruct({ + ...this.currentOutputState, + adaptiveTriggerLeftMode: modeMap[left.mode], + adaptiveTriggerLeftParam0: left.start, + adaptiveTriggerLeftParam1: left.end, + adaptiveTriggerLeftParam2: left.force, + + adaptiveTriggerRightMode: modeMap[right.mode], + adaptiveTriggerRightParam0: right.start, + adaptiveTriggerRightParam1: right.end, + adaptiveTriggerRightParam2: right.force, + + validFlag0: validFlag0 | DS5_VALID_FLAG0.LEFT_TRIGGER | DS5_VALID_FLAG0.RIGHT_TRIGGER, + }); + await this.sendOutputReport(outputStruct.pack(), 'set adaptive trigger mode'); + outputStruct.validFlag0 &= ~(DS5_VALID_FLAG0.LEFT_TRIGGER | DS5_VALID_FLAG0.RIGHT_TRIGGER); + + // Update current state to reflect the changes + this.updateCurrentOutputState(outputStruct); + + return { success: true }; + } catch (error) { + throw new Error("Failed to set left adaptive trigger mode", { cause: error }); + } + } + + /** + * Set vibration motors for haptic feedback + * @param {number} leftMotor - Left motor intensity (0-255) + * @param {number} rightMotor - Right motor intensity (0-255) + */ + async setVibration(leftMotor = 0, rightMotor = 0) { + try { + const { validFlag0 } = this.currentOutputState; + const outputStruct = new DS5OutputStruct({ + ...this.currentOutputState, + bcVibrationLeft: Math.max(0, Math.min(255, leftMotor)), + bcVibrationRight: Math.max(0, Math.min(255, rightMotor)), + validFlag0: validFlag0 | DS5_VALID_FLAG0.LEFT_VIBRATION | DS5_VALID_FLAG0.RIGHT_VIBRATION, // Update both vibration motors + }); + await this.sendOutputReport(outputStruct.pack(), 'set vibration'); + outputStruct.validFlag0 &= ~(DS5_VALID_FLAG0.LEFT_VIBRATION | DS5_VALID_FLAG0.RIGHT_VIBRATION); + + // Update current state to reflect the changes + this.updateCurrentOutputState(outputStruct); + } catch (error) { + throw new Error("Failed to set vibration", { cause: error }); + } + } + + /** + * Test speaker tone by controlling speaker volume and audio settings + * This creates a brief audio feedback through the controller's speaker + */ + async setSpeakerTone() { + try { + const { validFlag0 } = this.currentOutputState; + const outputStruct = new DS5OutputStruct({ + ...this.currentOutputState, + speakerVolume: 85, + validFlag0: validFlag0 | DS5_VALID_FLAG0.SPEAKER_VOLUME | DS5_VALID_FLAG0.AUDIO_CONTROL, + }); + await this.sendOutputReport(outputStruct.pack(), 'play speaker tone'); + outputStruct.validFlag0 &= ~(DS5_VALID_FLAG0.SPEAKER_VOLUME | DS5_VALID_FLAG0.AUDIO_CONTROL); + + // Send feature reports to enable speaker audio + // Audio configuration command + await this.sendFeatureReport(128, [6, 4, 0, 0, 8]); + + // Enable speaker tone + await this.sendFeatureReport(128, [6, 2, 1, 1, 0]); + + // Update current state to reflect the changes + this.updateCurrentOutputState(outputStruct); + } catch (error) { + throw new Error("Failed to set speaker tone", { cause: error }); + } + } + + /** + * Reset speaker settings to default (turn off speaker) + */ + async resetSpeakerSettings() { + try { + // Disable speaker tone first via feature report + await this.sendFeatureReport(128, [6, 2, 0, 1, 0]); + + const { validFlag0 } = this.currentOutputState; + const outputStruct = new DS5OutputStruct({ + ...this.currentOutputState, + speakerVolume: 0, + validFlag0: validFlag0 | DS5_VALID_FLAG0.SPEAKER_VOLUME | DS5_VALID_FLAG0.AUDIO_CONTROL, + }); + // outputStruct.audioControl = 0x00; + await this.sendOutputReport(outputStruct.pack(), 'stop speaker tone'); + outputStruct.validFlag0 &= ~(DS5_VALID_FLAG0.SPEAKER_VOLUME | DS5_VALID_FLAG0.AUDIO_CONTROL); + + // Update current state to reflect the changes + this.updateCurrentOutputState(outputStruct); + } catch (error) { + throw new Error("Failed to reset speaker settings", { cause: error }); + } + } + /** * Parse DS5 battery status from input data */ diff --git a/js/core.js b/js/core.js index 57b194f..abf0823 100644 --- a/js/core.js +++ b/js/core.js @@ -214,6 +214,11 @@ async function continue_connection({data, device}) { controller.setControllerInstance(controllerInstance); info = await controllerInstance.getInfo(); + + // Initialize output state for DS5 controllers + if (controllerInstance.initializeCurrentOutputState) { + await controllerInstance.initializeCurrentOutputState(); + } } catch (error) { const contextMessage = device ? l("Connected invalid device: ") + dec2hex(device.vendorId) + ":" + dec2hex(device.productId) @@ -243,6 +248,11 @@ async function continue_connection({data, device}) { $("#mainmenu").show(); $("#resetBtn").show(); + updateAdaptiveTriggerButtonVisibility(); + updateHapticFeedbackButtonVisibility(); + updateSpeakerToneButtonVisibility(); + updateMicrophoneTestButtonVisibility(); + $("#d-nvstatus").text = l("Unknown"); $("#d-bdaddr").text = l("Unknown"); @@ -309,6 +319,11 @@ async function disconnect() { $("#offlinebar").show(); $("#onlinebar").hide(); $("#mainmenu").hide(); + + updateAdaptiveTriggerButtonVisibility(); + updateHapticFeedbackButtonVisibility(); + updateSpeakerToneButtonVisibility(); + updateMicrophoneTestButtonVisibility(); } // Wrapper function for HTML onclick handlers @@ -381,7 +396,7 @@ async function init_svg_controller() { const svgContainer = document.getElementById('controller-svg-placeholder'); let svgContent; - + // Check if we have bundled assets (production mode) if (window.BUNDLED_ASSETS && window.BUNDLED_ASSETS.svg && window.BUNDLED_ASSETS.svg['dualshock-controller.svg']) { svgContent = window.BUNDLED_ASSETS.svg['dualshock-controller.svg']; @@ -393,7 +408,7 @@ async function init_svg_controller() { } svgContent = await response.text(); } - + svgContainer.innerHTML = svgContent; const lightBlue = '#7ecbff'; @@ -932,66 +947,348 @@ let alertCounter = 0; * @returns {string} - The ID of the created alert element */ function pushAlert(message, type = 'info', duration = 0, dismissible = true) { - const alertContainer = document.getElementById('alert-container'); - if (!alertContainer) { - console.error('Alert container not found'); - return null; - } + const alertContainer = document.getElementById('alert-container'); + if (!alertContainer) { + console.error('Alert container not found'); + return null; + } - const alertId = `alert-${++alertCounter}`; - const alertDiv = document.createElement('div'); - alertDiv.id = alertId; - alertDiv.className = `alert alert-${type} alert-dismissible fade show`; - alertDiv.setAttribute('role', 'alert'); - alertDiv.innerHTML = ` - ${message} - ${dismissible ? '' : ''} - `; + const alertId = `alert-${++alertCounter}`; + const alertDiv = document.createElement('div'); + alertDiv.id = alertId; + alertDiv.className = `alert alert-${type} alert-dismissible fade show`; + alertDiv.setAttribute('role', 'alert'); + alertDiv.innerHTML = ` + ${message} + ${dismissible ? '' : ''} + `; - alertContainer.appendChild(alertDiv); + alertContainer.appendChild(alertDiv); - if (duration > 0) { - setTimeout(() => { - dismissAlert(alertId); - }, duration); - } + if (duration > 0) { + setTimeout(() => { + dismissAlert(alertId); + }, duration); + } - return alertId; + return alertId; } function dismissAlert(alertId) { - const alertElement = document.getElementById(alertId); - if (alertElement) { - const bsAlert = new bootstrap.Alert(alertElement); - bsAlert.close(); - } + const alertElement = document.getElementById(alertId); + if (alertElement) { + const bsAlert = new bootstrap.Alert(alertElement); + bsAlert.close(); + } } function clearAllAlerts() { - const alertContainer = document.getElementById('alert-container'); - if (alertContainer) { - const alerts = alertContainer.querySelectorAll('.alert'); - alerts.forEach(alert => { - const bsAlert = new bootstrap.Alert(alert); - bsAlert.close(); - }); - } + const alertContainer = document.getElementById('alert-container'); + if (alertContainer) { + const alerts = alertContainer.querySelectorAll('.alert'); + alerts.forEach(alert => { + const bsAlert = new bootstrap.Alert(alert); + bsAlert.close(); + }); + } } function successAlert(message, duration = 1_500) { - return pushAlert(message, 'success', duration, false); + return pushAlert(message, 'success', duration, false); } function errorAlert(message, duration = 15_000) { - return pushAlert(message, 'danger', /* duration */); + return pushAlert(message, 'danger', /* duration */); } function warningAlert(message, duration = 8_000) { - return pushAlert(message, 'warning', duration); + return pushAlert(message, 'warning', duration); } function infoAlert(message, duration = 5_000) { - return pushAlert(message, 'info', duration, false); + return pushAlert(message, 'info', duration, false); +} + +// Adaptive trigger state management +let adaptiveTriggerEnabled = false; + +/** + * Toggle the left adaptive trigger on/off + */ +async function toggle_adaptive_trigger() { + const button = document.getElementById('adaptive-trigger-btn'); + const icon = document.getElementById('adaptive-trigger-icon'); + const text = document.getElementById('adaptive-trigger-text'); + + // Disable button during operation + button.disabled = true; + + try { + if (adaptiveTriggerEnabled) { + await controller.setAdaptiveTriggerPreset({ left: 'off', right: 'off'}); + adaptiveTriggerEnabled = false; + + button.className = 'btn btn-info ds-btn ds-i18n'; + icon.className = 'fas fa-hand-pointer'; + text.textContent = l("Enable Adaptive Trigger"); + + successAlert(l("Adaptive trigger disabled")); + } else { + await controller.setAdaptiveTriggerPreset({ left: 'heavy', right: 'heavy' }); + adaptiveTriggerEnabled = true; + + button.className = 'btn btn-warning ds-btn ds-i18n'; + icon.className = 'fas fa-hand-paper'; + text.textContent = l("Disable Adaptive Trigger"); + + successAlert(l("Adaptive trigger enabled")); + } + } + finally { + button.disabled = false; + } +} + +/** + * Update adaptive trigger button visibility based on controller type + */ +function updateAdaptiveTriggerButtonVisibility() { + const button = document.getElementById('adaptive-trigger-btn'); + if (controller?.isConnected() && controller.getModel() === "DS5") { + button.style.display = 'block'; + } else { + button.style.display = 'none'; + adaptiveTriggerEnabled = false; + } +} + +/** + * Test haptic feedback vibration for 3 seconds + */ +async function test_haptic_feedback() { + const button = document.getElementById('haptic-feedback-btn'); + const icon = document.getElementById('haptic-feedback-icon'); + const text = document.getElementById('haptic-feedback-text'); + + button.disabled = true; + + // Update UI to show vibration is active + button.className = 'btn btn-warning ds-btn ds-i18n'; + icon.className = 'fas fa-mobile-alt fa-shake'; + text.textContent = l("Vibrating..."); + + // Set vibration for 3 seconds (medium intensity on both motors) + await controller.setVibration(255, 255, 3000, ({success}) => { + button.className = 'btn btn-secondary ds-btn ds-i18n'; + icon.className = 'fas fa-mobile-alt'; + text.textContent = l("Test Haptic Feedback"); + button.disabled = false; + }); +} + +/** + * Test speaker tone by playing a 1-second tone through the controller's speaker + */ +async function test_speaker_tone() { + const button = document.getElementById('speaker-tone-btn'); + const icon = document.getElementById('speaker-tone-icon'); + const text = document.getElementById('speaker-tone-text'); + + button.disabled = true; + + // Update UI to show tone is playing + button.className = 'btn btn-warning ds-btn ds-i18n'; + icon.className = 'fas fa-volume-up fa-bounce'; + text.textContent = l("Playing tone..."); + + // Set speaker tone for 1 second + await controller.setSpeakerTone(100, ({success}) => { + button.className = 'btn btn-secondary ds-btn ds-i18n'; + icon.className = 'fas fa-volume-up'; + text.textContent = l("Test Speaker Tone"); + button.disabled = false; + }); +} + +/** + * Test microphone by monitoring audio input levels and asking user to blow into it + */ +async function test_microphone() { + const button = document.getElementById('microphone-test-btn'); + const icon = document.getElementById('microphone-test-icon'); + const text = document.getElementById('microphone-test-text'); + + button.disabled = true; + + try { + // Update UI to show microphone test is starting + button.className = 'btn btn-info ds-btn ds-i18n'; + icon.className = 'fas fa-microphone fa-pulse'; + text.textContent = l("Starting microphone test..."); + + // Request microphone access + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false + } + }); + + // Create audio context and analyzer + const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + const source = audioContext.createMediaStreamSource(stream); + const analyzer = audioContext.createAnalyser(); + + analyzer.fftSize = 256; + analyzer.smoothingTimeConstant = 0.8; + source.connect(analyzer); + + const bufferLength = analyzer.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + + // Update UI to show listening state + button.className = 'btn btn-success ds-btn ds-i18n'; + icon.className = 'fas fa-microphone fa-pulse'; + text.textContent = l("Blow into the microphone..."); + + let detectionCount = 0; + let isDetecting = false; + const testDuration = 10000; // 10 seconds + const startTime = Date.now(); + + const checkAudioLevel = () => { + if (Date.now() - startTime > testDuration) { + // Test timeout + cleanup(); + button.className = 'btn btn-outline-secondary ds-btn ds-i18n'; + icon.className = 'fas fa-microphone'; + text.textContent = l("Test completed - Try again"); + button.disabled = false; + return; + } + + analyzer.getByteFrequencyData(dataArray); + + // Calculate average volume level + let sum = 0; + for (let i = 0; i < bufferLength; i++) { + sum += dataArray[i]; + } + const average = sum / bufferLength; + + // Detect significant audio input (blowing into mic) + const threshold = 30; // Adjust this value as needed + if (average > threshold) { + if (!isDetecting) { + isDetecting = true; + detectionCount++; + + // Update UI to show detection + button.className = 'btn btn-warning ds-btn ds-i18n'; + icon.className = 'fas fa-microphone fa-bounce'; + text.textContent = l("Microphone input detected!") + ` (${detectionCount})`; + + // Provide haptic feedback if available + if (controller?.isConnected() && controller.getModel() === "DS5") { + controller.setVibration(100, 100, 200); + } + } + } else { + if (isDetecting) { + isDetecting = false; + // Return to listening state + button.className = 'btn btn-success ds-btn ds-i18n'; + icon.className = 'fas fa-microphone fa-pulse'; + text.textContent = l("Blow into the microphone..."); + } + } + + // Continue monitoring + requestAnimationFrame(checkAudioLevel); + }; + + const cleanup = () => { + // Stop all tracks + stream.getTracks().forEach(track => track.stop()); + // Close audio context + if (audioContext.state !== 'closed') { + audioContext.close(); + } + }; + + // Start monitoring + checkAudioLevel(); + + // Auto-stop after test duration + setTimeout(() => { + cleanup(); + if (detectionCount > 0) { + button.className = 'btn btn-success ds-btn ds-i18n'; + icon.className = 'fas fa-microphone fa-check'; + text.textContent = l("Microphone test passed!") + ` (${detectionCount} detections)`; + } else { + button.className = 'btn btn-outline-secondary ds-btn ds-i18n'; + icon.className = 'fas fa-microphone'; + text.textContent = l("No input detected - Try again"); + } + button.disabled = false; + }, testDuration); + + } catch (error) { + console.error('Microphone test error:', error); + + // Update UI to show error + button.className = 'btn btn-danger ds-btn ds-i18n'; + icon.className = 'fas fa-microphone-slash'; + + if (error.name === 'NotAllowedError') { + text.textContent = l("Microphone access denied"); + } else if (error.name === 'NotFoundError') { + text.textContent = l("No microphone found"); + } else { + text.textContent = l("Microphone test failed"); + } + + button.disabled = false; + + // Reset button after 3 seconds + setTimeout(() => { + button.className = 'btn btn-secondary ds-btn ds-i18n'; + icon.className = 'fas fa-microphone'; + text.textContent = l("Test Microphone"); + }, 3000); + } +} + +/** + * Update haptic feedback button visibility based on controller type + */ +function updateHapticFeedbackButtonVisibility() { + const button = document.getElementById('haptic-feedback-btn'); + const model = controller?.getModel(); + const supported = (controller?.isConnected() && model === "DS5"); + button.style.display = supported ? 'block' : 'none'; +} + +/** + * Update speaker tone button visibility based on controller type + */ +function updateSpeakerToneButtonVisibility() { + const button = document.getElementById('speaker-tone-btn'); + const model = controller?.getModel(); + const supported = (controller?.isConnected() && model === "DS5"); + button.style.display = supported ? 'block' : 'none'; +} + +/** + * Update microphone test button visibility based on controller type + */ +function updateMicrophoneTestButtonVisibility() { + const button = document.getElementById('microphone-test-btn'); + const model = controller?.getModel(); + const supported = (controller?.isConnected() && model === "DS5"); + button.style.display = supported ? 'block' : 'none'; } @@ -1014,6 +1311,10 @@ window.welcome_accepted = welcome_accepted; window.show_donate_modal = show_donate_modal; window.board_model_info = board_model_info; window.edge_color_info = edge_color_info; +window.toggle_adaptive_trigger = toggle_adaptive_trigger; +window.test_haptic_feedback = test_haptic_feedback; +window.test_speaker_tone = test_speaker_tone; +window.test_microphone = test_microphone; // Auto-initialize the application when the module loads gboot(); \ No newline at end of file