Reverse Engineer an E88 Drone app

Reverse Engineer an E88 Drone app

decompiled apk for the RC-UFO app to figure out the communication protocol. got the drone flying with python UDP

Needed python control of a sub 1000rs camera drone. The E88 pro has 2 cameras!

I also 3D printed a bare frame for the drone which makes it easier to explain parts to students. There are 2 PCBs : 1 with RF link to the remote, and also the sensors. and another PCB with WiFi, Camera, and a serial data output which connects it to the main board.

Decompiling the apk

Play Store Link

To View Video stream

ffplay rtsp://192.168.1.1:7070/webcam

or with python CV

import cv2, sys
rtsp_url = "rtsp://192.168.1.1:7070/webcam"
cap = cv2.VideoCapture(rtsp_url)
if not cap.isOpened():
    print(f"Error: Could not open video stream from {rtsp_url}")
    sys.exit(1)
    
print("Successfully connected to the RTSP stream. Press 'q' to quit.")
while True:
    ret, frame = cap.read()
    if not ret:
        print("Error: Failed to grab frame. End of stream or connection issue?")
        break
    cv2.imshow('Drone Stream', frame)
    if cv2.waitKey(1) & 0xFF == ord('q'):
        break
cap.release()
cv2.destroyAllWindows()

With help from Marshall Richards from Turbodrone, I was able to locate the part of the apk which sends bytes. What made it much easier was that it was dumping its packets via debug statements, so all i needed really was ADB .

I bought a bunch of the E88 Pro (misnomer. worse quality than E88. clickety joysticks instead of analog) for <10$ each. The app is called RC UFO, but same dev. The table shows the data packets, where first byte is a 0x03, making it a total of 9 bytes. It neatly outputs byte 0-7 in the ADB logs. So was very easy to figure out which byte did what, but to locate the prefixed 0x03(drone won’t budge without it), I had to go read the decompiled source. JAD

BYTE -1: 3
BYTE 0 : 102
BYTE 1 : ROLL (128  centered)
BYTE 2 : PITCH (128 centered)
BYTE 3 : UP/DOWN
BYTE 4: YAW
BYTE 5
  1 = takeoff
  2 = land
  4 = emergency stop
  128 = calibrate
BYTE 6 : XOR of bytes 1-5
BYTE 7: 0x99

This is pretty much it, and here’s an example code.

self.basebytes = bytearray(b'\x66\x80\x80\x80\x80\x00\x00\x99')

def xor(self,bs):
    s=0
    for byte_value in bs[1:6]:
        s ^= byte_value
    bs[6]=s
    return bs

self.send_udp_command(bytes(bytearray(b'\x03') + self.basebytes)) # Tack on the 0x03

There’s also a command to switch between the two cameras, but if you don’t properly pause the rtsp feed , it messes it up. \x06\x01 , and \x06\x02

Community Contribution

I have uploaded my work to the experimental section of the Turbodrone project by Marshall Richards

Functioning PyQt software

Features

One touch takeoff, land, flip(all ways), fly around, view video feed. Finicky features: Switching camera feed , one touch land(falls out of the sky sometimes while landing. might be a battery thing)

Controls

  • Z : one touch takeoff
  • X : land (careful)
  • C : Calibrate gyro
  • W/S : throttle
  • A/D : YAW
  • UP/DOWN: PITCH
  • LEFT/RIGHT: roll
  • F: FLIP (combine with up/down/left/right to specify direction of flip
  • H: toggle headless mode
  • 1,2 : camera selection. barely works.
'''
Author: jithinbp@gmail.com
For the E88pro drone which is under 10$
Features: One touch takeoff, land, flip(all ways), fly around, view video feed.
Finicky features: Switching camera feed , one touch land(falls out of the sky sometimes while landing. might be a battery thing)

Controls
Z : one touch takeoff
X : land (careful)
C : Calibrate gyro
W/S : throttle
A/D : YAW
UP/DOWN: PITCH
LEFT/RIGHT: roll
F: FLIP (combine with up/down/left/right to specify direction of flip 
H: toggle headless mode
1,2 : camera selection. barely works.

'''
import sys
import socket
import cv2
import time # Import time for delays
from PyQt5.QtWidgets import QApplication, QMainWindow, QLabel, QPushButton, QVBoxLayout, QWidget, QMessageBox
from PyQt5.QtGui import QImage, QPixmap
from PyQt5.QtCore import QThread, pyqtSignal, pyqtSlot, Qt, QByteArray, QTimer

# --- Constants ---
RTSP_URL = "rtsp://192.168.1.1:7070/webcam"
UDP_IP = "192.168.1.1" # This is the target IP for sending commands
UDP_SEND_PORT = 7099 # Port for sending commands
UDP_LISTEN_PORT = 7099 # Port for listening to responses (assuming same port for simplicity, adjust if different)
UDP_UP_COMMAND = b'\x06\x01'  # Byte sequence for UP command
UDP_DOWN_COMMAND = b'\x06\x02' # Byte sequence for DOWN command
UDP_BUFFER_SIZE = 1024 # Buffer size for UDP receive
STREAM_REINITIALIZE_DELAY_SEC = 2 # Delay before attempting to re-open stream after disruption

# --- Video Stream Thread ---
class VideoStreamThread(QThread):
    """
    A QThread subclass to handle video capture from an RTSP stream.
    It emits a QPixmap signal for each new frame.
    """
    change_pixmap_signal = pyqtSignal(QPixmap)
    error_signal = pyqtSignal(str)

    def __init__(self, rtsp_url):
        super().__init__()
        self._rtsp_url = rtsp_url
        self._run_flag = True
        self._reinitialize_flag = False # New flag to trigger stream re-initialization
        self._cap = None
        self._paused = False

    def run(self):
        """
        Main loop for video capture. Reads frames and emits them as QPixmap.
        Handles stream opening, reading, and re-initialization.
        """
        while self._run_flag:
            if self._paused:
                continue
            if self._reinitialize_flag:
                # If re-initialization is requested, release current capture and prepare to re-open
                print("Re-initializing video stream...")
                if self._cap:
                    self._cap.release()
                    self._cap = None
                self.msleep(int(STREAM_REINITIALIZE_DELAY_SEC * 1000)) # Wait before re-opening
                self._reinitialize_flag = False # Clear the flag

            if not self._cap or not self._cap.isOpened():
                print(f"Attempting to open RTSP stream: {self._rtsp_url}")
                self._cap = cv2.VideoCapture(self._rtsp_url)
                if not self._cap.isOpened():
                    self.error_signal.emit(f"Error: Could not open RTSP stream at {self._rtsp_url}. "
                                           "Please check the URL and network connection.")
                    self.msleep(1000) # Wait before retrying to open
                    continue # Try again in the next loop iteration

                print("RTSP stream opened successfully.")

            # Attempt to read a frame
            ret, cv_img = self._cap.read()
            if ret:
                # Convert OpenCV image to QPixmap
                qt_format = QImage.Format_RGB888
                if len(cv_img.shape) == 3 and cv_img.shape[2] == 3: # Check if it's a color image
                    rgb_image = cv2.cvtColor(cv_img, cv2.COLOR_BGR2RGB)
                    # Rotate the image 90 degrees clockwise
                    rgb_image = cv2.rotate(rgb_image, cv2.ROTATE_90_CLOCKWISE)
                    h, w, ch = rgb_image.shape
                    bytes_per_line = ch * w
                    convert_to_qt_format = QImage(rgb_image.data, w, h, bytes_per_line, qt_format)
                    p = convert_to_qt_format.scaled(640, 480, Qt.KeepAspectRatio) # Scale for display
                    self.change_pixmap_signal.emit(QPixmap.fromImage(p))
                else:
                    print("Warning: Received non-RGB image, skipping display.")
            else:
                # If reading fails, assume stream disruption and trigger re-initialization
                print("Warning: Failed to read frame from RTSP stream. Triggering re-initialization.")
                self.error_signal.emit(f"Stream disrupted. Attempting to re-establish connection to {self._rtsp_url}.")
                self._reinitialize_flag = True # Set flag to re-initialize in next loop iteration
                if self._cap: # Release immediately to avoid blocking
                    self._cap.release()
                    self._cap = None
            self.msleep(30) # Small delay to prevent busy-waiting and reduce CPU usage

        print("Video stream thread stopping.")
        if self._cap:
            self._cap.release()
            print("RTSP stream released.")

    def stop(self):
        """Stops the video stream thread gracefully."""
        self._run_flag = False
        self.wait() # Wait for the thread to finish its execution

    @pyqtSlot()
    def reinitialize_stream(self):
        """
        Slot to trigger a full re-initialization of the video stream.
        This is called when a known disruption (like a camera switch) occurs.
        """
        self._reinitialize_flag = True
        print("Re-initialization requested for video stream.")


# --- UDP Listener Thread ---
class UDPListenerThread(QThread):
    """
    A QThread subclass to listen for incoming UDP data.
    It emits a signal with the received data.
    """
    data_received_signal = pyqtSignal(bytes, tuple) # Signal to emit data and sender address
    error_signal = pyqtSignal(str)

    def __init__(self, port, buffer_size):
        super().__init__()
        self._listen_ip = "" # We bind to "" (0.0.0.0) to listen on all available local interfaces
        self._port = port
        self._buffer_size = buffer_size
        self._run_flag = True
        self._sock = None

    def run(self):
        """
        Main loop for UDP listening. Receives data and emits it.
        """
        try:
            self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # UDP socket
            self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # Allow reusing the address
            # Bind to all available network interfaces on the specified port
            self._sock.bind((self._listen_ip, self._port))
            self._sock.settimeout(1.0) # Set a timeout to allow graceful shutdown
            print(f"UDP listener started on {self._listen_ip}:{self._port} (listening on all interfaces)")
        except socket.error as e:
            self.error_signal.emit(f"Error binding UDP socket to {self._listen_ip}:{self._port}: {e}")
            self._run_flag = False
            return

        while self._run_flag:
            try:
                data, addr = self._sock.recvfrom(self._buffer_size)
                self.data_received_signal.emit(data, addr)
                print(f"Received UDP data from {addr}: {data.hex()}")
            except socket.timeout:
                # Timeout occurred, continue loop to check _run_flag
                pass
            except socket.error as e:
                if self._run_flag: # Only report error if not intentionally stopping
                    self.error_signal.emit(f"UDP receive error: {e}")
                break # Exit loop on persistent error

        print("UDP listener thread stopping.")
        if self._sock:
            self._sock.close()
            print("UDP socket closed.")

    def stop(self):
        """Stops the UDP listener thread gracefully."""
        self._run_flag = False
        if self._sock:
            # A small trick to unblock recvfrom if it's blocking
            try:
                self._sock.shutdown(socket.SHUT_RDWR)
            except OSError:
                pass # Socket might already be closed or in a bad state
        self.wait() # Wait for the thread to finish

# --- Main Application Window ---
class RTSPViewerApp(QMainWindow):
    """
    Main application window for RTSP viewing and UDP control.
    """
    SOMERSAULT = 8
    HEADLESS = 16
    def __init__(self):
        super().__init__()
        self.setWindowTitle("RTSP Stream Viewer & Device Control")
        self.setGeometry(100, 100, 800, 700) # Adjusted height to accommodate new label

        self.central_widget = QWidget()
        self.setCentralWidget(self.central_widget)
        self.layout = QVBoxLayout(self.central_widget)

        # Video display label
        self.image_label = QLabel(self)
        self.image_label.setFixedSize(640, 480) # Fixed size for video display
        self.image_label.setAlignment(Qt.AlignCenter)
        self.image_label.setStyleSheet("background-color: black; border: 1px solid gray;")
        self.layout.addWidget(self.image_label, alignment=Qt.AlignCenter)
        self.flip=False
        self.headless=False
        self.cam=0

        # Control buttons
        self.up_button = QPushButton("UP")
        self.up_button.setFixedSize(100, 40)
        self.up_button.clicked.connect(self.on_up_button_clicked)
        self.layout.addWidget(self.up_button, alignment=Qt.AlignCenter)

        self.down_button = QPushButton("DOWN")
        self.down_button.setFixedSize(100, 40)
        self.down_button.clicked.connect(self.on_down_button_clicked)
        self.layout.addWidget(self.down_button, alignment=Qt.AlignCenter)

        # UDP response display label
        self.udp_response_label = QLabel("UDP Response: None", self)
        self.udp_response_label.setAlignment(Qt.AlignCenter)
        self.udp_response_label.setStyleSheet("font-weight: bold; color: blue;")
        self.layout.addWidget(self.udp_response_label, alignment=Qt.AlignCenter)


        # Initialize video thread
        video = True
        if video:
            self.video_thread = VideoStreamThread(RTSP_URL)
            self.video_thread.change_pixmap_signal.connect(self.update_image)
            self.video_thread.error_signal.connect(self.show_error_message)
            self.video_thread.start()

        # Initialize UDP listener thread
        self.udp_listener_thread = UDPListenerThread(UDP_LISTEN_PORT, UDP_BUFFER_SIZE)
        self.udp_listener_thread.data_received_signal.connect(self.update_udp_response)
        self.udp_listener_thread.error_signal.connect(self.show_error_message)
        self.udp_listener_thread.start()

        # Add a placeholder for when video is not loaded
        self.image_label.setText("Loading RTSP stream...")
        self.image_label.setStyleSheet("background-color: black; color: white; border: 1px solid gray;")

        self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # UDP socket

        # Ensure the widget itself can receive key press events
        # We also want it to be able to re-gain focus after buttons are clicked
        self.setFocusPolicy(Qt.StrongFocus)
        # Create a QTimer for periodic voltage measurement
        self.accel = 50
        self.decel = 5
        self.send_udp_command(b'\x08\x01')
        self.timer = QTimer(self)
        self.last_heartbeat = time.time()
        self.timer.timeout.connect(self.send)  # Connect the timeout signal to the update method
        self.timer.start(30)  # Start the timer with an interval
        #byte0:102,byte1:128,byte2:128,byte3:128,byte4:128,byte5:0,byte6:0,byte7:153
        self.basebytes = bytearray(b'\x66\x80\x80\x80\x80\x00\x00\x99')

    def send(self):
        if self.flip:
            self.basebytes[5]+=self.SOMERSAULT
        if self.headless:
            self.basebytes[5]+=self.HEADLESS
        self.send_udp_command(bytes(bytearray(b'\x03') + self.basebytes))
        if time.time() - self.last_heartbeat > 1:
            self.send_udp_command(b'\x01\x01')
            self.last_heartbeat = time.time()
        if self.cam>0:
            self.video_thread._paused = True
            time.sleep(1)
            l = bytearray(b'\x06')
            l.append(self.cam)
            self.send_udp_command(bytes(l))
            self.cam=0
            time.sleep(1)
            self.video_thread._paused = False
            self.video_thread.reinitialize_stream()


        self.basebytes[5] = 0 
        for a in range(1,5):
            if (self.basebytes[a]>128):
                self.basebytes[a]-=self.decel
            elif (self.basebytes[a]<128):
                self.basebytes[a]+=self.decel


    def xor(self,bs):
        s=0
        for byte_value in bs[1:6]:
            s ^= byte_value
        bs[6]=s
        return bs

    def keyPressEvent(self, event):
        if event.key() == Qt.Key_Up: #Forward (pitch down)
            if self.basebytes[2]<200:
                self.basebytes[2]+=self.accel
            event.accept()  # Crucial: tell PyQt we handled this event
        elif event.key() == Qt.Key_Down: #Back (pitch up)
            if self.basebytes[2]>50:
                self.basebytes[2]-=self.accel
            event.accept()  # Crucial: tell PyQt we handled this event
        elif event.key() == Qt.Key_Left: #Roll left
            if self.basebytes[1]>50:
                self.basebytes[1]-=self.accel
            event.accept()  # Crucial: tell PyQt we handled this event
        elif event.key() == Qt.Key_Right: #Roll right
            if self.basebytes[1]<200:
                self.basebytes[1]+=self.accel
            event.accept()  # Crucial: tell PyQt we handled this event
        elif event.key() == Qt.Key_W:
            if self.basebytes[3]<200:
                self.basebytes[3]+=self.accel
            event.accept()  # Crucial: tell PyQt we handled this event
        elif event.key() == Qt.Key_S:
            if self.basebytes[3]>50:
                self.basebytes[3]-=self.accel
            event.accept()  # Crucial: tell PyQt we handled this event
        elif event.key() == Qt.Key_D:
            if self.basebytes[4]<200:
                self.basebytes[4]+=self.accel
            event.accept()  # Crucial: tell PyQt we handled this event
        elif event.key() == Qt.Key_A:
            if self.basebytes[4]>50:
                self.basebytes[4]-=self.accel
            event.accept()  # Crucial: tell PyQt we handled this event
        elif event.key() == Qt.Key_Z: #Takeoff
            self.basebytes[5] = 1
            event.accept()  # Crucial: tell PyQt we handled this event
        elif event.key() == Qt.Key_X: #Land
            self.basebytes[5] = 2
            event.accept()  # Crucial: tell PyQt we handled this event
        elif event.key() == Qt.Key_C: #Calibrate
            self.basebytes[5] = 128 
            event.accept()  # Crucial: tell PyQt we handled this event
        elif event.key() == Qt.Key_F: #Flip 360
            self.flip = True
            event.accept()  # Crucial: tell PyQt we handled this event
        elif event.key() == Qt.Key_H: #Headless
            self.headless = not self.headless
            event.accept()  # Crucial: tell PyQt we handled this event
        elif event.key() == Qt.Key_1: #cam 1
            self.cam=1
            event.accept()  # Crucial: tell PyQt we handled this event
        elif event.key() == Qt.Key_2: #cam 2
            self.cam=2
            event.accept()  # Crucial: tell PyQt we handled this event
        else:
            # For other keys, let the default behavior happen
            print(event.key())
            super().keyPressEvent(event)


    @pyqtSlot(QPixmap)
    def update_image(self, pixmap):
        """Slot to update the QLabel with new video frames."""
        self.image_label.setPixmap(pixmap)
        self.image_label.setText("") # Clear loading text once video starts

    @pyqtSlot(bytes, tuple)
    def update_udp_response(self, data, addr):
        """Slot to update the QLabel with received UDP data."""
        self.udp_response_label.setText(f"UDP Response from {addr[0]}:{addr[1]}: {data.hex()}")

    @pyqtSlot(str)
    def show_error_message(self, message):
        """Slot to display error messages in a QMessageBox."""
        #QMessageBox.critical(self, "Error", message)
        # For stream errors, keep the error message on the image label
        if "Stream disrupted" in message or "Could not open RTSP stream" in message:
            self.image_label.setText("Stream Error: " + message)
            self.image_label.setStyleSheet("background-color: red; color: white; border: 1px solid gray;")
            # Do not disable buttons for temporary stream disruptions, as they are part of control flow
        else: # For other errors (e.g., UDP binding)
            self.udp_response_label.setText("UDP Error: " + message)
            self.udp_response_label.setStyleSheet("font-weight: bold; color: red;")


    def send_udp_command(self, command):
        """Sends a UDP command to the specified IP and port."""
        try:
            self.sock.sendto(command, (UDP_IP, UDP_SEND_PORT))
            #print(f"Sent UDP command: {command.hex()} to {UDP_IP}:{UDP_SEND_PORT}")
        except socket.error as e:
            #QMessageBox.warning(self, "Network Error", f"Failed to send UDP command: {e}")
            print(f"UDP send error: {e}")

    def on_up_button_clicked(self):
        """Handler for the UP button click."""
        self.send_udp_command(UDP_UP_COMMAND)
        # Give the device a moment to switch cameras and stabilize the stream
        time.sleep(0.5) # Small delay after sending command
        self.video_thread.reinitialize_stream() # Trigger full stream re-initialization

    def on_down_button_clicked(self):
        """Handler for the DOWN button click."""
        self.send_udp_command(UDP_DOWN_COMMAND)
        # Give the device a moment to switch cameras and stabilize the stream
        time.sleep(0.5) # Small delay after sending command
        self.video_thread.reinitialize_stream() # Trigger full stream re-initialization

    def closeEvent(self, event):
        """Ensures all threads are stopped when the application closes."""
        print("Closing application. Stopping video and UDP threads...")
        self.video_thread.stop()
        self.udp_listener_thread.stop()
        event.accept()

# --- Main execution ---
if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = RTSPViewerApp()
    window.show()
    sys.exit(app.exec_())