Configuración de teleoperación VR del Meta Quest 3

Ruta completa paso a paso desde un Quest 3 básico hasta la teleoperación en vivo de un brazo robótico. Cubre la configuración de red, la configuración de Unity, el servidor UDP de Python, piper_controller.py y el patrón de adaptador para cualquier brazo.

1

Red y requisitos previos

Antes de escribir cualquier código, confirma que el Meta Quest 3 y la PC de control pueden comunicarse entre sí a través de la misma red local. El sistema utiliza UDP — ambos dispositivos deben compartir la misma subred, y los puertos UDP 8888 y 8889 deben estar abiertos en el firewall de la PC.

Lista de verificación de requisitos:

  • Auriculares Meta Quest 3 con seguimiento de manos habilitado (Configuración → Seguimiento de movimiento → Seguimiento de manos)
  • Punto de acceso Wi-Fi 6 — se recomienda encarecidamente para mantener la latencia de tránsito UDP por debajo de 10 ms
  • PC de control que ejecute Linux (Ubuntu 22.04+) o macOS con Python 3.10+
  • Para AgileX Piper: adaptador USB a CAN (por ejemplo, Kvaser o PEAK), cable CAN conectado al brazo
  • Puertos UDP 8888 y 8889 abiertos para entrada en el firewall de la PC

Verifica la conectividad:

# On the control PC — find your IP address
ip addr show   # Linux
ipconfig getifaddr en0   # macOS (Wi-Fi)

# Quick UDP listener test (run on PC, send any packet from Quest)
python3 -c "importar socket; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.bind(('0.0.0.0',8888)); print('Escuchando...'); print(s.recvfrom(256))"
Se requiere la misma subred. Ejecutar ip addr en la PC y verifica que los primeros tres octetos coincidan con la IP del Quest 3 (visible en Configuración del Quest → Wi-Fi → icono de engranaje). Un problema común es que las redes Wi-Fi corporativas aíslan a los clientes entre sí; utiliza un punto de acceso dedicado para trabajos de robótica.
2

Configuración de la aplicación Unity

La aplicación de Unity que se ejecuta en el Quest 3 está construida con Unity 2022.3 LTS o posterior, utilizando el paquete XR Hands para el seguimiento de manos. Tres scripts manejan el lado de la teleoperación:

  • VRHandPoseSender.cs — lee la pose de la mano del subsistema XR Hands, serializa a un paquete binario de 45 bytes, envía a través de UDP
  • VRGripperController.cs — mapea la fuerza de pellizco a un valor de agarre normalizado [0, 1]
  • VRTeleoperationManager.cs — gestión del ciclo de vida, UI de estado de conexión, reconexión automática

Tú haces No necesitas recompilar la aplicación de Unity para cambiar entre brazos robóticos. Expón los siguientes campos como campos de Inspector serializados y ajústalos desde el Editor de Unity o a través de un archivo de configuración:

Campo del Inspector Valor inicial de AgileX Piper Notas
IP objetivo La dirección IP de tu PC Ejecutar ip addr en la PC para encontrarla
positionOffset (m) (0, 0, 0.3) Desplaza el origen del robot virtual; el alcance de Piper es más corto que el xArm6
rotationOffset (deg) (0, 90, 0) Corrección de 90° Y para el marco Piper CAN; ajustar según la orientación del soporte
factorDeEscala 0.75 Reduce el rango de movimiento de la mano para ajustarse al espacio de trabajo de Piper (~600 mm de alcance)
Espacio de trabajo X (mm) ±400 Dejar un margen de 50 mm dentro de los límites físicos
Espacio de trabajo Z (mm) 50 – 700 Mantener Z min por encima de la superficie de la mesa para evitar colisiones
Calibre antes de su primera ejecución en vivo. Mueva el Quest 3 lentamente hacia cada borde del espacio de trabajo previsto y observe la posición comandada en la salida de su terminal. Confirme que el robot se mantenga bien dentro de sus límites de articulación antes de habilitar la transmisión a máxima velocidad.
3

Configuración del servidor UDP de Python

El servidor de Python ejecuta tres hilos concurrentes: un hilo receptor que lee datagramas UDP en bruto, un hilo de seguridad que valida paquetes y los encola, y un hilo de control del robot que drena la cola y llama al controlador del robot. Esta separación asegura que las llamadas lentas al SDK del robot nunca bloqueen la recepción de UDP.

Instalar dependencias:

pip install python-can piper_sdk

# Activate the CAN interface (run once per boot, requires root or dialout group)
bash can_activate.sh can0 1000000

# Verify the interface is UP
ifconfig can0

Ejecutar el servidor de teleoperación:

python3 teleoperation_main.py

El servidor se vincula a 0.0.0.0:8888 (mano derecha) y opcionalmente 0.0.0.0:8889 (mano izquierda). Cuando llega un paquete con el valid indicador establecido, el hilo de control llama robot.set_pose() y robot.set_gripper(). Presione Ctrl-C para activar una parada de emergencia y un apagado limpio.

La estructura simplificada del servidor que muestra cómo interactúan los tres hilos:

# teleoperation_main.py (simplified structure)
importar socket, struct, queue, threading, signal, time
de piper_controller importar PiperController   # swap this to change arms

HOST = "0.0.0.0"
RIGHT_PORT    = 8888
LEFT_PORT     = 8889
QUEUE_MAXSIZE = 3   # drop stale frames if robot is slow
CONTROL_HZ    = 30  # robot command rate

pose_queue     = queue.Queue(maxsize=QUEUE_MAXSIZE)
shutdown_event = threading.Event()

def receptor_udp(port: int):
    """Hilo 1 — recibir datagramas UDP en bruto."""
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.bind((HOST, port))
    sock.settimeout(1.0)
    mientras no shutdown_event.is_set():
        intentar:
            data, _ = sock.recvfrom(256)
            pose = parse_packet(data)
            y pose y pose["válido"]:
                intentar:
                    pose_queue.put_nowait(pose)
                excepto queue.Full:
                    pose_queue.get_nowait()   # drop oldest frame
                    pose_queue.put_nowait(pose)
        excepto socket.timeout:
            continuar
    sock.close()

def bucle_control_robot(robot: PiperController):
    """Hilo 3 — drenar la cola y comandar el robot a CONTROL_HZ."""
    period = 1.0 / CONTROL_HZ
    last_pose = None
    mientras no shutdown_event.is_set():
        t0 = time.monotonic()
        intentar:
            pose = pose_queue.get(timeout=0.1)
            y pose["estop"]:
                robot.emergency_stop()
                continuar
            y pose["válido"]:
                last_pose = pose
                x, y, z = transform_position(pose["posición"])
                roll, pitch, yaw = quat_to_euler(pose["rotación"])
                robot.set_pose(x, y, z, roll, pitch, yaw)
                robot.set_gripper(pose["garra"])
            # tracking lost — hold last known position (do not send zero)
        excepto queue.Empty:
            mercado
        time.sleep(max(0, period - (time.monotonic() - t0)))
El tamaño de la cola controla la latencia frente a la suavidad. A QUEUE_MAXSIZE de 1 da la latencia mínima pero puede sentirse entrecortado. Un valor de 3-5 suaviza el movimiento ante la pérdida de paquetes pero añade hasta 100 ms de latencia extra a 30 Hz. Comienza en 3 y ajusta al gusto.
4

Guía de piper_controller.py

La piper_controller.py el módulo envuelve el AgileX piper_sdk biblioteca de Python. Implementa la interfaz de cinco métodos esperada por teleoperation_main.py. Decisiones de diseño clave:

  • CAN a través de USB: El brazo Piper se comunica a través del bus CAN. El SDK toma un nombre de interfaz CAN (can0) y requiere que la interfaz esté activada antes de conectar.
  • Modo esclavo: Llamando MasterSlaveConfig(0xFC, 0, 0, 0) pone el brazo en modo esclavo para que acepte comandos de posición en streaming.
  • Fijación del espacio de trabajo: Las posiciones están fijadas a las constantes en la parte superior del archivo antes de cada llamada al SDK — estas son la última línea de defensa de software antes del firmware.
  • unidades del SDK: La posición está en micrómetros (entero), la orientación en miligrados — multiplica los valores de mm/deg en punto flotante por 1000 antes de pasarlos a EndEffectorCtrl.
# piper_controller.py
de piper_sdk importar C_PiperInterface
importar math, logging

logger = logging.getLogger(__name__)

# Piper workspace limits (millimetres) — keep 50 mm inside physical limits
X_MIN, X_MAX = -400, 400
Y_MIN, Y_MAX = -400, 400
Z_MIN, Z_MAX =  50,  700

# Maximum joint speed (0–100 %; keep conservative for teleop)
SPEED_PERCENT = 25


clase ControladorPiper:
    """Reemplazo directo para ControladorXArm — misma interfaz de cinco métodos."""

    def __init__(self, can_interface: str = "can0"):
        self.can_interface = can_interface
        self._piper: C_PiperInterface | None = None
        self.connected = False

    def conectar(self) -> bool:
        """Abrir puerto CAN y habilitar modo esclavo."""
        intentar:
            self._piper = C_PiperInterface(
                can_name=self.can_interface,
                judge_flag=False,     # allow 3rd-party CAN adapters
                can_auto_init=True,
                dh_is_offset=1,      # firmware >= V1.6-3
            )
            self._piper.ConnectPort()
            self._piper.MasterSlaveConfig(0xFC, 0, 0, 0)  # enter slave mode
            self.connected = True
            logger.info("Piper conectado en %s", self.can_interface)
            regresar True
        excepto Exception como e:
            logger.error("Error al conectar Piper: %s", e)
            regresar False

    def desconectar(self):
        """Deshabilitar servo y cerrar puerto."""
        y self._piper:
            intentar:
                self._piper.DisableArm(7)   # disable all joints
            excepto Exception:
                mercado
        self.connected = False

    def set_pose(self, x: float, y: float, z: float,
                 roll: float, pitch: float, yaw: float):
        """Mover efector final a (x,y,z) mm con orientación (rollo, inclinación, guiñada) grados."""
        si no self.connected:
            regresar
        x = max(X_MIN, min(X_MAX, x))
        y = max(Y_MIN, min(Y_MAX, y))
        z = max(Z_MIN, min(Z_MAX, z))
        intentar:
            self._piper.EndEffectorCtrl(
                int(x * 1000), int(y * 1000), int(z * 1000),
                int(roll  * 1000),
                int(pitch * 1000),
                int(yaw   * 1000),
                SPEED_PERCENT,
            )
        excepto Exception como e:
            logger.warning("error en set_pose: %s", e)

    def establecer_pinza(self, value: float):
        """Establecer apertura de la pinza. 0.0 = completamente cerrada, 1.0 = completamente abierta."""
        si no self.connected:
            regresar
        GRIPPER_MAX_UM = 70_000   # 70 mm max opening in µm
        target_um = int(value * GRIPPER_MAX_UM)
        intentar:
            self._piper.GripperCtrl(target_um, SPEED_PERCENT, 0x01, 0)
        excepto Exception como e:
            logger.warning("error en set_gripper: %s", e)

    def parada_de_emergencia(self):
        """Deshabilitar inmediatamente todas las articulaciones. Seguro para llamar desde cualquier hilo."""
        y self._piper:
            intentar:
                self._piper.DisableArm(7)
            excepto Exception:
                mercado
Verifica las unidades del SDK para tu versión de firmware. El SDK de Piper evoluciona rápidamente. En algunas versiones de firmware, la posición está en micrómetros (entero); en otras está en milímetros (flotante). Imprimir piper.GetPiperFirmwareVersion() y verificar contra la documentación del desarrollador de AgileX antes de comandar cualquier movimiento.
5

Validación de seguridad y primera sesión

Lee antes de encender. Los comandos de teleoperación VR controlan el robot en tiempo real basado en el movimiento de tu mano. Un positionOffset o scaleFactor calibrado incorrectamente puede hacer que el brazo se mueva instantáneamente a una posición que se dañe a sí mismo o lesione a personas cercanas. Siempre realiza una prueba en seco con la energía del robot APAGADA primero.

Lista de verificación de prueba en seco (energía APAGADA):

  • Inicia el servidor de Python y conecta Quest 3. Observa la transform_position salida impresa en la terminal.
  • Mantén tu mano en el centro del espacio de trabajo previsto. Confirma que los valores XYZ impresos están cerca de la posición de inicio del robot (aproximadamente 0, 0, 300 mm para Piper).
  • Mueve tu mano a cada borde del espacio de trabajo. Confirma que los valores se mantengan dentro de los límites fijados y nunca sean negativos en Z.
  • Presiona el botón de Menú en el Quest para activar el e-stop del software. Confirma emergency_stop() que se llama y el bucle se detiene.

Primera sesión en vivo:

  • Comienza en SPEED_PERCENT = 20 — esta es aproximadamente la velocidad máxima de la articulación de 40°/s.
  • Habilita la energía del servo. Mueve lentamente, manteniéndote cerca de la posición de inicio del brazo durante el primer minuto.
  • Verifica que el movimiento de la mano y el movimiento del robot estén en la misma dirección. Si la muñeca rota hacia atrás, ajusta rotationOffset en ±90° en Y en el Inspector de Unity.
  • Expande gradualmente el rango de movimiento una vez que se confirme que la asignación de dirección es correcta.
  • Mantén un botón de parada de emergencia físico (relé de potencia) al alcance en todo momento.

Se implementan dos rutas de parada de emergencia de software:

  • Botón de menú Quest 3: Establece el Bit 1 del byte de banderas en cada paquete UDP subsiguiente. El servidor de Python llama robot.emergency_stop() inmediatamente.
  • Ctrl-C (SIGINT): El controlador de señales establece el evento de apagado, lo que provoca que el bucle de control llame emergency_stop() y salga de manera limpia.
La pérdida de seguimiento se maneja de manera segura. Cuando el Quest 3 pierde el seguimiento de la mano (la mano sale del campo de visión de la cámara, los dedos se superponen), el valid indicador en el paquete UDP cae a 0. El servidor de Python mantiene la última posición conocida en lugar de llevar el brazo a la posición cero.
6

Interfaz de adaptador — Puerto para cualquier brazo

El patrón de intercambio de controladores se generaliza a cualquier brazo con un SDK de Python. El único requisito es que tu clase de controlador implemente estos cinco métodos con exactamente esta firma:

Método Negocio Contrato
connect() () → bool Abre el canal de comunicación; devuelve True en caso de éxito
disconnect() () → None Desactiva la alimentación del servo y cierra el puerto o socket
set_pose(x, y, z, roll, pitch, yaw) (float×6) → Ninguno Objetivo del efector final cartesiano en mm + grados; debe fijarse internamente
set_gripper(valor) (float) → Ninguno Apertura normalizada 0.0–1.0; mapear a unidades específicas del brazo internamente
emergency_stop() () → None Debe ser seguro llamarlo desde cualquier hilo en cualquier momento

Pasos para agregar un nuevo brazo:

  1. Escribir myarm_controller.py implementando los cinco métodos anteriores utilizando el SDK de su brazo. Codifique en duro los límites del espacio de trabajo y los límites de velocidad como constantes a nivel de módulo.
  2. Prueba unitaria en aislamiento: llama connect(), luego set_pose con una posición segura a 5 cm de casa. Verifica que el brazo se mueva y regrese a la posición esperada.
  3. Cambia la importación en teleoperation_main.py: reemplaza from piper_controller import PiperController con tu nuevo controlador. No se necesitan otros cambios.
  4. Calibra los parámetros del Inspector de Unity (positionOffset, rotationOffset, scaleFactor) para el espacio de trabajo del nuevo brazo.
  5. Valide el comportamiento de parada de emergencia, la retención de pérdida de seguimiento y las pinzas de espacio de trabajo antes de cualquier sesión de operador.
¿Usando ROS 2 en lugar de un SDK directo? Publicar en un geometry_msgs/PoseStamped tema dentro set_pose — el resto de la arquitectura permanece idéntico. El diseño basado en colas absorbe naturalmente la latencia de un ciclo de publicación/suscripción de ROS 2.

¿Configuración completa?

Verifique las especificaciones completas del paquete UDP o lea la wiki completa para una cobertura más profunda.