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/sety 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:
- Desconéctate limpiamente cuando la aplicación pase a segundo plano.
- Reconéctate y vuelve a suscribirte cuando se reanude.
- 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 enPreferences.
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.



