#include <Arduino.h>
#include <HTTPClient.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <time.h>
#include "LoRa_E22.h"

#define E22_RX_PIN   17  // ESP32 RX <- E22 TXD
#define E22_TX_PIN   16  // ESP32 TX -> E22 RXD
#define E22_M0_PIN   4
#define E22_M1_PIN   5
#define E22_AUX_PIN  22

#define E22_BAUDRATE 9600
#define E22_CONFIGURE_ON_BOOT 0U

#define LORA_HEADER1        0x55
#define LORA_HEADER2        0xAA
#define LORA_VERSION        2

#define LORA_TYPE_REQ       1
#define LORA_TYPE_RESP      2
#define LORA_TYPE_ACK       3
#define LORA_TYPE_TIME_SYNC 4

#define MASTER_ID           0UL
#define REQ_RETRY_MAX       5
#define REQ_REPEAT_COUNT    2
#define REQ_REPEAT_GAP_MS   700UL
#define RESP_TIMEOUT_MS     12000UL
#define E22_AUX_TIMEOUT_MS  5000UL
#define NODE_GAP_MS         1000UL
#define WAKE_DELAY_MS       500UL

#define WIFI_RETRY_INTERVAL_MS 10000UL
#define FIREBASE_HTTP_TIMEOUT_MS 4000U
#define FIREBASE_QUEUE_SIZE 8U
#define FIREBASE_RADIO_QUIET_MS 8000UL
#define RELAY_CHAIN_NODE_COUNT 2U
#if ((RELAY_CHAIN_NODE_COUNT < 2U) || (RELAY_CHAIN_NODE_COUNT > 4U))
#error "RELAY_CHAIN_NODE_COUNT must be 2, 3, or 4"
#endif
#define RELAY_TTL_INITIAL RELAY_CHAIN_NODE_COUNT
#define DUPLICATE_WINDOW_MS 30000UL

#define APP_RELAY_TEST_MODE 1U
#if (APP_RELAY_TEST_MODE != 0U)
#define TIME_SYNC_INTERVAL_MINUTES 5UL
#else
#define TIME_SYNC_INTERVAL_MINUTES 240UL
#endif
#define TIME_SYNC_REPEAT_MS 4000UL
#define TIME_SYNC_MIN_SEND_COUNT 3U
#define TIME_SYNC_FIRST_NODE_ID 1001UL
#define TIME_SYNC_FINAL_NODE_ID (TIME_SYNC_FIRST_NODE_ID + RELAY_CHAIN_NODE_COUNT - 1UL)

const char *WIFI_SSID = "HYPMOTION";
const char *WIFI_PASSWORD = "06042023";
const char *FIREBASE_DATABASE_URL =
  "https://iot-xdroneai-default-rtdb.asia-southeast1.firebasedatabase.app";
const char *FIREBASE_AUTH = "";

const char *NTP_SERVER = "pool.ntp.org";
const long  NTP_GMT_OFFSET_SEC = 7 * 3600;
const int   NTP_DAYLIGHT_OFFSET_SEC = 0;
bool ntpTimeSynced = false;


#define ENABLE_MASTER_POLLING 0
#define E22_TEXT_RECEIVE_MODE 0

HardwareSerial E22Serial(2);
LoRa_E22 e22ttl(E22_RX_PIN, E22_TX_PIN, &E22Serial, E22_AUX_PIN, E22_M0_PIN, E22_M1_PIN, UART_BPS_RATE_9600);
WiFiClientSecure firebaseClient;
uint32_t lastWiFiRetryMs = 0;

const uint32_t nodeIds[] = {
  1001UL,
  1002UL,
  1003UL,
  1004UL
};

uint32_t nodeSeqs[sizeof(nodeIds) / sizeof(nodeIds[0])] = {1};

typedef struct __attribute__((packed))
{
  uint8_t  header1;
  uint8_t  header2;
  uint8_t  version;
  uint8_t  type;

  uint32_t src_id;
  uint32_t dst_id;
  uint32_t hop_src_id;
  uint32_t hop_dst_id;
  uint32_t seq;
  uint32_t timestamp;

  uint16_t humidity;
  int16_t temperature;
  uint16_t sht_humidity;
  int16_t sht_temperature;
  uint16_t ph;
  uint16_t nitrogen;
  uint16_t phosphorus;
  uint16_t potassium;

  uint16_t battery_mv;

  uint8_t  status;
  uint8_t  ttl;
  uint8_t  reserved[5];

  uint16_t crc16;
} LoraPacket_t;

static_assert(sizeof(LoraPacket_t) == 55U, "ESP32 LoRa packet size mismatch");

LoraPacket_t rxPacket;
LoraPacket_t txPacket;
LoraPacket_t firebaseQueue[FIREBASE_QUEUE_SIZE];
uint8_t firebaseQueueHead = 0;
uint8_t firebaseQueueTail = 0;
uint8_t firebaseQueueCount = 0;
uint32_t firebaseQueueOverflowCount = 0;
uint32_t lastRadioPacketMs = 0;
uint32_t timeSyncCurrentSeq = 0;
uint32_t timeSyncLastSendMs = 0;
uint32_t timeSyncSendCount = 0;
uint32_t timeSyncAckCount = 0;
bool timeSyncAcked = false;

uint32_t rawByteCount = 0;
uint32_t packetOkCount = 0;
uint32_t crcFailCount = 0;
uint32_t headerDropCount = 0;
uint32_t ackSentCount = 0;

#define MAX_TRACKED_NODES 8

typedef struct
{
  uint32_t deviceId;
  uint32_t lastSeqPrinted;
  uint32_t lastPacketMs;
  bool used;
} NodeRxState_t;

NodeRxState_t nodeRxStates[MAX_TRACKED_NODES];

uint16_t crc16_ccitt(uint8_t *data, uint16_t len)
{
  uint16_t crc = 0xFFFF;

  for (uint16_t i = 0; i < len; i++)
  {
    crc ^= (uint16_t)data[i] << 8;

    for (uint8_t j = 0; j < 8; j++)
    {
      if (crc & 0x8000)
        crc = (crc << 1) ^ 0x1021;
      else
        crc <<= 1;
    }
  }

  return crc;
}

void finalizePacket(LoraPacket_t *packet)
{
  packet->crc16 = 0;
  packet->crc16 = crc16_ccitt((uint8_t *)packet, sizeof(LoraPacket_t) - 2);
}

bool validatePacket(LoraPacket_t *packet)
{
  if (packet->header1 != LORA_HEADER1 ||
      packet->header2 != LORA_HEADER2 ||
      packet->version != LORA_VERSION)
  {
    return false;
  }

  uint16_t crcRecv = packet->crc16;
  packet->crc16 = 0;
  uint16_t crcCalc = crc16_ccitt((uint8_t *)packet, sizeof(LoraPacket_t) - 2);
  packet->crc16 = crcRecv;

  return crcRecv == crcCalc;
}

bool e22WaitAux(uint32_t timeoutMs)
{
  uint32_t start = millis();

  while (digitalRead(E22_AUX_PIN) == LOW)
  {
    if ((millis() - start) >= timeoutMs)
    {
      return false;
    }
  }

  return true;
}

void e22SetNormalMode()
{
  digitalWrite(E22_M0_PIN, LOW);
  digitalWrite(E22_M1_PIN, LOW);
  delay(2);
  e22WaitAux(100);
}

void flushE22()
{
  while (E22Serial.available())
  {
    E22Serial.read();
  }
}

bool e22SetConfigurationMode()
{
  digitalWrite(E22_M0_PIN, LOW);
  digitalWrite(E22_M1_PIN, HIGH);
  delay(50);
  return e22WaitAux(E22_AUX_TIMEOUT_MS);
}

size_t e22ReadExact(uint8_t *data, size_t length, uint32_t timeoutMs)
{
  size_t offset = 0;
  uint32_t start = millis();
  while ((offset < length) && ((millis() - start) < timeoutMs))
  {
    while ((offset < length) && E22Serial.available())
    {
      data[offset++] = (uint8_t)E22Serial.read();
    }
    delay(1);
  }
  return offset;
}

bool e22ReadConfiguration(uint8_t config[9])
{
  static const uint8_t command[3] = {0xC1, 0x00, 0x09};
  uint8_t response[12] = {0};

  if (!e22SetConfigurationMode())
  {
    e22SetNormalMode();
    return false;
  }

  flushE22();
  E22Serial.write(command, sizeof(command));
  E22Serial.flush();
  size_t received = e22ReadExact(response, sizeof(response), 1500);
  e22SetNormalMode();

  if ((received != sizeof(response)) ||
      (response[0] != 0xC1) ||
      (response[1] != 0x00) ||
      (response[2] != 0x09))
  {
    return false;
  }

  memcpy(config, &response[3], 9);
  return true;
}

bool e22WriteConfiguration(const uint8_t config[9])
{
  uint8_t frame[12] = {0xC0, 0x00, 0x09};
  uint8_t response[12] = {0};
  memcpy(&frame[3], config, 9);

  if (!e22SetConfigurationMode())
  {
    e22SetNormalMode();
    return false;
  }

  flushE22();
  E22Serial.write(frame, sizeof(frame));
  E22Serial.flush();
  size_t received = e22ReadExact(response, sizeof(response), 1500);
  e22SetNormalMode();
  delay(150);

  if (received == sizeof(response))
  {
    Serial.println("E22 CONFIG WRITE ACK OK");
  }
  else
  {
    Serial.print("E22 CONFIG WRITE ACK MISSING, bytes=");
    Serial.println(received);
    Serial.println("Verifying configuration by reading it back...");
  }

  return true;
}

void e22EnsureMatchedConfiguration()
{
#if (E22_CONFIGURE_ON_BOOT != 0U)
  ResponseStructContainer container = e22ttl.getConfiguration();
  for (uint8_t attempt = 1; container.status.code != 1 && attempt < 3; attempt++)
  {
    Serial.print("E22 CONFIG READ RETRY ");
    Serial.print(attempt);
    Serial.print(": ");
    Serial.println(container.status.getResponseDescription());
    container.close();
    delay(200);
    container = e22ttl.getConfiguration();
  }

  Serial.print("E22 CONFIG READ: ");
  Serial.println(container.status.getResponseDescription());
  if (container.status.code != 1)
  {
    container.close();
    return;
  }

  Configuration configuration = *(Configuration *)container.data;
  container.close();

  bool matched =
    (configuration.ADDH == 0x00) &&
    (configuration.ADDL == 0xC8) &&
    (configuration.NETID == 0x00) &&
    (configuration.SPED.uartBaudRate == 0x03) &&
    (configuration.SPED.uartParity == 0x00) &&
    (configuration.SPED.airDataRate == 0x02) &&
    (configuration.OPTION.subPacketSetting == 0x00) &&
    (configuration.OPTION.RSSIAmbientNoise == 0x00) &&
    (configuration.OPTION.transmissionPower == 0x00) &&
    (configuration.CHAN == 0x19) &&
    (configuration.TRANSMISSION_MODE.WORPeriod == 0x03) &&
    (configuration.TRANSMISSION_MODE.WORTransceiverControl == 0x00) &&
    (configuration.TRANSMISSION_MODE.enableLBT == 0x00) &&
    (configuration.TRANSMISSION_MODE.enableRepeater == 0x00) &&
    (configuration.TRANSMISSION_MODE.fixedTransmission == 0x00) &&
    (configuration.TRANSMISSION_MODE.enableRSSI == 0x00) &&
    (configuration.CRYPT.CRYPT_H == 0x00) &&
    (configuration.CRYPT.CRYPT_L == 0x00);

  if (matched)
  {
    Serial.println("E22 CONFIG ALREADY MATCHED");
    return;
  }

  configuration.ADDH = 0x00;
  configuration.ADDL = 0xC8;
  configuration.NETID = 0x00;
  configuration.SPED.uartBaudRate = 0x03;
  configuration.SPED.uartParity = 0x00;
  configuration.SPED.airDataRate = 0x02;
  configuration.OPTION.subPacketSetting = 0x00;
  configuration.OPTION.RSSIAmbientNoise = 0x00;
  configuration.OPTION.transmissionPower = 0x00;
  configuration.CHAN = 0x19;
  configuration.TRANSMISSION_MODE.WORPeriod = 0x03;
  configuration.TRANSMISSION_MODE.WORTransceiverControl = 0x00;
  configuration.TRANSMISSION_MODE.enableLBT = 0x00;
  configuration.TRANSMISSION_MODE.enableRepeater = 0x00;
  configuration.TRANSMISSION_MODE.fixedTransmission = 0x00;
  configuration.TRANSMISSION_MODE.enableRSSI = 0x00;
  configuration.CRYPT.CRYPT_H = 0x00;
  configuration.CRYPT.CRYPT_L = 0x00;

  ResponseStatus writeStatus =
    e22ttl.setConfiguration(configuration, WRITE_CFG_PWR_DWN_SAVE);
  Serial.print("E22 CONFIG WRITE: ");
  Serial.println(writeStatus.getResponseDescription());

  ResponseStructContainer verifyContainer = e22ttl.getConfiguration();
  Serial.print("E22 CONFIG VERIFY READ: ");
  Serial.println(verifyContainer.status.getResponseDescription());
  if (verifyContainer.status.code == 1)
  {
    Configuration verify = *(Configuration *)verifyContainer.data;
    bool verified =
      (verify.ADDH == 0x00) &&
      (verify.ADDL == 0xC8) &&
      (verify.NETID == 0x00) &&
      (verify.SPED.uartBaudRate == 0x03) &&
      (verify.SPED.uartParity == 0x00) &&
      (verify.SPED.airDataRate == 0x02) &&
      (verify.CHAN == 0x19) &&
      (verify.TRANSMISSION_MODE.fixedTransmission == 0x00);
    Serial.println(verified ? "E22 CONFIG WRITE AND VERIFY OK" :
                              "E22 CONFIG VERIFY MISMATCH");
  }
  verifyContainer.close();
#endif
}
bool sendPacket(LoraPacket_t *packet)
{
  finalizePacket(packet);

  if (!e22WaitAux(E22_AUX_TIMEOUT_MS))
  {
    return false;
  }

  size_t written = E22Serial.write((uint8_t *)packet, sizeof(LoraPacket_t));
  E22Serial.flush();

  if (!e22WaitAux(E22_AUX_TIMEOUT_MS))
  {
    return false;
  }

  return written == sizeof(LoraPacket_t);
}

void wakeNode()
{
  uint8_t wakeBytes[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};

  e22WaitAux(E22_AUX_TIMEOUT_MS);
  E22Serial.write(wakeBytes, sizeof(wakeBytes));
  E22Serial.flush();
  e22WaitAux(E22_AUX_TIMEOUT_MS);
  delay(WAKE_DELAY_MS);
}

bool readPacket(LoraPacket_t *packet)
{
  static uint8_t buffer[sizeof(LoraPacket_t)];
  static size_t pos = 0;
  uint8_t *raw = (uint8_t *)packet;

  while (E22Serial.available())
  {
    uint8_t b = (uint8_t)E22Serial.read();
    rawByteCount++;

    if (pos == 0 && b != LORA_HEADER1)
    {
      headerDropCount++;
      continue;
    }

    if (pos == 1 && b != LORA_HEADER2)
    {
      pos = (b == LORA_HEADER1) ? 1 : 0;
      headerDropCount++;
      continue;
    }

    buffer[pos++] = b;

    if (pos < sizeof(LoraPacket_t))
    {
      continue;
    }

    memcpy(raw, buffer, sizeof(LoraPacket_t));
    pos = 0;

    if (!validatePacket(packet))
    {
      crcFailCount++;
      continue;
    }

    packetOkCount++;
    return true;
  }

  return false;
}

void buildReq(LoraPacket_t *packet, uint32_t nodeId, uint32_t seq)
{
  memset(packet, 0, sizeof(*packet));
  packet->header1 = LORA_HEADER1;
  packet->header2 = LORA_HEADER2;
  packet->version = LORA_VERSION;
  packet->type = LORA_TYPE_REQ;
  packet->src_id = MASTER_ID;
  packet->dst_id = nodeId;
  packet->hop_src_id = MASTER_ID;
  packet->hop_dst_id = nodeId;
  packet->seq = seq;
  packet->ttl = RELAY_TTL_INITIAL;
  packet->timestamp = millis() / 1000UL;
}

void buildAck(LoraPacket_t *packet, const LoraPacket_t *resp)
{
  memset(packet, 0, sizeof(*packet));
  packet->header1 = LORA_HEADER1;
  packet->header2 = LORA_HEADER2;
  packet->version = LORA_VERSION;
  packet->type = LORA_TYPE_ACK;
  packet->src_id = resp->src_id;
  packet->dst_id = MASTER_ID;
  packet->hop_src_id = MASTER_ID;
  packet->hop_dst_id = resp->hop_src_id;
  packet->seq = resp->seq;
  packet->timestamp = millis() / 1000UL;
  packet->ttl = resp->ttl;
}

void buildTimeSync(LoraPacket_t *packet, uint32_t epoch, uint32_t seq)
{
  memset(packet, 0, sizeof(*packet));
  packet->header1 = LORA_HEADER1;
  packet->header2 = LORA_HEADER2;
  packet->version = LORA_VERSION;
  packet->type = LORA_TYPE_TIME_SYNC;
  packet->src_id = MASTER_ID;
  packet->dst_id = TIME_SYNC_FINAL_NODE_ID;
  packet->hop_src_id = MASTER_ID;
  packet->hop_dst_id = TIME_SYNC_FIRST_NODE_ID;
  packet->seq = seq;
  packet->timestamp = epoch;
  packet->ttl = RELAY_TTL_INITIAL;
}
String makeJson(const LoraPacket_t *packet, time_t realTimestamp)
{
  String json = "{";
  json += "\"device_id\":" + String(packet->src_id);
  json += ",\"seq\":" + String(packet->seq);
  json += ",\"timestamp\":" + String(packet->timestamp);
  json += ",\"real_timestamp\":" + String((uint32_t)realTimestamp);
  json += ",\"humidity\":" + String(packet->humidity / 10.0f, 1);
  json += ",\"temperature\":" + String(packet->temperature / 10.0f, 1);
  json += ",\"sht_humidity\":" + String(packet->sht_humidity / 10.0f, 1);
  json += ",\"sht_temperature\":" + String(packet->sht_temperature / 10.0f, 1);
  json += ",\"ph\":" + String(packet->ph / 10.0f, 1);
  json += ",\"nitrogen\":" + String(packet->nitrogen);
  json += ",\"phosphorus\":" + String(packet->phosphorus);
  json += ",\"potassium\":" + String(packet->potassium);
  json += ",\"battery_mv\":" + String(packet->battery_mv);
  json += ",\"status\":" + String(packet->status);
  json += ",\"last_hop\":" + String(packet->hop_src_id);
  json += ",\"ttl\":" + String(packet->ttl);
  json += ",\"dbg_marker\":" + String(packet->reserved[0]);
  json += ",\"dbg_source\":" + String(packet->reserved[1]);
  json += ",\"dbg_low\":" + String(packet->reserved[2]);
  json += ",\"dbg_n\":" + String(packet->reserved[3] >> 4);
  json += ",\"dbg_p\":" + String(packet->reserved[3] & 0x0F);
  json += ",\"dbg_k\":" + String(packet->reserved[4]);
  json += "}";
  return json;
}

void maintainWiFi()
{
  if (WiFi.status() == WL_CONNECTED)
  {
    if (!ntpTimeSynced)
    {
      configTime(NTP_GMT_OFFSET_SEC, NTP_DAYLIGHT_OFFSET_SEC, NTP_SERVER);
      ntpTimeSynced = true;
      Serial.println("NTP sync requested");
    }
    return;
  }

  ntpTimeSynced = false;

  uint32_t now = millis();
  if ((now - lastWiFiRetryMs) < WIFI_RETRY_INTERVAL_MS)
  {
    return;
  }

  lastWiFiRetryMs = now;
  Serial.println("WIFI reconnecting...");
  WiFi.disconnect();
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
}

time_t getRealEpochTime()
{
  time_t now = time(nullptr);
  return (now > 100000) ? now : 0;
}

void serviceTimeSync()
{
  time_t now = getRealEpochTime();
  if (now == 0)
  {
    return;
  }

  uint32_t periodSeconds = TIME_SYNC_INTERVAL_MINUTES * 60UL;
  uint32_t localEpoch = (uint32_t)now + (uint32_t)NTP_GMT_OFFSET_SEC;
  uint32_t seq = localEpoch / periodSeconds;

  if (seq != timeSyncCurrentSeq)
  {
    timeSyncCurrentSeq = seq;
    timeSyncSendCount = 0;
    timeSyncAcked = false;
    timeSyncLastSendMs = 0;
  }

  if (timeSyncAcked && (timeSyncSendCount >= TIME_SYNC_MIN_SEND_COUNT))
  {
    return;
  }

  uint32_t nowMs = millis();
  if ((timeSyncLastSendMs != 0U) &&
      ((nowMs - timeSyncLastSendMs) < TIME_SYNC_REPEAT_MS))
  {
    return;
  }

  LoraPacket_t syncPacket;
  buildTimeSync(&syncPacket, (uint32_t)now, timeSyncCurrentSeq);
  if (sendPacket(&syncPacket))
  {
    timeSyncSendCount++;
    Serial.print("TIME_SYNC TX seq=");
    Serial.print(timeSyncCurrentSeq);
    Serial.print(" epoch=");
    Serial.print((uint32_t)now);
    Serial.print(" count=");
    Serial.println(timeSyncSendCount);
  }
  timeSyncLastSendMs = nowMs;
}
bool firebaseRequest(const String &path, const String &json, bool isPost, const char *tag)
{
  String url = String(FIREBASE_DATABASE_URL);
  url += path;
  url += ".json";
  if (strlen(FIREBASE_AUTH) > 0)
  {
    url += "?auth=";
    url += FIREBASE_AUTH;
  }

  HTTPClient http;
  http.setTimeout(FIREBASE_HTTP_TIMEOUT_MS);
  if (!http.begin(firebaseClient, url))
  {
    Serial.print("FIREBASE begin failed: ");
    Serial.println(tag);
    return false;
  }

  http.addHeader("Content-Type", "application/json");
  int httpCode = isPost ? http.POST(json) : http.PUT(json);
  bool ok = (httpCode >= 200 && httpCode < 300);

  Serial.print(ok ? "FIREBASE OK " : "FIREBASE ERROR ");
  Serial.print(tag);
  Serial.print(" HTTP=");
  Serial.println(httpCode);

  http.end();
  return ok;
}

bool sendJsonToServer(const LoraPacket_t *packet, const String &json)
{
  if (WiFi.status() != WL_CONNECTED)
  {
    return false;
  }

  String basePath = "/devices/" + String(packet->src_id);
  bool latestOk = firebaseRequest(basePath + "/latest", json, false, "latest");
  bool historyOk = firebaseRequest(basePath + "/history", json, true, "history");
  return latestOk && historyOk;
}

bool enqueueFirebasePacket(const LoraPacket_t *packet)
{
  if (firebaseQueueCount >= FIREBASE_QUEUE_SIZE)
  {
    firebaseQueueOverflowCount++;
    return false;
  }

  firebaseQueue[firebaseQueueTail] = *packet;
  firebaseQueueTail = (uint8_t)((firebaseQueueTail + 1U) % FIREBASE_QUEUE_SIZE);
  firebaseQueueCount++;
  return true;
}

void processFirebaseQueue()
{
  if ((firebaseQueueCount == 0U) ||
      (WiFi.status() != WL_CONNECTED) ||
      ((millis() - lastRadioPacketMs) < FIREBASE_RADIO_QUIET_MS))
  {
    return;
  }

  LoraPacket_t packet = firebaseQueue[firebaseQueueHead];
  String json = makeJson(&packet, getRealEpochTime());

  if (sendJsonToServer(&packet, json))
  {
    firebaseQueueHead = (uint8_t)((firebaseQueueHead + 1U) % FIREBASE_QUEUE_SIZE);
    firebaseQueueCount--;
  }
  else
  {
    lastRadioPacketMs = millis();
  }
}
bool shouldPrintPacket(const LoraPacket_t *packet)
{
  int freeIndex = -1;

  for (uint8_t i = 0; i < MAX_TRACKED_NODES; i++)
  {
    if (nodeRxStates[i].used)
    {
      if (nodeRxStates[i].deviceId != packet->src_id)
      {
        continue;
      }

      uint32_t now = millis();
      if ((nodeRxStates[i].lastSeqPrinted == packet->seq) &&
          ((now - nodeRxStates[i].lastPacketMs) < DUPLICATE_WINDOW_MS))
      {
        return false;
      }

      nodeRxStates[i].lastSeqPrinted = packet->seq;
      nodeRxStates[i].lastPacketMs = now;
      return true;
    }

    if (freeIndex < 0)
    {
      freeIndex = i;
    }
  }

  if (freeIndex >= 0)
  {
    nodeRxStates[freeIndex].used = true;
    nodeRxStates[freeIndex].deviceId = packet->src_id;
    nodeRxStates[freeIndex].lastSeqPrinted = packet->seq;
    nodeRxStates[freeIndex].lastPacketMs = millis();
  }

  return true;
}

void printSoilData(const LoraPacket_t *packet)
{
  Serial.println("----- SOIL DATA -----");
  Serial.print("Device ID : ");
  Serial.println(packet->src_id);
  Serial.print("Seq       : ");
  Serial.println(packet->seq);
  Serial.print("Humidity  : ");
  Serial.println(packet->humidity / 10.0f, 1);
  Serial.print("Temperature: ");
  Serial.println(packet->temperature / 10.0f, 1);
  Serial.print("SHT Humidity: ");
  Serial.println(packet->sht_humidity / 10.0f, 1);
  Serial.print("SHT Temp  : ");
  Serial.println(packet->sht_temperature / 10.0f, 1);
  Serial.print("pH        : ");
  Serial.println(packet->ph / 10.0f, 1);
  Serial.print("Nitrogen  : ");
  Serial.println(packet->nitrogen);
  Serial.print("Phosphorus: ");
  Serial.println(packet->phosphorus);
  Serial.print("Potassium : ");
  Serial.println(packet->potassium);
  Serial.print("Battery mV: ");
  Serial.println(packet->battery_mv);
  Serial.print("Status    : ");
  Serial.println(packet->status);
  Serial.print("Debug     : ");
  Serial.print(packet->reserved[0]);
  Serial.print(" source=");
  Serial.print(packet->reserved[1]);
  Serial.print(" status=");
  Serial.println(packet->reserved[2]);
  Serial.print("Route     : origin=");
  Serial.print(packet->src_id);
  Serial.print(" hop=");
  Serial.print(packet->hop_src_id);
  Serial.print("->");
  Serial.print(packet->hop_dst_id);
  Serial.print(" ttl=");
  Serial.println(packet->ttl);
  Serial.println("---------------------");
}

void ackResponse(const LoraPacket_t *resp)
{
  buildAck(&txPacket, resp);

  for (uint8_t ackRepeat = 0; ackRepeat < 3; ackRepeat++)
  {
    if (sendPacket(&txPacket))
    {
      ackSentCount++;
    }
    delay(100);
  }
}

bool pollNode(uint8_t nodeIndex)
{
  uint32_t nodeId = nodeIds[nodeIndex];
  uint32_t seq = nodeSeqs[nodeIndex];

  for (uint8_t attempt = 0; attempt <= REQ_RETRY_MAX; attempt++)
  {
    flushE22();
    buildReq(&txPacket, nodeId, seq);

    Serial.print("REQ node=");
    Serial.print(nodeId);
    Serial.print(" seq=");
    Serial.print(seq);
    Serial.print(" attempt=");
    Serial.println(attempt);

    wakeNode();

    bool requestSent = false;
    for (uint8_t repeat = 0; repeat < REQ_REPEAT_COUNT; repeat++)
    {
      if (sendPacket(&txPacket))
      {
        requestSent = true;
      }
      delay(REQ_REPEAT_GAP_MS);
    }

    if (!requestSent)
    {
      Serial.println("REQ send failed");
      continue;
    }

    uint32_t start = millis();
    while ((millis() - start) < RESP_TIMEOUT_MS)
    {
      if (!readPacket(&rxPacket))
      {
        continue;
      }

      if (rxPacket.type != LORA_TYPE_RESP ||
          rxPacket.src_id != nodeId ||
          rxPacket.dst_id != MASTER_ID ||
          rxPacket.seq != seq)
      {
        continue;
      }

      buildAck(&txPacket, &rxPacket);
      for (uint8_t ackRepeat = 0; ackRepeat < 3; ackRepeat++)
      {
        sendPacket(&txPacket);
        delay(100);
      }

      printSoilData(&rxPacket);
      String json = makeJson(&rxPacket, getRealEpochTime());
      sendJsonToServer(&rxPacket, json);
      nodeSeqs[nodeIndex]++;
      return true;
    }
  }

  Serial.print("NODE TIMEOUT: ");
  Serial.println(nodeId);
  return false;
}

void setup()
{
  Serial.begin(115200);
  delay(1000);

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  firebaseClient.setInsecure();

  pinMode(E22_M0_PIN, OUTPUT);
  pinMode(E22_M1_PIN, OUTPUT);
  pinMode(E22_AUX_PIN, INPUT_PULLUP);
  bool e22Started = e22ttl.begin();
  E22Serial.setTimeout(1500);
  Serial.print("E22 library begin: ");
  Serial.println(e22Started ? "OK" : "FAILED");
  Serial.print("E22 AUX level: ");
  Serial.println(digitalRead(E22_AUX_PIN));
  e22EnsureMatchedConfiguration();

  Serial.println("ESP32 E22 PUSH RECEIVER");
  Serial.print("UART baud: ");
  Serial.println(E22_BAUDRATE);
#if E22_TEXT_RECEIVE_MODE
  Serial.println("TEXT MODE");
#endif
}

void loop()
{
  maintainWiFi();
  serviceTimeSync();

#if E22_TEXT_RECEIVE_MODE
  if (e22ttl.available() > 1)
  {
    ResponseContainer response = e22ttl.receiveMessage();
    if (response.status.code == 1)
    {
      Serial.print(response.data);
    }
    else
    {
      Serial.println(response.status.getResponseDescription());
    }
  }
#elif ENABLE_MASTER_POLLING
  for (uint8_t i = 0; i < sizeof(nodeIds) / sizeof(nodeIds[0]); i++)
  {
    pollNode(i);
    delay(NODE_GAP_MS);
  }
#else
  if (readPacket(&rxPacket))
  {
    lastRadioPacketMs = millis();

    if ((rxPacket.type == LORA_TYPE_ACK) &&
        (rxPacket.src_id == MASTER_ID) &&
        (rxPacket.dst_id == TIME_SYNC_FINAL_NODE_ID) &&
        (rxPacket.hop_src_id == TIME_SYNC_FIRST_NODE_ID) &&
        (rxPacket.hop_dst_id == MASTER_ID) &&
        (rxPacket.seq == timeSyncCurrentSeq) &&
        (timeSyncAcked == false))
    {
      timeSyncAcked = true;
      timeSyncAckCount++;
      Serial.print("TIME_SYNC END-TO-END ACK via node=");
      Serial.print(rxPacket.hop_src_id);
      Serial.print(" seq=");
      Serial.println(rxPacket.seq);
    }
    else if ((rxPacket.type == LORA_TYPE_RESP) &&
             (rxPacket.dst_id == MASTER_ID) &&
             (rxPacket.hop_dst_id == MASTER_ID))
    {
      ackResponse(&rxPacket);

      if (shouldPrintPacket(&rxPacket))
      {
        printSoilData(&rxPacket);
        String json = makeJson(&rxPacket, getRealEpochTime());
        Serial.println(json);
        if (!enqueueFirebasePacket(&rxPacket))
        {
          Serial.println("FIREBASE QUEUE FULL");
        }
      }
    }
  }
#endif

  processFirebaseQueue();
}
