861 lines
29 KiB
C++
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;
|
|
}
|
|
}
|