Building native Apple HomeKit accessories on the ESP32 platform has evolved significantly. While early projects relied on fragmented forks of Apple's deprecated ADK, the 2026 standard for ESP32 HomeKit projects is the HomeSpan library. It handles the complex HomeKit Accessory Protocol (HAP) cryptography, mDNS broadcasting, and state management natively within the Arduino framework.
This guide walks through building a robust, opto-isolated smart relay with physical push-button override, targeting the ESP32-S3 architecture. We will cover the hardware decision matrix, exact pin mapping, production-ready code with error handling, and a debugging playbook for the most common HAP pairing failures.
1. The 2026 Decision Matrix: Which ESP32 for HomeKit?
HomeKit requires significant SRAM for HAP cryptography and mDNS service records. Choosing the wrong silicon variant results in silent memory allocation failures during pairing. Use this decision tree to select your board:
| If your project requires... | Choose this Variant | Why it wins | Approx. Cost (2026) |
|---|---|---|---|
| A single relay or basic sensor (low memory) | ESP32-WROOM-32E (4MB Flash) | Cheapest, sufficient SRAM (520KB) for 1-2 HAP services. | $4.50 |
| Multi-sensor hub, camera, or >3 accessories | ESP32-S3-WROOM-1 (N8R2) | Native USB for debugging, vector instructions for crypto, 2MB PSRAM. | $7.00 |
| Battery-powered, low-duty-cycle BLE/Thread | ESP32-C6 (RISC-V) | Native 802.15.4 (Thread/Matter), deep sleep current < 10µA. | $5.50 |
ESP_ERR_NO_MEM mDNS allocation errors that plague older WROOM boards when Apple adds new HAP characteristic requirements. The ESP32-S3 datasheet confirms its hardware cryptographic accelerators drastically reduce the latency of the SRP handshake during initial iOS pairing.
2. Parts List & Pin Mapping for the Smart Relay Build
This build controls a 120V/240V AC load via a 5V mechanical relay, while providing a local tactile button for manual override. The relay module must be opto-isolated to prevent AC back-EMF from resetting the ESP32-S3's sensitive 3.3V logic.
Bill of Materials
- MCU: ESP32-S3-DevKitC-1 (N8R2 variant with 8MB Flash / 2MB PSRAM)
- Relay Module: 5V 1-Channel Opto-Isolated Relay (Omron G5LE-14 or Songle SRD-05VDC-SL-C)
- Switch: 12x12mm Tactile Pushbutton (Momentary NO)
- Resistor: 10kΩ (for hardware pull-up redundancy, though internal pull-ups are used)
- Power: 5V 2A USB-C Power Supply (Do not use unregulated wall-warts; HAP crypto spikes draw >300mA transient current)
Pin Mapping Table
| ESP32-S3 Pin | Component | Notes & Constraints |
|---|---|---|
| GPIO 4 | Relay IN (Signal) | Active LOW. Outputs 3.3V (HIGH) to turn OFF, 0V (LOW) to turn ON. |
| GPIO 5 | Pushbutton (Signal) | Configured with INPUT_PULLUP. Reads LOW when pressed. |
| 5V (VBUS) | Relay VCC | Must be 5V. Do not power 5V relay coils from the 3.3V pin. |
| GND | Relay GND, Button GND | Common ground required for signal reference. |
3. Complete HomeSpan Code with Error Handling
The following code targets the ESP32-S3-DevKitC-1 (N8R2). It uses the HomeSpan library to expose the relay as a Lightbulb service, allowing Siri to control it via "Turn on the Smart Relay". It includes a non-blocking debounce routine for the physical button and a hard halt if the WiFi connection fails during setup.
Prerequisite: Install the HomeSpan library via the Arduino Library Manager and select "ESP32S3 Dev Module" in the Boards menu. Ensure "OPI PSRAM" is enabled in the Tools menu.
#include "HomeSpan.h"
// --- PIN DEFINITIONS ---
const int RELAY_PIN = 4;
const int BUTTON_PIN = 5;
// --- CUSTOM HAP SERVICE ---
struct DEV_Relay : Service::LightBulb {
SpanCharacteristic *power;
int relayPin;
int buttonPin;
int lastButtonState;
unsigned long lastDebounceTime;
const unsigned long debounceDelay = 50;
DEV_Relay(int rPin, int bPin) : Service::LightBulb("Smart Relay") {
power = new Characteristic::On();
relayPin = rPin;
buttonPin = bPin;
pinMode(relayPin, OUTPUT);
pinMode(buttonPin, INPUT_PULLUP);
// Active LOW relay initialization (HIGH = OFF)
digitalWrite(relayPin, HIGH);
lastButtonState = HIGH;
lastDebounceTime = 0;
}
// Called when iOS/Home app sends a state change
boolean update() {
int newState = power->getNewVal();
digitalWrite(relayPin, newState ? LOW : HIGH);
Serial.printf("HomeKit update: Relay %s\n", newState ? "ON" : "OFF");
return true;
}
// Polled continuously to check physical button state
void loop() {
int reading = digitalRead(buttonPin);
if (reading != lastButtonState) {
lastDebounceTime = millis();
}
if ((millis() - lastDebounceTime) > debounceDelay) {
// Detect falling edge (button pressed)
if (reading == LOW && lastButtonState == HIGH) {
int newState = !power->getVal();
power->setVal(newState); // Sync state back to HomeKit
digitalWrite(relayPin, newState ? LOW : HIGH);
Serial.printf("Physical Button: Relay %s\n", newState ? "ON" : "OFF");
}
}
lastButtonState = reading;
}
};
void setup() {
Serial.begin(115200);
delay(1000); // Wait for serial monitor
// Initialize HomeSpan
homeSpan.begin();
// Error Handling: Verify WiFi credentials are set and connected
// Note: HomeSpan handles WiFi via its CLI, but we verify connection here.
if (WiFi.status() != WL_CONNECTED) {
Serial.println("ERROR: WiFi disconnected. Use HomeSpan CLI (type 'W') to set 2.4GHz SSID.");
// Blink onboard LED to indicate fatal WiFi error
pinMode(48, OUTPUT); // RGB LED pin on standard S3 DevKit
while(1) {
digitalWrite(48, !digitalRead(48));
delay(250);
}
}
// Define HAP Accessory
new SpanAccessory();
new Service::AccessoryInformation();
new Characteristic::Identify();
new Characteristic::Manufacturer("ElectricalFlux");
new Characteristic::Model("S3-Relay-v1");
// Instantiate our custom relay service
new DEV_Relay(RELAY_PIN, BUTTON_PIN);
}
void loop() {
homeSpan.poll();
}
4. Debugging HomeKit Pairing & Runtime Failures
HomeKit pairing relies on mDNS (Bonjour) and the Secure Remote Password (SRP) protocol. When it fails, the Arduino serial monitor provides specific HAP error strings. Here is the ranked cause list for the most common failures.
Exact Error Strings & Fixes
1. mDNS init failed: ESP_ERR_NO_MEM
- Cause: The ESP32 has run out of internal SRAM while allocating memory for HAP service records and mDNS TXT records.
- Fix: If using an older WROOM board, reduce the number of accessories. If using the S3, ensure "OPI PSRAM" is enabled in the Arduino IDE Tools menu so HomeSpan can offload buffers to external RAM.
2. HomeSpan: WiFi disconnected (Flashing continuously)
- Cause: The ESP32 is dropping off the network due to 2.4GHz/5GHz band steering on modern mesh routers, or RSSI is below -70dBm.
- Fix: Create a dedicated 2.4GHz-only IoT SSID on your router. Move the ESP32 closer to the AP to ensure RSSI is > -65dBm.
3. HAP Pairing Failed: M2/M4 Crypto Error
- Cause: The SRP handshake failed. This usually happens if the ESP32's internal pairing database is corrupted, or if you are trying to re-pair an accessory that iOS still thinks is paired.
- Fix: Call
homeSpan.clearPairings()once insetup(), flash, then remove it. Alternatively, type 'H' in the HomeSpan Serial CLI to clear HAP data, and remove the "Ghost" accessory from the Apple Home app.
- Network Isolation: Ensure your iOS device and the ESP32 are on the exact same VLAN and Subnet. mDNS broadcasts (UDP port 5353) do not cross subnets without a dedicated mDNS reflector (like Avahi on a Pi).
- PSRAM Configuration: Open the Arduino IDE Tools menu. If "PSRAM" is set to "Disabled", the S3 will choke on HAP crypto allocations. Set it to "OPI PSRAM".
- Setup Code Format: When prompted in the Home app, the setup code must be entered as
XXX-XX-XXX(e.g.,466-37-726). HomeSpan defaults to466-37-726if not explicitly changed via the CLI.
5. Extending and Simplifying the Build
Depending on your deployment environment, you may need to strip this project down to its bare essentials or expand it into a multi-function sensor node.
How to Simplify (The "Headless" Relay)
If this relay is mounted inside a ceiling junction box or behind a wall plate where physical access is impossible, remove the physical button logic to save flash and reduce loop overhead:
- Delete
BUTTON_PINand theloop()method inside theDEV_Relaystruct. - Change the service type from
LightBulbtoSwitch(Service::Switch) if you want it to appear as a generic plug rather than a light in the Apple Home UI. - Remove the 10kΩ pull-up resistor and tactile switch from your BOM.
How to Extend (Adding Environmental Automation)
To make the relay respond to ambient conditions without relying on HomeKit automations (which require an Apple TV or HomePod hub), add an I2C sensor directly to the ESP32-S3:
- Add a BH1750 Light Sensor: Wire SDA to GPIO 8 and SCL to GPIO 9. Use the
claws/BH1750library. - Implement Local Hysteresis: In the
loop()function, read the lux value. If lux < 50 and the time is past 6:00 PM (using NTP time sync), triggerpower->setVal(1)and pull the relay pin LOW. - Add a
LightSensorHAP Service: Expose the BH1750 readings to the Home app by addingnew Service::LightSensor();and aCharacteristic::CurrentAmbientLightLevel()inside yourSpanAccessorydefinition. This allows you to view real-time lux data directly in the Apple Home interface alongside your relay control.
By standardizing on the ESP32-S3 and the HomeSpan framework, you bypass the fragility of older MQTT-to-Homebridge bridges, achieving local, low-latency control that survives internet outages and integrates natively with Apple's secure HomeKit ecosystem.






