For greenhouse automation, I wanted a protocol that survives long cable runs and noisy environments. Modbus RTU over RS485 is still one of the best options for this type of deployment.
I split the system into three layers: sensing, control logic, and actuation. Sensor values are exposed as holding registers, while relay and valve commands are written through a small command map. Keeping register addresses documented in a table prevented integration mistakes later.
The control loop is intentionally conservative. I use hysteresis bands instead of single thresholds to avoid relay chatter, and I enforce minimum on/off intervals for pumps and fans.
A dedicated heartbeat register tells me if each device is alive. If heartbeat updates stop, the controller falls back to a safe default profile instead of trying to continue with stale readings.
Documenting the register map
The register table is the contract between the controller and every node, so I keep it in the firmware next to the code that uses it. Holding registers cover both readable sensor values and writable commands; a heartbeat register at a fixed address lets the master check liveness cheaply.
Holding registers (function 03 read / 06 write), per node:
0x0000 R air_temp_c10 signed, tenths of °C (23.4°C -> 234)
0x0001 R humidity_pct 0..100
0x0002 R soil_moist_raw 0..1023 ADC
0x0010 R/W relay_bits bit0=pump bit1=fan bit2=vent
0x0011 R/W setpoint_temp_c10 signed, tenths of °C
0x001F R/W heartbeat master increments; node echoes
Reading and writing with ModbusMaster
On the Arduino side I use the ModbusMaster library over a MAX485 transceiver. The transceiver's DE/RE pin has to be driven around each transaction, which the library exposes through pre/post transmission callbacks.
#include <ModbusMaster.h>
const uint8_t RS485_DE_RE = 4; // driver enable / receiver enable (tied together)
const uint8_t NODE_ID = 1;
ModbusMaster node;
void preTx() { digitalWrite(RS485_DE_RE, HIGH); } // enable driver to send
void postTx() { digitalWrite(RS485_DE_RE, LOW); } // back to receive
void setup() {
pinMode(RS485_DE_RE, OUTPUT);
digitalWrite(RS485_DE_RE, LOW);
Serial1.begin(9600); // 8N1 is the common Modbus RTU default
node.begin(NODE_ID, Serial1);
node.preTransmission(preTx);
node.postTransmission(postTx);
}
// Read air temperature (0x0000) and toggle the fan bit (0x0010).
void controlStep() {
uint8_t rc = node.readHoldingRegisters(0x0000, 1);
if (rc == node.ku8MBSuccess) {
int16_t tempC10 = (int16_t)node.getResponseBuffer(0);
float tempC = tempC10 / 10.0f;
// Hysteresis band: fan on above 28°C, off below 26°C.
static bool fanOn = false;
if (tempC > 28.0f) fanOn = true;
else if (tempC < 26.0f) fanOn = false;
uint16_t relayBits = fanOn ? 0x0002 : 0x0000;
node.writeSingleRegister(0x0010, relayBits);
} else {
// rc carries the Modbus/timeout error; treat as a missed heartbeat.
Serial.print(F("Modbus read failed rc=0x"));
Serial.println(rc, HEX);
}
}
I keep the request rate conservative and always check the return code. A timeout is not a crash — it is a signal to hold the last safe output and, if it persists, drop that node into the safe default profile described above.
Sources
- Imported from the previous version of the site (gaborl.hu). Original publication around 2025-02-03.
Update history
- — Adopted from previous gaborl.hu (Hugo) site via legacy import.