IoT e IA

TinyML no ESP32: Treine, Implante e Publique Resultados via MQTT

Um passo a passo completo de TinyML no ESP32: treine um classificador de vibração em Python, converta-o para um modelo TensorFlow Lite int8, rode-o em um ESP32 com esp-tflite-micro e publique os resultados de classificação em um broker MQTT.

Capa do tutorial de TinyML no ESP32 mostrando uma placa ESP32 executando uma pequena rede neural e enviando resultados de classificação via MQTT
Rode isso no seu próprio broker MQTTTenha um broker MQTT privado e gerenciado no plano Free e comece com exemplos de código prontos.
Teste grátis Exemplos de código Sem cartão de crédito

O TinyML no ESP32 permite que um microcontrolador Wi-Fi barato classifique dados de sensores sozinho e informe apenas a resposta. Neste tutorial você vai treinar um pequeno classificador de vibração em Python, quantizá-lo para int8, executá-lo em um ESP32 com o port do TensorFlow Lite Micro da Espressif e publicar cada classificação no broker.connectmq.com via MQTT. Tudo roda em uma placa ESP32 sem nenhum periférico, porque o firmware simula o sinal do sensor até você conectar um acelerômetro real.

O que você vai construir

O dispositivo amostra uma janela de vibração de 256 pontos a 1 kHz, calcula três features (RMS, fator de crista e taxa de cruzamento por zero), executa uma pequena rede neural e classifica a máquina como idle, normal ou fault. Ele publica um resultado em JSON sempre que a classe muda, além de um heartbeat a cada 30 segundos, e mantém um status online/offline retido com Last Will.

Peça Escolha
Placa Qualquer placa de desenvolvimento ESP32 ou ESP32-S3
Framework ESP-IDF 5.x
Runtime de inferência Componente espressif/esp-tflite-micro
Treinamento Python 3, TensorFlow 2.x, NumPy
Cliente MQTT esp-mqtt do ESP-IDF
Broker broker.connectmq.com:1883 para testes

Por que ESP-IDF em vez da Arduino IDE? A Espressif mantém o esp-tflite-micro alinhado ao TensorFlow Lite Micro upstream atual, com kernels otimizados para o ESP32-S3. Vários ports para Arduino estão atrasados em relação à API upstream, o que é uma fonte comum de erros de compilação. Se TinyML é novidade para você, dê uma olhada antes em o que é TinyML.

Passo 1: treine e quantize o modelo em Python

Crie uma pasta tinyml_mqtt para o projeto ESP-IDF, com uma subpasta main, e coloque este script de treinamento ao lado dela. Ele gera janelas de vibração sintéticas rotuladas, treina uma pequena rede densa, converte-a em um modelo TensorFlow Lite totalmente int8 e grava main/model_data.cc e main/model_data.h, incluindo as constantes de normalização de features de que o firmware precisa.

pip install tensorflow numpy
# train_model.py
import numpy as np
import tensorflow as tf

FS, N = 1000, 256
LABELS = ["idle", "normal", "fault"]
rng = np.random.default_rng(42)


def gen(cls):
    t = np.arange(N) / FS
    if cls == 0:                                   # idle: apenas ruído do sensor
        return 0.05 * rng.standard_normal(N)
    x = rng.uniform(0.8, 1.2) * np.sin(2 * np.pi * 50 * t + rng.uniform(0, 2 * np.pi))
    x += 0.1 * rng.standard_normal(N)
    if cls == 2:                                   # fault: impactos periódicos
        idx = rng.choice(N, size=rng.integers(4, 10), replace=False)
        x[idx] += rng.uniform(2, 3, idx.size) * rng.choice([-1, 1], idx.size)
    return x


def features(x):
    rms = np.sqrt(np.mean(x ** 2))
    crest = np.max(np.abs(x)) / (rms + 1e-6)
    zcr = np.mean((x[1:] < 0) != (x[:-1] < 0))
    return np.array([rms, crest, zcr], dtype=np.float32)


y = rng.integers(0, 3, 6000)
X = np.stack([features(gen(c)) for c in y])
mean, std = X.mean(axis=0), X.std(axis=0)
Xn = ((X - mean) / std).astype(np.float32)

model = tf.keras.Sequential([
    tf.keras.Input(shape=(3,)),
    tf.keras.layers.Dense(16, activation="relu"),
    tf.keras.layers.Dense(3, activation="softmax"),
])
model.compile(optimizer="adam", loss="sparse_categorical_crossentropy", metrics=["accuracy"])
model.fit(Xn, y, epochs=30, batch_size=32, validation_split=0.2, verbose=2)


def representative_data():
    for i in range(300):
        yield [Xn[i:i + 1]]


converter = tf.lite.TFLiteConverter.from_keras_model(model)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = representative_data
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
converter.inference_input_type = tf.int8
converter.inference_output_type = tf.int8
tflite_model = converter.convert()
print("model size:", len(tflite_model), "bytes")

hex_bytes = ", ".join(f"0x{b:02x}" for b in tflite_model)
with open("tinyml_mqtt/main/model_data.cc", "w") as f:
    f.write('#include "model_data.h"\n\n')
    f.write(f"alignas(16) const unsigned char g_model[] = {{{hex_bytes}}};\n")
    f.write(f"const unsigned int g_model_len = {len(tflite_model)};\n")

with open("tinyml_mqtt/main/model_data.h", "w") as f:
    f.write("#pragma once\n\n")
    f.write("extern const unsigned char g_model[];\nextern const unsigned int g_model_len;\n\n")
    f.write("constexpr float kFeatMean[3] = {%s};\n" % ", ".join(f"{v:.6f}f" for v in mean))
    f.write("constexpr float kFeatStd[3] = {%s};\n" % ", ".join(f"{v:.6f}f" for v in std))
    f.write('constexpr const char *kLabels[3] = {"idle", "normal", "fault"};\n')
print("wrote main/model_data.cc and main/model_data.h")

O modelo tem duas camadas FULLY_CONNECTED e uma SOFTMAX, e a versão int8 tem apenas alguns kilobytes. A precisão de validação deve ficar próxima da perfeição, porque as classes sintéticas são bem separadas. Com dados reais, espere passar a maior parte do tempo na coleta de dados e nas features, e não na rede.

Por que quantização int8 completa

As configurações do conversor acima produzem um modelo cujos pesos, ativações, entrada e saída são todos inteiros de 8 bits. Isso importa no ESP32 por três motivos. O modelo fica com cerca de um quarto do tamanho da versão em float, os kernels otimizados do esp-tflite-micro são escritos para int8 e a arena necessária para os tensores intermediários também diminui. O preço é que você precisa converter as entradas para o domínio inteiro por conta própria, usando a escala e o zero point armazenados no tensor de entrada, e converter as saídas de volta. O firmware abaixo faz exatamente isso.

O conjunto de dados representativo é o que o conversor usa para escolher essas escalas. Se ele não cobrir toda a faixa de entradas reais, os valores fora da faixa são cortados e a precisão cai no dispositivo, mesmo que tenha parecido perfeita em Python. Quando passar para dados reais de sensores, alimente representative_data() com vetores de features normalizados reais de todas as classes.

Passo 2: crie o projeto ESP-IDF

Estrutura do projeto:

tinyml_mqtt/
  CMakeLists.txt
  sdkconfig.defaults
  main/
    CMakeLists.txt
    main.cc
    model_data.cc   (gerado)
    model_data.h    (gerado)

CMakeLists.txt de nível superior. Ele inclui o helper de Wi-Fi dos exemplos da Espressif para manter o tutorial curto:

cmake_minimum_required(VERSION 3.16)
set(EXTRA_COMPONENT_DIRS $ENV{IDF_PATH}/examples/common_components/protocol_examples_common)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(tinyml_mqtt)

main/CMakeLists.txt:

idf_component_register(SRCS "main.cc" "model_data.cc" INCLUDE_DIRS ".")

sdkconfig.defaults com a sua rede Wi-Fi:

CONFIG_EXAMPLE_CONNECT_WIFI=y
CONFIG_EXAMPLE_WIFI_SSID="your-ssid"
CONFIG_EXAMPLE_WIFI_PASSWORD="your-password"

Em seguida, adicione o runtime de inferência a partir do ESP Component Registry, de dentro da pasta do projeto:

idf.py set-target esp32s3      # ou esp32
idf.py add-dependency "espressif/esp-tflite-micro"

Passo 3: firmware com inferência e MQTT

O main/main.cc configura o Wi-Fi e o MQTT, carrega o modelo em uma tensor arena estática e executa o loop de amostragem, features, inferência e publicação. Altere TOPIC_BASE e o client ID para algo único antes de gravar o firmware.

#include <algorithm>
#include <cmath>
#include <cstdio>

#include "esp_event.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_random.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "mqtt_client.h"
#include "nvs_flash.h"
#include "protocol_examples_common.h"
#include "tensorflow/lite/micro/micro_interpreter.h"
#include "tensorflow/lite/micro/micro_mutable_op_resolver.h"
#include "tensorflow/lite/schema/schema_generated.h"

#include "model_data.h"

#define TOPIC_BASE "connectmq/demo/esp32-7c41a9/motor1"   // torne isto único
#define CLIENT_ID  "esp32-tinyml-7c41a9"                  // torne isto único

static const char *TAG = "tinyml";
static constexpr int kN = 256;
static constexpr float kFs = 1000.0f;
static constexpr int kArenaSize = 8 * 1024;
alignas(16) static uint8_t tensor_arena[kArenaSize];

static esp_mqtt_client_handle_t mqtt = nullptr;
static volatile bool mqtt_ready = false;

static void mqtt_event(void *arg, esp_event_base_t base, int32_t id, void *data) {
  if (id == MQTT_EVENT_CONNECTED) {
    mqtt_ready = true;
    esp_mqtt_client_publish(mqtt, TOPIC_BASE "/status", "online", 0, 1, 1);
    ESP_LOGI(TAG, "MQTT connected");
  } else if (id == MQTT_EVENT_DISCONNECTED) {
    mqtt_ready = false;
    ESP_LOGW(TAG, "MQTT disconnected");
  }
}

static void start_mqtt() {
  esp_mqtt_client_config_t cfg = {};
  cfg.broker.address.uri = "mqtt://broker.connectmq.com:1883";
  cfg.credentials.client_id = CLIENT_ID;
  cfg.session.last_will.topic = TOPIC_BASE "/status";
  cfg.session.last_will.msg = "offline";
  cfg.session.last_will.qos = 1;
  cfg.session.last_will.retain = 1;
  mqtt = esp_mqtt_client_init(&cfg);
  esp_mqtt_client_register_event(mqtt, MQTT_EVENT_ANY, mqtt_event, nullptr);
  esp_mqtt_client_start(mqtt);
}

// ---- sensor simulado: substitua por amostras reais de acelerômetro ----
static float uniform(float a, float b) { return a + (b - a) * (esp_random() / 4294967296.0f); }
static float randn() {
  float u1 = (esp_random() + 1.0f) / 4294967297.0f;
  float u2 = esp_random() / 4294967296.0f;
  return sqrtf(-2.0f * logf(u1)) * cosf(2.0f * M_PI * u2);
}
static void read_window(int scenario, float *x) {
  float amp = uniform(0.8f, 1.2f), phase = uniform(0.0f, 2.0f * M_PI);
  for (int i = 0; i < kN; i++) {
    x[i] = scenario == 0 ? 0.05f * randn()
                         : amp * sinf(2.0f * M_PI * 50.0f * i / kFs + phase) + 0.1f * randn();
  }
  if (scenario == 2) {
    int impacts = 4 + esp_random() % 6;
    for (int k = 0; k < impacts; k++) {
      int idx = esp_random() % kN;
      x[idx] += uniform(2.0f, 3.0f) * ((esp_random() & 1) ? 1.0f : -1.0f);
    }
  }
}

static void compute_features(const float *x, float *f) {
  float sumsq = 0, peak = 0;
  int crossings = 0;
  for (int i = 0; i < kN; i++) {
    sumsq += x[i] * x[i];
    peak = std::max(peak, fabsf(x[i]));
    if (i > 0 && ((x[i] < 0) != (x[i - 1] < 0))) crossings++;
  }
  float rms = sqrtf(sumsq / kN);
  f[0] = rms;
  f[1] = peak / (rms + 1e-6f);
  f[2] = (float)crossings / (kN - 1);
}

extern "C" void app_main(void) {
  ESP_ERROR_CHECK(nvs_flash_init());
  ESP_ERROR_CHECK(esp_netif_init());
  ESP_ERROR_CHECK(esp_event_loop_create_default());
  ESP_ERROR_CHECK(example_connect());
  start_mqtt();

  const tflite::Model *model = tflite::GetModel(g_model);
  if (model->version() != TFLITE_SCHEMA_VERSION) {
    ESP_LOGE(TAG, "schema mismatch");
    return;
  }
  static tflite::MicroMutableOpResolver<4> resolver;
  resolver.AddFullyConnected();
  resolver.AddSoftmax();
  resolver.AddQuantize();
  resolver.AddDequantize();

  static tflite::MicroInterpreter interpreter(model, resolver, tensor_arena, kArenaSize);
  if (interpreter.AllocateTensors() != kTfLiteOk) {
    ESP_LOGE(TAG, "AllocateTensors failed: increase kArenaSize");
    return;
  }
  ESP_LOGI(TAG, "arena used: %u bytes", (unsigned)interpreter.arena_used_bytes());
  TfLiteTensor *in = interpreter.input(0);
  TfLiteTensor *out = interpreter.output(0);

  static float window[kN];
  int last_label = -1;
  int64_t last_sent_us = 0;

  for (int n = 0;; n++) {
    int scenario = (n / 10) % 3;          // demo: muda o estado da máquina a cada 10 janelas
    read_window(scenario, window);

    float feat[3];
    compute_features(window, feat);
    for (int i = 0; i < 3; i++) {
      float norm = (feat[i] - kFeatMean[i]) / kFeatStd[i];
      int q = (int)lroundf(norm / in->params.scale) + in->params.zero_point;
      in->data.int8[i] = (int8_t)std::min(127, std::max(-128, q));
    }

    int64_t t0 = esp_timer_get_time();
    if (interpreter.Invoke() != kTfLiteOk) {
      ESP_LOGE(TAG, "Invoke failed");
      continue;
    }
    int infer_us = (int)(esp_timer_get_time() - t0);

    int best = 0;
    float best_p = -1;
    for (int i = 0; i < 3; i++) {
      float p = (out->data.int8[i] - out->params.zero_point) * out->params.scale;
      if (p > best_p) { best_p = p; best = i; }
    }
    ESP_LOGI(TAG, "%s p=%.2f (%d us)", kLabels[best], best_p, infer_us);

    int64_t now = esp_timer_get_time();
    if (mqtt_ready && (best != last_label || now - last_sent_us > 30000000)) {
      char payload[128];
      snprintf(payload, sizeof(payload),
               "{\"label\":\"%s\",\"p\":%.2f,\"rms\":%.3f,\"us\":%d,\"model\":\"vib-mlp-v1\"}",
               kLabels[best], best_p, feat[0], infer_us);
      esp_mqtt_client_publish(mqtt, TOPIC_BASE "/inference", payload, 0, 1, 0);
      last_label = best;
      last_sent_us = now;
    }
    vTaskDelay(pdMS_TO_TICKS(500));
  }
}

Compile, grave o firmware e abra o monitor:

python train_model.py
cd tinyml_mqtt
idf.py build flash monitor

Passo 4: acompanhe a chegada dos resultados

De qualquer computador, assine o seu prefixo:

mosquitto_sub -h broker.connectmq.com -p 1883 -i tinyml-viewer-31 \
  -t 'connectmq/demo/esp32-7c41a9/#' -v

Você verá o status online retido e, em seguida, uma mensagem de inferência cada vez que a máquina simulada mudar de estado, além de um heartbeat a cada 30 segundos. O firmware avalia duas janelas por segundo, mas publica apenas algumas mensagens por minuto, e essa é a essência do TinyML: processar localmente, informar de forma esparsa. O servidor MQTT público (gratuito), pensado para testes e protótipos, permite até 10 mensagens por segundo por cliente, então este projeto deixa folga de sobra.

Projetando os topics e o payload

O tutorial usa dois topics sob um mesmo prefixo de dispositivo: status, que é retido e respaldado por um Last Will, e inference, que transporta os resultados. Essa divisão permite que um dashboard mostre a saúde do dispositivo instantaneamente enquanto os resultados fluem como eventos. À medida que a frota cresce, por exemplo nos compressores de uma planta em Joinville, coloque mais contexto na hierarquia, por exemplo plant1/utilities/compressors/comp-04/inference, para que um assinante possa selecionar uma máquina, uma linha ou a planta inteira com wildcards.

Mantenha o payload curto, mas autodescritivo. O campo model diz qual firmware produziu um resultado após uma atualização over-the-air, p permite que os assinantes apliquem seu próprio limiar de confiança e us revela quando o tempo de inferência aumenta após uma troca de modelo. Se precisar ajustar o comportamento remotamente, faça o dispositivo assinar um topic cmd para configurações como o intervalo do heartbeat e valide cada valor antes de aplicá-lo. Em um broker ConnectMQ privado, você pode acompanhar os dois topics no explorador de topics em tempo real e definir uma regra de alerta por limiar em campos numéricos enquanto testa.

Passando para dados reais de sensores

Substitua read_window() por amostras de um acelerômetro, como um MPU6050 ou ADXL345, amostradas a uma taxa fixa com um timer de hardware ou o FIFO do sensor. Depois, retreine com janelas gravadas: registre janelas reais via MQTT ou serial para cada estado da máquina, rotule-as e alimente-as no mesmo código de features e treinamento. Mantenha o cálculo de features idêntico em Python e em C++, incluindo a definição de cruzamento por zero, ou a precisão no dispositivo não vai corresponder à do treinamento.

Quando for para produção, mude para TLS e credenciais: use a URI mqtts://broker.connectmq.com:8883 com o host do seu broker ConnectMQ, defina cfg.credentials.username, cfg.credentials.authentication.password e o client ID emitido e forneça o certificado da CA (o bundle de certificados do ESP-IDF cobre o Let's Encrypt). O tutorial de ESP32 com MQTT detalha a configuração do TLS.

Solução de problemas de TinyML no ESP32

Problema Causa Solução
AllocateTensors failed Arena pequena demais Aumente kArenaSize e depois reduza para arena_used_bytes() mais uma margem
Didn't find op for builtin opcode Operador não registrado Adicione-o ao resolver e aumente a contagem do template
Sempre prevê a mesma classe Features não normalizadas como no treinamento Verifique kFeatMean/kFeatStd e a fórmula de quantização da entrada
Erros de compilação nos headers do TFLM Versões misturadas ou port para Arduino Use o esp-tflite-micro do component registry com ESP-IDF 5.x
O MQTT nunca conecta Credenciais de Wi-Fi ou client ID duplicado Verifique o log do monitor e torne o client ID único

Próximos passos

Compare este pipeline construído à mão com o fluxo de trabalho de Edge Impulse com MQTT, que automatiza a coleta de dados e o projeto de features, ou aprofunde-se no próprio runtime em primeiros passos com TensorFlow Lite Micro. O guia de IA e IoT mostra onde dispositivos como este se encaixam em um sistema AIoT maior.

Comece a usar o ConnectMQ

O seu nó TinyML é tão útil quanto os sistemas que recebem os seus resultados. O ConnectMQ é um broker MQTT gerenciado com TLS, credenciais geradas e um explorador de topics em tempo real para acompanhar as mensagens de inferência. Crie um broker no plano gratuito Serverless em app.connectmq.com, sem cartão de crédito, e aponte o firmware para ele.

Perguntas frequentes

Qual ESP32 é o melhor para TinyML?

O ESP32-S3 é a escolha usual, porque suas instruções vetoriais aceleram os kernels de redes neurais no esp-tflite-micro e muitas placas incluem PSRAM. O ESP32 original funciona bem para modelos pequenos como o deste tutorial.

Posso fazer TinyML no ESP32 com a Arduino IDE?

Sim, por meio de bibliotecas Arduino que empacotam o TensorFlow Lite Micro ou exportações do Edge Impulse. Verifique se a versão da biblioteca corresponde à API atual do TFLM, porque ports mais antigos usam construtores e headers diferentes.

Qual o tamanho máximo de um modelo TinyML no ESP32?

Depende da flash para os pesos e da RAM para a tensor arena. Modelos densos e convolucionais pequenos cabem facilmente na memória interna; modelos de visão maiores geralmente precisam de um ESP32-S3 com PSRAM.

Por que publicar apenas na mudança?

A inferência roda muitas vezes por segundo, mas os sistemas a jusante só se importam quando o estado muda. Publicar na mudança mais um heartbeat economiza energia de rádio e largura de banda e mantém você dentro dos limites de taxa do broker.

Artigos relacionados

Todos os artigos →

Seu broker MQTT privado está a poucos cliques de distância

Crie uma conta, receba as credenciais do seu broker e conecte seu primeiro dispositivo — sem servidores para instalar, atualizar ou escalar.