Build Sheets

Source

node.ino

/*
 * node.ino — SOIL-01 soil-moisture tree node (producer, two-hop)
 *
 * Board  : Seeed XIAO ESP32-C6 (Arduino esp32 core 3.x REQUIRED for C6)
 * Sensor : capacitive soil probe, AOUT -> A0; VCC from D3 (read-time only)
 * Power  : 18650 on the BAT pads (onboard charger; charge via node USB-C)
 * Role   : wake hourly -> read -> one ESP-NOW packet to the relay -> deep
 *          sleep. Everything runs in setup(); loop() is empty.
 *
 * No libraries beyond the esp32 core (WiFi.h / esp_now.h / esp_wifi.h).
 * Build from a SKETCHBOOK COPY, never the repo checkout.
 */

#include <WiFi.h>
#include <esp_now.h>
#include <esp_wifi.h>
#include "config.h"

#define FW_VERSION "1.0.0"

#if DEBUG
  #define LOG(x)    Serial.print(x)
  #define LOGLN(x)  Serial.println(x)
#else
  #define LOG(x)
  #define LOGLN(x)
#endif

// Byte-identical struct lives in relay/relay.ino — change both together.
typedef struct __attribute__((packed)) {
  char device_id[24];
  char fw_version[12];
  int16_t moisture_raw;   // -1 = read failed
  float moisture_pct;     // NAN = invalid
  uint16_t battery_mv;
  uint8_t sensor_ok;      // 0/1
} SoilPacket;

static const uint8_t relayMac[6] = RELAY_MAC;
volatile bool sendDone = false;
volatile bool sendOk = false;

void onSent(const wifi_tx_info_t *info, esp_now_send_status_t status) {
  sendOk = (status == ESP_NOW_SEND_SUCCESS);
  sendDone = true;
}

// Median of 9 — capacitive probes are noisy at the mV level.
int readSoilRaw() {
  int v[9];
  for (int i = 0; i < 9; i++) { v[i] = analogRead(PIN_SOIL); delay(10); }
  for (int i = 1; i < 9; i++) {          // insertion sort, 9 elements
    int key = v[i], j = i - 1;
    while (j >= 0 && v[j] > key) { v[j + 1] = v[j]; j--; }
    v[j + 1] = key;
  }
  return v[4];
}

void goToSleep(bool forever) {
  digitalWrite(PIN_SENSOR_PWR, LOW);
  if (!forever) esp_sleep_enable_timer_wakeup(SLEEP_SECONDS * 1000000ULL);
  // forever: no wake source armed — recharge + reset button revives the node
  esp_deep_sleep_start();
}

void setup() {
#if DEBUG
  Serial.begin(115200);
  delay(200);
#endif

  // --- read the probe (powered only for the read) ---
  pinMode(PIN_SENSOR_PWR, OUTPUT);
  digitalWrite(PIN_SENSOR_PWR, HIGH);
  delay(200);  // probe settle
  analogReadResolution(12);
  int raw = readSoilRaw();
  digitalWrite(PIN_SENSOR_PWR, LOW);

  // --- battery ---
  uint32_t vbatMv = (uint32_t)(analogReadMilliVolts(PIN_VBAT) * VBAT_DIVIDER);

  // --- derive ---
  bool ok = raw >= SENSOR_MIN_RAW && raw <= SENSOR_MAX_RAW;
  float pct = NAN;
  if (ok) {
    pct = 100.0f * (RAW_AIR - raw) / (float)(RAW_AIR - RAW_WATER);
    if (pct < 0) pct = 0;
    if (pct > 100) pct = 100;
  }

  SoilPacket pkt = {};
  strncpy(pkt.device_id, DEVICE_ID, sizeof(pkt.device_id) - 1);
  strncpy(pkt.fw_version, FW_VERSION, sizeof(pkt.fw_version) - 1);
  pkt.moisture_raw = ok ? (int16_t)raw : -1;
  pkt.moisture_pct = pct;
  pkt.battery_mv = (uint16_t)vbatMv;
  pkt.sensor_ok = ok ? 1 : 0;

  LOG("raw="); LOG(raw); LOG(" pct="); LOG(pct);
  LOG(" vbat="); LOGLN(vbatMv);

  // --- ESP-NOW: channel must match the relay's (pinned on the router) ---
  WiFi.mode(WIFI_STA);
  esp_wifi_set_channel(WIFI_CHANNEL, WIFI_SECOND_CHAN_NONE);
  if (esp_now_init() != ESP_OK) { LOGLN("esp_now_init failed"); goToSleep(false); }
  esp_now_register_send_cb(onSent);

  esp_now_peer_info_t peer = {};
  memcpy(peer.peer_addr, relayMac, 6);
  peer.channel = WIFI_CHANNEL;
  peer.encrypt = false;
  esp_now_add_peer(&peer);

  for (int attempt = 0; attempt < SEND_RETRIES; attempt++) {
    sendDone = false;
    esp_now_send(relayMac, (const uint8_t *)&pkt, sizeof(pkt));
    unsigned long t0 = millis();
    while (!sendDone && millis() - t0 < SEND_WAIT_MS) delay(5);
    if (sendOk) break;
    LOGLN("send retry");
  }
  LOGLN(sendOk ? "delivered" : "gave up");

  // Low battery: this reading already announced it — now protect the cell.
  goToSleep(vbatMv < LOW_BATT_CUTOFF_MV);
}

void loop() {}  // never reached — the node sleeps out of setup()