Skip to content
My Account

HomeKit

ESP32 Power Plug with Energy Meter

For many years, the Dutch retailer Action has sold affordable smart‑home devices under the LSC Smart Connect brand.

25 min read

ESP32 Power Plug with Energy Meter
No ratings yet, be the first.

For many years, the Dutch retailer Action has sold affordable smart‑home devices under the LSC Smart Connect brand. These products are extremely popular due to their low price point, but internally the hardware is often far more capable than expected. Earlier generations frequently used ESP8266 modules, which made reflashing and custom firmware relatively easy.

Newer hardware revisions, however, increasingly rely on alternative Wi‑Fi chips with closed firmware, limited documentation, and poor community support. This makes long‑term ownership, security auditing, and integration into open ecosystems much harder.

In this three‑part series, we explore three LSC smart plugs:

  1. The classic “old” plug (Previous Blog)
  2. A newer hardware revision without power monitoring (previous blog)
  3. The smart plug with integrated energy monitoring (this blog)

This article focuses on converting the LSC Smart Power Plug with Power Monitor into a fully open‑source, native Apple HomeKit smart plug , with no cloud dependencyOTA updates, and full energy metering support.

Power Plug

What This Project Covers

In this step‑by‑step guide, you will learn how to:

  • Safely open the LSC Smart Power Plug
  • Identify the internal hardware and PCB layout
  • Remove the original Wi‑Fi module
  • Install an ESP8685‑WROOM‑03 (ESP32‑C3)
  • Flash and configure the ESP32 Life Cycle Manager (LCM)
  • Generate a HomeKit QR code based on the device MAC address
  • Flash custom HomeKit firmware
  • Pair the device with Apple Home
  • (Optional) Enable overcurrent protection via menuconfig

The end goal? A fully custom, native HomeKit smart plug, without cloud dependency.

Why Modify This Smart Plug?

There are more smart devices than ever, but not all of them truly belong to you. Most budget IoT devices depend on:

  • Cloud servers
  • Vendor accounts
  • Proprietary apps
  • Remote infrastructure
  • Unknown data collection

This creates the privacy trap: You buy the device, but the manufacturer controls it. If you want to understand the deeper implications of cloud-based IoT products, read this article: Source: Why You (Should or Shouldn't) Buy a Smart Plug from Action

By replacing the firmware, you regain:

  • Full privacy
  • Local device control
  • Firmware ownership
  • Offline operation
  • No telemetry
  • Long-term support independence

Difficulty Level

Rating: 2 / 5 Soldering Irons – Beginner friendly

Soldering iron difficulty rating icon: 2 out of 5, beginner friendly

Beginner‑friendly, provided you are comfortable with:

  • Desoldering and soldering
  • Working with small SMD pads
  • Using a serial flasher
  • Command‑line tools (esptoolidf.py, Docker)

⚠️ HIGH VOLTAGE WARNING

⚡ DANGER: MAINS ELECTRICITY CAN KILL

This project involves a device that connects directly to 230V AC.

  • Always unplug the device before opening
  • Never work on a live circuit
  • Use insulated tools
  • Avoid touching exposed conductors

The author accepts no liability for damage, injury, or accidents.
If you are unsure, consult a licensed electrician.

Hardware, Product Information

From the packaging:

  • Brand: LSC Smart Connect
  • Product: Smart Power Plug with Power Monitor

Article number:

  • 3202087

Markings on the device:

3202087 Max 16A 3680W μ T35
220–240V~ 50/60Hz
AL M21167 Made in P.R.C.
Inside of the smart plug, showing the original module and its markings
The smart plug's internal PCB and module revisions

Why We Replace the Original Chip

Multiple hardware revisions exist for this product.

VersionOriginal ModuleNotes
3202087CB2S / BK7231NClosed ecosystem
Diagram comparing the plug's CB2S/BK7231N hardware revisions

The BK7231 platform is difficult to flash and lacks a modern, open development ecosystem. Instead of trying to work around these limitations, we fully replace the module with an ESP8685‑WROOM‑03 (ESP32‑C3).

This provides:

  • Native Apple HomeKit support
  • OTA updates via LCM
  • Fully open‑source toolchain
  • Active ESP32 community support
  • Direct integration with the BL0937 energy‑metering IC
ESP8685-WROOM-03 module diagram

Opening the Plug

Tools Required

  • Plastic spudgers
  • Thin metal pry tool

Steps

  1. Insert a plastic spudger between the shell halves.
  2. Create a small gap.
Opening up the smart power plug's enclosure
  1. Slide in a metal spudger.
  2. Slowly pry toward a corner.
  3. Release each of the four clips.
The smart power plug's internal wiring
  1. The shell opens cleanly.
Wiring the ESP8685-WROOM-03 relay and GPIO connections inside the plug

Pin Mapping (Verified)

FunctionOriginal PinESP8685 GPIO
RelayP8GPIO6
ButtonP7GPIO7
Red LEDP6GPIO3
Blue LEDP10GPIO20
CF (BL0937)P26GPIO4
CF1 (BL0937)P24GPIO5
SEL (BL0937)P11GPIO21

GPIO assignments for BL0937 can be defined in firmware configuration.)

Removing the Original Module

Before installing the new ESP8685-WROOM-03, we first need to safely remove the original module from the PCB. This step is critical: damaging the pads will make the entire project much harder (or impossible). Fortunately, with the right tools, this is a clean and controlled operation.

Tools Required

For this process, I strongly recommend:

  • TS100/101 digital soldering iron (or equivalent temperature-controlled iron)
  • Desoldering wick / copper braid remover
  • Liquid flux (very important!)
  • Isopropyl alcohol and cotton swabs

Why the TS100/101? It heats quickly, maintains stable temperature, and is powerful enough for multi-pin modules without overheating the board.

TS100/TS101 soldering iron used for this project

Step 1, Identify the Original Chip

Depending on your plug revision, you will see:

  • CB2S / BK7231N

Both are surface-mounted Wi-Fi modules with edge solder pads around the module. Before removal, take high-resolution photos of the PCB from multiple angles. These will be invaluable if anything goes wrong.

Step 2, Add Flux Generously

Apply liquid flux along every solder pad edge of the module.

Flux:

  • Lowers melting temperature
  • Improves heat transfer
  • Prevents pad lifting
  • Makes solder flow correctly

Never attempt desoldering without it.

Step 3, Remove the Module

Set the TS100 to 350–380°C.

  1. Place desoldering braid on one row of pads
  2. Press with the hot iron
  3. Let solder absorb into the wick
  4. Repeat across all sides
  5. Once all joints are clean, gently lift the module.

Do not pull if resistance remains. Missing even one joint can tear a pad.

Step 4, Clean the Pads

After removal:

  1. Remove remaining solder with braid
  2. Clean flux using isopropyl alcohol
  3. Inspect for lifted pads under magnification
Connecting the ESP8685-WROOM-03 module inside the power plug

Your board should now show:

  • Clean alignment
  • Flat copper pads
  • No burned areas
  • No solder bridges

Preparing the ESP8685‑WROOM‑03

Now that the old module is removed we can prepare the replacement module.

Magnetic Programmer

Instead of soldering headers, I built a magnetic programmer jig, you need a 3D printer. If you do not have a 3D printer you can also solder some wires to the programming pads. source: https://www.studiopieters.nl/esp8685-wroom-03 or 3D printer magnetic programmer jig Source: https://www.studiopieters.nl/esp8685-wroom-03-programmer

3D-printed magnetic programmer jig for the ESP8685-WROOM-03 module
Diagram of the ESP8685-WROOM-03 programmer jig

Benefits:

  • No soldering
  • Stable contact
  • Works with esptool
  • Fast flashing
  • Low risk

Erase Flash & MAC Address

esptool.py erase_flash

Example output:

Chip is ESP32-D0WD-V3
Erasing flash...
Chip erase completed successfully
MAC: 68:25:dd:f0:40:94

⚠️ Save your MAC address , you need it when you generate the QR-Code.

Generate HomeKit QR Code

Do it yourself: Guide to ESP32 HomeKit QR Codes
Do it yourself: https://github.com/AchimPieters/esp32-homekit-qrcode

Example:

./gen_qrcode 7 693-41-208 M4T8 cc8da2ddafe0 new/qrcode.png

Meanings:

  • 7 = Outlet accessory
  • 693-41-208 = Setup code
  • M4T8 = Setup ID
  • cc8da2ddafe0 = MAC (no colons)

This will generate an images like this one. you can use only the QR-code if you want, or the whole image and print it as a sticker, and paste it on your plug.

Generated HomeKit setup QR code sticker

Or download precompiled qr-code here

Download QR-Code

Install Life Cycle Manager (LCM)

LCM (Life Cycle Manager) allows OTA (Over The Air) installations and updates without needing to reopening the device ever again!

Do it yourself:https://github.com/AchimPieters/esp32-lifecycle-manager

Or download precompiled files here

Download compiled Files

Go to the folder where you have placed the files and Flash:

python -m esptool --chip esp32c3 -b 460800 \
--before default_reset --after hard_reset write_flash \
--flash_mode dio --flash_size 4MB --flash_freq 80m \
0x0 build/bootloader/bootloader.bin \
0x8000 build/partition_table/partition-table.bin \
0xe000 build/ota_data_initial.bin \
0x20000 build/esp32-lifecycle-manager.bin

Installing the New Chip and Soldering the ESP8685-WROOM-03

With the old module removed and the pads cleaned, it’s time to install the new brain of your smart plug: the ESP8685-WROOM-03. This is the most delicate part of the entire upgrade, but with patience and the right technique, it’s completely manageable even for beginners.

Step 1, Align and Tack One Corner

  1. Position the ESP8685 on the footprint (one side 5 and other side 6 pads)
  2. Align all edges
  3. Melt one pre-tinned corner pad
  4. Let it cool
Diagram for checking the power plug's alignment inside the enclosure

Now check alignment on all sides. If misaligned:

  • Reheat the tack joint
  • Adjust the module
  • Retack once perfect

Step 2, Solder Opposite Corner

Once alignment looks correct:

  • Solder the opposite corner
  • Check alignment again

Once two corners are fixed, the module will not shift.

Step 3, Drag Soldering the Pads

Apply generous flux along one row of pins.

Now:

  1. Load a small solder bead on your tip
  2. Drag slowly across pins
  3. Let surface tension pull solder into place
  4. Repeat on the other side

Do not worry if you create bridges, they are easily removed later.

Step 4, Remove Solder Bridges

If pins are shorted together:

  1. Add flux
  2. Place desoldering braid
  3. Lightly press with the iron
  4. Let solder wick away
  5. Re-inspect

Your goal:

  • Clean joints
  • No blobs
  • No bridges
  • No lifted pads

Step 5, Final Cleaning & Inspection

Clean the board using:

  • Isopropyl alcohol
  • Cotton swabs

Inspect carefully:

  • All pads connected
  • No pins floating
  • No cold joints
  • No heat damage
  • No debris

Spend time here. Visual inspection prevents 90{91c9fad616bf0725810074c79079914b0bb05d8bf60d02d56ac06d2922548669} of failures.

What You’ve Achieved

At this point:

  • The old chip is gone
  • A modern ESP is installed
  • Your firmware control begins
  • OTA updates are possible
  • Privacy is restored

This is the exact moment your device transitions from: consumer product → personal hardware

This part is somewhat difficult for a beginner, but doable. So if you want to write your own code read the Do it yourself links. If not you can simply use the code I already wrote and compiled, just go to Install via LCM.

Do it yourself: https://github.com/AchimPieters/esp32-homekit
Do it yourself: ESP32 HomeKit Development with ESP-IDF

Config:

homekit_server_config_t config = {
  .accessories = accessories,
  .password = "693-41-208",  
.setupId = "M4T8",
};

Build:

docker run -it -v ~/ESP32-Power-Plug:/project -w /project espressif/idf:v5.4
idf.py set-target esp32C3
idf.py build

Produces: main.bin

View the full source code
/**
   Copyright 2026 Achim Pieters | StudioPieters®

   Permission is hereby granted, free of charge, to any person obtaining a copy
   of this software and associated documentation files (the "Software"), to deal
   in the Software without restriction, including without limitation the rights
   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
   copies of the Software, and to permit persons to whom the Software is
   furnished to do so, subject to the following conditions:

   The above copyright notice and this permission notice shall be included in all
   copies or substantial portions of the Software.

   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
   FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL THE
   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
   WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
   CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

   for more information visit https://www.studiopieters.nl
 **/

#include <stdio.h>
#include <string.h>
#include <stdbool.h>
#include <stdlib.h>
#include <math.h>
#include <esp_log.h>
#include <esp_err.h>
#include <nvs.h>
#include <nvs_flash.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <driver/gpio.h>
#include <esp_timer.h>
#include <homekit/homekit.h>
#include <homekit/characteristics.h>

#include "esp32-lcm.h"
#include "custom_charcteristc.h"
#include <button.h>

// Step 4: BL0937 component is present and wired to HomeKit.
// We initialize the driver and periodically push Voltage/Current/Power/Energy
// values into custom characteristics under the existing Outlet service.
#include "bl0937.h"

// -------- GPIO configuration (set these in sdkconfig) --------
#define BUTTON_GPIO      CONFIG_ESP_BUTTON_GPIO
#define RELAY_GPIO       CONFIG_ESP_RELAY_GPIO
#define BLUE_LED_GPIO    CONFIG_ESP_BLUE_LED_GPIO
#define RED_LED_GPIO     CONFIG_ESP_RED_LED_GPIO   // Rode LED: WiFi/lifecycle-indicator

static const char *RELAY_TAG   = "RELAY";
static const char *BUTTON_TAG  = "BUTTON";
static const char *IDENT_TAG   = "IDENT";
static const char *BL0937_TAG  = "BL0937";
static const char *ENERGY_TAG  = "ENERGY";

// Persisted cumulative energy so it survives reboots.
// We store energy in milli-Wh (mWh) to keep it compact and avoid float issues.
static const char *ENERGY_NVS_NAMESPACE = "bl0937";
static const char *ENERGY_NVS_KEY_MWH   = "energy_mwh";
static const char *CAL_NVS_KEY_VCAL     = "v_cal";
static const char *CAL_NVS_KEY_ICAL     = "i_cal";
static const char *CAL_NVS_KEY_PCAL     = "p_cal";

// Relay state (used by both the HomeKit relay service and the BL0937 watchdog)
// NOTE: keep this definition above tasks that reference it.
static bool relay_on = false;

// Base cumulative energy loaded from NVS (mWh). Total energy = base + (energy since boot).
static uint64_t s_energy_mwh_base = 0;

// Flag set by the button handler when energy is reset. The measurement task
// uses it to re-sync its internal change tracking and force HomeKit updates.
static volatile bool s_energy_reset_requested = false;

static esp_err_t energy_load_from_nvs(void)
{
        nvs_handle_t nvs;
        esp_err_t err = nvs_open(ENERGY_NVS_NAMESPACE, NVS_READONLY, &nvs);
        if (err != ESP_OK) {
                ESP_LOGW(ENERGY_TAG, "NVS open (read) failed (%s). Using 0.", esp_err_to_name(err));
                s_energy_mwh_base = 0;
                return err;
        }

        uint64_t val = 0;
        err = nvs_get_u64(nvs, ENERGY_NVS_KEY_MWH, &val);
        nvs_close(nvs);

        if (err == ESP_OK) {
                s_energy_mwh_base = val;
                ESP_LOGI(ENERGY_TAG, "Loaded energy from NVS: %llu mWh", (unsigned long long)val);
                return ESP_OK;
        }

        if (err == ESP_ERR_NVS_NOT_FOUND) {
                ESP_LOGI(ENERGY_TAG, "No stored energy found in NVS yet (fresh install). Using 0.");
                s_energy_mwh_base = 0;
                return ESP_OK;
        }

        ESP_LOGW(ENERGY_TAG, "NVS get_u64 failed (%s). Using 0.", esp_err_to_name(err));
        s_energy_mwh_base = 0;
        return err;
}

static esp_err_t energy_save_to_nvs(uint64_t energy_mwh)
{
        nvs_handle_t nvs;
        esp_err_t err = nvs_open(ENERGY_NVS_NAMESPACE, NVS_READWRITE, &nvs);
        if (err != ESP_OK) {
                ESP_LOGW(ENERGY_TAG, "NVS open (write) failed (%s)", esp_err_to_name(err));
                return err;
        }

        err = nvs_set_u64(nvs, ENERGY_NVS_KEY_MWH, energy_mwh);
        if (err == ESP_OK) {
                err = nvs_commit(nvs);
        }
        nvs_close(nvs);

        if (err != ESP_OK) {
                ESP_LOGW(ENERGY_TAG, "NVS save failed (%s)", esp_err_to_name(err));
        }
        return err;
}

static void calibration_load_from_nvs(bl0937_config_t *cfg)
{
        if (!cfg) return;

        nvs_handle_t nvs;
        esp_err_t err = nvs_open(ENERGY_NVS_NAMESPACE, NVS_READONLY, &nvs);
        if (err != ESP_OK) {
                ESP_LOGW(ENERGY_TAG, "NVS open (cal read) failed (%s). Using menuconfig defaults.", esp_err_to_name(err));
                return;
        }

        float vcal = cfg->voltage_calibration;
        float ical = cfg->current_calibration;
        float pcal = cfg->power_calibration;

        size_t len = sizeof(float);
        if (nvs_get_blob(nvs, CAL_NVS_KEY_VCAL, &vcal, &len) != ESP_OK) vcal = cfg->voltage_calibration;
        len = sizeof(float);
        if (nvs_get_blob(nvs, CAL_NVS_KEY_ICAL, &ical, &len) != ESP_OK) ical = cfg->current_calibration;
        len = sizeof(float);
        if (nvs_get_blob(nvs, CAL_NVS_KEY_PCAL, &pcal, &len) != ESP_OK) pcal = cfg->power_calibration;

        nvs_close(nvs);

        // Clamp to sane ranges to prevent absurd Eve/Home displays.
        if (!isfinite(vcal) || vcal < 0.5f || vcal > 2.0f) vcal = cfg->voltage_calibration;
        if (!isfinite(ical) || ical < 0.5f || ical > 2.0f) ical = cfg->current_calibration;
        if (!isfinite(pcal) || pcal < 0.5f || pcal > 2.0f) pcal = cfg->power_calibration;

        cfg->voltage_calibration = vcal;
        cfg->current_calibration = ical;
        cfg->power_calibration   = pcal;

        ESP_LOGI(ENERGY_TAG, "Calibration (NVS/menuconfig): V=%.6f I=%.6f P=%.6f", (double)vcal, (double)ical, (double)pcal);
}

// BL0937 -> HomeKit update interval
// How often we read the BL0937 and push values to HomeKit.
// Can be overridden via menuconfig (StudioPieters -> BL0937 Update Period).
#if defined(CONFIG_ESP_BL0937_UPDATE_PERIOD_MS)
#define BL0937_UPDATE_PERIOD_MS CONFIG_ESP_BL0937_UPDATE_PERIOD_MS
#else
#ifndef BL0937_UPDATE_PERIOD_MS
#define BL0937_UPDATE_PERIOD_MS 1000
#endif
#endif

// These characteristics are defined later in this file (Step 3).
// Energy meter characteristics are defined in this file and attached to the Outlet service.
// Use these names everywhere to avoid duplicate/copy issues with homekit_characteristic_t.
extern homekit_characteristic_t energy_voltage;
extern homekit_characteristic_t energy_current;
extern homekit_characteristic_t energy_power;
extern homekit_characteristic_t energy_energy;

// ---------- Status LED (RED) state machine ----------
// The red LED is used as a lifecycle indicator:
// - WiFi not ready / provisioning: solid ON
// - Normal: OFF
// - BL0937 error: 1 blink every 2 seconds
typedef enum {
        LED_MODE_WIFI_WAIT = 0,
        LED_MODE_NORMAL_OFF,
        LED_MODE_BL0937_ERROR,
} led_mode_t;

static volatile led_mode_t s_led_mode = LED_MODE_WIFI_WAIT;

static void bl0937_homekit_task(void *arg)
{
        (void)arg;

        // Init + start driver (single place; do NOT init in app_main as well)
        bl0937_config_t cfg = bl0937_config_default();
        calibration_load_from_nvs(&cfg);

        esp_err_t err = bl0937_init(&cfg);
        if (err != ESP_OK) {
                ESP_LOGE(BL0937_TAG, "bl0937_init failed: %s", esp_err_to_name(err));
                vTaskDelete(NULL);
                return;
        }

        err = bl0937_start();
        if (err != ESP_OK) {
                ESP_LOGE(BL0937_TAG, "bl0937_start failed: %s", esp_err_to_name(err));
                vTaskDelete(NULL);
                return;
        }

        // Restore cumulative energy base from NVS.
        (void)energy_load_from_nvs();

        // Persist total energy every 60s (mWh integer), so it survives reboots.
        TickType_t next_persist = xTaskGetTickCount() + pdMS_TO_TICKS(60000);

        ESP_LOGI(ENERGY_TAG, "BL0937 -> HomeKit task started (period=%d ms)", BL0937_UPDATE_PERIOD_MS);

        // --- Eve/Home polish ---
        // 1) Smooth noisy signals (EMA)
        // 2) Round to stable decimals (Eve graphs hate raw jitter)
        // 3) Rate-limit notifications (avoid HomeKit notify spam)
        // 4) Threshold updates (only meaningful changes)
        const float EMA_ALPHA_V = 0.20f;
        const float EMA_ALPHA_I = 0.20f;
        const float EMA_ALPHA_P = 0.20f;

        // Notify thresholds
        const float V_DELTA = 0.5f;     // V
        const float I_DELTA = 0.02f;    // A
        const float P_DELTA = 1.0f;     // W
        const float E_DELTA = 0.001f;   // kWh (1Wh)

        // Notify pacing
        const int64_t MIN_NOTIFY_US = 2LL * 1000LL * 1000LL; // 2 seconds

        float v_f = NAN, c_f = NAN, p_f = NAN;
        float last_v = NAN, last_c = NAN, last_p = NAN, last_e_kwh = NAN;
        int64_t last_v_notify_us = 0, last_c_notify_us = 0, last_p_notify_us = 0, last_e_notify_us = 0;

        // Error detection for BL0937 signal integrity (drives RED LED blink)
        int invalid_ticks = 0;
        int valid_recover_ticks = 0;

        while (true) {
                if (s_energy_reset_requested) {
                        s_energy_reset_requested = false;

                        // Reset smoothing + change detection so the next loop publishes fresh values.
                        v_f = c_f = p_f = NAN;
                        last_v = last_c = last_p = last_e_kwh = NAN;
                        last_v_notify_us = last_c_notify_us = last_p_notify_us = last_e_notify_us = 0;

                        // Publish a zero snapshot immediately (Home app feedback).
                        energy_voltage.value = HOMEKIT_FLOAT(0.0f);
                        homekit_characteristic_notify(&energy_voltage, energy_voltage.value);

                        energy_current.value = HOMEKIT_FLOAT(0.0f);
                        homekit_characteristic_notify(&energy_current, energy_current.value);

                        energy_power.value = HOMEKIT_FLOAT(0.0f);
                        homekit_characteristic_notify(&energy_power, energy_power.value);

                        energy_energy.value = HOMEKIT_FLOAT(0.0f);
                        homekit_characteristic_notify(&energy_energy, energy_energy.value);
                }

                const bl0937_measurements_t m = bl0937_get();

                // --- BL0937 validity watchdog (for stable Home/Eve behaviour) ---
                // When the relay is OFF, it's normal for there to be no meaningful load.
                // In that state we suppress the BL0937 "error blink" and reset the watchdog.
                if (!relay_on) {
                        invalid_ticks = 0;
                        valid_recover_ticks = 0;
                        if (s_led_mode == LED_MODE_BL0937_ERROR) {
                                s_led_mode = LED_MODE_NORMAL_OFF;
                        }
                } else {
                        const bool ok_now = (m.valid_voltage && m.valid_current && m.valid_power);
                        if (!ok_now) {
                                valid_recover_ticks = 0;
                                invalid_ticks++;
                        } else {
                                invalid_ticks = 0;
                                valid_recover_ticks++;
                        }

                        // After ~5 seconds of invalid data, indicate hardware/driver issue.
                        if (invalid_ticks * BL0937_UPDATE_PERIOD_MS >= 5000) {
                                /* disabled red error blink */
                        }
                        // If we recovered and are stable again for ~2 seconds, clear error.
                        if (s_led_mode == LED_MODE_BL0937_ERROR && valid_recover_ticks * BL0937_UPDATE_PERIOD_MS >= 2000) {
                                s_led_mode = LED_MODE_NORMAL_OFF;
                        }
                }

                // When not yet "valid" we still publish 0.0 (keeps Home app happy), but
                // we log the raw Hz values so calibration/debugging stays possible.
                if (!m.valid_voltage || !m.valid_current || !m.valid_power) {
                        ESP_LOGD(BL0937_TAG, "raw: cf=%.2fHz cfu=%.2fHz cfi=%.2fHz (valid V/I/P=%d/%d/%d)",
                                 (double)m.cf_hz, (double)m.cfu_hz, (double)m.cfi_hz,
                                 m.valid_voltage, m.valid_current, m.valid_power);
                }

                float v = (m.valid_voltage ? m.voltage_v : 0.0f);
                float c = (m.valid_current ? m.current_a : 0.0f);
                float p = (m.valid_power   ? m.power_w   : 0.0f);

                // Clamp and sanitize (Eve/Home dislikes NaN/inf/negative spikes)
                if (!isfinite(v) || v < 0.0f) v = 0.0f;
                if (!isfinite(c) || c < 0.0f) c = 0.0f;
                if (!isfinite(p) || p < 0.0f) p = 0.0f;

                // Smooth (EMA) only when we have valid signals
                if (isnan(v_f)) v_f = v; else v_f = v_f * (1.0f - EMA_ALPHA_V) + v * EMA_ALPHA_V;
                if (isnan(c_f)) c_f = c; else c_f = c_f * (1.0f - EMA_ALPHA_I) + c * EMA_ALPHA_I;
                if (isnan(p_f)) p_f = p; else p_f = p_f * (1.0f - EMA_ALPHA_P) + p * EMA_ALPHA_P;

                // Rounding/formatting (stable UI)
                const float v_out = roundf(v_f * 10.0f) / 10.0f;         // 0.1V
                const float c_out = roundf(c_f * 1000.0f) / 1000.0f;     // 0.001A
                const float p_out = roundf(p_f);                         // 1W

                // Total energy: base (NVS) + since-boot counter (from driver)
                const uint64_t e_since_boot_mwh = (uint64_t)((double)m.energy_wh * 1000.0);
                const uint64_t e_total_mwh = s_energy_mwh_base + e_since_boot_mwh;
                float e_total_kwh = (float)e_total_mwh / 1000000.0f;     // mWh -> kWh
                if (!isfinite(e_total_kwh) || e_total_kwh < 0.0f) e_total_kwh = 0.0f;
                e_total_kwh = roundf(e_total_kwh * 1000.0f) / 1000.0f;   // 0.001 kWh

                const int64_t now_us = esp_timer_get_time();

                // Update HomeKit characteristics only when meaningful AND rate-limited.
                if (isnan(last_v) || (fabsf(v_out - last_v) >= V_DELTA && (now_us - last_v_notify_us) >= MIN_NOTIFY_US)) {
                        last_v = v_out;
                        last_v_notify_us = now_us;
                        energy_voltage.value = HOMEKIT_FLOAT(v_out);
                        homekit_characteristic_notify(&energy_voltage, energy_voltage.value);
                }

                if (isnan(last_c) || (fabsf(c_out - last_c) >= I_DELTA && (now_us - last_c_notify_us) >= MIN_NOTIFY_US)) {
                        last_c = c_out;
                        last_c_notify_us = now_us;
                        energy_current.value = HOMEKIT_FLOAT(c_out);
                        homekit_characteristic_notify(&energy_current, energy_current.value);
                }

                if (isnan(last_p) || (fabsf(p_out - last_p) >= P_DELTA && (now_us - last_p_notify_us) >= MIN_NOTIFY_US)) {
                        last_p = p_out;
                        last_p_notify_us = now_us;
                        energy_power.value = HOMEKIT_FLOAT(p_out);
                        homekit_characteristic_notify(&energy_power, energy_power.value);
                }

                if (isnan(last_e_kwh) || (fabsf(e_total_kwh - last_e_kwh) >= E_DELTA && (now_us - last_e_notify_us) >= MIN_NOTIFY_US)) {
                        last_e_kwh = e_total_kwh;
                        last_e_notify_us = now_us;
                        energy_energy.value = HOMEKIT_FLOAT(e_total_kwh);
                        homekit_characteristic_notify(&energy_energy, energy_energy.value);
                }

                // Persist cumulative energy periodically so it survives a reboot.
                if (xTaskGetTickCount() >= next_persist) {
                        next_persist = xTaskGetTickCount() + pdMS_TO_TICKS(60000);
                        // Save the current total. Do NOT change s_energy_mwh_base during runtime;
                        // it is the "energy at boot" base. This avoids double counting.
                        (void)energy_save_to_nvs(e_total_mwh);
                }

                vTaskDelay(pdMS_TO_TICKS(BL0937_UPDATE_PERIOD_MS));
        }
}

// Relay / plug state (enige bron van waarheid)
// (defined near the top of this file)

// Forward declarations (used by status LED task)
static inline void red_led_write(bool on);

static void status_led_task(void *arg)
{
        (void)arg;

        // 50ms tick gives us stable timing without blocking other tasks.
        const TickType_t tick = pdMS_TO_TICKS(50);
        uint32_t t_ms = 0;

        while (true) {
                led_mode_t mode = s_led_mode;

                switch (mode) {
                case LED_MODE_WIFI_WAIT:
                        red_led_write(true);
                        break;
                case LED_MODE_NORMAL_OFF:
                        red_led_write(false);
                        break;
                case LED_MODE_BL0937_ERROR: {
                        // 1 blink: 200ms ON, rest OFF, repeating every 2000ms
                        const uint32_t phase = t_ms % 2000;
                        red_led_write(phase < 200);
                        break;
                }
                default:
                        red_led_write(false);
                        break;
                }

                t_ms += 50;
                vTaskDelay(tick);
        }
}

// ---------- Low-level GPIO helpers ----------

static inline void relay_write(bool on) {
        gpio_set_level(RELAY_GPIO, on ? 1 : 0);
}

static inline void blue_led_write(bool on) {
        // Blauwe LED: uitsluitend als aan/uit-indicator voor de relay (active low/high afhankelijk van hardware)
        gpio_set_level(BLUE_LED_GPIO, on ? 1 : 0);
}

static inline void red_led_write(bool on) {
        // Rode LED is active high: 1 = AAN, 0 = UIT
        gpio_set_level(RED_LED_GPIO, on ? 1 : 0);
}

// Visual feedback helper: blink the RED LED 3x, but restore its previous state
// afterwards (RED LED is also used for Wi‑Fi status).
static void blink_red_led_3x(void)
{
        const int prev = gpio_get_level(RED_LED_GPIO);
        for (int i = 0; i < 3; i++) {
                red_led_write(true);
                vTaskDelay(pdMS_TO_TICKS(120));
                red_led_write(false);
                vTaskDelay(pdMS_TO_TICKS(120));
        }
        gpio_set_level(RED_LED_GPIO, prev);
}

// Forward declaration van de characteristic zodat we hem in functies kunnen gebruiken
extern homekit_characteristic_t relay_on_characteristic;

// Centrale functie: zet state, stuurt hardware aan en (optioneel) HomeKit-notify
static void relay_set_state(bool on, bool notify_homekit) {
        if (relay_on == on) {
                // Geen verandering, niets te doen
                return;
        }

        relay_on = on;

        // Hardware aansturen
        relay_write(relay_on);
        blue_led_write(relay_on); // Blauwe LED volgt altijd de relay-status

        ESP_LOGI(RELAY_TAG, "Relay state -> %s", relay_on ? "ON" : "OFF");

        // HomeKit characteristic-snapshot updaten
        relay_on_characteristic.value = HOMEKIT_BOOL(relay_on);

        // Eventueel HomeKit-clients informeren
        if (notify_homekit) {
                homekit_characteristic_notify(&relay_on_characteristic,
                                              relay_on_characteristic.value);
        }
}

// All GPIO Settings
void gpio_init(void) {
        // Relay
        gpio_reset_pin(RELAY_GPIO);
        gpio_set_direction(RELAY_GPIO, GPIO_MODE_OUTPUT);

        // Blauwe LED (aan/uit)
        gpio_reset_pin(BLUE_LED_GPIO);
        gpio_set_direction(BLUE_LED_GPIO, GPIO_MODE_OUTPUT);

        // Rode LED: WiFi-status-indicator
        gpio_reset_pin(RED_LED_GPIO);
        gpio_set_direction(RED_LED_GPIO, GPIO_MODE_OUTPUT);

        // Initial state: alles uit, in sync brengen
        relay_on = false;
        relay_on_characteristic.value = HOMEKIT_BOOL(false);
        relay_write(false);
        blue_led_write(false);

        // Bij start is er nog geen WiFi -> status LED in WiFi-wachtstand
        s_led_mode = LED_MODE_WIFI_WAIT;
}

// ---------- Accessory identification (Blue LED) ----------

void accessory_identify_task(void *args) {
        // Blink BLUE LED to identify, then restore previous state
        bool previous_led_state = relay_on; // LED volgt normaal relay_on

        for (int i = 0; i < 3; i++) {
                for (int j = 0; j < 2; j++) {
                        blue_led_write(true);
                        vTaskDelay(pdMS_TO_TICKS(100));
                        blue_led_write(false);
                        vTaskDelay(pdMS_TO_TICKS(100));
                }
                vTaskDelay(pdMS_TO_TICKS(250));
        }

        // Zet LED terug naar de normale toestand (afhankelijk van relay_on)
        blue_led_write(previous_led_state);

        vTaskDelete(NULL);
}

void accessory_identify(homekit_value_t _value) {
        ESP_LOGI(IDENT_TAG, "Accessory identify");
        xTaskCreate(accessory_identify_task, "Accessory identify", configMINIMAL_STACK_SIZE,
                    NULL, 2, NULL);
}

// ---------- HomeKit characteristics ----------

#define DEVICE_NAME          "HomeKit Plug"
#define DEVICE_MANUFACTURER  "StudioPieters®"
#define DEVICE_SERIAL        "NLCC7DFD193A"
#define DEVICE_MODEL         "LS066NL/A"
#define FW_VERSION           "0.0.1"

homekit_characteristic_t name = HOMEKIT_CHARACTERISTIC_(NAME, DEVICE_NAME);
homekit_characteristic_t manufacturer = HOMEKIT_CHARACTERISTIC_(MANUFACTURER, DEVICE_MANUFACTURER);
homekit_characteristic_t serial = HOMEKIT_CHARACTERISTIC_(SERIAL_NUMBER, DEVICE_SERIAL);
homekit_characteristic_t model = HOMEKIT_CHARACTERISTIC_(MODEL, DEVICE_MODEL);
homekit_characteristic_t revision = HOMEKIT_CHARACTERISTIC_(FIRMWARE_REVISION, LIFECYCLE_DEFAULT_FW_VERSION);
homekit_characteristic_t ota_trigger = API_OTA_TRIGGER;

// Getter: HomeKit vraagt huidige toestand op
homekit_value_t relay_on_get() {
        return HOMEKIT_BOOL(relay_on);
}

// Setter: aangeroepen door HomeKit (Home-app / Siri / automations)
void relay_on_set(homekit_value_t value) {
        if (value.format != homekit_format_bool) {
                ESP_LOGE(RELAY_TAG, "Invalid value format: %d", value.format);
                return;
        }

        bool new_state = value.bool_value;

        // Via centrale functie, maar ZONDER notify (originator is HomeKit zelf)
        relay_set_state(new_state, false);
}

// We keep a handle to ON characteristic so we can notify on button presses
homekit_characteristic_t relay_on_characteristic =
        HOMEKIT_CHARACTERISTIC_(ON, false, .getter = relay_on_get, .setter = relay_on_set);

#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Woverride-init"
homekit_accessory_t *accessories[] = {
        HOMEKIT_ACCESSORY(
                .id = 1,
                .category = homekit_accessory_category_outlets, // Smart plug / outlet
                .services = (homekit_service_t *[]) {
                HOMEKIT_SERVICE(ACCESSORY_INFORMATION, .characteristics = (homekit_characteristic_t *[]) {
                        &name,
                        &manufacturer,
                        &serial,
                        &model,
                        &revision,
                        HOMEKIT_CHARACTERISTIC(IDENTIFY, accessory_identify),
                        NULL
                }),
                HOMEKIT_SERVICE(OUTLET, .primary = true, .characteristics = (homekit_characteristic_t *[]) {
                        HOMEKIT_CHARACTERISTIC(NAME, "HomeKit Plug"),
                        &relay_on_characteristic,
                        &ota_trigger,
                        &energy_voltage,
                        &energy_current,
                        &energy_power,
                        &energy_energy,
                        NULL
                }),
                NULL
        }),
        NULL
};
#pragma GCC diagnostic pop

homekit_server_config_t config = {
        .accessories = accessories,
        .password = CONFIG_ESP_SETUP_CODE,
        .setupId = CONFIG_ESP_SETUP_ID,
};

// ---------- Button handling ----------

void button_callback(button_event_t event, void *context) {
        switch (event) {
        case button_event_single_press: {
                ESP_LOGI(BUTTON_TAG, "Single press -> toggle relay");

                bool new_state = !relay_on;

                // 1) Zelfde logica als HomeKit, maar nu MET notify
                relay_set_state(new_state, true);

                break;
        }
        case button_event_double_press:
                // Reset cumulative energy without wiping the whole device.
                ESP_LOGI(BUTTON_TAG, "Double press -> reset energy total");
                s_energy_mwh_base = 0;
                (void)energy_save_to_nvs(0);

                // Reset BL0937's internal accumulator. The periodic measurement task
                // will push the updated (0.0) energy value to HomeKit on the next tick.
                (void)bl0937_reset_energy();
                s_energy_reset_requested = true;

                // Visual confirmation for the user (same idea as Identify)
                blink_red_led_3x();
                break;
        case button_event_long_press:
                ESP_LOGI(BUTTON_TAG, "Long press (10s) -> factory reset + reboot");
                lifecycle_factory_reset_and_reboot();
                break;
        default:
                ESP_LOGI(BUTTON_TAG, "Unknown button event: %d", event);
                break;
        }
}

// ---------- Wi-Fi / HomeKit startup ----------

void on_wifi_ready() {
        static bool homekit_started = false;

        // WiFi is nu up -> status LED normaal UIT (tenzij BL0937 error mode hem overneemt)
        if (s_led_mode == LED_MODE_WIFI_WAIT) {
                s_led_mode = LED_MODE_NORMAL_OFF;
        }

        if (homekit_started) {
                ESP_LOGI("INFORMATION", "HomeKit server already running; skipping re-initialization");
                return;
        }

        ESP_LOGI("INFORMATION", "Starting HomeKit server...");
        homekit_server_init(&config);
        homekit_started = true;
}

// ---------- app_main ----------

void app_main(void) {
        ESP_ERROR_CHECK(lifecycle_nvs_init());
        lifecycle_log_post_reset_state("INFORMATION");
        ESP_ERROR_CHECK(lifecycle_configure_homekit(&revision, &ota_trigger, "INFORMATION"));

        gpio_init();

        // Start status LED task (handles RED LED patterns non-blocking)
        xTaskCreate(status_led_task, "status_led", 2048, NULL, 1, NULL);

#if CONFIG_BL0937_ENABLE
        // Step 4: start the BL0937 measurement + HomeKit publisher task
        xTaskCreate(bl0937_homekit_task, "bl0937_hk", 4096, NULL, 5, NULL);
#else
        ESP_LOGI(BL0937_TAG, "BL0937 disabled in sdkconfig (CONFIG_BL0937_ENABLE=n)");
#endif

        button_config_t btn_cfg = button_config_default(button_active_low);
        btn_cfg.max_repeat_presses = 3;
        btn_cfg.long_press_time = 10000; // 10 seconds for lifecycle_factory_reset_and_reboot

        if (button_create(BUTTON_GPIO, btn_cfg, button_callback, NULL)) {
                ESP_LOGE(BUTTON_TAG, "Failed to initialize button");
        }

        esp_err_t wifi_err = wifi_start(on_wifi_ready);
        if (wifi_err == ESP_ERR_NVS_NOT_FOUND) {
                ESP_LOGW("WIFI", "WiFi configuration not found; provisioning required");
                // Geen geldige WiFi-config -> status LED WiFi-wachtstand
                s_led_mode = LED_MODE_WIFI_WAIT;
        } else if (wifi_err != ESP_OK) {
                ESP_LOGE("WIFI", "Failed to start WiFi: %s", esp_err_to_name(wifi_err));
                // Fout bij starten WiFi -> status LED WiFi-wachtstand
                s_led_mode = LED_MODE_WIFI_WAIT;
        }
}

Firmware Behavior

The firmware exposes a HomeKit Outlet with extended energy characteristics:

1. Relay & Blue LED

  • Blue LED always follows relay state
  • Relay is the single source of truth

2. Red LED, Wi‑Fi / Provisioning Status

  • ON during boot
  • OFF when Wi‑Fi is connected
  • ON if Wi‑Fi configuration is missing or fails

3. Button Actions

  • Single press: toggle relay + notify HomeKit
  • Long press (10s): factory reset + reboot via LCM

4. Energy Metering (BL0937)

A dedicated task continuously samples the BL0937 and updates HomeKit characteristics:

  • Voltage
  • Current
  • Power
  • Energy (kWh)

5. Optional Overcurrent Protection

When enabled in menuconfig:

  • Relay trips if current exceeds threshold
  • Debounce prevents false positives
  • HomeKit state updates immediately
  • Optional cooldown before re‑enable

Sign Firmware

Open anew terminal window go to the directory ESP32-Power-Plug-with-Energy-Meter and run:

openssl sha384 -binary -out build/main.bin.sig build/main.bin
printf "{91c9fad616bf0725810074c79079914b0bb05d8bf60d02d56ac06d2922548669}08x" "$(wc -c < build/main.bin)" | xxd -r -p >> build/main.bin.sig

Or just use my repository and read further below.

Install via LCM

  1. Connect to WiFi: LCM-XXXXX
  2. Portal opens automatically
  3. Enter home WiFi credentials
  4. Firmware source: AchimPieters/ESP32-Power-Plug-with-Energy-Meter
  5. Enable GPIO3 (Red LED in this case) and Led Level On
  6. Click Join
Lifecycle Manager's captive portal Wi-Fi setup screen

Installation begins, the LED starts blinking. When done the Device reboots.

Pair with Apple Home

Add HomeKit-power plug Devices to HomeKit

  1. Open the Home app.
  2. Scan the device's QR-Code.
  3. Follow the device-specific instructions.
  4. Name your device and assign it to a room.

Apple Home vs Eve Home, Why Eve Matters

Apple’s Home app intentionally exposes only a minimal feature set. While this is fine for basic on/off control, it does notprovide:

  • Firmware update UI
  • Detailed power metrics
  • Energy history graphs
  • Advanced accessory diagnostics

This is where the Eve Home app becomes essential.

The Eve Home app showing the power plug's energy metering data

What Eve Home Adds

Using Eve (free, no account required), you gain access to:

  • Full visibility of custom HomeKit characteristics
  • Live voltage, current, power, and energy readings
  • Historical graphs (hour / day / week / month)
  • Firmware update interface (OTA via LCM)
  • Debug‑friendly accessory information

Firmware Updates via Eve

To update firmware:

  1. Install the Eve Home app
  2. Select the smart plug
  3. Open accessory details
  4. Choose Firmware Update
  5. Wait for OTA process to complete

Apple Home itself does not offer this functionality.

Pairing with Apple Home

  1. Open the Home app
  2. Scan the HomeKit QR code
  3. Follow the pairing steps
  4. Assign the plug to a room

Done.

Update Firmware

When You want to update your plug with a new release of the firmware you have to download the EVE App. This because the Home App, doesn't has a building update function. In the Eve App select your device in this case your Power plug and hold it until you see "Turn On" and "Show in (the room you selected)", select the room, here you only see the homkitplu and turn on or off? press between the homekitplug and the on/off option, and a new screen will magically appear here select FirmwareUpdate, this whole takes a few minutes, including the reconnection to HomeKit.

Selecting the power plug in Apple Home to start a firmware update

Final Result

You now have:

  • A smart plug you truly own
  • Native Apple HomeKit integration
  • OTA firmware updates
  • No cloud dependency
  • No vendor lock‑in
  • No telemetry or spyware
  • Fully open‑source firmware
  • Long‑term usability

This device has transitioned from a disposable consumer product into personal, maintainable hardware.

Advertisement
All projects