Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/esphome-dsmr-phase-aliases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Teach the ESPHome DSMR meter driver to read per-phase current, voltage and power from default ESPHome name-derived object_ids (for example `current_phase_1`) and delivered/returned totals while keeping Sourceful Zap compatibility.
103 changes: 72 additions & 31 deletions drivers/esphome_dsmr.lua
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,26 @@
-- Endpoint contract (ESPHome web_server v3):
-- GET /sensor/<object_id> → {"id":"sensor-<…>","value":<num>,"state":"<num+unit>"}
-- GET /text_sensor/<object_id> → {"id":"text_sensor-<…>","value":"<str>","state":"<str>"}
-- Object IDs come from the `name:` of each entity, lowercased with spaces
-- → underscores. The reference firmware (srcful-zap-p1.yaml) defines:
-- energy_consumed / energy_produced (kWh, lifetime import/export)
-- power_consumed / power_produced (kW, total import/export)
-- power_consumed_l1..l3 / power_produced_l1..l3 (kW, per phase)
-- voltage_l1..l3 (V)
-- current_l1..l3 (A)
-- text_sensor/electric_meter_equipment_id (DSMR equipment id, often empty)
-- text_sensor/meter_identification (e.g. "LGF5E360")
-- Object IDs vary by firmware YAML. ESPHome derives them from each
-- entity's `name:` (lower-cased, spaces → underscores) unless an
-- explicit `object_id:` is set. The driver probes aliases in order
-- and uses the first hit:
--
-- Totals (kW): power_consumed|power_delivered,
-- power_produced|power_returned
-- Per-phase kW: power_{consumed|delivered}_lN or _phase_N
-- Voltage (V): voltage_lN or voltage_phase_N
-- Current (A): current_lN or current_phase_N
-- Energy (kWh): energy_consumed|energy_delivered,
-- energy_produced|energy_returned
--
-- Sourceful Zap pins explicit object_ids (*_consumed, current_l1, …).
-- Default ESPHome DSMR YAMLs (community P1 readers, DIY ESP32, etc.)
-- usually slug each entity's `name:` instead (current_phase_1, …) and
-- expose import/export totals as power_delivered / power_returned unless
-- the operator renamed them.
-- Serial: electric_meter_equipment_id, meter_identification,
-- dsmr_identification
--
-- Sign convention (SITE = positive W flows INTO the site):
-- meter.w = (power_consumed - power_produced) * 1000
Expand Down Expand Up @@ -53,15 +64,18 @@ DRIVER = {
id = "esphome-dsmr",
name = "ESPHome DSMR (P1)",
manufacturer = "ESPHome",
version = "1.0.0",
version = "1.0.1",
protocols = { "http" },
capabilities = { "meter" },
description = "Smart meter via ESPHome web_server v3 + dsmr component (Sourceful Zap on open firmware, DIY ESP32+P1, etc.).",
homepage = "https://github.com/erikarenhill/sourceful-zap-esphome",
authors = { "FTW contributors" },
tested_models = { "Sourceful Zap P1 (sourceful-zap-esphome firmware)" },
tested_models = {
"Sourceful Zap P1 (sourceful-zap-esphome firmware)",
"ESPHome DSMR P1 readers using name-derived object_ids (SlimmeLezer+)",
},
verification_status = "experimental",
verification_notes = "Built and validated against the live HTTP responses of an LGF5E360 meter behind a Sourceful Zap running the open-source ESPHome firmware. Awaiting a second site to promote to beta.",
verification_notes = "Validated on Sourceful Zap open firmware and a live SlimmeLezer+ running ESPHome DSMR YAML with name-derived phase object_ids (current_phase_N, power_consumed_phase_N).",
connection_defaults = {
-- No host default — the operator must point us at their device's
-- IP or mDNS hostname. We deliberately don't guess: ESPHome devices
Expand Down Expand Up @@ -147,17 +161,36 @@ local function fetch_num(object_id)
return n, nil
end

-- Try numeric sensor ids in order; first success wins. Returns (n, nil)
-- or (nil, last_err). 404s on earlier aliases are expected — callers
-- only log when every alias misses.
local function fetch_num_first(ids)
local last_err = nil
for _, id in ipairs(ids) do
local n, err = fetch_num(id)
if n ~= nil then return n, nil end
last_err = err
end
return nil, last_err
end

-- Best-effort serial discovery. Tries the dedicated DSMR equipment-id
-- text_sensor first (newer meters populate it), falls back to the OBIS
-- meter-identification line (e.g. LGF5E360 on the LG meters Sourceful
-- typically ships against). Returns "" on any failure — the host falls
-- back to MAC ARP / endpoint hashing for `device_id`, so a missing SN
-- is a soft failure, not a fatal one.
-- typically ships against), then the common ESPHome dsmr
-- text_sensor `identification` slug (dsmr_identification).
-- Returns "" on any failure — the host falls back to MAC ARP / endpoint
-- hashing for `device_id`, so a missing SN is a soft failure, not a fatal one.
local function fetch_serial()
local v, err = fetch_entity("text_sensor", "electric_meter_equipment_id")
if not err and type(v) == "string" and v ~= "" then return v end
v, err = fetch_entity("text_sensor", "meter_identification")
if not err and type(v) == "string" and v ~= "" then return v end
local ids = {
"electric_meter_equipment_id",
"meter_identification",
"dsmr_identification",
}
for _, id in ipairs(ids) do
local v, err = fetch_entity("text_sensor", id)
if not err and type(v) == "string" and v ~= "" then return v end
end
return ""
end

Expand Down Expand Up @@ -209,16 +242,16 @@ function driver_poll()
-- offline if it persists. Failures bump `consecutive_failures` so
-- subsequent polls back off exponentially instead of hammering a
-- meter that's gone offline.
local pc_kw, err = fetch_num("power_consumed")
local pc_kw, err = fetch_num_first({ "power_consumed", "power_delivered" })
if err then
consecutive_failures = consecutive_failures + 1
host.log("warn", "esphome_dsmr: power_consumed read failed (backoff " .. backoff_ms() .. "ms): " .. err)
host.log("warn", "esphome_dsmr: site import power read failed (backoff " .. backoff_ms() .. "ms): " .. err)
return backoff_ms()
end
local pp_kw, err2 = fetch_num("power_produced")
local pp_kw, err2 = fetch_num_first({ "power_produced", "power_returned" })
if err2 then
consecutive_failures = consecutive_failures + 1
host.log("warn", "esphome_dsmr: power_produced read failed (backoff " .. backoff_ms() .. "ms): " .. err2)
host.log("warn", "esphome_dsmr: site export power read failed (backoff " .. backoff_ms() .. "ms): " .. err2)
return backoff_ms()
end
-- Both totals succeeded — clear any prior backoff streak.
Expand All @@ -236,13 +269,21 @@ function driver_poll()
-- (Single-phase meters won't expose L2/L3 entities at all.)
local phase_w = {}
for i = 1, 3 do
local c, ec = fetch_num("power_consumed_l" .. i)
local p, ep = fetch_num("power_produced_l" .. i)
local c, ec = fetch_num_first({
"power_consumed_l" .. i,
"power_consumed_phase_" .. i,
"power_delivered_l" .. i,
})
local p, ep = fetch_num_first({
"power_produced_l" .. i,
"power_produced_phase_" .. i,
"power_returned_l" .. i,
})
if ec then
host.log("debug", "esphome_dsmr: power_consumed_l" .. i .. " unavailable: " .. ec)
host.log("debug", "esphome_dsmr: L" .. i .. " import power unavailable: " .. ec)
end
if ep then
host.log("debug", "esphome_dsmr: power_produced_l" .. i .. " unavailable: " .. ep)
host.log("debug", "esphome_dsmr: L" .. i .. " export power unavailable: " .. ep)
end
if c ~= nil and p ~= nil then
phase_w[i] = (c - p) * 1000.0
Expand All @@ -253,13 +294,13 @@ function driver_poll()
local v = {}
local a = {}
for i = 1, 3 do
v[i] = fetch_num("voltage_l" .. i)
a[i] = fetch_num("current_l" .. i)
v[i] = fetch_num_first({ "voltage_l" .. i, "voltage_phase_" .. i })
a[i] = fetch_num_first({ "current_l" .. i, "current_phase_" .. i })
end

-- Lifetime energy counters: ESPHome serves these in kWh, we emit in Wh.
local imp_kwh = fetch_num("energy_consumed")
local exp_kwh = fetch_num("energy_produced")
local imp_kwh = fetch_num_first({ "energy_consumed", "energy_delivered" })
local exp_kwh = fetch_num_first({ "energy_produced", "energy_returned" })

-- Optional phase/counter values are omitted when their HTTP read fails.
-- Publishing a synthetic 0 A would disable the per-phase fuse guard, and
Expand Down
112 changes: 112 additions & 0 deletions go/internal/drivers/esphome_dsmr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,118 @@ func TestESPHomeDSMR_BackoffResetsOnRecovery(t *testing.T) {
}
}

func TestESPHomeDSMR_NameDerivedPhaseObjectIDs(t *testing.T) {
// Default ESPHome DSMR YAML derives object_ids from entity names
// ("Current Phase 1" → current_phase_1) rather than the DSMR
// platform keys (current_l1). Totals may still use consumed/
// produced when the operator renamed them.
srv := mkESPHomeStub(map[string]string{
"sensor/power_consumed": "0",
"sensor/power_produced": "4.782",
"sensor/power_consumed_phase_1": "0",
"sensor/power_produced_phase_1": "4.775",
"sensor/power_consumed_phase_2": "0",
"sensor/power_produced_phase_2": "4.783",
"sensor/power_consumed_phase_3": "0",
"sensor/power_produced_phase_3": "4.743",
"sensor/voltage_phase_1": "236.1",
"sensor/voltage_phase_2": "236.0",
"sensor/voltage_phase_3": "236.5",
"sensor/current_phase_1": "20.5",
"sensor/current_phase_2": "20.4",
"sensor/current_phase_3": "20.3",
"text_sensor/dsmr_identification": "\"TESTDSMR-P1-00000001\"",
"text_sensor/electric_meter_equipment_id": "\"\"",
"text_sensor/meter_identification": "\"\"",
})
defer srv.Close()
host := strings.TrimPrefix(srv.URL, "http://")

tel, env := loadESPHomeDriver(t, host, nil)

if _, sn := env.Identity(); sn != "TESTDSMR-P1-00000001" {
t.Errorf("serial = %q, want TESTDSMR-P1-00000001 (dsmr_identification)", sn)
}

m := tel.Get("zap-p1", telemetry.DerMeter)
if m == nil {
t.Fatal("expected meter telemetry")
}
if !near(m.RawW, -4782) {
t.Errorf("meter.w = %v, want -4782 (exporting)", m.RawW)
}

var data map[string]any
if err := json.Unmarshal(m.Data, &data); err != nil {
t.Fatalf("meter data: %v", err)
}
if !near(data["l1_a"].(float64), 20.5) {
t.Errorf("l1_a = %v, want 20.5", data["l1_a"])
}
if !near(data["l2_a"].(float64), 20.4) {
t.Errorf("l2_a = %v, want 20.4", data["l2_a"])
}
if !near(data["l3_w"].(float64), -4743) {
t.Errorf("l3_w = %v, want -4743", data["l3_w"])
}
if !near(data["l1_v"].(float64), 236.1) {
t.Errorf("l1_v = %v, want 236.1", data["l1_v"])
}
}

func TestESPHomeDSMR_DeliveredReturnedObjectIDs(t *testing.T) {
// ESPHome's dsmr component names import/export totals
// power_delivered / power_returned. Phase reads may use the lN
// suffix (current_l1, power_delivered_l1, …) when the YAML pins
// those ids or the firmware exposes them directly.
srv := mkESPHomeStub(map[string]string{
"sensor/power_delivered": "1.5",
"sensor/power_returned": "0.25",
"sensor/power_delivered_l1": "0.5",
"sensor/power_delivered_l2": "0.5",
"sensor/power_delivered_l3": "0.5",
"sensor/power_returned_l1": "0.1",
"sensor/power_returned_l2": "0.08",
"sensor/power_returned_l3": "0.07",
"sensor/voltage_l1": "230",
"sensor/voltage_l2": "231",
"sensor/voltage_l3": "229",
"sensor/current_l1": "6.5",
"sensor/current_l2": "6.4",
"sensor/current_l3": "6.3",
"sensor/energy_delivered": "1000",
"sensor/energy_returned": "42",
"text_sensor/electric_meter_equipment_id": "\"\"",
"text_sensor/meter_identification": "\"\"",
})
defer srv.Close()
host := strings.TrimPrefix(srv.URL, "http://")

tel, _ := loadESPHomeDriver(t, host, nil)

m := tel.Get("zap-p1", telemetry.DerMeter)
if m == nil {
t.Fatal("expected meter telemetry")
}
if !near(m.RawW, 1250) {
t.Errorf("meter.w = %v, want +1250 (importing)", m.RawW)
}

var data map[string]any
if err := json.Unmarshal(m.Data, &data); err != nil {
t.Fatalf("meter data: %v", err)
}
if !near(data["l1_w"].(float64), 400) {
t.Errorf("l1_w = %v, want 400", data["l1_w"])
}
if !near(data["l2_a"].(float64), 6.4) {
t.Errorf("l2_a = %v, want 6.4", data["l2_a"])
}
if !near(data["import_wh"].(float64), 1000000) {
t.Errorf("import_wh = %v, want 1000000", data["import_wh"])
}
}

func TestESPHomeDSMRCatalogEntry(t *testing.T) {
entries, err := LoadCatalog("../../../drivers")
if err != nil {
Expand Down