Objective and use case
What you’ll build: A robust fermentation temperature controller using Arduino Mega 2560, RS485, and Ethernet to manage and monitor fermentation processes effectively.
Why it matters / Use cases
- Automated fermentation control for breweries to maintain optimal temperatures, ensuring consistent product quality.
- Remote monitoring of fermentation parameters via Ethernet, allowing for quick adjustments and oversight from anywhere.
- Integration with existing industrial systems using RS485 Modbus for seamless communication and control.
- Real-time data logging of temperature and PID tuning for analysis and optimization of fermentation processes.
- Enhanced safety and efficiency in fermentation by automating temperature adjustments based on sensor feedback.
Expected outcome
- Temperature control accuracy within ±0.5°C, ensuring precise fermentation conditions.
- Response time of the system to temperature changes under 5 seconds, allowing for timely adjustments.
- Data transmission rates of 9600 baud over RS485, ensuring reliable communication with minimal latency.
- HTTP JSON status updates every 10 seconds, providing real-time insights into fermentation conditions.
- Successful PID tuning leading to reduced overshoot and settling time, improving overall process stability.
Audience: Intermediate to advanced users; Level: Advanced project implementation.
Architecture/flow: Arduino Mega 2560 with W5500 Ethernet Shield, MAX485 for RS485 communication, and MAX31865 for RTD temperature sensing.
Hands‑on Practical Case: RS485 Fermentation PID Control on Arduino Mega 2560 + W5500 + MAX485 + MAX31865
This advanced project builds a robust fermentation temperature controller using a PT100 RTD sensor, a time‑proportioning PID for a heater, RS485 Modbus‑RTU for supervision/control, and an Ethernet REST status endpoint. You’ll deploy firmware on an Arduino Mega 2560 with a W5500 Ethernet Shield, a MAX485 transceiver for RS485, and a MAX31865 RTD amplifier.
The goal: an industrial‑style “rs485‑fermentation‑pid‑control” that exposes setpoints, tunings, and process values over RS485 (Modbus‑RTU), uses a reliable RTD temperature front‑end, and provides an auxiliary HTTP JSON status on Ethernet.
You’ll find precise wiring (text and table), full code, deterministic build/flash/run commands with Arduino CLI, step‑by‑step validation (sensor, PID actuation, RS485, Ethernet), troubleshooting, and improvements.
Prerequisites
- Familiarity with:
- SPI devices on shared buses and chip‑select discipline.
- UART/RS485 half‑duplex with transceiver direction control.
- PID control concepts and time‑proportioning (SSR windows).
- Arduino CLI workflow on Linux/macOS/Windows.
- Tools:
- A computer with Arduino CLI v0.32.0 or newer (tested with v0.35.x).
- A USB cable for Arduino Mega 2560.
- For RS485 validation: a USB‑RS485 adapter and the
mbpolltool. - For HTTP validation:
curl. - Basic hand tools and a multimeter.
Safety note: If you drive a mains heater with an SSR, follow electrical codes, use proper enclosures and fusing, and isolate high voltage wiring. The controller side remains low voltage; never touch mains terminals when powered.
Materials (exact models)
- Microcontroller: Arduino Mega 2560 (ATmega2560‑16AU)
- Ethernet: Arduino W5500 Ethernet Shield (compatible with Arduino Ethernet library)
- RS485: MAX485 module (5V TTL, RO/RE/DE/DI pinout; typical small PCB module)
- RTD Front‑end: MAX31865 breakout (3.3V, PT100/PT1000 compatible; Adafruit MAX31865 or equivalent with level shifting)
- Temperature sensor: PT100 (Class B or better), 2‑wire or 3‑wire (preferable), stainless probe
- Solid State Relay (SSR): Zero‑cross AC SSR rated appropriately for your heater
- Power: 5V from Arduino; 3.3V for MAX31865
- Cabling and termination: 120 Ω terminator for RS485 bus end
- Optional: USB‑RS485 dongle for testing Modbus‑RTU
Setup / Connection
The Arduino Mega 2560 hosts three peripherals:
- W5500 Ethernet shield: SPI device using the ICSP header (hardware SPI), CS on D10.
- MAX31865 RTD amplifier: SPI device on the same bus, dedicated CS on D9.
- MAX485 RS485 transceiver: Half‑duplex UART using Serial1 (TX1/RX1 on pins 18/19), direction control pin on D2.
We also control a heater via an SSR on D6 using a time‑proportion window (on/off within a fixed time window to approximate duty cycle).
SPI considerations
- On the Mega, hardware SPI is on the 6‑pin ICSP header (not pins 11/12/13 as on the Uno).
- The W5500 shield already routes MISO/MOSI/SCK through ICSP.
- The MAX31865 must share SPI lines and use a distinct CS pin. Ensure the non‑active CS stays HIGH to avoid bus contention.
RS485 considerations
- Use Serial1 for RS485. Connect:
- Mega TX1 (pin 18) → MAX485 DI
- Mega RX1 (pin 19) → MAX485 RO
- DE and RE (active‑high) tied together → Mega D2 (direction control)
- MAX485 VCC 5V and GND to Mega 5V/GND
- RS485 A/B lines to twisted pair; 120 Ω termination at the far ends; bias resistors as recommended (many modules include bias).
PT100 wiring to MAX31865
- Choose 3‑wire PT100 if available; it compensates for lead resistance.
- Follow your MAX31865 board’s 2/3/4‑wire jumpers.
- Typical Adafruit MAX31865 has solder jumpers for 2‑wire (bridge) or 3‑wire mode.
- Reference resistor (Rref) on most PT100 boards is 430.0 Ω. Use the exact value marked on your board for best accuracy.
SSR (heater) wiring
- Mega D6 → SSR input “+”
- Mega GND → SSR input “−”
- SSR load side in series with the heater and mains (performed by a qualified person). Use zero‑cross SSR for resistive heaters.
Pin/Signal Mapping Table
| Function | Device/Pin | Arduino Mega 2560 Pin | Notes |
|---|---|---|---|
| SPI SCK | W5500 + MAX31865 | ICSP‑3 | Shared SPI via ICSP |
| SPI MISO | W5500 + MAX31865 | ICSP‑1 | Shared |
| SPI MOSI | W5500 + MAX31865 | ICSP‑4 | Shared |
| W5500 CS | W5500 Ethernet Shield | D10 | Reserved for Ethernet |
| SD card CS (on shield) | microSD (unused) | D4 | Set HIGH as OUTPUT to deselect |
| MAX31865 CS | MAX31865 | D9 | Choose any free digital pin |
| MAX31865 VIN | MAX31865 | 3.3V | Prefer 3.3V; ensure board is 5V‑tolerant on logic |
| MAX31865 GND | MAX31865 | GND | Common ground |
| RS485 DI | MAX485 | Mega TX1 (D18) | Serial1 TX |
| RS485 RO | MAX485 | Mega RX1 (D19) | Serial1 RX |
| RS485 DE+RE | MAX485 | D2 | Tie DE and RE together to D2 |
| RS485 VCC | MAX485 | 5V | Power for MAX485 |
| RS485 GND | MAX485 | GND | Common ground |
| Heater SSR control | SSR (input +) | D6 | Time‑proportion window output |
| Heater SSR return | SSR (input −) | GND | Return |
Notes:
– If your MAX31865 breakout isn’t 5V‑tolerant on logic, keep SPI at 3.3V via level shifting. Many brand‑name boards include level shifting and tolerate 5V logic; check docs.
– Keep SPI CS lines configured as OUTPUT and set HIGH when not selected. We explicitly set D4 HIGH (microSD CS) to avoid interference.
Full Code (Arduino Mega 2560)
Create a folder “fermenter‑pid‑rs485” and save the following as “fermenter‑pid‑rs485/fermenter‑pid‑rs485.ino”.
/*
Fermentation PID Controller with RS485 (Modbus-RTU) and Ethernet status
Hardware: Arduino Mega 2560 + W5500 Ethernet Shield + MAX485 + MAX31865 (PT100)
Features:
- PT100 via MAX31865 (SPI)
- PID (time-proportioning window) for heater SSR on D6
- Modbus-RTU slave on RS485 (Serial1), DE/RE on D2
- HTTP JSON status on Ethernet (W5500) at /status
*/
#include <SPI.h>
#include <Ethernet.h>
#include <Adafruit_MAX31865.h>
#include <PID_v1.h>
#include <ModbusRTU.h>
// ---------------- Hardware configuration ----------------
static const uint8_t PIN_ETHERNET_CS = 10; // W5500 CS (from shield)
static const uint8_t PIN_SD_CS = 4; // SD CS (unused)
static const uint8_t PIN_MAX31865_CS = 9; // MAX31865 CS
static const uint8_t PIN_RS485_DIR = 2; // MAX485 DE+RE
static const uint8_t PIN_HEATER_SSR = 6; // Heater SSR control
// MAX31865 object
Adafruit_MAX31865 rtd = Adafruit_MAX31865(PIN_MAX31865_CS);
// RTD/board constants (adjust to your board’s exact Rref and RTD type)
#define RREF 430.0 // Ohms for PT100 board (typical Adafruit: 430.0)
#define RNOMINAL 100.0 // Ohms for PT100
// Ethernet configuration
byte mac[] = { 0xDE, 0xAD, 0xBE, 0xEF, 0xFE, 0xED }; // Use a unique MAC on your LAN
IPAddress ip(192, 168, 1, 50);
IPAddress dns(192, 168, 1, 1);
IPAddress gateway(192, 168, 1, 1);
IPAddress subnet(255, 255, 255, 0);
EthernetServer server(80);
// ---------------- PID configuration ----------------
double setpointC = 20.00; // default 20.00 C
double inputC = 0.0;
double outputPct = 0.0; // 0..100 %
double Kp = 30.0, Ki = 0.5, Kd = 200.0; // conservative starting point (tune in process)
PID pid(&inputC, &outputPct, &setpointC, Kp, Ki, Kd, REVERSE);
// Time-proportioning window (ms)
volatile unsigned long windowSizeMs = 2000UL;
unsigned long windowStart = 0;
// Enable/disable control
volatile bool controlEnabled = true;
// ---------------- Modbus RTU (slave) on RS485 ----------------
ModbusRTU mb;
// Modbus unit id
static const uint8_t MODBUS_ID = 1;
// Holding Register map (16-bit word registers)
// Scale strategy: temperatures in centi-degC, PID terms scaled by x100, output in 0..1000 = 0..100.0%
enum {
HR_SETPOINT_Cx100 = 0, // write/read
HR_TEMP_Cx100 = 1, // read
HR_OUTPUT_x10 = 2, // read (0..1000)
HR_WINDOW_MS_L = 3, // write/read low 16 bits
HR_WINDOW_MS_H = 4, // write/read high 16 bits
HR_KP_x100 = 5, // write/read
HR_KI_x100 = 6, // write/read
HR_KD_x100 = 7, // write/read
HR_ENABLE = 8, // write/read (0=off, 1=on)
HR_FAULT = 9, // read (fault bitmap from MAX31865)
HR_COUNT = 10
};
uint16_t hregs[HR_COUNT] = {0};
// ---------------- Helpers ----------------
void applyModbusWrites() {
// Apply changes from hregs to runtime variables
setpointC = ((int16_t)hregs[HR_SETPOINT_Cx100]) / 100.0;
uint32_t ws = (uint32_t)hregs[HR_WINDOW_MS_L] | ((uint32_t)hregs[HR_WINDOW_MS_H] << 16);
if (ws >= 250 && ws <= 60000) { // limit reasonable window 0.25..60 s
windowSizeMs = ws;
}
bool newEnable = (hregs[HR_ENABLE] != 0);
controlEnabled = newEnable;
// Update tunings if changed
Kp = ((int16_t)hregs[HR_KP_x100]) / 100.0;
Ki = ((int16_t)hregs[HR_KI_x100]) / 100.0;
Kd = ((int16_t)hregs[HR_KD_x100]) / 100.0;
pid.SetTunings(Kp, Ki, Kd);
}
void refreshModbusRegs() {
// Write runtime values back to holding registers
int16_t tempCx100 = (int16_t)(inputC * 100.0);
hregs[HR_TEMP_Cx100] = (uint16_t)tempCx100;
uint16_t outx10 = (uint16_t)(outputPct * 10.0); // 0..1000
hregs[HR_OUTPUT_x10] = outx10;
hregs[HR_WINDOW_MS_L] = (uint16_t)(windowSizeMs & 0xFFFF);
hregs[HR_WINDOW_MS_H] = (uint16_t)(windowSizeMs >> 16);
hregs[HR_SETPOINT_Cx100] = (int16_t)(setpointC * 100.0);
hregs[HR_KP_x100] = (int16_t)(Kp * 100.0);
hregs[HR_KI_x100] = (int16_t)(Ki * 100.0);
hregs[HR_KD_x100] = (int16_t)(Kd * 100.0);
hregs[HR_ENABLE] = controlEnabled ? 1 : 0;
// Fault register is handled in the sensor read step
}
bool cbReadHreg(TRegister* reg, uint16_t val) {
// Called after each read; nothing special needed
(void)reg; (void)val;
return true;
}
bool cbWriteHreg(TRegister* reg, uint16_t val) {
// Called on writes; update local mirror and apply changes
(void)val;
// Mirror all HREGs from Modbus stack to hregs[]
for (uint16_t i = 0; i < HR_COUNT; i++) {
hregs[i] = mb.Hreg(i);
}
applyModbusWrites();
return true;
}
void setupModbus() {
Serial1.begin(19200, SERIAL_8N1); // RS485 bus speed
mb.begin(&Serial1, PIN_RS485_DIR);
mb.slave(MODBUS_ID);
// Add holding registers
for (uint16_t i = 0; i < HR_COUNT; i++) {
mb.addHreg(i, 0, 1); // 1 = allow write (we'll gate at application level)
}
// Initialize with defaults
hregs[HR_SETPOINT_Cx100] = (int16_t)(setpointC * 100.0);
hregs[HR_WINDOW_MS_L] = (uint16_t)(windowSizeMs & 0xFFFF);
hregs[HR_WINDOW_MS_H] = (uint16_t)(windowSizeMs >> 16);
hregs[HR_KP_x100] = (int16_t)(Kp * 100.0);
hregs[HR_KI_x100] = (int16_t)(Ki * 100.0);
hregs[HR_KD_x100] = (int16_t)(Kd * 100.0);
hregs[HR_ENABLE] = controlEnabled ? 1 : 0;
// Push initial values to stack
for (uint16_t i = 0; i < HR_COUNT; i++) {
mb.Hreg(i, hregs[i]);
}
// Register callbacks
mb.onGetHreg(cbReadHreg);
mb.onSetHreg(cbWriteHreg);
}
void setupEthernet() {
pinMode(PIN_ETHERNET_CS, OUTPUT);
digitalWrite(PIN_ETHERNET_CS, HIGH);
pinMode(PIN_SD_CS, OUTPUT);
digitalWrite(PIN_SD_CS, HIGH); // Deselect SD
Ethernet.init(PIN_ETHERNET_CS);
Ethernet.begin(mac, ip, dns, gateway, subnet);
delay(1000);
server.begin();
}
void setupMAX31865() {
if (!rtd.begin(MAX31865_3WIRE)) { // set to MAX31865_2WIRE, _3WIRE, or _4WIRE
// If begin fails, continue; faults will be reported
}
}
void setupPID() {
pid.SetOutputLimits(0.0, 100.0); // 0..100 %
pid.SetMode(AUTOMATIC);
pid.SetSampleTime(1000); // 1 s sample time
windowStart = millis();
}
// Read temperature and record fault flags
uint8_t lastFault = 0;
void readTemperature() {
lastFault = rtd.readFault(); // Check before conversion to clear stale flags
if (lastFault) {
rtd.clearFault();
}
inputC = rtd.temperature(RNOMINAL, RREF);
// Fault capture for Modbus
lastFault = rtd.readFault();
hregs[HR_FAULT] = lastFault;
}
// Apply time-proportioning control to SSR
void applySSR() {
unsigned long now = millis();
if (now - windowStart >= windowSizeMs) {
windowStart += windowSizeMs; // Avoid drift from millis() wrap
}
bool heaterOn = false;
if (controlEnabled) {
unsigned long onTime = (unsigned long)(outputPct * windowSizeMs / 100.0);
heaterOn = ((now - windowStart) < onTime);
} else {
heaterOn = false;
}
digitalWrite(PIN_HEATER_SSR, heaterOn ? HIGH : LOW);
}
void handleHTTP() {
EthernetClient client = server.available();
if (!client) return;
// Very simple HTTP 1.0 parser
String req = client.readStringUntil('\r');
client.readStringUntil('\n'); // consume newline
// Only handle GET /status
bool ok = req.startsWith("GET /status");
while (client.available()) client.read(); // drain
if (!ok) {
client.println("HTTP/1.0 404 Not Found");
client.println("Content-Type: text/plain");
client.println("Connection: close");
client.println();
client.println("Not Found");
client.stop();
return;
}
// Build JSON
client.println("HTTP/1.0 200 OK");
client.println("Content-Type: application/json");
client.println("Cache-Control: no-store");
client.println("Connection: close");
client.println();
client.print("{\"ip\":\"");
client.print(Ethernet.localIP());
client.print("\",\"setpoint_c\":");
client.print(setpointC, 2);
client.print(",\"temp_c\":");
client.print(inputC, 2);
client.print(",\"output_pct\":");
client.print(outputPct, 1);
client.print(",\"window_ms\":");
client.print(windowSizeMs);
client.print(",\"enabled\":");
client.print(controlEnabled ? "true" : "false");
client.print(",\"kp\":");
client.print(Kp, 2);
client.print(",\"ki\":");
client.print(Ki, 2);
client.print(",\"kd\":");
client.print(Kd, 2);
client.print(",\"fault\":");
client.print(lastFault);
client.println("}");
client.stop();
}
void setup() {
// IO basics
pinMode(PIN_HEATER_SSR, OUTPUT);
digitalWrite(PIN_HEATER_SSR, LOW);
pinMode(PIN_RS485_DIR, OUTPUT);
digitalWrite(PIN_RS485_DIR, LOW); // receive by default
// Subsystems
setupEthernet();
setupMAX31865();
setupPID();
setupModbus();
}
unsigned long lastCompute = 0;
void loop() {
// 1 s loop for PID; sensor read first
unsigned long now = millis();
if (now - lastCompute >= 1000) {
lastCompute = now;
readTemperature();
if (controlEnabled && !hregs[HR_FAULT]) {
pid.Compute();
} else {
outputPct = 0.0;
}
refreshModbusRegs();
// Push HREG mirrors to stack for Modbus clients to read
for (uint16_t i = 0; i < HR_COUNT; i++) {
mb.Hreg(i, hregs[i]);
}
}
// Apply SSR time proportioning continuously
applySSR();
// Service Modbus RTU
mb.task();
// Service HTTP requests
handleHTTP();
}
Key notes on the code:
- The Modbus holding registers are scaled to keep integer 16‑bit values:
- Temperatures are centi‑degrees (e.g., 2000 = 20.00 C).
- PID tunings multiplied by 100.
- Output multiplied by 10 (0..1000 equals 0.0..100.0%).
- Window size stored as a 32‑bit value across two HREGs.
- PID is “REVERSE” action: when the temperature is below setpoint, the output increases.
- Time‑proportioning SSR window defaults to 2 s; adjust via Modbus.
- An HTTP GET /status returns a JSON snapshot for quick integration.
Build / Flash / Run (Arduino CLI)
We use Arduino CLI on the Arduino Mega 2560 (AVR). Commands below are explicit and reproducible. Replace the serial port with your actual port.
Create the project directory and place the .ino as instructed:
– Project path: ~/projects/fermenter-pid-rs485/fermenter-pid-rs485.ino
Install Arduino CLI and libraries:
arduino-cli version
# Update core indices and install AVR core
arduino-cli core update-index
arduino-cli core install arduino:avr
# Verify the board is detected and note the port (e.g., /dev/ttyACM0 or COM5)
arduino-cli board list
# Install required libraries with explicit versions
arduino-cli lib install "Ethernet@2.0.2"
arduino-cli lib install "Adafruit MAX31865 library@1.4.5"
arduino-cli lib install "PID@1.2.1"
arduino-cli lib install "ModbusRTU@2.3.8"
Compile and upload for Arduino Mega 2560:
# Compile (FQBN for Mega 2560)
arduino-cli compile --fqbn arduino:avr:mega ~/projects/fermenter-pid-rs485
# Upload (replace port as detected)
arduino-cli upload -p /dev/ttyACM0 --fqbn arduino:avr:mega ~/projects/fermenter-pid-rs485
Run and monitor serial logs if you add prints (this sketch is quiet on serial by design):
# Optional: serial monitor at 115200 if you add debug messages
arduino-cli monitor -p /dev/ttyACM0 -c baudrate=115200
Network sanity checks after the board reboots:
# Check reachability
ping -c 3 192.168.1.50
# Fetch JSON status
curl -s http://192.168.1.50/status | jq .
Step‑by‑step Validation
Follow these steps systematically after wiring is complete and firmware is flashed.
1) SPI bus integrity and device selection
- Power up the system with the W5500 shield attached.
- Ensure D4 is set HIGH (our code does this) so the SD card is deselected.
- Confirm the MAX31865 CS line idles HIGH and toggles when reading.
- If you have a logic probe or LED, verify activity on D9 when the board runs.
Expected: Ethernet responds to pings, HTTP status is reachable, temperature field shows a plausible value.
2) Temperature sensing via MAX31865
- Run:
curl -s http://192.168.1.50/status- Verify fields:
"temp_c"is near ambient (e.g., 20‑28 C). Touch the PT100 probe: temperature should climb slowly. - Fault handling:
- Disconnect the RTD:
faultshould become non‑zero (open circuit flags). Reconnect to clear. - If temperature reads unrealistically high/low or fault persists, recheck MAX31865 wiring and jumper configuration (2/3/4‑wire).
3) PID output and SSR actuation
- The heater SSR output is on D6. Without a heater connected, you can still observe LED on SSR modules (if present).
- Temporarily set setpoint below current temp to force 0% output:
- Default setpoint is 20.00 C; if ambient is ~23 C, output should be 0.
- Raise the setpoint via Modbus to 28.00 C and confirm the output rises (see steps in RS485 section).
- Observe SSR behavior:
- The SSR will toggle within a 2 s window.
- At 50% output, it should be ON ~1 s and OFF ~1 s within each window.
4) RS485 Modbus‑RTU: read/write registers
Use a USB‑RS485 adapter and connect it to the RS485 A/B lines with proper polarity and a 120 Ω terminator at the far end of the bus (your controller can be one end, the USB dongle the other).
Install mbpoll (Linux/macOS; Windows builds are also available):
# Debian/Ubuntu
sudo apt-get update && sudo apt-get install -y mbpoll
# macOS (Homebrew)
brew install mbpoll
Reading status registers (unit id 1, 19200 8N1, no parity):
# Read 10 holding registers starting at 0
mbpoll -m rtu -a 1 -b 19200 -P none -t 4 -r 0 -c 10 /dev/ttyUSB0
-t 4selects holding registers.- Expect values:
- Reg0 (setpoint) ~ 2000 for 20.00 C
- Reg1 (temp) varies around ambient
- Reg2 (output) 0..1000
- Reg3/4 (window L/H)
- Reg5/6/7 for Kp/Ki/Kd x100
- Reg8 enable (0/1)
- Reg9 fault bitmap
Write a new setpoint to 28.00 C (2800 in centi‑C):
# Write single register (HR 0) value 2800
mbpoll -m rtu -a 1 -b 19200 -P none -t 4 -r 0 -1 2800 /dev/ttyUSB0
Enable control (HR 8 = 1):
mbpoll -m rtu -a 1 -b 19200 -P none -t 4 -r 8 -1 1 /dev/ttyUSB0
Change PID tunings (example Kp=35.00, Ki=0.80, Kd=250.00), scale x100:
mbpoll -m rtu -a 1 -b 19200 -P none -t 4 -r 5 -1 3500 /dev/ttyUSB0
mbpoll -m rtu -a 1 -b 19200 -P none -t 4 -r 6 -1 80 /dev/ttyUSB0
mbpoll -m rtu -a 1 -b 19200 -P none -t 4 -r 7 -1 25000 /dev/ttyUSB0
Confirm that:
– The output (HR 2) rises when the setpoint exceeds the current temperature.
– The HTTP /status JSON reflects the same variables you wrote via Modbus.
5) Ethernet status endpoint
- Check link lights on the Ethernet shield.
- Verify IP:
ping 192.168.1.50 - Retrieve JSON:
bash
curl -s http://192.168.1.50/status | jq . - Fields to verify:
setpoint_c,temp_c,output_pct,window_ms,enabled,kp/ki/kd,fault.- Cross‑compare with Modbus values for consistency.
6) Closed‑loop test
- With the heater connected and the probe placed in a water bath or fermentation vessel (safer: a bench test in a cup of water):
- Start from ambient; set setpoint +2 C above ambient.
- Observe temperature rising; expect overshoot if Kd is low or Ki is high.
- Adjust
Kp,Ki,Kdvia Modbus in small increments:- Increase
Kpfor faster response (watch for oscillation). - Increase
Kdto reduce overshoot. - Adjust
Kito remove steady‑state error (avoid integral windup).
- Increase
- Verify that at steady state,
output_pcthovers near a value that compensates heat losses.
Troubleshooting
- No temperature change or fault flagged:
- Check MAX31865 wiring, power at 3.3V, and that your board is configured for the correct wire count (2/3/4‑wire).
- Verify Rref value; if your board uses 400 Ω vs 430 Ω, adjust
#define RREF. - Ensure CS pins: D9 for MAX31865 must be OUTPUT and default HIGH (the library manages this).
- Ethernet not responding:
- Ensure D4 (SD CS) is set to OUTPUT and HIGH (our
setupEthernet()does this). - Check for IP conflicts; change the IP to a free address.
- Verify cabling and link LEDs.
- RS485 communication errors (timeouts, CRC):
- Confirm DE/RE tied together to D2 and that your library is controlling direction (ModbusRTU handles this).
- Check A/B polarity; swap if needed (A↔B reversed yields no comms).
- Use 120 Ω terminators only at physical ends of the bus; add bias resistors if your modules don’t include them (typical ~680 Ω–1 kΩ pull‑up on A and pull‑down on B, or follow application note).
- Match serial settings exactly: 19200, 8N1, no parity.
- PID seems ineffective:
- Verify
controlEnabled(HR 8) is set to 1. - Confirm SSR output on D6 toggles within windows; a DMM won’t show duty—use an LED SSR indicator or scope.
- Increase window size if your heater is large/inertial (e.g., 5–10 s) to reduce relay chatter and allow heat to integrate smoothly.
- Temperature noisy or drifting:
- Improve RTD cabling (twisted pair, shielded).
- Use 3‑wire RTD to compensate lead resistance.
- Add simple filtering (moving average) in software if needed (beware of added lag).
- Library conflicts on SPI:
- Ensure only one CS is LOW at a time. If adding more SPI devices, define unique CS pins and set them HIGH by default.
Improvements
- Add cooling output: implement split‑range with two SSRs (heat and cool) and map output above 50% to heat and below 50% to cool, or run two PIDs with interlocks.
- Auto‑tuning: integrate a relay auto‑tuner to estimate process gain/time constants, then compute PID parameters (e.g., Ziegler–Nichols or Tyreus–Luyben).
- Modbus‑TCP server: serve the same registers on Ethernet (W5500) for SCADA systems that use TCP instead of RTU.
- Data logging: publish CSV/JSON to an SD card or a remote InfluxDB via UDP/TCP; add timestamps via NTP.
- Safety interlocks: add high‑temp cutout, sensor plausibility checks, and watchdog resets.
- Calibration: implement a two‑point calibration for the RTD path to compensate systematic offset from wiring and Rref tolerances.
- Multi‑vessel scaling: use the Mega’s resources to manage multiple MAX31865 channels and SSRs, each with its own PID loop and Modbus register block.
Final Checklist
- Materials
- Arduino Mega 2560, W5500 shield, MAX485, MAX31865, PT100, SSR, cables, 120 Ω terminator.
- Wiring
- SPI: W5500 via ICSP, MAX31865 on CS D9, SD CS D4 pulled HIGH.
- RS485: TX1→DI, RX1→RO, DE/RE→D2, A/B wired with termination.
- SSR: D6→SSR(+), GND→SSR(−); mains side wired safely.
- Firmware
- Code placed at
~/projects/fermenter-pid-rs485/fermenter-pid-rs485.ino. - Libraries installed: Ethernet@2.0.2, Adafruit MAX31865 library@1.4.5, PID@1.2.1, ModbusRTU@2.3.8.
- Built and uploaded with:
arduino-cli core install arduino:avrarduino-cli compile --fqbn arduino:avr:megaarduino-cli upload -p <PORT> --fqbn arduino:avr:mega
- Validation
- HTTP:
curl http://192.168.1.50/statusreturns JSON with plausible temps. - RS485:
mbpollreads/writes HREGs; setpoint/tunings take effect. - PID/SSR: output windowing toggles D6; heater responds in process.
- Faults: MAX31865 fault bit non‑zero when RTD is disconnected; returns to zero when restored.
- Tuning
- Set
window_msto match actuator; tune Kp/Ki/Kd for minimal overshoot and steady control. - Documentation
- Record your Modbus map, IP settings, and PID tunings for future reproducibility.
With this build, you have a field‑ready fermentation controller that speaks Modbus‑RTU over RS485 for integration into supervisory systems and provides a convenient Ethernet status endpoint for quick checks and dashboards.
Find this product and/or books on this topic on Amazon
As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.



