Tutoriales de IoT

Aplicación MQTT con .NET MAUI: supervisa y controla dispositivos en Android, iOS y Windows

Crea una única aplicación multiplataforma con .NET MAUI y MQTTnet que muestra telemetría en vivo y envía comandos a dispositivos, gestiona las reconexiones del ciclo de vida y funciona con un broker de ConnectMQ.

Un teléfono y un portátil ejecutando la misma aplicación MQTT con .NET MAUI con una lectura de temperatura en vivo y un interruptor de relé
Pruébalo en tu propio broker MQTTConsigue un broker MQTT privado y gestionado con el plan Free y empieza con ejemplos de código listos para usar.
Pruébalo gratis Ejemplos de código Sin tarjeta

Una aplicación MQTT con .NET MAUI te permite publicar una única base de código C# que supervisa y controla dispositivos desde Android, iOS, Windows y macOS. En este tutorial crearás una pequeña aplicación MAUI con MQTTnet que muestra una temperatura en vivo, indica la presencia del dispositivo y activa un relé, y te ocuparás de la parte que la mayoría de los ejemplos omiten: qué pasa cuando la aplicación pasa a segundo plano. Los ejemplos usan el broker público broker.connectmq.com y después pasan a credenciales privadas de ConnectMQ.

Qué vas a construir

  • Una única página con un indicador de conexión, la última temperatura y un interruptor de relé
  • Un view model que posee un único cliente MQTTnet y expone propiedades enlazables
  • Comandos publicados en .../relay/set y estado leído de .../relay/state
  • Reconexión al reanudar, con nueva suscripción

Los topics, todos bajo un prefijo único para no chocar con otros usuarios del broker compartido:

Topic Dirección Payload
connectmq/demo/m4ui7x/device1/temperature del dispositivo a la app {"value": 22.4}
connectmq/demo/m4ui7x/device1/status del dispositivo a la app (retenido) online / offline
connectmq/demo/m4ui7x/device1/relay/state del dispositivo a la app (retenido) on / off
connectmq/demo/m4ui7x/device1/relay/set de la app al dispositivo on / off

Separar set de state es un patrón deliberado: la aplicación solicita un cambio y el dispositivo confirma el estado real. El interruptor en pantalla refleja lo que informa el dispositivo, no lo que pulsó el usuario.

Crea el proyecto

Necesitas el SDK de .NET 8 o 9 con la carga de trabajo de MAUI (dotnet workload install maui).

dotnet new maui -n DeviceControl
cd DeviceControl
dotnet add package MQTTnet
dotnet add package CommunityToolkit.Mvvm

CommunityToolkit.Mvvm elimina casi todo el código repetitivo de notificación de cambios de propiedad. Es opcional, pero acorta mucho el view model.

El view model

Crea MainViewModel.cs. La regla crítica en cualquier aplicación MQTT con .NET MAUI: MQTTnet lanza los eventos en hilos de segundo plano, y las propiedades enlazadas a la interfaz deben actualizarse en el hilo principal. Olvidarlo provoca cierres en Android e iOS que desconciertan porque no siempre ocurren en Windows.

using System.Text.Json;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MQTTnet;
using MQTTnet.Formatter;
using MQTTnet.Protocol;

namespace DeviceControl;

public partial class MainViewModel : ObservableObject
{
    private const string Prefix = "connectmq/demo/m4ui7x/device1";
    private readonly IMqttClient _client = new MqttClientFactory().CreateMqttClient();
    private readonly MqttClientOptions _options;
    private bool _suppressToggle;

    [ObservableProperty] private string connectionText = "Disconnected";
    [ObservableProperty] private string temperatureText = "--";
    [ObservableProperty] private string deviceStatus = "unknown";
    [ObservableProperty] private bool relayOn;

    public MainViewModel()
    {
        _options = new MqttClientOptionsBuilder()
            .WithTcpServer("broker.connectmq.com", 8883)
            .WithTlsOptions(o => o.UseTls())
            .WithClientId(GetOrCreateClientId())
            .WithProtocolVersion(MqttProtocolVersion.V500)
            .WithKeepAlivePeriod(TimeSpan.FromSeconds(30))
            .Build();

        _client.ApplicationMessageReceivedAsync += e =>
        {
            var topic = e.ApplicationMessage.Topic;
            var payload = e.ApplicationMessage.ConvertPayloadToString();
            MainThread.BeginInvokeOnMainThread(() => Apply(topic, payload));
            return Task.CompletedTask;
        };

        _client.DisconnectedAsync += _ =>
        {
            MainThread.BeginInvokeOnMainThread(() => ConnectionText = "Disconnected");
            return Task.CompletedTask;
        };
    }

    private static string GetOrCreateClientId()
    {
        var id = Preferences.Get("mqtt_client_id", null as string);
        if (id is null)
        {
            id = $"maui-{Guid.NewGuid():N}"[..20];
            Preferences.Set("mqtt_client_id", id);
        }
        return id;
    }

    private void Apply(string topic, string payload)
    {
        if (topic.EndsWith("/temperature"))
        {
            try
            {
                using var doc = JsonDocument.Parse(payload);
                TemperatureText = $"{doc.RootElement.GetProperty("value").GetDouble():0.0} °C";
            }
            catch (Exception) { TemperatureText = "invalid"; }
        }
        else if (topic.EndsWith("/status"))
        {
            DeviceStatus = payload;
        }
        else if (topic.EndsWith("/relay/state"))
        {
            _suppressToggle = true;
            RelayOn = payload == "on";
            _suppressToggle = false;
        }
    }

    [RelayCommand]
    public async Task ConnectAsync()
    {
        if (_client.IsConnected) return;
        try
        {
            ConnectionText = "Connecting...";
            await _client.ConnectAsync(_options);
            await _client.SubscribeAsync(new MqttClientSubscribeOptionsBuilder()
                .WithTopicFilter(f => f.WithTopic($"{Prefix}/temperature"))
                .WithTopicFilter(f => f.WithTopic($"{Prefix}/status"))
                .WithTopicFilter(f => f.WithTopic($"{Prefix}/relay/state")
                    .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce))
                .Build());
            ConnectionText = "Connected";
        }
        catch (Exception ex)
        {
            ConnectionText = $"Error: {ex.Message}";
        }
    }

    public async Task DisconnectAsync()
    {
        if (_client.IsConnected) await _client.DisconnectAsync();
    }

    partial void OnRelayOnChanged(bool value)
    {
        if (_suppressToggle || !_client.IsConnected) return;
        _ = _client.PublishAsync(new MqttApplicationMessageBuilder()
            .WithTopic($"{Prefix}/relay/set")
            .WithPayload(value ? "on" : "off")
            .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)
            .Build());
    }
}

El flag _suppressToggle evita un bucle de eco: cuando el dispositivo informa de un nuevo estado, asignar RelayOn volvería a publicar un comando. El client ID se genera una sola vez y se guarda en Preferences, de modo que se mantiene estable entre ejecuciones, algo importante si más adelante activas sesiones persistentes.

La página

Sustituye el contenido de 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:local="clr-namespace:DeviceControl"
             x:Class="DeviceControl.MainPage"
             x:DataType="local:MainViewModel"
             Title="Device Control">
    <VerticalStackLayout Padding="24" Spacing="16">
        <Label Text="{Binding ConnectionText}" FontSize="14" />
        <Label Text="Temperature" FontSize="16" />
        <Label Text="{Binding TemperatureText}" FontSize="48" FontAttributes="Bold" />
        <Label Text="{Binding DeviceStatus, StringFormat='Device: {0}'}" />
        <HorizontalStackLayout Spacing="12">
            <Label Text="Relay" VerticalOptions="Center" />
            <Switch IsToggled="{Binding RelayOn}" />
        </HorizontalStackLayout>
        <Button Text="Reconnect" Command="{Binding ConnectCommand}" />
    </VerticalStackLayout>
</ContentPage>

Y MainPage.xaml.cs:

namespace DeviceControl;

public partial class MainPage : ContentPage
{
    private readonly MainViewModel _vm;

    public MainPage(MainViewModel vm)
    {
        InitializeComponent();
        BindingContext = _vm = vm;
    }

    protected override async void OnAppearing()
    {
        base.OnAppearing();
        await _vm.ConnectAsync();
    }
}

Registra ambos en MauiProgram.cs con builder.Services.AddSingleton<MainViewModel>(); y builder.Services.AddSingleton<MainPage>();, y asegúrate de que AppShell resuelve la página desde la inyección de dependencias (el ShellContent con ContentTemplate de la plantilla por defecto funciona una vez registrada la página).

Gestión del ciclo de vida de la aplicación

Los sistemas operativos móviles suspenden las aplicaciones en segundo plano y cierran sus sockets. Android puede mantener vivo el proceso un tiempo pero cortar la red; iOS suspende casi de inmediato. Intentar mantener abierta una conexión MQTT en segundo plano sin un foreground service (Android) o una estrategia de notificaciones push (iOS) es una batalla perdida. El enfoque práctico para una aplicación de supervisión:

  1. Desconéctate limpiamente cuando la aplicación pase a segundo plano.
  2. Reconéctate y vuelve a suscribirte cuando se reanude.
  3. Apóyate en los mensajes retenidos para el estado, de modo que la pantalla sea correcta en el instante en que te reconectas.

Conéctalo en App.xaml.cs sobrescribiendo CreateWindow y enganchando los eventos de la ventana:

protected override Window CreateWindow(IActivationState? activationState)
{
    var window = new Window(new AppShell());
    var vm = activationState!.Context.Services.GetRequiredService<MainViewModel>();
    window.Stopped += async (_, _) => await vm.DisconnectAsync();
    window.Resumed += async (_, _) => await vm.ConnectAsync();
    return window;
}

Como el dispositivo publica status y relay/state como mensajes retenidos, el broker los entrega en cuanto te suscribes. La aplicación nunca muestra un interruptor desactualizado al despertar. Para recibir alertas con la aplicación cerrada, la arquitectura correcta es un servicio de backend que se suscribe en el servidor y envía notificaciones push; consulta el servicio en segundo plano MQTT con ASP.NET Core para esa parte.

Simula el dispositivo

No necesitas hardware para probar. Desde una terminal, haz el papel del dispositivo:

# Presencia y estado del relé retenidos
mosquitto_pub -h broker.connectmq.com -p 1883 -i dev1-sim-a -r -t connectmq/demo/m4ui7x/device1/status -m online
mosquitto_pub -h broker.connectmq.com -p 1883 -i dev1-sim-b -r -t connectmq/demo/m4ui7x/device1/relay/state -m off

# Lectura de temperatura
mosquitto_pub -h broker.connectmq.com -p 1883 -i dev1-sim-c -t connectmq/demo/m4ui7x/device1/temperature -m '{"value": 22.4}'

# Observa los comandos que envía la app
mosquitto_sub -h broker.connectmq.com -p 1883 -i dev1-sim-d -t connectmq/demo/m4ui7x/device1/relay/set -v

Pulsa el interruptor en la aplicación y el comando aparece en la última terminal. Después publica on en relay/state como lo haría el dispositivo, y el interruptor lo confirma. El firmware real sigue el mismo contrato; el tutorial de MQTT con ESP32 muestra un lado de dispositivo que puedes combinar con esta aplicación.

Mostrar información mientras un comando está pendiente

Como el interruptor refleja el estado confirmado por el dispositivo, hay un breve intervalo entre la pulsación y la confirmación. Los usuarios interpretan ese intervalo como «la aplicación no funciona» a menos que lo muestres. Un enfoque sencillo es una propiedad Pending que se activa al publicar el comando y se desactiva cuando llega relay/state, enlazada a un ActivityIndicator. Añade un tiempo de espera de unos segundos: si no llega la confirmación, muestra «El dispositivo no respondió» y deja el interruptor en el último estado confirmado. Este pequeño detalle hace que una aplicación MQTT con .NET MAUI inspire confianza y saca a la luz problemas reales, como un dispositivo desconectado o un error de firmware, en lugar de ocultarlos.

Si además quieres saber si el dispositivo está accesible antes de que el usuario pulse, apóyate en el topic status retenido que gestiona el Last Will del dispositivo. Desactiva el interruptor mientras el estado sea offline.

Trampas por plataforma

Plataforma Trampa Solución
Android SocketException o conexión rechazada en el emulador Asegúrate de que el emulador tiene internet; el permiso INTERNET viene por defecto en la plantilla de MAUI
Android Cierre al actualizar etiquetas desde el handler de mensajes Pasa siempre la actualización a MainThread
iOS La conexión se cae segundos después de pasar a segundo plano Es lo esperado; desconecta en Stopped y reconecta en Resumed
Windows Funciona en Windows y falla en el móvil Suele ser un error de hilos que oculta el dispatcher de Windows; prueba pronto en un teléfono
Todas TCP sin cifrar en el 1883 bloqueado en redes móviles o Wi-Fi de invitados Prefiere TLS en el 8883, o WebSocket por el 8084 con WithWebSocketServer
Todas Dos instancias de la app con el mismo ID se expulsan mutuamente Guarda un client ID por instalación, como se muestra arriba

Credenciales de producción y seguridad

Publicar una aplicación móvil significa que el binario se puede descompilar, así que nunca incrustes una contraseña de administrador compartida. Opciones, de la más sencilla a la más robusta:

  • Emite una credencial por usuario o por instalación, aprovisionada por tu backend tras el inicio de sesión.
  • Mantén los comandos detrás de tu propia API: la aplicación llama a tu backend y el backend publica en el broker.
  • Guarda cualquier credencial que tenga la aplicación en SecureStorage, no en Preferences.

Con ConnectMQ, cada broker incluye un usuario, una contraseña y un client ID generados, y los client ID están vinculados a sus credenciales. Añádelos al builder de opciones con .WithCredentials("YOUR_USERNAME", "YOUR_PASSWORD") y .WithClientId("YOUR_CLIENT_ID") y apunta WithTcpServer al host de tu broker. El artículo de buenas prácticas de seguridad en MQTT trata con más detalle la estrategia de credenciales, y el tutorial de MQTTnet explica las opciones del cliente usadas aquí.

Probar en el servidor MQTT público gratuito está bien mientras construyes la interfaz. Las aplicaciones de producción, en las que usuarios reales controlan equipos reales, necesitan un broker MQTT gestionado privado con autenticación y TLS. Para una visión más amplia de las opciones para móvil y escritorio más allá de .NET, consulta la comparativa de bibliotecas cliente MQTT.

Empieza con ConnectMQ

Crea tu broker privado en app.connectmq.com con el plan Serverless gratuito, sin tarjeta de crédito. Admite hasta 25 conexiones, suficientes para un equipo de desarrollo y un puñado de dispositivos de prueba, y el cliente MQTT integrado en el navegador del portal te permite hacer el papel del dispositivo sin terminal.

Preguntas frecuentes

¿Puede una aplicación .NET MAUI recibir mensajes MQTT en segundo plano?

No de forma fiable por sí sola. Los sistemas operativos móviles suspenden la red en segundo plano, así que usa mensajes retenidos para el estado al reanudar y un suscriptor en el servidor con notificaciones push para las alertas mientras la aplicación está cerrada.

¿MQTTnet funciona en iOS y Android con MAUI?

Sí. MQTTnet es una biblioteca .NET pura y funciona en todos los destinos de MAUI. Usa TLS en el puerto 8883 y asegúrate de que las actualizaciones de la interfaz se hacen en el hilo principal.

¿La aplicación móvil debe conectarse directamente al broker o pasar por una API?

Para supervisión, una conexión directa con credenciales de solo lectura es eficiente. Para controlar equipos reales, muchos equipos enrutan los comandos a través de su propio backend para poder aplicar autorización y registro de auditoría antes de publicar.

¿Cómo migro una aplicación MQTT de Xamarin.Forms a MAUI?

Si usabas MQTTnet, el código MQTT suele trasladarse sin cambios. Actualiza a la API actual de MQTTnet (MqttClientFactory en la versión 5), sustituye las llamadas de Xamarin.Essentials por sus equivalentes en MAUI y vuelve a probar el uso de hilos en cada plataforma.

Artículos relacionados

Las mejores herramientas CLI de MQTT para pruebas, scripts y depuración

Una comparativa práctica de mosquitto_pub/sub, MQTTX CLI, HiveMQ MQTT CLI, la CLI de MQTT.js, curl y mqttui, con la misma prueba ejecutada contra broker.connectmq.com para que elijas la herramienta adecuada para scripts y depuración.

9 min de lectura

Todos los artículos →

Tu broker MQTT privado, a unos pocos clics

Crea una cuenta, obtén las credenciales de tu broker y conecta tu primer dispositivo, sin servidores que instalar, parchear ni escalar.