When makers transition from the workbench to wiring a smart home or commercial building, they quickly hit a wall: the National Electrical Code (NEC). A common and dangerous misconception is that "low voltage" means "no rules." In reality, the NEC Article 725 strictly governs remote-control, signaling, and power-limited circuits. If you are tapping into a 24VAC HVAC thermostat line to build a smart sensor, you are interfacing with a Class 2 circuit. Violating the separation and isolation rules of the NEC low voltage code can result in failed inspections, voided insurance, or a fried microcontroller.
This guide bridges the gap between electrical code compliance and embedded firmware. We will build a Power-over-Ethernet (PoE) ESP32 sensor node that safely monitors 24VAC Class 2 HVAC control signals, utilizing proper galvanic isolation to satisfy both the AHJ (Authority Having Jurisdiction) and the laws of physics.
Decoding NEC Low Voltage Code for Embedded Makers
Under NEC Article 725, low voltage circuits are divided into Class 1, Class 2, and Class 3. Most residential and light commercial HVAC thermostat wiring (24VAC) falls under Class 2. Class 2 circuits are power-limited by definition: the power source (the transformer) cannot exceed 30 volts and 100 volt-amperes (VA). Because the energy is strictly limited, Class 2 circuits are exempt from the strict overcurrent protection and derating rules of Chapter 3, but they have strict separation rules.
For embedded hardware, the critical takeaway is isolation. A Class 2 transformer is not a reliable safety barrier against ground loops or inductive spikes from HVAC contactor coils. You must galvanically isolate your 3.3V microcontroller logic from the 24VAC field wiring.
Parts List & Pin Mapping for the Class 2 Monitor
This build targets the Olimex ESP32-PoE (featuring the ESP32-WROOM-32 module and a LAN8710A Ethernet PHY). We chose PoE to eliminate the need for a 120V AC-to-DC buck converter inside the HVAC enclosure, keeping the entire node strictly low-voltage and NEC Article 800 (Communications Circuits) compliant for the data side.
Bill of Materials (BOM)
| Component | Exact Variant / Part Number | Estimated Cost (2026) | Purpose |
|---|---|---|---|
| Microcontroller | Olimex ESP32-PoE (ESP32-WROOM-32) | $28.00 | Main logic & PoE power extraction |
| Optocoupler | PC817 (Single channel, DIP-4) | $0.15 | Galvanic isolation for 24VAC signal |
| Bridge Rectifier | KBP206 (2A, 600V, SIP-4) | $0.30 | Converts 24VAC to pulsing DC for opto |
| Current Limit Resistor | 2.2kΩ, 1/2W Metal Film | $0.05 | Limits optocoupler LED current to ~10mA |
| Pull-down Resistor | 10kΩ, 1/4W | $0.05 | Prevents floating GPIO on ESP32 input |
| Enclosure | Bud Industries NBF-32016 (NEMA 1) | $14.50 | Physical separation from line voltage |
Pin Mapping Table
| ESP32 Pin | Direction | Connected To | Notes |
|---|---|---|---|
| GPIO 34 | Input | PC817 Pin 4 (Emitter) | Input-only pin; requires external 10k pull-down to GND |
| GPIO 33 | Output | Status LED (via 330Ω) | Visual heartbeat indicator |
| GND | Power | PC817 Pin 3 & Pull-down | Common ground for 3.3V logic side |
| Ethernet RJ45 | Data/Pwr | PoE Switch (802.3af) | Provides 5V to the Olimex onboard regulator |
Decision Tree: Selecting the Right Isolation for 24VAC
Interfacing 24VAC directly to a 3.3V GPIO will instantly destroy the ESP32's silicon. You must step down the voltage and isolate the grounds. Here is the decision path for selecting your interface topology:
| Topology Option | Pros | Cons | Verdict |
|---|---|---|---|
| Resistive Divider | Cheap, 2 parts | No isolation; ground loops will fry board | REJECT |
| Step-Down Transformer | Perfect isolation, clean sine wave | Bulky, expensive, overkill for logic sensing | REJECT |
| Dedicated AC-DC IC (e.g., HLK-PM01) | Provides clean DC power | Requires mains wiring, violates Class 2 separation | REJECT |
| Bridge Rectifier + Optocoupler | Full galvanic isolation, small footprint, cheap | Outputs 120Hz pulse train, requires software debouncing | DEFAULT PICK: PC817 + KBP206 |
The Winning Circuit: The 24VAC passes through the KBP206 bridge rectifier, becoming a pulsing DC waveform. The 2.2kΩ resistor limits the current to roughly 10mA, driving the internal LED of the PC817 optocoupler. The phototransistor on the other side pulls GPIO 34 high at 120Hz (twice the 60Hz AC frequency). The 10kΩ pull-down ensures the pin reads LOW when the AC waveform crosses zero.
Firmware: Compilable ESP32 Code with Error Handling
Because GPIO 34 on the ESP32-WROOM-32 is an input-only pin lacking internal pull-up/pull-down resistors, the hardware pull-down is mandatory. The firmware below uses an interrupt service routine (ISR) to count the 120Hz pulses, determining if the 24VAC signal is present without blocking the main loop.
#include <WiFi.h>
#include <esp_task_wdt.h>
#include <esp_err.h>
// --- PIN DEFINITIONS (Olimex ESP32-PoE) ---
const int PIN_24VAC_SENSE = 34; // Input only, optocoupler emitter
const int PIN_STATUS_LED = 33; // Heartbeat LED
// --- GLOBAL VARIABLES ---
volatile uint32_t pulseCount = 0;
unsigned long lastCheck = 0;
bool hvacActive = false;
// Watchdog timeout in seconds
#define WDT_TIMEOUT 5
// --- ISR FOR AC PULSE DETECTION ---
void IRAM_ATTR acPulseISR() {
pulseCount++;
}
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("[BOOT] Initializing NEC Class 2 HVAC Monitor...");
// Configure Pins
pinMode(PIN_STATUS_LED, OUTPUT);
// GPIO 34 is input-only. We rely on the external 10k pull-down resistor.
pinMode(PIN_24VAC_SENSE, INPUT);
// Attach interrupt on RISING edge of the 120Hz pulse train
attachInterrupt(digitalPinToInterrupt(PIN_24VAC_SENSE), acPulseISR, RISING);
// Initialize Task Watchdog Timer (TWDT)
esp_err_t wdt_err = esp_task_wdt_init(WDT_TIMEOUT, true);
if (wdt_err != ESP_OK) {
Serial.printf("[ERROR] WDT Init failed: %s\n", esp_err_to_name(wdt_err));
}
esp_task_wdt_add(NULL); // Subscribe main loop to WDT
// Ethernet initialization would go here for PoE
Serial.println("[BOOT] System Ready. Monitoring 24VAC...");
}
void loop() {
// Feed the watchdog to prevent panic resets
esp_task_wdt_reset();
unsigned long now = millis();
// Evaluate signal every 250ms
if (now - lastCheck >= 250) {
// A 24VAC 60Hz signal rectified yields ~120 pulses/sec.
// In 250ms, we expect ~30 pulses if HVAC is calling.
uint32_t currentPulses = pulseCount;
pulseCount = 0; // Reset counter
if (currentPulses > 15) {
if (!hvacActive) {
Serial.println("[STATUS] 24VAC Detected - HVAC Calling");
hvacActive = true;
}
digitalWrite(PIN_STATUS_LED, HIGH);
} else {
if (hvacActive) {
Serial.println("[STATUS] 24VAC Lost - HVAC Idle");
hvacActive = false;
}
digitalWrite(PIN_STATUS_LED, LOW);
}
lastCheck = now;
}
// Small yield to prevent tight-loop WDT triggers
delay(10);
}
Debugging: First Three Things to Check When It Fails
When working with AC signals and ESP32 interrupts, a common failure mode is the Watchdog Timer (WDT) panic. If your serial monitor spits out the following exact error string:
Guru Meditation Error: Core 1 panic'ed (Interrupt wdt timeout on CPU1)
Do not blindly reboot. Follow this ranked troubleshooting path:
- Blocking Code in the ISR: The most likely cause. Did you put a
delay(),Serial.print(), orWiFi.status()check inside theacPulseISRfunction? ISRs must execute in microseconds. Move all logic to the main loop and only increment avolatilecounter inside the ISR. - Missing External Pull-Down on GPIO 34: If you forgot the 10kΩ physical resistor, GPIO 34 will float. Electromagnetic interference (EMI) from the HVAC blower motor will trigger thousands of phantom interrupts per second, starving the CPU and tripping the WDT. Verify the pull-down with a multimeter (should read ~10kΩ to GND when unpowered).
- Optocoupler Saturation / Missing Current Limit: If the 24VAC signal spikes or the 2.2kΩ resistor is missing, the PC817 internal LED will draw excessive current, or the phototransistor will saturate too deeply, causing slow turn-off times and erratic pulse widths. Verify the voltage drop across the 2.2kΩ resistor; it should be roughly 22V AC (RMS) when the circuit is active.
Extending and Simplifying the Build
Once the baseline monitor is stable and passing local AHJ inspections for separation, you can adapt the project to your specific deployment environment.
How to Simplify (The Bench Prototype)
If you are strictly prototyping on a desk and do not need to run Cat6 through a plenum ceiling, drop the PoE requirement. Swap the Olimex ESP32-PoE for a standard ESP32-DevKitC V4. Power it via the USB-C port using a standard 5V wall wart. The firmware remains 100% identical, as the pinout for GPIO 34 and 33 is unchanged on the WROOM-32 module.
How to Extend (Commercial MQTT Integration)
To push the HVAC state to a home automation server (like Home Assistant), add the PubSubClient library. Because the Olimex board includes a LAN8710A PHY, use the ETH.h library instead of WiFi. Hardwired Ethernet is vastly superior to WiFi inside a metal air-handler enclosure, which acts as a Faraday cage.
Add this to your loop() to publish state changes only when the boolean flips, preventing MQTT broker spam:
if (hvacActive != lastPublishedState) {
client.publish("hvac/main/status", hvacActive ? "calling" : "idle");
lastPublishedState = hvacActive;
}
By respecting the hardware design constraints of the ESP32 and the legal boundaries of NEC Article 725, you bridge the gap between a fragile hobby project and a reliable, code-compliant building automation node.






