From 319eb23eba2336cdec9fb1f17602914cb16216d9 Mon Sep 17 00:00:00 2001 From: choibk Date: Sat, 8 Nov 2025 11:32:34 +0900 Subject: [PATCH] R3.1 --- README | 112 ++++++ platformio.ini | 6 + src/MPINO.cpp | 944 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.cpp | 996 ++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 2047 insertions(+), 11 deletions(-) create mode 100644 README create mode 100644 src/MPINO.cpp 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(); +}