Control de Motor Paso a Paso con ESP32 y .NET MAUI

Aprende a construir una aplicacion movil que controla un motor 28BYJ-48 via Bluetooth

πŸ“… Autor: Tostatronic ⏱️ Tiempo estimado: 2-3 horas πŸ“Š Nivel: Intermedio

Bienvenido a este Tutorial

ΒΏAlguna vez has querido controlar un motor desde tu telefono? En este tutorial completo, te guiare paso a paso para crear un sistema que te permita controlar un motor paso a paso 28BYJ-48 desde una aplicacion movil. Usaremos un ESP32 como cerebro del sistema y desarrollaremos una app con .NET MAUI que funciona tanto en Android como en iOS.

Al finalizar este tutorial, tendras un sistema funcional donde podras:

πŸ’‘ ΒΏPara quien es este tutorial?
Este tutorial esta dirigido a makers, estudiantes y desarrolladores que tengan conocimientos basicos de programacion y electronica. No necesitas ser experto, pero si es util tener familiaridad con Arduino y C#.

1Materiales y Herramientas

Antes de comenzar, asegurate de tener todos los materiales necesarios. He dividido la lista en componentes de hardware y software para que puedas verificar facilmente que no te falta nada.

πŸ”§ Hardware Necesario

πŸ’» Software Necesario

⚠️ Importante sobre el telefono:
Tu telefono debe tener Bluetooth Low Energy (BLE). La mayoria de telefonos modernos lo tienen, pero si tu telefono es muy antiguo, podria no ser compatible.

2Conociendo los Componentes

Antes de conectar todo, es importante entender que hace cada componente y como funcionan juntos. Esto te ayudara a solucionar problemas si algo no funciona correctamente.

El Motor 28BYJ-48

El 28BYJ-48 es un motor paso a paso unipolar muy popular en proyectos de electronica por su bajo costo y facilidad de uso. Aqui sus caracteristicas principales:

Caracteristica Valor
Voltaje de operacion 5V DC
Pasos por revolucion (modo full-step) 2048 pasos
Angulo por paso 0.176Β° (5.625Β° / 32)
Relacion de reduccion 1:64
Consumo de corriente ~240mA

El Driver ULN2003

El ESP32 no puede alimentar directamente las bobinas del motor porque requieren mas corriente de la que los pines GPIO pueden proporcionar. El ULN2003 actua como un "amplificador" que recibe las seΓ±ales de bajo voltaje del ESP32 y las convierte en seΓ±ales capaces de activar las bobinas del motor.

πŸ’‘ Tip: Los LEDs del modulo ULN2003 son muy utiles para depurar. Si ves que se encienden en secuencia cuando el motor deberia girar, significa que el ESP32 esta enviando las seΓ±ales correctamente.

El ESP32

El ESP32 es el cerebro del sistema. Es un microcontrolador potente que incluye WiFi y Bluetooth integrados. En este proyecto usaremos su capacidad de Bluetooth Low Energy (BLE) para comunicarse con la aplicacion movil.

3Armando el Circuito

Ahora viene la parte practica. Vamos a conectar todos los componentes siguiendo el diagrama de conexion. Toma tu tiempo y verifica cada conexion antes de encender el sistema.

πŸ”΄ Antes de conectar:
Asegurate de que el ESP32 este desconectado del USB y que la fuente de alimentacion este apagada. Conectar cables con el sistema encendido puede daΓ±ar los componentes.

Diagrama de Conexion

    ╔═══════════════════════════════════════════════════════════════════════╗
    β•‘                         DIAGRAMA DE CONEXION                          β•‘
    β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•

    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚     ESP32       β”‚                    β”‚    ULN2003      β”‚
    β”‚    DevKit V1    β”‚                    β”‚     Driver      β”‚
    β”‚                 β”‚                    β”‚                 β”‚
    β”‚              5V β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ VCC             β”‚
    β”‚             GND β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ GND             β”‚
    β”‚                 β”‚                    β”‚                 β”‚
    β”‚          GPIO14 β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ IN1   β—‹ LED1    β”‚
    β”‚          GPIO27 β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ IN2   β—‹ LED2    β”‚
    β”‚          GPIO26 β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ IN3   β—‹ LED3    β”‚
    β”‚          GPIO25 β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ IN4   β—‹ LED4    β”‚
    β”‚                 β”‚                    β”‚                 β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                    β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
                                           β”‚  β”‚  MOTOR    β”‚  β”‚
                                           β”‚  β”‚ CONNECTOR │──┼──────┐
                                           β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚      β”‚
                                           β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β”‚
                                                                    β”‚
                                                           β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”
                                                           β”‚    28BYJ-48     β”‚
                                                           β”‚  Stepper Motor  β”‚
                                                           β”‚                 β”‚
                                                           β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
                                                           β”‚   β”‚ Conectorβ”‚   β”‚
                                                           β”‚   β”‚ 5 pines β”‚   β”‚
                                                           β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
                                                           β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜


    ╔═══════════════════════════════════════════════════════════════════════╗
    β•‘                    ALIMENTACION EXTERNA (RECOMENDADO)                 β•‘
    β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•

    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚   Fuente 5V     β”‚         β”‚    ULN2003      β”‚
    β”‚    Externa      β”‚         β”‚                 β”‚
    β”‚                 β”‚         β”‚                 β”‚
    β”‚             (+) β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ 5V-12V (Motor)  β”‚
    β”‚             (-) β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ GND             β”‚
    β”‚                 β”‚         β”‚                 β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                        β”‚
                                        β”‚  Β‘IMPORTANTE!
                                        └──────────────────┐
                                                           β”‚
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                    β”‚
    β”‚     ESP32       β”‚                                    β”‚
    β”‚             GND β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
    β”‚                 β”‚     (Conectar GND comun)
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Tabla de Conexiones Rapida

ESP32 Pin ULN2003 Pin Color Sugerido Funcion
GPIO 14 IN1 πŸ”΅ Azul Bobina 1
GPIO 27 IN2 🟣 Morado Bobina 2
GPIO 26 IN3 🟑 Amarillo Bobina 3
GPIO 25 IN4 🟠 Naranja Bobina 4
GND GND ⚫ Negro Tierra comun
3.3V VCC πŸ”΄ Rojo Alimentacion logica

βœ… Verificacion del Circuito

Antes de continuar, verifica:

  1. Todos los cables estan firmemente conectados
  2. No hay cables sueltos o mal conectados
  3. El conector del motor esta bien insertado en el ULN2003
  4. GND del ESP32 esta conectado al GND del ULN2003

4Configurando el Entorno de Desarrollo

Antes de escribir codigo, necesitamos preparar nuestro entorno de desarrollo. Esto incluye instalar el software necesario y configurar las herramientas.

4.1 Configurando Arduino IDE para ESP32

Paso A: Instalar Arduino IDE

Si aun no tienes Arduino IDE, descargalo desde arduino.cc/en/software e instalalo.

Paso B: Agregar soporte para ESP32

  1. Abre Arduino IDE
  2. Ve a File β†’ Preferences
  3. En "Additional Boards Manager URLs", agrega:
    https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
  4. Click en OK

Paso C: Instalar el paquete ESP32

  1. Ve a Tools β†’ Board β†’ Boards Manager
  2. Busca "ESP32"
  3. Instala "ESP32 by Espressif Systems"
  4. Espera a que termine la instalacion

Paso D: Seleccionar la placa correcta

  1. Ve a Tools β†’ Board β†’ ESP32 Arduino
  2. Selecciona "ESP32 Dev Module"
  3. En Tools β†’ Port, selecciona el puerto COM de tu ESP32
πŸ’‘ ΒΏComo saber cual es el puerto correcto?
Desconecta el ESP32 y observa que puertos aparecen. Luego conectalo y el nuevo puerto que aparezca es el de tu ESP32.

4.2 Configurando Visual Studio para .NET MAUI

Paso A: Instalar Visual Studio 2022

Descarga Visual Studio 2022 Community (gratuito) desde visualstudio.microsoft.com

Paso B: Seleccionar cargas de trabajo

Durante la instalacion, asegurate de seleccionar:

  • βœ… Desarrollo de .NET Multi-platform App UI
  • βœ… Desarrollo movil con .NET

Paso C: Verificar .NET 9

Abre una terminal y ejecuta dotnet --version. Deberias ver la version 9.x.x instalada.

5Programando el ESP32

Ahora vamos a cargar el codigo en el ESP32. Este codigo convierte al ESP32 en un servidor Bluetooth que espera comandos de nuestra aplicacion movil y controla el motor en consecuencia.

5.1 Entendiendo el Codigo

Antes de copiar y pegar, dejame explicarte las partes mas importantes del codigo. Esto te ayudara a modificarlo si lo necesitas para tu proyecto.

La Comunicacion Bluetooth BLE

Usamos el estandar Nordic UART Service (NUS), que simula una comunicacion serial sobre Bluetooth. Es como tener un cable USB inalambrico entre tu telefono y el ESP32.

UUID Tipo Descripcion
6E400001-B5A3-F393-... Servicio Identificador del servicio UART
6E400002-B5A3-F393-... RX (Escribir) Para enviar comandos al ESP32
6E400003-B5A3-F393-... TX (Notificar) Para recibir respuestas del ESP32

Los Comandos Disponibles

El ESP32 entiende estos comandos:

Comando Ejemplo Descripcion
MOVE:XXX MOVE:90.0 Gira el motor 90 grados en sentido horario
MOVE:-XXX MOVE:-45.0 Gira 45 grados en sentido antihorario
STOP STOP Detiene el motor inmediatamente
HOME HOME Establece la posicion actual como 0Β°
GETPOS GETPOS Solicita la posicion actual

5.2 Codigo Completo del ESP32

Copia el siguiente codigo completo. No omitas ninguna parte, todo es necesario para que funcione correctamente.

πŸ“ ESP32_MotorControl.ino
/*
 * ESP32 Motor Control - 28BYJ-48 Stepper Motor
 *
 * Control de motor paso a paso 28BYJ-48 via Bluetooth BLE
 * Compatible con la aplicacion .NET MAUI MotorControlApp
 *
 * Hardware:
 * - ESP32 DevKit V1
 * - Motor 28BYJ-48 con driver ULN2003
 *
 * Conexiones:
 * - IN1 -> GPIO 14
 * - IN2 -> GPIO 27
 * - IN3 -> GPIO 26
 * - IN4 -> GPIO 25
 *
 * Autor: Tostatronic
 */

#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>

// ============ CONFIGURACION DEL MOTOR ============

// Pines del motor (ULN2003)
#define IN1 14
#define IN2 27
#define IN3 26
#define IN4 25

// Configuracion del motor 28BYJ-48
// El motor tiene 2048 pasos por revolucion (con reduccion de engranajes)
// En modo half-step son 4096 pasos por revolucion
#define STEPS_PER_REVOLUTION 2048  // Modo full-step
#define STEP_DELAY_US 2000         // Microsegundos entre pasos (ajusta para velocidad)

// ============ CONFIGURACION BLE ============

// UUIDs del servicio UART (Nordic UART Service)
#define SERVICE_UUID           "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"
#define CHARACTERISTIC_UUID_RX "6E400002-B5A3-F393-E0A9-E50E24DCCA9E"  // Recibir datos
#define CHARACTERISTIC_UUID_TX "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"  // Enviar datos

// Nombre del dispositivo BLE
#define DEVICE_NAME "ESP32_Motor_Control"

// ============ VARIABLES GLOBALES ============

BLEServer* pServer = NULL;
BLECharacteristic* pTxCharacteristic = NULL;
bool deviceConnected = false;
bool oldDeviceConnected = false;

// Estado del motor
float currentPositionDegrees = 0.0;
float targetPositionDegrees = 0.0;
bool isMoving = false;
bool stopRequested = false;

// Buffer para comandos
String commandBuffer = "";

// Secuencia de pasos (full-step mode para mayor torque)
// Secuencia: IN1, IN2, IN3, IN4
const int stepSequence[4][4] = {
  {1, 0, 0, 1},
  {1, 1, 0, 0},
  {0, 1, 1, 0},
  {0, 0, 1, 1}
};

int currentStep = 0;

// ============ DECLARACIONES ADELANTADAS ============

void processCommand(String cmd);
void sendData(String data);
void sendPosition();

// ============ CALLBACKS BLE ============

class ServerCallbacks: public BLEServerCallbacks {
    void onConnect(BLEServer* pServer) {
      deviceConnected = true;
      Serial.println("Cliente conectado");
    };

    void onDisconnect(BLEServer* pServer) {
      deviceConnected = false;
      Serial.println("Cliente desconectado");
    }
};

class RxCallbacks: public BLECharacteristicCallbacks {
    void onWrite(BLECharacteristic *pCharacteristic) {
      String rxValue = pCharacteristic->getValue();

      if (rxValue.length() > 0) {
        for (unsigned int i = 0; i < rxValue.length(); i++) {
          char c = rxValue[i];

          if (c == '\n' || c == '\r') {
            if (commandBuffer.length() > 0) {
              processCommand(commandBuffer);
              commandBuffer = "";
            }
          } else {
            commandBuffer += c;
          }
        }
      }
    }
};

// ============ FUNCIONES DEL MOTOR ============

void setupMotorPins() {
  pinMode(IN1, OUTPUT);
  pinMode(IN2, OUTPUT);
  pinMode(IN3, OUTPUT);
  pinMode(IN4, OUTPUT);

  // Apagar todas las bobinas inicialmente
  disableMotor();
}

void disableMotor() {
  digitalWrite(IN1, LOW);
  digitalWrite(IN2, LOW);
  digitalWrite(IN3, LOW);
  digitalWrite(IN4, LOW);
}

void setStep(int step) {
  digitalWrite(IN1, stepSequence[step][0]);
  digitalWrite(IN2, stepSequence[step][1]);
  digitalWrite(IN3, stepSequence[step][2]);
  digitalWrite(IN4, stepSequence[step][3]);
}

void stepMotor(int direction) {
  // direction: 1 = horario, -1 = antihorario
  currentStep += direction;

  if (currentStep > 3) currentStep = 0;
  if (currentStep < 0) currentStep = 3;

  setStep(currentStep);
  delayMicroseconds(STEP_DELAY_US);
}

float degreesToSteps(float degrees) {
  return (degrees / 360.0) * STEPS_PER_REVOLUTION;
}

void moveMotorDegrees(float degrees) {
  if (isMoving) {
    sendData("ERROR:Motor ya en movimiento");
    return;
  }

  isMoving = true;
  stopRequested = false;

  // Guardar posicion inicial y calcular posicion objetivo
  float startPosition = currentPositionDegrees;
  float targetPosition = startPosition + degrees;

  // Calcular pasos necesarios
  long steps = abs((long)round(degreesToSteps(degrees)));
  int direction = (degrees >= 0) ? 1 : -1;
  long stepsCompleted = 0;

  sendData("MOVING:1");
  Serial.print("Moviendo ");
  Serial.print(degrees);
  Serial.print(" grados (");
  Serial.print(steps);
  Serial.println(" pasos)");

  // Ejecutar movimiento
  for (long i = 0; i < steps && !stopRequested; i++) {
    stepMotor(direction);
    stepsCompleted++;

    // Permitir que BLE procese eventos cada 500 pasos
    if (i % 500 == 0) {
      delay(1);
    }
  }

  // Calcular posicion final basada en pasos completados
  float degreesCompleted = (stepsCompleted * 360.0) / STEPS_PER_REVOLUTION;
  currentPositionDegrees = startPosition + (direction * degreesCompleted);

  // Normalizar posicion entre 0 y 360
  while (currentPositionDegrees >= 360.0) currentPositionDegrees -= 360.0;
  while (currentPositionDegrees < 0.0) currentPositionDegrees += 360.0;

  // Redondear a 1 decimal para evitar errores de precision
  currentPositionDegrees = round(currentPositionDegrees * 10.0) / 10.0;

  // Apagar motor para evitar calentamiento
  disableMotor();

  isMoving = false;
  stopRequested = false;

  // Enviar posicion final
  sendPosition();
  sendData("MOVING:0");

  Serial.print("Posicion final: ");
  Serial.println(currentPositionDegrees);
}

void stopMotor() {
  if (isMoving) {
    stopRequested = true;
    Serial.println("Parada solicitada");
  }
  disableMotor();
}

void setHome() {
  currentPositionDegrees = 0.0;
  sendPosition();
  sendData("ACK");
  Serial.println("Home establecido");
}

// ============ FUNCIONES DE COMUNICACION ============

void sendData(String data) {
  if (deviceConnected && pTxCharacteristic != NULL) {
    pTxCharacteristic->setValue(data.c_str());
    pTxCharacteristic->notify();
    delay(10);  // Dar tiempo para que se envie
  }
  Serial.print("TX: ");
  Serial.println(data);
}

void sendPosition() {
  String posStr = "POS:" + String(currentPositionDegrees, 1);
  sendData(posStr);
}

void processCommand(String cmd) {
  cmd.trim();
  cmd.toUpperCase();

  Serial.print("Comando recibido: ");
  Serial.println(cmd);

  if (cmd.startsWith("MOVE:")) {
    // Comando de movimiento: MOVE:XXX.X
    String valueStr = cmd.substring(5);
    float degrees = valueStr.toFloat();

    sendData("ACK");
    moveMotorDegrees(degrees);
  }
  else if (cmd == "STOP") {
    stopMotor();
    sendData("ACK");
    sendPosition();
  }
  else if (cmd == "HOME") {
    setHome();
  }
  else if (cmd == "GETPOS") {
    sendPosition();
  }
  else if (cmd == "STATUS") {
    // Enviar estado completo
    sendPosition();
    sendData(isMoving ? "MOVING:1" : "MOVING:0");
  }
  else {
    sendData("ERROR:Comando no reconocido");
  }
}

// ============ CONFIGURACION BLE ============

void setupBLE() {
  Serial.println("Iniciando BLE...");

  // Inicializar BLE
  BLEDevice::init(DEVICE_NAME);

  // Crear servidor
  pServer = BLEDevice::createServer();
  pServer->setCallbacks(new ServerCallbacks());

  // Crear servicio
  BLEService *pService = pServer->createService(SERVICE_UUID);

  // Crear caracteristica TX (notificaciones hacia la app)
  pTxCharacteristic = pService->createCharacteristic(
    CHARACTERISTIC_UUID_TX,
    BLECharacteristic::PROPERTY_NOTIFY
  );
  pTxCharacteristic->addDescriptor(new BLE2902());

  // Crear caracteristica RX (recibir comandos de la app)
  BLECharacteristic *pRxCharacteristic = pService->createCharacteristic(
    CHARACTERISTIC_UUID_RX,
    BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_WRITE_NR
  );
  pRxCharacteristic->setCallbacks(new RxCallbacks());

  // Iniciar servicio
  pService->start();

  // Iniciar advertising
  BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
  pAdvertising->addServiceUUID(SERVICE_UUID);
  pAdvertising->setScanResponse(true);
  pAdvertising->setMinPreferred(0x06);
  pAdvertising->setMinPreferred(0x12);
  BLEDevice::startAdvertising();

  Serial.println("BLE listo - Esperando conexion...");
  Serial.print("Nombre del dispositivo: ");
  Serial.println(DEVICE_NAME);
}

// ============ SETUP Y LOOP ============

void setup() {
  Serial.begin(115200);
  Serial.println("\n=== ESP32 Motor Control ===");
  Serial.println("Motor: 28BYJ-48");
  Serial.println("Driver: ULN2003");

  // Configurar pines del motor
  setupMotorPins();

  // Configurar BLE
  setupBLE();

  Serial.println("\nSistema listo!");
  Serial.println("Comandos disponibles:");
  Serial.println("  MOVE:XXX.X - Mover XXX.X grados");
  Serial.println("  STOP       - Detener motor");
  Serial.println("  HOME       - Establecer posicion actual como 0");
  Serial.println("  GETPOS     - Obtener posicion actual");
  Serial.println("  STATUS     - Obtener estado completo");
}

void loop() {
  // Manejar reconexion BLE
  if (!deviceConnected && oldDeviceConnected) {
    delay(500);  // Dar tiempo al stack BLE
    pServer->startAdvertising();
    Serial.println("Reiniciando advertising...");
    oldDeviceConnected = deviceConnected;
  }

  if (deviceConnected && !oldDeviceConnected) {
    oldDeviceConnected = deviceConnected;
    // Enviar estado inicial
    delay(100);
    sendPosition();
  }

  // Leer comandos desde Serial (para debug)
  if (Serial.available()) {
    String serialCmd = Serial.readStringUntil('\n');
    serialCmd.trim();
    if (serialCmd.length() > 0) {
      processCommand(serialCmd);
    }
  }

  delay(10);  // Pequena pausa para estabilidad
}

5.3 Cargando el Codigo al ESP32

Sigue estos pasos:

  1. Conecta el ESP32 a tu computadora con el cable USB
  2. Abre Arduino IDE
  3. Copia y pega el codigo anterior en un nuevo sketch
  4. Ve a Tools β†’ Board y selecciona "ESP32 Dev Module"
  5. Ve a Tools β†’ Port y selecciona el puerto de tu ESP32
  6. Haz clic en el boton Upload (flecha hacia la derecha)
  7. Espera a que termine la compilacion y carga

5.4 Probando el ESP32

Antes de pasar a la aplicacion movil, verifiquemos que el ESP32 funciona correctamente.

Prueba con el Monitor Serial:

  1. Abre el Monitor Serial: Tools β†’ Serial Monitor
  2. Configura la velocidad a 115200 baud
  3. Deberias ver el mensaje "=== ESP32 Motor Control ==="
  4. Escribe MOVE:90 y presiona Enter
  5. El motor deberia girar 90 grados
βœ… Si el motor giro: Β‘Felicidades! El hardware esta funcionando correctamente. Puedes continuar con la aplicacion movil.
❌ Si el motor NO giro: Revisa las conexiones, especialmente los pines GPIO 14, 25, 26, 27 y las conexiones de tierra (GND).

6Creando el Proyecto .NET MAUI

Ahora vamos a crear la aplicacion movil. Usaremos .NET MAUI que nos permite crear una sola aplicacion que funciona en Android e iOS.

6.1 Crear el Proyecto

En Visual Studio:

  1. Abre Visual Studio 2022
  2. Selecciona "Create a new project"
  3. Busca ".NET MAUI App" y seleccionalo
  4. Nombra el proyecto: MotorControlApp
  5. Selecciona .NET 9.0 como framework
  6. Click en Create

6.2 Instalar Paquetes NuGet

Necesitamos instalar dos paquetes adicionales para el Bluetooth y el patron MVVM.

Instalar paquetes:

  1. Click derecho en el proyecto β†’ Manage NuGet Packages
  2. Busca e instala: Plugin.BLE (version 3.1.0)
  3. Busca e instala: CommunityToolkit.Mvvm (version 8.4.0)

6.3 Estructura del Proyecto

Vamos a crear la siguiente estructura de carpetas para organizar nuestro codigo:

πŸ“‚ Estructura del Proyecto
MotorControlApp/
β”œβ”€β”€ App.xaml
β”œβ”€β”€ App.xaml.cs
β”œβ”€β”€ MauiProgram.cs
β”œβ”€β”€ MainPage.xaml
β”œβ”€β”€ MainPage.xaml.cs
β”œβ”€β”€ Services/                   ← Crear esta carpeta
β”‚   β”œβ”€β”€ IBluetoothService.cs
β”‚   └── BluetoothService.cs
β”œβ”€β”€ ViewModels/                 ← Crear esta carpeta
β”‚   └── MainViewModel.cs
└── Platforms/
    └── Android/
        └── AndroidManifest.xml 

Crear las carpetas:

  1. Click derecho en el proyecto β†’ Add β†’ New Folder
  2. Nombra la carpeta Services
  3. Repite para crear la carpeta ViewModels

7Desarrollando la Aplicacion Movil

Ahora viene la parte mas larga: escribir el codigo de la aplicacion. He dividido esto en archivos para que sea mas facil de seguir.

7.1 Interface del Servicio Bluetooth

Primero, creamos la interfaz que define las operaciones Bluetooth disponibles.

Crear el archivo:

  1. Click derecho en carpeta Services
  2. Add β†’ Class
  3. Nombrar: IBluetoothService.cs
πŸ“ Services/IBluetoothService.cs
using Plugin.BLE.Abstractions;
using Plugin.BLE.Abstractions.Contracts;

namespace MotorControlApp.Services;

public interface IBluetoothService
{
    bool IsBluetoothEnabled { get; }
    bool IsConnected { get; }
    string? ConnectedDeviceName { get; }
    BluetoothState State { get; }

    event EventHandler<string>? DataReceived;
    event EventHandler<bool>? ConnectionStateChanged;

    Task<bool> CheckAndRequestPermissionsAsync();
    Task<IEnumerable<IDevice>> ScanForDevicesAsync(CancellationToken cancellationToken = default);
    Task<bool> ConnectToDeviceAsync(IDevice device);
    Task DisconnectAsync();
    Task<bool> SendDataAsync(string data);
}

7.2 Implementacion del Servicio Bluetooth

Esta es la implementacion real de la comunicacion Bluetooth.

Crear el archivo:

  1. Click derecho en carpeta Services
  2. Add β†’ Class
  3. Nombrar: BluetoothService.cs
πŸ“ Services/BluetoothService.cs
using Plugin.BLE;
using Plugin.BLE.Abstractions;
using Plugin.BLE.Abstractions.Contracts;
using Plugin.BLE.Abstractions.EventArgs;
using System.Text;

namespace MotorControlApp.Services;

public class BluetoothService : IBluetoothService
{
    // Nordic UART Service UUIDs
    private static readonly Guid UartServiceUuid = Guid.Parse("6E400001-B5A3-F393-E0A9-E50E24DCCA9E");
    private static readonly Guid UartTxCharacteristicUuid = Guid.Parse("6E400002-B5A3-F393-E0A9-E50E24DCCA9E");
    private static readonly Guid UartRxCharacteristicUuid = Guid.Parse("6E400003-B5A3-F393-E0A9-E50E24DCCA9E");

    private readonly IBluetoothLE _bluetoothLE;
    private readonly IAdapter _adapter;
    private IDevice? _connectedDevice;
    private ICharacteristic? _txCharacteristic;
    private ICharacteristic? _rxCharacteristic;
    private readonly List<IDevice> _discoveredDevices = new();

    public bool IsBluetoothEnabled => _bluetoothLE.State == BluetoothState.On;
    public bool IsConnected => _connectedDevice != null && _connectedDevice.State == DeviceState.Connected;
    public string? ConnectedDeviceName => _connectedDevice?.Name;
    public BluetoothState State => _bluetoothLE.State;

    public event EventHandler<string>? DataReceived;
    public event EventHandler<bool>? ConnectionStateChanged;

    public BluetoothService()
    {
        _bluetoothLE = CrossBluetoothLE.Current;
        _adapter = CrossBluetoothLE.Current.Adapter;

        _adapter.DeviceDiscovered += OnDeviceDiscovered;
        _adapter.DeviceConnected += OnDeviceConnected;
        _adapter.DeviceDisconnected += OnDeviceDisconnected;
    }

    public async Task<bool> CheckAndRequestPermissionsAsync()
    {
        try
        {
            var status = await Permissions.CheckStatusAsync<Permissions.Bluetooth>();
            if (status != PermissionStatus.Granted)
            {
                status = await Permissions.RequestAsync<Permissions.Bluetooth>();
            }

            var locationStatus = await Permissions.CheckStatusAsync<Permissions.LocationWhenInUse>();
            if (locationStatus != PermissionStatus.Granted)
            {
                locationStatus = await Permissions.RequestAsync<Permissions.LocationWhenInUse>();
            }

            return status == PermissionStatus.Granted && locationStatus == PermissionStatus.Granted;
        }
        catch (Exception ex)
        {
            System.Diagnostics.Debug.WriteLine($"Error requesting permissions: {ex.Message}");
            return false;
        }
    }

    public async Task<IEnumerable<IDevice>> ScanForDevicesAsync(CancellationToken cancellationToken = default)
    {
        _discoveredDevices.Clear();

        try
        {
            var scanCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
            scanCts.CancelAfter(TimeSpan.FromSeconds(10));

            await _adapter.StartScanningForDevicesAsync(
                serviceUuids: null,
                deviceFilter: device => !string.IsNullOrEmpty(device.Name),
                allowDuplicatesKey: false,
                cancellationToken: scanCts.Token
            );
        }
        catch (OperationCanceledException)
        {
            // Scan timeout - this is expected
        }
        catch (Exception ex)
        {
            System.Diagnostics.Debug.WriteLine($"Scan error: {ex.Message}");
        }

        return _discoveredDevices.ToList();
    }

    private void OnDeviceDiscovered(object? sender, DeviceEventArgs e)
    {
        if (!_discoveredDevices.Any(d => d.Id == e.Device.Id))
        {
            _discoveredDevices.Add(e.Device);
        }
    }

    public async Task<bool> ConnectToDeviceAsync(IDevice device)
    {
        try
        {
            await _adapter.StopScanningForDevicesAsync();

            var connectParameters = new ConnectParameters(
                autoConnect: false,
                forceBleTransport: true
            );

            await _adapter.ConnectToDeviceAsync(device, connectParameters);
            _connectedDevice = device;

            // Get UART service
            var service = await device.GetServiceAsync(UartServiceUuid);
            if (service == null)
            {
                System.Diagnostics.Debug.WriteLine("UART service not found");
                await DisconnectAsync();
                return false;
            }

            // Get TX characteristic (to write commands)
            _txCharacteristic = await service.GetCharacteristicAsync(UartTxCharacteristicUuid);

            // Get RX characteristic (to receive notifications)
            _rxCharacteristic = await service.GetCharacteristicAsync(UartRxCharacteristicUuid);

            if (_txCharacteristic == null || _rxCharacteristic == null)
            {
                System.Diagnostics.Debug.WriteLine("UART characteristics not found");
                await DisconnectAsync();
                return false;
            }

            // Subscribe to notifications
            _rxCharacteristic.ValueUpdated += OnCharacteristicValueUpdated;
            await _rxCharacteristic.StartUpdatesAsync();

            ConnectionStateChanged?.Invoke(this, true);
            return true;
        }
        catch (Exception ex)
        {
            System.Diagnostics.Debug.WriteLine($"Connection error: {ex.Message}");
            await DisconnectAsync();
            return false;
        }
    }

    private void OnCharacteristicValueUpdated(object? sender, CharacteristicUpdatedEventArgs e)
    {
        try
        {
            var bytes = e.Characteristic.Value;
            if (bytes != null && bytes.Length > 0)
            {
                var data = Encoding.UTF8.GetString(bytes);
                DataReceived?.Invoke(this, data);
            }
        }
        catch (Exception ex)
        {
            System.Diagnostics.Debug.WriteLine($"Error processing received data: {ex.Message}");
        }
    }

    public async Task DisconnectAsync()
    {
        try
        {
            if (_rxCharacteristic != null)
            {
                _rxCharacteristic.ValueUpdated -= OnCharacteristicValueUpdated;
                await _rxCharacteristic.StopUpdatesAsync();
            }

            if (_connectedDevice != null)
            {
                await _adapter.DisconnectDeviceAsync(_connectedDevice);
            }
        }
        catch (Exception ex)
        {
            System.Diagnostics.Debug.WriteLine($"Disconnect error: {ex.Message}");
        }
        finally
        {
            _connectedDevice = null;
            _txCharacteristic = null;
            _rxCharacteristic = null;
            ConnectionStateChanged?.Invoke(this, false);
        }
    }

    public async Task<bool> SendDataAsync(string data)
    {
        if (_txCharacteristic == null || !IsConnected)
        {
            return false;
        }

        try
        {
            var bytes = Encoding.UTF8.GetBytes(data);
            await _txCharacteristic.WriteAsync(bytes);
            return true;
        }
        catch (Exception ex)
        {
            System.Diagnostics.Debug.WriteLine($"Send error: {ex.Message}");
            return false;
        }
    }

    private void OnDeviceConnected(object? sender, DeviceEventArgs e)
    {
        System.Diagnostics.Debug.WriteLine($"Device connected: {e.Device.Name}");
    }

    private void OnDeviceDisconnected(object? sender, DeviceEventArgs e)
    {
        if (_connectedDevice?.Id == e.Device.Id)
        {
            MainThread.BeginInvokeOnMainThread(async () =>
            {
                await DisconnectAsync();
            });
        }
    }
}

7.3 ViewModel Principal

El ViewModel contiene toda la logica de la interfaz de usuario.

Crear el archivo:

  1. Click derecho en carpeta ViewModels
  2. Add β†’ Class
  3. Nombrar: MainViewModel.cs
πŸ“ ViewModels/MainViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MotorControlApp.Services;
using Plugin.BLE.Abstractions.Contracts;
using System.Collections.ObjectModel;

namespace MotorControlApp.ViewModels;

public partial class MainViewModel : ObservableObject
{
    private readonly IBluetoothService _bluetoothService;

    [ObservableProperty]
    private bool _isScanning;

    [ObservableProperty]
    private bool _isConnecting;

    [ObservableProperty]
    private bool _isConnected;

    [ObservableProperty]
    private string _connectionStatus = "Desconectado";

    [ObservableProperty]
    private string _connectedDeviceName = string.Empty;

    [ObservableProperty]
    private IDevice? _selectedDevice;

    [ObservableProperty]
    private string _targetDegrees = "0";

    [ObservableProperty]
    private string _currentPosition = "0";

    [ObservableProperty]
    private bool _isMotorMoving;

    [ObservableProperty]
    private string _statusMessage = string.Empty;

    [ObservableProperty]
    private bool _canSendCommand;

    [ObservableProperty]
    private bool _canConnect;

    [ObservableProperty]
    private bool _canScan = true;

    public ObservableCollection<IDevice> DiscoveredDevices { get; } = new();

    public MainViewModel(IBluetoothService bluetoothService)
    {
        _bluetoothService = bluetoothService;
        _bluetoothService.DataReceived += OnDataReceived;
        _bluetoothService.ConnectionStateChanged += OnConnectionStateChanged;

        UpdateCommandStates();
    }

    private void OnConnectionStateChanged(object? sender, bool isConnected)
    {
        MainThread.BeginInvokeOnMainThread(() =>
        {
            IsConnected = isConnected;
            if (isConnected)
            {
                ConnectionStatus = "Conectado";
                ConnectedDeviceName = _bluetoothService.ConnectedDeviceName ?? "Dispositivo";
                StatusMessage = "Conexion exitosa";
            }
            else
            {
                ConnectionStatus = "Desconectado";
                ConnectedDeviceName = string.Empty;
                StatusMessage = "Conexion perdida";
            }
            UpdateCommandStates();
        });
    }

    private void OnDataReceived(object? sender, string data)
    {
        MainThread.BeginInvokeOnMainThread(() =>
        {
            ProcessReceivedData(data);
        });
    }

    private void ProcessReceivedData(string data)
    {
        var trimmedData = data.Trim();

        if (trimmedData.StartsWith("POS:"))
        {
            var posStr = trimmedData.Substring(4);
            CurrentPosition = posStr;
            IsMotorMoving = false;
            StatusMessage = $"Posicion actual: {posStr} grados";
        }
        else if (trimmedData.StartsWith("MOVING:"))
        {
            var movingStr = trimmedData.Substring(7);
            IsMotorMoving = movingStr == "1";
            StatusMessage = IsMotorMoving ? "Motor en movimiento..." : "Motor detenido";
        }
        else if (trimmedData == "ACK")
        {
            StatusMessage = "Comando recibido";
        }
        else if (trimmedData.StartsWith("ERROR:"))
        {
            StatusMessage = $"Error: {trimmedData.Substring(6)}";
        }
    }

    [RelayCommand]
    private async Task ScanForDevicesAsync()
    {
        if (IsScanning) return;

        try
        {
            IsScanning = true;
            StatusMessage = "Verificando permisos...";

            var hasPermissions = await _bluetoothService.CheckAndRequestPermissionsAsync();
            if (!hasPermissions)
            {
                StatusMessage = "Se requieren permisos de Bluetooth y Ubicacion";
                return;
            }

            if (!_bluetoothService.IsBluetoothEnabled)
            {
                StatusMessage = "Activa el Bluetooth en tu dispositivo";
                return;
            }

            StatusMessage = "Buscando dispositivos...";
            DiscoveredDevices.Clear();

            var devices = await _bluetoothService.ScanForDevicesAsync();

            foreach (var device in devices)
            {
                DiscoveredDevices.Add(device);
            }

            if (DiscoveredDevices.Count == 0)
            {
                StatusMessage = "No se encontraron dispositivos. Verifica que el ESP32 este encendido.";
            }
            else
            {
                StatusMessage = $"Se encontraron {DiscoveredDevices.Count} dispositivos";
            }
        }
        catch (Exception ex)
        {
            StatusMessage = $"Error al escanear: {ex.Message}";
        }
        finally
        {
            IsScanning = false;
        }
    }

    [RelayCommand]
    private async Task ConnectToDeviceAsync()
    {
        if (SelectedDevice == null || IsConnecting) return;

        try
        {
            IsConnecting = true;
            StatusMessage = $"Conectando a {SelectedDevice.Name}...";

            var success = await _bluetoothService.ConnectToDeviceAsync(SelectedDevice);

            if (!success)
            {
                StatusMessage = "Error al conectar. Verifica que el dispositivo este encendido.";
            }
        }
        catch (Exception ex)
        {
            StatusMessage = $"Error de conexion: {ex.Message}";
        }
        finally
        {
            IsConnecting = false;
        }
    }

    [RelayCommand]
    private async Task DisconnectAsync()
    {
        await _bluetoothService.DisconnectAsync();
        StatusMessage = "Desconectado";
    }

    [RelayCommand]
    private async Task SendMoveCommandAsync()
    {
        if (!IsConnected || string.IsNullOrEmpty(TargetDegrees)) return;

        if (!double.TryParse(TargetDegrees, out var degrees))
        {
            StatusMessage = "Ingresa un valor numerico valido";
            return;
        }

        var command = $"MOVE:{degrees:F1}\n";
        var success = await _bluetoothService.SendDataAsync(command);

        if (success)
        {
            IsMotorMoving = true;
            StatusMessage = $"Enviando comando: girar {degrees} grados";
        }
        else
        {
            StatusMessage = "Error al enviar comando";
        }
    }

    [RelayCommand]
    private async Task StopMotorAsync()
    {
        if (!IsConnected) return;

        var success = await _bluetoothService.SendDataAsync("STOP\n");

        if (success)
        {
            StatusMessage = "Comando de parada enviado";
        }
        else
        {
            StatusMessage = "Error al enviar comando de parada";
        }
    }

    [RelayCommand]
    private async Task SetHomePositionAsync()
    {
        if (!IsConnected) return;

        var success = await _bluetoothService.SendDataAsync("HOME\n");

        if (success)
        {
            CurrentPosition = "0";
            StatusMessage = "Posicion home establecida";
        }
        else
        {
            StatusMessage = "Error al establecer home";
        }
    }

    [RelayCommand]
    private async Task RequestPositionAsync()
    {
        if (!IsConnected) return;

        var success = await _bluetoothService.SendDataAsync("GETPOS\n");

        if (!success)
        {
            StatusMessage = "Error al solicitar posicion";
        }
    }

    partial void OnIsConnectedChanged(bool value)
    {
        UpdateCommandStates();
    }

    partial void OnIsMotorMovingChanged(bool value)
    {
        UpdateCommandStates();
    }

    partial void OnSelectedDeviceChanged(IDevice? value)
    {
        UpdateCommandStates();
    }

    partial void OnIsScanningChanged(bool value)
    {
        UpdateCommandStates();
    }

    partial void OnIsConnectingChanged(bool value)
    {
        UpdateCommandStates();
    }

    private void UpdateCommandStates()
    {
        CanSendCommand = IsConnected && !IsMotorMoving;
        CanConnect = SelectedDevice != null && !IsConnected && !IsConnecting;
        CanScan = !IsScanning && !IsConnected && !IsConnecting;
    }
}

7.4 Interfaz de Usuario (XAML)

Ahora reemplazamos el contenido de MainPage.xaml con nuestra interfaz.

πŸ“ MainPage.xaml
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:viewmodels="clr-namespace:MotorControlApp.ViewModels"
             x:Class="MotorControlApp.MainPage"
             x:DataType="viewmodels:MainViewModel"
             BackgroundColor="#F5F5F5">

    <ScrollView>
        <VerticalStackLayout Padding="20" Spacing="16">

            <!-- Header -->
            <Label Text="Control Motor 28BYJ-48"
                   TextColor="#212121"
                   FontSize="24"
                   FontAttributes="Bold"
                   HorizontalOptions="Center" />

            <Label Text="Control via Bluetooth ESP32"
                   TextColor="#757575"
                   FontSize="16"
                   HorizontalOptions="Center" />

            <!-- Bluetooth Connection Section -->
            <Frame BackgroundColor="#FFFFFF" BorderColor="#E0E0E0" CornerRadius="12" Padding="16" HasShadow="False">
                <VerticalStackLayout Spacing="12">

                    <Label Text="Conexion Bluetooth"
                           TextColor="#2196F3"
                           FontSize="18"
                           FontAttributes="Bold" />

                    <!-- Connection Status -->
                    <HorizontalStackLayout Spacing="8" VerticalOptions="Center">
                        <BoxView WidthRequest="12"
                                 HeightRequest="12"
                                 CornerRadius="6"
                                 Color="{Binding IsConnected, Converter={StaticResource BoolToColorConverter}}" />
                        <Label Text="{Binding ConnectionStatus}"
                               VerticalOptions="Center" />
                        <Label Text="{Binding ConnectedDeviceName}"
                               TextColor="#2196F3"
                               FontAttributes="Bold"
                               IsVisible="{Binding IsConnected}"
                               VerticalOptions="Center" />
                    </HorizontalStackLayout>

                    <!-- Scan Button -->
                    <Button Text="{Binding IsScanning, Converter={StaticResource BoolToScanTextConverter}}"
                            Command="{Binding ScanForDevicesCommand}"
                            IsEnabled="{Binding CanScan}"
                            BackgroundColor="#2196F3"
                            TextColor="#FFFFFF" />

                    <!-- Device List -->
                    <Label Text="Dispositivos encontrados:"
                           IsVisible="{Binding DiscoveredDevices.Count, Converter={StaticResource IntToBoolConverter}}" />

                    <CollectionView ItemsSource="{Binding DiscoveredDevices}"
                                    SelectionMode="Single"
                                    SelectedItem="{Binding SelectedDevice}"
                                    HeightRequest="150"
                                    IsVisible="{Binding DiscoveredDevices.Count, Converter={StaticResource IntToBoolConverter}}">
                        <CollectionView.ItemTemplate>
                            <DataTemplate x:DataType="{x:Null}">
                                <Border Padding="12"
                                        Margin="0,4"
                                        BackgroundColor="#F5F5F5"
                                        Stroke="#E0E0E0"
                                        StrokeShape="RoundRectangle 8">
                                    <VerticalStackLayout>
                                        <Label Text="{Binding Name}"
                                               FontAttributes="Bold"
                                               FontSize="16"
                                               TextColor="#212121" />
                                        <Label Text="{Binding Id}"
                                               FontSize="12"
                                               TextColor="#757575" />
                                    </VerticalStackLayout>
                                </Border>
                            </DataTemplate>
                        </CollectionView.ItemTemplate>
                    </CollectionView>

                    <!-- Connect/Disconnect Buttons -->
                    <Grid ColumnDefinitions="*,*" ColumnSpacing="12">
                        <Button Grid.Column="0"
                                Text="Conectar"
                                Command="{Binding ConnectToDeviceCommand}"
                                IsEnabled="{Binding CanConnect}"
                                IsVisible="{Binding IsConnected, Converter={StaticResource InverseBoolConverter}}"
                                BackgroundColor="#2196F3"
                                TextColor="#FFFFFF" />

                        <Button Grid.Column="0"
                                Text="Desconectar"
                                BackgroundColor="#F44336"
                                TextColor="#FFFFFF"
                                Command="{Binding DisconnectCommand}"
                                IsVisible="{Binding IsConnected}" />

                        <ActivityIndicator Grid.Column="1"
                                           IsRunning="{Binding IsConnecting}"
                                           IsVisible="{Binding IsConnecting}"
                                           HorizontalOptions="Start"
                                           VerticalOptions="Center"
                                           Color="#2196F3" />
                    </Grid>

                </VerticalStackLayout>
            </Frame>

            <!-- Motor Control Section -->
            <Frame IsVisible="{Binding IsConnected}" BackgroundColor="#FFFFFF" BorderColor="#E0E0E0" CornerRadius="12" Padding="16" HasShadow="False">
                <VerticalStackLayout Spacing="16">

                    <Label Text="Control del Motor"
                           TextColor="#2196F3"
                           FontSize="18"
                           FontAttributes="Bold" />

                    <!-- Current Position Display -->
                    <Frame BackgroundColor="#BBDEFB" Padding="20" BorderColor="#BBDEFB" HasShadow="False">
                        <VerticalStackLayout HorizontalOptions="Center">
                            <Label Text="Posicion Actual"
                                   HorizontalOptions="Center"
                                   TextColor="#1976D2" />
                            <HorizontalStackLayout HorizontalOptions="Center" Spacing="4">
                                <Label Text="{Binding CurrentPosition}"
                                       FontSize="48"
                                       FontAttributes="Bold"
                                       TextColor="#1976D2"
                                       HorizontalOptions="Center" />
                                <Label Text="grados"
                                       FontSize="16"
                                       TextColor="#1976D2"
                                       VerticalOptions="Center" />
                            </HorizontalStackLayout>
                            <ActivityIndicator IsRunning="{Binding IsMotorMoving}"
                                               IsVisible="{Binding IsMotorMoving}"
                                               HeightRequest="20"
                                               Color="#1976D2" />
                            <Label Text="En movimiento..."
                                   IsVisible="{Binding IsMotorMoving}"
                                   HorizontalOptions="Center"
                                   TextColor="#1976D2"
                                   FontSize="12" />
                        </VerticalStackLayout>
                    </Frame>

                    <!-- Target Degrees Input -->
                    <VerticalStackLayout Spacing="8">
                        <Label Text="Grados a girar:" />
                        <Grid ColumnDefinitions="*,Auto" ColumnSpacing="12">
                            <Entry Grid.Column="0"
                                   Text="{Binding TargetDegrees}"
                                   Keyboard="Numeric"
                                   Placeholder="Ej: 90, -45, 360"
                                   BackgroundColor="#FFFFFF" />
                            <Label Grid.Column="1"
                                   Text="grados"
                                   FontSize="16"
                                   VerticalOptions="Center" />
                        </Grid>
                        <Label Text="Positivo = horario, Negativo = antihorario"
                               FontSize="12"
                               TextColor="#757575" />
                    </VerticalStackLayout>

                    <!-- Control Buttons -->
                    <Button Text="Girar Motor"
                            Command="{Binding SendMoveCommandCommand}"
                            IsEnabled="{Binding CanSendCommand}"
                            BackgroundColor="#2196F3"
                            TextColor="#FFFFFF" />

                    <Grid ColumnDefinitions="*,*" ColumnSpacing="12">
                        <Button Grid.Column="0"
                                Text="Detener"
                                BackgroundColor="#FFC107"
                                TextColor="#000000"
                                Command="{Binding StopMotorCommand}" />

                        <Button Grid.Column="1"
                                Text="Establecer Home"
                                BackgroundColor="#FFFFFF"
                                TextColor="#2196F3"
                                BorderColor="#2196F3"
                                BorderWidth="2"
                                Command="{Binding SetHomePositionCommand}" />
                    </Grid>

                    <Button Text="Actualizar Posicion"
                            BackgroundColor="#FFFFFF"
                            TextColor="#2196F3"
                            BorderColor="#2196F3"
                            BorderWidth="2"
                            Command="{Binding RequestPositionCommand}" />

                </VerticalStackLayout>
            </Frame>

            <!-- Status Message -->
            <Frame BackgroundColor="#EEEEEE" Padding="12" BorderColor="#E0E0E0" HasShadow="False">
                <Label Text="{Binding StatusMessage}"
                       HorizontalOptions="Center"
                       TextColor="#757575" />
            </Frame>

        </VerticalStackLayout>
    </ScrollView>

</ContentPage>

7.5 Code-Behind y Convertidores

Reemplaza el contenido de MainPage.xaml.cs:

πŸ“ MainPage.xaml.cs
using MotorControlApp.ViewModels;
using System.Globalization;

namespace MotorControlApp;

public partial class MainPage : ContentPage
{
    public MainPage(MainViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = viewModel;
    }
}

// Converter: bool to color (green/red for connection status)
public class BoolToColorConverter : IValueConverter
{
    public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
    {
        if (value is bool isConnected)
        {
            return isConnected ? Colors.Green : Colors.Red;
        }
        return Colors.Gray;
    }

    public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

// Converter: bool to scan button text
public class BoolToScanTextConverter : IValueConverter
{
    public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
    {
        if (value is bool isScanning)
        {
            return isScanning ? "Buscando..." : "Buscar Dispositivos";
        }
        return "Buscar Dispositivos";
    }

    public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

// Converter: int to bool (for visibility based on count)
public class IntToBoolConverter : IValueConverter
{
    public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
    {
        if (value is int count)
        {
            return count > 0;
        }
        return false;
    }

    public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

// Converter: inverse bool
public class InverseBoolConverter : IValueConverter
{
    public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
    {
        if (value is bool boolValue)
        {
            return !boolValue;
        }
        return true;
    }

    public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

7.6 Configuracion de la Aplicacion

Reemplaza el contenido de App.xaml:

πŸ“ App.xaml
<?xml version = "1.0" encoding = "UTF-8" ?>
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:MotorControlApp"
             x:Class="MotorControlApp.App">
    <Application.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="Resources/Styles/Colors.xaml" />
                <ResourceDictionary Source="Resources/Styles/Styles.xaml" />
            </ResourceDictionary.MergedDictionaries>

            <!-- Converters -->
            <local:BoolToColorConverter x:Key="BoolToColorConverter" />
            <local:BoolToScanTextConverter x:Key="BoolToScanTextConverter" />
            <local:IntToBoolConverter x:Key="IntToBoolConverter" />
            <local:InverseBoolConverter x:Key="InverseBoolConverter" />
        </ResourceDictionary>
    </Application.Resources>
</Application>

7.7 MauiProgram.cs (Inyeccion de Dependencias)

Reemplaza el contenido de MauiProgram.cs:

πŸ“ MauiProgram.cs
using Microsoft.Extensions.Logging;
using MotorControlApp.Services;
using MotorControlApp.ViewModels;

namespace MotorControlApp;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
            });

        // Register services
        builder.Services.AddSingleton<IBluetoothService, BluetoothService>();

        // Register ViewModels
        builder.Services.AddSingleton<MainViewModel>();

        // Register Pages
        builder.Services.AddSingleton<MainPage>();

#if DEBUG
        builder.Logging.AddDebug();
#endif

        return builder.Build();
    }
}

7.8 Permisos de Android

Para que la aplicacion pueda usar Bluetooth en Android, necesitamos configurar los permisos en el archivo AndroidManifest.xml.

Ubicacion del archivo:

Platforms/Android/AndroidManifest.xml

πŸ“ Platforms/Android/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application android:allowBackup="true"
                 android:icon="@mipmap/appicon"
                 android:roundIcon="@mipmap/appicon_round"
                 android:supportsRtl="true">
    </application>

    <!-- Permisos para Bluetooth (Android 11 y anteriores) -->
    <uses-permission android:name="android.permission.BLUETOOTH" />
    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />

    <!-- Permisos para Bluetooth (Android 12+) -->
    <uses-permission android:name="android.permission.BLUETOOTH_SCAN"
                     android:usesPermissionFlags="neverForLocation" />
    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

    <!-- Permisos de ubicacion (requeridos para BLE scanning) -->
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

    <!-- Declarar uso de Bluetooth LE -->
    <uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
</manifest>
⚠️ Importante: En Android 12 y superior, se requieren permisos adicionales de Bluetooth que el usuario debe aceptar cuando abra la aplicacion por primera vez.

8Probando el Sistema Completo

Β‘Llegamos al momento de la verdad! Vamos a probar que todo funciona correctamente.

8.1 Compilar y Ejecutar la App

En Visual Studio:

  1. Conecta tu telefono Android por USB
  2. Habilita la Depuracion USB en las opciones de desarrollador
  3. Selecciona tu dispositivo en el menu desplegable de Visual Studio
  4. Presiona F5 o el boton de Run
  5. Espera a que la aplicacion se instale y abra

8.2 Primera Conexion

En tu telefono:

  1. Asegurate de que el ESP32 este encendido (LED azul parpadeando)
  2. Abre la aplicacion MotorControlApp
  3. Acepta los permisos de Bluetooth y ubicacion
  4. Presiona "Buscar Dispositivos"
  5. Espera a que aparezca "ESP32_Motor_Control"
  6. Selecciona el dispositivo de la lista
  7. Presiona "Conectar"

8.3 Probando los Controles

Pruebas recomendadas:

  1. Escribe 90 en el campo de grados y presiona "Girar Motor"
  2. Observa que el motor gira 90Β° en sentido horario
  3. Escribe -90 y presiona "Girar Motor"
  4. El motor debe regresar a la posicion original
  5. Escribe 360 para una vuelta completa
  6. Presiona "Establecer Home" para resetear a 0Β°
πŸŽ‰ Β‘Felicidades!
Si llegaste hasta aqui y todo funciona, has completado exitosamente el proyecto. Ahora tienes un sistema de control de motor inalambrico funcional.

9Solucion de Problemas

Aqui estan los problemas mas comunes que puedes encontrar y como solucionarlos.

El ESP32 no aparece en el escaneo

Problema: No ves "ESP32_Motor_Control" en la lista de dispositivos.
Solucion:
  • Verifica que el ESP32 este encendido y el codigo cargado
  • Abre el Monitor Serial - deberias ver "BLE listo - Esperando conexion..."
  • Asegurate de que el Bluetooth de tu telefono este activado
  • Intenta reiniciar el ESP32 (boton EN)
  • Activa y desactiva el Bluetooth del telefono

Error de conexion

Problema: La conexion falla o se desconecta inmediatamente.
Solucion:
  • Acerca el telefono al ESP32 (menos de 5 metros)
  • Reinicia tanto el ESP32 como la aplicacion
  • Verifica que no haya otro dispositivo conectado al ESP32
  • En Android, ve a Configuracion β†’ Apps β†’ MotorControlApp β†’ Permisos y verifica que todos esten concedidos

El motor no gira

Problema: Envias comandos pero el motor no se mueve.
Solucion:
  • Verifica las conexiones de los cables
  • Revisa que el motor este correctamente enchufado al ULN2003
  • Observa los LEDs del ULN2003 - deberian parpadear en secuencia
  • Prueba con el Monitor Serial: escribe MOVE:360
  • Verifica la alimentacion - el motor necesita suficiente corriente

El motor vibra pero no gira

Problema: El motor hace ruido o vibra pero no gira.
Solucion:
  • Los cables pueden estar en el orden incorrecto
  • Verifica que GPIO14β†’IN1, GPIO27β†’IN2, GPIO26β†’IN3, GPIO25β†’IN4
  • Intenta intercambiar los cables de dos bobinas adyacentes

Permisos denegados en Android

Problema: La app muestra "Se requieren permisos..."
Solucion:
  • Ve a Configuracion β†’ Apps β†’ MotorControlApp β†’ Permisos
  • Activa: Bluetooth, Ubicacion (ambas)
  • Si usas Android 12+, asegurate de dar permiso a "Dispositivos cercanos"
  • Reinicia la aplicacion despues de cambiar permisos

β˜…Recursos Adicionales

Codigo Fuente Completo

Todo el codigo de este proyecto esta disponible en GitHub. Puedes clonarlo, modificarlo y usarlo como base para tus propios proyectos.

πŸ”— Ver en GitHub

Posibles Mejoras

Algunas ideas para expandir este proyecto:

Referencias Tecnicas

πŸ’¬ ΒΏTienes preguntas?
Si tienes dudas sobre este tutorial o encuentras algun error, no dudes en dejar un comentario o contactarme.

Β‘Gracias por seguir este tutorial!

Espero que este proyecto te haya sido util y que hayas aprendido algo nuevo. El mundo del IoT y la electronica tiene infinitas posibilidades, y este es solo el comienzo.

- Tostatronic