diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 3548e1a..e1facb2 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -28,6 +28,8 @@ Catalog source is not proof that a target can install or run a driver. | deye | 1.2.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | | easee | 1.0.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | | easee | 1.0.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | +| esphome-dsmr | 1.0.1 | ftw-core | not_assessed | 1.0.1 | — | not_recorded | — | not_assessed | no | +| esphome-dsmr | 1.0.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | | etrel | 1.0.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | | etrel | 1.0.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | | ferroamp | 1.1.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | diff --git a/devices.yaml b/devices.yaml index 9b36b29..7e3ef62 100644 --- a/devices.yaml +++ b/devices.yaml @@ -236,6 +236,19 @@ manufacturers: control: false firmware_versions: "" notes: "Three-phase hybrid, supports LV and HV variants with runtime detection" + - name: "ESPHome" + model_families: + - name: "DSMR web_server v3 P1 reader" + variants: [SlimmeLezer+, Sourceful Zap P1] + regions: [BE, DK, NL, SE] + protocols: + - protocol: http + driver: "esphome-dsmr" + version: "1.0.1" + ders: [meter] + control: false + firmware_versions: "DSMR 2022.10.1 on SlimmeLezer+; Sourceful Zap open firmware" + notes: "Name-derived phase object_ids live-validated on SlimmeLezer+; explicit Sourceful aliases retained." - name: "Easee" model_families: - name: "Easee" diff --git a/drivers/lua/esphome-dsmr.lua b/drivers/lua/esphome-dsmr.lua new file mode 100644 index 0000000..d3983d6 --- /dev/null +++ b/drivers/lua/esphome-dsmr.lua @@ -0,0 +1,387 @@ +-- esphome_dsmr.lua +-- ESPHome-flashed P1 / DSMR smart-meter reader (e.g. Sourceful Zap on +-- the open-source firmware at github.com/erikarenhill/sourceful-zap-esphome). +-- Emits: Meter (read-only) +-- Protocol: HTTP — ESPHome `web_server: version: 3` REST endpoints. +-- +-- Why a separate driver from `zap.lua`: +-- `zap.lua` talks to the *closed-source Sourceful Zap gateway* via its +-- /api/devices JSON tree. This driver targets the *ESPHome firmware* +-- you flash onto the same hardware (or any DIY ESP32 + DSMR meter +-- running ESPHome's `dsmr` component). The two firmwares expose +-- completely different HTTP shapes; one driver can't cover both. +-- +-- Endpoint contract (ESPHome web_server v3): +-- GET /sensor/ → {"id":"sensor-<…>","value":,"state":""} +-- GET /text_sensor/ → {"id":"text_sensor-<…>","value":"","state":""} +-- 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 +-- so positive = importing from grid, negative = exporting. Per-phase +-- `lN_w` is built the same way (consumed_lN − produced_lN). +-- +-- Config example (config.yaml): +-- drivers: +-- - name: zap-p1 +-- lua: drivers/esphome_dsmr.lua +-- is_site_meter: true +-- capabilities: +-- http: +-- allowed_hosts: ["192.168.1.147"] # or "srcful-zap-p1.local" +-- config: +-- host: "192.168.1.147" # required; mDNS name or IP +-- # make: "Sourceful" # optional, default "ESPHome" +-- # poll_ms: 5000 # optional, default 5000 +-- +-- ESPHome's `dsmr` updates roughly once per telegram (~1 s on most Swedish +-- meters, longer on slower ones). Polling faster than the telegram rate +-- just re-reads the same numbers — default 5 s keeps the meter fresh +-- without hammering the ESP32's tiny TCP stack. + +DRIVER = { + host_api_min = 1, + host_api_max = 1, + id = "esphome-dsmr", + name = "ESPHome DSMR (P1)", + manufacturer = "ESPHome", + 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)", + "ESPHome DSMR P1 readers using name-derived object_ids (SlimmeLezer+)", + }, + verification_status = "experimental", + 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).", + read_only = true, + 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 + -- pick their hostname from the YAML `name:` field, which is unique + -- per build, so any hard-coded default would be wrong on most sites. + }, +} + +PROTOCOL = "http" + +---------------------------------------------------------------------------- +-- State +---------------------------------------------------------------------------- + +local esp_host = nil -- "192.168.1.147" or "srcful-zap-p1.local" (no scheme) +local esp_base_url = nil +local make_name = "ESPHome" +local poll_ms = 5000 +local sn_set = false -- one-shot: only call host.set_sn once we've read it +local serial_attempts = 0 +local SERIAL_MAX_ATTEMPTS = 12 + +-- Capability flags filled in once on the first successful poll. The +-- ESPHome firmware varies in which entities it exposes (the +-- reference sourceful-zap-p1.yaml exposes reactive power per phase +-- + totals, but a stripped-down DIY firmware might not). Probing +-- once at startup avoids burning HTTP round-trips on perpetual 404s +-- — eight reactive channels at ~10 ms each adds 80 ms to every +-- poll otherwise. +local caps_probed = false +local has_reactive = false + +-- Exponential backoff on poll failures. Without this, a meter that +-- drops off the LAN gets hammered every poll_ms (default 5 s) until +-- it comes back — rude to the network and to the ESP32's tiny TCP +-- stack, especially since dropping the meter is exactly the kind of +-- thing that happens during DHCP renewals + flaky wifi. We grow the +-- effective interval after each failure and reset on first success. +local consecutive_failures = 0 +local BACKOFF_MAX_MS = 60000 +local function backoff_ms() + if consecutive_failures <= 0 then return poll_ms end + -- 2^N grows fast; cap at MAX. The first failure already doubles + -- the interval, which is what we want — most transient blips + -- recover within one poll cycle and don't deserve a third try + -- 5 s later. + local mult = 2 ^ math.min(consecutive_failures, 8) + local interval = poll_ms * mult + if interval > BACKOFF_MAX_MS then return BACKOFF_MAX_MS end + return interval +end + +---------------------------------------------------------------------------- +-- Helpers +---------------------------------------------------------------------------- + +-- GET one ESPHome entity, return its decoded `.value` field or (nil, err). +-- domain: "sensor" or "text_sensor". object_id: lowercased name w/ underscores. +local function fetch_entity(domain, object_id) + local url = esp_base_url .. "/" .. domain .. "/" .. object_id + local ok_http, body, err = pcall(host.http_get, url) + if not ok_http then return nil, tostring(body) end + if not body then return nil, err or "empty HTTP response" end + local ok_json, data = pcall(host.json_decode, body) + if not ok_json or not data then + return nil, "json decode failed: " .. tostring(body):sub(1, 80) + end + return data.value, nil +end + +-- Numeric sensor read; returns (n, nil) or (nil, err). Treats explicit +-- JSON null / non-number as missing — ESPHome publishes null when the +-- DSMR parser hasn't yet produced a fresh telegram for that field +-- (gas-meter ID on an electric-only feed is the typical case). +local function fetch_num(object_id) + local v, err = fetch_entity("sensor", object_id) + if err then return nil, err end + local n = tonumber(v) + if n == nil then return nil, "sensor/" .. object_id .. " value is not numeric (" .. tostring(v) .. ")" end + 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), 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 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 + +---------------------------------------------------------------------------- +-- Driver interface +---------------------------------------------------------------------------- + +function driver_init(config) + if not config or type(config.host) ~= "string" or config.host == "" then + host.log("error", "esphome_dsmr: config.host is required (e.g. \"192.168.1.147\" or \"srcful-zap-p1.local\")") + return + end + esp_host = config.host + local base_url = "http://" .. config.host + if string.sub(config.host, 1, 7) == "http://" or string.sub(config.host, 1, 8) == "https://" then + base_url = config.host + elseif tonumber(config.port) then + base_url = base_url .. ":" .. tostring(math.floor(tonumber(config.port))) + end + esp_base_url = base_url + + if type(config.make) == "string" and config.make ~= "" then + make_name = config.make + end + local configured_poll_ms = tonumber(config.poll_ms) + if configured_poll_ms and configured_poll_ms >= 1000 then + poll_ms = configured_poll_ms + end + + host.set_make(make_name) + host.set_poll_interval(poll_ms) + host.log("info", "esphome_dsmr: initialized (host=" .. esp_host .. ", make=" .. make_name .. ", poll=" .. poll_ms .. "ms)") +end + +function driver_poll() + if not esp_base_url then + return poll_ms -- init failed; idle + end + + -- One-shot serial discovery (cheap text_sensor reads, runs once per + -- driver lifetime — re-flashing the meter restarts the driver anyway). + if not sn_set and serial_attempts < SERIAL_MAX_ATTEMPTS then + serial_attempts = serial_attempts + 1 + local sn = fetch_serial() + if sn ~= "" then + host.set_sn(sn) + host.log("info", "esphome_dsmr: serial = " .. sn) + sn_set = true + elseif serial_attempts == SERIAL_MAX_ATTEMPTS then + host.log("info", "esphome_dsmr: no DSMR equipment id available; falling back to MAC/endpoint identity") + end + end + + -- Site-meter totals are the only mandatory reads. If either fails the + -- whole emit is meaningless — bail and let the watchdog flip us + -- 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_first({ "power_consumed", "power_delivered" }) + if err then + consecutive_failures = consecutive_failures + 1 + 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_first({ "power_produced", "power_returned" }) + if err2 then + consecutive_failures = consecutive_failures + 1 + 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. + if consecutive_failures > 0 then + host.log("info", "esphome_dsmr: poll recovered after " .. consecutive_failures .. " failure(s)") + consecutive_failures = 0 + end + + -- Convert kW → W and combine into site-convention net power. + local total_w = (pc_kw - pp_kw) * 1000.0 + + -- Per-phase power: ESPHome publishes consumed_lN and produced_lN + -- separately; site convention is the net. Missing phase reads are + -- non-fatal — fall back to 0 for that phase, log debug, keep going. + -- (Single-phase meters won't expose L2/L3 entities at all.) + local phase_w = {} + for i = 1, 3 do + 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: L" .. i .. " import power unavailable: " .. ec) + end + if ep then + 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 + end + end + + -- Per-phase voltage / current (V, A — already in base units). + local v = {} + local a = {} + for i = 1, 3 do + 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_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 + -- publishing a synthetic 0 Wh would look like a lifetime-counter reset. + local meter = { w = total_w } + for i = 1, 3 do + if phase_w[i] ~= nil then meter["l" .. i .. "_w"] = phase_w[i] end + if v[i] ~= nil then meter["l" .. i .. "_v"] = v[i] end + if a[i] ~= nil then meter["l" .. i .. "_a"] = a[i] end + end + if imp_kwh ~= nil then meter.import_wh = imp_kwh * 1000.0 end + if exp_kwh ~= nil then meter.export_wh = exp_kwh * 1000.0 end + host.emit("meter", meter) + + -- Long-format diagnostics into the TS DB. + for i = 1, 3 do + if phase_w[i] ~= nil then host.emit_metric("meter_l" .. i .. "_w", phase_w[i], "W") end + if v[i] ~= nil then host.emit_metric("meter_l" .. i .. "_v", v[i], "V") end + if a[i] ~= nil then host.emit_metric("meter_l" .. i .. "_a", a[i], "A") end + end + + -- Reactive power (kvar → var). DSMR meters publish reactive both + -- as a total and per phase, in each direction. The FTW + -- Meter struct doesn't carry kvar (load-flow control is active-only + -- for now), but the values are useful for grid-quality dashboards + -- and PF inference, so we drop them into the long-format TS DB + -- as diagnostic metrics. The capability is probed ONCE on first + -- successful poll — subsequent polls skip these requests on + -- firmwares that don't expose them, saving the 80-ish ms of + -- 404 round-trips per cycle. + if not caps_probed then + local probe, _ = fetch_num("reactive_power_imported") + has_reactive = (probe ~= nil) + caps_probed = true + if has_reactive then + host.log("info", "esphome_dsmr: reactive-power channels detected") + else + host.log("info", "esphome_dsmr: no reactive-power channels (firmware doesn't expose them)") + end + end + if has_reactive then + local function emit_var(name, kvar) + if kvar ~= nil then host.emit_metric(name, kvar * 1000.0) end + end + emit_var("meter_q_imp_var", fetch_num("reactive_power_imported")) + emit_var("meter_q_exp_var", fetch_num("reactive_power_exported")) + for i = 1, 3 do + emit_var("meter_q_imp_l" .. i .. "_var", fetch_num("reactive_power_imported_l" .. i)) + emit_var("meter_q_exp_l" .. i .. "_var", fetch_num("reactive_power_exported_l" .. i)) + end + end + + return poll_ms +end + +---------------------------------------------------------------------------- +-- Control (READ-ONLY — meter exposes no writable endpoint) +---------------------------------------------------------------------------- + +function driver_command(action, power_w, cmd) + if action == "init" or action == "deinit" then + return true + end + host.log("warn", "esphome_dsmr: read-only driver, ignoring action=" .. tostring(action)) + return false +end + +function driver_default_mode() + -- Read-only: nothing to revert. +end + +function driver_cleanup() + esp_base_url = nil + sn_set = false + serial_attempts = 0 + caps_probed = false + has_reactive = false + consecutive_failures = 0 +end diff --git a/drivers/tests/lua_harness/test_esphome_dsmr_aliases.lua b/drivers/tests/lua_harness/test_esphome_dsmr_aliases.lua new file mode 100644 index 0000000..40a69a7 --- /dev/null +++ b/drivers/tests/lua_harness/test_esphome_dsmr_aliases.lua @@ -0,0 +1,106 @@ +local scenario = arg[1] +if scenario ~= "name-derived" and scenario ~= "delivered-returned" then + error("alias scenario must be name-derived or delivered-returned") +end + +dofile("drivers/tests/lua_harness/host_mock.lua") +host.reset() + +host.set_poll_interval = function(interval_ms) + host._poll_interval = interval_ms +end +host.emit_metric = function() end +host.http_get = function(url) + local response = host._http_responses[url] + if response then return response, nil end + return nil, "404: " .. url +end + +local function sensor(path, value) + host._http_responses["http://127.0.0.1/" .. path] = + string.format('{"value":%s}', tostring(value)) +end + +local function text_sensor(path, value) + host._http_responses["http://127.0.0.1/text_sensor/" .. path] = + string.format('{"value":"%s"}', value) +end + +dofile("drivers/lua/esphome-dsmr.lua") + +if type(DRIVER) ~= "table" or DRIVER.id ~= "esphome-dsmr" or DRIVER.version ~= "1.0.1" then + error("ESPHome DSMR identity metadata is wrong") +end +if DRIVER.host_api_min ~= 1 or DRIVER.host_api_max ~= 1 or DRIVER.read_only ~= true then + error("ESPHome DSMR host API or read-only metadata is wrong") +end + +if scenario == "name-derived" then + sensor("sensor/power_consumed", 0) + sensor("sensor/power_produced", 4.782) + sensor("sensor/power_consumed_phase_1", 0) + sensor("sensor/power_produced_phase_1", 4.775) + sensor("sensor/power_consumed_phase_2", 0) + sensor("sensor/power_produced_phase_2", 4.783) + sensor("sensor/power_consumed_phase_3", 0) + sensor("sensor/power_produced_phase_3", 4.743) + sensor("sensor/voltage_phase_1", 236.1) + sensor("sensor/voltage_phase_2", 236.0) + sensor("sensor/voltage_phase_3", 236.5) + sensor("sensor/current_phase_1", 20.5) + sensor("sensor/current_phase_2", 20.4) + sensor("sensor/current_phase_3", 20.3) + text_sensor("dsmr_identification", "TESTDSMR-P1-00000001") +else + sensor("sensor/power_delivered", 1.5) + sensor("sensor/power_returned", 0.25) + sensor("sensor/power_delivered_l1", 0.5) + sensor("sensor/power_delivered_l2", 0.5) + sensor("sensor/power_delivered_l3", 0.5) + sensor("sensor/power_returned_l1", 0.1) + sensor("sensor/power_returned_l2", 0.08) + sensor("sensor/power_returned_l3", 0.07) + sensor("sensor/voltage_l1", 230) + sensor("sensor/voltage_l2", 231) + sensor("sensor/voltage_l3", 229) + sensor("sensor/current_l1", 6.5) + sensor("sensor/current_l2", 6.4) + sensor("sensor/current_l3", 6.3) + sensor("sensor/energy_delivered", 1000) + sensor("sensor/energy_returned", 42) +end + +driver_init({host = "127.0.0.1", poll_ms = 1000}) +local ok, poll_result = pcall(driver_poll) +if not ok then error("ESPHome DSMR poll failed: " .. tostring(poll_result)) end + +local samples = host._emitted.meter +if not samples or #samples ~= 1 then error("ESPHome DSMR did not emit one meter sample") end +local meter = samples[1] +local function near(actual, expected) + return type(actual) == "number" and math.abs(actual - expected) < 0.001 +end + +if scenario == "name-derived" then + if host._sn ~= "TESTDSMR-P1-00000001" then error("dsmr_identification alias was not used") end + if not near(meter.w, -4782) or not near(meter.l3_w, -4743) then + error("name-derived power aliases produced wrong signs or values") + end + if not near(meter.l1_a, 20.5) or not near(meter.l2_a, 20.4) or not near(meter.l1_v, 236.1) then + error("name-derived phase aliases were not emitted") + end +else + if not near(meter.w, 1250) or not near(meter.l1_w, 400) then + error("delivered/returned power aliases produced wrong signs or values") + end + if not near(meter.l2_a, 6.4) or not near(meter.import_wh, 1000000) or + not near(meter.export_wh, 42000) then + error("delivered/returned phase or energy aliases were not emitted") + end +end + +if driver_command("meter", 0, {}) ~= false then + error("ESPHome DSMR read-only command did not reject control") +end +driver_default_mode() +driver_cleanup() diff --git a/drivers/tests/test_driver_contract.py b/drivers/tests/test_driver_contract.py index d7fc5c5..5e42ff6 100644 --- a/drivers/tests/test_driver_contract.py +++ b/drivers/tests/test_driver_contract.py @@ -60,8 +60,8 @@ def test_driver_poll_returns_interval(self, driver_name): ) if poll_match: body = poll_match.group(1) - assert re.search(r'return\s+\d+', body), \ - f"{driver_name}: driver_poll should return a numeric poll interval" + assert re.search(r'return\s+(?:\d+|[a-z_]\w*(?:\([^)]*\))?)', body), \ + f"{driver_name}: driver_poll should return a poll interval" def test_calls_set_make_in_init(self, driver_name): """driver_init should call host.set_make().""" diff --git a/drivers/tests/test_esphome_dsmr_aliases.py b/drivers/tests/test_esphome_dsmr_aliases.py new file mode 100644 index 0000000..09faef2 --- /dev/null +++ b/drivers/tests/test_esphome_dsmr_aliases.py @@ -0,0 +1,40 @@ +"""Exercise ESPHome DSMR aliases against the canonical public driver.""" + +from pathlib import Path +import subprocess +import sys + +import pytest + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools")) + +from driver_package import _validate_lua_source_for_target # noqa: E402 + + +def test_esphome_dsmr_source_has_ftw_read_only_contract(): + source = (ROOT / "drivers/lua/esphome-dsmr.lua").read_bytes() + _validate_lua_source_for_target( + source, + target="ftw-core", + read_only=True, + package_version="1.0.1", + runtime_abi="gopher-lua-source-v1", + ) + + +@pytest.mark.parametrize("scenario", ["name-derived", "delivered-returned"]) +def test_esphome_dsmr_aliases(scenario): + result = subprocess.run( + [ + str(ROOT / "lua55"), + "drivers/tests/lua_harness/test_esphome_dsmr_aliases.lua", + scenario, + ], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/drivers/tests/test_http_drivers.py b/drivers/tests/test_http_drivers.py index 5184f01..c365727 100644 --- a/drivers/tests/test_http_drivers.py +++ b/drivers/tests/test_http_drivers.py @@ -115,11 +115,20 @@ def test_poll_returns_on_nil_data(self, driver_name): """driver_poll should return early if HTTP request fails.""" code = read_driver(driver_name) clean = strip_lua_comments(code) + poll_match = re.search( + r'function\s+driver_poll\s*\([^)]*\)(.*?)(?=\nfunction\s|\Z)', + clean, + re.DOTALL, + ) + poll_body = poll_match.group(1) if poll_match else "" - # Look for early return pattern: "if not data then return NNNN end" + # Accept fixed intervals and stateful backoff helpers. HTTP drivers + # may expose the request error as a separate return value. has_early_return = bool(re.search( - r'if\s+not\s+\w+\s+then\s+return\s+\d+', - clean, + r'if\s+(?:not\s+)?\w+[^\n]*then.*?return\s+' + r'(?:\d+|[a-z_]\w*(?:\([^)]*\))?)', + poll_body, + re.DOTALL, )) assert has_early_return, ( diff --git a/index.yaml b/index.yaml index e6a49c5..7c6aa64 100644 --- a/index.yaml +++ b/index.yaml @@ -96,6 +96,14 @@ drivers: control: false size_bytes: 2038 sha256: "e182fe7fc03547ff5afc8b5e34d8a0434591b6699b6b08aa27cad1398c2dbe16" + - name: "esphome-dsmr" + version: "1.0.1" + tier: community + protocol: http + ders: [meter] + control: false + size_bytes: 16776 + sha256: "06afd1e4079b041a8fd36b0b0801e3d997b546f0f1dbd6ee8774296c7a8f8351" - name: "etrel" version: "1.0.0" tier: community diff --git a/manifests/esphome-dsmr.yaml b/manifests/esphome-dsmr.yaml new file mode 100644 index 0000000..5729668 --- /dev/null +++ b/manifests/esphome-dsmr.yaml @@ -0,0 +1,25 @@ +name: "esphome-dsmr" +version: "1.0.1" +tier: community +author: "FTW contributors" +protocol: http +ders: [meter] +control: false +tested_devices: + - manufacturer: "ESPHome" + model_family: "DSMR web_server v3 P1 reader" + variants: [SlimmeLezer+, Sourceful Zap P1] + regions: [NL, BE, SE, DK] + firmware_versions: "DSMR 2022.10.1 on SlimmeLezer+; Sourceful Zap open firmware" + notes: "Name-derived phase object_ids live-validated on SlimmeLezer+; explicit Sourceful aliases retained." + min_driver_version: "1.0.1" +min_host_version: "1.10.0" +size_bytes: 0 +dkb_id: "" +sha256: "" +signature: "" + +bytecode_sha256: "" +bytecode_signature: "" +bytecode_size: 0 +changelog: "Read ESPHome name-derived and delivered/returned DSMR object_id aliases." diff --git a/packages/v1/esphome-dsmr/package-source.json b/packages/v1/esphome-dsmr/package-source.json new file mode 100644 index 0000000..5f589cd --- /dev/null +++ b/packages/v1/esphome-dsmr/package-source.json @@ -0,0 +1,106 @@ +{ + "schema_version": "sourceful.driver-package-source/v1", + "package_id": "com.sourceful.driver.esphome-dsmr", + "version": "1.0.1", + "channel": "beta", + "display_name": "ESPHome DSMR (P1)", + "identity": { + "schema": "sourceful.hardware-identity/v1", + "make": "driver_reported", + "serial": "driver_reported_when_available", + "host_fallbacks": [ + "mac", + "endpoint" + ], + "persistent_state_owner": "host" + }, + "source": { + "repository": "https://github.com/srcfl/device-drivers", + "path": "drivers/lua/esphome-dsmr.lua" + }, + "builder_id": "https://github.com/srcfl/device-drivers/blob/main/tools/driver_package.py", + "device_matches": [ + { + "manufacturer": "ESPHome", + "model_family": "DSMR web_server v3 P1 reader", + "variants": [ + "SlimmeLezer+", + "Sourceful Zap P1" + ], + "regions": [ + "NL", + "BE", + "SE", + "DK" + ] + } + ], + "capabilities": { + "telemetry": [ + "meter" + ], + "control": [] + }, + "permissions": [ + "http.get" + ], + "telemetry": { + "schema": "sourceful.telemetry/v2", + "sign_convention": "sourceful.site-import-positive/v1", + "streams": [ + { + "kind": "meter", + "power_field": "w", + "meaning": "Positive is grid import and negative is grid export at the site boundary." + } + ] + }, + "commands": [], + "read_only": true, + "default_mode": { + "strategy": "not_applicable", + "description": "The ESPHome meter endpoint is read-only and has no control state." + }, + "lease_policy": { + "required_for_control": false, + "expiry_action": "not_applicable" + }, + "rollback": { + "strategy": "install_previous_verified_package", + "state_owner": "host", + "automatic": false + }, + "compatibility": [ + { + "target": "ftw-core", + "artifact_id": "ftw.lua51.source", + "host": { + "product": "ftw", + "min_version": "1.10.0", + "max_version_exclusive": "2.0.0" + }, + "runtime": { + "name": "gopher-lua", + "semantics": "lua-5.1", + "version": "1.1.2", + "abi": "gopher-lua-source-v1", + "host_api": { + "profile": "sourceful.host/ftw-core/v1", + "min": 1, + "max": 1 + } + }, + "control_enabled": false + } + ], + "artifact_inputs": [ + { + "artifact_id": "ftw.lua51.source", + "target": "ftw-core", + "media_type": "application/vnd.sourceful.lua.source", + "input_path": "drivers/lua/esphome-dsmr.lua", + "transform": "copy", + "extension": "lua" + } + ] +} diff --git a/support-status.json b/support-status.json index bb4c280..45c5b04 100644 --- a/support-status.json +++ b/support-status.json @@ -336,6 +336,34 @@ } } }, + { + "catalog_source": true, + "catalog_version": "1.0.1", + "driver_id": "esphome-dsmr", + "package_id": "com.sourceful.driver.esphome-dsmr", + "targets": { + "blixt-l1": { + "candidate_package_version": null, + "control_enabled": false, + "hil": "not_recorded", + "historical_signed_beta_version": null, + "legacy_parity": "not_assessed", + "note": "", + "stable_package_version": null, + "target_conformance": "not_assessed" + }, + "ftw-core": { + "candidate_package_version": "1.0.1", + "control_enabled": false, + "hil": "not_recorded", + "historical_signed_beta_version": null, + "legacy_parity": "not_assessed", + "note": "", + "stable_package_version": null, + "target_conformance": "not_assessed" + } + } + }, { "catalog_source": true, "catalog_version": "1.0.0", diff --git a/tests/test_ftw_repository.py b/tests/test_ftw_repository.py index b09d67a..72cd52c 100644 --- a/tests/test_ftw_repository.py +++ b/tests/test_ftw_repository.py @@ -74,7 +74,7 @@ def test_publication_contains_the_full_read_only_catalog( _, public_key = keypair expected_ids = sorted(path.stem for path in (ROOT / "manifests").glob("*.yaml")) - assert len(expected_ids) == 61 + assert len(expected_ids) == 62 assert [driver["id"] for driver in manifest["drivers"]] == expected_ids assert all(driver["read_only"] for driver in manifest["drivers"]) assert all(not driver["control_enabled"] for driver in manifest["drivers"])