Um app .NET MAUI MQTT permite entregar uma única base de código C# que monitora e controla dispositivos a partir do Android, iOS, Windows e macOS. Neste tutorial você vai criar um pequeno app MAUI com MQTTnet que mostra uma temperatura ao vivo, exibe a presença do dispositivo e aciona um relé, como o de uma bomba em uma estação de saneamento, e vai tratar a parte que a maioria dos exemplos ignora: o que acontece quando o app vai para segundo plano. Os exemplos usam o servidor MQTT público broker.connectmq.com, que serve apenas para testes, e depois passam para credenciais privadas de um broker MQTT gerenciado da ConnectMQ.
O que você vai construir
- Uma única página com um indicador de conexão, a temperatura mais recente e um interruptor de relé
- Um view model que detém um cliente MQTTnet e expõe propriedades com binding
- Comandos publicados em
.../relay/sete estado lido de.../relay/state - Reconexão ao retomar o app, com nova assinatura
Topics, todos sob um prefixo exclusivo para não colidir com outros usuários do broker compartilhado:
| Topic | Direção | Payload |
|---|---|---|
connectmq/demo/m4ui7x/device1/temperature |
dispositivo para o app | {"value": 22.4} |
connectmq/demo/m4ui7x/device1/status |
dispositivo para o app (retido) | online / offline |
connectmq/demo/m4ui7x/device1/relay/state |
dispositivo para o app (retido) | on / off |
connectmq/demo/m4ui7x/device1/relay/set |
app para o dispositivo | on / off |
Separar set de state é um padrão intencional: o app solicita uma mudança e o dispositivo confirma o estado real. O interruptor na tela reflete o que o dispositivo informa, não o que o usuário tocou.
Criando o projeto
Você precisa do .NET 8 ou 9 SDK com o workload do MAUI (dotnet workload install maui).
dotnet new maui -n DeviceControl
cd DeviceControl
dotnet add package MQTTnet
dotnet add package CommunityToolkit.Mvvm
O CommunityToolkit.Mvvm elimina a maior parte do código repetitivo de property-changed. Ele é opcional, mas deixa o view model bem mais curto.
O view model
Crie o MainViewModel.cs. A regra crítica em qualquer app .NET MAUI MQTT: o MQTTnet dispara eventos em threads de segundo plano, e as propriedades ligadas à interface precisam ser atualizadas na thread principal. Esquecer isso causa crashes no Android e no iOS que confundem, porque nem sempre acontecem no 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());
}
}
A flag _suppressToggle evita um loop de eco: quando o dispositivo informa um novo estado, definir RelayOn publicaria um comando de novo. O client ID é gerado uma única vez e guardado em Preferences, então permanece estável entre as execuções, o que importa se mais tarde você ativar sessões persistentes.
A página
Substitua o conteúdo 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>
E o 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();
}
}
Registre os dois no MauiProgram.cs com builder.Services.AddSingleton<MainViewModel>(); e builder.Services.AddSingleton<MainPage>();, e garanta que o AppShell resolva a página pela injeção de dependência (o ShellContent com ContentTemplate do template padrão funciona assim que a página é registrada).
Tratando o ciclo de vida do app
Os sistemas operacionais móveis suspendem apps em segundo plano e fecham seus sockets. O Android pode manter o processo vivo por um tempo, mas corta a rede; o iOS suspende quase imediatamente. Tentar manter uma conexão MQTT aberta em segundo plano sem um foreground service (Android) ou uma estratégia de notificações push (iOS) é uma batalha perdida. A abordagem prática para um app de monitoramento:
- Desconecte de forma limpa quando o app for para segundo plano.
- Reconecte e assine novamente quando ele for retomado.
- Confie em mensagens retidas para o estado, para que a tela esteja correta no instante em que você reconectar.
Faça a ligação no App.xaml.cs sobrescrevendo CreateWindow e conectando os eventos da janela:
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 o dispositivo publica status e relay/state como mensagens retidas, o broker as entrega imediatamente na assinatura. O app nunca mostra um interruptor desatualizado depois de acordar. Para alertas enquanto o app está fechado, a arquitetura certa é um serviço de backend assinando no lado do servidor e enviando notificações push; veja o background service MQTT no ASP.NET Core para essa parte.
Simulando o dispositivo
Você não precisa de hardware para testar. A partir de um terminal, faça o papel do dispositivo:
# Presença e estado do relé retidos
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
# Leitura de temperatura
mosquitto_pub -h broker.connectmq.com -p 1883 -i dev1-sim-c -t connectmq/demo/m4ui7x/device1/temperature -m '{"value": 22.4}'
# Acompanhe os comandos que o app envia
mosquitto_sub -h broker.connectmq.com -p 1883 -i dev1-sim-d -t connectmq/demo/m4ui7x/device1/relay/set -v
Toque no interruptor do app e o comando aparece no último terminal. Depois publique on em relay/state, como o dispositivo faria, e o interruptor confirma. Um firmware real segue o mesmo contrato; o tutorial de MQTT com ESP32 mostra um lado de dispositivo que você pode combinar com este app.
Mostrando feedback enquanto um comando está pendente
Como o interruptor reflete o estado confirmado pelo dispositivo, existe um pequeno intervalo entre o toque e a confirmação. Os usuários interpretam esse intervalo como "o app está com defeito", a menos que você o mostre. Uma abordagem simples é uma propriedade Pending que liga quando o comando é publicado e desliga quando relay/state chega, ligada a um ActivityIndicator. Adicione um timeout de alguns segundos: se nenhuma confirmação chegar, mostre "O dispositivo não respondeu" e mantenha o interruptor no último estado confirmado. Esse pequeno detalhe faz um app .NET MAUI MQTT transmitir confiança e expõe problemas reais, como um dispositivo offline ou um bug de firmware, em vez de escondê-los.
Se você também quer saber se o dispositivo está acessível antes de o usuário tocar, confie no topic status retido, alimentado pelo Last Will do dispositivo. Desative o interruptor enquanto o status for offline.
Armadilhas por plataforma
| Plataforma | Armadilha | Correção |
|---|---|---|
| Android | SocketException ou conexão recusada no emulador |
Garanta que o emulador tenha internet; a permissão INTERNET já vem no template MAUI por padrão |
| Android | Crash ao atualizar labels a partir do handler de mensagens | Sempre encaminhe para a MainThread |
| iOS | A conexão cai segundos depois de ir para segundo plano | Esperado; desconecte em Stopped e reconecte em Resumed |
| Windows | Funciona no Windows, quebra no celular | Geralmente um bug de threading escondido pelo dispatcher do Windows; teste cedo em um celular |
| Todas | TCP simples na 1883 bloqueado em redes móveis ou Wi-Fi de visitantes | Prefira TLS na 8883 ou WebSocket na 8084 com WithWebSocketServer |
| Todas | Duas instâncias do app com o mesmo ID derrubam uma à outra | Guarde um client ID por instalação, como mostrado acima |
Credenciais de produção e segurança
Distribuir um app móvel significa que o binário pode ser descompilado, então nunca embuta uma senha de administrador compartilhada. Opções, da mais simples à mais robusta:
- Emita uma credencial por usuário ou por instalação, provisionada pelo seu backend após o login.
- Mantenha os comandos atrás da sua própria API: o app chama o seu backend, e o backend publica no broker.
- Guarde qualquer credencial que o app precise manter no
SecureStorage, não emPreferences.
Com a ConnectMQ, cada broker vem com usuário, senha e client ID gerados, e os client IDs são vinculados às suas credenciais. Coloque-os no options builder com .WithCredentials("YOUR_USERNAME", "YOUR_PASSWORD") e .WithClientId("YOUR_CLIENT_ID") e aponte o WithTcpServer para o host do seu broker. O artigo de boas práticas de segurança MQTT aborda a estratégia de credenciais em mais profundidade, e o tutorial de MQTTnet explica as opções de cliente usadas aqui.
Testar no servidor MQTT público (gratuito) não tem problema enquanto você constrói a interface. Apps de produção, em que usuários reais controlam equipamentos reais, precisam de um broker MQTT gerenciado privado, com autenticação e TLS. Para uma visão mais ampla das opções para mobile e desktop além do .NET, veja a comparação de bibliotecas de cliente MQTT.
Comece a usar a ConnectMQ
Crie o seu broker privado em app.connectmq.com no plano Serverless gratuito, sem cartão de crédito. Ele suporta até 25 conexões, o suficiente para uma equipe de desenvolvimento e alguns dispositivos de teste, e o cliente MQTT no navegador do portal permite fazer o papel do dispositivo sem terminal.
Perguntas frequentes
Um app .NET MAUI consegue receber mensagens MQTT em segundo plano?
Não de forma confiável por conta própria. Os sistemas operacionais móveis suspendem a rede em segundo plano, então use mensagens retidas para o estado ao retomar e um subscriber no lado do servidor com notificações push para alertas enquanto o app está fechado.
O MQTTnet funciona no iOS e no Android com o MAUI?
Sim. O MQTTnet é uma biblioteca .NET pura e roda em todos os alvos do MAUI. Use TLS na porta 8883 e garanta que as atualizações de interface aconteçam na thread principal.
O app móvel deve se conectar diretamente ao broker ou passar por uma API?
Para monitoramento, uma conexão direta com credenciais somente leitura é eficiente. Para controlar equipamentos reais, muitas equipes encaminham os comandos pelo próprio backend para aplicar autorização e logs de auditoria antes de publicar.
Como migro um app MQTT em Xamarin.Forms para o MAUI?
O código MQTT geralmente migra sem alterações se você usava o MQTTnet. Atualize para a API atual do MQTTnet (MqttClientFactory na versão 5), substitua as chamadas do Xamarin.Essentials pelos equivalentes do MAUI e teste novamente o threading em cada plataforma.



