Field-node firmware for the AgroSmart precision-agriculture system. A NodeMCU ESP-12E (ESP8266) samples a water sensor board and a soil probe, applies calibration and temperature compensation, and publishes readings as versioned JSON.
The ESP8266 is not 5 V tolerant — 3.6 V absolute maximum on an input. Both
sensor boards are 5 V parts, so every sensor-TX → ESP-RX line needs a resistor
divider (1 kΩ series + 2 kΩ to ground) or a level shifter, fitted before first
power-on. Details in docs/hardware/wiring.md.
Watch polarity on the soil probe. Its manual warns that reversed power or swapped A/B differential lines can destroy the device.
| Device | Interface | Provides |
|---|---|---|
| 4-in-1 water board | TTL UART, 9600 8N1, 5 V | water pH, light, temperature, water level |
| 7-in-1 soil probe | RS485 Modbus RTU, 4800 8N1, 4.5–30 V | moisture, soil temp, EC, soil pH, N, P, K |
The two boards run at different baud rates. Getting them the wrong way round is the single most common cause of a sensor that appears dead.
| Function | Pin | GPIO | Notes |
|---|---|---|---|
| 4-in-1 RX (← board TX) | D5 | 14 | 9600, via divider |
| 4-in-1 TX (→ board RX) | D6 | 12 | |
| Soil probe RX (← converter TXD) | D7 | 13 | 4800, via divider |
| Soil probe TX (→ converter RXD) | D1 | 5 | 3.3 V direct |
| Water-level power gate | D2 | 4 | duty-cycled to prevent trace corrosion |
| Debug + flashing | — | 1/3 | UART0 on USB, 115200 |
Both sensors run on SoftwareSerial, which is comfortable at these rates and
leaves UART0 on USB for logging throughout — no Serial.swap(), no external
USB-TTL adapter, no boot-strap-pin risk on D8. There is no DE/RE pin because the
RS485 converter is an auto-flow-control module — an HW-097 (bare MAX485) was
tried first and rejected, which
docs/hardware/npk-7in1-probe.md
explains. It is still a 5 V part, so its TXD keeps the divider.
# Host-side unit tests — no board required
pio test -e native
# Build and flash the firmware
pio run -e nodemcuv2 -t upload
pio device monitor
# Phase B: decode the 4-in-1's undocumented serial format
pio run -e sniffer -t upload
pio device monitor -e sniffercp include/secrets.example.h include/secrets.h # then set AGRO_DEVICE_TOKENWithout a device token every publish reports not-configured and readings only
spool. The build still succeeds without secrets.h — it is gitignored, so CI and
a fresh clone must not depend on it — and the node says so loudly at boot.
WiFi credentials do not belong in that file in normal use: an unprovisioned
node raises a AgroSensor-Setup captive portal. The WIFI_SSID define exists
only for bench work, where a portal on every reflash is a nuisance.
The real ingest endpoint does not exist yet, so develop against the mock:
python tools/mock-ingest.py # accept everything
python tools/mock-ingest.py --mode down # simulate an outage
python tools/mock-ingest.py --fail-rate 0.5 # exercise backoff and the spoolPoint AGRO_INGEST_URL in include/config.h at your machine's LAN address,
not localhost — the request comes from the ESP8266. The failure modes are the
interesting ones: --mode reject must not be spooled, --mode down must be.
1. The soil probe does not measure N, P or K. It measures bulk electrical
conductivity and back-calculates all three with a linear fit. Out of the factory
they are one EC reading wearing three scaling factors, and dry soil legitimately
reports zero nitrogen. Every reading carries an npkEstimated flag that stays
true until per-soil coefficients are written to the device, and there is a unit
test asserting that a zero-NPK dry-soil frame decodes as valid rather than as a
fault. Treat these values as relative trends, not lab results.
See docs/hardware/npk-7in1-probe.md.
2. The 4-in-1's serial format is undocumented. Its framing is implemented and
tested; its field layout is provisional, and the CSV column order is an explicit
guess until you run the sniffer. Nothing trustworthy comes out of phWater,
sunlight or waterLevel before that.
See docs/hardware/ph-4in1-protocol.md.
src/main.cpp composition root — wiring and scheduling only
src/sniffer/ Phase B protocol sniffer (separate env)
include/config.h pins, timings, identity
lib/ firmware modules (see lib/README)
test/ host-side Unity tests (see test/README)
docs/ hardware, module and integration documentation
Design rule: parsers take a byte buffer, never a serial port. Everything with
logic in it is a pure function, and the hardware wrappers compile to nothing on the
host. That is what makes pio test -e native possible, and it matters because
protocol decoding is both the most likely thing to be wrong and the most painful
thing to debug on-target. docs/modules/README.md covers
the decomposition and how each module is broken down before coding.
| Phase | State |
|---|---|
| A — Toolchain | ✅ Arduino framework; both envs build clean, no warnings |
| B — 4-in-1 protocol | ⬜ Blocked on hardware. Sniffer ready |
| C — Modbus soil probe | 🟡 Read + write paths implemented and tested; needs on-hardware cross-check |
| D — Calibration | 🟡 pH and EC→nutrient maths tested, persisted to LittleFS; no on-node way to enter calibration points |
| E — Integration | ✅ Both sensors sampled, payload built and logged |
| F — Network delivery | 🟡 WiFi, NTP, HTTP uplink and store-and-forward working against a mock; TLS and OTA outstanding |
✅ means the code exists and its decisions are covered by host tests — not that
it has been confirmed against real hardware. docs/ROADMAP.md
tracks that distinction and every open gap behind these rows.
The soil probe's vendor code has two defects, each with a named regression test:
- Negative temperatures decode as garbage. Their Arduino and Python samples
read the temperature register unsigned, so
0xFF9B(−10.1 °C) becomes ~6549 °C. - The Arduino sample never verifies CRC. RS485 in a wet field picks up noise, and an unchecked frame occasionally hands you a plausible-looking wrong number.
{
"v": 1, "sensorId": "AGS-001",
"readings": { "temperature": 24.8, "moisture": 42.5, "salinity": 0.380,
"phSoil": 6.80, "npk": { "nitrogen": 95, "phosphorus": 52, "potassium": 210 },
"phWater": 7.10, "sunlight": 780, "waterLevel": 63 },
"quality": { "npkEstimated": true, "stabilising": false, "soilDry": false },
"diag": { "rssi": -62, "uptime": 38211, "fw": "0.1.0" }
}Absent fields are omitted, not zeroed — an unplugged board must never report
"phWater": 0.0, which is a plausible acid reading nobody downstream could
distinguish from a real one.
There is currently nowhere to send this: the AgroSmart backend directory is
empty and the web app has no API routes. The contract, the field mapping, and ten
specific mismatches with the existing dashboard are documented in
docs/integration/dashboard-contract.md.
Note in particular: do not have the device write directly to Supabase with the
app's publishable key — no RLS policies exist, so the table would be
world-writable.
See CONTRIBUTING.md. Host tests must pass (pio test -e native)
and both environments must build warning-free.
Dual-licensed by content type:
Third-party dependency licences, including one that needs attention before a
release, are listed in THIRD-PARTY.md.