December 17, 2025

Building the Primary HVAC Controller

Over the last couple of weeks, I’ve been working on a small project that blends my HVAC background with my growing interest in embedded systems and OT security. I’m building a miniature “desktop HVAC system” that behaves like the control loops I work with in the field, just scaled down to a breadboard and a handful of sensors.

Phase 1 was all about the basics: wiring a thermistor to an ESP32, reading temperature data, and controlling a small CPU fan with PWM. It worked, and it proved that the idea was viable.

Phase 2 is where things start to look like an actual control system.


The Goal of This Phase

My objective was to build a primary HVAC controller, a device that:

This mirrors a simplified version of how building automation systems (BAS) behave in commercial HVAC: a central controller, some sensor modules, and actuators making decisions every few seconds.


Hardware Setup

The controller is built on an Arduino Uno, which handles all real-time control. For this phase I wired up:

Inputs

Outputs

Even with a small breadboard, the system ended up feeling like a compact VAV controller: temperature in, CO₂ in, occupancy in, airflow and ventilation out.


Creating a Single Configuration Block

Before writing the control logic, I wanted to avoid hard-coding thresholds everywhere. In real HVAC systems, technicians tune setpoints, deadbands, and damper limits all the time.

So I created a configuration section at the top of the sketch:

const float COOL_SETPOINT_F   = 74.0f;
const float COOL_DEADBAND_F   = 2.0f;

const int FAN_DUTY_LOW        = 80;
const int FAN_DUTY_MED        = 160;
const int FAN_DUTY_HIGH       = 255;

const int CO2_LOW_PPM         = 800;
const int CO2_MED_PPM         = 1200;

const int DAMPER_LOW_DEG      = 45;
const int DAMPER_MED_DEG      = 90;
const int DAMPER_HIGH_DEG     = 120;

const unsigned long OCCUPANCY_HOLD_MS = 30000;

This section lets me adjust system behavior in one place. Want a different setpoint? A tighter deadband? A more aggressive CO₂ response? It’s a single edit instead of a rewrite.


Adding Occupancy Logic

Most real spaces don’t flip between occupied and unoccupied instantly. People move; sensors flicker; a single footstep shouldn’t shut down ventilation.

So I implemented a 30-second occupancy hold:

if (pirState == HIGH) {
  lastMotionMs = now;
}
occupied = (pirState == HIGH) ||
           (now - lastMotionMs < OCCUPANCY_HOLD_MS);

This gives the system a more realistic feel: ventilation stays active briefly after motion stops, just like in conference rooms and corridors.


Environmental Control Logic

Once the sensor readings were coming in cleanly, I built the logic that drives the fan and damper.

Temperature → Fan

The main temperature source is the BME280, with the thermistor used as a fallback. The logic is straightforward:

This maps closely to basic cooling-only VAV logic.


CO₂ → Damper Position

Ventilation demand increases with CO₂ levels:

This is the same concept used in demand-controlled ventilation (DCV). In my small setup, the actuator is just a servo, but it behaves convincingly like a damper motor.


Structured JSON Output

Every five seconds, the controller prints a complete snapshot of sensors and outputs:

{
  "sensors": {
    "thermistorF": 74.39,
    "bmeTempF": 75.38,
    "bmeHumidity": 34.22,
    "co2ppm": 842,
    "pir": true,
    "indoorTempF": 75.38
  },
  "outputs": {
    "fanDuty": 160,
    "damper2Angle": 90,
    "occupied": true
  }
}

This format is perfect for:

I also set up a simple Python script to write each JSON line to a log file with timestamps.


Where This Is Heading Next

The primary controller is now stable and behaving like a small HVAC controller should. Upcoming phases will build on this:

This is becoming much more than a hobby project. It’s turning into a functional learning tool for HVAC controls and (eventually) cybersecurity testing.