diff --git a/README b/README
new file mode 100644
index 0000000..73e51c6
--- /dev/null
+++ b/README
@@ -0,0 +1,112 @@
+[2025-09-20] ESP32_R3.1 / MPINO_R1.4
+
+ESP32 I²C↔MQTT Bridge & MPINO Control — README
+이 문서는 현 스케치 기준으로 사용하는
+시리얼 명령어(Serial CLI) 와 MQTT 토픽/명령(페이로드 형식) 을 한눈에 정리한 것입니다.
+베이스 토픽, Wi-Fi·MQTT 설정은 시리얼 CLI로 변경할 수 있으며 NVS에 영구 저장됩니다.
+________________________________________
+개요
+• ESP32가 I²C 마스터로 MPINO 슬레이브를 폴링하여 센서/설정 프레임을 수신하고 MQTT로 퍼블리시합니다.
+• 외부에서 MQTT 명령을 보내면 ESP32가 I²C로 3바이트 명령을 슬레이브에 전달합니다.
+• 설정은 모두 Preferences(NVS) 에 저장되어 전원 재인가 후에도 유지됩니다.
+• 저장된 Wi-Fi AP가 1개도 없으면 부팅 시 자동으로 CONFIG 모드에 진입합니다.
+________________________________________
+시리얼 명령어 (Serial CLI)
+명령 형식 설명
+HELP HELP 가능한 모든 명령과 사용법을 표시
+CONFIG CONFIG 설정 모드 진입 (Wi-Fi/MQTT 연결·I²C 폴링 일시 중지)
+EXIT EXIT 또는 CONFIG OFF 설정 모드 종료, 정상 동작 재개
+LIST LIST 저장된 Wi-Fi AP 후보 목록 표시(최대 6개)
+ADD ADD ssid,password AP 후보 추가
+EDIT EDIT index ssid,password AP 후보 수정
+DELETE DELETE index AP 후보 삭제
+CLEAR CLEAR AP 후보 전체 삭제
+MQTTSET MQTTSET server,user,pass,topic MQTT 설정 저장• server는 host 또는 host:port 형식• topic은 베이스 토픽
+MQTTINFO MQTTINFO 현재 MQTT 설정과 파생 토픽(status/config/command/…) 표시
+REQINFO REQINFO REQID와 REQTIME 현재값 요약 표시
+REQID REQID [start,end] 폴링 ID 범위 설정 또는 인자 없이 조회
+REQTIME REQTIME [ms] 폴링 주기(ms) 설정 또는 인자 없이 조회 (하한 200 ms)
+메모
+• 모든 설정은 NVS에 영구 저장됩니다.
+• REQTIME 간격마다 I²C를 읽고 유효 프레임이면 바로 MQTT 발행합니다.
+• 슬레이브가 SENSOR/CONFIG 프레임을 번갈아 보낸다면, 특정 단일 토픽만 보면 체감 주기가 2 × REQTIME처럼 보일 수 있습니다.
+예시
+CONFIG
+ADD MyAP,myPw
+MQTTSET qideun.com,,,"selimcns"
+REQTIME 1000
+REQID 1,16
+EXIT
+________________________________________
+MQTT 토픽
+아래 예시는 베이스 토픽이 selimcns인 경우입니다.
+베이스 토픽은 MQTTSET …, 로 변경하면 모든 파생 토픽이 함께 바뀝니다.
+퍼블리시(ESP32 → 브로커)
+• selimcns/status — 센서값 JSON
+• {
+• "EC":123, "pH":6.54,
+• "airTemperature":23.45, "airHumidity":55.67,
+• "ADC0":0, "ADC1":0, "ADC2":0,
+• "soilTemp1":21.34, "soilTemp2":22.01,
+• "l_minute":0, "pumpPin":0, "valvePin":0,
+• "waterLevelPin":0, "rainSensor":0
+• }
+• selimcns/config — 구성(설정 프레임) JSON
+• {
+• "currentMode":0,
+• "cycleTime":0,
+• "cycleRestartDelay":0,
+• "valveDelay":0,
+• "smStart":0, "smStop":0
+• }
+• selimcns/bridge — LWT (retain)
+연결 시 "online" 발행, 비정상 종료 시 브로커가 "offline" 게시.
+• selimcns/speed/response — 핑 응답(ECHO)
+구독(브리지 ← 외부)
+• selimcns/command — 명령 토픽
+o 페이로드 형식: 문자열 "ID,VALUE" (쉼표로 구분된 10진 정수 2개, 공백 없음)
+o 동작: ESP32가 I²C로 3바이트 [ID][VALUE LSB][VALUE MSB] 를 슬레이브에 전송
+o 권장: retain 사용 금지(재접속 시 재실행 방지)
+• selimcns/speed — 핑용 ECHO 입력
+수신 페이로드를 그대로 selimcns/speed/response에 재전송
+________________________________________
+명령 ID 매핑 (슬레이브 receiveData() 기준)
+ID 의미 VALUE (단위) 비고
+1 모드 전환 0=MANUAL, 1=AUTO, 2=AI 전환 시 stopPumpSequence() 수행
+2 펌프 제어 0=OFF, 1=ON AI 모드에서만 유효. 밸브 선개방+지연 후 ON
+3 cycleTime 초 펌프 ON 유지 시간 (최대 65,535 s ≈ 18.2 h)
+4 cycleRestartDelay 초 펌프 OFF 유지 시간
+5 valveDelay 초 밸브 개방 후 펌프 ON까지 딜레이
+6 smStart 0~1023 자동 모드 ON 임계 (3개 ADC 중 2개 초과)
+7 smStop 0~1023 자동 모드 OFF 임계 (3개 ADC 중 2개 미만)
+8 holdDuration 초 조건 유지 시간(채터링 방지)
+설정 명령(3~8)을 보내면 슬레이브가 configData를 갱신·CRC 재계산 후 I²C로 내보내며,
+브리지가 곧바로 /config에 새 값을 퍼블리시합니다. 이를 간접 ACK 로 활용하세요.
+________________________________________
+사용 예시
+# 자동 모드로 전환
+mosquitto_pub -h qideun.com -t selimcns/command -m "1,1"
+
+# 자동 모드 파라미터 설정
+mosquitto_pub -h qideun.com -t selimcns/command -m "6,850" # smStart
+mosquitto_pub -h qideun.com -t selimcns/command -m "7,600" # smStop
+mosquitto_pub -h qideun.com -t selimcns/command -m "5,5" # valveDelay 5s
+mosquitto_pub -h qideun.com -t selimcns/command -m "3,300" # cycleTime 5분
+mosquitto_pub -h qideun.com -t selimcns/command -m "4,900" # restartDelay 15분
+
+# AI 모드에서 펌프 ON → OFF
+mosquitto_pub -h qideun.com -t selimcns/command -m "1,2" # AI 모드
+mosquitto_pub -h qideun.com -t selimcns/command -m "2,1" # 펌프 ON
+mosquitto_pub -h qideun.com -t selimcns/command -m "2,0" # 펌프 OFF
+
+# 상태/설정 모니터링
+mosquitto_sub -h qideun.com -t selimcns/status
+mosquitto_sub -h qideun.com -t selimcns/config
+________________________________________
+참고 & 주의사항
+• 베이스 토픽은 MQTTSET의 마지막 인자로 설정합니다. 변경 시 모든 파생 토픽이 자동 갱신됩니다.
+• REQTIME은 I²C 폴링 주기입니다. 슬레이브가 SENSOR/CONFIG를 번갈아 보낼 경우, 특정 단일 토픽 기준 체감 주기가 2 × REQTIME처럼 보일 수 있습니다. 필요 시 REQTIME을 절반으로 조정하거나(간단), 코드에서 한 사이클에 2회 폴링하도록 변경할 수 있습니다.
+• 명령 토픽은 retain 금지를 권장합니다(재접속 시 동일 명령 재적용 방지).
+• 타이밍 명령(3,4,5,8)의 단위는 초, 내부에서는 ms로 변환되어 사용됩니다.
+• smStart > smStop(히스테리시스)을 권장합니다.
+• 네트워크가 끊기거나 CRC 에러가 나면 해당 주기의 MQTT 발행은 스킵됩니다(로그 확인).
diff --git a/platformio.ini b/platformio.ini
index 4b30716..ef4b167 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -12,3 +12,9 @@
platform = espressif32
board = esp32dev
framework = arduino
+lib_deps =
+ knolleary/PubSubClient@^2.8
+ bblanchon/ArduinoJson@^7.4.2
+ paulstoffregen/OneWire@^2.3.8
+ milesburton/DallasTemperature@^4.0.5
+ 4-20ma/ModbusMaster@^2.0.1
diff --git a/src/MPINO.cpp b/src/MPINO.cpp
new file mode 100644
index 0000000..c416410
--- /dev/null
+++ b/src/MPINO.cpp
@@ -0,0 +1,944 @@
+// MPINO-16A8R8T Slave Control Board Code R1.1, 2024-11-05
+// 수동모드, AI 모드에서 펌프 On 시 밸브가 닫혀있는 상태에서 펌프가 On 되는 현상 수정
+#include
+#include
+#include
+#include
+
+// 송신 주기 및 타이밍 변수 선언
+unsigned long lastSendTime = 0;
+const unsigned long sendInterval = 50; // 송신 간격 (100ms)
+bool sendSensorNext = true; // SensorData 전송 플래그 초기값
+
+#define ONE_WIRE_BUS 12 // DS18B20 온도 센서 핀
+OneWire oneWire(ONE_WIRE_BUS);
+DallasTemperature sensors(&oneWire);
+
+// 온도 측정값 저장 변수
+float soilTemp[2]; // 2개의 온도 값을 저장하는 배열
+
+// RS485 통신 모듈 및 센서 설정
+ModbusMaster node1; // ID 1: 토양 센서
+ModbusMaster node2; // ID 2: 대기 센서
+unsigned long lastRequestTime = 0;
+const unsigned long requestInterval = 1000; // 각 센서 데이터 요청 간격 (1초)
+
+// 데이터 변수 선언
+int moisture = 0;
+float soiltemperature = 0.0;
+int ec = 0;
+float ph = 0.0;
+float airtemperature = 0.0;
+float airhumidity = 0.0;
+
+// 타이머 설정
+unsigned long previousMillis = 0;
+const long interval = 1000; // 데이터 읽기 간격 (1초)
+
+// 핀 정의
+const int flowSensorPin = 2; // 유량 센서 핀
+const int pumpPin = 64; // 펌프 핀
+const int valvePin = 67; // 밸브 핀
+const int control_PumpOn = 36;
+const int control_PumpOff = 37;
+const int waterLevelPin = 31; // 워터 레벨 핀
+const int rainSensor = 29; // 강우 센서 핀
+const int manualModePin = 33; // 수동 모드 선택 핀
+const int autoModePin = 34; // 자동 모드 선택 핀
+const int aiModePin = 35; // AI 모드 선택 핀
+const int adcPins[3] = {A0, A1, A2}; // 토양 수분 센서 핀들
+
+// 토양 수분 센서 값
+int ADC0 = 0;
+int ADC1 = 0;
+int ADC2 = 0;
+
+// 필터링된 ADC 값
+float filtered_ADC[3] = {0, 0, 0};
+const float alpha = 0.1; // LPF 강도 설정 (0.1 ~ 0.3 정도 추천)
+
+// 설정 값
+int smStart = 850; // 펌프 ON 수분 기준값
+int smStop = 600; // 펌프 OFF 수분 기준값
+unsigned long valveDelay = 5000; // 밸브 열림 대기 시간 (5초)
+unsigned long cycleTime = 60000; // 자동 모드 사이클 시간 (10초)
+unsigned long holdDuration = 10000; // 조건 유지 시간 (10초)
+// 필요한 변수 추가
+unsigned long pumpStartTime = 0;
+unsigned long restartDelayStartTime = 0;
+unsigned long cycleStartTime = 0; // 초기화 추가, 필요에 따라 사용
+unsigned long cycleRestartDelay = 60000;
+
+unsigned long debounceDelay = 200; // 디바운스 딜레이 (1초)
+unsigned long lastSensorPrintTime = 0; // 마지막 센서 출력 시간
+const unsigned long sensorPrintInterval = 2000; // 센서 출력 간격 (2초)
+
+unsigned long holdStartTime = 0;
+bool holdConditionMet = false;
+
+// 유량계 관련 변수
+volatile int flow_frequency = 0; // 펄스 카운터
+unsigned long cloopTime = 0; // 마지막 유량 측정 시간
+float l_minute = 0.0, l_hour = 0.0, l_second = 0.0; // 유량 계산 변수
+float minFlow = 0.5; // 초기 값 설정. 필요에 따라 수정 가능
+
+// 상태 변수
+bool control_pumpState = false; // 펌프 플래그
+bool pumpIsOn = false;
+bool valveOpening = false;
+bool waterLevelHigh = false;
+unsigned long valveOpenStartTime = 0;
+unsigned long lastDebounceTime = 0;
+bool inCycle = false;
+bool autoModeAlerted = false; // 자동 모드에서 연속 메시지 방지
+
+// 전역 변수 선언
+bool delayAfterPumpOff = false; // 펌프 종료 후 대기 상태 플래그
+unsigned long pumpOffTime = 0; // 펌프가 꺼진 시각 저장
+const unsigned long pumpRestartDelay = 5000; // 펌프 재시작 지연 시간 (5초)
+int currentStep = 1; // 시퀀스의 시작 단계
+
+// 모드 상태 정의
+enum Mode { MANUAL, AUTO, AI };
+Mode currentMode = MANUAL;
+
+// 이전 핀 상태 추적 변수
+int lastManualPinState = LOW;
+int lastAutoPinState = LOW;
+int lastAiPinState = LOW;
+
+// 제어 명령 플래그
+//bool manualModeRequested = false;
+//bool autoModeRequested = false;
+bool aiModeRequested = false;
+
+// 모드 전환을 위한 타이머 및 디바운스 변수
+unsigned long lastModeChangeTime = 0;
+const unsigned long modeChangeDebounceDelay = 1000; // 모드 변경 디바운스 시간 (500ms)
+
+// 버튼을 일정 시간 동안 누를 때만 모드 전환
+unsigned long holdTime = 1000; // 모드 전환을 위한 홀드 시간
+unsigned long manualHoldStart = 0;
+unsigned long autoHoldStart = 0;
+unsigned long aiHoldStart = 0;
+
+// 펌프 시퀀스 상태
+enum PumpState { IDLE, OPENING_VALVE, PUMP_ON };
+PumpState pumpState = IDLE;
+
+//I2C 통신 송수신 설정
+const uint8_t SENSOR_DATA_HEADER = 0x01;
+const uint8_t CONFIG_DATA_HEADER = 0x02;
+
+#pragma pack(push, 1)
+struct SensorData {
+ uint8_t header = SENSOR_DATA_HEADER;
+ uint16_t ec;
+ uint16_t ph;
+ int16_t airTemperature;
+ int16_t airHumidity;
+ uint16_t ADC0;
+ uint16_t ADC1;
+ uint16_t ADC2;
+ int16_t soilTemp1; // 첫 번째 DS18B20 센서 온도
+ int16_t soilTemp2; // 두 번째 DS18B20 센서 온도
+ uint16_t l_minute;
+ uint8_t pumpPin;
+ uint8_t valvePin;
+ uint8_t waterLevelPin;
+ uint8_t rainSensor;
+ uint16_t crc;
+};
+#pragma pack(pop)
+SensorData sensorData;
+
+
+// ConfigData 구조체 정의
+#pragma pack(push, 1)
+struct ConfigData {
+ uint8_t header = CONFIG_DATA_HEADER; // 1 byte
+ uint8_t currentMode; // 1 byte (0: Manual, 1: Auto, 2: AI)
+ uint32_t cycleTime; // 4 bytes
+ uint32_t cycleRestartDelay; // 4 bytes
+ uint32_t valveDelay; // 4 bytes
+ uint32_t smStart; // 4 bytes
+ uint32_t smStop; // 4 bytes
+ //uint32_t holdDuration; // 4 bytes
+ uint16_t crc; // 2 bytes
+};
+#pragma pack(pop)
+
+ConfigData configData;
+
+
+uint16_t calculateCRC(uint8_t* data, size_t length) {
+ uint16_t crc = 0xFFFF;
+ for (size_t i = 0; i < length; i++) {
+ crc ^= data[i];
+ for (int j = 0; j < 8; j++) {
+ if (crc & 1)
+ crc = (crc >> 1) ^ 0xA001;
+ else
+ crc >>= 1;
+ }
+ }
+ return crc;
+}
+
+
+void updateSoilMoistureValues() {
+ for (int i = 0; i < 3; i++) {
+ int rawADC = analogRead(adcPins[i]);
+ // if (i == 2) {
+ // rawADC += 65; // ADC2에만 65 추가
+ // }
+ // LPF 적용
+ filtered_ADC[i] = alpha * rawADC + (1 - alpha) * filtered_ADC[i];
+ }
+
+ // 필터링된 ADC 값을 ADC0, ADC1, ADC2 변수에 할당
+ ADC0 = filtered_ADC[0];
+ ADC1 = filtered_ADC[1];
+ ADC2 = filtered_ADC[2];
+}
+// 슬레이브 ID 1의 데이터 읽기 함수
+void readSensorDataID1() {
+ uint8_t result = node1.readHoldingRegisters(0x00, 4);
+ if (result == node1.ku8MBSuccess) {
+ moisture = node1.getResponseBuffer(0);
+ soiltemperature = node1.getResponseBuffer(1) / 10.0;
+ ec = node1.getResponseBuffer(2);
+ ph = node1.getResponseBuffer(3) / 10.0;
+
+ Serial.println("ID 1 - Soil Sensor Data:");
+ Serial.print("Moisture: "); Serial.println(moisture);
+ Serial.print("Temperature: "); Serial.println(soiltemperature, 1);
+ Serial.print("EC: "); Serial.print(ec); Serial.println(" us/cm");
+ Serial.println("PH: "); Serial.println(ph, 2);
+ } else {
+ Serial.print("Error reading from sensor ID 1. Error code: ");
+ Serial.println(result);
+ }
+}
+
+// 슬레이브 ID 2의 데이터 읽기 함수
+void readSensorDataID2() {
+ uint8_t result = node2.readInputRegisters(0x0001, 2); // 온도와 습도 데이터 요청
+ if (result == node2.ku8MBSuccess) {
+ // Modbus에서 수신된 온도 값
+ int16_t rawTemperature = node2.getResponseBuffer(0); // 온도 값 (16비트 부호 있는 값)
+ int16_t rawHumidity = node2.getResponseBuffer(1); // 습도 값 (16비트 부호 없는 값)
+
+ // 온도 변환 (Modbus 데이터는 1/10 단위로 제공됨)
+ airtemperature = rawTemperature / 10.0;
+
+ // 습도 변환 (습도는 항상 양수)
+ airhumidity = rawHumidity / 10.0;
+
+ // 디버깅용 출력
+ Serial.print("ID 2 - Temperature and Humidity Data:");
+ Serial.print(" Temperature: ");
+ Serial.print(airtemperature);
+ Serial.print(" °C, Humidity: ");
+ Serial.print(airhumidity);
+ Serial.println(" %");
+ } else {
+ Serial.print("Error reading from sensor ID 2. Error code: ");
+ Serial.println(result);
+ }
+}
+
+// DS18B20 온도 센서 값 읽기
+void readDS18B20Data() {
+ sensors.requestTemperatures(); // 모든 센서로부터 온도 요청
+
+ // 첫 번째 센서의 온도 읽기
+ float temp1 = sensors.getTempCByIndex(0);
+ soilTemp[0] = (temp1 == DEVICE_DISCONNECTED_C) ? -127.0 : temp1; // 센서 연결 실패 시 -127.0 저장
+
+ // 두 번째 센서의 온도 읽기
+ float temp2 = sensors.getTempCByIndex(1);
+ soilTemp[1] = (temp2 == DEVICE_DISCONNECTED_C) ? -127.0 : temp2; // 센서 연결 실패 시 -127.0 저장
+
+ // 시리얼 출력 (디버깅용)
+ Serial.print("SoilTemp 1: ");
+ Serial.print(soilTemp[0]);
+ Serial.print(" °C, SoilTemp 2: ");
+ Serial.println(soilTemp[1]);
+}
+
+// 유량 센서의 펄스를 처리하는 인터럽트 핸들러
+void flow() {
+ flow_frequency++;
+}
+
+// 1초마다 유량을 계산하여 출력하는 함수
+void calculateFlowRate() {
+ unsigned long currentTime = millis();
+ if (currentTime - cloopTime >= 1000) { // 1초마다 유량 계산
+ cloopTime = currentTime; // 마지막 계산 시간 갱신
+
+ if (flow_frequency == 0) {
+ l_minute = 0.0;
+ l_hour = 0.0;
+ Serial.println("Flow frequency is zero. Setting flow rates to 0.");
+ } else {
+ // L/min 및 L/hour 계산
+ l_minute = (flow_frequency / 7.5); // L/min 단위로 변환 (유량계 사양에 따라 조정 필요)
+ l_hour = l_minute * 60; // L/hr 계산
+
+ // 출력
+ Serial.print("Flow rate: ");
+ Serial.print(l_minute);
+ Serial.print(" L/min, ");
+ Serial.print(l_hour);
+ Serial.println(" L/hour");
+ }
+
+ // 펄스 카운터 초기화
+ flow_frequency = 0;
+ }
+}
+
+void checkMode() {
+ unsigned long currentMillis = millis();
+
+ if (currentMillis - lastModeChangeTime < modeChangeDebounceDelay) {
+ return;
+ }
+
+ int manualPinState = digitalRead(manualModePin);
+ int autoPinState = digitalRead(autoModePin);
+ int aiPinState = digitalRead(aiModePin);
+
+ if (manualPinState == HIGH && lastManualPinState == LOW && currentMode != MANUAL) {
+ currentMode = MANUAL;
+ Serial.println("Switched to Manual Mode.");
+ lastModeChangeTime = currentMillis;
+ } else if (autoPinState == HIGH && lastAutoPinState == LOW && currentMode != AUTO) {
+ currentMode = AUTO;
+ Serial.println("Switched to Auto Mode.");
+ lastModeChangeTime = currentMillis;
+
+ // 확인용: 자동 모드 전환 시 cycleTime과 cycleRestartDelay 값 출력
+ Serial.print("Auto Mode - cycleTime: ");
+ Serial.println(cycleTime);
+ Serial.print("Auto Mode - cycleRestartDelay: ");
+ Serial.println(cycleRestartDelay);
+
+ // ConfigData의 값도 출력
+ Serial.print("ConfigData cycleTime: ");
+ Serial.println(configData.cycleTime);
+ Serial.print("ConfigData cycleRestartDelay: ");
+ Serial.println(configData.cycleRestartDelay);
+ } else if (aiPinState == HIGH && lastAiPinState == LOW && currentMode != AI) {
+ currentMode = AI;
+ Serial.println("Switched to AI Mode.");
+ lastModeChangeTime = currentMillis;
+ }
+
+ lastManualPinState = manualPinState;
+ lastAutoPinState = autoPinState;
+ lastAiPinState = aiPinState;
+}
+
+
+void checkWaterLevel() {
+ if (digitalRead(waterLevelPin) == HIGH) { // 높은 수위일 때
+ if (!waterLevelHigh) { // 처음 감지된 경우에만 실행
+ Serial.println("Water level is high, stopping pump and returning to idle mode.");
+ stopPumpSequence();
+ waterLevelHigh = true;
+ }
+ } else {
+ waterLevelHigh = false;
+ }
+}
+
+// checkPumpControl 함수에서 수정
+void checkPumpControl() {
+ unsigned long currentTime = millis();
+ int smCountHigh = 0;
+ int smCountLow = 0;
+
+ // waterLevelHigh가 true일 경우 제어 상태를 종료합니다
+ if (waterLevelHigh) {
+ control_pumpState = false;
+ return;
+ }
+
+ if (currentMode == MANUAL) {
+ // 수동 모드에서 control_PumpOn 핀이 HIGH일 때 펌프 작동
+ if (digitalRead(control_PumpOn) == HIGH && !control_pumpState) {
+ Serial.println("Manual mode: Turning on the pump");
+ control_pumpState = true;
+ } else if (digitalRead(control_PumpOff) == HIGH && control_pumpState) {
+ Serial.println("Manual mode: Turning off the pump");
+ control_pumpState = false;
+ stopPumpSequence();
+ }
+ } else if (currentMode == AUTO) {
+ // ADC0, ADC1, ADC2 변수를 사용해 조건 체크
+ if (ADC0 > smStart) smCountHigh++;
+ if (ADC1 > smStart) smCountHigh++;
+ if (ADC2 > smStart) smCountHigh++;
+
+ if (ADC0 < smStop) smCountLow++;
+ if (ADC1 < smStop) smCountLow++;
+ if (ADC2 < smStop) smCountLow++;
+
+ if (smCountHigh >= 2 && !control_pumpState) {
+ if (!holdConditionMet) {
+ holdStartTime = currentTime;
+ holdConditionMet = true;
+ }
+ if (currentTime - holdStartTime >= holdDuration) {
+ control_pumpState = true;
+ pumpStartTime = currentTime; // cycleTime 체크 시작
+ }
+ } else if (smCountLow >= 2 || digitalRead(rainSensor) == HIGH) {
+ if (!holdConditionMet) {
+ holdStartTime = currentTime;
+ holdConditionMet = true;
+ }
+ if (currentTime - holdStartTime >= holdDuration && control_pumpState) {
+ control_pumpState = false;
+ stopPumpSequence();
+ restartDelayStartTime = currentTime; // cycleRestartDelay 체크 시작
+ }
+ } else {
+ holdConditionMet = false;
+ holdStartTime = 0;
+ }
+ }
+}
+
+void managePumpSequence() {
+ unsigned long currentTime = millis();
+
+ // 물이 높은 경우 펌프를 즉시 중지하고 리턴
+ if (digitalRead(waterLevelPin) == HIGH) {
+ stopPumpSequence();
+ return;
+ }
+
+ // 펌프 초기화 후 빠르게 재시작하지 않도록 지연
+ if (delayAfterPumpOff && (currentTime - pumpOffTime < valveDelay)) {
+ return;
+ } else {
+ delayAfterPumpOff = false;
+ }
+
+ // 자동 모드에서 강우 센서가 HIGH인 경우 펌프를 중지
+ if (currentMode == AUTO && digitalRead(rainSensor) == HIGH) {
+ stopPumpSequence();
+ return;
+ }
+
+ // 현재 모드에 따른 동작 처리
+ switch (currentMode) {
+ case MANUAL:
+ handleManualMode();
+ break;
+ case AUTO:
+ handleAutoMode();
+ break;
+ case AI:
+ handleAIMode();
+ break;
+ }
+}
+
+void handleManualMode() {
+ // 수동 모드에서 펌프 ON 명령이 들어왔을 때
+ if (digitalRead(control_PumpOn) == HIGH && !pumpIsOn && !valveOpening) {
+ Serial.println("Manual mode: Opening valve and waiting for delay");
+ digitalWrite(valvePin, HIGH); // 밸브를 먼저 열기
+ valveOpening = true; // 밸브 열림 상태 플래그 설정
+ valveOpenStartTime = millis(); // 밸브 열림 시작 시간 기록
+ }
+
+ // 밸브가 열리고 대기 시간이 지난 후에 펌프를 켭니다
+ if (valveOpening && (millis() - valveOpenStartTime >= valveDelay) && digitalRead(valvePin) == HIGH && !pumpIsOn) {
+ Serial.println("Manual mode: Valve delay complete, turning on pump");
+ digitalWrite(pumpPin, HIGH); // 펌프 켜기
+ pumpIsOn = true; // 펌프 상태 플래그 설정
+ valveOpening = false; // 밸브 상태 초기화
+ }
+
+ // 수동 모드에서 펌프 OFF 명령이 들어왔을 때
+ if (digitalRead(control_PumpOff) == HIGH && pumpIsOn) {
+ Serial.println("Manual mode: Turning off the pump and closing valve");
+ stopPumpSequence(); // 펌프와 밸브 즉시 종료
+ }
+}
+
+void handleAutoMode() {
+ unsigned long currentMillis = millis();
+
+ // 특정 조건이 충족되면 시퀀스를 중단
+ if (checkExitConditions()) {
+ Serial.println("Exit condition met, stopping sequence.");
+ stopPumpSequence(); // 시퀀스를 종료하고 초기 상태로 복귀
+ currentStep = 1; // 초기 단계로 복귀하여 시퀀스 재시작 준비
+ return;
+ }
+
+ switch (currentStep) {
+ case 1:
+ Serial.println("Step 1: Opening valve");
+ digitalWrite(valvePin, HIGH); // 밸브 열기
+ valveOpenStartTime = currentMillis; // 밸브 열림 시작 시간 기록
+ currentStep = 2; // 다음 단계로 이동
+ break;
+
+ case 2:
+ if (currentMillis - valveOpenStartTime >= valveDelay) {
+ Serial.println("Step 2: Valve Delay complete, turning on pump");
+ digitalWrite(pumpPin, HIGH); // 펌프 켜기
+ pumpStartTime = currentMillis; // 펌프 시작 시간 기록
+ pumpIsOn = true; // 펌프 상태 설정
+ currentStep = 3; // 다음 단계로 이동
+ }
+ break;
+
+ case 3:
+ if (currentMillis - pumpStartTime >= cycleTime) {
+ Serial.println("Step 3: cycleTime complete, turning off pump");
+ digitalWrite(pumpPin, LOW); // 펌프 끄기
+ pumpIsOn = false; // 펌프 상태 업데이트
+ currentStep = 4; // 다음 단계로 이동
+ }
+ break;
+
+ case 4:
+ Serial.println("Step 4: Closing valve");
+ digitalWrite(valvePin, LOW); // 밸브 닫기
+ valveOpenStartTime = 0;
+ restartDelayStartTime = currentMillis; // 재시작 대기 시간 기록
+ currentStep = 5; // 다음 단계로 이동
+ break;
+
+ case 5:
+ if (currentMillis - restartDelayStartTime >= cycleRestartDelay) {
+ Serial.println("Step 5: cycleRestartDelay complete, ready to start new sequence");
+ currentStep = 1; // 시퀀스를 처음 단계로 복귀
+ }
+ break;
+ }
+}
+
+// checkExitConditions 함수에서 수정
+bool checkExitConditions() {
+ int smStopCount = 0;
+
+ // ADC0, ADC1, ADC2 변수를 사용하여 smStop 조건 확인
+ if (ADC0 < smStop) smStopCount++;
+ if (ADC1 < smStop) smStopCount++;
+ if (ADC2 < smStop) smStopCount++;
+
+ bool waterLevelHigh = digitalRead(waterLevelPin) == HIGH;
+ bool rainDetected = digitalRead(rainSensor) == HIGH;
+
+ return (smStopCount >= 2 || waterLevelHigh || rainDetected);
+}
+
+void handleAIMode() {
+ unsigned long currentMillis = millis();
+
+ // AI 모드에서 펌프를 켜야 하는 경우
+ if (aiModeRequested && !pumpIsOn) {
+ // 밸브가 열리는 상태가 아니라면 밸브 열기
+ if (!valveOpening) {
+ Serial.println("AI Mode: Opening valve and starting delay.");
+ digitalWrite(valvePin, HIGH); // 밸브 열기
+ valveOpening = true; // 밸브 열림 상태 플래그 설정
+ valveOpenStartTime = currentMillis; // 밸브 열림 시간 기록
+ }
+ // 밸브가 열린 후 지연 시간이 지나면 펌프를 켜기
+ else if (currentMillis - valveOpenStartTime >= valveDelay) {
+ Serial.println("AI Mode: Turning on the pump after valve delay.");
+ digitalWrite(pumpPin, HIGH); // 펌프 켜기
+ pumpIsOn = true; // 펌프 상태 업데이트
+ valveOpening = false; // 밸브 열림 상태 플래그 해제
+ valveOpenStartTime = 0; // 밸브 지연 시간 초기화
+ }
+ }
+ // AI 모드가 중단되면 (제어 명령 2,0)
+ else if (!aiModeRequested && (pumpIsOn || valveOpening)) {
+ Serial.println("AI Mode: Turning off the pump and closing valve.");
+ stopPumpSequence(); // 펌프와 밸브를 즉시 중지
+ }
+}
+
+void handleControlCommand(int command, int value) {
+ if (command == 1) { // 모드 제어 명령
+ if (value == 0) {
+ currentMode = MANUAL;
+ Serial.println("Control Command: Manual Mode activated.");
+ } else if (value == 1) {
+ currentMode = AUTO;
+ Serial.println("Control Command: Auto Mode activated.");
+ } else if (value == 2) {
+ currentMode = AI;
+ Serial.println("Control Command: AI Mode activated.");
+ }
+ stopPumpSequence(); // 모드 전환 시 펌프 초기화
+ } else if (command == 2) { // 펌프 제어 명령
+ if (value == 0) {
+ aiModeRequested = false;
+ stopPumpSequence();
+ Serial.println("Control Command: Pump off.");
+ } else if (value == 1) {
+ aiModeRequested = true;
+ if (currentMode == AI) {
+ handleAIMode();
+ } else if (currentMode == AUTO) {
+ handleAutoMode();
+ }
+ }
+ }
+}
+
+void stopPumpSequence() {
+ Serial.println("Stopping pump sequence. Turning off pump and closing valve.");
+ digitalWrite(pumpPin, LOW); // 펌프 끄기
+ digitalWrite(valvePin, LOW); // 밸브 닫기
+ pumpIsOn = false; // 펌프 상태 플래그 설정
+ valveOpening = false; // 밸브 열림 상태 플래그 해제
+ aiModeRequested = false; // AI 모드 요청 플래그 해제
+ control_pumpState = false; // 제어 플래그 해제
+ valveOpenStartTime = 0; // 밸브 열림 시간 초기화
+ currentStep = 1; // 시퀀스를 초기 단계로 복귀
+}
+
+void sendSensorDataToMaster() {
+ // SensorData 업데이트
+ updateSoilMoistureValues(); // 먼저 토양 수분 값 업데이트
+
+ sensorData.ec = ec; // EC 값
+ sensorData.ph = ph * 100; // PH 값 (곱하기 100을 통해 정밀도 조정)
+ sensorData.airTemperature = airtemperature * 100; // 대기 온도 값 (곱하기 100을 통해 정밀도 조정)
+ sensorData.airHumidity = airhumidity * 100; // 대기 습도 값 (곱하기 100을 통해 정밀도 조정)
+ sensorData.ADC0 = ADC0; // 업데이트된 토양 수분 센서 값 (ADC0)
+ sensorData.ADC1 = ADC1; // 업데이트된 토양 수분 센서 값 (ADC1)
+ sensorData.ADC2 = ADC2; // 업데이트된 토양 수분 센서 값 (ADC2, 85 추가 적용)
+ sensorData.soilTemp1 = soilTemp[0] * 100; // 첫 번째 DS18B20 센서 값 (곱하기 100)
+ sensorData.soilTemp2 = soilTemp[1] * 100; // 두 번째 DS18B20 센서 값 (곱하기 100)
+ sensorData.l_minute = l_minute; // 유량 센서 값 (L/min 단위)
+ sensorData.pumpPin = digitalRead(pumpPin); // 펌프 핀의 현재 상태 (HIGH 또는 LOW)
+ sensorData.valvePin = digitalRead(valvePin); // 밸브 핀의 현재 상태 (HIGH 또는 LOW)
+ sensorData.waterLevelPin = digitalRead(waterLevelPin); // 수위 센서 핀의 상태 (HIGH 또는 LOW)
+ sensorData.rainSensor = digitalRead(rainSensor); // 강우 센서 핀의 상태 (HIGH 또는 LOW)
+
+ /// CRC 계산
+ sensorData.crc = calculateCRC((uint8_t*)&sensorData, sizeof(SensorData) - sizeof(sensorData.crc));
+
+ // 데이터 송신
+ Wire.write((uint8_t*)&sensorData, sizeof(SensorData));
+}
+
+
+void sendConfigDataToMaster() {
+ // currentMode 업데이트
+ /*
+ // configData 구조체의 크기를 출력하여 확인
+ Serial.print("Size of ConfigData structure: ");
+ Serial.println(sizeof(ConfigData));
+
+ Serial.print( "Current Mode: " );
+ Serial.println( currentMode );
+ */
+ configData.currentMode = currentMode == MANUAL ? 0 : (currentMode == AUTO ? 1 : 2);
+
+ // 시간 관련 설정 값들을 초 단위로 변환하여 업데이트
+
+ configData.cycleTime = cycleTime;
+ configData.cycleRestartDelay = cycleRestartDelay;
+ configData.valveDelay = valveDelay;
+
+ // 토양 수분 설정 값들 업데이트
+ configData.smStart = smStart;
+ configData.smStop = smStop;
+ //configData.holdDuration = holdDuration / 1000;
+
+ // CRC 계산
+ configData.crc = calculateCRC((uint8_t*)&configData, sizeof(ConfigData) - sizeof(configData.crc));
+/*
+ // 디버깅: 전송 전에 ConfigData 내용을 시리얼로 출력 (선택사항)
+ Serial.println("Sending ConfigData to Master:");
+ Serial.print("Current Mode: "); Serial.println(configData.currentMode);
+ Serial.print("Cycle Time (s): "); Serial.println(configData.cycleTime);
+ Serial.print("Cycle Restart Delay (s): "); Serial.println(configData.cycleRestartDelay);
+ Serial.print("Valve Delay (s): "); Serial.println(configData.valveDelay);
+ Serial.print("Soil Moisture Start Threshold: "); Serial.println(configData.smStart);
+ Serial.print("Soil Moisture Stop Threshold: "); Serial.println(configData.smStop);
+ //Serial.print("Hold Duration (s): "); Serial.println(configData.holdDuration);
+ Serial.print("CRC: "); Serial.println(configData.crc, HEX);
+*/
+ // I2C를 통해 ConfigData 전송
+ Wire.write((uint8_t*)&configData, sizeof(ConfigData));
+}
+
+void onRequestEvent() {
+ unsigned long currentMillis = millis();
+
+ // 데이터 전송 간격이 충분히 지났는지 확인
+ if ((currentMillis - lastSendTime >= sendInterval) || lastSendTime == 0) {
+ if (sendSensorNext) {
+ sendSensorDataToMaster();
+ sendSensorNext = false;
+ } else {
+ sendConfigDataToMaster();
+ sendSensorNext = true;
+ }
+ lastSendTime = currentMillis; // 전송 후 시간 갱신
+ } else {
+ Serial.println("Not enough time has passed to send data.");
+ }
+}
+
+// receiveData 함수에서 업데이트
+// receiveData 함수에서 업데이트
+void receiveData(int numBytes) {
+ int settingID = Wire.read();
+ int lowerByte = Wire.read();
+ int upperByte = Wire.read();
+ uint32_t settingValue = (upperByte << 8) | lowerByte;
+
+ Serial.print("Received setting ID: ");
+ Serial.println(settingID);
+ Serial.print("Setting Value: ");
+ Serial.println(settingValue);
+
+ switch (settingID) {
+ case 1: // currentMode 설정 0 == 수동, 1 == 자동, 2 == AI
+ if (settingValue == 0) {
+ currentMode = MANUAL;
+ Serial.println("Switched to Manual Mode.");
+ } else if (settingValue == 1) {
+ currentMode = AUTO;
+ Serial.println("Switched to Auto Mode.");
+ } else if (settingValue == 2) {
+ currentMode = AI;
+ Serial.println("Switched to AI Mode.");
+ } else {
+ Serial.println("Invalid mode value received for setting ID 1.");
+ }
+ stopPumpSequence(); // 모드 전환 시 펌프 초기화
+ break;
+
+ case 2: // AI 모드에서 펌프 제어
+ if (currentMode == AI) {
+ aiModeRequested = (settingValue == 1);
+ Serial.print("AI Mode: Received command to turn ");
+ Serial.println(aiModeRequested ? "ON" : "OFF");
+ handleAIMode();
+ } else {
+ Serial.println("Pump control command received, but system is not in AI mode.");
+ }
+ break;
+
+ case 3: // cycleTime 설정 (펌프 ON 지속시간)
+ {
+ unsigned long calculatedCycleTime = settingValue * 1000;
+ cycleTime = (calculatedCycleTime > 86400000UL) ? 86400000UL : calculatedCycleTime;
+ configData.cycleTime = cycleTime;
+
+ Serial.print("Updated cycleTime: ");
+ Serial.println(cycleTime);
+ }
+ break;
+
+ case 4: // cycleRestartDelay 설정 (펌프 OFF 지속시간)
+ {
+ unsigned long calculatedRestartDelay = settingValue * 1000UL; // 밀리초 단위 변환
+ cycleRestartDelay = (calculatedRestartDelay > 86400000UL) ? 86400000UL : calculatedRestartDelay;
+ configData.cycleRestartDelay = cycleRestartDelay;
+
+ Serial.print("Updated cycleRestartDelay: ");
+ Serial.println(cycleRestartDelay);
+ }
+ break;
+
+ case 5: // valveDelay 설정
+ {
+ unsigned long calculatedDelay = settingValue * 1000;
+ valveDelay = (calculatedDelay > 86400000UL) ? 86400000UL : calculatedDelay;
+ configData.valveDelay = valveDelay;
+
+ Serial.print("Updated valveDelay: ");
+ Serial.println(valveDelay);
+ }
+ break;
+
+ case 6: // smStart 설정
+ smStart = (settingValue > 1023) ? 1023 : settingValue;
+ configData.smStart = smStart;
+
+ Serial.print("Updated smStart: ");
+ Serial.println(smStart);
+ break;
+
+ case 7: // smStop 설정
+ smStop = (settingValue > 1023) ? 1023 : settingValue;
+ configData.smStop = smStop;
+
+ Serial.print("Updated smStop: ");
+ Serial.println(smStop);
+ break;
+
+ case 8: // holdDuration 설정
+ {
+ unsigned long calculatedHoldDuration = settingValue * 1000;
+ holdDuration = (calculatedHoldDuration > 86400000UL) ? 86400000UL : calculatedHoldDuration;
+
+ Serial.print("Updated holdDuration: ");
+ Serial.println(holdDuration);
+ }
+ break;
+
+ default:
+ Serial.println("Unknown setting ID received");
+ break;
+ }
+
+ // CRC 업데이트 후 설정 데이터 전송
+ configData.crc = calculateCRC((uint8_t*)&configData, sizeof(ConfigData) - sizeof(configData.crc));
+ //Serial.print("Updated CRC: ");
+ //Serial.println(configData.crc, HEX);
+
+ // ConfigData를 마스터로 전송
+ sendConfigDataToMaster();
+}
+
+
+
+void printSensorData() {
+ unsigned long currentTime = millis();
+
+ // 2초 간격으로 센서 및 플래그 상태를 출력
+ if (currentTime - lastSensorPrintTime >= sensorPrintInterval) {
+ lastSensorPrintTime = currentTime;
+
+ // 토양 수분 센서 값 출력
+ Serial.print("Soil Moisture Sensor Values: ");
+ Serial.print("ADC0: "); Serial.print(ADC0);
+ Serial.print(", ADC1: "); Serial.print(ADC1);
+ Serial.print(", ADC2: "); Serial.println(ADC2);
+ Serial.println();
+
+ Serial.print("SoilTemp 1: ");
+ Serial.print(soilTemp[0]);
+ Serial.print(", ");
+
+ Serial.print("SoilTemp 2: ");
+ Serial.println(soilTemp[1]);
+
+ // water level, rain sensor 상태 출력
+ Serial.print("Water Level: ");
+ Serial.print(digitalRead(waterLevelPin) == HIGH ? "High" : "Low");
+ Serial.print(", Rain Sensor: ");
+ Serial.println(digitalRead(rainSensor) == HIGH ? "Detected" : "Not Detected");
+
+ // 유량 정보 출력
+ calculateFlowRate(); // 1초마다 유량 계산하여 출력
+
+ // 각 플래그 및 상태 변수 출력
+ Serial.print("Current Mode: ");
+ switch (currentMode) {
+ case MANUAL:
+ Serial.println("Manual");
+ break;
+ case AUTO:
+ Serial.println("Auto");
+ break;
+ case AI:
+ Serial.println("AI");
+ break;
+ }
+ Serial.print(",Pump: ");
+ Serial.print(pumpIsOn ? "ON" : "OFF");
+ Serial.print(",Valve: ");
+ Serial.print(valveOpening ? "Opening" : "Closed");
+ Serial.print(",ControlPump: ");
+ Serial.print(control_pumpState ? "ON" : "OFF");
+ Serial.print(",Water High: ");
+ Serial.print(waterLevelHigh ? "True" : "False");
+ Serial.print(",In Cycle: ");
+ Serial.print(inCycle ? "True" : "False");
+ Serial.print(",AutoModeAlerted: ");
+ Serial.println(autoModeAlerted ? "True" : "False");
+
+ Serial.println("-------------------------------------------------");
+ }
+}
+
+
+void setup() {
+ Serial.begin(115200);
+ Wire.begin(8);
+ Wire.setClock(100000); // I2C 속도를 400kHz로 설정 (고속 모드) 예를 들어, 100000은 100kHz (기본), 400000은 400kHz (고속)
+ Serial2.begin(9600); // RS485 통신 포트 초기화
+ node1.begin(1, Serial2); // RS485 슬레이브 ID 1
+ node2.begin(2, Serial2); // RS485 슬레이브 ID 2
+ Wire.onRequest(onRequestEvent); // 요청 시 이벤트 처리
+ Wire.onReceive(receiveData);
+
+ sensors.begin(); // DS18B20 센서 초기화
+
+ pinMode(pumpPin, OUTPUT);
+ pinMode(valvePin, OUTPUT);
+ pinMode(control_PumpOn, INPUT);
+ pinMode(control_PumpOff, INPUT);
+ pinMode(waterLevelPin, INPUT);
+ pinMode(rainSensor, INPUT);
+ pinMode(flowSensorPin, INPUT);
+
+ pinMode(manualModePin, INPUT);
+ pinMode(autoModePin, INPUT);
+ pinMode(aiModePin, INPUT);
+
+ digitalWrite(flowSensorPin, HIGH); // 내장 풀업 설정
+ attachInterrupt(digitalPinToInterrupt(flowSensorPin), flow, RISING); // 유량 센서 인터럽트 설정
+
+ analogReference(EXTERNAL);
+
+ digitalWrite(pumpPin, LOW); // 초기 펌프 OFF
+ digitalWrite(valvePin, LOW); // 초기 밸브 CLOSE
+
+ // lastSendTime을 현재 시간에서 초기화하여 첫 전송이 즉시 발생하게 함
+ lastSendTime = millis() - sendInterval; // sendInterval 간격을 맞춰 첫 전송 가능
+ // 초기 설정 값 출력
+ Serial.println("System Initialization:");
+ Serial.print("Initial cycleTime: ");
+ Serial.println(cycleTime);
+ Serial.print("Initial cycleRestartDelay: ");
+ Serial.println(cycleRestartDelay);
+
+ Serial.println("System initialized in Manual mode.");
+}
+
+void loop() {
+ unsigned long currentMillis = millis();
+
+ // 주기적으로 센서 데이터를 읽습니다
+ if (currentMillis - lastRequestTime >= requestInterval) {
+ lastRequestTime = currentMillis;
+
+ // ID 1 데이터 읽기
+ readSensorDataID1();
+
+ // ID 1 요청 후 짧은 대기 후 ID 2 데이터 읽기
+ delay(50); // 50ms 대기 (필요한 최소한의 대기 시간)
+ readSensorDataID2();
+ }
+ updateSoilMoistureValues(); // 토양 수분 값 업데이트
+ checkMode(); // 모드 확인 및 전환
+ checkWaterLevel(); // 워터 레벨 상태 확인
+ checkPumpControl(); // 펌프 제어 상태 확인
+ managePumpSequence(); // 펌프 시퀀스 관리
+ readDS18B20Data(); // 온도 센서 데이터 읽기
+ calculateFlowRate();
+ //printSensorData(); // 센서 및 상태 출력
+}
diff --git a/src/main.cpp b/src/main.cpp
index cb9fbba..0fa41db 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,18 +1,992 @@
-#include
+// ===== ESP32 Master (I2C → MQTT Bridge, Ultra-Robust WiFi/MQTT, DHCP) =====
+// - 동적 IP(DHCP) 유지
+// - I2C→MQTT 브릿지 + 시리얼 CLI (HELP)
+// - NVS 저장/복구
+// - Wi-Fi 강화:
+// * deauth(3) 즉시 재접속: 이벤트 핸들러에선 "즉시 연결"을 호출하지 않고 스케줄만 표시
+// * 연결은 ensureWiFi() 단일 경로에서만 수행 → 중복 호출/크래시 방지
+// * BSSID/채널 스티키, FAST_SCAN, PMF capable
+// * 상태머신/타임아웃/플랩 완화/오프라인 워치독/라디오 하드리셋
+// * esp_wifi_connect() 에러는 로그만(절대 abort 안함)
+//
+// Arduino-ESP32 2.x/3.x
+// Libs: PubSubClient, ArduinoJson, Preferences
-// put function declarations here:
-int myFunction(int, int);
+#include
+#include
+#include
+#include
+#include
+#include
+extern "C" {
+ #include "esp_wifi.h"
+ #include "esp_err.h"
+}
+
+// ---------- 작은 유틸 ----------
+static inline unsigned long uminul(unsigned long a, unsigned long b){ return (ab)?a:b; }
+static inline int imax(int a,int b){ return (a>b)?a:b; }
+
+// ===================== I2C =====================
+static const uint8_t I2C_ADDR = 0x08;
+static const int I2C_SDA = 21;
+static const int I2C_SCL = 22;
+static const uint32_t I2C_FREQ = 100000;
+
+const uint8_t SENSOR_DATA_HEADER = 0x01;
+const uint8_t CONFIG_DATA_HEADER = 0x02;
+
+struct __attribute__((packed)) SensorData {
+ uint8_t header;
+ uint16_t ec;
+ uint16_t ph;
+ int16_t airTemperature; // x100
+ int16_t airHumidity; // x100
+ uint16_t ADC0;
+ uint16_t ADC1;
+ uint16_t ADC2;
+ int16_t soilTemp1; // x100
+ int16_t soilTemp2; // x100
+ uint16_t l_minute;
+ uint8_t pumpPin;
+ uint8_t valvePin;
+ uint8_t waterLevelPin;
+ uint8_t rainSensor;
+ uint16_t crc;
+};
+struct __attribute__((packed)) ConfigData {
+ uint8_t header;
+ uint8_t currentMode;
+ uint32_t cycleTime;
+ uint32_t cycleRestartDelay;
+ uint32_t valveDelay;
+ uint32_t smStart;
+ uint32_t smStop;
+ uint16_t crc;
+};
+SensorData sensorData;
+ConfigData configData;
+
+// CRC16 (Modbus A001)
+static uint16_t crc16_modbus(const uint8_t* data, size_t len) {
+ uint16_t crc = 0xFFFF;
+ for (size_t i=0;i> 1) ^ 0xA001 : (crc >> 1);
+ }
+ return crc;
+}
+static inline uint16_t calculateCRC(const uint8_t* data, size_t n) { return crc16_modbus(data,n); }
+
+// ===================== 네트워킹/MQTT =====================
+WiFiClient espClient;
+PubSubClient client(espClient);
+
+volatile bool wifiReady = false;
+volatile bool mqttReady = false;
+
+unsigned long lastWifiAttemptMs = 0, wifiBackoffMs = 1000;
+unsigned long lastMqttAttemptMs = 0, mqttBackoffMs = 2000;
+const unsigned long WIFI_BACKOFF_MAX = 8000;
+const unsigned long MQTT_BACKOFF_MAX = 60000;
+
+// ======== Wi-Fi 보강 파라미터 ========
+const int AP_RETRY_PER = 3; // 동일 AP 연속 재시도
+const int AP_RETRY_BOOST_ON_DEAUTH = 6; // reason=3 시 같은 AP 추가 부스팅
+const unsigned long CONNECT_TIMEOUT_MS = 10000; // 연결 타임아웃
+const unsigned long MIN_UPTIME_MS = 15000; // GOT_IP 후 이 시간 내 끊기면 플랩
+const unsigned long OFFLINE_HARD_RESET_MS = 60000; // 오프라인 오래 지속 시 라디오 리셋
+const unsigned long OFFLINE_REBOOT_MS = 0; // (0=OFF) 너무 오래 끊기면 MCU 재부팅
+const bool STICKY_BSSID_ENABLED = true; // 스티키 BSSID/채널
+
+// ======== MQTT 보강 파라미터 ========
+const uint16_t MQTT_KEEPALIVE_SEC = 20;
+const uint8_t MQTT_SOCKET_TIMEOUT_SEC = 5;
+
+// ====== 순차 AP 접속 상태 ======
+int currentApIdx = 0;
+int apRetryLeft = AP_RETRY_PER;
+int sameApBoostTries = 0;
+
+// ====== 연결 상태머신/타이머/플래그 ======
+enum WifiConnState { WIFI_IDLE, WIFI_CONNECTING, WIFI_ONLINE };
+volatile WifiConnState wifiState = WIFI_IDLE;
+
+unsigned long connectStartMs = 0;
+unsigned long lastGotIpMs = 0;
+unsigned long offlineSinceMs = 0;
+unsigned long lastHardResetMs = 0;
+
+// “즉시 재접속” 스케줄 플래그(이벤트→루프)
+volatile bool forceImmediateConnect = false;
+volatile bool preferCachedNext = false;
+
+// ====== 마지막 연결 AP 기록(스캔 생략 직행용) ======
+wifi_ap_record_t lastApRec;
+bool hasLastApRec = false;
+
+// ===================== CONFIG 모드 =====================
+bool configMode = false;
+
+// ===================== 폴링 주기/간격 =====================
+unsigned long pollIntervalMs = 1000; // REQTIME
+unsigned long i2cGapMs = 80; // I2CGAP
+unsigned long nextPollMs = 0;
+unsigned long cycleStartMs = 0;
+uint8_t i2cPhase = 0;
+
+// I2C 수신 성공 감시
+unsigned long lastSensorOkMs = 0;
+unsigned long i2cAliveTimeoutMs = 0; // 0=STRICT
+volatile bool i2cFreshOk = false;
+volatile uint8_t i2cCycleAttempts = 0;
+volatile uint8_t i2cCycleOkCount = 0;
+volatile bool i2cCycleSensorOK = false;
+volatile bool i2cCycleConfigOK = false;
+
+// ===================== LED =====================
+static const int LED_PIN_DEFAULT = 2;
+static const bool LED_ACTIVE_HIGH_DEFAULT= true;
+static const uint16_t LED_BLINK_MS_DEFAULT = 60;
+
+int ledPin = LED_PIN_DEFAULT;
+bool ledActiveHigh = LED_ACTIVE_HIGH_DEFAULT;
+bool ledEnable = true;
+uint16_t ledBlinkMs = LED_BLINK_MS_DEFAULT;
+unsigned long ledBlinkUntilMs = 0;
+
+// ===================== 저장 구조 (Preferences) =====================
+Preferences prefs;
+#define PNS "cfg"
+#define MAX_APS 6
+
+struct APEntry { char ssid[32]; char pass[64]; };
+struct StoredConfig {
+ uint8_t numAPs;
+ APEntry aps[MAX_APS];
+
+ char mqttHost[64];
+ uint16_t mqttPort;
+ char mqttUser[32];
+ char mqttPass[64];
+ char baseTopic[64];
+
+ char clientIdSuffix[32];
+
+ uint8_t reqIdStart;
+ uint8_t reqIdEnd;
+ uint32_t reqTimeMs;
+
+ // LED
+ int16_t ledPin;
+ uint16_t ledBlinkMs;
+ uint8_t ledActiveHigh;
+ uint8_t ledEnable;
+
+ // I2C
+ uint32_t i2cGapMs;
+ uint32_t i2cAliveTimeoutMs;
+
+ uint16_t version; // 0x0008
+ uint16_t crc;
+};
+
+static StoredConfig gCfg;
+
+// ===================== 토픽 =====================
+static char topicStatus[96], topicConfig[96], topicCommand[96],
+ topicSpeed[96], topicSpeedResp[96], topicLWT[96];
+
+static void makeTopic(char* dst, size_t n, const char* base, const char* tail) {
+ String b(base); while (b.endsWith("/")) b.remove(b.length()-1);
+ String t = b + "/" + tail;
+ strncpy(dst, t.c_str(), n-1); dst[n-1]=0;
+}
+static void rebuildTopicsFromBase() {
+ makeTopic(topicStatus, sizeof(topicStatus), gCfg.baseTopic, "status");
+ makeTopic(topicConfig, sizeof(topicConfig), gCfg.baseTopic, "config");
+ makeTopic(topicCommand, sizeof(topicCommand), gCfg.baseTopic, "command");
+ makeTopic(topicSpeed, sizeof(topicSpeed), gCfg.baseTopic, "speed");
+ makeTopic(topicSpeedResp, sizeof(topicSpeedResp), gCfg.baseTopic, "speed/response");
+ makeTopic(topicLWT, sizeof(topicLWT), gCfg.baseTopic, "bridge");
+}
+
+// ===================== LED 제어 =====================
+static inline void ledApply(bool on) {
+ if (!ledEnable || ledPin < 0) return;
+ digitalWrite(ledPin, (on ^ (!ledActiveHigh)) ? HIGH : LOW);
+}
+static void ledUpdate() {
+ if (!ledEnable || ledPin < 0) return;
+ bool base = wifiReady;
+ if (wifiReady && (millis() < ledBlinkUntilMs)) base = !base;
+ ledApply(base);
+}
+static void ledFlash() {
+ if (!ledEnable || ledPin < 0) return;
+ ledBlinkUntilMs = millis() + ledBlinkMs;
+ ledUpdate();
+}
+
+// ===================== 기본값/로드/저장 =====================
+static void cfgDefaults() {
+ memset(&gCfg, 0, sizeof(gCfg));
+ gCfg.version = 0x0008;
+ gCfg.numAPs = 0;
+ strncpy(gCfg.mqttHost, "selimcns.synology.me", sizeof(gCfg.mqttHost)-1);
+ gCfg.mqttPort = 1883;
+ strncpy(gCfg.baseTopic, "selimcns", sizeof(gCfg.baseTopic)-1);
+ gCfg.reqIdStart = 1; gCfg.reqIdEnd = 10; gCfg.reqTimeMs = 1000;
+ gCfg.ledPin = LED_PIN_DEFAULT; gCfg.ledBlinkMs = LED_BLINK_MS_DEFAULT;
+ gCfg.ledActiveHigh = LED_ACTIVE_HIGH_DEFAULT ? 1 : 0; gCfg.ledEnable = 1;
+ gCfg.i2cGapMs = 80; gCfg.i2cAliveTimeoutMs = 0;
+ gCfg.crc = crc16_modbus((uint8_t*)&gCfg, sizeof(gCfg)-sizeof(gCfg.crc));
+}
+static bool cfgLoad() {
+ if (!prefs.begin(PNS, false)) { Serial.println("NVS: prefs.begin() FAIL"); return false; }
+ Serial.println("NVS: prefs.begin() OK");
+ StoredConfig tmp; size_t need=sizeof(tmp);
+ size_t got = prefs.getBytes("blob",&tmp,need);
+ Serial.printf("cfgLoad(): got %u / %u bytes\n", (unsigned)got, (unsigned)need);
+ if (got != need) { Serial.println("cfgLoad(): size mismatch"); return false; }
+ if (tmp.version != 0x0008) { Serial.println("cfgLoad(): version mismatch"); return false; }
+ uint16_t calc = crc16_modbus((uint8_t*)&tmp, need-sizeof(tmp.crc));
+ if (calc != tmp.crc) { Serial.println("cfgLoad(): CRC mismatch"); return false; }
+ gCfg = tmp; return true;
+}
+static bool cfgSave() {
+ gCfg.crc = crc16_modbus((uint8_t*)&gCfg, sizeof(gCfg)-sizeof(gCfg.crc));
+ size_t wrote = prefs.putBytes("blob",&gCfg,sizeof(gCfg));
+ Serial.printf("cfgSave(): wrote %u / %u bytes\n", (unsigned)wrote, (unsigned)sizeof(gCfg));
+ return wrote == sizeof(gCfg);
+}
+static void applyConfigRuntime() {
+ rebuildTopicsFromBase();
+ pollIntervalMs = gCfg.reqTimeMs ? gCfg.reqTimeMs : 1000;
+ i2cGapMs = gCfg.i2cGapMs ? gCfg.i2cGapMs : 80;
+ i2cAliveTimeoutMs = gCfg.i2cAliveTimeoutMs;
+ ledPin = gCfg.ledPin;
+ ledBlinkMs = gCfg.ledBlinkMs ? gCfg.ledBlinkMs : LED_BLINK_MS_DEFAULT;
+ ledActiveHigh = (gCfg.ledActiveHigh != 0);
+ ledEnable = (gCfg.ledEnable != 0);
+ if (ledPin >= 0) pinMode(ledPin, OUTPUT);
+ ledUpdate();
+}
+
+// ===================== Wi-Fi 튜닝/하드리셋 =====================
+static void espLowLevelTune() {
+ wifi_country_t kr = { "KR", 1, 13, WIFI_COUNTRY_POLICY_MANUAL };
+ esp_wifi_set_country(&kr);
+ esp_wifi_set_ps(WIFI_PS_NONE);
+ esp_wifi_set_protocol(WIFI_IF_STA, WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N);
+ esp_wifi_set_storage(WIFI_STORAGE_RAM);
+ esp_wifi_set_bandwidth(WIFI_IF_STA, WIFI_BW_HT20);
+ #ifdef ESP_IDF_VERSION
+ esp_wifi_set_inactive_time(WIFI_IF_STA, 20); // 약 2s (비콘 20개)
+ #endif
+}
+
+static void hardWifiReset() {
+ unsigned long now = millis();
+ if (now - lastHardResetMs < 5000) return;
+ Serial.println("WiFi: HARD RESET (radio off/on)");
+ WiFi.disconnect(true, true);
+ WiFi.mode(WIFI_OFF);
+ delay(150);
+ WiFi.mode(WIFI_STA);
+ WiFi.persistent(false);
+ WiFi.setAutoReconnect(false);
+ WiFi.setSleep(false);
+ WiFi.setTxPower(WIFI_POWER_19_5dBm);
+ espLowLevelTune();
+ lastHardResetMs = now;
+}
+
+// ===================== AP 스캔/선택 & 캐시 =====================
+static bool scanLockFor(const char* ssid, uint8_t bssidOut[6], int &chOut) {
+ chOut = 0; memset(bssidOut, 0, 6);
+ int n = WiFi.scanNetworks(false, true);
+ if (n <= 0) return false;
+ int bestIdx=-1, bestRssi=-999;
+ for (int i=0;ibestRssi){bestRssi=r;bestIdx=i;}
+ }
+ if (bestIdx>=0) {
+ chOut = WiFi.channel(bestIdx);
+ memcpy(bssidOut, WiFi.BSSID(bestIdx), 6);
+ return true;
+ }
+ return false;
+}
+
+static int findAPIndexBySSID(const String& ssid) {
+ for (int i=0;i= gCfg.numAPs) { Serial.println("WiFi: invalid AP index"); return; }
+ if (wifiState == WIFI_CONNECTING) { Serial.println("WiFi: connect skipped (already CONNECTING)"); return; }
+
+ const char* ssid = gCfg.aps[idx].ssid;
+ const char* pass = gCfg.aps[idx].pass;
+
+ WiFi.mode(WIFI_STA);
+ WiFi.setSleep(false);
+ WiFi.setAutoReconnect(false);
+ WiFi.setTxPower(WIFI_POWER_19_5dBm);
+ espLowLevelTune();
+
+ wifi_config_t conf; memset(&conf, 0, sizeof(conf));
+ strncpy((char*)conf.sta.ssid, ssid, sizeof(conf.sta.ssid)-1);
+ strncpy((char*)conf.sta.password, pass, sizeof(conf.sta.password)-1);
+
+ conf.sta.scan_method = WIFI_FAST_SCAN;
+ conf.sta.sort_method = WIFI_CONNECT_AP_BY_SIGNAL;
+ conf.sta.threshold.rssi = -127;
+ conf.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK;
+
+ conf.sta.pmf_cfg.capable = true;
+ conf.sta.pmf_cfg.required = false;
+
+ uint8_t bssid[6]; int ch=0;
+
+ if (STICKY_BSSID_ENABLED && preferCachedBssid && hasLastApRec
+ && strncmp((const char*)lastApRec.ssid, ssid, sizeof(lastApRec.ssid)) == 0) {
+ memcpy(conf.sta.bssid, lastApRec.bssid, 6);
+ conf.sta.bssid_set = 1;
+ conf.sta.channel = lastApRec.primary;
+ Serial.printf("WiFi: connect #%d SSID=%s ch=%d (cached BSSID lock)\n", idx+1, ssid, conf.sta.channel);
+ } else if (STICKY_BSSID_ENABLED) {
+ if (scanLockFor(ssid, bssid, ch)) {
+ memcpy(conf.sta.bssid, bssid, 6);
+ conf.sta.bssid_set = 1;
+ conf.sta.channel = ch;
+ Serial.printf("WiFi: connect #%d SSID=%s ch=%d (scan BSSID lock)\n", idx+1, ssid, ch);
+ } else {
+ Serial.printf("WiFi: connect #%d SSID=%s (no lock)\n", idx+1, ssid);
+ }
+ } else {
+ Serial.printf("WiFi: connect #%d SSID=%s (BSSID lock off)\n", idx+1, ssid);
+ }
+
+ esp_wifi_disconnect();
+ esp_err_t e1 = esp_wifi_set_config(WIFI_IF_STA, &conf);
+ if (e1 != ESP_OK) {
+ Serial.printf("WiFi: set_config err=0x%X\n", e1);
+ }
+ esp_err_t e2 = esp_wifi_connect();
+ if (e2 == ESP_ERR_WIFI_CONN || e2 == ESP_ERR_WIFI_STATE) {
+ Serial.printf("WiFi: connect in-progress (err=0x%X), continue\n", e2);
+ } else if (e2 != ESP_OK) {
+ Serial.printf("WiFi: esp_wifi_connect err=0x%X\n", e2);
+ }
+
+ wifiState = WIFI_CONNECTING;
+ connectStartMs = millis();
+ if (sameApBoostTries > 0) sameApBoostTries--; else apRetryLeft = AP_RETRY_PER - 1;
+}
+
+// ===================== MQTT 보장 =====================
+static void mqttResubscribe() {
+ if (!mqttReady || !client.connected()) return;
+ client.subscribe(topicCommand);
+ client.subscribe(topicSpeed);
+ Serial.println(String("Subscribed: ")+topicCommand);
+ Serial.println(String("Subscribed: ")+topicSpeed);
+}
+
+static void ensureMQTT() {
+ if (configMode) return;
+ if (!wifiReady) return;
+ if (mqttReady && client.connected()) return;
+
+ unsigned long now = millis();
+ if (now - lastMqttAttemptMs < mqttBackoffMs) return;
+ lastMqttAttemptMs = now;
+
+ client.setServer(gCfg.mqttHost, gCfg.mqttPort);
+ client.setKeepAlive(MQTT_KEEPALIVE_SEC);
+ client.setBufferSize(2048);
+ client.setSocketTimeout(MQTT_SOCKET_TIMEOUT_SEC);
+
+ Serial.print("Connecting to MQTT...");
+ String cid = "ESP32Client-";
+ if (gCfg.clientIdSuffix[0]) cid += gCfg.clientIdSuffix; else cid += WiFi.macAddress();
+
+ bool ok;
+ if (gCfg.mqttUser[0]) ok = client.connect(cid.c_str(), gCfg.mqttUser, gCfg.mqttPass, topicLWT, 0, true, "offline");
+ else ok = client.connect(cid.c_str(), nullptr, nullptr, topicLWT, 0, true, "offline");
+
+ if (ok) {
+ Serial.println("connected.");
+ mqttReady = true; mqttBackoffMs = 2000;
+ client.publish(topicLWT, "online", true);
+ mqttResubscribe();
+ } else {
+ mqttReady = false;
+ Serial.print("failed, rc="); Serial.println(client.state());
+ mqttBackoffMs = (mqttBackoffMs < MQTT_BACKOFF_MAX) ? mqttBackoffMs*2 : MQTT_BACKOFF_MAX;
+ }
+ ledUpdate();
+}
+
+// ===================== Wi-Fi 보장(단일 접속 경로) =====================
+static void ensureWiFi() {
+ if (configMode) return;
+
+ if (WiFi.isConnected()) cacheCurrentApRecord();
+
+ if (wifiState == WIFI_ONLINE && !WiFi.isConnected()) {
+ wifiState = WIFI_IDLE; wifiReady = false;
+ if (offlineSinceMs == 0) offlineSinceMs = millis();
+ }
+
+ if (!WiFi.isConnected()) {
+ if (offlineSinceMs == 0) offlineSinceMs = millis();
+ unsigned long offAge = millis() - offlineSinceMs;
+ if (offAge > OFFLINE_HARD_RESET_MS) {
+ hardWifiReset();
+ lastWifiAttemptMs = 0;
+ }
+ if (OFFLINE_REBOOT_MS > 0 && offAge > OFFLINE_REBOOT_MS) {
+ Serial.println("SYSTEM: offline too long → restarting MCU");
+ delay(100); ESP.restart();
+ }
+ } else {
+ offlineSinceMs = 0;
+ }
+
+ if (WiFi.isConnected()) { wifiReady = true; wifiState = WIFI_ONLINE; return; }
+
+ // 연결 진행 중이면 타임아웃 감시
+ if (wifiState == WIFI_CONNECTING) {
+ if (millis() - connectStartMs < CONNECT_TIMEOUT_MS) return;
+
+ Serial.println("WiFi: connect timeout");
+ WiFi.disconnect(false, false);
+ wifiState = WIFI_IDLE;
+
+ if (apRetryLeft > 0) {
+ apRetryLeft--;
+ Serial.printf("WiFi: retry same AP (idx=%d), retries left=%d\n", currentApIdx, apRetryLeft);
+ } else if (sameApBoostTries > 0) {
+ sameApBoostTries--;
+ Serial.printf("WiFi: boosted retry same AP (idx=%d), boost left=%d\n", currentApIdx, sameApBoostTries);
+ } else {
+ currentApIdx = (currentApIdx + 1) % gCfg.numAPs;
+ apRetryLeft = AP_RETRY_PER;
+ Serial.printf("WiFi: switching to next AP (idx=%d)\n", currentApIdx);
+ }
+ lastWifiAttemptMs = 0; // 즉시 재시도
+ return;
+ }
+
+ if (gCfg.numAPs == 0) {
+ // 런타임에도 AP가 0개가 되면 자동 CONFIG 모드 진입
+ setConfigMode(true, "no AP candidates");
+ return;
+ }
+
+ unsigned long now = millis();
+ bool timeReady = (now - lastWifiAttemptMs >= wifiBackoffMs);
+ if (!(timeReady || forceImmediateConnect)) return;
+ lastWifiAttemptMs = now;
+
+ bool prefer = (preferCachedNext || (sameApBoostTries > 0 && hasLastApRec));
+ connectToAP(currentApIdx, prefer);
+ preferCachedNext = false;
+ forceImmediateConnect = false;
+}
+
+// ===================== Wi-Fi 이벤트 =====================
+static void onWiFiEvent(WiFiEvent_t event, WiFiEventInfo_t info) {
+ switch (event) {
+ case ARDUINO_EVENT_WIFI_STA_START:
+ Serial.println("WiFi: STA_START"); break;
+ case ARDUINO_EVENT_WIFI_STA_CONNECTED:
+ Serial.println("WiFi: STA_CONNECTED"); break;
+ case ARDUINO_EVENT_WIFI_STA_GOT_IP:
+ wifiReady = true; wifiState = WIFI_ONLINE; wifiBackoffMs = 1000;
+ lastGotIpMs = millis();
+ cacheCurrentApRecord();
+ Serial.print("WiFi: GOT_IP "); Serial.println(WiFi.localIP());
+ Serial.printf("WiFi: RSSI %d dBm\n", WiFi.RSSI());
+ lastMqttAttemptMs = 0; mqttBackoffMs = 2000;
+ ledUpdate();
+ break;
+ case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: {
+ wifiReady=false; mqttReady=false; wifiState = WIFI_IDLE;
+ uint8_t r = info.wifi_sta_disconnected.reason;
+ Serial.printf("WiFi: DISCONNECTED (reason=%u) on AP#%d\n", r, currentApIdx+1);
+
+ // 드라이버 정리
+ WiFi.disconnect(false, false);
+
+ // 짧은 업타임이면 같은 AP 우선
+ if (lastGotIpMs && (millis() - lastGotIpMs) < MIN_UPTIME_MS) {
+ sameApBoostTries = imax(sameApBoostTries, AP_RETRY_BOOST_ON_DEAUTH/2);
+ }
+
+ if (r == 3) {
+ // deauth → 캐시된 BSSID로 즉시 재협상: "직접 연결"하지 않고, 즉시 스케줄 플래그만 세움
+ sameApBoostTries = imax(sameApBoostTries, AP_RETRY_BOOST_ON_DEAUTH);
+ preferCachedNext = true; // 다음 연결에서 캐시 BSSID 사용
+ forceImmediateConnect = true; // ensureWiFi가 즉시 연결 수행
+ wifiBackoffMs = 0;
+ lastWifiAttemptMs = 0;
+ } else {
+ wifiBackoffMs = uminul(wifiBackoffMs * 2, WIFI_BACKOFF_MAX);
+ lastWifiAttemptMs = millis() - wifiBackoffMs; // 빠른 재시도
+ }
+ ledUpdate();
+ break;
+ }
+ case ARDUINO_EVENT_WIFI_STA_STOP:
+ Serial.println("WiFi: STA_STOP");
+ wifiState = WIFI_IDLE; wifiReady = false; break;
+ default: break;
+ }
+}
+
+// ===================== MQTT Callback =====================
+static void mqttCallback(char* topic, byte* payload, unsigned int length) {
+ String message((const char*)payload, length);
+ String t(topic);
+ Serial.print("MQTT ["); Serial.print(t); Serial.print("] "); Serial.println(message);
+
+ if (t == topicSpeed) {
+ client.publish(topicSpeedResp, message.c_str());
+ } else if (t == topicCommand) {
+ int comma = message.indexOf(',');
+ if (comma <= 0) { Serial.println("[CMD] Invalid format. Use: firstByte,secondWord"); return; }
+ int firstByte = message.substring(0, comma).toInt();
+ int secondWord = message.substring(comma + 1).toInt();
+
+ Wire.beginTransmission(I2C_ADDR);
+ Wire.write((uint8_t)(firstByte & 0xFF));
+ Wire.write((uint8_t)(secondWord & 0xFF));
+ Wire.write((uint8_t)((secondWord >> 8) & 0xFF));
+ uint8_t err = Wire.endTransmission(true);
+ if (err==0) Serial.println("[CMD] I2C send OK"); else Serial.printf("[CMD] I2C send error: %u\n", err);
+ }
+}
+
+// ===================== I2C read & publish =====================
+static void publishDataToMQTT() {
+ if (!mqttReady || !client.connected()) { Serial.println("[MQTT] skip publish: not connected"); return; }
+ unsigned long age = millis() - lastSensorOkMs;
+ if (i2cAliveTimeoutMs == 0) { if (!i2cFreshOk){ Serial.printf("[MQTT] skip publish: not fresh (age=%lums)\n", age); return; } }
+ else { if (age > i2cAliveTimeoutMs){ Serial.printf("[MQTT] skip publish: I2C stale (age=%lums > %lums)\n", age, i2cAliveTimeoutMs); return; } }
+
+ StaticJsonDocument<512> doc;
+ doc["EC"]=sensorData.ec; doc["pH"]=sensorData.ph/100.0;
+ doc["airTemperature"]=sensorData.airTemperature/100.0; doc["airHumidity"]=sensorData.airHumidity/100.0;
+ doc["ADC0"]=sensorData.ADC0; doc["ADC1"]=sensorData.ADC1; doc["ADC2"]=sensorData.ADC2;
+ doc["soilTemp1"]=sensorData.soilTemp1/100.0; doc["soilTemp2"]=sensorData.soilTemp2/100.0;
+ doc["l_minute"]=sensorData.l_minute; doc["pumpPin"]=sensorData.pumpPin; doc["valvePin"]=sensorData.valvePin;
+ doc["waterLevelPin"]=sensorData.waterLevelPin; doc["rainSensor"]=sensorData.rainSensor;
+ char buf[900]; size_t n=serializeJson(doc,buf,sizeof(buf)); Serial.printf("[MQTT] sensor JSON size=%u\n",(unsigned)n);
+ if (n==0 || n>=sizeof(buf)) { Serial.println("[MQTT] JSON too large (sensor)"); return; }
+ if (!client.publish(topicStatus, buf)) Serial.println("[MQTT] Publish failed (sensor)."); else { Serial.println("Published sensor data."); ledFlash(); }
+}
+
+static void publishConfigDataToMQTT() {
+ if (!mqttReady || !client.connected()) { Serial.println("[MQTT] skip publish: not connected"); return; }
+ unsigned long age = millis() - lastSensorOkMs;
+ if (i2cAliveTimeoutMs == 0) { if (!i2cFreshOk){ Serial.printf("[MQTT] skip publish: not fresh (age=%lums)\n", age); return; } }
+ else { if (age > i2cAliveTimeoutMs){ Serial.printf("[MQTT] skip publish: I2C stale (age=%lums > %lums)\n", age, i2cAliveTimeoutMs); return; } }
+
+ StaticJsonDocument<256> doc;
+ doc["currentMode"]=configData.currentMode; doc["cycleTime"]=configData.cycleTime;
+ doc["cycleRestartDelay"]=configData.cycleRestartDelay; doc["valveDelay"]=configData.valveDelay;
+ doc["smStart"]=configData.smStart; doc["smStop"]=configData.smStop;
+ char buf[300]; size_t n=serializeJson(doc,buf,sizeof(buf)); Serial.printf("[MQTT] config JSON size=%u\n",(unsigned)n);
+ if (n==0 || n>=sizeof(buf)) { Serial.println("[MQTT] JSON too large (config)"); return; }
+ if (!client.publish(topicConfig, buf)) Serial.println("[MQTT] Publish failed (config)."); else { Serial.println("Published config data."); ledFlash(); }
+}
+
+static bool i2cRequestOnce(uint8_t attemptIdx) {
+ const size_t LEN_SENSOR = sizeof(SensorData);
+ const size_t LEN_CONFIG = sizeof(ConfigData);
+ const size_t LEN_MAX = (LEN_SENSOR>LEN_CONFIG)?LEN_SENSOR:LEN_CONFIG;
+
+ uint8_t buf[64];
+ unsigned long t0=micros();
+ int n = Wire.requestFrom((uint8_t)I2C_ADDR, (uint8_t)LEN_MAX, (bool)true);
+ if (n<=0){ Serial.printf("[I2C] attempt#%u: no data\n", attemptIdx); return false; }
+ int r = Wire.readBytes((char*)buf, n);
+ if (r!=n){ Serial.printf("[I2C] attempt#%u: short read %d/%d\n", attemptIdx, r, n); return false; }
+
+ uint8_t header = buf[0]; size_t need=0;
+ if (header==SENSOR_DATA_HEADER) need=LEN_SENSOR;
+ else if (header==CONFIG_DATA_HEADER) need=LEN_CONFIG;
+ else { Serial.printf("[I2C] attempt#%u: Unknown header 0x%02X (len=%d)\n", attemptIdx, header, n); return false; }
+
+ if ((size_t)n < need) { Serial.printf("[I2C] attempt#%u: short frame %d < %u\n", attemptIdx, n, (unsigned)need); return false; }
+
+ if (header==SENSOR_DATA_HEADER) {
+ memcpy(&sensorData, buf, need);
+ uint16_t calc = calculateCRC((uint8_t*)&sensorData, need-sizeof(sensorData.crc));
+ if (calc==sensorData.crc) {
+ lastSensorOkMs = millis();
+ i2cFreshOk = true; i2cCycleOkCount++; i2cCycleSensorOK = true;
+ Serial.printf("[I2C] attempt#%u: SENSOR CRC OK (len=%u)\n", attemptIdx, (unsigned)need);
+ publishDataToMQTT();
+ } else { Serial.printf("[I2C] attempt#%u: SENSOR CRC mismatch calc=0x%04X recv=0x%04X\n", attemptIdx, calc, sensorData.crc); return false; }
+ } else {
+ memcpy(&configData, buf, need);
+ uint16_t calc = calculateCRC((uint8_t*)&configData, need-sizeof(configData.crc));
+ if (calc==configData.crc) {
+ lastSensorOkMs = millis();
+ i2cFreshOk = true; i2cCycleOkCount++; i2cCycleConfigOK = true;
+ Serial.printf("[I2C] attempt#%u: CONFIG CRC OK (len=%u)\n", attemptIdx, (unsigned)need);
+ publishConfigDataToMQTT();
+ } else { Serial.printf("[I2C] attempt#%u: CONFIG CRC mismatch calc=0x%04X recv=0x%04X\n", attemptIdx, calc, configData.crc); return false; }
+ }
+
+ unsigned long t1=micros();
+ Serial.printf("I2C RX time (us): %lu\n", (t1-t0));
+ return true;
+}
+
+// ===================== HELP/CLI =====================
+static void printHelp(bool inConfig) {
+ Serial.println("\n=== HELP ===");
+ Serial.println("CONFIG / EXIT : 설정 모드 ON/OFF");
+ Serial.println("LIST/ADD/EDIT/DELETE/CLEAR : Wi-Fi 후보 관리");
+ Serial.println("MQTTSET server,user,pass,topic | MQTTINFO");
+ Serial.println("REQINFO / REQID / REQTIME / I2CGAP / I2CALIVE");
+ Serial.println("LEDINFO / LEDPIN / LEDUSE / LEDACTIVE / LEDBLINK");
+ Serial.println("NVSINFO / SAVE / FACTORY");
+ Serial.println("* AP 후보가 0개이면 자동으로 CONFIG 모드로 진입합니다.");
+ if (inConfig) Serial.println("* CONFIG 모드: 네트워크/I2C 폴링 중지 (ADD로 AP 추가 후 EXIT)");
+ else Serial.println("* 정상 모드: 변경 필요시 'CONFIG' 진입");
+ Serial.println("===========================\n");
+}
+
+static String trimBoth(const String& s){ String t=s; t.trim(); return t; }
+
+static void printMQTT() {
+ Serial.println("\n=== MQTT INFO ===");
+ Serial.printf("server : %s:%u\n", gCfg.mqttHost, gCfg.mqttPort);
+ Serial.printf("user : %s\n", gCfg.mqttUser[0]? gCfg.mqttUser:"(none)");
+ Serial.printf("pass : %s\n", gCfg.mqttPass[0]? gCfg.mqttPass:"(none)");
+ Serial.printf("base : %s\n", gCfg.baseTopic);
+ Serial.printf("topic-status : %s\n", topicStatus);
+ Serial.printf("topic-config : %s\n", topicConfig);
+ Serial.printf("topic-command : %s\n", topicCommand);
+ Serial.printf("topic-speed : %s\n", topicSpeed);
+ Serial.printf("topic-speedResp : %s\n", topicSpeedResp);
+ Serial.printf("topic-LWT : %s\n", topicLWT);
+ Serial.println("=================\n");
+}
+
+static void printAPList() {
+ Serial.println("\n=== Wi-Fi LIST ===");
+ for (int i=0;i0){ cmd=line.substring(0,sp); args=trimBoth(line.substring(sp+1)); }
+ String CMD=cmd; CMD.toUpperCase();
+
+ if (CMD=="HELP") { printHelp(configMode); return; }
+
+ if (CMD=="CONFIG") { setConfigMode(true, "user command"); return; }
+
+ if (CMD=="EXIT" || (CMD=="CONFIG" && args.equalsIgnoreCase("OFF"))) {
+ setConfigMode(false, nullptr); return;
+ }
+
+ if (CMD=="LIST"){ printAPList(); return; }
+
+ if (CMD=="ADD"){
+ int comma=args.indexOf(',');
+ if (comma<=0){ Serial.println("usage: ADD ssid,password"); return; }
+ String ssid=args.substring(0,comma);
+ String pass=args.substring(comma+1);
+ if (gCfg.numAPs>=MAX_APS){ Serial.println("AP FULL"); return; }
+ strncpy(gCfg.aps[gCfg.numAPs].ssid, ssid.c_str(), sizeof(gCfg.aps[0].ssid)-1);
+ strncpy(gCfg.aps[gCfg.numAPs].pass, pass.c_str(), sizeof(gCfg.aps[0].pass)-1);
+ gCfg.numAPs++; cfgSave(); Serial.println("OK: ADDED"); return;
+ }
+
+ if (CMD=="EDIT"){
+ int sp2=args.indexOf(' ');
+ if (sp2<=0){ Serial.println("usage: EDIT index ssid,password"); return; }
+ int idx=args.substring(0,sp2).toInt()-1;
+ String tail=trimBoth(args.substring(sp2+1));
+ int comma=tail.indexOf(',');
+ if (idx<0 || idx>=gCfg.numAPs || comma<=0){ Serial.println("usage: EDIT index ssid,password"); return; }
+ String ssid=tail.substring(0,comma);
+ String pass=tail.substring(comma+1);
+ strncpy(gCfg.aps[idx].ssid, ssid.c_str(), sizeof(gCfg.aps[0].ssid)-1);
+ strncpy(gCfg.aps[idx].pass, pass.c_str(), sizeof(gCfg.aps[0].pass)-1);
+ cfgSave(); Serial.println("OK: EDITED"); return;
+ }
+
+ if (CMD=="DELETE"){
+ int idx=args.toInt()-1;
+ if (idx<0 || idx>=gCfg.numAPs){ Serial.println("usage: DELETE index"); return; }
+ for (int i=idx;i=(int)args.length()) break;
+ }
+ if (i<4){ Serial.println("usage: MQTTSET server,user,pass,topic"); return; }
+
+ String host=f[0]; int colon=host.indexOf(':');
+ if (colon>0){
+ String h=host.substring(0,colon); int p=host.substring(colon+1).toInt(); if (p<=0) p=1883;
+ strncpy(gCfg.mqttHost,h.c_str(),sizeof(gCfg.mqttHost)-1); gCfg.mqttPort=(uint16_t)p;
+ } else {
+ strncpy(gCfg.mqttHost,host.c_str(),sizeof(gCfg.mqttHost)-1); gCfg.mqttPort=1883;
+ }
+ strncpy(gCfg.mqttUser,f[1].c_str(),sizeof(gCfg.mqttUser)-1);
+ strncpy(gCfg.mqttPass,f[2].c_str(),sizeof(gCfg.mqttPass)-1);
+ strncpy(gCfg.baseTopic,f[3].c_str(),sizeof(gCfg.baseTopic)-1);
+
+ rebuildTopicsFromBase(); cfgSave(); Serial.println("OK: MQTT SET & SAVED");
+ mqttReady=false; return;
+ }
+
+ if (CMD=="MQTTINFO"){ printMQTT(); return; }
+
+ if (CMD == "REQINFO") {
+ Serial.printf("REQID %u,%u\n", gCfg.reqIdStart, gCfg.reqIdEnd);
+ Serial.printf("REQTIME %u\n", (unsigned)gCfg.reqTimeMs);
+ Serial.printf("I2CGAP %u\n", (unsigned)gCfg.i2cGapMs);
+ Serial.printf("I2CALIVE %u\n", (unsigned)gCfg.i2cAliveTimeoutMs);
+ return;
+ }
+
+ if (CMD=="REQID") {
+ if (args.length() == 0) { Serial.printf("REQID %u,%u\n", gCfg.reqIdStart, gCfg.reqIdEnd); return; }
+ int comma = args.indexOf(',');
+ if (comma <= 0) { Serial.println("usage: REQID start,end"); return; }
+ int s = args.substring(0, comma).toInt();
+ int e = args.substring(comma + 1).toInt();
+ if (s<0||s>255||e<0||e>255||s>e) { Serial.println("ERR: range 0..255 and start<=end"); return; }
+ gCfg.reqIdStart=(uint8_t)s; gCfg.reqIdEnd=(uint8_t)e; cfgSave();
+ Serial.printf("OK: REQID %u,%u\n", gCfg.reqIdStart, gCfg.reqIdEnd); return;
+ }
+
+ if (CMD=="REQTIME") {
+ if (args.length() == 0) { Serial.printf("REQTIME %u\n", (unsigned)gCfg.reqTimeMs); return; }
+ uint32_t ms = (uint32_t)args.toInt(); if (ms < 200) ms = 200;
+ gCfg.reqTimeMs = ms; pollIntervalMs = ms; cfgSave();
+ Serial.printf("OK: REQTIME %u\n", (unsigned)ms); return;
+ }
+
+ if (CMD=="I2CGAP") {
+ if (args.length()==0) { Serial.printf("I2CGAP %u\n", (unsigned)gCfg.i2cGapMs); return; }
+ uint32_t ms = (uint32_t)args.toInt(); if (ms < 10) ms = 10;
+ gCfg.i2cGapMs = ms; i2cGapMs = ms; cfgSave();
+ Serial.printf("OK: I2CGAP %u\n", (unsigned)ms); return;
+ }
+
+ if (CMD=="I2CALIVE") {
+ if (args.length()==0) { Serial.printf("I2CALIVE %u\n", (unsigned)gCfg.i2cAliveTimeoutMs); return; }
+ uint32_t ms = (uint32_t)args.toInt();
+ gCfg.i2cAliveTimeoutMs = ms; i2cAliveTimeoutMs = ms; cfgSave();
+ Serial.printf("OK: I2CALIVE %u\n", (unsigned)ms); return;
+ }
+
+ if (CMD=="LEDINFO") {
+ Serial.println("\n=== LED INFO ===");
+ Serial.printf("PIN : %d\n", gCfg.ledPin);
+ Serial.printf("USE : %s\n", gCfg.ledEnable ? "ON":"OFF");
+ Serial.printf("ACTIVE: %s\n", gCfg.ledActiveHigh ? "HIGH":"LOW");
+ Serial.printf("BLINK : %u ms\n", (unsigned)gCfg.ledBlinkMs);
+ Serial.println("================\n"); return;
+ }
+ if (CMD=="LEDPIN") {
+ if (args.length()==0) { Serial.printf("LEDPIN %d\n", gCfg.ledPin); return; }
+ int p = args.toInt(); gCfg.ledPin = p; cfgSave();
+ if (p >= 0) { ledPin=p; pinMode(ledPin, OUTPUT); } else { ledPin=-1; }
+ Serial.printf("OK: LEDPIN %d\n", gCfg.ledPin); return;
+ }
+ if (CMD=="LEDUSE") {
+ if (args.length()==0) { Serial.printf("LEDUSE %u\n", (unsigned)gCfg.ledEnable); return; }
+ int v = args.toInt(); gCfg.ledEnable = (v?1:0); ledEnable=(v!=0); cfgSave();
+ Serial.printf("OK: LEDUSE %u\n", (unsigned)gCfg.ledEnable); return;
+ }
+ if (CMD=="LEDACTIVE") {
+ if (args.length()==0) { Serial.printf("LEDACTIVE %s\n", gCfg.ledActiveHigh?"HIGH":"LOW"); return; }
+ String a=args; a.toUpperCase();
+ if (a=="HIGH"||a=="1") { gCfg.ledActiveHigh=1; ledActiveHigh=true; }
+ else if (a=="LOW"||a=="0") { gCfg.ledActiveHigh=0; ledActiveHigh=false; }
+ else { Serial.println("usage: LEDACTIVE HIGH|LOW"); return; }
+ cfgSave(); Serial.printf("OK: LEDACTIVE %s\n", gCfg.ledActiveHigh?"HIGH":"LOW"); return;
+ }
+ if (CMD=="LEDBLINK") {
+ if (args.length()==0) { Serial.printf("LEDBLINK %u\n", (unsigned)gCfg.ledBlinkMs); return; }
+ uint32_t ms = (uint32_t)args.toInt(); if (ms<10) ms=10; if (ms>1000) ms=1000;
+ gCfg.ledBlinkMs=(uint16_t)ms; ledBlinkMs=gCfg.ledBlinkMs; cfgSave();
+ Serial.printf("OK: LEDBLINK %u\n", (unsigned)gCfg.ledBlinkMs); return;
+ }
+
+ if (CMD=="NVSINFO") {
+ Serial.println("\n=== NVS INFO ===");
+ Serial.printf("size(StoredConfig) : %u bytes\n", (unsigned)sizeof(StoredConfig));
+ Serial.printf("version : 0x%04X\n", gCfg.version);
+ Serial.printf("crc (current) : 0x%04X\n", gCfg.crc);
+ Serial.println("=================\n"); return;
+ }
+ if (CMD=="SAVE") { cfgSave(); Serial.println("OK: SAVED"); return; }
+ if (CMD=="FACTORY") {
+ cfgDefaults(); cfgSave(); applyConfigRuntime();
+ Serial.println("OK: FACTORY defaults saved");
+ setConfigMode(true, "factory reset (no AP candidates)");
+ return;
+ }
+
+ Serial.println("UNKNOWN CMD");
+}
+
+// ===================== SETUP / LOOP =====================
void setup() {
- // put your setup code here, to run once:
- int result = myFunction(2, 3);
+ Serial.begin(115200); delay(100);
+
+ if (!cfgLoad()) { Serial.println("CONFIG: load failed → defaults"); cfgDefaults(); cfgSave(); }
+ applyConfigRuntime();
+
+ currentApIdx = 0;
+ apRetryLeft = AP_RETRY_PER;
+ sameApBoostTries = 0;
+ wifiState = WIFI_IDLE;
+ lastGotIpMs = 0;
+ offlineSinceMs = 0;
+ hasLastApRec = false;
+ forceImmediateConnect = true; // 부팅 즉시 첫 연결 시도
+ preferCachedNext = false;
+
+ Serial.printf("Loaded APs=%u, MQTT=%s:%u, base=%s\n", gCfg.numAPs, gCfg.mqttHost, gCfg.mqttPort, gCfg.baseTopic);
+ Serial.printf("I2CGAP=%u, I2CALIVE=%u (0=STRICT)\n", (unsigned)gCfg.i2cGapMs, (unsigned)gCfg.i2cAliveTimeoutMs);
+
+ if (gCfg.numAPs == 0) {
+ setConfigMode(true, "no AP stored at boot");
+ }
+
+ Wire.begin(I2C_SDA, I2C_SCL, I2C_FREQ);
+
+ WiFi.mode(WIFI_STA);
+ WiFi.setSleep(false);
+ WiFi.persistent(false);
+ WiFi.setAutoReconnect(false);
+ WiFi.setTxPower(WIFI_POWER_19_5dBm);
+ espLowLevelTune();
+ WiFi.onEvent(onWiFiEvent);
+
+ client.setCallback(mqttCallback);
+ client.setKeepAlive(MQTT_KEEPALIVE_SEC);
+ client.setBufferSize(2048);
+ client.setSocketTimeout(MQTT_SOCKET_TIMEOUT_SEC);
+
+ if (ledPin >= 0) pinMode(ledPin, OUTPUT);
+ ledUpdate();
+
+ Serial.println("\nESP32 Bridge ready.");
+ Serial.printf("I2C SDA=%d SCL=%d Freq=%lu\n", I2C_SDA, I2C_SCL, I2C_FREQ);
+ Serial.println("Type HELP for CLI.");
+
+ nextPollMs = millis();
+ cycleStartMs = nextPollMs;
+ i2cPhase = 0;
}
void loop() {
- // put your main code here, to run repeatedly:
-}
+ // Serial line reader
+ static String line;
+ while (Serial.available()) {
+ char c=(char)Serial.read();
+ if (c=='\r') continue;
+ if (c=='\n') { cliHandle(line); line=""; }
+ else line += c;
+ }
-// put function definitions here:
-int myFunction(int x, int y) {
- return x + y;
-}
\ No newline at end of file
+ if (configMode) { ledUpdate(); delay(10); return; }
+
+ ensureWiFi();
+ ensureMQTT();
+ if (mqttReady) client.loop();
+
+ unsigned long now = millis();
+ if (now >= nextPollMs) {
+ if (i2cPhase == 0) {
+ i2cFreshOk = false; i2cCycleAttempts = 0; i2cCycleOkCount = 0;
+ i2cCycleSensorOK = false; i2cCycleConfigOK = false;
+ cycleStartMs = now;
+ Serial.printf("[I2C] cycle begin @%lums (gap=%ums)\n", cycleStartMs, (unsigned)i2cGapMs);
+
+ i2cCycleAttempts++; (void)i2cRequestOnce(1);
+ i2cPhase = 1; nextPollMs = cycleStartMs + i2cGapMs;
+ } else {
+ i2cCycleAttempts++; (void)i2cRequestOnce(2);
+ Serial.printf("[I2C] cycle end: attempts=%u, ok=%u, sensorOK=%u, configOK=%u, fresh=%u\n",
+ i2cCycleAttempts, i2cCycleOkCount, i2cCycleSensorOK?1:0, i2cCycleConfigOK?1:0, i2cFreshOk?1:0);
+
+ i2cPhase = 0;
+ unsigned long baseNext = cycleStartMs + pollIntervalMs;
+ nextPollMs = (baseNext > now) ? baseNext : (now + pollIntervalMs);
+ }
+ }
+
+ ledUpdate();
+}