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