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
25 changes: 21 additions & 4 deletions custom_components/enpal_webparser/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,8 @@ def __init__(self, hass: HomeAssistant, coordinator: DataUpdateCoordinator, sens
self._coordinator = coordinator
self._source_candidates = [make_id(name) for name in sensor_names]
self._active_source_uid = None # Will be determined from available sensors
self._interval_hours = interval_seconds / 3600
self._fallback_interval_hours = interval_seconds / 3600
self._last_update_time = None # Track real elapsed time between updates
self._state = self.hass.data[DOMAIN]["cumulative_energy_state"]
self._value = None
self._last_updated = None
Expand Down Expand Up @@ -309,16 +310,32 @@ def _handle_coordinator_update(self):
return

# Now process the update with the active source
now = datetime.now()
for sensor in self._coordinator.data:
if make_id(sensor["name"]) == self._active_source_uid:
try:
power_watt = float(sensor["value"])
energy_kwh = power_watt * self._interval_hours / 1000

# Use actual elapsed time between updates instead of
# the configured interval. In WebSocket mode, push
# updates can arrive much more frequently than the
# polling interval, which would otherwise multiply
# the energy by the wrong factor.
if self._last_update_time is not None:
elapsed_hours = (now - self._last_update_time).total_seconds() / 3600
else:
# First update after start/restore — use the
# configured interval as a reasonable fallback.
elapsed_hours = self._fallback_interval_hours

energy_kwh = power_watt * elapsed_hours / 1000
if self._value is None:
self._value = 0.0
self._value += energy_kwh
self._last_updated = datetime.now().isoformat()
_LOGGER.debug("[Enpal] +%.5f kWh -> Total: %.3f kWh", energy_kwh, self._value)
self._last_update_time = now
self._last_updated = now.isoformat()
_LOGGER.debug("[Enpal] +%.5f kWh (%.1fs elapsed) -> Total: %.3f kWh",
energy_kwh, elapsed_hours * 3600, self._value)
except Exception as e:
_LOGGER.warning("[Enpal] Error in energy calculation: %s", e)
break
Expand Down
138 changes: 133 additions & 5 deletions custom_components/enpal_webparser/tests/test_sensor_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,14 @@ def test_no_matching_sensor_fallback(self, mock_hass, mock_coordinator):
assert "No suitable DC power sensor found" in warning_msg

def test_energy_calculation_with_selected_sensor(self, mock_hass, mock_coordinator):
"""Test that energy calculation works with auto-selected sensor."""
"""Test that energy calculation works with auto-selected sensor.

Uses controlled timestamps to verify the integration:
- First update at t=0: uses fallback interval (3600s = 1h)
- Second update at t=3600s: uses real elapsed time (1h)
"""
from datetime import datetime, timedelta

mock_coordinator.data = [
{"name": "Inverter: Power DC Total (Huawei)", "value": "5000", "timestamp": "01/01/2024 12:00:00"},
]
Expand All @@ -164,18 +171,26 @@ def test_energy_calculation_with_selected_sensor(self, mock_hass, mock_coordinat
)
sensor._value = 0.0

# First update - selects sensor and calculates
sensor._handle_coordinator_update()
# First update at t=0 — no previous timestamp, uses fallback interval (1h)
t0 = datetime(2024, 1, 1, 12, 0, 0)
with patch("custom_components.enpal_webparser.sensor.datetime") as mock_dt:
mock_dt.now.return_value = t0
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
sensor._handle_coordinator_update()

# Should have calculated energy: 5000W * 1h / 1000 = 5 kWh
assert sensor._active_source_uid == "inverter_power_dc_total_huawei"
assert sensor._value == pytest.approx(5.0, rel=0.01)

# Second update with different power
# Second update 1 hour later with different power
mock_coordinator.data = [
{"name": "Inverter: Power DC Total (Huawei)", "value": "3000", "timestamp": "01/01/2024 13:00:00"},
]
sensor._handle_coordinator_update()
t1 = t0 + timedelta(hours=1)
with patch("custom_components.enpal_webparser.sensor.datetime") as mock_dt:
mock_dt.now.return_value = t1
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
sensor._handle_coordinator_update()

# Should add: 5 + 3 = 8 kWh
assert sensor._value == pytest.approx(8.0, rel=0.01)
Expand Down Expand Up @@ -318,6 +333,119 @@ def test_make_id_consistency(self, mock_hass, mock_coordinator):
assert make_id("Inverter: Power DC Total Calculated") == "inverter_power_dc_total_calculated"
assert make_id("Inverter: Power DC Total") == "inverter_power_dc_total"
assert make_id("Inverter: Power DC Total SMA") == "inverter_power_dc_total_sma"


class TestCumulativeEnergySensorElapsedTime:
"""Test that the energy calculation uses real elapsed time, not the configured interval.

In WebSocket mode, updates arrive more frequently (e.g. every 15s) than the
configured polling interval (e.g. 60s). Using the configured interval would
over-count energy by a factor of (interval / real_elapsed).
"""

def test_first_update_uses_fallback_interval(self, mock_hass, mock_coordinator):
"""On the very first update, use the configured interval as fallback."""
mock_coordinator.data = [
{"name": "Inverter: Power DC Total (Huawei)", "value": "6000", "timestamp": "01/01/2024 12:00:00"},
]
sensor = create_sensor_with_mocked_state(
mock_hass, mock_coordinator, ["Inverter: Power DC Total (Huawei)"], interval=60
)
sensor._value = 0.0

sensor._handle_coordinator_update()

# 6000 W * (60s / 3600) / 1000 = 0.1 kWh
assert sensor._value == pytest.approx(0.1, abs=1e-6)

def test_elapsed_time_used_for_subsequent_updates(self, mock_hass, mock_coordinator):
"""After the first update, real elapsed time must be used."""
from datetime import datetime, timedelta

mock_coordinator.data = [
{"name": "Inverter: Power DC Total (Huawei)", "value": "6000", "timestamp": "01/01/2024 12:00:00"},
]
sensor = create_sensor_with_mocked_state(
mock_hass, mock_coordinator, ["Inverter: Power DC Total (Huawei)"], interval=60
)
sensor._value = 0.0

# Simulate first update at a known time
t0 = datetime(2024, 6, 1, 12, 0, 0)
sensor._last_update_time = t0
sensor._active_source_uid = "inverter_power_dc_total_huawei"

# Simulate second update 15 seconds later (WebSocket push)
t1 = t0 + timedelta(seconds=15)
with patch("custom_components.enpal_webparser.sensor.datetime") as mock_dt:
mock_dt.now.return_value = t1
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
sensor._handle_coordinator_update()

# 6000 W * (15s / 3600) / 1000 = 0.025 kWh
assert sensor._value == pytest.approx(0.025, abs=1e-6)

def test_rapid_updates_do_not_overcount(self, mock_hass, mock_coordinator):
"""Simulate 4 rapid updates at 15s intervals — total must match 1 minute of production."""
from datetime import datetime, timedelta

mock_coordinator.data = [
{"name": "Inverter: Power DC Total (Huawei)", "value": "3600", "timestamp": "01/01/2024 12:00:00"},
]
sensor = create_sensor_with_mocked_state(
mock_hass, mock_coordinator, ["Inverter: Power DC Total (Huawei)"], interval=60
)
sensor._value = 0.0
sensor._active_source_uid = "inverter_power_dc_total_huawei"

base_time = datetime(2024, 6, 1, 12, 0, 0)
sensor._last_update_time = base_time

# 4 updates, each 15 seconds apart — totalling 60 seconds
for i in range(1, 5):
t = base_time + timedelta(seconds=15 * i)
with patch("custom_components.enpal_webparser.sensor.datetime") as mock_dt:
mock_dt.now.return_value = t
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
sensor._handle_coordinator_update()

# 3600 W * (60s total / 3600) / 1000 = 0.06 kWh
# This is the same result as a single 60s poll — no overcounting.
assert sensor._value == pytest.approx(0.06, abs=1e-6)

def test_old_interval_would_overcount(self, mock_hass, mock_coordinator):
"""Demonstrate that using the fixed interval in the same scenario would overcount.

With 4 updates at 15s intervals but interval=60s the old code would have
calculated 4 * (3600 * 60/3600 / 1000) = 4 * 0.06 = 0.24 kWh instead of
the correct 0.06 kWh — a 4× overcounting.

This test verifies the fix by asserting the correct value (0.06 kWh).
"""
from datetime import datetime, timedelta

mock_coordinator.data = [
{"name": "Inverter: Power DC Total (Huawei)", "value": "3600", "timestamp": "01/01/2024 12:00:00"},
]
sensor = create_sensor_with_mocked_state(
mock_hass, mock_coordinator, ["Inverter: Power DC Total (Huawei)"], interval=60
)
sensor._value = 0.0
sensor._active_source_uid = "inverter_power_dc_total_huawei"

base_time = datetime(2024, 6, 1, 12, 0, 0)
sensor._last_update_time = base_time

for i in range(1, 5):
t = base_time + timedelta(seconds=15 * i)
with patch("custom_components.enpal_webparser.sensor.datetime") as mock_dt:
mock_dt.now.return_value = t
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
sensor._handle_coordinator_update()

# Must NOT be 0.24 (the old bug) — must be 0.06
assert sensor._value != pytest.approx(0.24, abs=0.01)
assert sensor._value == pytest.approx(0.06, abs=1e-6)

# Verify sensor uses same transformation
mock_coordinator.data = [
Expand Down
Loading