SEM-METER/Fusion Energy Emporia Vue 2 clone ESPHome Conversion

I managed to reverse engineer the guts of the S3 based clone of Emporia Vue, called SEM-METER (Fusion Energy). I calibrated both the 50a and the 200a clips using a kill-o-watt and then checked with various loads, they seem to all be within 1% or less.

#==============================================================================
# ESP32-S3 "Emporia Vue 2 clone" (W338 / Artery AT32F421) - ESPHome
#==============================================================================
# Single-file config for the Chinese W338 clone: ESP32-S3-WROOM-1U + AT32F421
# metering MCU. The AT32 streams a measurement frame over UART (ESP RX=GPIO39,
# 115200 8N1); the uart debug parser decodes it into a globals array, and
# template sensors cir1..cir16 / phase_a/b/c read that array. 16 branch circuits
# + 3 mains phases, per-circuit 240V + enable toggles, daily energy, and a
# connection-aware status LED (blink/solid/glow).
#
# Frame map (record = byte after each 0xFF tag):
#   0x00..0x0F = branch circuits 1..16,  0x10/0x11/0x12 = mains phase A/B/C
#   per active (status 0x07) record, offsets after "FF NN":
#     r[0] status (07 active, 01 idle)
#     r[4..5]  voltage  BE16 (/10.30 = V)   [mains]
#     r[12..15] real pwr BE32 (/95 branch, /102 mains = W)
#     r[20]    line frequency (Hz, direct)
# Calibrated against a Kill-A-Watt near 240 W - trim the /95 and /102 divisors
# per channel if your CTs read high/low.
#
# Onboard pins (found by pin-walk): Status LED GPIO2, buzzer GPIO21.
# >>> Replace the WiFi / API key / OTA placeholders below before flashing. <<<
#==============================================================================

substitutions:
  device_name: "emporia-clone"
  friendly_name: "Emporia Clone Monitor"
  device_description: "W338 AT32 clone - 16-channel branch circuit energy monitor"

  leg_1: "Phase A"
  leg_2: "Phase B"
  leg_3: "Phase C"
  cir_1:  "Circuit 1"
  cir_2:  "Circuit 2"
  cir_3:  "Circuit 3"
  cir_4:  "Circuit 4"
  cir_5:  "Circuit 5"
  cir_6:  "Circuit 6"
  cir_7:  "Circuit 7"
  cir_8:  "Circuit 8"
  cir_9:  "Circuit 9"
  cir_10: "Circuit 10"
  cir_11: "Circuit 11"
  cir_12: "Circuit 12"
  cir_13: "Circuit 13"
  cir_14: "Circuit 14"
  cir_15: "Circuit 15"
  cir_16: "Circuit 16"
  xtra:   "Balance"   # leftover/unmonitored power (total minus circuits 1..16)

esp32:
  board: esp32-s3-devkitc-1
  framework:
    type: esp-idf

esphome:
  name: ${device_name}
  comment: ${device_description}
  friendly_name: ${friendly_name}
  on_boot:
    priority: -100
    then:
      - script.execute: update_status_led

logger:
  level: DEBUG
  logs:
    sensor: INFO   # silence per-sensor publish spam (16 circuits + 3 phases)

# ---- Network ----------------------------------------------------------------
wifi:
  ssid: "YOUR_WIFI_SSID"            # <-- edit
  password: "YOUR_WIFI_PASSWORD"    # <-- edit
  min_auth_mode: WPA2
  # AP fallback + captive portal: if it can't join your WiFi, it broadcasts
  # "<name> Setup" so you can re-provision from a phone.
  ap:
    ssid: "${device_name} Setup"
  on_connect:
    - script.execute: update_status_led
  on_disconnect:
    - script.execute: update_status_led

captive_portal:

api:
  reboot_timeout: 0s
  encryption:
    key: "REPLACE_WITH_44_CHAR_BASE64_KEY="   # <-- esphome wizard generates this
  on_client_connected:
    - script.execute: update_status_led
  on_client_disconnected:
    - script.execute: update_status_led

ota:
  platform: esphome
  password: "REPLACE_WITH_OTA_PASSWORD"   # <-- edit

web_server:
  port: 80
  version: 2

mdns:
  disabled: false

time:
  - platform: homeassistant
    id: device_time

preferences:
  # Daily-energy totals use restore:true; batch NVS writes to bound flash wear.
  flash_write_interval: "1h"

# ---- UART data source: the AT32 measurement frame ---------------------------
globals:
  - id: raw_w
    type: float[16]
    restore_value: no
  - id: raw_pa
    type: float
    restore_value: no
  - id: raw_pb
    type: float
    restore_value: no
  - id: raw_pc
    type: float
    restore_value: no
  - id: line_v_a
    type: float
    restore_value: no
  - id: line_v_b
    type: float
    restore_value: no
  - id: line_hz
    type: float
    restore_value: no

uart:
  id: meter_uart
  rx_pin: GPIO39
  baud_rate: 115200
  rx_buffer_size: 1024
  debug:
    direction: RX
    dummy_receiver: true
    after:
      delimiter: "\n"
      timeout: 250ms
      bytes: 400
    sequence:
      - lambda: |-
          const auto &b = bytes;
          int n = b.size();
          for (int i = 0; i + 2 < n; i++) {
            if (b[i] != 0xFF) continue;
            uint8_t rec = b[i+1];
            if (rec > 0x12) continue;
            uint8_t status = b[i+2];
            if (status != 0x01 && status != 0x07) continue;
            const uint8_t *r = &b[i+2];

            // Idle record: zero the channel so a load turning OFF reads 0.
            if (status == 0x01) {
              if (rec <= 0x0F) id(raw_w)[rec] = 0.0f;
              else if (rec == 0x10) id(raw_pa) = 0.0f;
              else if (rec == 0x11) id(raw_pb) = 0.0f;
              else if (rec == 0x12) id(raw_pc) = 0.0f;
              continue;
            }

            // Active record: need the full field span in this chunk.
            if (i + 22 >= n) continue;

            uint32_t wraw = ((uint32_t)r[12] << 24) | ((uint32_t)r[13] << 16)
                          | ((uint32_t)r[14] << 8)  |  (uint32_t)r[15];
            uint16_t vraw = (r[4] << 8) | r[5];

            if (rec <= 0x0F) {
              id(raw_w)[rec] = wraw / 95.0f;          // 50 A branch CT scale
            } else if (rec == 0x10) {
              id(raw_pa) = wraw / 102.0f;             // 200 A mains CT scale
              if (vraw > 900) { id(line_v_a) = vraw / 10.30f; id(line_hz) = (float) r[20]; }
            } else if (rec == 0x11) {
              id(raw_pb) = wraw / 102.0f;
              if (vraw > 900) id(line_v_b) = vraw / 10.30f;
            } else if (rec == 0x12) {
              id(raw_pc) = wraw / 102.0f;
            }
          }

# ---- Buzzer + connection-aware status LED -----------------------------------
output:
  - platform: ledc
    pin: GPIO41
    id: buzzer
  - platform: ledc          # status LED (PWM so it can glow)
    id: status_led_output
    pin: GPIO2              # add `inverted: true` here if the LED is active-low
    frequency: 1000Hz

rtttl:
  output: buzzer
  on_finished_playback:
    - logger.log: "Song ended!"

light:
  - platform: monochromatic
    id: status_led
    output: status_led_output
    internal: true
    default_transition_length: 0ms
    effects:
      - strobe:
          name: "Fast Blink"
          colors:
            - { state: true,  brightness: 100%, duration: 500ms }
            - { state: false, duration: 500ms }
      - lambda:
          name: "Slow Glow"
          update_interval: 40ms
          lambda: |-
            static float phase = 0;
            phase += 0.05;  // ~2s full cycle
            if (phase > 3.14159) phase = -3.14159;
            float brightness = (sin(phase) + 1.0) / 2.0;
            auto call = id(status_led).turn_on();
            call.set_brightness(brightness);
            call.set_transition_length(0);
            call.perform();

# LED: fast blink = no WiFi, solid = WiFi but no API, slow glow = API connected.
script:
  - id: update_status_led
    then:
      - if:
          condition: { not: { wifi.connected: } }
          then:
            - light.turn_on: { id: status_led, brightness: 100%, effect: "Fast Blink" }
          else:
            - if:
                condition: { api.connected: }
                then:
                  - light.turn_on: { id: status_led, brightness: 100%, effect: "Slow Glow" }
                else:
                  - light.turn_on: { id: status_led, brightness: 100%, effect: none }

interval:
  - interval: 30s   # backup refresh in case an event is missed
    then:
      - script.execute: update_status_led

button:
  - platform: template
    name: "Two Beeps"
    on_press:
      - rtttl.play: "two short:d=4,o=5,b=100:16e6,16e6"
  - platform: template
    name: "Funky Town"
    icon: mdi:music
    on_press:
      - rtttl.play: "funkytown:d=8,o=4,b=125:c6,c6,a#5,c6,p,g5,p,g5,c6,f6,e6,c6,p,c6,c6,a#5,c6,p,g5,p,g5,c6,f6,e6,c6"

# ---- Per-circuit 240V mode + enable toggles (runtime, no reflash) -----------
switch:
  - platform: restart
    name: "Restart"
    entity_category: config

  # 240V mode ON = single-leg CT on a double-pole breaker; firmware doubles W.
  - { platform: template, name:  "${cir_1} 240V Mode", id:  cir1_is_240v, optimistic: true, restore_mode: RESTORE_DEFAULT_ON,  entity_category: config }
  - { platform: template, name:  "${cir_2} 240V Mode", id:  cir2_is_240v, optimistic: true, restore_mode: RESTORE_DEFAULT_ON,  entity_category: config }
  - { platform: template, name:  "${cir_3} 240V Mode", id:  cir3_is_240v, optimistic: true, restore_mode: RESTORE_DEFAULT_OFF, entity_category: config }
  - { platform: template, name:  "${cir_4} 240V Mode", id:  cir4_is_240v, optimistic: true, restore_mode: RESTORE_DEFAULT_OFF, entity_category: config }
  - { platform: template, name:  "${cir_5} 240V Mode", id:  cir5_is_240v, optimistic: true, restore_mode: RESTORE_DEFAULT_OFF, entity_category: config }
  - { platform: template, name:  "${cir_6} 240V Mode", id:  cir6_is_240v, optimistic: true, restore_mode: RESTORE_DEFAULT_OFF, entity_category: config }
  - { platform: template, name:  "${cir_7} 240V Mode", id:  cir7_is_240v, optimistic: true, restore_mode: RESTORE_DEFAULT_OFF, entity_category: config }
  - { platform: template, name:  "${cir_8} 240V Mode", id:  cir8_is_240v, optimistic: true, restore_mode: RESTORE_DEFAULT_OFF, entity_category: config }
  - { platform: template, name:  "${cir_9} 240V Mode", id:  cir9_is_240v, optimistic: true, restore_mode: RESTORE_DEFAULT_OFF, entity_category: config }
  - { platform: template, name: "${cir_10} 240V Mode", id: cir10_is_240v, optimistic: true, restore_mode: RESTORE_DEFAULT_OFF, entity_category: config }
  - { platform: template, name: "${cir_11} 240V Mode", id: cir11_is_240v, optimistic: true, restore_mode: RESTORE_DEFAULT_OFF, entity_category: config }
  - { platform: template, name: "${cir_12} 240V Mode", id: cir12_is_240v, optimistic: true, restore_mode: RESTORE_DEFAULT_OFF, entity_category: config }
  - { platform: template, name: "${cir_13} 240V Mode", id: cir13_is_240v, optimistic: true, restore_mode: RESTORE_DEFAULT_OFF, entity_category: config }
  - { platform: template, name: "${cir_14} 240V Mode", id: cir14_is_240v, optimistic: true, restore_mode: RESTORE_DEFAULT_OFF, entity_category: config }
  - { platform: template, name: "${cir_15} 240V Mode", id: cir15_is_240v, optimistic: true, restore_mode: RESTORE_DEFAULT_OFF, entity_category: config }
  - { platform: template, name: "${cir_16} 240V Mode", id: cir16_is_240v, optimistic: true, restore_mode: RESTORE_DEFAULT_OFF, entity_category: config }

  # Enable OFF = report 0 W and freeze that circuit's daily energy.
  - { platform: template, name:  "${cir_1} Enabled", id:  cir1_enabled, optimistic: true, restore_mode: RESTORE_DEFAULT_ON, entity_category: config }
  - { platform: template, name:  "${cir_2} Enabled", id:  cir2_enabled, optimistic: true, restore_mode: RESTORE_DEFAULT_ON, entity_category: config }
  - { platform: template, name:  "${cir_3} Enabled", id:  cir3_enabled, optimistic: true, restore_mode: RESTORE_DEFAULT_ON, entity_category: config }
  - { platform: template, name:  "${cir_4} Enabled", id:  cir4_enabled, optimistic: true, restore_mode: RESTORE_DEFAULT_ON, entity_category: config }
  - { platform: template, name:  "${cir_5} Enabled", id:  cir5_enabled, optimistic: true, restore_mode: RESTORE_DEFAULT_ON, entity_category: config }
  - { platform: template, name:  "${cir_6} Enabled", id:  cir6_enabled, optimistic: true, restore_mode: RESTORE_DEFAULT_ON, entity_category: config }
  - { platform: template, name:  "${cir_7} Enabled", id:  cir7_enabled, optimistic: true, restore_mode: RESTORE_DEFAULT_ON, entity_category: config }
  - { platform: template, name:  "${cir_8} Enabled", id:  cir8_enabled, optimistic: true, restore_mode: RESTORE_DEFAULT_ON, entity_category: config }
  - { platform: template, name:  "${cir_9} Enabled", id:  cir9_enabled, optimistic: true, restore_mode: RESTORE_DEFAULT_ON, entity_category: config }
  - { platform: template, name: "${cir_10} Enabled", id: cir10_enabled, optimistic: true, restore_mode: RESTORE_DEFAULT_ON, entity_category: config }
  - { platform: template, name: "${cir_11} Enabled", id: cir11_enabled, optimistic: true, restore_mode: RESTORE_DEFAULT_ON, entity_category: config }
  - { platform: template, name: "${cir_12} Enabled", id: cir12_enabled, optimistic: true, restore_mode: RESTORE_DEFAULT_ON, entity_category: config }
  - { platform: template, name: "${cir_13} Enabled", id: cir13_enabled, optimistic: true, restore_mode: RESTORE_DEFAULT_ON, entity_category: config }
  - { platform: template, name: "${cir_14} Enabled", id: cir14_enabled, optimistic: true, restore_mode: RESTORE_DEFAULT_ON, entity_category: config }
  - { platform: template, name: "${cir_15} Enabled", id: cir15_enabled, optimistic: true, restore_mode: RESTORE_DEFAULT_ON, entity_category: config }
  - { platform: template, name: "${cir_16} Enabled", id: cir16_enabled, optimistic: true, restore_mode: RESTORE_DEFAULT_ON, entity_category: config }

binary_sensor:
  - platform: status
    name: "Status"
    entity_category: diagnostic

text_sensor:
  - platform: template
    name: "Uptime"
    id: uptime_human
    icon: mdi:clock-start
    entity_category: diagnostic
  - platform: version
    name: "ESPHome Version"
    entity_category: diagnostic
  - platform: wifi_info
    ip_address: { name: "IP", icon: mdi:ip-outline, entity_category: diagnostic }
    ssid:       { name: "SSID", icon: mdi:wifi-settings, entity_category: diagnostic }
    bssid:      { name: "BSSID", icon: mdi:wifi-settings, entity_category: diagnostic }
    mac_address: { name: "MAC", icon: mdi:network-outline, entity_category: diagnostic }

# YAML anchors reused below.
.filters:
  - &throttle_avg
    throttle_average: 5s
  - &throttle_time
    throttle: 60s

sensor:
  # ---- Diagnostics ----------------------------------------------------------
  - platform: wifi_signal
    name: "WiFi Signal Strength"
    update_interval: 60s
    entity_category: diagnostic
  - platform: uptime
    name: "Uptime Seconds"
    id: uptime_sensor
    update_interval: 60s
    internal: true
    on_raw_value:
      then:
        - text_sensor.template.publish:
            id: uptime_human
            state: !lambda |-
              int seconds = round(id(uptime_sensor).get_raw_state());
              int days = seconds / (24 * 3600);  seconds = seconds % (24 * 3600);
              int hours = seconds / 3600;         seconds = seconds % 3600;
              int minutes = seconds / 60;         seconds = seconds % 60;
              return ((days ? to_string(days) + "d " : "") +
                      (hours ? to_string(hours) + "h " : "") +
                      (minutes ? to_string(minutes) + "m " : "") +
                      (to_string(seconds) + "s")).c_str();

  # ---- RAW per-circuit power (internal); enable + 240V logic applied here ----
  - { platform: template, id:  cir1, internal: true, update_interval: 2s, device_class: power, unit_of_measurement: "W", lambda: 'float x = id(raw_w)[0];  return id(cir1_enabled).state  ? (id(cir1_is_240v).state  ? x*2.0f : x) : 0.0f;' }
  - { platform: template, id:  cir2, internal: true, update_interval: 2s, device_class: power, unit_of_measurement: "W", lambda: 'float x = id(raw_w)[1];  return id(cir2_enabled).state  ? (id(cir2_is_240v).state  ? x*2.0f : x) : 0.0f;' }
  - { platform: template, id:  cir3, internal: true, update_interval: 2s, device_class: power, unit_of_measurement: "W", lambda: 'float x = id(raw_w)[2];  return id(cir3_enabled).state  ? (id(cir3_is_240v).state  ? x*2.0f : x) : 0.0f;' }
  - { platform: template, id:  cir4, internal: true, update_interval: 2s, device_class: power, unit_of_measurement: "W", lambda: 'float x = id(raw_w)[3];  return id(cir4_enabled).state  ? (id(cir4_is_240v).state  ? x*2.0f : x) : 0.0f;' }
  - { platform: template, id:  cir5, internal: true, update_interval: 2s, device_class: power, unit_of_measurement: "W", lambda: 'float x = id(raw_w)[4];  return id(cir5_enabled).state  ? (id(cir5_is_240v).state  ? x*2.0f : x) : 0.0f;' }
  - { platform: template, id:  cir6, internal: true, update_interval: 2s, device_class: power, unit_of_measurement: "W", lambda: 'float x = id(raw_w)[5];  return id(cir6_enabled).state  ? (id(cir6_is_240v).state  ? x*2.0f : x) : 0.0f;' }
  - { platform: template, id:  cir7, internal: true, update_interval: 2s, device_class: power, unit_of_measurement: "W", lambda: 'float x = id(raw_w)[6];  return id(cir7_enabled).state  ? (id(cir7_is_240v).state  ? x*2.0f : x) : 0.0f;' }
  - { platform: template, id:  cir8, internal: true, update_interval: 2s, device_class: power, unit_of_measurement: "W", lambda: 'float x = id(raw_w)[7];  return id(cir8_enabled).state  ? (id(cir8_is_240v).state  ? x*2.0f : x) : 0.0f;' }
  - { platform: template, id:  cir9, internal: true, update_interval: 2s, device_class: power, unit_of_measurement: "W", lambda: 'float x = id(raw_w)[8];  return id(cir9_enabled).state  ? (id(cir9_is_240v).state  ? x*2.0f : x) : 0.0f;' }
  - { platform: template, id: cir10, internal: true, update_interval: 2s, device_class: power, unit_of_measurement: "W", lambda: 'float x = id(raw_w)[9];  return id(cir10_enabled).state ? (id(cir10_is_240v).state ? x*2.0f : x) : 0.0f;' }
  - { platform: template, id: cir11, internal: true, update_interval: 2s, device_class: power, unit_of_measurement: "W", lambda: 'float x = id(raw_w)[10]; return id(cir11_enabled).state ? (id(cir11_is_240v).state ? x*2.0f : x) : 0.0f;' }
  - { platform: template, id: cir12, internal: true, update_interval: 2s, device_class: power, unit_of_measurement: "W", lambda: 'float x = id(raw_w)[11]; return id(cir12_enabled).state ? (id(cir12_is_240v).state ? x*2.0f : x) : 0.0f;' }
  - { platform: template, id: cir13, internal: true, update_interval: 2s, device_class: power, unit_of_measurement: "W", lambda: 'float x = id(raw_w)[12]; return id(cir13_enabled).state ? (id(cir13_is_240v).state ? x*2.0f : x) : 0.0f;' }
  - { platform: template, id: cir14, internal: true, update_interval: 2s, device_class: power, unit_of_measurement: "W", lambda: 'float x = id(raw_w)[13]; return id(cir14_enabled).state ? (id(cir14_is_240v).state ? x*2.0f : x) : 0.0f;' }
  - { platform: template, id: cir15, internal: true, update_interval: 2s, device_class: power, unit_of_measurement: "W", lambda: 'float x = id(raw_w)[14]; return id(cir15_enabled).state ? (id(cir15_is_240v).state ? x*2.0f : x) : 0.0f;' }
  - { platform: template, id: cir16, internal: true, update_interval: 2s, device_class: power, unit_of_measurement: "W", lambda: 'float x = id(raw_w)[15]; return id(cir16_enabled).state ? (id(cir16_is_240v).state ? x*2.0f : x) : 0.0f;' }

  # RAW mains power (internal): A / B / C.
  - { platform: template, id: phase_a_power, internal: true, update_interval: 2s, device_class: power, unit_of_measurement: "W", lambda: 'return id(raw_pa);' }
  - { platform: template, id: phase_b_power, internal: true, update_interval: 2s, device_class: power, unit_of_measurement: "W", lambda: 'return id(raw_pb);' }
  - { platform: template, id: phase_c_power, internal: true, update_interval: 2s, device_class: power, unit_of_measurement: "W", lambda: 'return id(raw_pc);' }

  # ---- Phase voltage + frequency --------------------------------------------
  - { platform: template, name: "${leg_1} Voltage",   update_interval: 5s, unit_of_measurement: "V",  accuracy_decimals: 1, device_class: voltage,   state_class: measurement, lambda: 'return id(line_v_a);' }
  - { platform: template, name: "${leg_1} Frequency", update_interval: 5s, unit_of_measurement: "Hz", accuracy_decimals: 0, device_class: frequency, state_class: measurement, lambda: 'return id(line_hz);' }
  - { platform: template, name: "${leg_2} Voltage",   update_interval: 5s, unit_of_measurement: "V",  accuracy_decimals: 1, device_class: voltage,   state_class: measurement, lambda: 'return id(line_v_b);' }

  # ---- Published power (copy sensors, throttled) ----------------------------
  - { platform: copy, name: "${leg_1} Power", source_id: phase_a_power, filters: *throttle_avg }
  - { platform: copy, name: "${leg_2} Power", source_id: phase_b_power, filters: *throttle_avg }
  - { platform: copy, name: "${leg_3} Power", source_id: phase_c_power, filters: *throttle_avg }
  - { platform: copy, name: "Total Power",    source_id: total_power,   filters: *throttle_avg }
  - { platform: copy, name: "${xtra} Power",  source_id: balance_power, filters: *throttle_avg }
  - { platform: copy, name:  "${cir_1} Power", source_id:  cir1, filters: *throttle_avg }
  - { platform: copy, name:  "${cir_2} Power", source_id:  cir2, filters: *throttle_avg }
  - { platform: copy, name:  "${cir_3} Power", source_id:  cir3, filters: *throttle_avg }
  - { platform: copy, name:  "${cir_4} Power", source_id:  cir4, filters: *throttle_avg }
  - { platform: copy, name:  "${cir_5} Power", source_id:  cir5, filters: *throttle_avg }
  - { platform: copy, name:  "${cir_6} Power", source_id:  cir6, filters: *throttle_avg }
  - { platform: copy, name:  "${cir_7} Power", source_id:  cir7, filters: *throttle_avg }
  - { platform: copy, name:  "${cir_8} Power", source_id:  cir8, filters: *throttle_avg }
  - { platform: copy, name:  "${cir_9} Power", source_id:  cir9, filters: *throttle_avg }
  - { platform: copy, name: "${cir_10} Power", source_id: cir10, filters: *throttle_avg }
  - { platform: copy, name: "${cir_11} Power", source_id: cir11, filters: *throttle_avg }
  - { platform: copy, name: "${cir_12} Power", source_id: cir12, filters: *throttle_avg }
  - { platform: copy, name: "${cir_13} Power", source_id: cir13, filters: *throttle_avg }
  - { platform: copy, name: "${cir_14} Power", source_id: cir14, filters: *throttle_avg }
  - { platform: copy, name: "${cir_15} Power", source_id: cir15, filters: *throttle_avg }
  - { platform: copy, name: "${cir_16} Power", source_id: cir16, filters: *throttle_avg }

  # ---- Totals + balance (Total includes Phase C for 3-phase service) --------
  - platform: template
    id: total_power
    lambda: 'return id(phase_a_power).state + id(phase_b_power).state + id(phase_c_power).state;'
    update_interval: 5s
    device_class: power
    state_class: measurement
    unit_of_measurement: "W"
  - platform: total_daily_energy
    name: "Total Daily Energy"
    power_id: total_power
    accuracy_decimals: 0
    restore: true
    filters: *throttle_time

  - platform: template
    id: balance_power
    update_interval: 5s
    device_class: power
    state_class: measurement
    unit_of_measurement: "W"
    lambda: !lambda |-
      return max(0.0f, id(total_power).state -
        id( cir1).state - id( cir2).state - id( cir3).state - id( cir4).state -
        id( cir5).state - id( cir6).state - id( cir7).state - id( cir8).state -
        id( cir9).state - id(cir10).state - id(cir11).state - id(cir12).state -
        id(cir13).state - id(cir14).state - id(cir15).state - id(cir16).state);
  - platform: total_daily_energy
    name: "${xtra} Daily Energy"
    power_id: balance_power
    accuracy_decimals: 0
    restore: true
    filters: *throttle_time

  # ---- Per-circuit daily energy ---------------------------------------------
  - { power_id:  cir1, platform: total_daily_energy, accuracy_decimals: 0, restore: true, name:  "${cir_1} Daily Energy", filters: *throttle_time }
  - { power_id:  cir2, platform: total_daily_energy, accuracy_decimals: 0, restore: true, name:  "${cir_2} Daily Energy", filters: *throttle_time }
  - { power_id:  cir3, platform: total_daily_energy, accuracy_decimals: 0, restore: true, name:  "${cir_3} Daily Energy", filters: *throttle_time }
  - { power_id:  cir4, platform: total_daily_energy, accuracy_decimals: 0, restore: true, name:  "${cir_4} Daily Energy", filters: *throttle_time }
  - { power_id:  cir5, platform: total_daily_energy, accuracy_decimals: 0, restore: true, name:  "${cir_5} Daily Energy", filters: *throttle_time }
  - { power_id:  cir6, platform: total_daily_energy, accuracy_decimals: 0, restore: true, name:  "${cir_6} Daily Energy", filters: *throttle_time }
  - { power_id:  cir7, platform: total_daily_energy, accuracy_decimals: 0, restore: true, name:  "${cir_7} Daily Energy", filters: *throttle_time }
  - { power_id:  cir8, platform: total_daily_energy, accuracy_decimals: 0, restore: true, name:  "${cir_8} Daily Energy", filters: *throttle_time }
  - { power_id:  cir9, platform: total_daily_energy, accuracy_decimals: 0, restore: true, name:  "${cir_9} Daily Energy", filters: *throttle_time }
  - { power_id: cir10, platform: total_daily_energy, accuracy_decimals: 0, restore: true, name: "${cir_10} Daily Energy", filters: *throttle_time }
  - { power_id: cir11, platform: total_daily_energy, accuracy_decimals: 0, restore: true, name: "${cir_11} Daily Energy", filters: *throttle_time }
  - { power_id: cir12, platform: total_daily_energy, accuracy_decimals: 0, restore: true, name: "${cir_12} Daily Energy", filters: *throttle_time }
  - { power_id: cir13, platform: total_daily_energy, accuracy_decimals: 0, restore: true, name: "${cir_13} Daily Energy", filters: *throttle_time }
  - { power_id: cir14, platform: total_daily_energy, accuracy_decimals: 0, restore: true, name: "${cir_14} Daily Energy", filters: *throttle_time }
  - { power_id: cir15, platform: total_daily_energy, accuracy_decimals: 0, restore: true, name: "${cir_15} Daily Energy", filters: *throttle_time }
  - { power_id: cir16, platform: total_daily_energy, accuracy_decimals: 0, restore: true, name: "${cir_16} Daily Energy", filters: *throttle_time }

I wanted to post a quick update in case anyone else is interested in this project.

Over the past few weeks I’ve continued developing the ESPHome firmware, and it’s now much more robust than the initial version. Some of the major improvements include:

  • Reliable bounded UART parser with automatic recovery from corrupted data.
  • Shared accumulator architecture with extensive host-side replay testing.
  • Health and diagnostic entities (component state, UART health, parser statistics, last event, etc.).
  • A validation layer that prevents impossible values from ever reaching Home Assistant. During development it successfully caught and rejected an invalid reading of over 36 million watts, preventing it from corrupting Recorder statistics and history graphs.
  • Extensive unit and replay tests covering malformed frames, partial captures, startup behavior, recovery, and validation.

The project is actively maintained, and I’d appreciate any feedback or testing from the community.

The latest source code and documentation are available on GitHub:

Thanks to everyone who offered suggestions and encouragement in this thread—they helped shape many of the improvements.

Awesome. I just purchased two one for each of my panels! I’ll be giving this a shot this weekend.

That’s awesome! I’d love to hear how it goes. If you run into any issues during setup or have suggestions for improvements, please let me know. Your feedback will help make the project better for everyone.

quky, this is fantastic work. The bounded parser, the 0x3B marker support, and the validation layer (a 36 MW reading caught before it hit Recorder!) take this way past where my original write-up left off. Glad I could be part of this community.

I’m migrating my own meter off my single-file YAML and onto your component now.

Migration started on my end. One thing I noticed isn’t in the repo yet, so documenting it here for anyone else with this board.

Hardware extras (found by pin-walk):

  • Edit corrected to 41*-> Buzzer: GPIO41 (passive piezo, works with ESPHome’s rtttl on a ledc output)
  • Status LED: GPIO02

Both are plain ESPHome components, so they drop into the config right alongside the sem_meter component. On my unit the LED runs a connection-aware scheme: blinking while it hunts for WiFi, solid once WiFi is up, and a slow glow once the HA API connects. Makes it easy to tell at a glance where in the boot sequence a unit is stuck without pulling logs.

Also thinking of adding a “Find Unit” button that chirps the buzzer, handy when you’ve got a few of these scattered across panels and outbuildings.

And yes, Funkytown is going back on mine :slight_smile: Some features are non-negotiable.

Thanks! I really appreciate the feedback, especially since your original write-up helped get this project started.
I’m glad the parser improvements and validation layer proved useful. Preventing bad readings from reaching Home Assistant was one of my main goals.
Thanks for documenting the additional hardware pins. One interesting point is that my board’s passive buzzer is connected to GPIO41, and that’s been verified with the parser alarms, Wi-Fi watchdog, and self-test melodies. It’s possible there are different hardware revisions using different GPIO assignments. I’ll definitely keep that in mind before documenting the hardware.
I like the idea of a dedicated Find Unit button. I already have a diagnostic buzzer function, so turning that into a “Locate SEM Meter” feature would be a nice addition.
And yes… Funkytown is absolutely staying. Some features really are non-negotiable. :grinning_face_with_smiling_eyes:

HA! yes you are correct it is 41, I typoed 21 while writing this

ok. here goes. Two panels. Two units. Have jury-rigged dupont pins to allow access. There has got to be a better way to set up a universal tool to mate with any set of programming pads – better than the long pins and floating holders sold on ebay --something that just works. Just downloaded an entire flash backup and all partitions of the first device using EspConnect, and will be flashing a barebones install for OTA update to the full config yaml. My pinouts are the same as yours, cgriff

Did it work? I soldered dupont header pins and used spring clip probes. The dts reset process was a littlenweird but it worked eventually.