Aprende a construir una aplicacion movil que controla un motor 28BYJ-48 via Bluetooth
ΒΏ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:
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.
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 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 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.
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.
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.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 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)
βββββββββββββββββββ
| 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 |
Antes de continuar, verifica:
Antes de escribir codigo, necesitamos preparar nuestro entorno de desarrollo. Esto incluye instalar el software necesario y configurar las herramientas.
Si aun no tienes Arduino IDE, descargalo desde arduino.cc/en/software e instalalo.
https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
Descarga Visual Studio 2022 Community (gratuito) desde visualstudio.microsoft.com
Durante la instalacion, asegurate de seleccionar:
Abre una terminal y ejecuta dotnet --version.
Deberias ver la version 9.x.x instalada.
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.
Antes de copiar y pegar, dejame explicarte las partes mas importantes del codigo. Esto te ayudara a modificarlo si lo necesitas para tu proyecto.
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 |
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 |
Copia el siguiente codigo completo. No omitas ninguna parte, todo es necesario para que funcione correctamente.
/*
* 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
}
Antes de pasar a la aplicacion movil, verifiquemos que el ESP32 funciona correctamente.
MOVE:90 y presiona Enter
Ahora vamos a crear la aplicacion movil. Usaremos .NET MAUI que nos permite crear una sola aplicacion que funciona en Android e iOS.
Necesitamos instalar dos paquetes adicionales para el Bluetooth y el patron MVVM.
Vamos a crear la siguiente estructura de carpetas para organizar nuestro codigo:
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
Ahora viene la parte mas larga: escribir el codigo de la aplicacion. He dividido esto en archivos para que sea mas facil de seguir.
Primero, creamos la interfaz que define las operaciones Bluetooth disponibles.
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);
}
Esta es la implementacion real de la comunicacion Bluetooth.
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();
});
}
}
}
El ViewModel contiene toda la logica de la interfaz de usuario.
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;
}
}
Ahora reemplazamos el contenido de MainPage.xaml con nuestra interfaz.
<?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>
Reemplaza el contenido de 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();
}
}
Reemplaza el contenido de 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>
Reemplaza el contenido de 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();
}
}
Para que la aplicacion pueda usar Bluetooth en Android, necesitamos configurar los permisos en el archivo 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>
Β‘Llegamos al momento de la verdad! Vamos a probar que todo funciona correctamente.
Aqui estan los problemas mas comunes que puedes encontrar y como solucionarlos.
MOVE:360Todo el codigo de este proyecto esta disponible en GitHub. Puedes clonarlo, modificarlo y usarlo como base para tus propios proyectos.
π Ver en GitHubAlgunas ideas para expandir este proyecto:
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