IoT and AI

TinyML on ESP32: Train, Deploy and Publish Results over MQTT

A complete TinyML ESP32 walkthrough: train a vibration classifier in Python, convert it to an int8 TensorFlow Lite model, run it on an ESP32 with esp-tflite-micro and publish classification results to an MQTT broker.

TinyML ESP32 tutorial cover showing an ESP32 board running a small neural network and sending classification results over MQTT
Run this on your own MQTT brokerGet a private, managed MQTT broker on the Free plan and start from ready-made code samples.
Try it free Code samples No card required

TinyML on ESP32 lets a cheap Wi-Fi microcontroller classify sensor data on its own and report only the answer. In this tutorial you will train a small vibration classifier in Python, quantize it to int8, run it on an ESP32 with Espressif's port of TensorFlow Lite Micro, and publish each classification to broker.connectmq.com over MQTT. Everything runs on a bare ESP32 board, because the firmware simulates the sensor signal until you plug in a real accelerometer.

What You Will Build

The device samples a 256-point vibration window at 1 kHz, computes three features (RMS, crest factor and zero-crossing rate), runs a tiny neural network and classifies the machine as idle, normal or fault. It publishes a JSON result whenever the class changes, plus a heartbeat every 30 seconds, and keeps a retained online/offline status with a Last Will.

Piece Choice
Board Any ESP32 or ESP32-S3 dev board
Framework ESP-IDF 5.x
Inference runtime espressif/esp-tflite-micro component
Training Python 3, TensorFlow 2.x, NumPy
MQTT client ESP-IDF esp-mqtt
Broker broker.connectmq.com:1883 for testing

Why ESP-IDF instead of the Arduino IDE? Espressif maintains esp-tflite-micro against current upstream TensorFlow Lite Micro, with optimized kernels for the ESP32-S3. Several Arduino ports lag behind the upstream API, which is a common source of compile errors. If TinyML is new to you, skim what TinyML is first.

Step 1: Train and Quantize the Model in Python

Create a folder tinyml_mqtt for the ESP-IDF project with a main subfolder, and put this training script next to it. It generates labelled synthetic vibration windows, trains a small dense network, converts it to a fully int8 TensorFlow Lite model and writes main/model_data.cc and main/model_data.h, including the feature normalization constants the firmware needs.

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: sensor noise only
        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: periodic impacts
        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")

The model has two FULLY_CONNECTED layers and a SOFTMAX, and the int8 version is only a few kilobytes. Validation accuracy should be near perfect, because the synthetic classes are well separated. With real data, expect to spend most of your time on data collection and features, not on the network.

Why Full Int8 Quantization

The converter settings above produce a model whose weights, activations, input and output are all 8-bit integers. That matters on the ESP32 for three reasons. The model is roughly a quarter of the size of the float version, the optimized kernels in esp-tflite-micro are written for int8, and the arena needed for intermediate tensors shrinks as well. The price is that you must convert inputs into the integer domain yourself, using the scale and zero point stored in the input tensor, and convert outputs back. The firmware below does exactly that.

The representative dataset is what the converter uses to choose those scales. If it does not cover the full range of real inputs, values outside the range are clipped and accuracy drops on the device even though it looked perfect in Python. When you move to real sensor data, feed real normalized feature vectors from every class into representative_data().

Step 2: Create the ESP-IDF Project

Project layout:

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

Top-level CMakeLists.txt. It pulls in Espressif's example Wi-Fi helper so the tutorial stays short:

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 with your Wi-Fi network:

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

Then add the inference runtime from the ESP Component Registry, from inside the project folder:

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

Step 3: Firmware with Inference and MQTT

main/main.cc sets up Wi-Fi and MQTT, loads the model into a static tensor arena, and runs the sample, feature, inference and publish loop. Change TOPIC_BASE and the client ID to something unique before flashing.

#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"   // make this unique
#define CLIENT_ID  "esp32-tinyml-7c41a9"                  // make this unique

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);
}

// ---- simulated sensor: replace with real accelerometer samples ----
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: change machine state every 10 windows
    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));
  }
}

Build, flash and open the monitor:

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

Step 4: Watch the Results Arrive

From any computer, subscribe to your prefix:

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

You will see the retained online status, then one inference message each time the simulated machine changes state, plus a heartbeat every 30 seconds. The firmware evaluates two windows per second but publishes only a handful of messages per minute, which is the whole point of TinyML: compute locally, report sparsely. The free public MQTT broker allows up to 10 messages per second per client, so this design leaves plenty of headroom.

Designing the Topics and Payload

The tutorial uses two topics under one device prefix: status, which is retained and backed by a Last Will, and inference, which carries results. That split lets a dashboard show device health instantly while results flow as events. As the fleet grows, put more context into the hierarchy, for example plant1/utilities/compressors/comp-04/inference, so that a subscriber can select one machine, one line or the whole site with wildcards.

Keep the payload short but self-describing. The model field tells you which firmware produced a result after an over-the-air update, p lets subscribers apply their own confidence threshold, and us reveals when inference time creeps up after a model change. If you need to tune behaviour remotely, subscribe the device to a cmd topic for settings such as the heartbeat interval and validate every value before applying it. On a private ConnectMQ broker you can watch both topics in the live topic explorer and set a threshold alert rule on numeric fields while you test.

Moving to Real Sensor Data

Replace read_window() with samples from an accelerometer such as an MPU6050 or ADXL345, sampled at a fixed rate with a hardware timer or the sensor's FIFO. Then re-train on recorded windows: log real windows over MQTT or serial from each machine state, label them, and feed them to the same feature and training code. Keep the feature computation in Python and C++ identical, including the zero-crossing definition, or accuracy on the device will not match training.

When you go to production, switch to TLS and credentials: use the URI mqtts://broker.connectmq.com:8883 with your ConnectMQ broker host, set cfg.credentials.username, cfg.credentials.authentication.password and the issued client ID, and supply the CA certificate (the ESP-IDF certificate bundle handles Let's Encrypt). The ESP32 MQTT tutorial covers TLS setup in detail.

Troubleshooting TinyML on ESP32

Problem Cause Fix
AllocateTensors failed Arena too small Increase kArenaSize, then trim to arena_used_bytes() plus a margin
Didn't find op for builtin opcode Operator not registered Add it to the resolver and raise the template count
Always predicts one class Features not normalized like training Check kFeatMean/kFeatStd and the input quantization formula
Compile errors in TFLM headers Mixed versions or Arduino port Use esp-tflite-micro from the component registry with ESP-IDF 5.x
MQTT never connects Wi-Fi credentials or duplicate client ID Check the monitor log and make the client ID unique

Next Steps

Compare this hand-built pipeline with the Edge Impulse MQTT workflow, which automates data collection and feature design, or dig into the runtime itself in TensorFlow Lite Micro getting started. The AI and IoT guide shows where devices like this fit in a larger AIoT system.

Get Started with ConnectMQ

Your TinyML node is only as useful as the systems that receive its results. ConnectMQ is a managed MQTT broker with TLS, generated credentials and a live topic explorer for watching inference messages in real time. Create a broker on the free Serverless plan at app.connectmq.com, no credit card required, and point the firmware at it.

Frequently Asked Questions

Which ESP32 is best for TinyML?

The ESP32-S3 is the usual choice because its vector instructions accelerate neural network kernels in esp-tflite-micro and many boards include PSRAM. The original ESP32 works fine for small models like the one in this tutorial.

Can I do TinyML on ESP32 with the Arduino IDE?

Yes, through Arduino libraries that bundle TensorFlow Lite Micro or Edge Impulse exports. Check that the library version matches the current TFLM API, because older ports use different constructors and headers.

How large can a TinyML model on ESP32 be?

It depends on flash for the weights and RAM for the tensor arena. Small dense and convolutional models fit easily in internal memory; larger vision models usually need an ESP32-S3 with PSRAM.

Why publish only on change?

Inference runs many times per second, but downstream systems only care when the state changes. Publishing on change plus a heartbeat saves radio power and bandwidth and keeps you within broker rate limits.

Related articles

All articles →

Your private MQTT broker is a few clicks away

Create an account, get your broker credentials and connect your first device — no servers to install, patch or scale.