/* * farm-node.ino — Farmers IoT Toolkit, WATER + VALVE NODE (module 1 sensing, * module 2 actuation) * * Reads the water-tank probe, POSTs it as JSON to the Node-RED base station * (Module 4), and drives the master irrigation valve from a state the base * station holds. Import flows/both-sensors-flow.json to receive it. * * water QDY30A RS485 @ 9600 -> HW-0519 -> RXD=D7 TXD=D1 (probe needs 18V) * valve relay board -> IN=D2 (see VALVE_ACTIVE_LOW) * (pins as physically soldered 2026-07-19 — water sits on D7/D1, which was the * soil pair in the original layout; firmware follows the iron, not the other * way round.) * * ** THE SOIL PROBE IS NOT ON THIS BOARD. ** It moved to its own battery-powered * deep-sleep node (firmware/soil-node-sleep/) on 2026-08-03, and the soil bus was * removed from this sketch on 2026-08-05. Do not re-add it here. While it was * still compiled in, this board polled a bus with nothing on it and POSTed * {"node":"soil-bed-1","ok":false} every cycle — under the SAME name as the real * soil-bed-1, so two devices published into one identity and the dashboard * reported a healthy node as 90% faulty. See STATUS.md 2026-08-03. * * The one-ESP8266-two-RS485-buses build (both sensors, one board, separate * transceivers so each keeps slave address 1 and its own baud with zero register * writes) is still demonstrated in firmware/bench-both/ — that is where to look * for it, not here. * * ** NO DE PIN. ** The HW-0519 is auto-direction — it derives transmit enable from * TXD via an onboard 74HC04. water-level.ino toggles D1 as DE, which is exactly * wrong on this board: D1 is water's TXD here, so driving it as DE would clamp the * transceiver's driver onto the bus and the probe could never reply. Never run * water-level.ino on this node, and never add a DE pin back to this sketch. * * Pins in use: D7/D1 water, D2 valve. D6/D5 are FREE since the soil bus left. * D3/D4/D8 are boot straps and D0 has no interrupts (so it cannot do SoftwareSerial * RX). See the Module 3 page on the site for the full table. * * Board: NodeMCU 1.0 (ESP-12E Module) [Arduino IDE -> Boards Manager -> esp8266] * No libraries to install. Modbus frame and CRC are hand-rolled so you can see * exactly what goes on the wire — that matters when it doesn't work. * * Copy config.example.h to config.h and edit it before flashing. * * arduino-cli compile --upload --fqbn esp8266:esp8266:nodemcuv2 \ * -p /dev/ttyUSB0 firmware/farm-node * (compile --upload — plain `upload` flashes a STALE binary, devlog 2026-07-16) * * OTA (over-the-air) update — reflash over WiFi, no cable. Needs OTA_ENABLE=1 in * config.h. The FIRST flash carrying OTA must still go over USB (above); after it * boots, the node advertises itself and every later update can go wireless: * * arduino-cli compile --fqbn esp8266:esp8266:nodemcuv2 \ * --output-dir /tmp/farm-node firmware/farm-node * python3 ~/.arduino15/packages/esp8266/hardware/esp8266//tools/espota.py \ * -i farm-node-1.local -p 8266 --auth=change-me-ota \ * -f /tmp/farm-node/farm-node.ino.bin * ( is the installed core version, e.g. 3.1.2) * * (-i takes .local or the node's IP; --auth is OTA_PASSWORD. * Both node and laptop must be on the same hotspot. The node closes the valve * before flashing and reboots when done.) */ #include #include #include #include #include "config.h" // OTA fallback guards — a pre-OTA config.h still compiles (same #ifndef pattern // as the VALVE_* and POST_* fallbacks below). Copy real values from config.example.h. #ifndef OTA_ENABLE #define OTA_ENABLE 0 #endif #ifndef OTA_HOSTNAME #define OTA_HOSTNAME "farm-node" #endif #ifndef OTA_PASSWORD #define OTA_PASSWORD "" #endif // ArduinoOTA only makes sense with a live radio, so it's gated on !BENCH_MODE too. // Pulling the header in only when used keeps the bench build lean. #if !BENCH_MODE && OTA_ENABLE #include #endif // Valve config fallbacks — a pre-valve config.h still compiles (same idea as the // POST_* #ifndef guards). Copy the real values from config.example.h. #ifndef VALVE_PIN #define VALVE_PIN D2 #endif #ifndef VALVE_ACTIVE_LOW #define VALVE_ACTIVE_LOW 1 #endif #ifndef VALVE_POLL_S #define VALVE_POLL_S 1 #endif #ifndef VALVE_PATH #define VALVE_PATH "/valve" #endif #ifndef VALVE_MAX_OPEN_S #define VALVE_MAX_OPEN_S 0 #endif // --- pack voltage sense (Module 3) -------------------------------------------- // Divider soldered as a flying assembly: pack+ ─[100k]─ A0 ─[33k]─ P− // // The NodeMCU carries its OWN 220k/100k divider between the A0 pin and the chip's // 1.0V TOUT input. That 320k hangs across our 33k bottom leg and drags it to // ~30k, so the junction sits at ~2.90V with A0 connected but ~3.13V with it // disconnected. Both are correct; they are different circuits. It also means the // theoretical mV/count is wrong by ~8%, and clone boards vary further because // nobody holds those onboard resistors to 1%. PACK_MV_PER_COUNT is therefore // MEASURED against a meter, never derived. See project/bench/04. #ifndef PACK_MV_PER_COUNT #define PACK_MV_PER_COUNT 13.6f // placeholder — see setup()'s warning #endif #ifndef PACK_CALIBRATED #define PACK_CALIBRATED 0 // set to 1 in config.h once you have two points #endif // The ESP8266's ADC picks up hash off its own radio, so one sample is not a // reading. Median of five discards the spikes an average would smear through the // result — same argument as resyncing on a frame header rather than trusting // byte 0. uint16_t packCounts() { int s[5]; for (uint8_t i = 0; i < 5; i++) { s[i] = analogRead(A0); delay(2); } for (uint8_t i = 1; i < 5; i++) for (uint8_t j = i; j && s[j] < s[j-1]; j--) { int t = s[j]; s[j] = s[j-1]; s[j-1] = t; } return (uint16_t)s[2]; } // --- phone charger duty-cycle (Stage 2) --------------------------------------- // Relay on the POSITIVE line between the fuse and the phone buck's INPUT, so the // buck's own idle draw goes away when off and we switch ~0.5 A at 12 V rather than // 2 A at 5 V. NO contact, so relay unpowered = charger off, the same fail-safe the // valve uses. #ifndef CHARGER_PIN #define CHARGER_PIN D6 // GPIO12 — free since the soil bus moved to its own node #endif #ifndef CHARGER_ACTIVE_LOW #define CHARGER_ACTIVE_LOW 0 // this node's relay boards are active-HIGH (2026-07-17) #endif #ifndef PACK_ON_MV #define PACK_ON_MV 12300 // pack has recovered — there is surplus, charge the phone #endif #ifndef PACK_OFF_MV #define PACK_OFF_MV 11600 // pack is falling — let the phone coast on its own battery #endif // A0 counts outside this band cannot be a real 3S pack on this divider: 12.6 V full // lands near 920 and the ADC saturates around 14 V, while anything under ~100 is // below any voltage this pack can reach and still be connected. Outside the band the // number is a FAULT — open divider, lost ground bond, wedged ADC — not a measurement, // and the charger must not act on it. #ifndef PACK_RAW_MIN #define PACK_RAW_MIN 100 #endif #ifndef PACK_RAW_MAX #define PACK_RAW_MAX 1010 #endif bool chargerOn = false; inline int chargerLevel(bool on) { return on ? (CHARGER_ACTIVE_LOW ? LOW : HIGH) : (CHARGER_ACTIVE_LOW ? HIGH : LOW); } void setCharger(bool on) { if (on == chargerOn) return; chargerOn = on; digitalWrite(CHARGER_PIN, chargerLevel(on)); Serial.printf("CHARGER %s\n", on ? "ON" : "OFF"); } // Hysteresis, not a threshold. Between OFF and ON we deliberately do NOTHING — that // dead band is what stops the relay hunting when the phone's own charging load drags // the pack back across a single setpoint. At 53 mOhm pack impedance a 1.5 A charge // costs ~80 mV of sag against a 700 mV band, so it cannot chatter. // // An implausible reading forces OFF rather than ON. With no trustworthy pack voltage // you cannot know there is surplus, and this design's stated priority is that the // sensors keep logging even when the phone does not: you lose your view of the farm // before you lose the data. void updateCharger(uint16_t raw, uint16_t mv) { if (raw < PACK_RAW_MIN || raw > PACK_RAW_MAX) { setCharger(false); return; } if (!chargerOn && mv >= PACK_ON_MV) setCharger(true); else if ( chargerOn && mv <= PACK_OFF_MV) setCharger(false); } // --- water bus (the only RS485 bus on this node) --- #define WATER_RX D7 // GPIO13 <- HW-0519 RXD (as soldered 2026-07-19) #define WATER_TX D1 // GPIO5 -> HW-0519 TXD (as soldered 2026-07-19) #define WATER_BAUD 9600 // QDY30A default. NOT 4800 — that's the THC-S soil probe, // which lives on its own node now (soil-node-sleep/). #define WATER_REG 0x0004 // level, int16 SIGNED, 1 count = 1 mm (ruler-confirmed) #define SENSOR_ADDR 1 // the QDY30A ships as address 1 and we never rewrite it SoftwareSerial water(WATER_RX, WATER_TX); // --- valve (Module 2), MANUAL control for now --------------------------------- // No moisture thresholds yet: the base station (Node-RED) holds the desired // state and a button on its page sets it; this node polls that state and drives // the relay on D2. Thresholds + the full safety cutoff are the next step. bool valveOpen = false; unsigned long valveOpenedAt = 0; // Last time the base station CONFIRMED it wants the valve open. valveSafety() counts // from this, not from valveOpenedAt — see there. unsigned long valveHeardAt = 0; // Relay-OFF must equal valve-CLOSED so a crash, reset, or lost link fails safe. // Most 1-channel boards are active-LOW (IN low = energised) — set VALVE_ACTIVE_LOW. inline int valveLevel(bool open) { return open ? (VALVE_ACTIVE_LOW ? LOW : HIGH) : (VALVE_ACTIVE_LOW ? HIGH : LOW); } void setValve(bool open) { if (open && !valveOpen) valveOpenedAt = valveHeardAt = millis(); // start both timers digitalWrite(VALVE_PIN, valveLevel(open)); valveOpen = open; } // Dead-man: shut an open valve after VALVE_MAX_OPEN_S with no word from the base // station. 0 = disabled (pure manual). // // It counts from the last poll that CONFIRMED "open", not from the moment the valve // opened. Counting from the opening protected nothing: at the limit it shut the // valve, and one second later the next poll re-opened it — a water-hammer blip in // every run longer than the limit, and a walked-away valve still open. The case the // node alone can catch is the LINK dying (a failed poll holds the last state), and // that is exactly when the confirmations stop. How long a run lasts is the base // station's job: its 30 min / 1 hour buttons close the valve on the node's poll. void valveSafety() { if (VALVE_MAX_OPEN_S > 0 && valveOpen && (millis() - valveHeardAt) > (unsigned long)VALVE_MAX_OPEN_S * 1000UL) { setValve(false); Serial.println(F("VALVE force-closed: no word from the base station")); } } uint16_t modbusCRC(const uint8_t *buf, uint8_t len) { uint16_t crc = 0xFFFF; for (uint8_t i = 0; i < len; i++) { crc ^= buf[i]; for (uint8_t b = 0; b < 8; b++) crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : (crc >> 1); } return crc; } /* * Read `count` registers from `start` on `port`. True ONLY on a CRC-valid frame. * Everything downstream trusts this, so it fails closed: a bad frame is no data, * never partial data. * * Slides along the receive window looking for a well-formed header rather than * parsing from byte 0. Measured on our hardware neither HW-0519 echoes (replies * arrive at exactly frameLen), but the FT232 USB adapter definitely does, and the * slide costs nothing when there's no echo. The CRC is the real proof; the header * match just finds candidates fast. */ // Bench diagnostics. "no valid modbus frame" is one bit of information, and it // hides the only distinction that matters when a probe goes quiet: // // n == 0 nothing reached the ESP at all — the probe never transmitted, or // the HW-0519's RXD never reaches D7. A wiring/power/probe fault. // n > 0 the probe IS replying and something is mangling it — bus integrity, // grounding, or baud. A signal-quality fault, an entirely different hunt. // // Compiled out unless BENCH_MODE, so the field build is unchanged. #if BENCH_MODE static void rs485Dump(const char *why, const uint8_t *buf, uint8_t n) { Serial.printf(" RS485 %-14s n=%u", why, n); if (n) { Serial.print(" rx:"); for (uint8_t i = 0; i < n; i++) Serial.printf(" %02X", buf[i]); } Serial.println(); } #define RS485_DUMP(why, buf, n) rs485Dump(why, buf, n) #else #define RS485_DUMP(why, buf, n) ((void)0) #endif bool readRegisters(SoftwareSerial &port, uint16_t start, uint16_t count, uint16_t *out) { uint8_t req[8] = { SENSOR_ADDR, 0x03, (uint8_t)(start >> 8), (uint8_t)(start & 0xFF), (uint8_t)(count >> 8), (uint8_t)(count & 0xFF), 0, 0 }; uint16_t crc = modbusCRC(req, 6); req[6] = crc & 0xFF; // CRC is little-endian on the wire req[7] = crc >> 8; while (port.available()) port.read(); // drop stale bytes port.write(req, 8); port.flush(); RS485_DUMP("tx", req, 8); // confirm we send a well-formed request const uint8_t frameLen = 5 + count * 2; // addr fn bytecount [data] crc crc uint8_t buf[64]; uint8_t n = 0; // 300ms is ~5x the worst real turnaround. Generous enough not to clip a slow // reply, short enough that a dead probe reports fast instead of burning CPU. unsigned long deadline = millis() + 300; while (millis() < deadline && n < sizeof(buf)) { if (port.available()) { buf[n++] = port.read(); if (n >= frameLen + 8) break; } } if (n < frameLen) { RS485_DUMP(n ? "short" : "SILENT", buf, n); return false; } for (uint8_t i = 0; i + frameLen <= n; i++) { if (buf[i] != SENSOR_ADDR) continue; if (buf[i+1] != 0x03) continue; if (buf[i+2] != count * 2) continue; // byte count — skips any echo uint16_t rxCRC = buf[i+frameLen-2] | (buf[i+frameLen-1] << 8); if (rxCRC != modbusCRC(buf + i, frameLen - 2)) continue; for (uint16_t r = 0; r < count; r++) out[r] = (buf[i + 3 + r*2] << 8) | buf[i + 4 + r*2]; return true; } RS485_DUMP("bad-crc/frame", buf, n); // bytes arrived but none of them parse return false; } // Retry — the first exchange after an idle period can lose its header to the // transceiver's TX->RX turnaround. Known, harmless, just retry. bool readWithRetry(SoftwareSerial &port, uint16_t start, uint16_t count, uint16_t *out, uint8_t tries) { for (uint8_t t = 0; t < tries; t++) { if (readRegisters(port, start, count, out)) return true; delay(60); } return false; } #if BENCH_MODE void connectWiFi() {} bool postJSON(const char *, const String &) { return false; } void pollValveCommand() {} #else void connectWiFi() { if (WiFi.status() == WL_CONNECTED) return; Serial.printf("WiFi: connecting to %s", WIFI_SSID); WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASSWORD); // Bounded wait. If the base station is down we still want to keep reading and // reporting over serial rather than blocking here forever. unsigned long deadline = millis() + 20000; while (WiFi.status() != WL_CONNECTED && millis() < deadline) { delay(500); Serial.print("."); } if (WiFi.status() == WL_CONNECTED) { Serial.println(" ok"); Serial.println(" my ip : " + WiFi.localIP().toString()); // The gateway IS the phone running the hotspot, so this is where we POST. // Printing it turns a failed POST from a mystery into an address comparison. Serial.println(" gateway : " + WiFi.gatewayIP().toString() + " <- the phone"); Serial.printf(" rssi : %d dBm\n", WiFi.RSSI()); } else { Serial.println(" FAILED (will retry next cycle)"); } } /* * An explicit POST_HOST wins; otherwise derive from the gateway. * * The phone running the hotspot IS our gateway, so it can't go stale — and Android * randomises the hotspot subnet and can reshuffle on any restart. A hardcoded IP * fails silently: the node keeps reading happily and nothing ever arrives. * Deriving it deletes that whole failure class, and means a farmer never has to * find an IP address — which is the better toolkit anyway. */ String postUrl(const char *path) { String host = (sizeof(POST_HOST) > 1) // sizeof("") == 1 ? String(POST_HOST) : WiFi.gatewayIP().toString(); return "http://" + host + ":" + String(POST_PORT) + path; } bool postJSON(const char *path, const String &json) { if (WiFi.status() != WL_CONNECTED) return false; const String url = postUrl(path); WiFiClient client; HTTPClient http; if (!http.begin(client, url)) { Serial.println("POST: bad URL: " + url); return false; } http.addHeader("Content-Type", "application/json"); int code = http.POST(json); http.end(); if (code > 0 && code < 300) { Serial.printf("POST %s: %d ok\n", path, code); return true; } // Negative = client-side (couldn't open a socket); positive = the server // answered and didn't like it. Printing the URL turns "-1" from a mystery into // "that address isn't on this network", which is the usual cause. Serial.printf("POST %s: failed (%d) -> %s\n", path, code, url.c_str()); return false; } // Ask the base station whether the valve should be open. Manual for now — a // button on the Node-RED page sets it, and GET /valve returns "1" or "0". // // We POLL rather than run a server on the node because the phone's hotspot hands // the node its IP and can change it; the node always knows how to reach the base // station (its gateway), never the reverse. A failed poll HOLDS the last state — // a brief WiFi blip shouldn't cycle the valve. void pollValveCommand() { if (WiFi.status() != WL_CONNECTED) return; const String url = postUrl(VALVE_PATH); WiFiClient client; HTTPClient http; if (!http.begin(client, url)) return; int code = http.GET(); if (code == 200) { const String body = http.getString(); const bool want = body.indexOf('1') >= 0; // "1" = open, "0" = closed if (want) valveHeardAt = millis(); // the base station is there and still wants it open if (want != valveOpen) { setValve(want); Serial.printf("VALVE -> %s (base station)\n", want ? "OPEN" : "CLOSED"); } } http.end(); } #endif // BENCH_MODE // --- OTA (over-the-air firmware update) --------------------------------------- // Lets you reflash over WiFi once the node is on battery / sealed / up a tank. // Everything here is a no-op when OTA is off or in bench mode, so the rest of the // sketch calls setupOTA()/otaHandle()/otaDelay() unconditionally. #if !BENCH_MODE && OTA_ENABLE bool otaReady = false; // begin() runs ONCE per boot, and only after WiFi is up (it starts mDNS). Called // every loop but self-guards, so it just latches on at the first connected pass. void setupOTA() { if (otaReady || WiFi.status() != WL_CONNECTED) return; ArduinoOTA.setHostname(OTA_HOSTNAME); if (sizeof(OTA_PASSWORD) > 1) ArduinoOTA.setPassword(OTA_PASSWORD); // "" == no auth // Fail safe: the sketch stops running during a flash, so a relay latched OPEN // would stay open across the whole update + reboot. Shut the valve first. ArduinoOTA.onStart([]() { setValve(false); Serial.println(F("OTA: update starting — valve forced CLOSED, hold still")); }); ArduinoOTA.onEnd([]() { Serial.println(F("\nOTA: complete, rebooting")); }); ArduinoOTA.onProgress([](unsigned int done, unsigned int total) { Serial.printf("OTA: %u%%\r", total ? (done * 100) / total : 0); }); ArduinoOTA.onError([](ota_error_t e) { Serial.printf("\nOTA: error %u\n", e); }); ArduinoOTA.begin(); otaReady = true; Serial.printf("OTA: ready as \"%s\" (%s.local) at %s\n", OTA_HOSTNAME, OTA_HOSTNAME, WiFi.localIP().toString().c_str()); } void otaHandle() { if (otaReady) ArduinoOTA.handle(); } // A drop-in for delay() that keeps servicing OTA while it waits. The sensor cadence // spends most of its time sleeping between polls; a plain delay() there would make // the node deaf to an update for seconds and fail the handshake. Sensor reads // themselves (~sub-second) are left alone — poking OTA mid-read risks the bit-banged // SoftwareSerial timing, and a missed window just means retrying the upload. void otaDelay(unsigned long ms) { unsigned long start = millis(); while (millis() - start < ms) { otaHandle(); delay(10); } } #else void setupOTA() {} void otaHandle() {} void otaDelay(unsigned long ms) { delay(ms); } #endif // Only publish RSSI when there's a link to measure. Disconnected, WiFi.RSSI() // returns a sentinel (we saw 31) that looks exactly like a real reading — and // publishing a sentinel as data is the same sin as reporting a wrong depth. // The valve's ACTUAL pin state, as a JSON fragment. // // It rides on the water payload because the valve belongs to the NODE, not to a // probe. It used to be reported ONLY in the soil message — which meant dropping // the soil half would have silently blinded both dashboards to whether the valve // ever obeyed. /water is now the only place it is published, so nothing here may // become conditional on a sensor again. // Also publishes how long the valve has ACTUALLY been open, and how long it would // have left before valveSafety() shuts it if the base station went silent now. Only // this node can answer either: the base station's copy is a command, and it is // forgotten on a Node-RED restart, so a dashboard counting from that would restart a // run timer on a valve that never closed. valve_close_in_s sits near the full limit // while the link is up — it is a dead-man margin, not a scheduled close. // Unsigned subtraction, so a millis() rollover at 49 days is still right. String valveField() { String s = String(",\"valve\":\"") + (valveOpen ? "open" : "closed") + "\""; if (valveOpen) { unsigned long openS = (millis() - valveOpenedAt) / 1000UL; s += ",\"valve_open_s\":" + String(openS); if (VALVE_MAX_OPEN_S > 0) { long left = (long)VALVE_MAX_OPEN_S - (long)((millis() - valveHeardAt) / 1000UL); s += ",\"valve_close_in_s\":" + String(left < 0 ? 0 : left); } } return s; } void appendTail(String &json) { #if !BENCH_MODE if (WiFi.status() == WL_CONNECTED) json += ",\"rssi\":" + String(WiFi.RSSI()); #endif // The pack belongs to the NODE, not to a probe — so it rides here, in the tail // that BOTH the ok:true and ok:false branches call. Put it in the ok:true // branch and a failing water probe silently blinds you to the battery too, // exactly when you most want to know what the battery is doing. // // Read BEFORE postJSON(), never during: the ADC is at its noisiest while the // radio transmits, and appendTail() is the last thing to run before the POST. // // Raw counts are published alongside the volts on purpose. The scale constant // is a per-board measurement that can be wrong, and logged counts let it be // re-derived later from data already on the dashboard instead of a second trip // out to the plantation with a meter. Same reason the water payload keeps raw. // The charger decision rides here deliberately: appendTail() is the last thing to // run before the POST, which is the quietest the ADC gets, and it is called from // BOTH the ok:true and ok:false branches so a failing water probe can never stop // the pack being read or the charger being serviced. uint16_t pc = packCounts(); uint16_t mv = (uint16_t)(pc * PACK_MV_PER_COUNT); updateCharger(pc, mv); json += ",\"pack_raw\":" + String(pc) + ",\"pack_mv\":" + String(mv); if (pc < PACK_RAW_MIN || pc > PACK_RAW_MAX) json += ",\"pack_ok\":false"; // the reading is a fault, not a low battery — say so #if !PACK_CALIBRATED json += ",\"pack_cal\":false"; // don't let an uncalibrated volt look like a measured one #endif json += ",\"charger\":\"" + String(chargerOn ? "on" : "off") + "\""; json += ",\"uptime_s\":" + String(millis() / 1000) + "}"; } void readAndSendWater() { uint16_t v[1]; String json; if (readWithRetry(water, WATER_REG, 1, v, 5)) { int16_t raw = (int16_t)v[0]; // SIGNED — a probe just under its zero returns // a real negative, not 65000-odd int depth_mm = raw - DRY_OFFSET_COUNTS; // A probe reading just under its zero is normal, not a fault. Clamp what we // report so the dashboard never shows a negative tank, but keep raw in the // payload so a drifting zero stays visible to whoever looks. int reported = depth_mm < 0 ? 0 : depth_mm; json = String("{\"node\":\"") + NODE_ID_WATER + "\",\"ok\":true" + ",\"raw\":" + raw + ",\"depth_mm\":" + reported; if (TANK_FULL_MM > 0) { float pct = (100.0f * reported) / (float)TANK_FULL_MM; if (pct > 100.0f) pct = 100.0f; // overfull reads as full, not 103% json += ",\"percent\":" + String(pct, 1); } json += valveField(); appendTail(json); Serial.printf("WATER raw=%-6d depth=%d mm%s\n", raw, depth_mm, depth_mm < 0 ? " (below zero — check the dry offset)" : ""); } else { // Report the failure rather than staying silent. A missing message looks // identical to a dead node or dead WiFi; an explicit error tells the // dashboard the node is alive and the probe is not. json = String("{\"node\":\"") + NODE_ID_WATER + "\",\"ok\":false" + ",\"error\":\"no valid modbus frame\"" + valveField(); appendTail(json); Serial.println(F("WATER FAILED — check 18V at the probe, and blue=A yellow=B")); } Serial.println(json); postJSON(POST_PATH_WATER, json); } void setup() { Serial.begin(115200); // debug, over USB // Valve fails safe from the first instant: write the CLOSED level, THEN make // the pin an output, so it can't glitch through OPEN during boot. digitalWrite(VALVE_PIN, valveLevel(false)); pinMode(VALVE_PIN, OUTPUT); valveOpen = false; // Charger fails safe from the first instant, same pattern as the valve: write the // OFF level, THEN make the pin an output, so it cannot glitch the relay on during // boot. First real reading corrects it within one report. digitalWrite(CHARGER_PIN, chargerLevel(false)); pinMode(CHARGER_PIN, OUTPUT); chargerOn = false; water.begin(WATER_BAUD); delay(500); Serial.println(F("\n\nFarmers IoT Toolkit — water + valve node")); Serial.printf("water=%s dry_offset=%d tank_full=%d mm\n", NODE_ID_WATER, DRY_OFFSET_COUNTS, TANK_FULL_MM); Serial.println(F("soil probe is a separate node (soil-node-sleep) — D6/D5 free here.")); Serial.printf("valve on GPIO%d active-%s poll=%ds max-open=%ds (manual)\n", VALVE_PIN, VALVE_ACTIVE_LOW ? "LOW" : "HIGH", VALVE_POLL_S, VALVE_MAX_OPEN_S); if (TANK_FULL_MM <= 0) Serial.println(F("tank_full_mm not set — reporting depth only, no percentage")); // Say out loud whether the pack number can be believed. An uncalibrated // divider still reports a plausible-looking voltage, and a plausible wrong // number is worse than no number — it's the same failure class as the VIN pin // that read 3.10V open and collapsed under load. Serial.printf("pack sense on A0 %.2f mV/count %s\n", PACK_MV_PER_COUNT, PACK_CALIBRATED ? "(calibrated)" : "(PLACEHOLDER — calibrate before believing volts)"); Serial.printf("pack now: %u counts = %u mV\n", packCounts(), (uint16_t)(packCounts() * PACK_MV_PER_COUNT)); Serial.printf("charger on GPIO%d active-%s ON>=%umV OFF<=%umV (boots OFF)\n", CHARGER_PIN, CHARGER_ACTIVE_LOW ? "LOW" : "HIGH", PACK_ON_MV, PACK_OFF_MV); #if BENCH_MODE // Radio off, and mean it: the SDK auto-connects to the last known AP on boot // whether you asked or not. That's ~80mA with 300mA spikes on a rail already // feeding a boost, plus interrupt pressure on two bit-banged serial buses. WiFi.mode(WIFI_OFF); WiFi.forceSleepBegin(); Serial.println(F("BENCH MODE — WiFi off, polling every 2s, serial only.")); Serial.println(F("Set BENCH_MODE 0 in config.h once both probes read reliably.\n")); #else connectWiFi(); #endif } void loop() { connectWiFi(); // no-op when already up, reconnects when it isn't setupOTA(); // latches on at the first connected pass, no-op after otaHandle(); // service any in-flight update before the blocking sensor reads readAndSendWater(); Serial.println(); #if BENCH_MODE delay(2000); #else // Keep the sensor cadence at REPORT_INTERVAL_S, but poll the valve command // every VALVE_POLL_S during the wait so the base-station button responds in // ~1s instead of once per report. valveSafety() runs on the same tick, and // otaDelay() keeps OTA alive across the wait (a plain delay would make the node // deaf to an update for the whole interval). for (int elapsed = 0; elapsed < REPORT_INTERVAL_S; elapsed += VALVE_POLL_S) { pollValveCommand(); valveSafety(); otaDelay((unsigned long)VALVE_POLL_S * 1000UL); } #endif }