#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "esp_sleep.h" #include "esp_wifi.h" // esp_wifi_set_ps #define CURRENT_VERSION "R_1_0_23" // ---------- Low Power ---------- #define LOW_POWER_MODE 1 #define LP_SLEEP_GUARD_MS 20 #define SENSOR_PWR_PIN -1 // 전원 게이팅 핀 없으면 -1 // ---------- Publish Interval 기본값 ---------- #define PUBLISH_INTERVAL_MS_DEFAULT 1000UL // MQTT 발행 간격 기본(부팅 후) #define MQTT_KEEPALIVE_SEC 60 #define SLEEP_SLICE_MS 5000UL // 긴 대기 쪼개기(keepalive/명령 수신용) // ---------- Duty Cycle 기본값 ---------- #define DUTY_SLEEP_MS_DEFAULT 10000UL // 슬립 윈도우(수면 간격) #define DUTY_AWAKE_MS_DEFAULT 2000UL // 깨어있는 윈도우(발행 유지 시간) // -------- OTA -------- #define OTA_SERVER "https://esp32.qmsguide.synology.me" #define OTA_VERSION_FILE "/housecontrol.txt" // -------- Pins -------- #define SDA_PIN 21 #define SCL_PIN 22 #define RELAY1 16 // 12V LED 메인 (active-low) #define RELAY2 17 // 12V LED 서브, R1과 항상 동기 (active-low, 파생) #define RELAY3 18 // 5V LED (active-low) #define LED_TX 23 #define LED_I2C 26 #define LED_WIFI 27 #define BTN_R1 32 #define BTN_R2 33 const unsigned long DEBOUNCE_MS = 30; // ---- Relay polarity (true=active-low, false=active-high) #define RELAY1_ACTIVE_LOW true #define RELAY2_ACTIVE_LOW true #define RELAY3_ACTIVE_LOW false // 공통 릴레이 제어/상태 유틸 inline void relay_write(uint8_t pin, bool active_low, bool on) { digitalWrite(pin, on ? (active_low ? LOW : HIGH) : (active_low ? HIGH : LOW)); } inline bool relay_is_on(uint8_t pin, bool active_low) { int lv = digitalRead(pin); return active_low ? (lv == LOW) : (lv == HIGH); } inline bool relay_active_low_for_pin(uint8_t pin) { if (pin == RELAY1) return RELAY1_ACTIVE_LOW; if (pin == RELAY2) return RELAY2_ACTIVE_LOW; if (pin == RELAY3) return RELAY3_ACTIVE_LOW; return true; } // ★ R2 파생 로직: R1 상태를 R2에 그대로 복제 inline void updateDerivedRelays() { bool r1_on = relay_is_on(RELAY1, RELAY1_ACTIVE_LOW); relay_write(RELAY2, RELAY2_ACTIVE_LOW, r1_on); } struct Button { uint8_t pin; bool lastStable; bool lastReading; unsigned long lastChange; }; Button btnR1{ BTN_R1, HIGH, HIGH, 0 }; Button btnR2{ BTN_R2, HIGH, HIGH, 0 }; // -------- I2C Addresses -------- #define INA219_ADDR_A 0x40 #define INA219_ADDR_B 0x41 #define ADS1115_ADDR 0x48 #define BH1750_ADDR 0x23 // -------- ADS1115 보정 -------- const float A_SCALE[4] = { 1.00f, 1.00f, 1.00f, 1.00f }; const float A_OFFSET[4] = { 0.35f, 0.35f, 0.14f, 0.00f }; // ===================== XY-MD04 (RS485 Modbus-RTU, auto-direction) ====================== // GPIO4/5 오토-디렉션(DE/RE 없음) #define RS485_RX_PIN 4 #define RS485_TX_PIN 5 inline void rs485_tx_mode(bool) {} // auto-dir 모듈 가정(무동작) // MD04 파라미터(온습도: 인풋레지스터 0x0001/0x0002, ID=1) #define MD04_SLAVE_ID 1 #define MD04_FUNC_READ 0x04 // Input Register #define MD04_REG_T 0x0001 #define MD04_REG_H 0x0002 #define MD04_POLL_MS 1200 // RS-485 타이밍 #define RS485_MAXBUF 64 #define RS485_TIMEOUT_MS 800 #define POST_TX_GUARD_US 12000 #define INTER_READ_DELAY_MS 15 #define RS485_RETRY 1 HardwareSerial& RS485 = Serial2; float md04_airT = NAN, md04_airH = NAN; uint32_t md04_errcnt = 0, lastMd04Poll = 0; bool md04_ok = false; // -------- WiFi / MQTT -------- struct WiFiCandidate { const char* ssid; const char* password; }; WiFiCandidate candidates[] = { { "ChoiBK", "#Info96716" }, { "ChoiBK_35", "#Info96716" }, { "WAVLINK-N_868C", "Info96716" } }; const int candidateCount = sizeof(candidates) / sizeof(candidates[0]); const char* mqtt_broker = "qideun.com"; const int mqtt_port = 1883; const char* mqtt_topic_control = "sokuree/house/control"; const char* mqtt_topic_status = "sokuree/house/status"; // -------- Objects -------- WiFiClient espClient; PubSubClient client(espClient); WiFiUDP ntpUDP; NTPClient timeClient(ntpUDP, "pool.ntp.org", 9 * 3600, 60000); BH1750 lightMeter(BH1750::CONTINUOUS_HIGH_RES_MODE); Adafruit_ADS1X15 ads; Adafruit_INA219 inaA(INA219_ADDR_A), inaB(INA219_ADDR_B); // -------- timers / intervals -------- unsigned long lastPublishTime = 0, lastWiFiReconnectAttempt = 0, lastMqttReconnectAttempt = 0; int mqttReconnectAttempts = 0; const int maxMqttReconnectAttempts = 5; uint32_t publishIntervalMs = PUBLISH_INTERVAL_MS_DEFAULT; uint32_t duty_sleep_ms = DUTY_SLEEP_MS_DEFAULT; uint32_t duty_awake_ms = DUTY_AWAKE_MS_DEFAULT; uint32_t duty_awake_until_ms = 0; // AWAKE 윈도우 종료 시각 // -------- energy -------- double dailyWh1 = 0.0, dailyWh2 = 0.0; int lastDay = -1; uint32_t lastEnergyMs = 0; // -------- I2C health -------- volatile int lastDiscReason = -1; uint32_t lastI2cCheckMs = 0, i2cErrorCount = 0; bool i2cFaultLatched = false; // -------- I2C device health -------- bool bh1750_ok = true, ads_ok = true, inaA_ok = true, inaB_ok = true; uint32_t bh1750_err = 0, ads_err = 0, inaA_err = 0, inaB_err = 0; uint32_t lastI2cErrReportMs = 0; // ---------- Forward Declarations ---------- void checkForOTAUpdate(); bool buttonPressed(Button& b); void toggleRelay(uint8_t relayPin); uint16_t crc16_modbus(const uint8_t* data, size_t len); bool waitWiFi(uint32_t ms); void connectToBestAP(); void connectToMqtt(); void publishStatus(); void setPublishIntervalMs(uint32_t ms); void setDutySleepMs(uint32_t ms); void setDutyAwakeMs(uint32_t ms); void md04_poll_if_due(); static inline void i2c_error_report_1s(); // ----------------------------- Helpers ----------------------------- static inline bool i2cPing(uint8_t addr) { Wire.beginTransmission(addr); return (Wire.endTransmission() == 0); } uint16_t crc16_modbus(const uint8_t* data, size_t len) { uint16_t crc = 0xFFFF; for (size_t i = 0; i < len; i++) { crc ^= data[i]; for (int b = 0; b < 8; b++) crc = (crc & 1) ? ((crc >> 1) ^ 0xA001) : (crc >> 1); } return crc; } inline void sensor_power(bool sleep_on) { #if (SENSOR_PWR_PIN >= 0) pinMode(SENSOR_PWR_PIN, OUTPUT); digitalWrite(SENSOR_PWR_PIN, sleep_on ? LOW : HIGH); #endif // BH1750 Power save Wire.beginTransmission(BH1750_ADDR); Wire.write(sleep_on ? 0x00 : 0x01); Wire.endTransmission(); if (!sleep_on) { Wire.beginTransmission(BH1750_ADDR); Wire.write(0x10); Wire.endTransmission(); } } // 연결 상태에 따라: 연결이면 delay(모뎀슬립), 비연결이면 light sleep inline void cooperative_sleep_block(uint32_t ms_block) { if (ms_block < 10) { client.loop(); return; } uint32_t remain = ms_block; while (remain > 0) { uint32_t slice = (remain > SLEEP_SLICE_MS) ? SLEEP_SLICE_MS : remain; sensor_power(true); if (WiFi.status() == WL_CONNECTED) { delay(slice); } else { esp_sleep_enable_timer_wakeup((uint64_t)slice * 1000ULL); esp_light_sleep_start(); } sensor_power(false); client.loop(); remain -= slice; } } void i2cBusRecover() { Wire.end(); pinMode(SCL_PIN, INPUT_PULLUP); pinMode(SDA_PIN, INPUT_PULLUP); delay(2); for (int i = 0; i < 9 && digitalRead(SDA_PIN) == LOW; ++i) { pinMode(SCL_PIN, OUTPUT); digitalWrite(SCL_PIN, LOW); delayMicroseconds(6); digitalWrite(SCL_PIN, HIGH); delayMicroseconds(6); pinMode(SCL_PIN, INPUT_PULLUP); delayMicroseconds(6); } pinMode(SDA_PIN, OUTPUT); digitalWrite(SDA_PIN, LOW); delayMicroseconds(6); pinMode(SCL_PIN, OUTPUT); digitalWrite(SCL_PIN, HIGH); delayMicroseconds(6); digitalWrite(SDA_PIN, HIGH); delayMicroseconds(6); Wire.begin(SDA_PIN, SCL_PIN); Wire.setClock(100000); #if defined(ESP_ARDUINO_VERSION) Wire.setTimeOut(50); #endif lightMeter.begin(BH1750::CONTINUOUS_HIGH_RES_MODE); ads.begin(ADS1115_ADDR); ads.setGain(GAIN_ONE); inaA.begin(); inaB.begin(); i2cErrorCount++; i2cFaultLatched = true; digitalWrite(LED_I2C, HIGH); Serial.println("[I2C] bus recovered & sensors reinit"); } bool waitWiFi(uint32_t ms) { uint32_t t0 = millis(); while (WiFi.status() != WL_CONNECTED && (millis() - t0) < ms) delay(100); return WiFi.status() == WL_CONNECTED; } // 버튼 디바운스 bool buttonPressed(Button& b) { bool rd = digitalRead(b.pin); unsigned long now = millis(); if (rd != b.lastReading) { b.lastReading = rd; b.lastChange = now; } if ((now - b.lastChange) > DEBOUNCE_MS) { if (rd != b.lastStable) { b.lastStable = rd; if (rd == LOW) return true; } } return false; } // 릴레이 토글(극성 반영) // ★ R1만 직접 토글, R2는 항상 R1을 따라가도록 파생 void toggleRelay(uint8_t relayPin) { bool active_low = relay_active_low_for_pin(relayPin); bool on = relay_is_on(relayPin, active_low); relay_write(relayPin, active_low, !on); if (relayPin == RELAY1) { updateDerivedRelays(); } } // ---------- OTA ---------- void checkForOTAUpdate() { if (WiFi.status() != WL_CONNECTED) return; HTTPClient http; String versionURL = String(OTA_SERVER) + OTA_VERSION_FILE; http.begin(versionURL); int httpCode = http.GET(); if (httpCode == HTTP_CODE_OK) { String response = http.getString(); response.trim(); int sep = response.indexOf('|'); if (sep != -1) { String serverVersion = response.substring(0, sep); String serverBinName = response.substring(sep + 1); if (serverVersion != CURRENT_VERSION) { HTTPClient binHttp; binHttp.begin(String(OTA_SERVER) + "/" + serverBinName); int binCode = binHttp.GET(); if (binCode == HTTP_CODE_OK) { int contentLength = binHttp.getSize(); WiFiClient* stream = binHttp.getStreamPtr(); if (Update.begin(contentLength)) { size_t written = Update.writeStream(*stream); if (written == contentLength && Update.end() && Update.isFinished()) ESP.restart(); } } binHttp.end(); } } } http.end(); } /* ----------------------------- RS485 / Modbus ----------------------------- */ static void build_read_req(uint8_t* out, uint8_t sid, uint8_t func, uint16_t reg, uint16_t qty, bool crcLH) { out[0] = sid; out[1] = func; out[2] = (uint8_t)(reg >> 8); out[3] = (uint8_t)(reg & 0xFF); out[4] = (uint8_t)(qty >> 8); out[5] = (uint8_t)(qty & 0xFF); uint16_t crc = crc16_modbus(out, 6); if (crcLH) { out[6] = (uint8_t)(crc & 0xFF); out[7] = (uint8_t)(crc >> 8); } else { out[6] = (uint8_t)(crc >> 8); out[7] = (uint8_t)(crc & 0xFF); } } static bool same8(const uint8_t* a, const uint8_t* b) { for (int i = 0; i < 8; i++) if (a[i] != b[i]) return false; return true; } static bool md04_read_one(uint8_t sid, uint16_t reg, int16_t& outVal) { uint8_t reqLH[8], reqHL[8]; build_read_req(reqLH, sid, 0x04, reg, 1, true); build_read_req(reqHL, sid, 0x04, reg, 1, false); for (int pass = 0; pass < 2; ++pass) { const uint8_t* req = (pass == 0) ? reqLH : reqHL; while (RS485.available()) RS485.read(); rs485_tx_mode(true); RS485.write(req, 8); RS485.flush(); rs485_tx_mode(false); delayMicroseconds(POST_TX_GUARD_US); uint8_t rx[RS485_MAXBUF]; size_t n = 0; unsigned long t0 = millis(); bool echoDropped = false; while ((millis() - t0) < RS485_TIMEOUT_MS) { while (RS485.available() && n < RS485_MAXBUF) rx[n++] = RS485.read(); if (!echoDropped && n >= 8 && same8(rx, req)) { memmove(rx, rx + 8, n - 8); n -= 8; echoDropped = true; } if (n >= 7) { if (rx[0] == sid && rx[1] == 0x04 && rx[2] == 0x02) { uint16_t crc_calc = crc16_modbus(rx, 5); uint16_t crc_recv = (uint16_t)rx[5] | ((uint16_t)rx[6] << 8); if (crc_calc == crc_recv) { outVal = (int16_t)((rx[3] << 8) | rx[4]); return true; } } if (n >= 5 && rx[0] == sid && rx[1] == 0x84) return false; } delay(1); } } return false; } bool md04_transaction() { bool ok = false; int16_t t10 = 0, h10 = 0; for (int attempt = 0; attempt <= RS485_RETRY; ++attempt) { bool okT = md04_read_one(MD04_SLAVE_ID, MD04_REG_T, t10); delay(INTER_READ_DELAY_MS); bool okH = md04_read_one(MD04_SLAVE_ID, MD04_REG_H, h10); if (okT && okH) { md04_airT = (float)t10 / 10.0f; md04_airH = (float)((uint16_t)h10) / 10.0f; md04_ok = isfinite(md04_airT) && isfinite(md04_airH); ok = md04_ok; break; } else md04_errcnt++; } if (!ok) md04_ok = false; return md04_ok; } void md04_poll_if_due() { uint32_t now = millis(); if (now - lastMd04Poll < MD04_POLL_MS) return; lastMd04Poll = now; md04_transaction(); } /* ----------------------------- WiFi events ----------------------------- */ void onWiFiEvent(arduino_event_id_t event, arduino_event_info_t info) { if (event == ARDUINO_EVENT_WIFI_STA_DISCONNECTED) { lastDiscReason = info.wifi_sta_disconnected.reason; digitalWrite(LED_WIFI, HIGH); Serial.printf("[WiFi] DISCONNECTED reason=%d\n", lastDiscReason); } else if (event == ARDUINO_EVENT_WIFI_STA_CONNECTED) { Serial.printf("[WiFi] CONNECTED bssid=%02X:%02X:%02X:%02X:%02X:%02X ch=%d\n", info.wifi_sta_connected.bssid[0], info.wifi_sta_connected.bssid[1], info.wifi_sta_connected.bssid[2], info.wifi_sta_connected.bssid[3], info.wifi_sta_connected.bssid[4], info.wifi_sta_connected.bssid[5], info.wifi_sta_connected.channel); } else if (event == ARDUINO_EVENT_WIFI_STA_GOT_IP) { digitalWrite(LED_WIFI, LOW); Serial.print("[WiFi] GOT IP "); Serial.println(WiFi.localIP()); WiFi.printDiag(Serial); } } /* ----------------------------- MQTT ----------------------------- */ void setPublishIntervalMs(uint32_t ms) { if (ms < 500UL) ms = 500UL; if (ms > 600000UL) ms = 600000UL; publishIntervalMs = ms; Serial.printf("[CFG] publishIntervalMs=%lu ms\n", (unsigned long)publishIntervalMs); } void setDutySleepMs(uint32_t ms) { if (ms < 500UL) ms = 500UL; if (ms > 600000UL) ms = 600000UL; duty_sleep_ms = ms; Serial.printf("[CFG] duty_sleep_ms=%lu ms\n", (unsigned long)duty_sleep_ms); } void setDutyAwakeMs(uint32_t ms) { if (ms < 200UL) ms = 200UL; if (ms > 60000UL) ms = 60000UL; duty_awake_ms = ms; Serial.printf("[CFG] duty_awake_ms=%lu ms\n", (unsigned long)duty_awake_ms); } void handleMQTTMessage(const String& message) { bool updated = false; // 릴레이 제어: R1과 R2는 항상 동기 (R2 파생) if (message == "11" || message == "21") { // R1 ON, R2도 자동 ON relay_write(RELAY1, RELAY1_ACTIVE_LOW, true); updateDerivedRelays(); updated = true; } else if (message == "10" || message == "20") { // R1 OFF, R2도 자동 OFF relay_write(RELAY1, RELAY1_ACTIVE_LOW, false); updateDerivedRelays(); updated = true; } else if (message == "31") { // R3 ON (5V LED) relay_write(RELAY3, RELAY3_ACTIVE_LOW, true); updated = true; } else if (message == "30") { // R3 OFF relay_write(RELAY3, RELAY3_ACTIVE_LOW, false); updated = true; } else if (message == "100") { checkForOTAUpdate(); } // Publish interval else if (message.startsWith("PUB,")) { long sec = message.substring(4).toInt(); if (sec > 0) setPublishIntervalMs((uint32_t)sec * 1000UL); } else if (message.startsWith("PUBMS,")) { long ms = message.substring(6).toInt(); if (ms > 0) setPublishIntervalMs((uint32_t)ms); } else if (message == "PUB?") { StaticJsonDocument<96> d; d["pub_ms"] = (int)publishIntervalMs; d["sleep_ms"] = (int)duty_sleep_ms; d["awake_ms"] = (int)duty_awake_ms; char b[96]; size_t l = serializeJson(d, b); client.publish("sokuree/house/status/cfg", b, l); } // Duty cycle else if (message.startsWith("SLEEP,")) { long sec = message.substring(6).toInt(); if (sec > 0) setDutySleepMs((uint32_t)sec * 1000UL); } else if (message.startsWith("SLEEPMS,")) { long ms = message.substring(8).toInt(); if (ms > 0) setDutySleepMs((uint32_t)ms); } else if (message.startsWith("AWAKE,")) { long sec = message.substring(6).toInt(); if (sec > 0) setDutyAwakeMs((uint32_t)sec * 1000UL); } else if (message.startsWith("AWAKEMS,")) { long ms = message.substring(8).toInt(); if (ms > 0) setDutyAwakeMs((uint32_t)ms); } else if (message == "DUTY?") { StaticJsonDocument<96> d; d["sleep_ms"] = (int)duty_sleep_ms; d["awake_ms"] = (int)duty_awake_ms; d["pub_ms"] = (int)publishIntervalMs; char b[96]; size_t l = serializeJson(d, b); client.publish("sokuree/house/status/cfg", b, l); } if (updated) delay(5); } void callback(char* topic, byte* payload, unsigned int length) { String msg; msg.reserve(length); for (unsigned int i = 0; i < length; i++) msg += (char)payload[i]; handleMQTTMessage(msg); } void connectToMqtt() { if (!WiFi.isConnected() || client.connected()) return; uint64_t chipId = ESP.getEfuseMac(); char clientId[32]; snprintf(clientId, sizeof(clientId), "ESP32_%04X", (uint16_t)(chipId & 0xFFFF)); if (client.connect(clientId)) { client.subscribe(mqtt_topic_control); mqttReconnectAttempts = 0; } else { mqttReconnectAttempts++; if (mqttReconnectAttempts >= maxMqttReconnectAttempts) ESP.restart(); } } /* ----------------------------- ADS helpers ----------------------------- */ float readADS_V(uint8_t ch) { int16_t r[5]; for (int i = 0; i < 5; i++) { r[i] = ads.readADC_SingleEnded(ch); delay(2); } for (int i = 1; i < 5; i++) { int16_t k = r[i], j = i - 1; while (j >= 0 && r[j] > k) { r[j + 1] = r[j]; j--; } r[j + 1] = k; } float v = ads.computeVolts(r[2]); if (v < 0.0f) v = 0.0f; return v; } /* ----------------------------- I2C 1초 에러 요약 ----------------------------- */ static inline void i2c_error_report_1s() { uint32_t now = millis(); if (now - lastI2cErrReportMs < 1000UL) return; lastI2cErrReportMs = now; if (!bh1750_ok || !ads_ok || !inaA_ok || !inaB_ok || i2cFaultLatched) { Serial.print("[I2C][ERR] modules="); bool first = true; auto pf = [&](const char* name) { if (!first) Serial.print(", "); Serial.print(name); first = false; }; if (!bh1750_ok) pf("BH1750"); if (!ads_ok) pf("ADS1115"); if (!inaA_ok) pf("INA219_A"); if (!inaB_ok) pf("INA219_B"); if (i2cFaultLatched) { if (!first) Serial.print(", "); Serial.print("BUS_FAULT"); first = false; } Serial.print(" | cnts { bh="); Serial.print(bh1750_err); Serial.print(", ads="); Serial.print(ads_err); Serial.print(", inaA="); Serial.print(inaA_err); Serial.print(", inaB="); Serial.print(inaB_err); Serial.print(", bus="); Serial.print(i2cErrorCount); Serial.println(" }"); } } /* ----------------------------- Publish Status ----------------------------- */ void publishStatus() { // I2C 상태 점검 & 복구 if (millis() - lastI2cCheckMs > 2000) { lastI2cCheckMs = millis(); static uint8_t consec_ping_fail = 0; bool ping_ok = i2cPing(ADS1115_ADDR) && i2cPing(INA219_ADDR_A) && i2cPing(INA219_ADDR_B) && i2cPing(BH1750_ADDR); if (!ping_ok) { consec_ping_fail++; if (consec_ping_fail >= 2) { Serial.println("[I2C] ping failed twice -> recover"); i2cBusRecover(); consec_ping_fail = 0; } } else { consec_ping_fail = 0; i2cFaultLatched = false; digitalWrite(LED_I2C, LOW); } } // 핑 OK 플래그 bh1750_ok = i2cPing(BH1750_ADDR); ads_ok = i2cPing(ADS1115_ADDR); inaA_ok = i2cPing(INA219_ADDR_A); inaB_ok = i2cPing(INA219_ADDR_B); if (!bh1750_ok) bh1750_err++; if (!ads_ok) ads_err++; if (!inaA_ok) inaA_err++; if (!inaB_ok) inaB_err++; // BH1750 float lux = NAN; if (bh1750_ok) { lux = lightMeter.readLightLevel(); if (!isfinite(lux) || lux < 0) { bh1750_ok = false; bh1750_err++; lux = NAN; } } // INA219 float bv1 = NAN, c1 = NAN, bv2 = NAN, c2 = NAN; if (inaA_ok) { bv1 = inaA.getBusVoltage_V(); c1 = inaA.getCurrent_mA(); if (!isfinite(bv1) || !isfinite(c1)) { inaA_ok = false; inaA_err++; } } if (inaB_ok) { bv2 = inaB.getBusVoltage_V(); c2 = inaB.getCurrent_mA(); if (!isfinite(bv2) || !isfinite(c2)) { inaB_ok = false; inaB_err++; } } // ADS1115 auto safe_readADS_V = [&](uint8_t ch) -> float { if (!ads_ok) return NAN; float v = readADS_V(ch); if (!isfinite(v) || v < 0) { ads_ok = false; ads_err++; return NAN; } return v; }; float a_rawV[4] = { NAN, NAN, NAN, NAN }; for (int i = 0; i < 4; i++) a_rawV[i] = safe_readADS_V(i); float a_mapV[4] = { NAN, NAN, NAN, NAN }; for (int i = 0; i < 4; i++) if (isfinite(a_rawV[i])) { a_mapV[i] = a_rawV[i] * A_SCALE[i] + A_OFFSET[i]; if (!isfinite(a_mapV[i])) a_mapV[i] = NAN; } // 배터리 % auto batpct = [&](float v) { if (!isfinite(v)) return NAN; float p = (v - 3.3f) / (4.2f - 3.3f) * 100.0f; if (p < 0) p = 0; if (p > 100) p = 100; return roundf(p * 10) / 10.0f; }; float batpct0 = isfinite(a_mapV[0]) ? batpct(a_mapV[0]) : NAN; float batpct1 = isfinite(a_mapV[1]) ? batpct(a_mapV[1]) : NAN; float batpct2 = isfinite(a_mapV[2]) ? batpct(a_mapV[2]) : NAN; // 전력/에너지 적산 float pwr1_mW = (isfinite(bv1) && isfinite(c1)) ? (bv1 * c1) : NAN; float pwr2_mW = (isfinite(bv2) && isfinite(c2)) ? (bv2 * c2) : NAN; uint32_t nowMs = millis(); if (lastEnergyMs == 0) lastEnergyMs = nowMs; float dt_h = (nowMs - lastEnergyMs) / 3600000.0f; if (dt_h > (10.0f / 3600.0f)) dt_h = (10.0f / 3600.0f); lastEnergyMs = nowMs; timeClient.update(); time_t rawTime = timeClient.getEpochTime(); struct tm ti; localtime_r(&rawTime, &ti); int currentDay = ti.tm_mday; if (lastDay == -1) lastDay = currentDay; if (currentDay != lastDay) { dailyWh1 = 0.0; dailyWh2 = 0.0; lastDay = currentDay; } if (isfinite(pwr1_mW)) dailyWh1 += (pwr1_mW / 1000.0f) * dt_h; if (isfinite(pwr2_mW)) dailyWh2 += (pwr2_mW / 1000.0f) * dt_h; // ★ 파생 릴레이 동기 (R1 변경 후 혹시 모를 불일치 방지) updateDerivedRelays(); // ===== 발행 JSON ===== StaticJsonDocument<1024> doc; doc["ver"] = CURRENT_VERSION; doc["r1"] = relay_is_on(RELAY1, RELAY1_ACTIVE_LOW) ? "ON" : "OFF"; doc["r2"] = relay_is_on(RELAY2, RELAY2_ACTIVE_LOW) ? "ON" : "OFF"; doc["r3"] = relay_is_on(RELAY3, RELAY3_ACTIVE_LOW) ? "ON" : "OFF"; // 조도 if (isfinite(lux)) doc["lux"] = (int)lux; else doc["lux"] = nullptr; // INA219 if (isfinite(bv1)) doc["bv1"] = roundf(bv1 * 100) / 100.0f; else doc["bv1"] = nullptr; if (isfinite(c1)) doc["c1"] = roundf(c1 * 100) / 100.0f; else doc["c1"] = nullptr; if (isfinite(bv2)) doc["bv2"] = roundf(bv2 * 100) / 100.0f; else doc["bv2"] = nullptr; if (isfinite(c2)) doc["c2"] = roundf(c2 * 100) / 100.0f; else doc["c2"] = nullptr; // ADS 맵V for (int i = 0; i < 4; i++) { char key[3]; snprintf(key, sizeof(key), "a%d", i); if (isfinite(a_mapV[i])) doc[key] = roundf(a_mapV[i] * 100) / 100.0f; else doc[key] = nullptr; } // 배터리 % if (isfinite(batpct0)) doc["batp0"] = batpct0; else doc["batp0"] = nullptr; if (isfinite(batpct1)) doc["batp1"] = batpct1; else doc["batp1"] = nullptr; if (isfinite(batpct2)) doc["batp2"] = batpct2; else doc["batp2"] = nullptr; // 전력/에너지 if (isfinite(pwr1_mW)) doc["pwr1"] = roundf(pwr1_mW * 100) / 100.0f; else doc["pwr1"] = nullptr; if (isfinite(pwr2_mW)) doc["pwr2"] = roundf(pwr2_mW * 100) / 100.0f; else doc["pwr2"] = nullptr; doc["dwh1"] = roundf(dailyWh1 * 10000) / 10000.0f; doc["dwh2"] = roundf(dailyWh2 * 10000) / 10000.0f; char buffer[768]; size_t len = serializeJson(doc, buffer); digitalWrite(LED_TX, HIGH); client.publish(mqtt_topic_status, buffer, len); digitalWrite(LED_TX, LOW); Serial.print("Status sent: "); Serial.println(buffer); } /* ----------------------------- Setup / Loop ----------------------------- */ void connectToBestAP() { WiFi.persistent(false); WiFi.mode(WIFI_STA); WiFi.disconnect(true, true); delay(60); int n = WiFi.scanNetworks(false, true); struct Found { const char* ssid; const char* pass; int ch; uint8_t bssid[6]; int rssi; bool found; } best = { nullptr, nullptr, 0, { 0 }, -1000, false }, alt = { nullptr, nullptr, 0, { 0 }, -1000, false }; for (int i = 0; i < n; ++i) { String s = WiFi.SSID(i); int r = WiFi.RSSI(i); int ch = WiFi.channel(i); const uint8_t* b = WiFi.BSSID(i); for (int j = 0; j < candidateCount; ++j) if (s == candidates[j].ssid) { if (!best.found || r > best.rssi) { if (best.found) alt = best; best = { candidates[j].ssid, candidates[j].password, ch, { 0 }, r, true }; if (b) memcpy(best.bssid, b, 6); } else if (!alt.found || r > alt.rssi) { alt = { candidates[j].ssid, candidates[j].password, ch, { 0 }, r, true }; if (b) memcpy(alt.bssid, b, 6); } } } auto tryConnect = [&](const Found& f) -> bool { if (!f.found) return false; lastDiscReason = -1; Serial.printf("[WiFi] try lock %s ch%d rssi%d\n", f.ssid, f.ch, f.rssi); WiFi.begin(f.ssid, f.pass, f.ch, f.bssid); if (waitWiFi(6000)) return true; Serial.printf("[WiFi] lock failed status=%d reason=%d\n", WiFi.status(), lastDiscReason); lastDiscReason = -1; Serial.printf("[WiFi] retry generic %s\n", f.ssid); WiFi.disconnect(true, true); delay(80); WiFi.begin(f.ssid, f.pass); if (waitWiFi(8000)) return true; Serial.printf("[WiFi] generic failed status=%d reason=%d\n", WiFi.status(), lastDiscReason); return false; }; bool ok = false; if (best.found) ok = tryConnect(best); if (!ok && alt.found) ok = tryConnect(alt); if (!ok) { Serial.printf("[WiFi] fallback generic %s\n", candidates[0].ssid); WiFi.begin(candidates[0].ssid, candidates[0].password); waitWiFi(8000); } WiFi.scanDelete(); } void setup() { Serial.begin(115200); pinMode(RELAY1, OUTPUT); pinMode(RELAY2, OUTPUT); pinMode(RELAY3, OUTPUT); // ★ 초기상태: 모두 OFF로 통일 (극성 반영) relay_write(RELAY1, RELAY1_ACTIVE_LOW, false); relay_write(RELAY2, RELAY2_ACTIVE_LOW, false); relay_write(RELAY3, RELAY3_ACTIVE_LOW, false); updateDerivedRelays(); // R1 OFF → R2 OFF pinMode(LED_TX, OUTPUT); digitalWrite(LED_TX, LOW); pinMode(LED_I2C, OUTPUT); digitalWrite(LED_I2C, LOW); pinMode(LED_WIFI, OUTPUT); digitalWrite(LED_WIFI, HIGH); pinMode(BTN_R1, INPUT_PULLUP); pinMode(BTN_R2, INPUT_PULLUP); Wire.begin(SDA_PIN, SCL_PIN); Wire.setClock(100000); #if defined(ESP_ARDUINO_VERSION) Wire.setTimeOut(50); #endif lightMeter.begin(BH1750::CONTINUOUS_HIGH_RES_MODE); ads.begin(ADS1115_ADDR); ads.setGain(GAIN_ONE); inaA.begin(); inaB.begin(); RS485.begin(9600, SERIAL_8N1, RS485_RX_PIN, RS485_TX_PIN); WiFi.setSleep(true); esp_wifi_set_ps(WIFI_PS_MAX_MODEM); WiFi.onEvent(onWiFiEvent); connectToBestAP(); client.setServer(mqtt_broker, mqtt_port); client.setCallback(callback); client.setKeepAlive(MQTT_KEEPALIVE_SEC); client.setBufferSize(768); timeClient.begin(); for (int i = 0; i < 5; ++i) { if (WiFi.status() == WL_CONNECTED && timeClient.update()) break; delay(100); } digitalWrite(LED_WIFI, (WiFi.status() == WL_CONNECTED) ? LOW : HIGH); sensor_power(false); // 웨이크 상태 시작 // 첫 측정/발행 publishStatus(); lastPublishTime = millis(); // 첫 AWAKE 윈도우 시작 duty_awake_until_ms = millis() + duty_awake_ms; } void connectToMqtt(); void loop() { unsigned long now = millis(); // 버튼 즉시 처리 (두 버튼 모두 R1+R2 토글로 사용) if (buttonPressed(btnR1)) { toggleRelay(RELAY1); publishStatus(); } if (buttonPressed(btnR2)) { toggleRelay(RELAY1); publishStatus(); } // WiFi LED digitalWrite(LED_WIFI, (WiFi.status() == WL_CONNECTED) ? LOW : HIGH); // 연결 유지/재연결 if (WiFi.status() != WL_CONNECTED) { if (now - lastWiFiReconnectAttempt > 5000UL) { lastWiFiReconnectAttempt = now; WiFi.mode(WIFI_STA); connectToBestAP(); } delay(10); } if (!client.connected()) { if (now - lastMqttReconnectAttempt > 5000) { lastMqttReconnectAttempt = now; connectToMqtt(); } } else { client.loop(); } // RS485 폴링(비동기) md04_poll_if_due(); // I2C 에러 요약(1초 주기) i2c_error_report_1s(); // ===== Duty-cycle: AWAKE 구간 → 발행 / AWAKE 끝나면 SLEEP 블록 ===== bool in_burst = ((int32_t)(duty_awake_until_ms - millis()) > 0); if (in_burst) { // 깨어있는 동안: PUB 주기로 반복 발행 if (millis() - lastPublishTime > publishIntervalMs) { lastPublishTime = millis(); publishStatus(); } } else { // AWAKE 종료 → 슬립 블록 수행 cooperative_sleep_block(duty_sleep_ms); // 슬립 끝: 즉시 1회 발행 publishStatus(); lastPublishTime = millis(); // 다음 AWAKE 윈도우 시작 duty_awake_until_ms = millis() + duty_awake_ms; } }