Community project

Smart Home IR Controller

Hugo Oliveira

Published July 16, 2026 · Updated July 16, 2026

ESP3210 components8 assembly steps
Remix this project
Photo of Smart Home IR Controller

This smart home IR controller uses an M5Dial as the central hub to manage air conditioning, room climate, occupancy detection, and addressable lighting across multiple zones. The system is built around ESP32-C3 Super Mini nodes distributed throughout the home—one for IR control and temperature sensing near the AC unit, radar nodes for presence detection in each room, door sensors for entry points, and LED nodes driving WS2812B strips per zone. All nodes communicate via ESP-NOW for low-latency wireless control.

This guide provides the complete wiring diagrams, parts list, and firmware for assembling a distributed smart home network. You'll learn how to configure each node type, set up the M5Dial control panel with local temperature display and home status screensaver, and deploy the modular firmware across your ESP32 devices. The project includes AC control via IR, multi-room temperature and humidity monitoring, mmWave radar presence sensing, door/window detection, and synchronized RGB lighting—all coordinated from a single touchscreen interface.

Wiring diagram

Interactive · read-only
Wiring diagram for Smart Home IR Controller

Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.

Parts list

Bill of materials
ComponentQtyNotes
SHT301Sensirion SHT3x temperature and relative humidity sensor with I2C interface. Common SHT30 breakout boards use address 0x44 or 0x45.
ESP32-C3 Super Mini (Nó IR + Sensor + Corrente)1Nó combinado junto ao AC: envia IR, lê temperatura/humidade SHT30, lê corrente do AC via SCT-013 no GPIO0. Circuito de bias externo: 2x10kΩ entre 3.3V e GND com ponto médio ao TIP do SCT-013, condensador 10µF em paralelo.
SHT301Sensirion SHT3x temperature and relative humidity sensor with I2C interface. Common SHT30 breakout boards use address 0x44 or 0x45.
LED IR 940nm + R 33Ω (Nó Remoto)1LED emissor infravermelho 940nm com resistência de 33Ω em série, ligado ao ESP32-C3 do nó remoto. Aponta para o recetor do ar condicionado Whirlpool.
ESP32-C3 Super Mini (Nó Radar)1Nó ESP-NOW com LD2410 radar de presença mmWave — fica no teto de cada divisão
LD2410 24GHz mmWave Radar Presence Sensor1Hi-Link 24GHz FMCW mmWave radar module for human presence detection, capable of detecting both stationary (still, lying in bed) and moving targets within configurable range gates. Communicates over UART at 256000 baud (default). Operates from 5V supply; UART TX pin outputs at 5V logic requiring level shifting to 3.3V for ESP32-S3 RX. Also exposes an OUT pin (binary presence indicator) and supports Bluetooth configuration on some variants.
ESP32-C3 Super Mini (Nó Porta)1Nó ESP-NOW com Reed Switch — fica em cada porta/entrada
Reed Switch1Glass-encapsulated magnetic reed switch -- contacts close in the presence of a magnetic field. Standard door/window sensor for alarms. Wire as a digital input with internal pull-up; debounce in firmware.
ESP32-C3 Super Mini (Nó LED WS2812B)1Nó remoto ESP-NOW para controlo de fita LED WS2812B. Recebe comandos do M5Dial via ESP-NOW e controla a fita com FastLED.
WS2812B1Addressable RGB LED strip

Assembly

8 steps
  1. M5Dial — sensor de temperatura local (SHT30)

    Liga o SHT30 ao Port A do M5Dial (conector Grove de 4 pinos amarelo). O cabo Grove tem a ordem: GND (preto), VCC (vermelho), SDA (branco/amarelo), SCL (amarelo/branco). Liga diretamente sem soldas.

    • Tip: O SHT30 opera a 3.3V — totalmente compatível com o M5Dial.
    • Tip: O endereço I2C é 0x44 por omissão.
  2. Nó IR+Sensor+Corrente (junto ao AC) — montar o ESP32-C3

    Este é o nó mais completo. Num ESP32-C3 Super Mini, vais ligar o LED IR, o SHT30 e o circuito de corrente SCT-013.

    • Tip: Usa uma breadboard ou monta numa caixa pequena que encaixes junto ao AC.
  3. Nó IR+Sensor — ligar o LED IR e o SHT30 ao ESP32-C3

    LED IR: Anodo (+) → resistência 33Ω → GPIO3 do ESP32-C3; Cátodo (−) → GND. SHT30: VCC → 3V3; GND → GND; SDA → GPIO4; SCL → GPIO5. Alimenta o ESP32-C3 via USB-C.

    • Tip: A resistência de 33Ω deve ficar em série entre o GPIO3 e o anodo do LED.
    • Tip: O LED IR parece apagado — é normal, a luz é invisível para o olho humano.
    • Não ligues o LED IR diretamente ao GPIO sem a resistência de 33Ω — danifica o ESP32-C3.
  4. Nó IR+Sensor — circuito de bias e ligação do SCT-013

    Este circuito é obrigatório para o SCT-013 funcionar com o ADC do ESP32-C3. Monta na breadboard: 1. Dois resistores de 10kΩ em série entre 3.3V e GND 2. O ponto médio (entre os dois 10kΩ) liga ao TIP do jack 3.5mm do SCT-013 3. O ponto médio também liga ao GPIO0 do ESP32-C3 4. Um condensador electrolítico de 10µF entre o ponto médio e GND (+ para o ponto médio) 5. O SLEEVE do jack 3.5mm liga ao GND No quadro elétrico: abre o SCT-013 e abraça só o cabo de FASE do AC (não o neutro).

    • Tip: O SCT-013-030 tem resistência de carga interna — não precisas de adicionar resistência de carga extra.
    • Tip: Abraça o cabo de fase do AC — normalmente o fio castanho em Portugal.
    • Tip: Distância do SCT-013 ao ESP32-C3: o cabo do SCT-013 tem ~1m. Estende com cabo de áudio mono 3.5mm se necessário.
    • Trabalha no quadro elétrico SEMPRE com a eletricidade DESLIGADA no disjuntor!
    • O SCT-013 deve abraçar APENAS um fio (fase OU neutro) — não os dois juntos!
    • O condensador electrolítico tem polaridade — o + fica para o ponto médio do divisor.
  5. Nó Radar — ESP32-C3 + LD2410 (por divisão)

    Para cada divisão que queiras monitorizar presença: LD2410 VCC → pino 5V do ESP32-C3 LD2410 GND → GND LD2410 OUT → divisor resistivo → GPIO4 do ESP32-C3 Divisor resistivo (obrigatório — LD2410 OUT é 5V, ESP32-C3 é 3.3V): GPIO4 ←[10kΩ]← OUT do LD2410 Ponto médio ←[20kΩ]→ GND Instala no teto da divisão apontado para baixo. Alimenta via USB-C.

    • Tip: O LD2410 deteta presença estática — funciona mesmo com pessoa sentada ou a dormir.
    • Tip: O alcance típico é 5-6 metros — cobre a maioria das divisões.
    • O pino OUT do LD2410 é 5V — não ligues diretamente ao GPIO do ESP32-C3 sem o divisor resistivo!
  6. Nó Porta — ESP32-C3 + Reed Switch (por porta)

    Para cada porta que queiras monitorizar: Reed switch PIN1 → GPIO5 do ESP32-C3 (pull-up interno ativado no código) Reed switch PIN2 → GND Coloca o reed switch no batente da porta. O íman vai na porta em si, alinhado com o reed switch quando a porta está fechada. Alimenta o ESP32-C3 via USB-C.

    • Tip: Distância máxima típica de ativação do reed switch: 1-2cm.
    • Tip: Usa fita bi-adesiva ou cola quente para fixar o reed switch e o íman.
  7. Nó LED — ESP32-C3 + Fita WS2812B (por divisão)

    Para cada divisão com fita LED: ESP32-C3 GPIO2 → resistência 330Ω → DATA da fita WS2812B A fita WS2812B precisa de alimentação 5V SEPARADA — não uses os pinos do ESP32-C3: Fonte 5V (carregador USB) → VCC da fita GND da fonte → GND da fita E GND do ESP32-C3 (GND partilhado obrigatório) Alimenta o ESP32-C3 via USB-C.

    • Tip: Para fitas até 30 LEDs usa um carregador USB 5V 2A.
    • Tip: Para fitas maiores usa fonte 5V adequada (calcula 60mA por LED ao máximo brilho).
    • Tip: A resistência de 330Ω no DATA protege contra picos de tensão.
    • A fita WS2812B consome muito mais corrente do que o ESP32-C3 consegue fornecer — usa sempre fonte 5V dedicada!
    • O GND da fita e o GND do ESP32-C3 DEVEM estar ligados entre si.
  8. Configurar os MACs ESP-NOW e fazer Deploy

    1. Faz Deploy no M5Dial — abre o Serial Monitor — anota o MAC: '[M5Dial] MAC para ESP-NOW: AA:BB:CC:DD:EE:FF' 2. Em sensor_node.cpp: substitui DIAL_MAC[] pelo MAC real do M5Dial 3. Em sensor_node.cpp: substitui ROOM_ID pelo número da divisão (0=Sala com AC) 4. Faz Deploy no ESP32-C3 do nó IR+Sensor — anota o MAC do nó: '[WiFi] MAC deste nó: ...' 5. Em firmware.cpp: substitui IR_NODE_MAC[] pelo MAC do nó IR 6. Faz Deploy novamente no M5Dial — o ecrã mostra 'Nó IR: OK' a verde 7. Repete para cada nó radar (radar_node.cpp), porta (door_node.cpp) e LED (led_node.cpp) com os respetivos ROOM_ID/DOOR_ID

    • Tip: Cada ESP32-C3 tem um MAC único — anota numa etiqueta quem é cada nó.
    • Tip: O ROOM_ID define a que divisão pertence cada nó de sensor ou radar.
    • Tip: O estado real do AC (ON/OFF) aparece no ecrã do módulo AC — verde=ligado, cinzento=desligado.

Pin assignments

Board wiring reference
PinConnectionType
3V3sht30 VCCpower
GNDsht30 GNDground
GPIO 13sht30 SDAi2c
GPIO 15sht30 SCLi2c
EXTesp32c3_node USB-CCarregador USB-C de paredepower
EXTesp32c3_node GPIO3LED IR 940nm + R 33Ω (Nó Remoto) ANODEdigital
EXTesp32c3_node GPIO4SHT30 SDAi2c
EXTesp32c3_node GPIO5SHT30 SCLi2c
GNDir_led_remote CATHODEground
3V3sht30_remote VCCpower
GNDsht30_remote GNDground
EXTradar_node USB-CCarregador USB-C de paredepower
EXTradar_node GPIO4LD2410 24GHz mmWave Radar Presence Sensor OUTdigital
EXTradar_node 5V_OUTLD2410 24GHz mmWave Radar Presence Sensor VCCpower
GNDld2410_radar GNDground
EXTld2410_radar TXNão usadouart
EXTld2410_radar RXNão usadouart
EXTdoor_node USB-CCarregador USB-C de paredepower
EXTdoor_node GPIO5Reed Switch PIN1digital
GNDreed_switch PIN2ground
EXTled_node VCCCarregador USB-C de parede 5Vpower
GNDled_node GNDground
EXTled_node GPIO2WS2812B DATAdigital
5Vws2812b_strip VCCpower
GNDws2812b_strip GNDground

Firmware

ESP32
firmware.cppDeploy to device
// =============================================================
//  M5Dial - Painel de Casa Inteligente v1.7
//  Módulo 1 : Controlo AC Whirlpool por IR (via nó remoto ESP-NOW)
//  Módulo 2 : Divisões - temperatura/humidade de nós remotos
//  Módulo 3 : Presença - radares por divisão + portas separadas
//  Módulo 4 : Luzes - fita WS2812B por divisão via ESP-NOW
//  Screensaver: Relógio + Temperatura + Estado casa
//
//  Nós remotos (ESP32-C3 Super Mini):
//    - Nó IR+Sensor+SCT : src/sensor_node/sensor_node.cpp  (junto ao AC)
//    - Nó Radar         : src/radar_node/radar_node.cpp    (teto de cada divisão)
//    - Nó Porta         : src/door_node/door_node.cpp      (em cada porta/entrada)
//    - Nó LED           : src/led_node/led_node.cpp        (por divisão com fita WS2812B)
// =============================================================

#include <Arduino.h>
#include <M5Dial.h>
#include <Wire.h>
#include <Adafruit_SHT31.h>
#include <WiFi.h>
#include <esp_now.h>
#include <IRremoteESP8266.h>
#include <ir_Whirlpool.h>
#include <time.h>
#include <LittleFS.h>

// ── Pins ─────────────────────────────────────────────────────
#define SHT30_SDA   13
#define SHT30_SCL   15

// ── Tempos ───────────────────────────────────────────────────
#define SCREENSAVER_MS      20000
#define SENSOR_READ_MS       5000
#define CLOCK_UPDATE_MS      1000
#define LOG_INTERVAL_MS  1800000UL   // 30 minutos

// ── LittleFS ─────────────────────────────────────────────────
#define LOG_FILE        "/datalog.csv"
#define LOG_MAX_BYTES   1400000UL    // ~1.4 MB — apaga o mais antigo acima disto

// ── Wi-Fi para NTP (opcional) ─────────────────────────────────
#define WIFI_SSID   ""
#define WIFI_PASS   ""
#define NTP_SERVER  "pool.ntp.org"
#define TZ_OFFSET   3600
#define TZ_DST      3600

// ── Divisões remotas ─────────────────────────────────────────
#define MAX_ROOMS     8
#define MAX_DOORS     8
#define MAX_LED_NODES 8

// ── Tipos de pacotes ESP-NOW ──────────────────────────────────
#define PACKET_TYPE_RADAR   0xBB
#define PACKET_TYPE_DOOR    0xCC
#define PACKET_TYPE_LED_CMD 0xDD
#define PACKET_TYPE_AC_STATUS 0xAE

// ── Modos de efeito LED ───────────────────────────────────────
#define EFFECT_SOLID       0
#define EFFECT_FADE        1
#define EFFECT_RAINBOW     2
#define EFFECT_PULSE       3
#define EFFECT_WARM_WHITE  4
#define EFFECT_OFF         5





struct SensorPacket {
  uint8_t  roomId;
  float    temperature;
  float    humidity;
  uint32_t timestamp;
  bool     valid;
};

struct AcCommandPacket {
  uint8_t  type;
  bool     power;
  uint8_t  temp;
  uint8_t  mode;
  uint8_t  fan;
};

struct LedCommandPacket {
  uint8_t  type;        // 0xDD
  uint8_t  nodeId;      // ID do nó (255 = todos)
  uint8_t  effect;
  uint8_t  brightness;
  uint8_t  r, g, b;
  uint8_t  speed;
};

struct RadarPacket {
  uint8_t  type;           // 0xBB
  uint8_t  roomId;
  bool     occupied;
  bool     radarActive;
  uint32_t lastDetectedMs;
  char     label[16];      // label da divisao (do no)
};

struct DoorPacket {
  uint8_t  type;           // 0xCC
  uint8_t  doorId;
  char     label[16];
  bool     isOpen;
  uint32_t openCount;
};

struct RoomData {
  float    temperature;
  float    humidity;
  uint32_t lastReceived;
  bool     hasData;
  char     label[16]      = "Sensor";
};

struct RadarRoomState {
  bool     occupied       = false;
  bool     radarActive    = false;
  bool     hasData        = false;
  unsigned long lastReceived = 0;
  char     label[16]      = "Divisao";
};

struct DoorState {
  char     label[16]      = "Porta";
  bool     isOpen         = false;
  uint32_t openCount      = 0;
  bool     hasData        = false;
  unsigned long lastReceived = 0;
};

struct LedNodeState {
  bool    active     = false;
  uint8_t effect     = EFFECT_WARM_WHITE;
  uint8_t brightness = 128;
  uint8_t r = 255, g = 180, b = 80;
  uint8_t speed      = 5;
  char    label[16]  = "Luzes";
  uint8_t mac[6]     = {0xFF,0xFF,0xFF,0xFF,0xFF,0xFF};
  bool    macSet     = false;
};

enum AppState {
  STATE_MENU = 0,
  STATE_AC,
  STATE_ROOMS,
  STATE_PRESENCE,
  STATE_LIGHTS,
  STATE_HISTORY,
  STATE_SYSTEM,
  STATE_SCREENSAVER
};

struct AcState {
  bool    power   = false;
  uint8_t temp    = 24;
  uint8_t mode    = kWhirlpoolAcAuto;
  uint8_t fan     = kWhirlpoolAcFanAuto;
  int8_t  submenu = 0;
  bool    editing = false;
};

struct MenuItem {
  const char* label;
  const char* icon;
  uint16_t    color;
};

struct AcStatusPacket {
  uint8_t type;        // 0xAE
  bool    acOn;        // true = AC a consumir corrente (ligado)
  float   currentRms;  // corrente RMS medida (Amperes)
};

struct HeartbeatPacket {
  uint8_t  type;           // 0xFE
  uint8_t  nodeType;       // NODE_TYPE_*
  uint8_t  nodeId;         // roomId / doorId / nodeId conforme tipo
  char     label[16];      // nome do nó (ex: "Sala", "Principal")
  uint8_t  powerSource;    // POWER_USB ou POWER_BATTERY
  uint8_t  batteryPct;     // 0-100 (0 se USB)
  uint8_t  rssi;           // potência do sinal (0-100, estimado)
};

struct NodeRegistryEntry {
  bool     active        = false;
  uint8_t  mac[6]        = {};
  uint8_t  nodeType      = NODE_TYPE_SENSOR;
  uint8_t  nodeId        = 0;
  char     label[16]     = {};
  uint8_t  powerSource   = POWER_USB;
  uint8_t  batteryPct    = 0;
  uint8_t  rssi          = 0;
  unsigned long lastSeen = 0;
};

struct HistEntry {
  char      timeStr[10];   // "HH:MM"
  char      dateStr[7];    // "DD/MM"
  float     temp;
  float     hum;
  bool      acOn;
  float     acAmps;
};

// ── Registo de nós ───────────────────────────────────────────
#define PACKET_TYPE_HEARTBEAT  0xHB  // redefinido abaixo
#undef  PACKET_TYPE_HEARTBEAT
#define PACKET_TYPE_HEARTBEAT  0xFE

#define NODE_TYPE_SENSOR   0   // IR + SHT30 + SCT
#define NODE_TYPE_RADAR    1   // LD2410
#define NODE_TYPE_DOOR     2   // Reed switch
#define NODE_TYPE_LED      3   // WS2812B

#define POWER_USB          0
#define POWER_BATTERY      1

#define MAX_NODES_REG      20  // máx. nós registados








// Forward declarations

// Forward declarations
void updateNodeRegistry(const uint8_t *mac, const HeartbeatPacket& hb);

static void dropOldestLine();

static const char* EFFECT_NAMES[] = {
  "Solido", "Fade", "Arco-iris", "Pulso", "Br.Quente", "Desligar"
};

// ── Estruturas de pacotes ESP-NOW ─────────────────────────────




// ── MACs dos nós ─────────────────────────────────────────────
// Substitui com os MACs reais dos teus ESP32-C3!
uint8_t IR_NODE_MAC[6]  = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF };
bool    irPeerAdded     = false;

// Nós LED - configura um por divisão com fita WS2812B
// Exemplo: ledNodes[0].mac = {0xAA,0xBB,0xCC,0xDD,0xEE,0xFF};
//          ledNodes[0].macSet = true;
//          strncpy(ledNodes[0].label, "Sala", 15);

// ── Paleta de cores ──────────────────────────────────────────
#define COL_BG       0x0820
#define COL_CARD     0x1082
#define COL_ACCENT   0x07FF
#define COL_TEXT     0xFFFF
#define COL_DIM      0x8410
#define COL_RED      0xF800
#define COL_GREEN    0x07E0
#define COL_ORANGE   0xFD20
#define COL_YELLOW   0xFFE0
#define COL_LBLUE    0x3D9F
#define COL_PURPLE   0xD81F

// ── Menu ─────────────────────────────────────────────────────
static const MenuItem MENU_ITEMS[] = {
  { "Ar Cond.", "AC",  COL_ACCENT },
  { "Temp/Hum", "TMP", COL_ACCENT },
  { "Presenca", "PRE", COL_GREEN  },
  { "Luzes",    "LUZ", COL_YELLOW },
  { "Historico","LOG", COL_ORANGE },
  { "Sistema",  "SYS", COL_DIM    },
  // Adicionar novos módulos aqui
};
static const int NUM_ITEMS = sizeof(MENU_ITEMS) / sizeof(MenuItem);

// ── Globais ──────────────────────────────────────────────────
AppState       appState        = STATE_MENU;
AcState        acState;
RoomData       rooms[MAX_ROOMS]      = {};
RadarRoomState radarRooms[MAX_ROOMS];
DoorState      doors[MAX_DOORS];
LedNodeState   ledNodes[MAX_LED_NODES];
int            menuSel         = 0;
int            roomViewSel     = 0;
int            lightsSelNode   = 0;
int            lightsSubmenu   = 0;
bool           lightsEditing   = false;
long           lastEnc         = 0;
bool           needRedraw      = true;
unsigned long  lastActivity    = 0;
unsigned long  lastSensorRead  = 0;

float          sensorTemp  = NAN;
float          sensorHum   = NAN;
bool           sensorOk    = false;

int            espNowRxCount   = 0;
NodeRegistryEntry nodeRegistry[MAX_NODES_REG];
unsigned long  lastClockUpdate = 0;
bool           ntpSynced       = false;
unsigned long  lastLogWrite    = 0;
bool           fsOk            = false;
uint32_t       logEntryCount   = 0;

// ── Histórico — buffer das últimas leituras para o ecrã ──────
#define HIST_DISPLAY  5

HistEntry histBuf[HIST_DISPLAY];
int       histBufCount  = 0;
int       histViewSel   = 0;

// ── Estado real do AC (via SCT-013) ──────────────────────────
bool           acRealOn        = false;   // estado lido pelo SCT-013
float          acCurrentRms    = 0.0f;    // corrente RMS medida
bool           acStatusReceived = false;  // já recebemos dados do SCT?

// ── Dias da semana e meses ────────────────────────────────────
static const char* WEEK_DAYS[] = {
  "Domingo", "Segunda", "Terca", "Quarta",
  "Quinta",  "Sexta",   "Sabado"
};
static const char* MONTHS[] = {
  "Jan","Fev","Mar","Abr","Mai","Jun",
  "Jul","Ago","Set","Out","Nov","Dez"
};

// ── Objetos ──────────────────────────────────────────────────
Adafruit_SHT31 sht31;

// ── Forward declarations ──────────────────────────────────────
const char* acModeName(uint8_t m);
const char* acFanName(uint8_t f);
void sendAcCommand();
void sendLedCommand(int nodeIdx);
void readSensor();
void syncNTP();
bool anyoneHome();
void writeLogEntry();
void loadLastEntries();
void drawMenu();
void drawAc();
void drawRooms();
void drawPresence();
void drawLights();
void drawHistory();
void drawSystem();
void drawScreensaver();
void handleMenuEncoder(int delta, bool pressed);
void handleAcEncoder(int delta, bool pressed);
void handleRoomsEncoder(int delta, bool pressed);
void handlePresenceEncoder(int delta, bool pressed);
void handleLightsEncoder(int delta, bool pressed);
void handleHistoryEncoder(int delta, bool pressed);
void handleSystemEncoder(int delta, bool pressed);
void wakeFromScreensaver();
void onEspNowReceive(const uint8_t *mac, const uint8_t *data, int len);

// ── Helpers AC ───────────────────────────────────────────────
const char* acModeName(uint8_t m) {
  switch (m) {
    case kWhirlpoolAcAuto: return "Auto";
    case kWhirlpoolAcCool: return "Frio";
    case kWhirlpoolAcHeat: return "Calor";
    case kWhirlpoolAcFan:  return "Vent.";
    case kWhirlpoolAcDry:  return "Seco";
    default: return "?";
  }
}

const char* acFanName(uint8_t f) {
  switch (f) {
    case kWhirlpoolAcFanAuto:   return "Auto";
    case kWhirlpoolAcFanLow:    return "Baixa";
    case kWhirlpoolAcFanMedium: return "Media";
    case kWhirlpoolAcFanHigh:   return "Alta";
    default: return "?";
  }
}

void sendAcCommand() {
  if (!irPeerAdded) {
    Serial.println("[AC] No IR nao configurado - define IR_NODE_MAC!");
    M5Dial.Speaker.tone(400, 120);
    return;
  }
  AcCommandPacket cmd;
  cmd.type  = 0xAC;
  cmd.power = acState.power;
  cmd.temp  = acState.temp;
  cmd.mode  = acState.mode;
  cmd.fan   = acState.fan;
  esp_err_t result = esp_now_send(IR_NODE_MAC, (uint8_t*)&cmd, sizeof(cmd));
  if (result == ESP_OK) {
    Serial.printf("[AC] Enviado: power=%s temp=%d\n",
                  cmd.power ? "ON" : "OFF", cmd.temp);
    M5Dial.Speaker.tone(1000, 80);
  } else {
    Serial.println("[AC] Falha ao enviar!");
    M5Dial.Speaker.tone(400, 120);
  }
}

// ── Enviar comando LED ────────────────────────────────────────
void sendLedCommand(int nodeIdx) {
  if (nodeIdx < 0 || nodeIdx >= MAX_LED_NODES) return;
  LedNodeState& n = ledNodes[nodeIdx];
  if (!n.macSet) {
    Serial.printf("[LED] Nó %d sem MAC configurado!\n", nodeIdx);
    M5Dial.Speaker.tone(400, 120);
    return;
  }
  if (!esp_now_is_peer_exist(n.mac)) {
    esp_now_peer_info_t peer = {};
    memcpy(peer.peer_addr, n.mac, 6);
    peer.channel = 0;
    peer.encrypt = false;
    esp_now_add_peer(&peer);
  }
  LedCommandPacket cmd;
  cmd.type       = PACKET_TYPE_LED_CMD;
  cmd.nodeId     = (uint8_t)nodeIdx;
  cmd.effect     = n.effect;
  cmd.brightness = n.brightness;
  cmd.r          = n.r;
  cmd.g          = n.g;
  cmd.b          = n.b;
  cmd.speed      = n.speed;
  esp_err_t res = esp_now_send(n.mac, (uint8_t*)&cmd, sizeof(cmd));
  if (res == ESP_OK) {
    Serial.printf("[LED] Enviado para nó %d: efeito=%d brilho=%d\n",
      nodeIdx, n.effect, n.brightness);
    M5Dial.Speaker.tone(1000, 60);
  } else {
    Serial.printf("[LED] Falha envio nó %d\n", nodeIdx);
    M5Dial.Speaker.tone(400, 120);
  }
}

// ── NTP ──────────────────────────────────────────────────────
void syncNTP() {
  if (strlen(WIFI_SSID) == 0) return;
  Serial.print("[NTP] A ligar a ");
  Serial.println(WIFI_SSID);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  int tries = 0;
  while (WiFi.status() != WL_CONNECTED && tries < 20) {
    delay(500); tries++;
  }
  if (WiFi.status() == WL_CONNECTED) {
    configTime(TZ_OFFSET, TZ_DST, NTP_SERVER);
    delay(1500);
    struct tm timeinfo;
    if (getLocalTime(&timeinfo)) {
      M5Dial.Rtc.setDateTime({
        { (uint16_t)(timeinfo.tm_year + 1900),
          (uint8_t)(timeinfo.tm_mon + 1),
          (uint8_t)timeinfo.tm_mday },
        { (uint8_t)timeinfo.tm_hour,
          (uint8_t)timeinfo.tm_min,
          (uint8_t)timeinfo.tm_sec }
      });
      ntpSynced = true;
      Serial.println("[NTP] RTC atualizado!");
    }
    WiFi.disconnect();
    WiFi.mode(WIFI_STA);
  } else {
    Serial.println("[NTP] Falha - a usar RTC interno");
  }
}

// ── LittleFS: apaga a primeira linha do ficheiro (registo mais antigo) ──────
static void dropOldestLine() {
  File src = LittleFS.open(LOG_FILE, "r");
  if (!src) return;

  // Salta a primeira linha (mais antiga)
  src.readStringUntil('\n');

  // Copia o resto para um ficheiro temporário
  File tmp = LittleFS.open("/datalog.tmp", "w");
  if (!tmp) { src.close(); return; }
  while (src.available()) {
    String ln = src.readStringUntil('\n');
    ln.trim();
    if (ln.length() > 0) { tmp.println(ln); }
  }
  src.close();
  tmp.close();

  // Substitui o ficheiro original pelo temporário
  LittleFS.remove(LOG_FILE);
  LittleFS.rename("/datalog.tmp", LOG_FILE);
  if (logEntryCount > 0) logEntryCount--;
  Serial.println("[LOG] Registo mais antigo removido (fila circular)");
}

// ── LittleFS: gravar entrada ─────────────────────────────────
void writeLogEntry() {
  if (!fsOk) return;

  // Verifica tamanho — remove só o registo mais antigo se ultrapassar limite
  {
    File f = LittleFS.open(LOG_FILE, "r");
    if (f) {
      size_t sz = f.size();
      f.close();
      if (sz > LOG_MAX_BYTES) {
        dropOldestLine();
        Serial.println("[LOG] Fila circular: removido registo mais antigo");
      }
    }
  }

  auto dt = M5Dial.Rtc.getDateTime();
  char line[80];
  snprintf(line, sizeof(line),
    "%04d-%02d-%02d %02d:%02d,%.1f,%.0f,%s,%.2f\n",
    dt.date.year, dt.date.month, dt.date.date,
    dt.time.hours, dt.time.minutes,
    sensorOk ? sensorTemp : -99.0f,
    sensorOk ? sensorHum  :   0.0f,
    (acStatusReceived ? (acRealOn ? "ON" : "OFF") : "?"),
    acCurrentRms
  );

  File f = LittleFS.open(LOG_FILE, "a");
  if (f) {
    f.print(line);
    f.close();
    logEntryCount++;
    Serial.printf("[LOG] #%lu: %s", logEntryCount, line);
  } else {
    Serial.println("[LOG] Erro ao abrir ficheiro!");
  }

  // Atualiza buffer de ecrã
  if (histBufCount < HIST_DISPLAY) {
    int idx = histBufCount++;
    snprintf(histBuf[idx].timeStr, 10, "%02d:%02d",
             dt.time.hours, dt.time.minutes);
    snprintf(histBuf[idx].dateStr, 7, "%02d/%02d",
             dt.date.date, dt.date.month);
    histBuf[idx].temp   = sensorOk ? sensorTemp : -99.0f;
    histBuf[idx].hum    = sensorOk ? sensorHum  :   0.0f;
    histBuf[idx].acOn   = acStatusReceived ? acRealOn : false;
    histBuf[idx].acAmps = acCurrentRms;
  } else {
    // Desloca buffer — o mais recente fica no índice 0
    for (int i = HIST_DISPLAY - 1; i > 0; i--)
      histBuf[i] = histBuf[i-1];
    snprintf(histBuf[0].timeStr, 10, "%02d:%02d",
             dt.time.hours, dt.time.minutes);
    snprintf(histBuf[0].dateStr, 7, "%02d/%02d",
             dt.date.date, dt.date.month);
    histBuf[0].temp   = sensorOk ? sensorTemp : -99.0f;
    histBuf[0].hum    = sensorOk ? sensorHum  :   0.0f;
    histBuf[0].acOn   = acStatusReceived ? acRealOn : false;
    histBuf[0].acAmps = acCurrentRms;
  }
}

// ── LittleFS: carregar últimas N entradas do ficheiro ─────────
void loadLastEntries() {
  if (!fsOk) return;
  File f = LittleFS.open(LOG_FILE, "r");
  if (!f) return;

  // Conta linhas e recolhe as últimas HIST_DISPLAY
  String lines[HIST_DISPLAY];
  int count = 0;
  while (f.available()) {
    String ln = f.readStringUntil('\n');
    ln.trim();
    if (ln.length() < 10) continue;
    lines[count % HIST_DISPLAY] = ln;
    count++;
  }
  f.close();

  int total = min(count, HIST_DISPLAY);
  histBufCount = 0;
  // Reconstrói buffer do mais recente para o mais antigo
  for (int i = 0; i < total; i++) {
    int lineIdx = ((count - 1 - i) % HIST_DISPLAY + HIST_DISPLAY) % HIST_DISPLAY;
    String& ln = lines[lineIdx];
    // Formato: YYYY-MM-DD HH:MM,temp,hum,acState,amps
    int c1 = ln.indexOf(',');
    int c2 = ln.indexOf(',', c1 + 1);
    int c3 = ln.indexOf(',', c2 + 1);
    int c4 = ln.indexOf(',', c3 + 1);
    if (c1 < 0 || c2 < 0 || c3 < 0 || c4 < 0) continue;
    String dt  = ln.substring(0, c1);  // "YYYY-MM-DD HH:MM"
    String tmp = ln.substring(c1 + 1, c2);
    String hm  = ln.substring(c2 + 1, c3);
    String ac  = ln.substring(c3 + 1, c4);
    String amp = ln.substring(c4 + 1);
    // Extrai hora e data
    int spIdx = dt.indexOf(' ');
    String datePart = dt.substring(0, spIdx);  // YYYY-MM-DD
    String timePart = dt.substring(spIdx + 1); // HH:MM
    snprintf(histBuf[i].timeStr, 10, "%s", timePart.c_str());
    // DD/MM
    snprintf(histBuf[i].dateStr, 7, "%s/%s",
      datePart.substring(8, 10).c_str(),
      datePart.substring(5, 7).c_str());
    histBuf[i].temp   = tmp.toFloat();
    histBuf[i].hum    = hm.toFloat();
    histBuf[i].acOn   = (ac == "ON");
    histBuf[i].acAmps = amp.toFloat();
    histBufCount++;
  }
  logEntryCount = (uint32_t)count;
  Serial.printf("[LOG] Carregadas %d entradas do ficheiro\n", histBufCount);
}

// ── Sensor local ─────────────────────────────────────────────
void readSensor() {
  float t = sht31.readTemperature();
  float h = sht31.readHumidity();
  if (!isnan(t) && !isnan(h)) {
    sensorTemp = t; sensorHum = h; sensorOk = true;
  } else {
    sensorOk = false;
  }
}

// ── Helper: alguém em casa? ───────────────────────────────────
bool anyoneHome() {
  for (int i = 0; i < MAX_ROOMS; i++)
    if (radarRooms[i].hasData && radarRooms[i].occupied) return true;
  return false;
}

// ── ESP-NOW callback ─────────────────────────────────────────
// ── Helper: actualiza registo de nós ───────────────────────────
void updateNodeRegistry(const uint8_t *mac, const HeartbeatPacket& hb) {
  // Procura nó existente pelo MAC
  int freeSlot = -1;
  for (int i = 0; i < MAX_NODES_REG; i++) {
    if (nodeRegistry[i].active && memcmp(nodeRegistry[i].mac, mac, 6) == 0) {
      // Atualiza entrada existente
      nodeRegistry[i].nodeType    = hb.nodeType;
      nodeRegistry[i].nodeId      = hb.nodeId;
      nodeRegistry[i].powerSource = hb.powerSource;
      nodeRegistry[i].batteryPct  = hb.batteryPct;
      nodeRegistry[i].rssi        = hb.rssi;
      nodeRegistry[i].lastSeen    = millis();
      strncpy(nodeRegistry[i].label, hb.label, 15);
      nodeRegistry[i].label[15]   = '\0';
      return;
    }
    if (!nodeRegistry[i].active && freeSlot < 0) freeSlot = i;
  }
  // Nó novo
  if (freeSlot >= 0) {
    nodeRegistry[freeSlot].active      = true;
    memcpy(nodeRegistry[freeSlot].mac, mac, 6);
    nodeRegistry[freeSlot].nodeType    = hb.nodeType;
    nodeRegistry[freeSlot].nodeId      = hb.nodeId;
    nodeRegistry[freeSlot].powerSource = hb.powerSource;
    nodeRegistry[freeSlot].batteryPct  = hb.batteryPct;
    nodeRegistry[freeSlot].rssi        = hb.rssi;
    nodeRegistry[freeSlot].lastSeen    = millis();
    strncpy(nodeRegistry[freeSlot].label, hb.label, 15);
    nodeRegistry[freeSlot].label[15]   = '\0';
    Serial.printf("[REG] Novo nó registado: %s (tipo %d, id %d)\n",
      hb.label, hb.nodeType, hb.nodeId);
    if (appState == STATE_SYSTEM) needRedraw = true;
  }
}

void onEspNowReceive(const uint8_t *mac, const uint8_t *data, int len) {
  espNowRxCount++;

  // Pacote heartbeat de nó remoto
  if (len == (int)sizeof(HeartbeatPacket)) {
    HeartbeatPacket hb;
    memcpy(&hb, data, sizeof(hb));
    if (hb.type == PACKET_TYPE_HEARTBEAT) {
      updateNodeRegistry(mac, hb);
      return;
    }
  }

  // Pacote estado AC (SCT-013)
  if (len == (int)sizeof(AcStatusPacket)) {
    AcStatusPacket asp;
    memcpy(&asp, data, sizeof(asp));
    if (asp.type == PACKET_TYPE_AC_STATUS) {
      acRealOn         = asp.acOn;
      acCurrentRms     = asp.currentRms;
      acStatusReceived = true;
      if (appState == STATE_AC || appState == STATE_MENU) needRedraw = true;
      return;
    }
  }

  if (len == (int)sizeof(RadarPacket)) {
    RadarPacket rp;
    memcpy(&rp, data, sizeof(rp));
    if (rp.type == PACKET_TYPE_RADAR && rp.roomId < MAX_ROOMS) {
      radarRooms[rp.roomId].occupied     = rp.occupied;
      radarRooms[rp.roomId].radarActive  = rp.radarActive;
      radarRooms[rp.roomId].hasData      = true;
      radarRooms[rp.roomId].lastReceived = millis();
      if (rp.label[0] != '\0') {
        strncpy(radarRooms[rp.roomId].label, rp.label, 15);
        radarRooms[rp.roomId].label[15] = '\0';
      }
      if (appState == STATE_PRESENCE || appState == STATE_SCREENSAVER ||
          appState == STATE_MENU) needRedraw = true;
      return;
    }
  }

  if (len == (int)sizeof(DoorPacket)) {
    DoorPacket dp;
    memcpy(&dp, data, sizeof(dp));
    if (dp.type == PACKET_TYPE_DOOR && dp.doorId < MAX_DOORS) {
      doors[dp.doorId].isOpen       = dp.isOpen;
      doors[dp.doorId].openCount    = dp.openCount;
      doors[dp.doorId].hasData      = true;
      doors[dp.doorId].lastReceived = millis();
      strncpy(doors[dp.doorId].label, dp.label, 15);
      doors[dp.doorId].label[15] = '\0';
      if (appState == STATE_PRESENCE || appState == STATE_SCREENSAVER ||
          appState == STATE_MENU) needRedraw = true;
      return;
    }
  }

  if (len != (int)sizeof(SensorPacket)) return;
  SensorPacket pkt;
  memcpy(&pkt, data, sizeof(pkt));
  if (pkt.roomId >= MAX_ROOMS) return;
  if (pkt.valid) {
    rooms[pkt.roomId].temperature = pkt.temperature;
    rooms[pkt.roomId].humidity    = pkt.humidity;
    rooms[pkt.roomId].hasData     = true;
  }
  rooms[pkt.roomId].lastReceived = millis();
  // Usa label do nodeRegistry se disponivel
  for (int i = 0; i < MAX_NODES_REG; i++) {
    if (nodeRegistry[i].active &&
        nodeRegistry[i].nodeType == NODE_TYPE_SENSOR &&
        nodeRegistry[i].nodeId == pkt.roomId) {
      strncpy(rooms[pkt.roomId].label, nodeRegistry[i].label, 15);
      rooms[pkt.roomId].label[15] = '\0';
      break;
    }
  }
  if (appState == STATE_ROOMS || appState == STATE_SCREENSAVER) needRedraw = true;
}

// ── Screensaver ──────────────────────────────────────────────
void drawScreensaver() {
  M5.Display.fillScreen(COL_BG);
  M5.Display.setTextDatum(MC_DATUM);

  auto dt = M5Dial.Rtc.getDateTime();
  char timeBuf[9];
  snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d",
           dt.time.hours, dt.time.minutes);
  M5.Display.setTextColor(COL_TEXT);
  M5.Display.setTextSize(3.2f);
  M5.Display.drawString(timeBuf, 120, 42);

  int y = dt.date.year, mo = dt.date.month, d = dt.date.date;
  int yy = y; int mm = mo;
  if (mm < 3) { mm += 12; yy--; }
  int wday = (d + (13*(mm+1)/5) + yy + yy/4 - yy/100 + yy/400) % 7;
  static const int wmap[] = {6,0,1,2,3,4,5};
  int wd = wmap[wday % 7];

  char dateBuf[24];
  snprintf(dateBuf, sizeof(dateBuf), "%s, %02d %s %04d",
           WEEK_DAYS[wd], dt.date.date,
           MONTHS[dt.date.month - 1], dt.date.year);
  M5.Display.setTextColor(COL_DIM);
  M5.Display.setTextSize(0.82f);
  M5.Display.drawString(dateBuf, 120, 74);

  M5.Display.drawFastHLine(40, 85, 160, COL_CARD);

  if (sensorOk) {
    char tBuf[14];
    snprintf(tBuf, sizeof(tBuf), "%.1f C", sensorTemp);
    M5.Display.setTextColor(COL_ACCENT);
    M5.Display.setTextSize(2.0f);
    M5.Display.drawString(tBuf, 120, 110);
    char hBuf[10];
    snprintf(hBuf, sizeof(hBuf), "%.0f%% HR", sensorHum);
    M5.Display.setTextColor(COL_LBLUE);
    M5.Display.setTextSize(1.2f);
    M5.Display.drawString(hBuf, 120, 134);
  } else {
    M5.Display.setTextColor(COL_DIM);
    M5.Display.setTextSize(1.0f);
    M5.Display.drawString("Sensor...", 120, 115);
  }

  M5.Display.drawFastHLine(40, 150, 160, COL_CARD);

  int shown = 0;
  for (int i = 0; i < MAX_ROOMS && shown < 2; i++) {
    if (!rooms[i].hasData) continue;
    bool fresh = (millis() - rooms[i].lastReceived) < 300000UL;
    char rBuf[28];
    snprintf(rBuf, sizeof(rBuf), "%s: %.1fC %.0f%%",
             rooms[i].label, rooms[i].temperature, rooms[i].humidity);
    M5.Display.setTextColor(fresh ? COL_DIM : 0x4208);
    M5.Display.setTextSize(0.78f);
    M5.Display.drawString(rBuf, 120, 160 + shown * 14);
    shown++;
  }

  bool hasPresData = false;
  for (int i = 0; i < MAX_ROOMS; i++) if (radarRooms[i].hasData) { hasPresData = true; break; }
  if (hasPresData) {
    bool home = anyoneHome();
    M5.Display.setTextColor(home ? COL_GREEN : COL_ORANGE);
    M5.Display.setTextSize(0.88f);
    M5.Display.drawString(home ? "Casa OCUPADA" : "Casa VAZIA", 120, 196);
  }

  M5.Display.setTextColor(ntpSynced ? COL_GREEN : COL_DIM);
  M5.Display.setTextSize(0.68f);
  M5.Display.drawString(ntpSynced ? "NTP" : "RTC", 207, 28);

  M5.Display.setTextColor(COL_DIM);
  M5.Display.setTextSize(0.75f);
  M5.Display.drawString("Toque ou gire para ativar", 120, 222);
}

void wakeFromScreensaver() {
  appState     = STATE_MENU;
  needRedraw   = true;
  lastActivity = millis();
}

// ── Draw: Histórico ──────────────────────────────────────────
void drawHistory() {
  M5.Display.fillScreen(COL_BG);
  M5.Display.setTextDatum(MC_DATUM);

  M5.Display.setTextColor(COL_ORANGE);
  M5.Display.setTextSize(1.3f);
  M5.Display.drawString("Historico", 120, 22);

  // Info de armazenamento
  if (fsOk) {
    size_t used  = LittleFS.usedBytes();
    size_t total = LittleFS.totalBytes();
    char fsBuf[28];
    snprintf(fsBuf, sizeof(fsBuf), "%lu reg  |  %u/%u KB",
             logEntryCount, (unsigned)(used/1024), (unsigned)(total/1024));
    M5.Display.setTextColor(COL_DIM);
    M5.Display.setTextSize(0.72f);
    M5.Display.drawString(fsBuf, 120, 40);
  } else {
    M5.Display.setTextColor(COL_RED);
    M5.Display.setTextSize(0.75f);
    M5.Display.drawString("LittleFS nao disponivel", 120, 40);
  }

  M5.Display.drawFastHLine(20, 52, 200, COL_CARD);

  if (histBufCount == 0) {
    M5.Display.setTextColor(COL_DIM);
    M5.Display.setTextSize(0.88f);
    M5.Display.drawString("Sem registos ainda", 120, 110);
    M5.Display.setTextSize(0.78f);
    M5.Display.drawString("Registo a cada 30 min", 120, 130);
  } else {
    // Cabeçalho
    M5.Display.setTextColor(COL_DIM);
    M5.Display.setTextSize(0.72f);
    M5.Display.setTextDatum(ML_DATUM);
    M5.Display.drawString("Data  Hora   Temp  Hum  AC", 14, 60);
    M5.Display.drawFastHLine(10, 68, 220, COL_CARD);

    int startY = 76;
    int visible = min(histBufCount, HIST_DISPLAY);
    for (int i = 0; i < visible; i++) {
      bool sel = (i == histViewSel);
      if (sel) M5.Display.fillRoundRect(8, startY + i*28 - 4, 224, 24, 6, COL_CARD);

      HistEntry& e = histBuf[i];
      M5.Display.setTextColor(sel ? COL_ORANGE : COL_DIM);
      M5.Display.setTextSize(0.70f);
      M5.Display.setTextDatum(ML_DATUM);
      char dateLine[8];
      snprintf(dateLine, sizeof(dateLine), "%s", e.dateStr);
      M5.Display.drawString(dateLine, 12, startY + i*28 + 4);

      M5.Display.setTextColor(sel ? COL_TEXT : COL_DIM);
      M5.Display.setTextSize(0.72f);
      M5.Display.drawString(e.timeStr, 52, startY + i*28 + 4);

      char tmpStr[8];
      snprintf(tmpStr, sizeof(tmpStr), "%.1fC", e.temp);
      M5.Display.setTextColor(sel ? COL_ACCENT : COL_TEXT);
      M5.Display.drawString(tmpStr, 96, startY + i*28 + 4);

      char humStr[7];
      snprintf(humStr, sizeof(humStr), "%.0f%%", e.hum);
      M5.Display.setTextColor(sel ? COL_LBLUE : COL_DIM);
      M5.Display.drawString(humStr, 144, startY + i*28 + 4);

      M5.Display.setTextColor(e.acOn ? COL_GREEN : COL_DIM);
      M5.Display.drawString(e.acOn ? "ON" : "OFF", 186, startY + i*28 + 4);

      if (e.acOn) {
        char ampStr[8];
        snprintf(ampStr, sizeof(ampStr), "%.1fA", e.acAmps);
        M5.Display.setTextColor(COL_DIM);
        M5.Display.setTextSize(0.65f);
        M5.Display.drawString(ampStr, 210, startY + i*28 + 4);
      }
    }
    M5.Display.setTextDatum(MC_DATUM);
  }

  M5.Display.setTextColor(COL_DIM);
  M5.Display.setTextSize(0.78f);
  M5.Display.setTextDatum(MC_DATUM);
  M5.Display.drawString("Toque=voltar", 120, 226);
}

// ── Draw: Menu ───────────────────────────────────────────────
void drawMenu() {
  M5.Display.fillScreen(COL_BG);
  M5.Display.setTextDatum(MC_DATUM);

  M5.Display.setTextColor(COL_ACCENT);
  M5.Display.setTextSize(1.4f);
  M5.Display.drawString("Casa Inteligente", 120, 22);

  auto dt = M5Dial.Rtc.getDateTime();
  char clockMini[9];
  snprintf(clockMini, sizeof(clockMini), "%02d:%02d",
           dt.time.hours, dt.time.minutes);
  M5.Display.setTextColor(COL_TEXT);
  M5.Display.setTextSize(1.0f);
  M5.Display.drawString(clockMini, 120, 40);

  if (sensorOk) {
    char mini[24];
    snprintf(mini, sizeof(mini), "%.1fC  %.0f%%", sensorTemp, sensorHum);
    M5.Display.setTextColor(COL_DIM);
    M5.Display.setTextSize(0.82f);
    M5.Display.drawString(mini, 120, 55);
  }

  M5.Display.setTextColor(irPeerAdded ? COL_GREEN : COL_ORANGE);
  M5.Display.setTextSize(0.72f);
  M5.Display.drawString(irPeerAdded ? "No IR: OK" : "No IR: config MAC!", 120, 67);

  if (acStatusReceived) {
    M5.Display.setTextColor(acRealOn ? COL_GREEN : COL_DIM);
    M5.Display.setTextSize(0.68f);
    char acBuf[20];
    snprintf(acBuf, sizeof(acBuf), "AC: %s %.1fA",
             acRealOn ? "ON" : "OFF", acCurrentRms);
    M5.Display.drawString(acBuf, 120, 78);
  }

  {
    bool hasPresData = false;
    for (int i = 0; i < MAX_ROOMS; i++) if (radarRooms[i].hasData) { hasPresData = true; break; }
    if (hasPresData) {
      bool home = anyoneHome();
      M5.Display.setTextColor(home ? COL_GREEN : COL_ORANGE);
      M5.Display.setTextSize(0.72f);
      M5.Display.drawString(home ? "Casa: OCUPADA" : "Casa: VAZIA", 120, 78);
    }
  }

  int cx = 120, cy = 148, r = 60;
  for (int i = 0; i < NUM_ITEMS && i < 6; i++) {
    float angle = (2.0f * PI * i / NUM_ITEMS) - PI/2.0f;
    int ix = cx + (int)(r * cosf(angle));
    int iy = cy + (int)(r * sinf(angle));
    bool sel = (i == menuSel);
    uint16_t cardCol = sel ? MENU_ITEMS[i].color : COL_CARD;
    M5.Display.fillRoundRect(ix-34, iy-26, 68, 52, 10, cardCol);
    M5.Display.setTextColor(COL_TEXT);
    M5.Display.setTextSize(sel ? 1.8f : 1.4f);
    M5.Display.drawString(MENU_ITEMS[i].icon, ix, iy-8);
    M5.Display.setTextSize(0.82f);
    M5.Display.drawString(MENU_ITEMS[i].label, ix, iy+18);
  }

  M5.Display.setTextColor(COL_DIM);
  M5.Display.setTextSize(0.80f);
  M5.Display.drawString("Girar=nav  Carregar=entrar", 120, 218);
}

// ── Draw: AC ─────────────────────────────────────────────────
void drawAc() {
  M5.Display.fillScreen(COL_BG);
  M5.Display.setTextDatum(MC_DATUM);

  M5.Display.setTextColor(COL_ACCENT);
  M5.Display.setTextSize(1.4f);
  M5.Display.drawString("Ar Condicionado", 120, 22);

  M5.Display.setTextColor(irPeerAdded ? COL_GREEN : COL_ORANGE);
  M5.Display.setTextSize(0.75f);
  M5.Display.drawString(irPeerAdded ? "No IR: OK" : "No IR: configura MAC", 120, 38);

  // Estado real do AC via SCT-013
  if (acStatusReceived) {
    uint16_t sctCol = acRealOn ? COL_GREEN : COL_DIM;
    M5.Display.setTextColor(sctCol);
    M5.Display.setTextSize(0.72f);
    char sctBuf[28];
    snprintf(sctBuf, sizeof(sctBuf), "AC real: %s  (%.2fA)",
             acRealOn ? "LIGADO" : "DESLIGADO", acCurrentRms);
    M5.Display.drawString(sctBuf, 120, 50);

    // Alerta de dessincronização
    if (acState.power != acRealOn) {
      M5.Display.setTextColor(COL_ORANGE);
      M5.Display.setTextSize(0.68f);
      M5.Display.drawString("! Estado dessincronizado !", 120, 62);
    }
  }

  uint16_t stateCol = acState.power ? COL_GREEN : COL_RED;
  // Se temos dado real, usa o estado real para a cor do círculo
  if (acStatusReceived) stateCol = acRealOn ? COL_GREEN : COL_RED;
  M5.Display.fillCircle(120, 80, 22, stateCol);
  M5.Display.setTextColor(COL_TEXT);
  M5.Display.setTextSize(1.3f);
  M5.Display.drawString(acRealOn && acStatusReceived ? "ON" :
                        (!acRealOn && acStatusReceived ? "OFF" :
                        (acState.power ? "ON" : "OFF")), 120, 80);

  char buf[16];
  snprintf(buf, sizeof(buf), "%d C", acState.temp);
  bool selTemp = (acState.submenu == 1);
  M5.Display.setTextColor(selTemp ? COL_ACCENT : COL_TEXT);
  M5.Display.setTextSize(selTemp ? 2.8f : 2.2f);
  M5.Display.drawString(buf, 120, 118);

  M5.Display.drawFastHLine(60, 143, 120, COL_CARD);

  bool selMode = (acState.submenu == 2);
  M5.Display.setTextColor(selMode ? COL_ACCENT : COL_DIM);
  M5.Display.setTextSize(1.1f);
  M5.Display.drawString("Modo:", 80, 158);
  M5.Display.setTextColor(selMode ? COL_ACCENT : COL_TEXT);
  M5.Display.drawString(acModeName(acState.mode), 165, 158);

  bool selFan = (acState.submenu == 3);
  M5.Display.setTextColor(selFan ? COL_ACCENT : COL_DIM);
  M5.Display.setTextSize(1.1f);
  M5.Display.drawString("Vent.:", 80, 178);
  M5.Display.setTextColor(selFan ? COL_ACCENT : COL_TEXT);
  M5.Display.drawString(acFanName(acState.fan), 165, 178);

  if (acState.submenu == 0) {
    M5.Display.setTextColor(COL_ACCENT);
    M5.Display.setTextSize(0.9f);
    M5.Display.drawString("Carregar=ON/OFF", 120, 200);
  } else if (acState.editing) {
    M5.Display.setTextColor(COL_ORANGE);
    M5.Display.setTextSize(0.9f);
    M5.Display.drawString("Girar=alterar  Carrag.=OK", 120, 200);
  } else {
    M5.Display.setTextColor(COL_DIM);
    M5.Display.setTextSize(0.9f);
    M5.Display.drawString("Girar=nav  Carregar=editar", 120, 200);
  }
  M5.Display.setTextColor(COL_DIM);
  M5.Display.setTextSize(0.82f);
  M5.Display.drawString("Toque=voltar", 120, 218);
}

// ── Draw: Temperatura / Humidade (todos os sensores por divisão) ──
void drawRooms() {
  M5.Display.fillScreen(COL_BG);
  M5.Display.setTextDatum(MC_DATUM);

  M5.Display.setTextColor(COL_ACCENT);
  M5.Display.setTextSize(1.3f);
  M5.Display.drawString("Temperatura / Hum.", 120, 18);
  M5.Display.drawFastHLine(10, 30, 220, COL_CARD);

  // Cabeçalho de colunas
  M5.Display.setTextColor(COL_DIM);
  M5.Display.setTextSize(0.68f);
  M5.Display.setTextDatum(ML_DATUM);
  M5.Display.drawString("Divisao", 14, 38);
  M5.Display.setTextDatum(MR_DATUM);
  M5.Display.drawString("Temp.    Hum.", 226, 38);
  M5.Display.drawFastHLine(10, 46, 220, COL_CARD);

  int y = 58;
  bool hasAny = false;

  // Sensor local do Dial
  if (sensorOk) {
    hasAny = true;
    // Fundo de destaque na linha selecionada
    M5.Display.setTextColor(COL_TEXT);
    M5.Display.setTextSize(0.85f);
    M5.Display.setTextDatum(ML_DATUM);
    M5.Display.drawString("Local (Dial)", 14, y);
    char tBuf[10], hBuf[8];
    snprintf(tBuf, sizeof(tBuf), "%.1f C", sensorTemp);
    snprintf(hBuf, sizeof(hBuf), "%.0f%%", sensorHum);
    M5.Display.setTextColor(COL_ACCENT);
    M5.Display.setTextDatum(MR_DATUM);
    // Temp à direita, hum antes
    M5.Display.drawString(hBuf, 175, y);
    M5.Display.setTextColor(COL_TEXT);
    M5.Display.drawString(tBuf, 226, y);
    y += 20;
    M5.Display.drawFastHLine(10, y - 4, 220, COL_CARD);
  }

  // Sensores remotos
  for (int i = 0; i < MAX_ROOMS && y < 210; i++) {
    if (!rooms[i].hasData) continue;
    hasAny = true;
    bool fresh = (millis() - rooms[i].lastReceived) < 300000UL; // 5 min
    bool veryOld = (millis() - rooms[i].lastReceived) > 600000UL; // 10 min

    uint16_t labelCol = veryOld ? 0x4208 : (fresh ? COL_TEXT : COL_DIM);
    uint16_t tempCol  = veryOld ? 0x4208 : COL_ACCENT;
    uint16_t humCol   = veryOld ? 0x4208 : COL_LBLUE;

    M5.Display.setTextColor(labelCol);
    M5.Display.setTextSize(0.85f);
    M5.Display.setTextDatum(ML_DATUM);
    M5.Display.drawString(rooms[i].label, 14, y);

    char tBuf[10], hBuf[8];
    snprintf(tBuf, sizeof(tBuf), "%.1f C", rooms[i].temperature);
    snprintf(hBuf, sizeof(hBuf), "%.0f%%", rooms[i].humidity);
    M5.Display.setTextColor(humCol);
    M5.Display.setTextDatum(MR_DATUM);
    M5.Display.drawString(hBuf, 175, y);
    M5.Display.setTextColor(tempCol);
    M5.Display.drawString(tBuf, 226, y);

    // Indicador de dados antigos
    if (!fresh) {
      M5.Display.setTextColor(COL_ORANGE);
      M5.Display.setTextSize(0.60f);
      M5.Display.setTextDatum(ML_DATUM);
      M5.Display.drawString("old", 14, y + 10);
    }

    y += 22;
    M5.Display.drawFastHLine(10, y - 4, 220, COL_CARD);
  }

  if (!hasAny) {
    M5.Display.setTextColor(COL_DIM);
    M5.Display.setTextSize(0.95f);
    M5.Display.setTextDatum(MC_DATUM);
    M5.Display.drawString("Sem sensores", 120, 110);
    M5.Display.setTextSize(0.80f);
    M5.Display.drawString("Liga os nos ESP32-C3", 120, 132);
  }

  M5.Display.setTextColor(COL_DIM);
  M5.Display.setTextSize(0.72f);
  M5.Display.setTextDatum(MC_DATUM);
  M5.Display.drawString("Toque=voltar", 120, 228);
}

// ── Draw: Luzes ──────────────────────────────────────────────
void drawLights() {
  M5.Display.fillScreen(COL_BG);
  M5.Display.setTextDatum(MC_DATUM);

  M5.Display.setTextColor(COL_YELLOW);
  M5.Display.setTextSize(1.3f);
  M5.Display.drawString("Luzes", 120, 22);

  int activeNodes = 0;
  int nodeIds[MAX_LED_NODES];
  for (int i = 0; i < MAX_LED_NODES; i++)
    if (ledNodes[i].macSet) nodeIds[activeNodes++] = i;

  if (activeNodes == 0) {
    M5.Display.setTextColor(COL_DIM);
    M5.Display.setTextSize(0.9f);
    M5.Display.drawString("Sem nos LED", 120, 90);
    M5.Display.setTextSize(0.78f);
    M5.Display.drawString("Configura LED_NODE_MAC", 120, 112);
    M5.Display.drawString("em led_node.cpp", 120, 128);
    M5.Display.setTextColor(COL_ORANGE);
    M5.Display.setTextSize(0.72f);
    M5.Display.drawString("Ver: src/led_node/led_node.cpp", 120, 154);
    M5.Display.setTextColor(COL_DIM);
    M5.Display.drawString("Toque=voltar", 120, 218);
    return;
  }

  LedNodeState& node = ledNodes[nodeIds[lightsSelNode % activeNodes]];
  int nIdx = nodeIds[lightsSelNode % activeNodes];

  if (activeNodes > 1) {
    char nodeLabel[24];
    snprintf(nodeLabel, sizeof(nodeLabel), "< %s >", node.label);
    M5.Display.setTextColor(COL_TEXT);
    M5.Display.setTextSize(0.9f);
    M5.Display.drawString(nodeLabel, 120, 40);
  } else {
    M5.Display.setTextColor(COL_TEXT);
    M5.Display.setTextSize(0.9f);
    M5.Display.drawString(node.label, 120, 40);
  }

  // Preview cor
  uint16_t previewCol = M5.Display.color888(node.r, node.g, node.b);
  if (node.effect == EFFECT_OFF) {
    M5.Display.fillCircle(120, 72, 16, COL_CARD);
    M5.Display.setTextColor(COL_DIM);
    M5.Display.setTextSize(0.8f);
    M5.Display.drawString("OFF", 120, 72);
  } else {
    M5.Display.fillCircle(120, 72, 16, previewCol);
  }

  M5.Display.drawFastHLine(40, 96, 160, COL_CARD);

  // Parâmetros
  const char* paramLabels[4] = { "Efeito:", "Brilho:", "Speed:", "Cor:" };
  char paramValues[4][20];
  snprintf(paramValues[0], 20, "%s", EFFECT_NAMES[node.effect]);
  snprintf(paramValues[1], 20, "%d%%", (int)map(node.brightness, 0, 255, 0, 100));
  snprintf(paramValues[2], 20, "%d", node.speed);
  snprintf(paramValues[3], 20, "R%d G%d B%d", node.r, node.g, node.b);

  for (int i = 0; i < 4; i++) {
    bool sel  = (lightsSubmenu == i) && !lightsEditing;
    bool edit = (lightsSubmenu == i) && lightsEditing;
    uint16_t lc = edit ? COL_ORANGE : (sel ? COL_YELLOW : COL_DIM);
    uint16_t vc = edit ? COL_ORANGE : (sel ? COL_YELLOW : COL_TEXT);
    M5.Display.setTextColor(lc);
    M5.Display.setTextSize(0.88f);
    M5.Display.setTextDatum(ML_DATUM);
    M5.Display.drawString(paramLabels[i], 32, 108 + i * 22);
    M5.Display.setTextColor(vc);
    M5.Display.setTextDatum(MR_DATUM);
    M5.Display.drawString(paramValues[i], 210, 108 + i * 22);
  }

  M5.Display.setTextDatum(MC_DATUM);
  if (lightsEditing) {
    M5.Display.setTextColor(COL_ORANGE);
    M5.Display.setTextSize(0.82f);
    M5.Display.drawString("Girar=alterar  Carregar=OK", 120, 202);
  } else {
    M5.Display.setTextColor(COL_DIM);
    M5.Display.setTextSize(0.82f);
    M5.Display.drawString("Girar=nav  Carregar=editar", 120, 202);
  }
  M5.Display.setTextColor(COL_DIM);
  M5.Display.setTextSize(0.78f);
  M5.Display.drawString("Toque=voltar", 120, 218);
}

// ── Draw: Presença ───────────────────────────────────────────
void drawPresence() {
  M5.Display.fillScreen(COL_BG);
  M5.Display.setTextDatum(MC_DATUM);

  M5.Display.setTextColor(COL_GREEN);
  M5.Display.setTextSize(1.3f);
  M5.Display.drawString("Presenca em Casa", 120, 22);

  bool home = anyoneHome();
  bool hasRadar = false;
  bool hasDoor  = false;
  for (int i = 0; i < MAX_ROOMS; i++) if (radarRooms[i].hasData) { hasRadar = true; break; }
  for (int i = 0; i < MAX_DOORS;  i++) if (doors[i].hasData)      { hasDoor  = true; break; }

  uint16_t stateCol = (hasRadar || hasDoor)
    ? (home ? COL_GREEN : COL_ORANGE) : COL_CARD;
  M5.Display.fillRoundRect(30, 36, 180, 38, 10, stateCol);
  M5.Display.setTextColor(COL_TEXT);
  M5.Display.setTextSize(1.6f);
  M5.Display.drawString(
    !hasRadar && !hasDoor ? "SEM NOS" :
    home ? "OCUPADA" : "VAZIA", 120, 55);

  M5.Display.drawFastHLine(20, 82, 200, COL_CARD);

  int yp = 92;
  M5.Display.setTextColor(COL_DIM);
  M5.Display.setTextSize(0.80f);
  M5.Display.setTextDatum(ML_DATUM);
  M5.Display.drawString("RADAR (divisoes):", 20, yp);
  yp += 14;
  bool anyRadar = false;
  for (int i = 0; i < MAX_ROOMS; i++) {
    if (!radarRooms[i].hasData) continue;
    anyRadar = true;
    bool fresh = (millis() - radarRooms[i].lastReceived) < 30000UL;
    M5.Display.setTextColor(fresh ? (radarRooms[i].occupied ? COL_GREEN : COL_DIM) : COL_ORANGE);
    M5.Display.setTextSize(0.82f);
    M5.Display.setTextDatum(ML_DATUM);
    M5.Display.drawString(radarRooms[i].label, 24, yp);
    M5.Display.setTextDatum(MR_DATUM);
    M5.Display.drawString(radarRooms[i].occupied ? "OCUP." : "VAZIA", 218, yp);
    yp += 14;
    if (yp > 148) break;
  }
  if (!anyRadar) {
    M5.Display.setTextColor(COL_DIM);
    M5.Display.setTextSize(0.78f);
    M5.Display.setTextDatum(MC_DATUM);
    M5.Display.drawString("Sem nos radar", 120, yp);
    yp += 14;
  }

  M5.Display.drawFastHLine(20, yp + 2, 200, COL_CARD);
  yp += 10;

  M5.Display.setTextColor(COL_DIM);
  M5.Display.setTextSize(0.80f);
  M5.Display.setTextDatum(ML_DATUM);
  M5.Display.drawString("PORTAS:", 20, yp);
  yp += 14;
  bool anyDoor = false;
  for (int i = 0; i < MAX_DOORS; i++) {
    if (!doors[i].hasData) continue;
    anyDoor = true;
    bool fresh = (millis() - doors[i].lastReceived) < 60000UL;
    M5.Display.setTextColor(fresh ? (doors[i].isOpen ? COL_ORANGE : COL_GREEN) : COL_DIM);
    M5.Display.setTextSize(0.82f);
    M5.Display.setTextDatum(ML_DATUM);
    M5.Display.drawString(doors[i].label, 24, yp);
    M5.Display.setTextDatum(MR_DATUM);
    M5.Display.drawString(doors[i].isOpen ? "ABERTA" : "FECHADA", 218, yp);
    yp += 14;
    if (yp > 200) break;
  }
  if (!anyDoor) {
    M5.Display.setTextColor(COL_DIM);
    M5.Display.setTextSize(0.78f);
    M5.Display.setTextDatum(MC_DATUM);
    M5.Display.drawString("Sem nos porta", 120, yp);
  }

  M5.Display.setTextColor(COL_DIM);
  M5.Display.setTextSize(0.80f);
  M5.Display.setTextDatum(MC_DATUM);
  M5.Display.drawString("Toque=voltar", 120, 226);
}

// ── Encoder handlers ─────────────────────────────────────────
void handleMenuEncoder(int delta, bool pressed) {
  if (delta != 0) {
    menuSel = (menuSel + delta + NUM_ITEMS) % NUM_ITEMS;
    needRedraw = true;
  }
  if (pressed) {
    if (menuSel == 0) { appState = STATE_AC; acState.submenu = 0; acState.editing = false; }
    else if (menuSel == 1) { appState = STATE_ROOMS; roomViewSel = 0; roomViewSel = 0; }
    else if (menuSel == 2) { appState = STATE_PRESENCE; }
    else if (menuSel == 3) { appState = STATE_LIGHTS; lightsSubmenu = 0; lightsEditing = false; }
    else if (menuSel == 4) { appState = STATE_HISTORY; histViewSel = 0; }
    else if (menuSel == 5) { appState = STATE_SYSTEM; }
    needRedraw = true;
  }
}

void handleAcEncoder(int delta, bool pressed) {
  if (!acState.editing) {
    if (delta != 0) { acState.submenu = (acState.submenu + delta + 4) % 4; needRedraw = true; }
    if (pressed) {
      if (acState.submenu == 0) { acState.power = !acState.power; sendAcCommand(); needRedraw = true; }
      else { acState.editing = true; needRedraw = true; }
    }
  } else {
    if (delta != 0) {
      if (acState.submenu == 1) {
        acState.temp = constrain(acState.temp + delta, 16, 30);
      } else if (acState.submenu == 2) {
        uint8_t modes[] = { kWhirlpoolAcAuto, kWhirlpoolAcCool,
                            kWhirlpoolAcHeat, kWhirlpoolAcFan, kWhirlpoolAcDry };
        int idx = 0;
        for (int i = 0; i < 5; i++) if (modes[i] == acState.mode) idx = i;
        idx = (idx + delta + 5) % 5;
        acState.mode = modes[idx];
      } else if (acState.submenu == 3) {
        uint8_t fans[] = { kWhirlpoolAcFanAuto, kWhirlpoolAcFanLow,
                           kWhirlpoolAcFanMedium, kWhirlpoolAcFanHigh };
        int idx = 0;
        for (int i = 0; i < 4; i++) if (fans[i] == acState.fan) idx = i;
        idx = (idx + delta + 4) % 4;
        acState.fan = fans[idx];
      }
      needRedraw = true;
    }
    if (pressed) { acState.editing = false; sendAcCommand(); needRedraw = true; }
  }
}

void handleRoomsEncoder(int delta, bool pressed) {
  if (delta != 0) {
    int activeRooms = 0;
    for (int i = 0; i < MAX_ROOMS; i++) if (rooms[i].hasData) activeRooms++;
    if (activeRooms > 0)
      roomViewSel = (roomViewSel + delta + activeRooms) % activeRooms;
    needRedraw = true;
  }
}

void handlePresenceEncoder(int delta, bool pressed) {
  (void)delta; (void)pressed;
}

void handleHistoryEncoder(int delta, bool pressed) {
  if (delta != 0) {
    histViewSel = constrain(histViewSel + delta, 0, max(0, histBufCount - 1));
    needRedraw = true;
  }
  (void)pressed;
}

static int sysPage = 0;  // 0=info geral, 1=nos registados

void handleSystemEncoder(int delta, bool pressed) {
  if (delta != 0) {
    sysPage = (sysPage + (delta > 0 ? 1 : -1) + 2) % 2;
    needRedraw = true;
  }
  (void)pressed;
}

// ── Draw: Sistema ────────────────────────────────────────────
void drawSystem() {
  M5.Display.fillScreen(COL_BG);
  M5.Display.setTextDatum(MC_DATUM);

  M5.Display.setTextColor(COL_DIM);
  M5.Display.setTextSize(1.3f);
  M5.Display.drawString("Sistema", 120, 22);

  // Indicador de pagina
  M5.Display.setTextColor(COL_DIM);
  M5.Display.setTextSize(0.72f);
  M5.Display.drawString(sysPage == 0 ? "[INFO]  nos" : "info  [NOS]", 120, 35);
  M5.Display.drawFastHLine(30, 42, 180, COL_CARD);

  if (sysPage == 0) {
    // ─────────────── PAGINA 0: INFORMACOES GERAIS ───────────────
    // MAC Address
    M5.Display.setTextColor(COL_DIM);
    M5.Display.setTextSize(0.78f);
    M5.Display.drawString("MAC Address (ESP-NOW)", 120, 54);
    String mac = WiFi.macAddress();
    M5.Display.setTextColor(COL_ACCENT);
    M5.Display.setTextSize(1.05f);
    M5.Display.drawString(mac, 120, 70);
    M5.Display.drawFastHLine(30, 83, 180, COL_CARD);

    // ESP-NOW
    M5.Display.setTextColor(COL_DIM);
    M5.Display.setTextSize(0.75f);
    M5.Display.drawString("ESP-NOW", 120, 93);
    char espBuf[28];
    snprintf(espBuf, sizeof(espBuf), "Pacotes recebidos: %d", espNowRxCount);
    M5.Display.setTextColor(COL_TEXT);
    M5.Display.setTextSize(0.80f);
    M5.Display.drawString(espBuf, 120, 105);
    M5.Display.setTextColor(irPeerAdded ? COL_GREEN : COL_ORANGE);
    M5.Display.setTextSize(0.78f);
    M5.Display.drawString(irPeerAdded ? "No IR: configurado" : "No IR: sem MAC", 120, 118);
    M5.Display.drawFastHLine(30, 128, 180, COL_CARD);

    // LittleFS
    M5.Display.setTextColor(COL_DIM);
    M5.Display.setTextSize(0.75f);
    M5.Display.drawString("Armazenamento", 120, 138);
    if (fsOk) {
      size_t used  = LittleFS.usedBytes();
      size_t total = LittleFS.totalBytes();
      char fsBuf[32];
      snprintf(fsBuf, sizeof(fsBuf), "%u KB / %u KB",
               (unsigned)(used/1024), (unsigned)(total/1024));
      M5.Display.setTextColor(COL_GREEN);
      M5.Display.setTextSize(0.80f);
      M5.Display.drawString(fsBuf, 120, 150);
      char regBuf[24];
      snprintf(regBuf, sizeof(regBuf), "%lu registos gravados", logEntryCount);
      M5.Display.setTextColor(COL_TEXT);
      M5.Display.setTextSize(0.75f);
      M5.Display.drawString(regBuf, 120, 163);
    } else {
      M5.Display.setTextColor(COL_RED);
      M5.Display.setTextSize(0.80f);
      M5.Display.drawString("LittleFS: ERRO", 120, 150);
    }
    M5.Display.drawFastHLine(30, 175, 180, COL_CARD);

    // Versao + hora
    M5.Display.setTextColor(COL_DIM);
    M5.Display.setTextSize(0.72f);
    M5.Display.drawString("Casa Inteligente v1.12  M5Dial", 120, 185);
    M5.Display.setTextColor(ntpSynced ? COL_GREEN : COL_DIM);
    M5.Display.drawString(ntpSynced ? "Hora: NTP sincronizada" : "Hora: RTC interno", 120, 197);

  } else {
    // ─────────────── PAGINA 1: NOS REGISTADOS ───────────────────
    static const char* NODE_TYPE_ICONS[] = { "IR+T", "RDR", "PRT", "LED" };
    static const uint16_t NODE_TYPE_COLS[] = {
      COL_ACCENT, COL_GREEN, COL_ORANGE, COL_YELLOW
    };

    int total = 0;
    for (int i = 0; i < MAX_NODES_REG; i++) if (nodeRegistry[i].active) total++;

    if (total == 0) {
      M5.Display.setTextColor(COL_DIM);
      M5.Display.setTextSize(0.88f);
      M5.Display.drawString("Nenhum no registado", 120, 110);
      M5.Display.setTextSize(0.78f);
      M5.Display.drawString("Os nos registam-se", 120, 132);
      M5.Display.drawString("automaticamente ao ligar.", 120, 146);
    } else {
      char totalBuf[24];
      snprintf(totalBuf, sizeof(totalBuf), "%d nos ativos", total);
      M5.Display.setTextColor(COL_DIM);
      M5.Display.setTextSize(0.72f);
      M5.Display.drawString(totalBuf, 120, 50);

      // Cabecalho
      M5.Display.setTextColor(COL_DIM);
      M5.Display.setTextSize(0.68f);
      M5.Display.setTextDatum(ML_DATUM);
      M5.Display.drawString("Tipo  Local          Alim.", 10, 60);
      M5.Display.drawFastHLine(8, 68, 224, COL_CARD);

      int row = 0;
      int startY = 76;
      for (int i = 0; i < MAX_NODES_REG && row < 6; i++) {
        if (!nodeRegistry[i].active) continue;
        NodeRegistryEntry& n = nodeRegistry[i];
        bool fresh = (millis() - n.lastSeen) < 120000UL;
        int y = startY + row * 24;
        uint8_t nt = min((uint8_t)3, n.nodeType);

        // Tipo (icone colorido)
        M5.Display.setTextColor(fresh ? NODE_TYPE_COLS[nt] : COL_DIM);
        M5.Display.setTextSize(0.72f);
        M5.Display.setTextDatum(ML_DATUM);
        M5.Display.drawString(NODE_TYPE_ICONS[nt], 10, y);

        // Label (localizacao)
        M5.Display.setTextColor(fresh ? COL_TEXT : COL_DIM);
        M5.Display.setTextSize(0.72f);
        M5.Display.drawString(n.label, 48, y);

        // Alimentacao
        if (n.powerSource == POWER_BATTERY) {
          uint16_t batCol = (n.batteryPct >= 60) ? COL_GREEN
                          : (n.batteryPct >= 30) ? COL_ORANGE : COL_RED;
          char batBuf[10];
          snprintf(batBuf, sizeof(batBuf), "BAT %d%%", n.batteryPct);
          M5.Display.setTextColor(batCol);
          M5.Display.setTextDatum(MR_DATUM);
          M5.Display.setTextSize(0.70f);
          M5.Display.drawString(batBuf, 230, y);
        } else {
          M5.Display.setTextColor(fresh ? COL_GREEN : COL_DIM);
          M5.Display.setTextDatum(MR_DATUM);
          M5.Display.setTextSize(0.70f);
          M5.Display.drawString("USB", 230, y);
        }

        // Indicador offline
        if (!fresh) {
          M5.Display.setTextColor(COL_RED);
          M5.Display.setTextSize(0.62f);
          M5.Display.setTextDatum(ML_DATUM);
          M5.Display.drawString("OFF", 10, y + 11);
        }
        row++;
      }
      M5.Display.setTextDatum(MC_DATUM);
    }
  }

  M5.Display.setTextColor(COL_DIM);
  M5.Display.setTextSize(0.75f);
  M5.Display.setTextDatum(MC_DATUM);
  M5.Display.drawString("Girar=pagina  Toque=voltar", 120, 226);
}

void handleLightsEncoder(int delta, bool pressed) {
  int activeNodes = 0;
  int nodeIds[MAX_LED_NODES];
  for (int i = 0; i < MAX_LED_NODES; i++)
    if (ledNodes[i].macSet) nodeIds[activeNodes++] = i;
  if (activeNodes == 0) return;

  int nIdx = nodeIds[lightsSelNode % activeNodes];
  LedNodeState& node = ledNodes[nIdx];

  if (!lightsEditing) {
    if (delta != 0) {
      lightsSubmenu = (lightsSubmenu + delta + 4) % 4;
      needRedraw = true;
    }
    if (pressed) {
      lightsEditing = true;
      needRedraw = true;
    }
  } else {
    if (delta != 0) {
      if (lightsSubmenu == 0) {
        int e = (int)node.effect + delta;
        node.effect = (uint8_t)((e + 6) % 6);
      } else if (lightsSubmenu == 1) {
        int b = (int)node.brightness + delta * 13;
        node.brightness = (uint8_t)constrain(b, 0, 255);
      } else if (lightsSubmenu == 2) {
        int s = (int)node.speed + delta;
        node.speed = (uint8_t)constrain(s, 1, 10);
      } else if (lightsSubmenu == 3) {
        static const uint8_t COLOR_PRESETS[][3] = {
          {255,180,80},  // Branco quente
          {255,255,200}, // Branco frio
          {255,0,0},     // Vermelho
          {0,255,0},     // Verde
          {0,0,255},     // Azul
          {255,100,0},   // Laranja
          {128,0,255},   // Roxo
          {0,255,200},   // Ciano
        };
        static int colorIdx = 0;
        colorIdx = (colorIdx + delta + 8) % 8;
        node.r = COLOR_PRESETS[colorIdx][0];
        node.g = COLOR_PRESETS[colorIdx][1];
        node.b = COLOR_PRESETS[colorIdx][2];
      }
      needRedraw = true;
    }
    if (pressed) {
      lightsEditing = false;
      sendLedCommand(nIdx);
      needRedraw = true;
    }
  }
}

// ── Setup ────────────────────────────────────────────────────
void setup() {
  auto cfg = M5.config();
  M5Dial.begin(cfg, true, false);

  M5.Display.setBrightness(128);
  M5.Display.setTextDatum(MC_DATUM);
  M5.Display.setColorDepth(16);

  Wire.begin(SHT30_SDA, SHT30_SCL);
  if (sht31.begin(0x44, &Wire)) {
    sensorOk = true;
    readSensor();
  }

  Serial.begin(115200);
  syncNTP();

  WiFi.mode(WIFI_STA);
  WiFi.disconnect();
  Serial.print("[M5Dial] MAC para ESP-NOW: ");
  Serial.println(WiFi.macAddress());

  // ── LittleFS ───────────────────────────────────────────────
  if (LittleFS.begin(true)) {
    fsOk = true;
    Serial.printf("[LittleFS] OK — %u KB livres\n",
      (unsigned)((LittleFS.totalBytes() - LittleFS.usedBytes()) / 1024));
    // Cria cabeçalho se ficheiro novo
    if (!LittleFS.exists(LOG_FILE)) {
      File f = LittleFS.open(LOG_FILE, "w");
      if (f) { f.println("timestamp,temp_c,hum_pct,ac_state,ac_amps"); f.close(); }
    }
    loadLastEntries();
  } else {
    Serial.println("[LittleFS] Falha na montagem!");
  }

  if (esp_now_init() == ESP_OK) {
    esp_now_register_recv_cb(onEspNowReceive);
    Serial.println("[ESP-NOW] Pronto");

    bool macIsSet = false;
    for (int i = 0; i < 6; i++) if (IR_NODE_MAC[i] != 0xFF) { macIsSet = true; break; }
    if (macIsSet) {
      esp_now_peer_info_t peer = {};
      memcpy(peer.peer_addr, IR_NODE_MAC, 6);
      peer.channel = 0;
      peer.encrypt = false;
      if (esp_now_add_peer(&peer) == ESP_OK) {
        irPeerAdded = true;
        Serial.println("[ESP-NOW] Nó IR adicionado");
      }
    } else {
      Serial.println("[ESP-NOW] MAC do nó IR nao configurado!");
    }
  } else {
    Serial.println("[ESP-NOW] Falha na inicializacao");
  }

  lastEnc         = M5Dial.Encoder.read();
  lastActivity    = millis();
  lastClockUpdate = millis();
  lastLogWrite    = millis();

  // Splash
  M5.Display.fillScreen(COL_BG);
  M5.Display.setTextColor(COL_ACCENT);
  M5.Display.setTextSize(1.5f);
  M5.Display.drawString("Casa Inteligente", 120, 60);
  M5.Display.setTextColor(COL_DIM);
  M5.Display.setTextSize(0.85f);
  M5.Display.drawString("M5Dial v1.10", 120, 90);
  M5.Display.setTextColor(fsOk ? COL_GREEN : COL_RED);
  M5.Display.setTextSize(0.78f);
  M5.Display.drawString(fsOk ? "LittleFS: OK" : "LittleFS: ERRO", 120, 200);
  auto splashDt = M5Dial.Rtc.getDateTime();
  char splashTime[9];
  snprintf(splashTime, sizeof(splashTime), "%02d:%02d",
           splashDt.time.hours, splashDt.time.minutes);
  M5.Display.setTextColor(COL_TEXT);
  M5.Display.setTextSize(1.2f);
  M5.Display.drawString(splashTime, 120, 110);
  M5.Display.setTextColor(irPeerAdded ? COL_GREEN : COL_ORANGE);
  M5.Display.setTextSize(0.82f);
  M5.Display.drawString(irPeerAdded ? "Nó IR: OK" : "Nó IR: define MAC", 120, 128);
  M5.Display.setTextColor(COL_GREEN);
  M5.Display.drawString("ESP-NOW ativo", 120, 144);
  M5.Display.setTextColor(COL_PURPLE);
  M5.Display.drawString("Radar+Porta: nos separados", 120, 158);
  M5.Display.setTextColor(COL_YELLOW);
  M5.Display.drawString("Luzes WS2812B: no LED", 120, 172);
  if (sensorOk) {
    char sBuf[24];
    snprintf(sBuf, sizeof(sBuf), "%.1fC  %.0f%% HR", sensorTemp, sensorHum);
    M5.Display.setTextColor(COL_LBLUE);
    M5.Display.setTextSize(1.0f);
    M5.Display.drawString(sBuf, 120, 188);
  }
  delay(1800);
  needRedraw = true;
}

// ── Loop ─────────────────────────────────────────────────────
void loop() {
  M5Dial.update();
  unsigned long now = millis();

  if (now - lastClockUpdate >= CLOCK_UPDATE_MS) {
    lastClockUpdate = now;
    if (appState == STATE_SCREENSAVER) needRedraw = true;
    if (appState == STATE_MENU) {
      auto rtcNow = M5Dial.Rtc.getDateTime();
      if (rtcNow.time.seconds == 0) needRedraw = true;
    }
  }

  if (now - lastSensorRead >= SENSOR_READ_MS) {
    lastSensorRead = now;
    readSensor();
    if (appState == STATE_SCREENSAVER || appState == STATE_MENU ||
        appState == STATE_ROOMS) needRedraw = true;
  }

  // Registo LittleFS a cada 30 minutos
  if (fsOk && (now - lastLogWrite >= LOG_INTERVAL_MS)) {
    lastLogWrite = now;
    writeLogEntry();
    if (appState == STATE_HISTORY) needRedraw = true;
  }

  long enc = M5Dial.Encoder.read();
  int delta = 0;
  if (enc != lastEnc) {
    delta = (enc - lastEnc) > 0 ? 1 : -1;
    lastEnc = enc;
    lastActivity = now;
  }
  bool pressed = M5Dial.BtnA.wasPressed();
  if (pressed) lastActivity = now;

  auto touch = M5Dial.Touch.getDetail();
  bool touched = touch.wasPressed();
  if (touched) lastActivity = now;

  // Screensaver
  if (appState != STATE_SCREENSAVER && (now - lastActivity) >= SCREENSAVER_MS) {
    appState = STATE_SCREENSAVER;
    needRedraw = true;
  }
  if (appState == STATE_SCREENSAVER) {
    if (touched || delta != 0 || pressed) wakeFromScreensaver();
  }

  // Lógica por estado
  if (appState == STATE_MENU) {
    handleMenuEncoder(delta, pressed);
  } else if (appState == STATE_AC) {
    handleAcEncoder(delta, pressed);
    if (touched) { appState = STATE_MENU; acState.editing = false; needRedraw = true; }
  } else if (appState == STATE_ROOMS) {
    handleRoomsEncoder(delta, pressed);
    if (touched || pressed) { appState = STATE_MENU; needRedraw = true; }
  } else if (appState == STATE_PRESENCE) {
    handlePresenceEncoder(delta, pressed);
    if (touched || pressed) { appState = STATE_MENU; needRedraw = true; }
  } else if (appState == STATE_LIGHTS) {
    handleLightsEncoder(delta, pressed);
    if (touched) { appState = STATE_MENU; lightsEditing = false; needRedraw = true; }
  } else if (appState == STATE_HISTORY) {
    handleHistoryEncoder(delta, pressed);
    if (touched || pressed) { appState = STATE_MENU; needRedraw = true; }
  } else if (appState == STATE_SYSTEM) {
    handleSystemEncoder(delta, pressed);
    if (touched || pressed) { appState = STATE_MENU; needRedraw = true; }
  }

  if (needRedraw) {
    needRedraw = false;
    if      (appState == STATE_MENU)        drawMenu();
    else if (appState == STATE_AC)          drawAc();
    else if (appState == STATE_ROOMS)       drawRooms();
    else if (appState == STATE_PRESENCE)    drawPresence();
    else if (appState == STATE_LIGHTS)      drawLights();
    else if (appState == STATE_HISTORY)     drawHistory();
    else if (appState == STATE_SYSTEM)      drawSystem();
    else if (appState == STATE_SCREENSAVER) drawScreensaver();
  }

  delay(10);
}

“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik