Bancada de desenvolvimento em ambiente escuro com teclado retroiluminado em verde — programando firmware e app
DIY de verdade: firmware de um lado, app do outro, e o rádio no meio

O que vamos construir: um ESP32 anunciando um serviço BLE (GATT) com uma característica de temperatura que suporta leitura e notificação, e um app React Native — usando a lib react-native-ble-plx — que escaneia, encontra o dispositivo pelo UUID do serviço, conecta, lê o valor atual e recebe atualizações ao vivo a cada 2 segundos. Código completo dos dois lados, permissões de Android e iOS resolvidas, e a lista dos erros que mordem todo mundo na primeira vez.

Bluetooth Low Energy é a cola entre o mundo dos dispositivos e o mundo dos apps: é assim que balança conversa com aplicativo de treino, fechadura conversa com celular e — no nosso mundo — totem e sensor conversam com o app de campo do técnico. A documentação oficial é fragmentada (metade fala de firmware, metade fala de mobile, nenhuma fala das duas juntas), então este é o tutorial que gostaríamos de ter lido: as duas pontas, no mesmo texto, funcionando juntas.

Você vai precisar de: uma placa ESP32 DevKit (qualquer clone comum serve), um cabo USB de dados (metade dos problemas de gravação são cabo de tomada disfarçado), e um celular físico — emulador não tem rádio Bluetooth, então Android Studio/Xcode simulator ficam de fora dessa.

BLE em 5 minutos: o vocabulário mínimo

BLE tem fama de complicado porque os nomes são ruins, não porque o modelo seja difícil. O essencial:

TermoO que éNo nosso projeto
PeripheralO dispositivo que anuncia e serve dados.O ESP32
CentralQuem escaneia, conecta e consome os dados.O app React Native
AdvertisingO "grito" periódico do peripheral: "existo, e ofereço isto".O ESP32 anunciando o UUID do serviço
GATTO contrato de dados sobre a conexão: serviços que contêm características.1 serviço, 1 característica
ServiçoAgrupador lógico de características, identificado por UUID."Serviço de telemetria"
CaracterísticaO dado em si: um valor com propriedades (read, write, notify...).A temperatura, como texto
NotifyO peripheral empurra o valor novo sem o app pedir.Atualização a cada 2 s
ReadO app puxa o valor atual quando quer.Leitura inicial ao conectar

A diferença entre read e notify define a arquitetura: read é o app perguntando (puxa), notify é o dispositivo avisando (empurra). Para telemetria que muda com frequência, notify é o caminho — economiza rádio, bateria e código de polling. Nosso firmware implementa os dois.

Uma decisão antes de codar: UUIDs. Serviços padronizados (bateria, frequência cardíaca) têm UUIDs curtos definidos pelo Bluetooth SIG; serviço próprio usa UUID de 128 bits gerado por você (qualquer gerador de UUID v4 serve). Gere dois — um pro serviço, um pra característica — e use os mesmos nos dois lados. Os deste tutorial funcionam, mas gere os seus pra produção.

Passo 1 — O firmware: ESP32 como servidor GATT

Setup do ambiente

A biblioteca BLE já vem com o core Arduino-ESP32 (a documentação oficial da Espressif cobre a API) — não precisa instalar nada além do board package.

O sketch completo

firmware/ble_temp.ino — C++ (Arduino framework)
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>

// Gere os SEUS UUIDs (v4) para produção — estes são do tutorial
#define SERVICE_UUID        "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"

const uint32_t NOTIFY_INTERVAL_MS = 2000;

BLEServer* server = nullptr;
BLECharacteristic* tempCharacteristic = nullptr;
bool deviceConnected = false;

class ServerCallbacks : public BLEServerCallbacks {
  void onConnect(BLEServer*) override {
    deviceConnected = true;
    Serial.println("central conectada");
  }
  void onDisconnect(BLEServer* s) override {
    deviceConnected = false;
    Serial.println("central desconectada — voltando a anunciar");
    s->getAdvertising()->start(); // sem isso, ninguém reconecta
  }
};

void setup() {
  Serial.begin(115200);

  BLEDevice::init("RhodiumTemp"); // nome visível no scan
  server = BLEDevice::createServer();
  server->setCallbacks(new ServerCallbacks());

  BLEService* service = server->createService(SERVICE_UUID);

  tempCharacteristic = service->createCharacteristic(
    CHARACTERISTIC_UUID,
    BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY
  );
  // CCCD (0x2902): descritor que permite à central LIGAR as notificações.
  // Esquecer esta linha = notify silenciosamente não funciona.
  tempCharacteristic->addDescriptor(new BLE2902());

  service->start();

  BLEAdvertising* advertising = BLEDevice::getAdvertising();
  // ESSENCIAL: anunciar o UUID do serviço — é por ele que o app filtra o scan
  advertising->addServiceUUID(SERVICE_UUID);
  advertising->setScanResponse(true);
  advertising->start();

  Serial.println("BLE pronto, anunciando...");
}

void loop() {
  if (deviceConnected) {
    // Troque pela leitura real do seu sensor (DS18B20, SHT31...)
    float temperature = 23.0f + (esp_random() % 100) / 25.0f;

    char payload[16];
    snprintf(payload, sizeof(payload), "%.2f", temperature);

    tempCharacteristic->setValue(payload);
    tempCharacteristic->notify();
    Serial.printf("notify: %s\n", payload);
  }
  delay(NOTIFY_INTERVAL_MS);
}

Três pontos deste código pagam o artigo inteiro, porque são exatamente onde os fóruns estão cheios de gente travada:

Grave o sketch (botão de upload; se a placa não entrar em modo de gravação sozinha, segure o botão BOOT durante o "Connecting..."), abra o Serial Monitor a 115200 e confirme o "BLE pronto, anunciando...". Antes de escrever qualquer linha do app, valide com um scanner genérico — o nRF Connect (Nordic, gratuito nas duas lojas) mostra o RhodiumTemp anunciando, o serviço, a característica, e deixa você ligar o notify na mão. Se funciona no nRF Connect e não funciona no seu app, o bug é do app; se não funciona nem lá, é do firmware. Essa bissecção economiza noites.

Passo 2 — O app: projeto React Native com react-native-ble-plx

Expo ou bare?

A react-native-ble-plx — a lib de referência pra BLE em React Native — exige código nativo, então não roda no Expo Go. Os dois caminhos que funcionam:

app.json — config plugin (caminho Expo)
{
  "expo": {
    "plugins": [
      [
        "react-native-ble-plx",
        {
          "isBackgroundEnabled": false,
          "modes": ["central"],
          "bluetoothAlwaysPermission": "Este app usa Bluetooth para conectar ao sensor de temperatura."
        }
      ]
    ]
  }
}

Permissões — onde 90% dos tutoriais desatualizados quebram

O Android mudou o modelo de permissões de Bluetooth no Android 12: a documentação oficial define BLUETOOTH_SCAN e BLUETOOTH_CONNECT como permissões de runtime a partir da API 31 — e, pra versões anteriores, escanear BLE exige ACCESS_FINE_LOCATION (porque beacon revela localização). No iOS, a chave NSBluetoothAlwaysUsageDescription no Info.plist é obrigatória — sem ela o app crasha ao tocar no Bluetooth, sem mensagem útil.

android/app/src/main/AndroidManifest.xml (caminho bare)
<!-- Android 12+ (API 31) -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
                 android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

<!-- Android 11 e anteriores -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
ios/SeuApp/Info.plist (caminho bare)
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Este app usa Bluetooth para conectar ao sensor de temperatura.</string>

O módulo BLE do app

Separe o BLE da interface — um módulo com o manager, as permissões e a decodificação. A lib trafega valores em base64, então instale também o polyfill buffer (npm install buffer):

src/ble.ts — TypeScript
import { BleManager } from "react-native-ble-plx";
import { PermissionsAndroid, Platform } from "react-native";
import { Buffer } from "buffer";

export const SERVICE_UUID = "4fafc201-1fb5-459e-8fcc-c5c9c331914b";
export const CHARACTERISTIC_UUID = "beb5483e-36e1-4688-b7f5-ea07361b26a8";

const ANDROID_12_API_LEVEL = 31;

// UMA instância pro app inteiro — criar mais de um BleManager causa bugs sutis
export const bleManager = new BleManager();

export async function requestBlePermissions(): Promise<boolean> {
  if (Platform.OS !== "android") return true; // iOS pede sozinho no 1º uso

  if (Number(Platform.Version) >= ANDROID_12_API_LEVEL) {
    const result = await PermissionsAndroid.requestMultiple([
      PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
      PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
    ]);
    return Object.values(result).every(
      (status) => status === PermissionsAndroid.RESULTS.GRANTED
    );
  }

  const granted = await PermissionsAndroid.request(
    PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION
  );
  return granted === PermissionsAndroid.RESULTS.GRANTED;
}

export function decodeBase64(value: string | null | undefined): string {
  if (!value) return "";
  return Buffer.from(value, "base64").toString("utf-8");
}

O componente: escanear, conectar, assinar

src/TempScreen.tsx — TypeScript + React
import React, { useEffect, useRef, useState } from "react";
import { Button, FlatList, StyleSheet, Text, View } from "react-native";
import { Device, Subscription } from "react-native-ble-plx";
import {
  bleManager, requestBlePermissions, decodeBase64,
  SERVICE_UUID, CHARACTERISTIC_UUID,
} from "./ble";

type Status = "idle" | "scanning" | "connecting" | "connected" | "error";

export default function TempScreen() {
  const [status, setStatus] = useState<Status>("idle");
  const [devices, setDevices] = useState<Device[]>([]);
  const [temperature, setTemperature] = useState<string>("--");
  const monitorRef = useRef<Subscription | null>(null);
  const deviceRef = useRef<Device | null>(null);

  useEffect(() => {
    // cleanup ao desmontar: para o monitor e desconecta
    return () => {
      monitorRef.current?.remove();
      deviceRef.current?.cancelConnection().catch(() => {});
    };
  }, []);

  async function startScan() {
    const ok = await requestBlePermissions();
    if (!ok) { setStatus("error"); return; }

    setDevices([]);
    setStatus("scanning");

    // Filtrar pelo UUID do serviço: só aparece quem anuncia o nosso serviço
    bleManager.startDeviceScan([SERVICE_UUID], null, (error, device) => {
      if (error) { setStatus("error"); return; }
      if (!device) return;
      setDevices((prev) =>
        prev.some((d) => d.id === device.id) ? prev : [...prev, device]
      );
    });
  }

  async function connectTo(device: Device) {
    bleManager.stopDeviceScan();
    setStatus("connecting");

    try {
      const connected = await device.connect();
      // obrigatório antes de qualquer read/monitor:
      await connected.discoverAllServicesAndCharacteristics();
      deviceRef.current = connected;

      // 1) leitura pontual do valor atual
      const characteristic = await connected.readCharacteristicForService(
        SERVICE_UUID, CHARACTERISTIC_UUID
      );
      setTemperature(decodeBase64(characteristic.value));

      // 2) assinatura das notificações (a lib liga o CCCD pra você)
      monitorRef.current = connected.monitorCharacteristicForService(
        SERVICE_UUID, CHARACTERISTIC_UUID,
        (error, char) => {
          if (error) { setStatus("error"); return; }
          setTemperature(decodeBase64(char?.value));
        }
      );

      // 3) reagir à queda de conexão
      connected.onDisconnected(() => {
        monitorRef.current?.remove();
        setStatus("idle");
        setTemperature("--");
      });

      setStatus("connected");
    } catch {
      setStatus("error");
    }
  }

  return (
    <View style={styles.screen}>
      <Text style={styles.temp}>{temperature} °C</Text>
      <Text style={styles.status}>{status}</Text>

      <Button title="Escanear" onPress={startScan} />

      <FlatList
        data={devices}
        keyExtractor={(d) => d.id}
        renderItem={({ item }) => (
          <Text style={styles.device} onPress={() => connectTo(item)}>
            {item.name ?? "(sem nome)"} — {item.id}
          </Text>
        )}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, padding: 24, paddingTop: 80 },
  temp: { fontSize: 56, fontWeight: "600", textAlign: "center" },
  status: { textAlign: "center", marginBottom: 24, opacity: 0.6 },
  device: { paddingVertical: 12, fontSize: 15 },
});

O fluxo completo, na ordem que o rádio vê: permissão → scan filtrado por serviço → connect → discover → read → monitor. O discoverAllServicesAndCharacteristics é obrigatório e esquecível — sem ele, read e monitor falham com erro de "characteristic not found" mesmo com tudo certo no firmware. E o monitor da lib já cuida de escrever no CCCD (aquele descritor BLE2902 do firmware) — os dois lados do tutorial se encaixam aqui.

Rode no celular físico (npx expo run:android / npx react-native run-android), toque em Escanear, conecte no RhodiumTemp — e a temperatura passa a atualizar sozinha a cada 2 segundos. Firmware empurrando, app ouvindo, zero polling.

Troubleshooting: o que morde de verdade

Daqui pra produção

Esse par ESP32 + app é o "hello world" da mesma arquitetura que opera coisa séria: no nosso ecossistema de venda autônoma e dispositivos conectados — mais de 800 unidades em campo — BLE e outras camadas de rádio fazem papel de configuração local, diagnóstico de campo e comunicação entre módulos, enquanto a telemetria sobe por Wi-Fi/4G. A distância entre o tutorial e o produto está nas partes chatas: reconexão automática, atualização de firmware remota, segurança das características, e o suporte do dia 200. É exatamente essa ponte que a gente constrói pra clientes — começando, como todo projeto nosso, pela Fase 1: o Diagnóstico de Viabilidade Técnica.

Perguntas frequentes

Preciso parear o ESP32 com o celular para usar BLE?

Não. Para um servidor GATT simples sem criptografia, a conexão BLE acontece direto pelo app — sem passar pelas configurações de Bluetooth do sistema e sem PIN. Pareamento (bonding) só entra quando você habilita segurança nas características, com dados sensíveis ou comandos críticos.

Funciona com Expo ou preciso de projeto bare?

Funciona com Expo, mas não no Expo Go: a react-native-ble-plx exige código nativo, então é preciso um development build (expo-dev-client) com o config plugin da lib, ou um projeto bare React Native. O Expo Go não carrega módulos nativos de terceiros.

Qual o alcance do BLE com ESP32?

Em ambiente interno real, algo entre 10 e 30 metros, dependendo de paredes, interferência e antena da placa. BLE foi projetado para proximidade e baixo consumo — para longas distâncias a tecnologia certa é outra (LoRa, Wi-Fi, 4G), tema do nosso artigo de automação com IoT.

Consigo enviar comandos do app para o ESP32?

Sim. Basta criar uma segunda característica com a propriedade WRITE no firmware e usar writeCharacteristicWithResponseForDevice no app. O ESP32 recebe o valor num callback (onWrite) e age — liga um relé, muda um parâmetro, aciona um motor. É o mesmo padrão deste tutorial, na direção contrária.

Tem um produto que precisa de hardware conversando com app?

Do protótipo em bancada ao dispositivo em campo com atualização remota e suporte, o caminho tem armadilhas que só quem opera conhece. Na Fase 1 — o Diagnóstico de Viabilidade Técnica — colocamos sua ideia na mesa e você sai com o veredicto: arquitetura, custo de ciclo de vida e prazo. Sessão de até 3 horas, R$ 4.900, abatidos do projeto se você seguir.

Agendar meu Diagnóstico
R

Sobre o autor

Rhodion Souza Araújo é fundador e CEO da Rhodium Tecnologia, em breve completando 30 anos de trajetória em tecnologia e mais de 100 automações entregues em produção desde 2016 — de apps e agentes de IA a hardware de venda autônoma com mais de 800 unidades em campo. LinkedIn