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.