Skip to content
Merged
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
2 changes: 2 additions & 0 deletions SUPPORT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
13 changes: 13 additions & 0 deletions devices.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
387 changes: 387 additions & 0 deletions drivers/lua/esphome-dsmr.lua

Large diffs are not rendered by default.

106 changes: 106 additions & 0 deletions drivers/tests/lua_harness/test_esphome_dsmr_aliases.lua
Original file line number Diff line number Diff line change
@@ -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()
4 changes: 2 additions & 2 deletions drivers/tests/test_driver_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()."""
Expand Down
40 changes: 40 additions & 0 deletions drivers/tests/test_esphome_dsmr_aliases.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 12 additions & 3 deletions drivers/tests/test_http_drivers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, (
Expand Down
8 changes: 8 additions & 0 deletions index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions manifests/esphome-dsmr.yaml
Original file line number Diff line number Diff line change
@@ -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."
106 changes: 106 additions & 0 deletions packages/v1/esphome-dsmr/package-source.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
28 changes: 28 additions & 0 deletions support-status.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading