← Back

TV Remote Locating Buddy

Introduction



I am a member of Makers, an ECE club at USC where Viterbi Engineering students spend the year building group projects. I worked in a team of six to build a robot that finds lost TV remotes. This project pushed me out of my comfort zone, as I gained experience working with a Raspberry Pi and ESP32s microcontrollers.

Project Overview

Concept

Concept Diagram

Losing a TV remote can be extremely frustrating, especially if your favorite sports team is playing live. Our creation solves this problem.

The concept was to place four ESP32 microcontrollers around the room at each corner, one ESP32 inside the TV remote, and have them all communicate with each other to determine an (x,y,z) coordinate that represents the remote’s location. Then, a robot mounted on the wall would use servos to point a laser at that coordinate.

UWB Distancing

We set up four ESPs with radio modules, put them in 3D-printed boxes, and placed them at each corner of the room. The fifth ESPs was placed inside of the TV Remote, acting as the anchor.

The ESP32s communicate wirelessly and emit radio signals, so finding the distance between two is just the time of one wave multiplied by the speed of light. We used this to calculate the distance between the remote and each corner of the room. With these four distances, a system of equations is solved to find the remote’s location in space as an (x, y, z) coordinate. This is known as Ultra-Wideband (UWB) Triangulation.

ESP
ESP

Pointing the Laser

Laser System

For the servos to move into the right spot, we had to convert the Cartesian coordinate found by the ESP32s into horizontal and vertical angles. The horizontal angle was calculated using:

$$ \theta_h = -(\tan^{-1}\left(\frac{x}{y}\right) \cdot \frac{180}{\pi} - 180) - 180 $$

This centers the laser’s rotation relative to the base coordinate system. To find the vertical angle, we first calculated the horizontal plane distance \( p \) and then derived the angle from the z-axis using:

$$ p = \sqrt{x^2 + y^2} $$ $$ \text{ratio} = \frac{z}{p} $$ $$ \theta_v = -90 - \tan^{-1}(\text{ratio}) \cdot \frac{180}{\pi} $$

The robot itself consists of a laser mounted to the side of two perpendicular servos that allow it to point to any coordinate in R3. The horizontal servo moves from left to right along the XY plane, and the vertical servo moves up and down along the YZ plane. Everything is controlled by a Raspberry Pi microprocessor.

Laser System

Python Code


        import paho.mqtt.client as mqtt
        import re
        import numpy as np
        from scipy.optimize import least_squares
        import RPi.GPIO as GPIO
        import time
        import math
        
        # === Globals ===
        latest_position = None
        current_horizontal_angle = 0
        current_vertical_angle = 0
        
        # === GPIO pin setup ===
        GPIO.setmode(GPIO.BCM)
        SERVO_X_PIN = 18
        SERVO_Y_PIN = 27
        LASER_PIN = 25
        BUTTON_PIN = 22
        
        GPIO.setup(SERVO_X_PIN, GPIO.OUT)
        GPIO.setup(SERVO_Y_PIN, GPIO.OUT)
        GPIO.setup(LASER_PIN, GPIO.OUT)
        GPIO.setup(BUTTON_PIN, GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
        
        servo_x = GPIO.PWM(SERVO_X_PIN, 50)
        servo_y = GPIO.PWM(SERVO_Y_PIN, 50)
        servo_x.start(0)
        servo_y.start(0)
        
        # === Distance history ===
        distance_history = {
            "ULCORNER": [],
            "URCORNER": [],
            "BLCORNER": [],
            "BRCORNER": []
        }
        MAX_HISTORY_LENGTH = 10
        
        # === Reference points ===
        ref1 = np.array([-27.94, 0, -232.41])     # BL
        ref2 = np.array([-83.82, 461, -232.41])   # UL
        ref3 = np.array([218.44, 461, -232.41])   # UR
        ref4 = np.array([299.72, 0, -232.41])     # BR
        
        # === Helper functions ===
        def update_history(esp32_id, new_distance):
            if esp32_id in distance_history:
                distance_history[esp32_id].append(new_distance)
                if len(distance_history[esp32_id]) > MAX_HISTORY_LENGTH:
                    distance_history[esp32_id].pop(0)
        
        def extract_distance(data):
            match = re.search(r'(\d+)\s*cm', data)
            return float(match.group(1)) if match else None
        
        def calculate_average(arr):
            if not arr:
                return 0
            arr_np = np.array(arr)
            mean = np.mean(arr_np)
            std = np.std(arr_np)
            filtered = arr_np[(arr_np >= mean - 0.8 * std) & (arr_np <= mean + 0.8 * std)]
            if len(filtered) == 0:
                filtered = arr_np
            alpha = 0.3
            smoothed = filtered[0]
            for val in filtered[1:]:
                smoothed = alpha * val + (1 - alpha) * smoothed
            return smoothed
        
        def triangulate_position():
            global latest_position
            if all(len(distance_history[k]) >= 3 for k in ["ULCORNER", "URCORNER", "BLCORNER", "BRCORNER"]):
                d1 = calculate_average(distance_history["BLCORNER"])
                d2 = calculate_average(distance_history["ULCORNER"])
                d3 = calculate_average(distance_history["URCORNER"])
                d4 = calculate_average(distance_history["BRCORNER"])
        
                def my_system(vars):
                    x, y, z = vars
                    F = np.zeros(4)
                    F[0] = np.sqrt((x - ref1[0])**2 + (y - ref1[1])**2 + (z - ref1[2])**2) - d1
                    F[1] = np.sqrt((x - ref2[0])**2 + (y - ref2[1])**2 + (z - ref2[2])**2) - d2
                    F[2] = np.sqrt((x - ref3[0])**2 + (y - ref3[1])**2 + (z - ref3[2])**2) - d3
                    F[3] = np.sqrt((x - ref4[0])**2 + (y - ref4[1])**2 + (z - ref4[2])**2) - d4
                    return F
        
                initial_guess = [100, 100, 100]
                result = least_squares(my_system, initial_guess)
                latest_position = np.round(result.x, decimals=4)
                print("Unknown point:", latest_position)
        
        def on_message(client, userdata, msg):
            esp32_id = msg.topic.split("/")[-1]
            data = msg.payload.decode().strip()
            distance = extract_distance(data)
            if distance is not None:
                update_history(esp32_id, distance)
                triangulate_position()
        
        def cartesian_to_servo_angles(x, y, z):
            horizontal_angle = -(math.degrees(math.atan2(x, y)) - 180) - 180
            p = math.sqrt(x**2 + y**2)
            ratio = z / p if p != 0 else 0
            ratio = max(min(ratio, 10), -10)
            vertical_angle = -90 - math.degrees(math.atan(ratio))
            return horizontal_angle, vertical_angle
        
        def angle_to_duty_cycle(angle):
            return max(2, min(12, 2 + (angle + 90) * 10 / 180))
        
        def turn_laser_on():
            GPIO.output(LASER_PIN, GPIO.HIGH)
        
        def turn_laser_off():
            GPIO.output(LASER_PIN, GPIO.LOW)
        
        def point_laser_at_position(position):
            global current_horizontal_angle, current_vertical_angle
        
            target_x, target_y, target_z = position
            target_horizontal_angle, target_vertical_angle = cartesian_to_servo_angles(target_x, target_y, target_z)
            target_horizontal_angle -= 20
            target_vertical_angle += 7
        
            step_size = 1
        
            turn_laser_on()
            time.sleep(0.5)
        
            # Horizontal movement
            while round(current_horizontal_angle, 1) != round(target_horizontal_angle, 1):
                if current_horizontal_angle < target_horizontal_angle:
                    current_horizontal_angle = min(current_horizontal_angle + step_size, target_horizontal_angle)
                else:
                    current_horizontal_angle = max(current_horizontal_angle - step_size, target_horizontal_angle)
        
                duty = angle_to_duty_cycle(current_horizontal_angle)
                servo_x.ChangeDutyCycle(duty)
                time.sleep(0.1)
        
            servo_x.ChangeDutyCycle(0)
            time.sleep(0.2)
        
            # Vertical movement
            while round(current_vertical_angle, 1) != round(target_vertical_angle, 1):
                if current_vertical_angle < target_vertical_angle:
                    current_vertical_angle = min(current_vertical_angle + step_size, target_vertical_angle)
                else:
                    current_vertical_angle = max(current_vertical_angle - step_size, target_vertical_angle)
        
                duty = angle_to_duty_cycle(current_vertical_angle)
                servo_y.ChangeDutyCycle(duty)
                time.sleep(0.1)
        
            servo_y.ChangeDutyCycle(0)
            time.sleep(0.2)
        
            time.sleep(3)
            turn_laser_off()
        
        # === MQTT setup ===
        client = mqtt.Client()
        client.on_message = on_message
        client.connect(mqtt_broker, mqtt_port, 60)
        for topic in mqtt_topics:
            client.subscribe(topic)
        
        print("Subscribed to MQTT topics.")
        print("Listening for button press...")
        
        try:
            while True:
                client.loop(timeout=0.1)
        
                if GPIO.input(BUTTON_PIN) == GPIO.HIGH:
                    print("Button pressed!")
                    if latest_position is not None:
                        point_laser_at_position(latest_position)
                    else:
                        print("Not enough data to triangulate yet.")
                    time.sleep(0.5)
        
        except KeyboardInterrupt:
            print("Shutting down...")
        
        finally:
            servo_x.stop()
            servo_y.stop()
            turn_laser_off()
            GPIO.cleanup()
            print("GPIO cleaned up.")
      

CAD Modeling

CAD Model



The designed CAD models for the robot are shown to the left.

Final Demonstration

A short demo is shown in the video. We placed the remote in a random spot, turned on the program, and the laser pointed with near-perfect accuracy.