Community project
Smart Home IR Controller
Hugo Oliveira
Published July 16, 2026 · Updated July 16, 2026

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
Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.
Parts list
Bill of materials| Component | Qty | Notes |
|---|---|---|
| SHT30 | 1 | Sensirion 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) | 1 | Nó 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. |
| SHT30 | 1 | Sensirion 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) | 1 | LED 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) | 1 | Nó ESP-NOW com LD2410 radar de presença mmWave — fica no teto de cada divisão |
| LD2410 24GHz mmWave Radar Presence Sensor | 1 | Hi-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) | 1 | Nó ESP-NOW com Reed Switch — fica em cada porta/entrada |
| Reed Switch | 1 | Glass-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) | 1 | Nó remoto ESP-NOW para controlo de fita LED WS2812B. Recebe comandos do M5Dial via ESP-NOW e controla a fita com FastLED. |
| WS2812B | 1 | Addressable RGB LED strip |
Assembly
8 stepsM5Dial — 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.
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.
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.
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.
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!
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.
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.
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| Pin | Connection | Type |
|---|---|---|
| 3V3 | sht30 VCC | power |
| GND | sht30 GND | ground |
| GPIO 13 | sht30 SDA | i2c |
| GPIO 15 | sht30 SCL | i2c |
| EXT | esp32c3_node USB-C → Carregador USB-C de parede | power |
| EXT | esp32c3_node GPIO3 → LED IR 940nm + R 33Ω (Nó Remoto) ANODE | digital |
| EXT | esp32c3_node GPIO4 → SHT30 SDA | i2c |
| EXT | esp32c3_node GPIO5 → SHT30 SCL | i2c |
| GND | ir_led_remote CATHODE | ground |
| 3V3 | sht30_remote VCC | power |
| GND | sht30_remote GND | ground |
| EXT | radar_node USB-C → Carregador USB-C de parede | power |
| EXT | radar_node GPIO4 → LD2410 24GHz mmWave Radar Presence Sensor OUT | digital |
| EXT | radar_node 5V_OUT → LD2410 24GHz mmWave Radar Presence Sensor VCC | power |
| GND | ld2410_radar GND | ground |
| EXT | ld2410_radar TX → Não usado | uart |
| EXT | ld2410_radar RX → Não usado | uart |
| EXT | door_node USB-C → Carregador USB-C de parede | power |
| EXT | door_node GPIO5 → Reed Switch PIN1 | digital |
| GND | reed_switch PIN2 | ground |
| EXT | led_node VCC → Carregador USB-C de parede 5V | power |
| GND | led_node GND | ground |
| EXT | led_node GPIO2 → WS2812B DATA | digital |
| 5V | ws2812b_strip VCC | power |
| GND | ws2812b_strip GND | ground |
Firmware
ESP32// =============================================================
// 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.