HouseControl/src/main.cpp
2025-11-14 09:24:15 +09:00

861 lines
29 KiB
C++

#include <Arduino.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <Wire.h>
#include <BH1750.h>
#include <NTPClient.h>
#include <WiFiUdp.h>
#include <HTTPClient.h>
#include <Update.h>
#include <time.h>
#include <string.h>
#include <math.h>
#include <Adafruit_ADS1X15.h>
#include <Adafruit_INA219.h>
#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;
}
}