Building a custom Arduino HVAC controller allows you to bypass the limitations of off-the-shelf smart thermostats, giving you direct programmatic control over heating, cooling, and fan logic. To interface safely with a standard residential furnace or air handler, your microcontroller must switch a 24VAC Class 2 control circuit using the standard R, W, Y, G, and C wire protocol. Direct GPIO switching is impossible and dangerous; you must use opto-isolated relays to keep the low-voltage DC logic completely separated from the 24VAC AC control lines.

This guide provides the exact component variants, a complete pin mapping, and production-ready C++ code with hysteresis and compressor short-cycle protection. The code targets the Arduino Nano V3 (ATmega328P) due to its native 5V logic, which reliably triggers standard 5V relay modules without requiring external level shifters.

Project Overview & Component Specifications

Difficulty Rating: Intermediate (Requires basic I2C wiring and 24VAC safety awareness)
Estimated Build Time: 3 hours (bench testing) to 5 hours (in-wall installation)
Target Board: Arduino Nano V3 (ATmega328P, 5V logic, 16MHz)

Sourcing the correct relay module is the most common point of failure in DIY HVAC projects. You must use a relay board with optocoupler isolation (typically PC817 chips). This ensures that if a relay coil fails or a voltage spike occurs on the 24VAC side, it cannot travel back through the flyback diode and fry your microcontroller's ATmega chip.

Table 1: Arduino HVAC Controller Bill of Materials (2026 Pricing)
Component Exact Variant / Model Key Specification Avg. Price
Microcontroller Arduino Nano V3 (ATmega328P) 5V Logic, 14 Digital I/O $18.00
Env. Sensor BME280 I2C Breakout (Adafruit 2652) Temp/Humidity/Pressure, 3.3V-5V $19.95
Relay Module 4-Channel 5V Relay w/ Optocoupler Songle SRD-05VDC-SL-C, 10A/250VAC $6.50
Thermostat Wire 18 AWG 5-Conductor (18/5) Class 2 rated, solid copper $0.40/ft
Test Power Supply 24VAC 40VA Transformer Wall-mount or DIN, 1.6A max $15.00

Wiring the 24VAC Thermostat Interface

⚠️ SAFETY CALLOUT: 24VAC & Mains Proximity
While 24VAC is considered low-voltage (NEC Article 725 Class 2), the furnace control board it connects to handles 120V/240V mains. Never short the R (24VAC Hot) wire to the furnace chassis or earth ground. Always de-energize the air handler at the main breaker panel before connecting your DIY controller to the actual HVAC system. For bench testing, use an isolated 24VAC transformer.

Standard thermostat wiring uses the R wire as the 24VAC "Hot" source, and the C wire as the 24VAC "Common" (return). To call for heat, the thermostat closes a switch between R and W. Our Arduino will use the relay's Common (COM) and Normally Open (NO) contacts to simulate this switch.

Pin Mapping & Connections

Arduino Nano Pin Connected To Function / Wire Color
5V Relay VCC, BME280 VIN Logic and coil power
GND Relay GND, BME280 GND DC Logic Ground (Do NOT connect to 24VAC C)
A4 (SDA) BME280 SDA I2C Data
A5 (SCL) BME280 SCL I2C Clock
D4 Relay IN1 Heat Call (W)
D5 Relay IN2 Cool Call (Y)
D6 Relay IN3 Fan Call (G)

Step-by-Step Relay Wiring

  1. Feed 24VAC Hot to Relays: Connect the 24VAC R wire (Red) to the COM terminal of Relay 1, Relay 2, and Relay 3 using jumper wires.
  2. Wire the Load Terminals: Connect the NO (Normally Open) terminal of Relay 1 to the W wire (White/Heat). Connect Relay 2 NO to Y (Yellow/Cool). Connect Relay 3 NO to G (Green/Fan).
  3. Complete the Circuit: Connect the 24VAC C wire (Blue/Black) directly to the C terminal on your furnace control board or test transformer. Do not route the C wire through the Arduino or the relays.
  4. Sensor Placement: Mount the BME280 at least 3 feet away from the relay module to prevent the relay coils' ambient heat from skewing your temperature readings.

Complete Arduino HVAC Controller Code

This code implements a critical safety feature: compressor short-cycle protection. Air conditioning compressors require equalization time when turned off; restarting them immediately can cause mechanical lock-up or blow the 3A fuse on your furnace control board. The code enforces a 5-minute minimum off-time for the cooling relay.

Prerequisites: Install the Adafruit BME280 Library and the Adafruit Unified Sensor Library via the Arduino Library Manager before compiling.

/*
 * DIY Arduino HVAC Controller
 * Target: Arduino Nano V3 (ATmega328P)
 * Sensor: BME280 (I2C)
 * Relays: Active LOW 4-Channel Opto-isolated Module
 */

#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>

// --- PIN DEFINITIONS ---
#define RELAY_HEAT  4   // IN1 (W)
#define RELAY_COOL  5   // IN2 (Y)
#define RELAY_FAN   6   // IN3 (G)

// --- HVAC LOGIC SETPOINTS ---
#define HEAT_TARGET      21.0   // Celsius (approx 70F)
#define HEAT_HYSTERESIS  1.0    // Prevents rapid cycling
#define COOL_TARGET      24.0   // Celsius (approx 75F)
#define COOL_HYSTERESIS  1.0

// Compressor must stay off for 5 minutes (300,000 ms) after turning off
#define COMPRESSOR_DELAY_MS 300000UL 

Adafruit_BME280 bme;

// State tracking
bool isHeating = false;
bool isCooling = false;
bool isFanOn = false;
unsigned long lastCoolingStateChange = 0;

void setup() {
  Serial.begin(9600);
  
  // Relays are Active LOW on most opto-isolated boards
  pinMode(RELAY_HEAT, OUTPUT);
  pinMode(RELAY_COOL, OUTPUT);
  pinMode(RELAY_FAN, OUTPUT);
  
  // Initialize all relays to OFF (HIGH for active-low)
  digitalWrite(RELAY_HEAT, HIGH);
  digitalWrite(RELAY_COOL, HIGH);
  digitalWrite(RELAY_FAN, HIGH);
  lastCoolingStateChange = millis();

  // Initialize I2C Sensor
  if (!bme.begin(0x76)) { // Try 0x76 first, fallback to 0x77 if needed
    if (!bme.begin(0x77)) {
      Serial.println("ERROR: Could not find a valid BME280 sensor, check wiring!");
      while (1); // Halt execution to prevent uncontrolled HVAC state
    }
  }
  Serial.println("HVAC Controller Initialized.");
}

void loop() {
  float currentTemp = bme.readTemperature();
  
  // Error handling for disconnected sensor (returns NaN)
  if (isnan(currentTemp)) {
    Serial.println("ERROR: Sensor returned NaN. Shutting down relays for safety.");
    allRelaysOff();
    delay(5000);
    return;
  }

  unsigned long currentMillis = millis();
  bool compressorDelayMet = (currentMillis - lastCoolingStateChange) >= COMPRESSOR_DELAY_MS;

  // --- HEATING LOGIC ---
  if (currentTemp < (HEAT_TARGET - HEAT_HYSTERESIS) && !isCooling) {
    if (!isHeating) {
      Serial.println("Calling for HEAT");
      digitalWrite(RELAY_HEAT, LOW); // Active LOW
      isHeating = true;
      isFanOn = true;
      digitalWrite(RELAY_FAN, LOW);
    }
  } else if (currentTemp >= HEAT_TARGET && isHeating) {
    Serial.println("Heat target reached. Stopping HEAT.");
    digitalWrite(RELAY_HEAT, HIGH);
    isHeating = false;
    // Fan can stay on for a bit, or turn off immediately based on preference
    digitalWrite(RELAY_FAN, HIGH);
    isFanOn = false;
  }

  // --- COOLING LOGIC (with short-cycle protection) ---
  if (currentTemp > (COOL_TARGET + COOL_HYSTERESIS) && !isHeating && compressorDelayMet) {
    if (!isCooling) {
      Serial.println("Calling for COOL");
      digitalWrite(RELAY_COOL, LOW);
      isCooling = true;
      isFanOn = true;
      digitalWrite(RELAY_FAN, LOW);
      lastCoolingStateChange = currentMillis;
    }
  } else if (currentTemp <= COOL_TARGET && isCooling) {
    Serial.println("Cool target reached. Stopping COOL.");
    digitalWrite(RELAY_COOL, HIGH);
    isCooling = false;
    digitalWrite(RELAY_FAN, HIGH);
    isFanOn = false;
    lastCoolingStateChange = currentMillis; // Reset timer when turning OFF
  }

  // Print status every 10 seconds
  static unsigned long lastPrint = 0;
  if (currentMillis - lastPrint > 10000) {
    Serial.print("Temp: "); Serial.print(currentTemp);
    Serial.print("C | Heat: "); Serial.print(isHeating);
    Serial.print(" | Cool: "); Serial.println(isCooling);
    lastPrint = currentMillis;
  }

  delay(1000); // 1-second polling interval
}

void allRelaysOff() {
  digitalWrite(RELAY_HEAT, HIGH);
  digitalWrite(RELAY_COOL, HIGH);
  digitalWrite(RELAY_FAN, HIGH);
  isHeating = false;
  isCooling = false;
  isFanOn = false;
}

Debugging: First Three Things to Check When It Fails

When moving from a breadboard prototype to a permanent installation, environmental and electrical noise will expose weaknesses in your build. If the system fails, follow this ranked diagnostic path.

Exact Error / Symptom Most Likely Cause Measurement & Fix
Could not find a valid BME280 sensor, check wiring! I2C address mismatch or missing pull-up resistors on SDA/SCL lines. Run an I2C scanner sketch. If address 0x76 or 0x77 doesn't appear, check continuity on A4/A5. Generic BME280 modules sometimes lack onboard 4.7kΩ pull-ups; add them between SDA/SCL and 5V.
Relay chatter (rapid clicking every 1-2 seconds) Temperature floating due to sensor noise, or hysteresis band set to 0.0. Verify HEAT_HYSTERESIS is ≥ 1.0. If the BME280 is reading erratic jumps (e.g., 21.0 to 22.5 instantly), the sensor is likely suffering from EMI. Route I2C wires away from the 24VAC relay load paths.
Furnace blower runs, but no heat or cooling is produced. Swapped W (Heat) and Y (Cool) 24VAC wires at the relay NO terminals. Use a multimeter set to AC Voltage. Measure between the C wire and the W wire terminal on the furnace board while the Arduino calls for heat. You should read ~24VAC. If 0V, swap the W and Y wires on your relay block.
Arduino resets randomly when a relay clicks. Voltage brownout caused by relay coil inductive kickback pulling down the 5V rail. Ensure your relay module has a flyback diode across the coil (standard on Songle modules). If using a cheap USB power supply, upgrade to a 5V 2A dedicated wall adapter to handle the coil inrush current.

Extending and Simplifying the Build

How to Simplify (The Garage Heater Bang-Bang Controller)

If you only need to control a single 24VAC zone (like a garage unit heater or a boiler pump), strip the build down. Replace the BME280 with a $3 DHT11 sensor, remove the cooling and fan relays, and delete the compressor delay logic from the code. This reduces your BOM cost to under $15 and eliminates I2C address conflicts entirely.

How to Extend (Adding Wi-Fi and Home Assistant)

To integrate this controller into a smart home ecosystem via MQTT, you will need to swap the Arduino Nano for an ESP32-WROOM-32 DevKit. However, this introduces a critical hardware constraint: the ESP32 operates at 3.3V logic, while the optocoupler LEDs inside standard 5V relay modules require ~4.5V to trigger reliably.

Do not connect ESP32 GPIO pins directly to a 5V relay IN pin. You have two options to bridge this gap:

  1. Use a Logic Level Shifter: A bidirectional TXB0104 module will safely translate the 3.3V ESP32 signals to 5V for the relay board.
  2. Use N-Channel MOSFETs: Wire a 2N7000 MOSFET as a low-side switch for each relay input. The ESP32 drives the MOSFET gate at 3.3V, and the MOSFET sinks the relay's 5V ground pin. This is the most robust method for industrial-style DIY enclosures.

For deeper reading on low-voltage control circuit safety standards, refer to the NFPA 70 (National Electrical Code) Article 725 regarding Class 1, Class 2, and Class 3 remote-control and signaling circuits. Always verify your local AHJ (Authority Having Jurisdiction) requirements before permanently wiring DIY electronics into your home's central HVAC infrastructure.