O Zero W é fraco para desktop e ótimo como dispositivo dedicado: consome menos de 1 W, tem Wi-Fi, Bluetooth, porta de câmera e 40 pinos de GPIO. Aqui estão 48 projetos que aproveitam isso e, no fim, o guia completo de um nó MQTT que você liga em qualquer tomada, carro ou barco e ele começa a publicar sozinho.
Todos testados como viáveis no ARMv6 de 512 MB. A dificuldade considera montagem e configuração; o hardware indica o que comprar além da placa, fonte e cartão.
Um único cartão SD que vira sensor em qualquer lugar. Você pluga os sensores, liga na energia e ele descobre sozinho o que está conectado, acha uma rede, publica no broker e aparece no Home Assistant. Sem rede, guarda tudo e envia depois.
Tenta a rede de casa, depois o hotspot do celular. Sem nenhuma, abre um portal Wi-Fi para configurar pelo telefone.
Um arquivo node.toml na partição de boot, editável no Windows ou Mac com o cartão no leitor.
Varre o barramento I²C e o 1-Wire a cada 30 ciclos. Plugou um sensor novo, ele passa a publicar.
Toda medição entra numa fila SQLite. Sem conexão, ela cresce; quando volta, esvazia com QoS 1.
Publica mensagens de descoberta do Home Assistant. Cada sensor vira uma entidade com unidade e tipo.
Last Will retido em status: o broker marca offline se o nó sumir sem se despedir.
nodes/zero-5a3c91f2/status online | offline # retido, Last Will nodes/zero-5a3c91f2/sistema {"cpu_temp": 41.2, "wifi_rssi": -58.0, "uptime_h": 12.4, "ts": 1790000000} nodes/zero-5a3c91f2/bme280_76 {"temperatura": 23.4, "umidade": 71.2, "pressao": 1013.2, "ts": 1790000000} nodes/zero-5a3c91f2/ds18b20_3f1a {"temperatura": 19.8, "ts": 1790000000} nodes/zero-5a3c91f2/gps {"lat": -27.14, "lon": -48.48, "vel_kmh": 0.0, "ts": 1790000000} homeassistant/sensor/zero-5a3c91f2/bme280_76_temperatura/config # descoberta, retido
Cerca de duas horas do cartão em branco ao dado no Home Assistant. Depois disso, cada nó novo é uma cópia do cartão.
| Item | Função | Obrigatório |
|---|---|---|
| Pi Zero W + barra de pinos soldada | O nó | Sim |
| Cartão microSD A1, 16–32 GB, de marca | Sistema e fila offline | Sim |
| Fonte 5 V / 2,5 A ou power bank | Energia estável; subtensão causa travamentos | Sim |
| BME280 (ou SHT31) em placa breakout | Temperatura, umidade, pressão | Um sensor I²C |
| DS18B20 à prova d'água + resistor 4,7 kΩ | Temperatura de água, freezer, solo | Opcional |
| BH1750 | Luminosidade em lux | Opcional |
| GPS NEO-6M ou NEO-M8N com antena | Posição e velocidade em uso móvel | Opcional |
| Case ventilado ou caixa IP65 com prensa-cabo | Uso externo, barco, veículo | Recomendado |
No Raspberry Pi Imager, escolha o dispositivo Raspberry Pi Zero e o sistema Raspberry Pi OS Lite (32-bit). Em "Editar configurações", defina hostname zero-campo, usuário e senha, a rede Wi-Fi de casa com país BR, e ative SSH com chave pública. Sem interface gráfica o sistema sobra mais RAM e escreve menos no cartão.
Primeiro boot demora 2 a 4 minutos. Depois, acesse com ssh usuario@zero-campo.local.
Liga I²C, 1-Wire e UART, manda o log para a RAM (menos escrita no SD) e ativa o watchdog de hardware, que reinicia a placa se o sistema travar.
sudo apt update && sudo apt full-upgrade -y sudo apt install -y i2c-tools sqlite3 python3-venv mosquitto-clients # I2C ligado, console serial desligado, UART de hardware ligada sudo raspi-config nonint do_i2c 0 sudo raspi-config nonint do_serial_cons 1 sudo raspi-config nonint do_serial_hw 0 # 1-Wire no GPIO4; libera a UART principal para o GPS (desativa o Bluetooth) echo 'dtoverlay=w1-gpio,gpiopin=4' | sudo tee -a /boot/firmware/config.txt echo 'dtoverlay=disable-bt' | sudo tee -a /boot/firmware/config.txt sudo systemctl disable hciuart # log só em RAM: poupa o cartão sudo mkdir -p /etc/systemd/journald.conf.d printf '[Journal]\nStorage=volatile\nRuntimeMaxUse=16M\n' | sudo tee /etc/systemd/journald.conf.d/ram.conf # watchdog de hardware: reinicia se o sistema congelar por 15 s sudo sed -i 's/^#\?RuntimeWatchdogSec=.*/RuntimeWatchdogSec=15/' /etc/systemd/system.conf sudo reboot
disable-bt e o hciuart.Com a placa desligada. O mapa de pinos está no topo da página. Todos os sensores I²C dividem os mesmos dois fios, cada um com seu endereço.
| Sensor | Barramento | Endereço | Pinos físicos |
|---|---|---|---|
| BME280 / BMP280 | I²C | 0x76 ou 0x77 | 1 VCC · 3 SDA · 5 SCL · 6 GND |
| SHT31 | I²C | 0x44 | mesmos pinos |
| BH1750 | I²C | 0x23 | mesmos pinos |
| DS18B20 | 1-Wire | 28-xxxxxxxxxxxx | 1 VCC · 7 dados · 9 GND + 4,7 kΩ |
| GPS NEO-6M | UART 9600 | /dev/serial0 | 2 VCC · 8 TX→RX · 10 RX←TX · 14 GND |
i2cdetect -y 1 # deve mostrar 76, 44, 23… ls /sys/bus/w1/devices/ # deve listar 28-… cat /sys/bus/w1/devices/28-*/temperature # milésimos de °C
Para o GPS, instale o gpsd e aponte para a serial:
sudo apt install -y gpsd gpsd-clients
sudo sed -i 's|^DEVICES=.*|DEVICES="/dev/serial0"|; s|^GPSD_OPTIONS=.*|GPSD_OPTIONS="-n"|' /etc/default/gpsd
sudo systemctl enable --now gpsd
cgps -s # aguarde o "3D FIX" com céu aberto
Em uso móvel o nó fica atrás do CGNAT da operadora do celular. Por isso ele sempre inicia a conexão para fora, e o broker precisa estar acessível na internet ou numa rede privada.
Controle total, TLS com Let's Encrypt, ACL por usuário. Uma VPS pequena aguenta centenas de nós.
HiveMQ Cloud ou EMQX Serverless têm planos gratuitos. Troque só host, user e password no node.toml.
Nó e broker na mesma tailnet, porta 1883 sem expor nada na internet. Use tls = false e o IP 100.x do broker.
sudo apt install -y mosquitto mosquitto-clients certbot
sudo certbot certonly --standalone -d mqtt.seudominio.com.br
# copia o certificado para o mosquitto a cada renovação
sudo install -d -m 750 -o root -g mosquitto /etc/mosquitto/certs
sudo tee /etc/letsencrypt/renewal-hooks/deploy/mosquitto.sh >/dev/null <<'EOF'
#!/bin/sh
D=/etc/letsencrypt/live/mqtt.seudominio.com.br
install -m 640 -o root -g mosquitto $D/fullchain.pem $D/privkey.pem /etc/mosquitto/certs/
systemctl restart mosquitto
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/mosquitto.sh
sudo /etc/letsencrypt/renewal-hooks/deploy/mosquitto.sh
sudo mosquitto_passwd -c /etc/mosquitto/passwd node
sudo mosquitto_passwd /etc/mosquitto/passwd homeassistant
listener 1883 127.0.0.1 listener 8883 certfile /etc/mosquitto/certs/fullchain.pem keyfile /etc/mosquitto/certs/privkey.pem allow_anonymous false password_file /etc/mosquitto/passwd acl_file /etc/mosquitto/acl
user node topic readwrite nodes/# topic write homeassistant/# user homeassistant topic read nodes/# topic readwrite homeassistant/#
Depois: sudo systemctl restart mosquitto e libere a porta 8883 no firewall.
Ele fica em /boot/firmware/node.toml, a partição FAT que aparece como unidade "bootfs" em qualquer computador. Assim você muda broker e redes Wi-Fi sem SSH, só com o cartão no leitor.
[mqtt] host = "mqtt.seudominio.com.br" port = 8883 tls = true user = "node" password = "troque-esta-senha" base = "nodes" [node] name = "" # vazio: usa zero-<serial da CPU>, único por placa interval = 60 # segundos entre leituras [gps] enabled = false # redes em ordem de preferência (maior prioridade primeiro) [[wifi]] ssid = "Casa" psk = "senha-da-casa" prioridade = 30 [[wifi]] ssid = "Hotspot-Celular" psk = "senha-do-hotspot" prioridade = 20
nodes/#, como no passo 4.Um único arquivo Python. Ele lê a configuração, detecta sensores, grava cada leitura na fila e esvazia a fila sempre que há conexão. O paho-mqtt cuida da reconexão.
sudo useradd -r -s /usr/sbin/nologin -G i2c,gpio node
sudo mkdir -p /opt/node-agent
sudo python3 -m venv /opt/node-agent/venv
sudo /opt/node-agent/venv/bin/pip install paho-mqtt smbus2 RPi.bme280
sudo nano /opt/node-agent/agent.py # cole o código abaixo
#!/usr/bin/env python3
"""Nó MQTT móvel: detecta sensores, publica e guarda leituras offline."""
import glob, json, socket, sqlite3, time, tomllib
import paho.mqtt.client as mqtt
from smbus2 import SMBus, i2c_msg
CFG = tomllib.load(open("/boot/firmware/node.toml", "rb"))
M, N = CFG["mqtt"], CFG.get("node", {})
def cpu_serial():
for line in open("/proc/cpuinfo"):
if line.startswith("Serial"):
return line.split(":")[1].strip()[-8:]
return socket.gethostname()
NODE_ID = N.get("name") or f"zero-{cpu_serial()}"
BASE = f'{M.get("base", "nodes")}/{NODE_ID}'
DB = sqlite3.connect("/var/lib/node-agent/outbox.db", isolation_level=None)
DB.execute("PRAGMA journal_mode=WAL")
DB.execute("CREATE TABLE IF NOT EXISTS outbox (id INTEGER PRIMARY KEY, topic TEXT, payload TEXT)")
bus = SMBus(1)
# ---------- sensores ----------
def present(addr):
try:
bus.read_byte(addr)
return True
except OSError:
return False
def read_bme280(addr):
import bme280 # pacote RPi.bme280
cal = bme280.load_calibration_params(bus, addr)
d = bme280.sample(bus, addr, cal)
# BMP280 no mesmo endereço não mede umidade; ignore esse campo se for o caso
return {"temperatura": round(d.temperature, 2),
"umidade": round(d.humidity, 1),
"pressao": round(d.pressure, 1)}
def read_sht31(addr):
bus.write_i2c_block_data(addr, 0x2C, [0x06]) # medição única, alta repetibilidade
time.sleep(0.02)
msg = i2c_msg.read(addr, 6)
bus.i2c_rdwr(msg)
d = list(msg)
t = -45 + 175 * (d[0] << 8 | d[1]) / 65535
h = 100 * (d[3] << 8 | d[4]) / 65535
return {"temperatura": round(t, 2), "umidade": round(h, 1)}
def read_bh1750(addr):
bus.write_byte(addr, 0x20) # uma leitura, alta resolução
time.sleep(0.2)
msg = i2c_msg.read(addr, 2)
bus.i2c_rdwr(msg)
d = list(msg)
return {"luz": round((d[0] << 8 | d[1]) / 1.2, 1)}
I2C = {0x76: ("bme280", read_bme280), 0x77: ("bme280", read_bme280),
0x44: ("sht31", read_sht31), 0x23: ("bh1750", read_bh1750)}
def detect():
return [(name, addr, fn) for addr, (name, fn) in I2C.items() if present(addr)]
def read_ds18b20():
out = {}
for path in glob.glob("/sys/bus/w1/devices/28-*/temperature"):
sid = path.split("/")[-2][-4:]
try:
out[f"ds18b20_{sid}"] = {"temperatura": int(open(path).read()) / 1000}
except (OSError, ValueError):
pass
return out
def read_system():
temp = int(open("/sys/class/thermal/thermal_zone0/temp").read()) / 1000
rssi = None
for line in open("/proc/net/wireless").readlines()[2:]:
if line.strip().startswith("wlan0"):
rssi = float(line.split()[3].rstrip("."))
up = float(open("/proc/uptime").read().split()[0])
return {"cpu_temp": round(temp, 1), "wifi_rssi": rssi, "uptime_h": round(up / 3600, 1)}
def read_gps():
try:
with socket.create_connection(("127.0.0.1", 2947), timeout=3) as s:
s.sendall(b'?WATCH={"enable":true,"json":true}\n')
f = s.makefile()
for _ in range(20):
msg = json.loads(f.readline())
if msg.get("class") == "TPV" and msg.get("mode", 0) >= 2:
return {"lat": msg["lat"], "lon": msg["lon"],
"vel_kmh": round(msg.get("speed", 0) * 3.6, 1)}
except (OSError, ValueError, KeyError):
pass
return {}
# ---------- fila offline ----------
def enqueue(topic, payload):
DB.execute("INSERT INTO outbox (topic, payload) VALUES (?, ?)", (topic, json.dumps(payload)))
DB.execute("DELETE FROM outbox WHERE id <= (SELECT MAX(id) - 50000 FROM outbox)")
def flush():
rows = DB.execute("SELECT id, topic, payload FROM outbox ORDER BY id LIMIT 500").fetchall()
for rid, topic, payload in rows:
info = client.publish(topic, payload, qos=1)
try:
info.wait_for_publish(timeout=5)
except Exception:
return
if not info.is_published():
return
DB.execute("DELETE FROM outbox WHERE id = ?", (rid,))
# ---------- Home Assistant ----------
META = {"temperatura": ("°C", "temperature"), "umidade": ("%", "humidity"),
"pressao": ("hPa", "pressure"), "luz": ("lx", "illuminance"),
"cpu_temp": ("°C", "temperature"), "wifi_rssi": ("dBm", "signal_strength"),
"uptime_h": ("h", None), "vel_kmh": ("km/h", "speed")}
def announce(readings):
dev = {"identifiers": [NODE_ID], "name": NODE_ID,
"model": "Raspberry Pi Zero W", "manufacturer": "DIY"}
for sensor, values in readings.items():
for metric in values:
unit, dclass = META.get(metric, (None, None))
cfg = {"name": f"{sensor} {metric}",
"unique_id": f"{NODE_ID}_{sensor}_{metric}",
"state_topic": f"{BASE}/{sensor}",
"value_template": f"{{{{ value_json.{metric} }}}}",
"availability_topic": f"{BASE}/status",
"state_class": "measurement",
"device": dev}
if unit:
cfg["unit_of_measurement"] = unit
if dclass:
cfg["device_class"] = dclass
client.publish(f"homeassistant/sensor/{NODE_ID}/{sensor}_{metric}/config",
json.dumps(cfg), qos=1, retain=True)
# ---------- MQTT ----------
state = {"announced": False}
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=NODE_ID)
client.username_pw_set(M["user"], M["password"])
if M.get("tls", True):
client.tls_set()
client.will_set(f"{BASE}/status", "offline", qos=1, retain=True)
client.reconnect_delay_set(min_delay=2, max_delay=120)
def on_connect(c, userdata, flags, reason_code, properties):
if not reason_code.is_failure:
c.publish(f"{BASE}/status", "online", qos=1, retain=True)
state["announced"] = False
print("conectado ao broker", flush=True)
client.on_connect = on_connect
client.connect_async(M["host"], M.get("port", 8883), keepalive=60)
client.loop_start()
# ---------- laço principal ----------
sensors, cycle = [], 0
while True:
if cycle % 30 == 0:
sensors = detect()
print("sensores I2C:", [f"{n}@{a:#x}" for n, a, _ in sensors], flush=True)
cycle += 1
readings = {"sistema": read_system()}
for name, addr, fn in sensors:
try:
readings[f"{name}_{addr:02x}"] = fn(addr)
except OSError as e:
print("falha na leitura", name, e, flush=True)
readings.update(read_ds18b20())
if CFG.get("gps", {}).get("enabled"):
g = read_gps()
if g:
readings["gps"] = g
ts = int(time.time())
for sensor, values in readings.items():
enqueue(f"{BASE}/{sensor}", {**values, "ts": ts})
if client.is_connected():
if not state["announced"]:
announce(readings)
state["announced"] = True
flush()
time.sleep(N.get("interval", 60))
Dois mecanismos. Um script lê as redes do node.toml a cada boot e cria perfis no NetworkManager com prioridade. Se nenhuma rede conhecida aparecer, o comitup abre um ponto de acesso comitup-NNN com portal em http://10.41.0.1 para você escolher a rede pelo celular.
#!/usr/bin/env python3
"""Cria ou atualiza perfis Wi-Fi do NetworkManager a partir do node.toml."""
import subprocess, tomllib
cfg = tomllib.load(open("/boot/firmware/node.toml", "rb"))
have = subprocess.run(["nmcli", "-t", "-f", "NAME", "con", "show"],
capture_output=True, text=True).stdout.splitlines()
for net in cfg.get("wifi", []):
name = f"node-{net['ssid']}"
prio = str(net.get("prioridade", 10))
if name in have:
subprocess.run(["nmcli", "con", "modify", name,
"wifi-sec.psk", net["psk"],
"connection.autoconnect-priority", prio])
else:
subprocess.run(["nmcli", "con", "add", "type", "wifi", "ifname", "wlan0",
"con-name", name, "ssid", net["ssid"],
"wifi-sec.key-mgmt", "wpa-psk", "wifi-sec.psk", net["psk"],
"connection.autoconnect-priority", prio])
sudo apt install -y comitup # portal de configuração quando não há rede
O systemd sobe o agente no boot, reinicia se ele cair e cria o diretório da fila com o dono certo.
[Unit] Description=Sincroniza redes Wi-Fi do node.toml After=NetworkManager.service Wants=NetworkManager.service [Service] Type=oneshot ExecStart=/usr/bin/python3 /opt/node-agent/node-wifi.py [Install] WantedBy=multi-user.target
[Unit] Description=Agente MQTT do nó móvel After=network-online.target node-wifi.service Wants=network-online.target [Service] User=node Group=node SupplementaryGroups=i2c gpio StateDirectory=node-agent ExecStart=/opt/node-agent/venv/bin/python /opt/node-agent/agent.py Restart=always RestartSec=10 [Install] WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now node-wifi node-agent
journalctl -u node-agent -f # deve mostrar "sensores I2C" e "conectado ao broker"
mosquitto_sub -h mqtt.seudominio.com.br -p 8883 --capath /etc/ssl/certs \ -u homeassistant -P 'senha' -t 'nodes/#' -v
ts original.status vira offline.zero-xxxxxxxx aparece em Configurações › Dispositivos, com uma entidade por medida.Com o primeiro nó funcionando, desligue, leia o cartão inteiro como imagem (Raspberry Pi Imager não lê; use Win32 Disk Imager, dd ou balenaEtcher em modo clone) e grave nos outros cartões. O ID vem do serial da CPU, então cada placa publica em tópicos próprios sem mudar nada.
Antes de gerar a imagem, apague as chaves SSH do host para cada cópia criar as suas no primeiro boot:
sudo rm /etc/ssh/ssh_host_* && sudo systemctl enable regenerate_ssh_host_keys && sudo poweroff
Em cada cópia, ajuste só o node.toml se o nó for para outra rede.
A média fica perto de 0,7 W com Wi-Fi ligado e leitura a cada minuto. O GPS acrescenta cerca de 0,2 W.
| Fonte | Uso indicado | Cuidado |
|---|---|---|
| Power bank 10.000 mAh | Coleta temporária, trilhas, eventos | Alguns desligam com corrente baixa; prefira modelos com modo "baixa corrente" |
| USB 12 V do carro ou barco | Rastreador veicular, telemetria náutica | A partida do motor derruba a tensão; use conversor 12→5 V de qualidade com capacitor |
| Painel 10 W + bateria 18650 + controlador | Estação off-grid permanente | Dimensione 3 dias de autonomia para semanas nubladas |
| HAT de UPS (PiSugar e similares) | Desligamento seguro ao faltar energia | Confira compatibilidade com o Zero W e o formato do case |
interval = 300 reduz tráfego e aquecimento. Para temperatura de água, 5 minutos basta.
Sem GPS e sem BLE, mantenha disable-bt. Sem monitor, o HDMI fica ocioso e consome pouco.
vcgencmd get_throttled deve retornar 0x0. Qualquer outro valor indica subtensão no passado.
| Sintoma | Causa provável | O que fazer |
|---|---|---|
| Não conecta no hotspot do celular | Hotspot em 5 GHz | Force 2,4 GHz ou "Maximizar compatibilidade" |
i2cdetect vazio | SDA/SCL trocados, I²C desligado | Confira pinos 3 e 5; rode raspi-config nonint do_i2c 0 |
| DS18B20 marca 85 °C | Valor de reset: sem pull-up ou alimentação fraca | Resistor de 4,7 kΩ entre dados e 3V3 |
| Falha de TLS logo após ligar | Sem RTC, relógio errado até o NTP sincronizar | Normal nos primeiros segundos; o paho reconecta sozinho |
| Travamentos aleatórios | Subtensão ou cabo micro-USB fino | Fonte oficial, cabo curto; confira get_throttled |
| GPS sem posição | Partida a frio ou antena coberta | Céu aberto por 5–15 min na primeira vez; acompanhe com cgps -s |
| Fila crescendo sem parar | Credencial ou ACL recusando publicação | Veja journalctl -u node-agent e o log do Mosquitto |
Assine nodes/<id>/cmd para acionar relés, mudar intervalo ou pedir leitura imediata.
Um fluxo no n8n com o gatilho MQTT dispara mensagem quando a temperatura do freezer passa do limite.
Telegraf grava o tópico gps no InfluxDB e o painel Geomap do Grafana desenha o percurso.
Guarde o agente num repositório e rode git pull com um timer do systemd uma vez por dia.