// ===== 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

#include <Wire.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include <cstring>

extern "C" {
  #include "esp_wifi.h"
  #include "esp_err.h"
}

// ---------- 작은 유틸 ----------
static inline unsigned long uminul(unsigned long a, unsigned long b){ return (a<b)?a:b; }
static inline unsigned long umaxul(unsigned long a, unsigned long b){ return (a>b)?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<len;i++) {
    crc ^= data[i];
    for (int j=0;j<8;j++) crc = (crc & 1) ? (crc >> 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;i<n;i++) if (WiFi.SSID(i)==ssid) {
    int r=WiFi.RSSI(i);
    if (r>bestRssi){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;i++) if (String(gCfg.aps[i].ssid)==ssid) return i;
  return -1;
}

static void cacheCurrentApRecord() {
  wifi_ap_record_t r;
  if (esp_wifi_sta_get_ap_info(&r)==ESP_OK) {
    lastApRec = r; hasLastApRec = true;
  }
}

// ===================== CONFIG 모드 토글 헬퍼 =====================
static void setConfigMode(bool on, const char* reason=nullptr) {
  if (on) {
    configMode = true; mqttReady = false; wifiReady = false;
    if (client.connected()) client.disconnect();
    WiFi.disconnect(true, true);
    wifiState = WIFI_IDLE;
    Serial.print("[CONFIG] MODE ON");
    if (reason) { Serial.print(" : "); Serial.print(reason); }
    Serial.println(" (network/poll paused)");
    ledUpdate();
  } else {
    if (gCfg.numAPs == 0) {
      Serial.println("[CONFIG] Cannot exit: no AP candidates. Use 'ADD ssid,password' first.");
      return;
    }
    configMode = false;
    Serial.println("[CONFIG] MODE OFF (resume)");
    forceImmediateConnect = true; // 즉시 연결 시도
    ledUpdate();
  }
}

// ===================== AP 연결(저수준, 단일 경로) =====================
static void connectToAP(int idx, bool preferCachedBssid) {
  if (idx < 0 || idx >= 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;i<gCfg.numAPs;i++)
    Serial.printf("%d) %s , %s\n", i+1, gCfg.aps[i].ssid, gCfg.aps[i].pass);
  if (gCfg.numAPs==0) Serial.println("(empty)");
  Serial.println("==================\n");
}

static void cliHandle(const String& rawLine) {
  String line = trimBoth(rawLine);
  if (!line.length()) return;
  String cmd=line; int sp=line.indexOf(' '); String args="";
  if (sp>0){ 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<gCfg.numAPs-1;i++) gCfg.aps[i]=gCfg.aps[i+1];
    gCfg.numAPs--; cfgSave(); Serial.println("OK: DELETED");
    if (gCfg.numAPs==0) setConfigMode(true, "no AP candidates after DELETE");
    return;
  }

  if (CMD=="CLEAR"){
    gCfg.numAPs=0; memset(gCfg.aps,0,sizeof(gCfg.aps)); cfgSave();
    Serial.println("OK: CLEARED");
    setConfigMode(true, "no AP candidates after CLEAR");
    return;
  }

  if (CMD=="MQTTSET"){
    String f[4]; int pos=0,i=0;
    while (i<4){
      int c=args.indexOf(',',pos);
      if (c<0){ f[i++]=trimBoth(args.substring(pos)); break; }
      f[i++]=trimBoth(args.substring(pos,c)); pos=c+1; if (pos>=(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() {
  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() {
  // 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;
  }

  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();
}
