From 416e223acc4bfe25c37f6bcc9a91e76123a41d6d Mon Sep 17 00:00:00 2001 From: Tommy Lindgren Date: Mon, 20 Jul 2026 20:56:24 +0200 Subject: [PATCH] fix(esphome-dsmr): read per-phase meter data from ESPHome name-derived object_ids ESPHome DSMR YAMLs often slug entity names (current_phase_1) instead of Sourceful-style ids (current_l1). Probe delivered/returned aliases too. Adds SlimmeLezer+ to tested_models and bumps driver to 1.0.1. Signed-off-by: Tommy Lindgren --- .changeset/esphome-dsmr-phase-aliases.md | 5 + drivers/esphome_dsmr.lua | 103 ++++++++++++++------- go/internal/drivers/esphome_dsmr_test.go | 112 +++++++++++++++++++++++ 3 files changed, 189 insertions(+), 31 deletions(-) create mode 100644 .changeset/esphome-dsmr-phase-aliases.md diff --git a/.changeset/esphome-dsmr-phase-aliases.md b/.changeset/esphome-dsmr-phase-aliases.md new file mode 100644 index 00000000..4ef0e47c --- /dev/null +++ b/.changeset/esphome-dsmr-phase-aliases.md @@ -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. diff --git a/drivers/esphome_dsmr.lua b/drivers/esphome_dsmr.lua index ab9258ff..77891dca 100644 --- a/drivers/esphome_dsmr.lua +++ b/drivers/esphome_dsmr.lua @@ -14,15 +14,26 @@ -- Endpoint contract (ESPHome web_server v3): -- GET /sensor/ → {"id":"sensor-<…>","value":,"state":""} -- GET /text_sensor/ → {"id":"text_sensor-<…>","value":"","state":""} --- 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 @@ -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 @@ -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 @@ -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. @@ -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 @@ -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 diff --git a/go/internal/drivers/esphome_dsmr_test.go b/go/internal/drivers/esphome_dsmr_test.go index ad8ebfca..ba7a3187 100644 --- a/go/internal/drivers/esphome_dsmr_test.go +++ b/go/internal/drivers/esphome_dsmr_test.go @@ -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 {