TKK_E32231388/biuh.ino

856 lines
22 KiB
C++

#include <Arduino.h>
#include <WiFi.h>
// Enable verbose debug from Firebase_ESP_Client (helps reveal which host/step fails)
#define FIREBASE_ESP_CLIENT_DEBUG_PORT Serial
#define FIREBASE_ESP_CLIENT_DEBUG_LEVEL 2
#include <Firebase_ESP_Client.h>
#include <WiFiClientSecure.h>
#include "DHT.h"
#include <Wire.h>
#include "RTClib.h"
#include "time.h"
#include <esp_heap_caps.h>
static const char* WIFI_SSID = "oven";
static const char* WIFI_PASSWORD = "1sampai8";
static const char* FIREBASE_API_KEY = "AIzaSyC-Wdu6vNC0dRyF33S3_C2wpVt9I6wnp0w";
static const char* FIREBASE_DATABASE_URL = "https://smartoven-f9fdd-default-rtdb.firebaseio.com/";
char apiKey[100] = "";
char databaseUrl[200] = "";
FirebaseData fbdo;
FirebaseAuth auth;
FirebaseConfig config;
static bool signupOK = false;
static bool signUpAnonymousWithRetry(uint32_t retryDelayMs = 2000) {
// Anonymous sign-in: email/password kosong
for (;;) {
signupOK = Firebase.signUp(&config, &auth, "", "");
if (signupOK) {
Serial.println("[FB] signUp(anonymous): OK");
return true;
}
Serial.print("[FB] signUp(anonymous): FAIL ");
Serial.println(config.signer.signupError.message.c_str());
delay(retryDelayMs);
}
}
static bool waitFirebaseReady(uint32_t timeoutMs = 15000) {
Serial.print("[FB] Waiting ready");
uint32_t start = millis();
while (!Firebase.ready() && (millis() - start) < timeoutMs) {
delay(250);
Serial.print('.');
}
Serial.println();
Serial.println(Firebase.ready() ? "[FB] Ready" : "[FB] Not ready (timeout)");
return Firebase.ready();
}
static bool fbIsPathNotExist() {
// Firebase_ESP_Client uses negative codes for internal errors.
// -103 is commonly used for "path not exist" in RTDB get.
if (fbdo.httpCode() == -103) return true;
String r = fbdo.errorReason();
r.toLowerCase();
return r.indexOf("path not exist") >= 0;
}
// ===== HISTORY (RTDB) =====
static const bool ENABLE_HISTORY = true;
static const uint32_t HISTORY_INTERVAL_MS = 1800000; // 30 menit
static uint32_t lastHistoryPushMs = 0;
static bool fbPushHistory(float temp, float hum, int heaterDb, int fanDb, int mode, bool active) {
if (WiFi.status() != WL_CONNECTED) return false;
if (!Firebase.ready()) return false;
time_t epoch = time(nullptr);
if (epoch <= 0) {
Serial.println("[FB] WARN pushHistory skip (time not set)");
return false;
}
FirebaseJson json;
json.set("ts", (int)epoch);
json.set("temp", temp);
json.set("hum", hum);
json.set("heater", heaterDb);
json.set("fan", fanDb);
json.set("mode", mode);
json.set("active", active ? 1 : 0);
bool ok = Firebase.RTDB.pushJSON(&fbdo, "/history", &json);
if (ok) {
Serial.print("[FB] OK pushHistory key=");
Serial.println(fbdo.pushName());
} else {
Serial.print("[FB] FAIL pushHistory code=");
Serial.print(fbdo.httpCode());
Serial.print(" reason=");
Serial.println(fbdo.errorReason());
}
return ok;
}
// ===== FIREBASE HELPERS (STATUS LOG) =====
static bool fbSetFloat(const char* path, float value) {
if (WiFi.status() != WL_CONNECTED) {
Serial.print("[FB] FAIL setFloat ");
Serial.print(path);
Serial.println(" (WiFi not connected)");
return false;
}
if (!Firebase.ready()) {
Serial.print("[FB] FAIL setFloat ");
Serial.print(path);
Serial.println(" (Firebase not ready)");
return false;
}
bool ok = Firebase.RTDB.setFloat(&fbdo, path, value);
if (ok) {
Serial.print("[FB] OK setFloat ");
Serial.print(path);
Serial.print(" = ");
Serial.println(value);
} else {
Serial.print("[FB] FAIL setFloat ");
Serial.print(path);
Serial.print(" code=");
Serial.print(fbdo.httpCode());
Serial.print(" reason=");
Serial.println(fbdo.errorReason());
}
return ok;
}
static bool fbSetInt(const char* path, int value) {
if (WiFi.status() != WL_CONNECTED) {
Serial.print("[FB] FAIL setInt ");
Serial.print(path);
Serial.println(" (WiFi not connected)");
return false;
}
if (!Firebase.ready()) {
Serial.print("[FB] FAIL setInt ");
Serial.print(path);
Serial.println(" (Firebase not ready)");
return false;
}
bool ok = Firebase.RTDB.setInt(&fbdo, path, value);
if (ok) {
Serial.print("[FB] OK setInt ");
Serial.print(path);
Serial.print(" = ");
Serial.println(value);
} else {
Serial.print("[FB] FAIL setInt ");
Serial.print(path);
Serial.print(" code=");
Serial.print(fbdo.httpCode());
Serial.print(" reason=");
Serial.println(fbdo.errorReason());
}
return ok;
}
static bool fbGetInt(const char* path, int& outValue) {
if (WiFi.status() != WL_CONNECTED) {
Serial.print("[FB] FAIL getInt ");
Serial.print(path);
Serial.println(" (WiFi not connected)");
return false;
}
if (!Firebase.ready()) {
Serial.print("[FB] FAIL getInt ");
Serial.print(path);
Serial.println(" (Firebase not ready)");
return false;
}
bool ok = Firebase.RTDB.getInt(&fbdo, path);
if (ok) {
outValue = fbdo.intData();
Serial.print("[FB] OK getInt ");
Serial.print(path);
Serial.print(" = ");
Serial.println(outValue);
} else {
if (fbIsPathNotExist()) {
outValue = 0;
Serial.print("[FB] WARN getInt ");
Serial.print(path);
Serial.println(" (path not exist -> default 0)");
return true;
}
Serial.print("[FB] FAIL getInt ");
Serial.print(path);
Serial.print(" code=");
Serial.print(fbdo.httpCode());
Serial.print(" reason=");
Serial.println(fbdo.errorReason());
}
return ok;
}
static bool fbGetFloat(const char* path, float& outValue) {
if (WiFi.status() != WL_CONNECTED) {
Serial.print("[FB] FAIL getFloat ");
Serial.print(path);
Serial.println(" (WiFi not connected)");
return false;
}
if (!Firebase.ready()) {
Serial.print("[FB] FAIL getFloat ");
Serial.print(path);
Serial.println(" (Firebase not ready)");
return false;
}
bool ok = Firebase.RTDB.getFloat(&fbdo, path);
if (ok) {
outValue = fbdo.floatData();
Serial.print("[FB] OK getFloat ");
Serial.print(path);
Serial.print(" = ");
Serial.println(outValue);
} else {
if (fbIsPathNotExist()) {
outValue = 0.0f;
Serial.print("[FB] WARN getFloat ");
Serial.print(path);
Serial.println(" (path not exist -> default 0)");
return true;
}
Serial.print("[FB] FAIL getFloat ");
Serial.print(path);
Serial.print(" code=");
Serial.print(fbdo.httpCode());
Serial.print(" reason=");
Serial.println(fbdo.errorReason());
}
return ok;
}
// ===== RTC =====
RTC_DS3231 rtc;
static bool rtcAvailable = false;
static void scanI2C() {
Serial.println("I2C scan...");
uint8_t found = 0;
for (uint8_t addr = 1; addr < 127; addr++) {
Wire.beginTransmission(addr);
uint8_t err = Wire.endTransmission();
if (err == 0) {
Serial.print(" Found device at 0x");
if (addr < 16) Serial.print('0');
Serial.println(addr, HEX);
found++;
}
}
if (found == 0) {
Serial.println(" No I2C devices found (cek wiring SDA/SCL + pullup + pin Wire.begin)");
}
}
static void printHeap(const char* tag) {
Serial.print("[HEAP] ");
Serial.print(tag);
Serial.print(" free=");
Serial.print(ESP.getFreeHeap());
Serial.print(" min=");
Serial.print(ESP.getMinFreeHeap());
Serial.print(" 8bit=");
Serial.println((uint32_t)heap_caps_get_free_size(MALLOC_CAP_8BIT));
}
static bool extractHostFromUrl(const char* url, char* outHost, size_t outSize) {
if (!url || !outHost || outSize < 4) return false;
outHost[0] = '\0';
const char* p = url;
if (strncmp(p, "https://", 8) == 0) p += 8;
else if (strncmp(p, "http://", 7) == 0) p += 7;
const char* end = strchr(p, '/');
size_t len = end ? (size_t)(end - p) : strlen(p);
if (len == 0 || len >= outSize) return false;
memcpy(outHost, p, len);
outHost[len] = '\0';
return true;
}
static bool testTcpConnect(const char* host, uint16_t port) {
WiFiClient client;
client.setTimeout(5000);
bool ok = client.connect(host, port);
client.stop();
Serial.print("[NET] TCP ");
Serial.print(host);
Serial.print(":");
Serial.print(port);
Serial.print(" => ");
Serial.println(ok ? "OK" : "FAIL");
return ok;
}
static bool testTlsConnectInsecure(const char* host, uint16_t port) {
WiFiClientSecure client;
client.setInsecure();
client.setTimeout(7000);
printHeap("before TLS");
bool ok = client.connect(host, port);
Serial.print("[NET] TLS(insecure) ");
Serial.print(host);
Serial.print(":");
Serial.print(port);
Serial.print(" => ");
Serial.println(ok ? "OK" : "FAIL");
client.stop();
printHeap("after TLS");
return ok;
}
static void netDiagnostics() {
Serial.println("[NET] Diagnostics...");
printHeap("start");
// DNS test
IPAddress ip;
bool dnsOk = WiFi.hostByName("www.google.com", ip);
Serial.print("[NET] DNS www.google.com => ");
Serial.println(dnsOk ? ip.toString() : String("FAIL"));
// TCP/TLS to Google
testTcpConnect("www.google.com", 443);
testTlsConnectInsecure("www.google.com", 443);
// Firebase host test
char fbHost[128];
if (extractHostFromUrl(FIREBASE_DATABASE_URL, fbHost, sizeof(fbHost))) {
bool fbDnsOk = WiFi.hostByName(fbHost, ip);
Serial.print("[NET] DNS ");
Serial.print(fbHost);
Serial.print(" => ");
Serial.println(fbDnsOk ? ip.toString() : String("FAIL"));
testTcpConnect(fbHost, 443);
testTlsConnectInsecure(fbHost, 443);
} else {
Serial.println("[NET] Could not parse Firebase host from URL");
}
}
// ===== NTP =====
const char* ntpServer1 = "pool.ntp.org";
const char* ntpServer2 = "time.google.com";
const char* ntpServer3 = "time.nist.gov";
const long gmtOffset_sec = 7 * 3600;
const int daylightOffset_sec = 0;
unsigned long lastSync = 0;
const unsigned long syncInterval = 3600000; // 1 jam
// ===== DHT =====
#define DHTPIN 23
#define DHTTYPE DHT22
DHT dht(DHTPIN, DHTTYPE);
// ===== RELAY =====
const int relayHeater = 12;
const int relayFan = 13;
// ===== LED =====
const int ledHeater = 12;
// ===== RELAY LOGIC =====
#define RELAY_ON LOW
#define RELAY_OFF HIGH
// ===== VARIABLE =====
int startHour, startMinute;
int endHour, endMinute;
float targetTemp;
float targetTempFan;
// ===== KALIBRASI =====
float tempOffset = 0;
float humOffset = 0;
int mode = 0;
int manualHeater = 0;
int manualFan = 0;
// ===== WIFI CONNECT =====
static bool connectWiFi(uint32_t timeoutMs = 20000) {
WiFi.mode(WIFI_STA);
WiFi.setSleep(false);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
Serial.print("WiFi connecting to: ");
Serial.println(WIFI_SSID);
uint32_t start = millis();
while (WiFi.status() != WL_CONNECTED && (millis() - start) < timeoutMs) {
delay(300);
Serial.print('.');
}
Serial.println();
if (WiFi.status() == WL_CONNECTED) {
Serial.print("WiFi connected, IP: ");
Serial.println(WiFi.localIP());
return true;
}
Serial.println("WiFi gagal connect");
return false;
}
static int monthStrToIndex(const char* mon) {
// __DATE__ format: "Mmm dd yyyy"
static const char* months[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"};
for (int i = 0; i < 12; i++) {
if (strncmp(mon, months[i], 3) == 0) return i;
}
return 0;
}
static bool setTimeFromCompileUtc() {
// fallback kalau NTP diblokir: set waktu dari waktu compile sketch
// Ini cukup buat TLS (sertifikat) asal firmware baru di-upload (waktunya mendekati sekarang).
const char* dateStr = __DATE__; // "May 20 2026"
const char* timeStr = __TIME__; // "HH:MM:SS"
char mon[4] = {0};
int day = 1;
int year = 2020;
int hour = 0;
int minute = 0;
int second = 0;
memcpy(mon, dateStr, 3);
mon[3] = '\0';
day = atoi(dateStr + 4);
year = atoi(dateStr + 7);
hour = atoi(timeStr);
minute = atoi(timeStr + 3);
second = atoi(timeStr + 6);
struct tm t;
memset(&t, 0, sizeof(t));
t.tm_year = year - 1900;
t.tm_mon = monthStrToIndex(mon);
t.tm_mday = day;
t.tm_hour = hour;
t.tm_min = minute;
t.tm_sec = second;
t.tm_isdst = 0;
setenv("TZ", "UTC0", 1);
tzset();
time_t epoch = mktime(&t);
if (epoch <= 0) return false;
struct timeval tv;
tv.tv_sec = epoch;
tv.tv_usec = 0;
settimeofday(&tv, nullptr);
Serial.print("[TIME] Fallback set from compile UTC: ");
Serial.println(ctime(&epoch));
return true;
}
// ===== SYNC RTC =====
static bool syncNtpTime(uint32_t timeoutMs = 20000) {
// set timezone for localtime
configTime(gmtOffset_sec, daylightOffset_sec, ntpServer1, ntpServer2, ntpServer3);
struct tm timeinfo;
uint32_t start = millis();
while (!getLocalTime(&timeinfo) && (millis() - start) < timeoutMs) {
delay(500);
Serial.print('#');
}
Serial.println();
if (!getLocalTime(&timeinfo)) {
Serial.println("NTP gagal");
return false;
}
// validasi kasar: tahun harus sudah masuk akal
if (timeinfo.tm_year + 1900 < 2020) {
Serial.println("NTP dapat tapi waktu belum valid");
return false;
}
Serial.print("NTP OK: ");
Serial.print(timeinfo.tm_year + 1900);
Serial.print('-');
Serial.print(timeinfo.tm_mon + 1);
Serial.print('-');
Serial.print(timeinfo.tm_mday);
Serial.print(' ');
Serial.print(timeinfo.tm_hour);
Serial.print(':');
Serial.print(timeinfo.tm_min);
Serial.print(':');
Serial.println(timeinfo.tm_sec);
if (rtcAvailable) {
rtc.adjust(DateTime(
timeinfo.tm_year + 1900,
timeinfo.tm_mon + 1,
timeinfo.tm_mday,
timeinfo.tm_hour,
timeinfo.tm_min,
timeinfo.tm_sec
));
Serial.println("RTC Sync NTP");
}
return true;
}
static void syncRTC() {
if (syncNtpTime()) {
Serial.println(rtcAvailable ? "RTC/NTP sync OK" : "NTP sync OK (RTC tidak ada)");
}
}
// ===== SETUP =====
void setup() {
Serial.begin(115200);
delay(2000);
Serial.println("BOOT...");
// ===== PIN MODE =====
pinMode(relayHeater, OUTPUT);
pinMode(relayFan, OUTPUT);
pinMode(ledHeater, OUTPUT);
digitalWrite(relayHeater, RELAY_OFF);
digitalWrite(relayFan, RELAY_OFF);
digitalWrite(ledHeater, LOW);
// ===== DHT =====
dht.begin();
// ===== I2C =====
Wire.begin(13, 14);
scanI2C();
// ===== RTC =====
rtcAvailable = rtc.begin();
if (!rtcAvailable) {
Serial.println("RTC error (DS3231 tidak terdeteksi). Lanjut tanpa RTC (pakai NTP). ");
}
// ===== FIREBASE CONFIG (HARDCODE) =====
strncpy(apiKey, FIREBASE_API_KEY, sizeof(apiKey) - 1);
apiKey[sizeof(apiKey) - 1] = '\0';
strncpy(databaseUrl, FIREBASE_DATABASE_URL, sizeof(databaseUrl) - 1);
databaseUrl[sizeof(databaseUrl) - 1] = '\0';
if (strlen(apiKey) == 0 || strlen(databaseUrl) == 0) {
Serial.println("[WARN] FIREBASE_API_KEY / FIREBASE_DATABASE_URL masih kosong. Isi dulu di bagian atas sketch.");
}
// ===== WIFI =====
if (!connectWiFi()) {
// retry terus sampai konek
while (!connectWiFi()) {
delay(2000);
}
}
// ===== NTP TIME (PENTING UNTUK SSL/TLS) =====
// Banyak error SSL Firebase terjadi kalau waktu belum terset saat TLS handshake.
Serial.println("Sync NTP...");
bool ntpOk = syncNtpTime(60000);
if (!ntpOk) {
Serial.println("[TIME] NTP gagal. Coba fallback waktu compile...");
setTimeFromCompileUtc();
}
time_t nowEpoch = time(nullptr);
Serial.print("[TIME] Now epoch: ");
Serial.println((long)nowEpoch);
if (nowEpoch > 0) {
Serial.print("[TIME] Now: ");
Serial.println(ctime(&nowEpoch));
}
Serial.print("Heap before Firebase: ");
Serial.println(ESP.getFreeHeap());
// ===== NETWORK DIAGNOSTICS =====
// Ini bantu bedain: DNS/port443 block vs TLS init/heap issue.
netDiagnostics();
// ===== FIREBASE =====
config.api_key = apiKey;
config.database_url = databaseUrl;
// Anonymous auth ONLY (tanpa test_mode).
// Pastikan di Firebase Console: Authentication -> Sign-in method -> Anonymous di-enable.
signUpAnonymousWithRetry();
// buffer & timeout tuning (membantu masalah SSL init / write error)
// coba lebih kecil dulu biar nggak kehabisan heap
fbdo.setBSSLBufferSize(2048, 512);
fbdo.setResponseSize(2048);
config.timeout.serverResponse = 10 * 1000;
Firebase.begin(&config, &auth);
Firebase.reconnectWiFi(true);
waitFirebaseReady();
// ===== RTC SYNC =====
if (rtcAvailable) {
// kalau RTC ada, pastikan sudah disesuaikan
syncRTC();
}
lastSync = millis();
Serial.println("🔥 SYSTEM READY");
}
// ===== LOOP =====
void loop() {
// ===== WIFI CHECK =====
if (WiFi.status() != WL_CONNECTED) {
WiFi.reconnect();
}
// ===== RTC PERIODIC SYNC =====
if (WiFi.status() == WL_CONNECTED &&
millis() - lastSync > syncInterval) {
syncRTC();
lastSync = millis();
}
// ===== FIREBASE READY CHECK =====
if (WiFi.status() != WL_CONNECTED) {
Serial.println("[FB] Skip: WiFi not connected");
delay(1000);
return;
}
if (!Firebase.ready()) {
static uint32_t lastNotReadyLog = 0;
if (millis() - lastNotReadyLog > 5000) {
Serial.println("[FB] Skip: Firebase not ready");
lastNotReadyLog = millis();
}
delay(200);
// tetap lanjut loop (biar logic lokal jalan), tapi operasi Firebase akan FAIL di wrapper
}
if (!signupOK) {
Serial.println("[FB] Skip: signUp not OK");
delay(2000);
return;
}
// ===== GET FIREBASE =====
fbGetInt("/control/start_hour", startHour);
fbGetInt("/control/start_minute", startMinute);
fbGetInt("/control/end_hour", endHour);
fbGetInt("/control/end_minute", endMinute);
fbGetFloat("/control/target_temp", targetTemp);
fbGetFloat("/control/target_temp_fan", targetTempFan);
fbGetInt("/control/mode", mode);
fbGetInt("/manual/heater", manualHeater);
fbGetInt("/manual/fan", manualFan);
// fallback kalau target fan belum diset
if (targetTempFan <= 0) targetTempFan = targetTemp;
// ===== GET KALIBRASI =====
fbGetFloat("/calibration/temp_offset", tempOffset);
fbGetFloat("/calibration/hum_offset", humOffset);
// ===== CURRENT TIME =====
int hourNow = 0;
int minuteNow = 0;
bool timeOk = false;
if (rtcAvailable) {
DateTime now = rtc.now();
hourNow = now.hour();
minuteNow = now.minute();
timeOk = true;
} else {
struct tm timeinfo;
if (getLocalTime(&timeinfo, 1000)) {
hourNow = timeinfo.tm_hour;
minuteNow = timeinfo.tm_min;
timeOk = true;
}
}
int currentTime = hourNow * 60 + minuteNow;
int startTime = startHour * 60 + startMinute;
int endTime = endHour * 60 + endMinute;
bool active = false;
if (timeOk) {
active = (startTime <= endTime)
? (currentTime >= startTime &&
currentTime <= endTime)
: (currentTime >= startTime ||
currentTime <= endTime);
} else {
// kalau waktu tidak valid, fail-safe: nonaktif
Serial.println("[TIME] Tidak dapat ambil waktu (RTC/NTP). Mode AUTO akan nonaktif.");
active = false;
}
// ===== SENSOR =====
float temp = dht.readTemperature();
float hum = dht.readHumidity();
// DHT read kadang gagal (NaN) karena timing/interrupt/WiFi atau wiring.
// Coba retry sekali agar lebih stabil.
if (isnan(temp) || isnan(hum)) {
Serial.println("[SENSOR] DHT read failed, retry...");
delay(250);
temp = dht.readTemperature();
hum = dht.readHumidity();
}
// ===== VALIDASI SENSOR =====
if (isnan(temp) || isnan(hum)) {
Serial.println("[SENSOR] DHT error (NaN). Cek wiring VCC/GND/DATA, pullup 4.7k-10k ke VCC, kabel jangan panjang, pastikan sensor DHT22 & pin GPIO benar.");
delay(2000);
return;
}
// ===== APPLY KALIBRASI =====
temp += tempOffset;
hum += humOffset;
// ===== CONTROL =====
bool heaterState = false;
bool fanState = false;
if (mode == 1) {
// ===== MANUAL MODE =====
heaterState = (manualHeater != 0);
fanState = (manualFan != 0);
digitalWrite(
relayFan,
fanState ? RELAY_ON : RELAY_OFF
);
} else {
// ===== AUTO MODE =====
if (active) {
heaterState = (temp < targetTemp);
fanState = (temp > targetTempFan);
digitalWrite(
relayFan,
fanState ? RELAY_ON : RELAY_OFF
);
} else {
heaterState = false;
fanState = false;
digitalWrite(relayFan, RELAY_OFF);
}
}
// ===== APPLY HEATER =====
digitalWrite(
relayHeater,
heaterState ? RELAY_ON : RELAY_OFF
);
digitalWrite(
ledHeater,
heaterState ? HIGH : LOW
);
// ===== SEND SENSOR =====
bool okTemp = fbSetFloat("/sensor/temperature", temp);
bool okHum = fbSetFloat("/sensor/humidity", hum);
// ===== SEND RELAY =====
int heaterDb = heaterState ? 1 : 0;
int fanDb = fanState ? 1 : 0;
bool okHeater = fbSetInt("/relay/heater", heaterDb);
bool okFan = fbSetInt("/relay/fan", fanDb);
// Mirror manual state to match relay state (biar UI manual & relay selalu sama)
bool okManualHeater = fbSetInt("/manual/heater", heaterDb);
bool okManualFan = fbSetInt("/manual/fan", fanDb);
Serial.print("[FB] Upload summary: ");
Serial.println((okTemp && okHum && okHeater && okFan && okManualHeater && okManualFan) ? "OK" : "FAIL");
// ===== DEBUG =====
Serial.print("Time: ");
Serial.print(hourNow);
Serial.print(":");
Serial.print(minuteNow);
Serial.print(" (");
Serial.print(rtcAvailable ? "RTC" : "NTP");
Serial.println(")");
Serial.print("Temp: ");
Serial.println(temp);
Serial.print("Humidity: ");
Serial.println(hum);
Serial.print("Temp Offset: ");
Serial.println(tempOffset);
Serial.print("Hum Offset: ");
Serial.println(humOffset);
Serial.print("Mode: ");
Serial.println(mode == 1 ? "MANUAL" : "AUTO");
Serial.print("Active: ");
Serial.println(active);
// ===== PUSH HISTORY =====
if (ENABLE_HISTORY) {
uint32_t nowMs = millis();
if (lastHistoryPushMs == 0 || (nowMs - lastHistoryPushMs) >= HISTORY_INTERVAL_MS) {
fbPushHistory(temp, hum, heaterDb, fanDb, mode, active);
lastHistoryPushMs = nowMs;
}
}
delay(3000);
}