Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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: 22 additions & 3 deletions custom_components/sigen/calculated_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,17 +404,29 @@ def _calculate_total_inverter_energy(
return None

total_energy = Decimal("0.0")
valid_sample_count = 0
inverters_data = coordinator_data.get("inverters", {})

if not inverters_data:
_LOGGER.debug("[%s] Inverter data is empty", log_prefix)
return None # No inverters found

for inverter_name, inverter_data in inverters_data.items():
energy_value = safe_decimal(inverter_data.get(energy_key))
raw_value = inverter_data.get(energy_key)
if raw_value is None:
_LOGGER.debug(
"[%s] Missing '%s' for inverter %s",
log_prefix,
energy_key,
inverter_name,
)
continue

energy_value = safe_decimal(raw_value)
if energy_value is not None:
try:
total_energy += energy_value
valid_sample_count += 1
except (ValueError, TypeError, InvalidOperation) as e:
_LOGGER.warning(
"[%s] Invalid energy value '%s' for key '%s' in inverter %s: %s",
Expand All @@ -426,12 +438,19 @@ def _calculate_total_inverter_energy(
)
else:
_LOGGER.debug(
"[%s] Missing '%s' for inverter %s",
"[%s] Invalid '%s' value '%s' for inverter %s",
log_prefix,
energy_key,
inverter_name
raw_value,
inverter_name,
)

# If every inverter value was missing/invalid, publish unavailable instead of 0
# to avoid zero-bounce spikes in Energy Dashboard after reconnection events.
if valid_sample_count == 0:
_LOGGER.debug("[%s] No valid '%s' samples in this poll", log_prefix, energy_key)
return None
Comment on lines +450 to +452
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None return only covers the all-invalid case; partial-offline totals still decrease

The new valid_sample_count == 0 guard correctly prevents publishing 0 when every inverter is offline. However, consider a two-inverter setup where Inverter A goes offline (None, skipped) while Inverter B legitimately continues reporting. The function returns Inverter B's value alone — potentially a large drop from the previous combined total (e.g. 10 kWh → 5 kWh). This is less than zero (not caught by the sensor.py guard which only triggers on a drop to exactly 0), and HA's total_increasing logic may record it as a reset.

This is a pre-existing issue and this PR's scope is zero-bounce, but it's worth a comment here so the partial-offline decrease case isn't overlooked in a follow-up.


# Return as Decimal, matching other calculated sensors
return safe_decimal(total_energy)

Expand Down
65 changes: 59 additions & 6 deletions custom_components/sigen/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
from typing import Any, Optional, cast
from decimal import Decimal, InvalidOperation
from datetime import timedelta

from homeassistant.components.sensor import (
SensorDeviceClass,
Expand All @@ -20,6 +21,7 @@
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util

from .modbusregisterdefinitions import (
RunningState,
Expand All @@ -35,7 +37,7 @@
)
from .static_sensor import StaticSensors as SS
from .static_sensor import COORDINATOR_DIAGNOSTIC_SENSORS # Import the new descriptions
from .common import generate_sigen_entity, generate_device_id, SigenergySensorEntityDescription, SensorEntityDescription
from .common import generate_sigen_entity, generate_device_id, SigenergySensorEntityDescription, SensorEntityDescription, safe_decimal
from .const import (
DOMAIN,
DEVICE_TYPE_PLANT,
Expand All @@ -50,6 +52,18 @@
_LOGGER = logging.getLogger(__name__)


PROTECTED_DAILY_ENERGY_KEYS = {
"plant_daily_pv_energy",
"plant_daily_battery_charge_energy",
"plant_daily_battery_discharge_energy",
"inverter_daily_pv_energy",
"inverter_ess_daily_charge_energy",
"inverter_ess_daily_discharge_energy",
}

DAILY_RESET_GUARD_WINDOW = timedelta(minutes=20)


async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
Expand Down Expand Up @@ -201,6 +215,7 @@ def __init__(
if isinstance(description, SigenergySensorEntityDescription)
else None
)
self._last_valid_daily_energy_value: Decimal | None = None

def _decode_alarm_bits(self, value: int, alarm_mapping: dict) -> str:
"""Decode alarm bits into human-readable text."""
Expand Down Expand Up @@ -229,6 +244,44 @@ def _get_raw_value(self) -> Any:
return data.get("dc_chargers", {}).get(self._device_name, {}).get(self.entity_description.key)
return None

def _is_near_daily_reset(self) -> bool:
"""Return True in a symmetric window around midnight."""
now = dt_util.now()
seconds_since_midnight = (
now.hour * 3600 + now.minute * 60 + now.second + now.microsecond / 1_000_000
)
distance_to_midnight = min(seconds_since_midnight, 86400 - seconds_since_midnight)
return distance_to_midnight <= DAILY_RESET_GUARD_WINDOW.total_seconds()

def _apply_daily_energy_zero_guard(self, value: Any) -> Any:
"""Prevent transient zero-bounce for daily total_increasing energy sensors."""
key = getattr(self.entity_description, "key", None)
if key not in PROTECTED_DAILY_ENERGY_KEYS:
return value

if value is None:
return None

value_dec = safe_decimal(value)
if value_dec is None:
return value

if (
value_dec == 0
and self._last_valid_daily_energy_value is not None
and self._last_valid_daily_energy_value > 0
and not self._is_near_daily_reset()
):
_LOGGER.warning(
"[%s] Ignoring transient daily energy drop to 0 outside reset window (last=%s)",
self.entity_id,
self._last_valid_daily_energy_value,
)
return None
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING level causes log spam during extended outages

_LOGGER.warning(...) is called on every poll cycle while the guard is active. If polling runs every 30 seconds and an inverter is offline for an hour, this produces ~120 consecutive warning-level entries — all identical — which will visibly flood HA's logs.

Consider logging the first suppression at warning and subsequent consecutive suppressions at debug, or switching entirely to debug/info since this is expected operational behaviour (the sensor is already publishing unavailable, which provides the user-facing signal):

Suggested change
_LOGGER.warning(
"[%s] Ignoring transient daily energy drop to 0 outside reset window (last=%s)",
self.entity_id,
self._last_valid_daily_energy_value,
)
return None
_LOGGER.debug(
"[%s] Ignoring transient daily energy drop to 0 outside reset window (last=%s)",
self.entity_id,
self._last_valid_daily_energy_value,
)
return None


self._last_valid_daily_energy_value = value_dec
return value
Comment on lines +271 to +285
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extended outage crossing midnight suppresses legitimate reset

If HA stays running but the Modbus/network connection drops before the pre-midnight window opens (e.g. connection goes offline at 23:39, last poll left _last_valid_daily_energy_value = 14.0), and doesn't recover until after the post-midnight window closes (e.g. 00:21), the inverter correctly reports 0 (daily counter has reset) but the guard sees:

  • value_dec == 0
  • _last_valid_daily_energy_value = 14.0 > 0
  • _is_near_daily_reset()False (00:21 is outside the ±20 min window) ✓

The 0 is suppressed and unavailable is published instead.

This does not cause phantom energy (HA's total_increasing will handle the eventual reset correctly once production provides a non-zero reading), but it delays acknowledgment of the midnight reset and leaves daily sensors as unavailable until first real post-sunrise activity — which can look alarming in the Energy Dashboard and may interfere with automations that rely on a clean 0 at start of day.

One approach is to track the wall-clock timestamp of the last non-None reading and, if the gap exceeds a threshold (e.g. 30 min), allow the 0 through unconditionally as a probable legitimate counter reset rather than a transient glitch:

# In __init__
self._last_successful_daily_energy_ts: datetime | None = None

# In _apply_daily_energy_zero_guard, replace the suppression block:
STALE_THRESHOLD = timedelta(minutes=30)
now = dt_util.now()
stale = (
    self._last_successful_daily_energy_ts is None
    or (now - self._last_successful_daily_energy_ts) > STALE_THRESHOLD
)
if (
    value_dec == 0
    and self._last_valid_daily_energy_value is not None
    and self._last_valid_daily_energy_value > 0
    and not self._is_near_daily_reset()
    and not stale
):
    ...  # suppress

This keeps the glitch protection for short outages while allowing long-gap reconnects to pass a 0 through regardless.

Comment on lines +284 to +285
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_last_valid_daily_energy_value updates on value_dec == 0 during midnight window

When a midnight-window 0 passes the guard (intentionally allowed), _last_valid_daily_energy_value is set to Decimal(0). This is actually correct — once the daily counter legitimately resets, future 0 polls should not fire the guard (> 0 check prevents it). However, consider adding a short comment here to make the intent explicit, since a reader could easily assume the update should only happen for non-zero values:

Suggested change
self._last_valid_daily_energy_value = value_dec
return value
# Update reference only for non-suppressed values (includes legitimate midnight 0 resets).
self._last_valid_daily_energy_value = value_dec
return value

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


@property
def native_value(self) -> Any:
"""Return the state of the sensor."""
Expand All @@ -252,8 +305,8 @@ def native_value(self) -> Any:

# Round if needed
if transformed is not None and self._round_digits is not None:
return round(Decimal(transformed), self._round_digits)
return transformed
transformed = round(Decimal(transformed), self._round_digits)
return self._apply_daily_energy_zero_guard(transformed)
except Exception as ex:
if raw_value is None:
_LOGGER.debug("Value function failed for %s because data is missing: %s", self.entity_id, ex)
Expand Down Expand Up @@ -309,15 +362,15 @@ def native_value(self) -> Any:
"plant_grid_sensor_status": {0: "Offline", 1: "Online"},
}
if self.entity_description.key in enum_maps:
return enum_maps[self.entity_description.key].get(raw_value, f"Unknown: {raw_value}")
return self._apply_daily_energy_zero_guard(enum_maps[self.entity_description.key].get(raw_value, f"Unknown: {raw_value}"))

if self._round_digits is not None:
try:
return round(Decimal(raw_value), self._round_digits)
return self._apply_daily_energy_zero_guard(round(Decimal(raw_value), self._round_digits))
except (TypeError, ValueError, InvalidOperation):
_LOGGER.warning("Could not round direct value for %s: %s", self.entity_id, raw_value)

return raw_value
return self._apply_daily_energy_zero_guard(raw_value)


class PVStringSensor(SigenergySensor):
Expand Down
Loading