Repository: mikey0000/Mammotion-HA
Pull request #329: Add joystick function
Author: login: d3dfantasy99
Labels: name: enhancement
description: New feature or request
name: Review effort [1-5]: 4
Created: 2025-05-09T13:27:47Z | Merged: 2025-05-18T02:00:07Z

### **User description**
Add joystick function


___

### **Description**
- Introduced joystick functionality for controlling the mower's movement.
- Added service calls for moving forward, backward, left, and right with speed validation.
- Implemented a joystick UI with buttons for directional control and a disclaimer for safety.
- Enhanced the video streaming interface to support joystick interactions.


___



### **Changes walkthrough** 📝
<table><thead><tr><th></th><th align="left">Relevant files</th></tr></thead><tbody><tr><td><strong>Enhancement
</strong></td><td><table>
<tr>
  <td>
    <details>
      <summary><strong>camera.py</strong><dd><code>Implement Joystick Movement Controls for Mower</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

custom_components/mammotion/camera.py
<li>Added joystick control functions for moving the mower.<br> <li> Implemented speed validation for movement commands.<br> <li> Registered new service calls for joystick movements.<br>


</details>


  </td>
  <td><a href="https://github.com/mikey0000/Mammotion-HA/pull/329/files#diff-077e4162e67170d3d23c28f2f6461a4c8b4a18e49717b1d667de6056e84d7c22">+96/-0</a>&nbsp; &nbsp; </td>
</tr>

<tr>
  <td>
    <details>
      <summary><strong>agora-client.js</strong><dd><code>Add Joystick Control UI and Functionality</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

custom_components/mammotion/www/agora-client.js
<li>Introduced joystick overlay and button controls for movement.<br> <li> Added event listeners for joystick button interactions.<br> <li> Implemented disclaimer for movement commands.<br>


</details>


  </td>
  <td><a href="https://github.com/mikey0000/Mammotion-HA/pull/329/files#diff-a7569a1b2af96070285e8d71e3b1f60e960a010ee3645b5fea3031e0ff4b22bd">+512/-12</a></td>
</tr>
</table></td></tr></tr></tbody></table>

___

> 💡 **Penify usage**:
>Comment `/help` on the PR to get a list of all available Penify tools and their descriptions




Diff:
diff --git a/custom_components/mammotion/camera.py b/custom_components/mammotion/camera.py
index 9eeeef80..c347fdc8 100644
--- a/custom_components/mammotion/camera.py
+++ b/custom_components/mammotion/camera.py
@@ -187,6 +187,98 @@ async def handle_get_tokens(call: ServiceCall) -> ServiceResponse:
             # Return all the data needed for the Agora SDK
             return stream_data.data
         return {}
+    
+    async def handle_move_forward(call) -> None:
+        entity_id = call.data["entity_id"]
+    
+        # Check if speed parameter exists and validate it
+        speed = 0.4  # Default speed
+        if "speed" in call.data:
+            try:
+                speed_value = float(call.data["speed"])
+                if 0.1 <= speed_value <= 1:
+                    speed = speed_value
+                else:
+                    _LOGGER.warning(
+                        f"Invalid speed value for {entity_id}: {speed_value}. Must be between 0 and 1. Using default."
+                    )
+            except (ValueError, TypeError):
+                _LOGGER.warning(
+                    f"Invalid speed format for {entity_id}: {call.data['speed']}. Must be a number. Using default."
+                )
+        
+        mower: MammotionMowerData = _get_mower_by_entity_id(entity_id)
+        if mower:
+            await mower.reporting_coordinator.async_move_forward(speed=speed)
+    
+    async def handle_move_left(call) -> None:
+        entity_id = call.data["entity_id"]
+    
+        # Check if speed parameter exists and validate it
+        speed = 0.4  # Default speed
+        if "speed" in call.data:
+            try:
+                speed_value = float(call.data["speed"])
+                if 0.1 <= speed_value <= 1:
+                    speed = speed_value
+                else:
+                    _LOGGER.warning(
+                        f"Invalid speed value for {entity_id}: {speed_value}. Must be between 0 and 1. Using default."
+                    )
+            except (ValueError, TypeError):
+                _LOGGER.warning(
+                    f"Invalid speed format for {entity_id}: {call.data['speed']}. Must be a number. Using default."
+                )
+        
+        mower: MammotionMowerData = _get_mower_by_entity_id(entity_id)
+        if mower:
+            await mower.reporting_coordinator.async_move_left(speed=speed)
+
+    async def handle_move_right(call) -> None:
+        entity_id = call.data["entity_id"]
+    
+        # Check if speed parameter exists and validate it
+        speed = 0.4  # Default speed
+        if "speed" in call.data:
+            try:
+                speed_value = float(call.data["speed"])
+                if 0.1 <= speed_value <= 1:
+                    speed = speed_value
+                else:
+                    _LOGGER.warning(
+                        f"Invalid speed value for {entity_id}: {speed_value}. Must be between 0 and 1. Using default."
+                    )
+            except (ValueError, TypeError):
+                _LOGGER.warning(
+                    f"Invalid speed format for {entity_id}: {call.data['speed']}. Must be a number. Using default."
+                )
+        
+        mower: MammotionMowerData = _get_mower_by_entity_id(entity_id)
+        if mower:
+            await mower.reporting_coordinator.async_move_right(speed=speed)
+    
+    async def handle_move_backward(call) -> None:
+        entity_id = call.data["entity_id"]
+    
+        # Check if speed parameter exists and validate it
+        speed = 0.4  # Default speed
+        if "speed" in call.data:
+            try:
+                speed_value = float(call.data["speed"])
+                if 0.1 <= speed_value <= 1:
+                    speed = speed_value
+                else:
+                    _LOGGER.warning(
+                        f"Invalid speed value for {entity_id}: {speed_value}. Must be between 0 and 1. Using default."
+                    )
+            except (ValueError, TypeError):
+                _LOGGER.warning(
+                    f"Invalid speed format for {entity_id}: {call.data['speed']}. Must be a number. Using default."
+                )
+        
+        mower: MammotionMowerData = _get_mower_by_entity_id(entity_id)
+        if mower:
+            await mower.reporting_coordinator.async_move_back(speed=speed)
 
     hass.services.async_register("mammotion", "refresh_stream", handle_refresh_stream)
     hass.services.async_register("mammotion", "start_video", handle_start_video)
@@ -197,3 +289,7 @@ async def handle_get_tokens(call: ServiceCall) -> ServiceResponse:
         handle_get_tokens,
         supports_response=SupportsResponse.ONLY,
     )
+    hass.services.async_register("mammotion", "move_forward", handle_move_forward)
+    hass.services.async_register("mammotion", "move_left", handle_move_left)
+    hass.services.async_register("mammotion", "move_right", handle_move_right)
+    hass.services.async_register("mammotion", "move_backward", handle_move_backward)
\ No newline at end of file
diff --git a/custom_components/mammotion/www/agora-client.js b/custom_components/mammotion/www/agora-client.js
index b2a4b02a..cd4e18c7 100644
--- a/custom_components/mammotion/www/agora-client.js
+++ b/custom_components/mammotion/www/agora-client.js
@@ -15,6 +15,15 @@ class CameraAgoraCard extends HTMLElement {
     2: "Right Camera",
     3: "Rear Camera"
   };
+  
+  // Add joystick control properties
+  _disclaimerShown = false;
+  _moveIntervals = {
+    up: null,
+    down: null,
+    left: null,
+    right: null
+  };
 
   // Required method for Home Assistant cards
   setConfig(config) {
@@ -25,7 +34,9 @@ class CameraAgoraCard extends HTMLElement {
     //Define custom parameters
     this._config = {
       ...config,
-      autostart: config.autostart !== undefined ? config.autostart : false
+      autostart: config.autostart !== undefined ? config.autostart : false,
+      enableJoystick: config.enableJoystick !== undefined ? config.enableJoystick : false,
+      speed: config.speed !== undefined ? parseFloat(config.speed) : 0.4
     };
     
     // Prepare shadow DOM container
@@ -103,6 +114,85 @@ class CameraAgoraCard extends HTMLElement {
         user-select: none;
         box-shadow: 0 1px 3px rgba(0,0,0,0.3);
       }
+      /* Joystick CSS */
+      .joystick-overlay {
+        position: absolute;
+        top: 0;
+        left: 0;
+        width: 100%;
+        height: 100%;
+        z-index: 5;
+        pointer-events: none; /* Allows clicks to pass through to underlying elements */
+        opacity: 0; /* Initially hidden */
+        transition: opacity 0.3s;
+      }
+      .joystick-overlay.visible {
+        opacity: 1;
+      }
+      .joystick-button {
+        position: absolute;
+        width: 40px;
+        height: 40px;
+        background: rgba(60, 60, 60, 0.6);
+        border: none;
+        border-radius: 50%;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        cursor: pointer;
+        pointer-events: auto; /* Makes button clickable */
+        transition: background 0.2s;
+        box-shadow: none;
+      }
+      .joystick-button:active {
+        background: rgba(80, 80, 80, 0.7);
+      }
+      .joystick-up {
+        top: 20px;
+        left: 50%;
+        transform: translateX(-50%);
+      }
+      .joystick-down {
+        bottom: 20px;
+        left: 50%;
+        transform: translateX(-50%);
+      }
+      .joystick-left {
+        left: 20px;
+        top: 50%;
+        transform: translateY(-50%);
+      }
+      .joystick-right {
+        right: 20px;
+        top: 50%;
+        transform: translateY(-50%);
+      }
+      .disclaimer-overlay {
+        position: absolute;
+        top: 0;
+        left: 0;
+        width: 100%;
+        height: 100%;
+        background: rgba(0, 0, 0, 0.8);
+        z-index: 10;
+        display: flex;
+        flex-direction: column;
+        justify-content: center;
+        align-items: center;
+        color: white;
+        text-align: center;
+        box-sizing: border-box;
+        padding: 10px;
+      }
+      .disclaimer-button {
+        margin-top: 15px;
+        padding: 8px 16px;
+        background: #4CAF50;
+        color: white;
+        border: none;
+        border-radius: 4px;
+        cursor: pointer;
+      }
     `;
     
     const card = document.createElement('ha-card');
@@ -156,7 +246,12 @@ class CameraAgoraCard extends HTMLElement {
 
   // Standard configuration for the editor
   static getStubConfig() {
-    return { entity: "" };
+    return { 
+      entity: "",
+      autostart: false,
+      enableJoystick: false,
+      speed: 0.4 
+    };
   }
 
   // Initialize Agora streaming
@@ -178,6 +273,11 @@ class CameraAgoraCard extends HTMLElement {
         return;
       }
       
+      // Setup joystick overlay if enabled
+      if (this._config.enableJoystick) {
+        this._setupJoystickOverlay();
+      }
+      
       if(this._config.autostart)
       {
         // Start playback immediately
@@ -227,6 +327,11 @@ class CameraAgoraCard extends HTMLElement {
     this._isConnecting = true;
     this._showLoading("Connecting to video stream...");
     
+    // Ensure joystick overlay is setup if enabled
+    if (this._config.enableJoystick) {
+      this._setupJoystickOverlay();
+    }
+    
     try {
       const entityId = this._config.entity;
       const switchVideoButton = this.shadowRoot.getElementById('switch-video-button');
@@ -298,8 +403,15 @@ class CameraAgoraCard extends HTMLElement {
           this._isPlaying = false;
           this._hideLoading();
           this._showLoading("Connection lost. Click play to reconnect.");
+          this._toggleJoystickVisibility(false);
+          
+          // Stop any active movement commands when connection is lost
+          Object.keys(this._moveIntervals).forEach(dir => {
+            this._stopContinuousMove(dir);
+          });
         } else if (state === 'CONNECTING') {
           this._showLoading("Connecting...");
+          this._toggleJoystickVisibility(false);
         }
       });
 
@@ -323,6 +435,7 @@ class CameraAgoraCard extends HTMLElement {
 
           if (this._remoteUsers.length <= 0) {
             this._showLoading("Video stream end");
+            this._toggleJoystickVisibility(false);
             return;
           }
             
@@ -343,6 +456,7 @@ class CameraAgoraCard extends HTMLElement {
         if (!this._isPlaying) {
           this._showLoading("Connection timeout. Click play to retry.");
           this._isConnecting = false;
+          this._toggleJoystickVisibility(false);
         }
       }, 20000);
 
@@ -353,6 +467,11 @@ class CameraAgoraCard extends HTMLElement {
       this._isPlaying = true;
       this._isConnecting = false;
       
+      // Show joystick controls if enabled
+      if (this._config.enableJoystick) {
+        this._toggleJoystickVisibility(true);
+      }
+      
       // Set a reconnection check
       this._startConnectionCheck();
       
@@ -360,6 +479,7 @@ class CameraAgoraCard extends HTMLElement {
       console.error("Error starting video:", error);
       this._isConnecting = false;
       this._showLoading(`Connection error. Click play to retry.`);
+      this._toggleJoystickVisibility(false);
     }
   }
 
@@ -373,10 +493,32 @@ class CameraAgoraCard extends HTMLElement {
   _showCurrentVideo() {
     const videoContainer = this.shadowRoot.getElementById('agora-video');
     const loadingElement = this.shadowRoot.getElementById('loading');
+    const joystickOverlay = this.shadowRoot.getElementById('joystick-overlay');
+    
+    // Store joystick overlay before clearing container
+    if (joystickOverlay) {
+      videoContainer.removeChild(joystickOverlay);
+    }
+    
+    // Clear container (but keep loading overlay if it exists)
+    if (loadingElement) {
+      videoContainer.removeChild(loadingElement);
+    }
     
-    // Clear container (but keep loading overlay)
     videoContainer.innerHTML = '';
-    videoContainer.appendChild(loadingElement);
+    
+    // Restore overlays
+    if (loadingElement) {
+      videoContainer.appendChild(loadingElement);
+    }
+    
+    if (this._config.enableJoystick && joystickOverlay) {
+      videoContainer.appendChild(joystickOverlay);
+      this._toggleJoystickVisibility(this._isPlaying);
+    } else if (this._config.enableJoystick) {
+      // If joystick overlay was missing, recreate it
+      this._setupJoystickOverlay();
+    }
     
     // If there are users with video
     if (this._remoteUsers.length > 0) {
@@ -414,7 +556,8 @@ class CameraAgoraCard extends HTMLElement {
     }
   }
   
-  // Start periodic connection check
+  
+  // Add safety interval to check and stop any continuous movements if video is not playing
   _startConnectionCheck() {
     if (this._connectionCheckInterval) {
       clearInterval(this._connectionCheckInterval);
@@ -425,9 +568,20 @@ class CameraAgoraCard extends HTMLElement {
         const state = this._client.connectionState;
         if (state !== 'CONNECTED') {
           this._showLoading("Connection unstable...");
+          this._toggleJoystickVisibility(false);
+          
+          // Stop any movement when connection is unstable
+          Object.keys(this._moveIntervals).forEach(dir => {
+            this._stopContinuousMove(dir);
+          });
         }
+      } else {
+        // If not playing, ensure all movements are stopped
+        Object.keys(this._moveIntervals).forEach(dir => {
+          this._stopContinuousMove(dir);
+        });
       }
-    }, 5000);
+    }, 2000); // Check every 2 seconds
   }
   
   // Stop video
@@ -439,6 +593,11 @@ class CameraAgoraCard extends HTMLElement {
         clearInterval(this._connectionCheckInterval);
       }
       
+      // Stop all continuous movement commands
+      Object.keys(this._moveIntervals).forEach(dir => {
+        this._stopContinuousMove(dir);
+      });
+      
       // Clean up video tracks for each remote user before leaving
       if (this._remoteUsers.length > 0) {
         for (const user of this._remoteUsers) {
@@ -470,18 +629,36 @@ class CameraAgoraCard extends HTMLElement {
       this._isPlaying = false;
       this._isConnecting = false;
       
-      // Clean up video container
+      // Hide joystick controls
+      this._toggleJoystickVisibility(false);
+      
+      // Save references to important elements
       const videoContainer = this.shadowRoot.getElementById('agora-video');
       const loadingElement = this.shadowRoot.getElementById('loading');
-      if (videoContainer && loadingElement) {
-        videoContainer.innerHTML = '';
-        videoContainer.appendChild(loadingElement);
-      }
+      const joystickOverlay = this.shadowRoot.getElementById('joystick-overlay');
+      
+      // Create a temporary container for the elements we want to preserve
+      const tempContainer = document.createElement('div');
+      if (loadingElement) tempContainer.appendChild(loadingElement.cloneNode(true));
+      if (joystickOverlay) tempContainer.appendChild(joystickOverlay.cloneNode(true));
+      
+      // Clean up video container
+      videoContainer.innerHTML = '';
+      
+      // Restore the preserved elements
+      Array.from(tempContainer.children).forEach(el => {
+        videoContainer.appendChild(el);
+      });
       
       if (this._hass && this._config && this._config.entity) {
         await this._hass.callService('mammotion', 'stop_video', { entity_id: this._config.entity });
       }
       
+      // Reinitialize event listeners for joystick after DOM manipulation
+      if (this._config.enableJoystick) {
+        this._setupJoystickControls();
+      }
+      
       // Show video stopped message
       this._showLoading("Video stopped. Click play to start.");
     } catch (error) {
@@ -502,11 +679,57 @@ class CameraAgoraCard extends HTMLElement {
     }
   }
 
-  // Cleanup when card is removed
+  // Setup global event handlers for safety
+  constructor() {
+    super();
+    
+    // Add global event handlers to stop movements if mouseup/touchend happens outside the component
+    this._boundGlobalMouseUp = this._globalMouseUp.bind(this);
+    this._boundGlobalTouchEnd = this._globalTouchEnd.bind(this);
+    
+    document.addEventListener('mouseup', this._boundGlobalMouseUp);
+    document.addEventListener('touchend', this._boundGlobalTouchEnd);
+  }
+  
+  // Global mouseup handler (safety)
+  _globalMouseUp(e) {
+    if (this._isPlaying) {
+      // Check if any move intervals are active and stop them
+      Object.keys(this._moveIntervals).forEach(dir => {
+        if (this._moveIntervals[dir]) {
+          this._stopContinuousMove(dir);
+        }
+      });
+    }
+  }
+  
+  // Global touchend handler (safety)
+  _globalTouchEnd(e) {
+    if (this._isPlaying) {
+      // Check if any move intervals are active and stop them
+      Object.keys(this._moveIntervals).forEach(dir => {
+        if (this._moveIntervals[dir]) {
+          this._stopContinuousMove(dir);
+        }
+      });
+    }
+  }
+  
+  // Remove global listeners when disconnected
   disconnectedCallback() {
     if (this._connectionCheckInterval) {
       clearInterval(this._connectionCheckInterval);
     }
+    
+    // Remove global event listeners
+    document.removeEventListener('mouseup', this._boundGlobalMouseUp);
+    document.removeEventListener('touchend', this._boundGlobalTouchEnd);
+    
+    // Stop all continuous movement
+    Object.keys(this._moveIntervals).forEach(dir => {
+      this._stopContinuousMove(dir);
+    });
+    
     this._stopVideo();
   }
 
@@ -530,6 +753,285 @@ class CameraAgoraCard extends HTMLElement {
       });
     }
   }
+  
+  // Set up joystick controls
+  _setupJoystickControls() {
+    if (!this._config.enableJoystick) return;
+    
+    // Add event listeners for joystick buttons
+    const directions = ['up', 'down', 'left', 'right'];
+    
+    directions.forEach(dir => {
+      const button = this.shadowRoot.getElementById(`joystick-${dir}`);
+      if (button) {
+        // Remove any existing event listeners to prevent duplicates
+        const newButton = button.cloneNode(true);
+        button.parentNode.replaceChild(newButton, button);
+        
+        // For single clicks
+        newButton.addEventListener('click', (e) => {
+          e.stopPropagation();
+          this._handleJoystickPress(dir);
+        });
+        
+        // For continuous press
+        newButton.addEventListener('mousedown', (e) => {
+          e.stopPropagation();
+          this._startContinuousMove(dir);
+        });
+        newButton.addEventListener('mouseup', (e) => {
+          e.stopPropagation();
+          this._stopContinuousMove(dir);
+        });
+        newButton.addEventListener('mouseleave', (e) => {
+          e.stopPropagation();
+          this._stopContinuousMove(dir);
+        });
+        
+        // Touch support for mobile devices
+        newButton.addEventListener('touchstart', (e) => {
+          e.preventDefault(); // Prevents scrolling
+          e.stopPropagation();
+          this._startContinuousMove(dir);
+        });
+        newButton.addEventListener('touchend', (e) => {
+          e.stopPropagation();
+          this._stopContinuousMove(dir);
+        });
+      }
+    });
+  }
+  
+  // Create joystick overlay if needed
+  _setupJoystickOverlay() {
+    if (!this._config.enableJoystick) return;
+    
+    const videoContainer = this.shadowRoot.getElementById('agora-video');
+    const existingOverlay = this.shadowRoot.getElementById('joystick-overlay');
+    
+    if (!existingOverlay) {
+      const joystickOverlay = document.createElement('div');
+      joystickOverlay.id = 'joystick-overlay';
+      joystickOverlay.className = 'joystick-overlay';
+      joystickOverlay.innerHTML = `
+        <button id="joystick-up" class="joystick-button joystick-up">
+          <svg viewBox="0 0 24 24" width="24" height="24"><path fill="white" d="M7.41,15.41L12,10.83L16.59,15.41L18,14L12,8L6,14L7.41,15.41Z" /></svg>
+        </button>
+        <button id="joystick-down" class="joystick-button joystick-down">
+          <svg viewBox="0 0 24 24" width="24" height="24"><path fill="white" d="M7.41,8.59L12,13.17L16.59,8.59L18,10L12,16L6,10L7.41,8.59Z" /></svg>
+        </button>
+        <button id="joystick-left" class="joystick-button joystick-left">
+          <svg viewBox="0 0 24 24" width="24" height="24"><path fill="white" d="M15.41,16.59L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.59Z" /></svg>
+        </button>
+        <button id="joystick-right" class="joystick-button joystick-right">
+          <svg viewBox="0 0 24 24" width="24" height="24"><path fill="white" d="M8.59,16.59L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.59Z" /></svg>
+        </button>
+      `;
+      
+      videoContainer.appendChild(joystickOverlay);
+      
+      // Setup event listeners
+      this._setupJoystickControls();
+    }
+  }
+  
+  // Toggle joystick visibility based on playback state
+  _toggleJoystickVisibility(show) {
+    if (!this._config.enableJoystick) return;
+    
+    const joystickOverlay = this.shadowRoot.getElementById('joystick-overlay');
+    if (joystickOverlay) {
+      if (show) {
+        joystickOverlay.classList.add('visible');
+      } else {
+        joystickOverlay.classList.remove('visible');
+      }
+    }
+  }
+  
+  // Handle joystick button press
+  _handleJoystickPress(direction) {
+    if (!this._disclaimerShown) {
+      this._showMoveDisclaimer(() => {
+        this._disclaimerShown = true;
+        this._sendMoveCommand(direction);
+      });
+    } else {
+      this._sendMoveCommand(direction);
+    }
+  }
+  
+  // Show disclaimer before first movement
+  _showMoveDisclaimer(onAccept) {
+    const videoContainer = this.shadowRoot.getElementById('agora-video');
+    
+    // Create disclaimer overlay
+    const disclaimerOverlay = document.createElement('div');
+    disclaimerOverlay.className = 'disclaimer-overlay';
+    disclaimerOverlay.innerHTML = `
+      <h3>Warning!</h3>
+      <p>By pressing these controls, the robot will physically move.</p>
+      <p>Please be aware that the video feed may not be in real time.</p>
+      <p>Make sure the area around the robot is clear and safe.</p>
+      <button id="disclaimer-accept" class="disclaimer-button">I understand, proceed</button>
+    `;
+    
+    videoContainer.appendChild(disclaimerOverlay);
+    
+    // Add event listener to accept button
+    const acceptButton = disclaimerOverlay.querySelector('#disclaimer-accept');
+    acceptButton.addEventListener('click', (e) => {
+      e.stopPropagation(); // Prevent click from propagating
+      
+      // Stop any active movements before removing the disclaimer
+      Object.keys(this._moveIntervals).forEach(dir => {
+        this._stopContinuousMove(dir);
+      });
+      
+      // Remove the disclaimer after stopping movements
+      videoContainer.removeChild(disclaimerOverlay);
+      
+      // Force mouseup event simulation to ensure any pressed buttons are released
+      document.dispatchEvent(new MouseEvent('mouseup'));
+      if (window.TouchEvent) {
+        document.dispatchEvent(new TouchEvent('touchend'));
+      }
+      
+      // Wait a small amount of time before allowing movement again
+      setTimeout(() => {
+        if (onAccept) onAccept();
+      }, 100);
+    });
+  }
+  
+  // Start continuous movement
+  _startContinuousMove(direction) {
+    // Don't start if video is not playing
+    if (!this._isPlaying) {
+      console.log("Cannot start movement - video not playing");
+      return;
+    }
+    
+    // First send command immediately
+    if (!this._disclaimerShown) {
+      this._showMoveDisclaimer(() => {
+        this._disclaimerShown = true;
+        // Make sure mouseup events are processed first
+        setTimeout(() => {
+          // Check if the button is still pressed (should not be after disclaimer)
+          if (this._isMouseStillDown()) {
+            this._sendMoveCommand(direction);
+            
+            // Start continuous sending after confirmation
+            if (this._isPlaying) {
+              // First clear any existing interval to be safe
+              this._stopContinuousMove(direction);
+              
+              // Add safety timeout (max 10 seconds of continuous movement)
+              this._moveIntervals[direction] = setInterval(() => {
+                if (!this._isPlaying) {
+                  this._stopContinuousMove(direction);
+                  return;
+                }
+                this._sendMoveCommand(direction);
+              }, 500); // Send command every 500ms
+              
+              // Set a safety timeout - max 10 seconds of movement
+              setTimeout(() => {
+                this._stopContinuousMove(direction);
+              }, 10000);
+            }
+          }
+        }, 200);
+      });
+    } else {
+      this._sendMoveCommand(direction);
+      
+      // Start continuous sending
+      if (this._isPlaying) {
+        // First clear any existing interval to be safe
+        this._stopContinuousMove(direction);
+        
+        this._moveIntervals[direction] = setInterval(() => {
+          if (!this._isPlaying) {
+            this._stopContinuousMove(direction);
+            return;
+          }
+          this._sendMoveCommand(direction);
+        }, 500); // Send command every 500ms
+        
+        // Set a safety timeout - max 10 seconds of movement
+        setTimeout(() => {
+          this._stopContinuousMove(direction);
+        }, 10000);
+      }
+    }
+  }
+  
+  // Check if mouse button is still being pressed
+  _isMouseStillDown() {
+    // This is a simple way to check, we assume mouse is not down if method is called after disclaimer
+    return false;
+  }
+  
+  // Stop continuous movement
+  _stopContinuousMove(direction) {
+    if (this._moveIntervals[direction]) {
+      clearInterval(this._moveIntervals[direction]);
+      this._moveIntervals[direction] = null;
+    }
+  }
+  
+  // Send movement command to Home Assistant
+  _sendMoveCommand(direction) {
+    if (!this._hass || !this._config) {
+      this._stopContinuousMove(direction);
+      return;
+    }
+    
+    if (!this._isPlaying) {
+      console.log("Stopping movement command because video is not playing");
+      this._stopContinuousMove(direction);
+      return;
+    }
+    
+    const entityId = this._config.entity;
+    const speed = this._config.speed;
+    
+    // Define service and action based on direction
+    let service = 'mammotion';
+    let action = '';
+    
+    switch (direction) {
+      case 'up':
+        action = 'move_forward';
+        break;
+      case 'down':
+        action = 'move_backward';
+        break;
+      case 'left':
+        action = 'move_left';
+        break;
+      case 'right':
+        action = 'move_right';
+        break;
+    }
+    
+    // Send command to Home Assistant
+    if (action) {
+      try {
+        this._hass.callService(service, action, { 
+          entity_id: entityId, 
+          speed: speed 
+        });
+        console.log(`Sending ${action} command to ${entityId} with speed ${speed}`);
+      } catch (error) {
+        console.error(`Error sending ${action} command:`, error);
+        // Stop continuous movement on error
+        this._stopContinuousMove(direction);
+      }
+    }
+  }
 }
 
 // Register the element


Reviews:
user: login: mikey0000
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-18T01:52:05Z
user: login: mikey0000
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-18T01:59:02Z
user: login: mikey0000
state: APPROVED
author_association: OWNER
submitted_at: 2025-05-18T01:59:57Z

Review comments:
diff_hunk: @@ -530,6 +753,283 @@ class CameraAgoraCard extends HTMLElement {
       });
     }
   }
+  
+  // Set up joystick controls
+  _setupJoystickControls() {
+    if (!this._config.enableJoystick) return;
+    
+    // Add event listeners for joystick buttons
+    const directions = ['up', 'down', 'left', 'right'];
+    
+    directions.forEach(dir => {
+      const button = this.shadowRoot.getElementById(`joystick-${dir}`);
+      if (button) {
+        // Remove any existing event listeners to prevent duplicates
+        const newButton = button.cloneNode(true);
+        button.parentNode.replaceChild(newButton, button);
+        
+        // For single clicks
+        newButton.addEventListener('click', (e) => {
+          e.stopPropagation();
+          this._handleJoystickPress(dir);
+        });
+        
+        // For continuous press
+        newButton.addEventListener('mousedown', (e) => {
+          e.stopPropagation();
+          this._startContinuousMove(dir);
+        });
+        newButton.addEventListener('mouseup', (e) => {
+          e.stopPropagation();
+          this._stopContinuousMove(dir);
+        });
+        newButton.addEventListener('mouseleave', (e) => {
+          e.stopPropagation();
+          this._stopContinuousMove(dir);
+        });
+        
+        // Touch support for mobile devices
+        newButton.addEventListener('touchstart', (e) => {
+          e.preventDefault(); // Prevents scrolling
+          e.stopPropagation();
+          this._startContinuousMove(dir);
+        });
+        newButton.addEventListener('touchend', (e) => {
+          e.stopPropagation();
+          this._stopContinuousMove(dir);
+        });
+      }
+    });
+  }
+  
+  // Create joystick overlay if needed
+  _setupJoystickOverlay() {
+    if (!this._config.enableJoystick) return;
+    
+    const videoContainer = this.shadowRoot.getElementById('agora-video');
+    const existingOverlay = this.shadowRoot.getElementById('joystick-overlay');
+    
+    if (!existingOverlay) {
+      const joystickOverlay = document.createElement('div');
+      joystickOverlay.id = 'joystick-overlay';
+      joystickOverlay.className = 'joystick-overlay';
+      joystickOverlay.innerHTML = `
+        <button id="joystick-up" class="joystick-button joystick-up">
+          <svg viewBox="0 0 24 24" width="24" height="24"><path fill="white" d="M7.41,15.41L12,10.83L16.59,15.41L18,14L12,8L6,14L7.41,15.41Z" /></svg>
+        </button>
+        <button id="joystick-down" class="joystick-button joystick-down">
+          <svg viewBox="0 0 24 24" width="24" height="24"><path fill="white" d="M7.41,8.59L12,13.17L16.59,8.59L18,10L12,16L6,10L7.41,8.59Z" /></svg>
+        </button>
+        <button id="joystick-left" class="joystick-button joystick-left">
+          <svg viewBox="0 0 24 24" width="24" height="24"><path fill="white" d="M15.41,16.59L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.59Z" /></svg>
+        </button>
+        <button id="joystick-right" class="joystick-button joystick-right">
+          <svg viewBox="0 0 24 24" width="24" height="24"><path fill="white" d="M8.59,16.59L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.59Z" /></svg>
+        </button>
+      `;
+      
+      videoContainer.appendChild(joystickOverlay);
+      
+      // Setup event listeners
+      this._setupJoystickControls();
+    }
+  }
+  
+  // Toggle joystick visibility based on playback state
+  _toggleJoystickVisibility(show) {
+    if (!this._config.enableJoystick) return;
+    
+    const joystickOverlay = this.shadowRoot.getElementById('joystick-overlay');
+    if (joystickOverlay) {
+      if (show) {
+        joystickOverlay.classList.add('visible');
+      } else {
+        joystickOverlay.classList.remove('visible');
+      }
+    }
+  }
+  
+  // Handle joystick button press
+  _handleJoystickPress(direction) {
+    if (!this._disclaimerShown) {
+      this._showMoveDisclaimer(() => {
+        this._disclaimerShown = true;
+        this._sendMoveCommand(direction);
+      });
+    } else {
+      this._sendMoveCommand(direction);
+    }
+  }
+  
+  // Show disclaimer before first movement
+  _showMoveDisclaimer(onAccept) {
+    const videoContainer = this.shadowRoot.getElementById('agora-video');
+    
+    // Create disclaimer overlay
+    const disclaimerOverlay = document.createElement('div');
+    disclaimerOverlay.className = 'disclaimer-overlay';
+    disclaimerOverlay.innerHTML = `
+      <h3>Warning!</h3>
+      <p>By pressing these controls, the robot will physically move.</p>
+      <p>Please be aware that the video feed may not be in real time.</p>
+      <p>Make sure the area around the robot is clear and safe.</p>
+      <button id="disclaimer-accept" class="disclaimer-button">I understand, proceed</button>
+    `;
+    
+    videoContainer.appendChild(disclaimerOverlay);
+    
+    // Add event listener to accept button
+    const acceptButton = disclaimerOverlay.querySelector('#disclaimer-accept');
+    acceptButton.addEventListener('click', (e) => {
+      e.stopPropagation(); // Prevent click from propagating
+      
+      // Stop any active movements before removing the disclaimer
+      Object.keys(this._moveIntervals).forEach(dir => {
+        this._stopContinuousMove(dir);
+      });
+      
+      // Remove the disclaimer after stopping movements
+      videoContainer.removeChild(disclaimerOverlay);
+      
+      // Force mouseup event simulation to ensure any pressed buttons are released
+      document.dispatchEvent(new MouseEvent('mouseup'));
+      document.dispatchEvent(new TouchEvent('touchend'));
path: custom_components/mammotion/www/agora-client.js
user: login: mikey0000
body: TouchEvent isn't always defined on desktop, this stops the disclaimer from being dismissed properly.
created_at: 2025-05-18T01:52:05Z
author_association: OWNER
diff_hunk: @@ -530,6 +753,283 @@ class CameraAgoraCard extends HTMLElement {
       });
     }
   }
+  
+  // Set up joystick controls
+  _setupJoystickControls() {
+    if (!this._config.enableJoystick) return;
+    
+    // Add event listeners for joystick buttons
+    const directions = ['up', 'down', 'left', 'right'];
+    
+    directions.forEach(dir => {
+      const button = this.shadowRoot.getElementById(`joystick-${dir}`);
+      if (button) {
+        // Remove any existing event listeners to prevent duplicates
+        const newButton = button.cloneNode(true);
+        button.parentNode.replaceChild(newButton, button);
+        
+        // For single clicks
+        newButton.addEventListener('click', (e) => {
+          e.stopPropagation();
+          this._handleJoystickPress(dir);
+        });
+        
+        // For continuous press
+        newButton.addEventListener('mousedown', (e) => {
+          e.stopPropagation();
+          this._startContinuousMove(dir);
+        });
+        newButton.addEventListener('mouseup', (e) => {
+          e.stopPropagation();
+          this._stopContinuousMove(dir);
+        });
+        newButton.addEventListener('mouseleave', (e) => {
+          e.stopPropagation();
+          this._stopContinuousMove(dir);
+        });
+        
+        // Touch support for mobile devices
+        newButton.addEventListener('touchstart', (e) => {
+          e.preventDefault(); // Prevents scrolling
+          e.stopPropagation();
+          this._startContinuousMove(dir);
+        });
+        newButton.addEventListener('touchend', (e) => {
+          e.stopPropagation();
+          this._stopContinuousMove(dir);
+        });
+      }
+    });
+  }
+  
+  // Create joystick overlay if needed
+  _setupJoystickOverlay() {
+    if (!this._config.enableJoystick) return;
+    
+    const videoContainer = this.shadowRoot.getElementById('agora-video');
+    const existingOverlay = this.shadowRoot.getElementById('joystick-overlay');
+    
+    if (!existingOverlay) {
+      const joystickOverlay = document.createElement('div');
+      joystickOverlay.id = 'joystick-overlay';
+      joystickOverlay.className = 'joystick-overlay';
+      joystickOverlay.innerHTML = `
+        <button id="joystick-up" class="joystick-button joystick-up">
+          <svg viewBox="0 0 24 24" width="24" height="24"><path fill="white" d="M7.41,15.41L12,10.83L16.59,15.41L18,14L12,8L6,14L7.41,15.41Z" /></svg>
+        </button>
+        <button id="joystick-down" class="joystick-button joystick-down">
+          <svg viewBox="0 0 24 24" width="24" height="24"><path fill="white" d="M7.41,8.59L12,13.17L16.59,8.59L18,10L12,16L6,10L7.41,8.59Z" /></svg>
+        </button>
+        <button id="joystick-left" class="joystick-button joystick-left">
+          <svg viewBox="0 0 24 24" width="24" height="24"><path fill="white" d="M15.41,16.59L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.59Z" /></svg>
+        </button>
+        <button id="joystick-right" class="joystick-button joystick-right">
+          <svg viewBox="0 0 24 24" width="24" height="24"><path fill="white" d="M8.59,16.59L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.59Z" /></svg>
+        </button>
+      `;
+      
+      videoContainer.appendChild(joystickOverlay);
+      
+      // Setup event listeners
+      this._setupJoystickControls();
+    }
+  }
+  
+  // Toggle joystick visibility based on playback state
+  _toggleJoystickVisibility(show) {
+    if (!this._config.enableJoystick) return;
+    
+    const joystickOverlay = this.shadowRoot.getElementById('joystick-overlay');
+    if (joystickOverlay) {
+      if (show) {
+        joystickOverlay.classList.add('visible');
+      } else {
+        joystickOverlay.classList.remove('visible');
+      }
+    }
+  }
+  
+  // Handle joystick button press
+  _handleJoystickPress(direction) {
+    if (!this._disclaimerShown) {
+      this._showMoveDisclaimer(() => {
+        this._disclaimerShown = true;
+        this._sendMoveCommand(direction);
+      });
+    } else {
+      this._sendMoveCommand(direction);
+    }
+  }
+  
+  // Show disclaimer before first movement
+  _showMoveDisclaimer(onAccept) {
+    const videoContainer = this.shadowRoot.getElementById('agora-video');
+    
+    // Create disclaimer overlay
+    const disclaimerOverlay = document.createElement('div');
+    disclaimerOverlay.className = 'disclaimer-overlay';
+    disclaimerOverlay.innerHTML = `
+      <h3>Warning!</h3>
+      <p>By pressing these controls, the robot will physically move.</p>
+      <p>Please be aware that the video feed may not be in real time.</p>
+      <p>Make sure the area around the robot is clear and safe.</p>
+      <button id="disclaimer-accept" class="disclaimer-button">I understand, proceed</button>
+    `;
+    
+    videoContainer.appendChild(disclaimerOverlay);
+    
+    // Add event listener to accept button
+    const acceptButton = disclaimerOverlay.querySelector('#disclaimer-accept');
+    acceptButton.addEventListener('click', (e) => {
+      e.stopPropagation(); // Prevent click from propagating
+      
+      // Stop any active movements before removing the disclaimer
+      Object.keys(this._moveIntervals).forEach(dir => {
+        this._stopContinuousMove(dir);
+      });
+      
+      // Remove the disclaimer after stopping movements
+      videoContainer.removeChild(disclaimerOverlay);
+      
+      // Force mouseup event simulation to ensure any pressed buttons are released
+      document.dispatchEvent(new MouseEvent('mouseup'));
+      document.dispatchEvent(new TouchEvent('touchend'));
path: custom_components/mammotion/www/agora-client.js
user: login: mikey0000
body: ```suggestion
      if (window.TouchEvent) {
        document.dispatchEvent(new TouchEvent('touchend'));
      }
```
created_at: 2025-05-18T01:59:02Z
author_association: OWNER

PR comments:
user: login: penify-dev[bot]
created_at: 2025-05-09T13:28:12Z
body: ## PR Review 🔍

<table>
<tr>
<tr><td> ⏱️&nbsp;<strong>Estimated&nbsp;effort&nbsp;to&nbsp;review [1-5]</strong></td><td>

4, because the PR introduces significant new functionality with multiple service calls and UI components, requiring careful review of both the backend and frontend code.


</td></tr>
<tr><td> 🧪&nbsp;<strong>Relevant tests</strong></td><td>

No


</td></tr>
<tr><td rowspan=2> ⚡&nbsp;<strong>Possible issues</strong></td>
<td>

<strong>Possible Bug:</strong> The speed validation allows values between 0.1 and 1, but the default speed is set to 0.4. Ensure that this is the intended behavior.</td></tr>
<tr>
<td>

<strong>Possible Bug:</strong> The joystick controls may not properly handle simultaneous button presses, which could lead to unexpected behavior.</td></tr>
<tr><td> 🔒&nbsp;<strong>Security concerns</strong></td><td>

No

</td></tr>
</table>

author_association: CONTRIBUTOR
user: login: penify-dev[bot]
created_at: 2025-05-09T13:28:35Z
body: ## PR Code Suggestions ✨

<table><thead><tr><td>Category</td><td align=left>Suggestion&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </td><td align=center>Score</td></tr><tbody><tr><td rowspan=2><strong>Possible issue</strong></td>
<td>



<details><summary>Use the <code>get</code> method to safely access <code>entity_id</code></summary>

___


**Ensure that the <code>entity_id</code> is present in the <code>call.data</code> before accessing it to avoid <br>potential KeyErrors.**

[custom_components/mammotion/camera.py [192]](https://github.com/mikey0000/Mammotion-HA/pull/329/files#diff-077e4162e67170d3d23c28f2f6461a4c8b4a18e49717b1d667de6056e84d7c22R192-R192)

```diff
-entity_id = call.data["entity_id"]
+entity_id = call.data.get("entity_id")
 
```



<details><summary><b>Suggestion importance[1-10]: 8</b></summary>

Why: Using `call.data.get("entity_id")` prevents potential KeyErrors if `entity_id` is not present, improving the robustness of the code.


</details></details></td><td align=center>8

</td></tr><tr><td>



<details><summary>Add a check for the existence of the method on the mower's reporting coordinator</summary>

___


**Consider adding a check to ensure that the <code>mower</code> object is valid before attempting to call <br>methods on it, to prevent potential runtime errors.**

[custom_components/mammotion/camera.py [212]](https://github.com/mikey0000/Mammotion-HA/pull/329/files#diff-077e4162e67170d3d23c28f2f6461a4c8b4a18e49717b1d667de6056e84d7c22R212-R212)

```diff
-if mower:
+if mower and hasattr(mower.reporting_coordinator, 'async_move_forward'):
     await mower.reporting_coordinator.async_move_forward(speed=speed)
 
```



<details><summary><b>Suggestion importance[1-10]: 6</b></summary>

Why: Adding a check for the existence of the method on the mower's reporting coordinator can prevent runtime errors, although the current check for `mower` is already a good practice.


</details></details></td><td align=center>6

</td></tr><tr><td rowspan=1><strong>Maintainability</strong></td>
<td>



<details><summary>Extract speed validation into a separate function for better maintainability</summary>

___


**Consolidate the speed validation logic into a separate function to reduce code duplication <br>across movement handlers.**

[custom_components/mammotion/camera.py [195-230]](https://github.com/mikey0000/Mammotion-HA/pull/329/files#diff-077e4162e67170d3d23c28f2f6461a4c8b4a18e49717b1d667de6056e84d7c22R195-R230)

```diff
-speed = 0.4  # Default speed
-if "speed" in call.data:
-    try:
-        speed_value = float(call.data["speed"])
-        if 0.1 <= speed_value <= 1:
-            speed = speed_value
-        else:
-            _LOGGER.warning(
-                f"Invalid speed value for {entity_id}: {speed_value}. Must be between 0 and 1. Using default."
-            )
-    except (ValueError, TypeError):
-        _LOGGER.warning(
-            f"Invalid speed format for {entity_id}: {call.data['speed']}. Must be a number. Using default."
-        )
+speed = validate_speed(call.data.get("speed"), entity_id)
 
```



<details><summary><b>Suggestion importance[1-10]: 7</b></summary>

Why: Extracting the speed validation logic into a separate function would enhance maintainability and reduce code duplication, which is beneficial for future updates.


</details></details></td><td align=center>7

</td></tr><tr><td rowspan=1><strong>Best practice</strong></td>
<td>



<details><summary>Group service registration calls for improved organization</summary>

___


**Ensure that the service registration calls are grouped together for better readability and <br>maintainability.**

[custom_components/mammotion/camera.py [283-295]](https://github.com/mikey0000/Mammotion-HA/pull/329/files#diff-077e4162e67170d3d23c28f2f6461a4c8b4a18e49717b1d667de6056e84d7c22R283-R295)

```diff
 hass.services.async_register("mammotion", "refresh_stream", handle_refresh_stream)
 hass.services.async_register("mammotion", "start_video", handle_start_video)
+hass.services.async_register("mammotion", "move_forward", handle_move_forward)
+hass.services.async_register("mammotion", "move_left", handle_move_left)
+hass.services.async_register("mammotion", "move_right", handle_move_right)
+hass.services.async_register("mammotion", "move_backward", handle_move_backward)
 
```



<details><summary><b>Suggestion importance[1-10]: 5</b></summary>

Why: Grouping service registration calls improves organization, but this is more of a style preference than a critical issue, hence the lower score.

</details></details></td><td align=center>5

</td></tr></tr></tbody></table>
author_association: CONTRIBUTOR