diff --git a/custom_components/sigen/calculated_sensor.py b/custom_components/sigen/calculated_sensor.py index 3fd4fea..46155d6 100644 --- a/custom_components/sigen/calculated_sensor.py +++ b/custom_components/sigen/calculated_sensor.py @@ -404,6 +404,7 @@ 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: @@ -411,10 +412,21 @@ def _calculate_total_inverter_energy( 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", @@ -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 + # Return as Decimal, matching other calculated sensors return safe_decimal(total_energy) diff --git a/custom_components/sigen/sensor.py b/custom_components/sigen/sensor.py index 6f6da1a..64c4e97 100644 --- a/custom_components/sigen/sensor.py +++ b/custom_components/sigen/sensor.py @@ -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, @@ -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, @@ -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, @@ -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, @@ -201,6 +215,9 @@ def __init__( if isinstance(description, SigenergySensorEntityDescription) else None ) + # In-memory only: this resets on HA restart, so the first post-restart poll + # has no historical reference and is intentionally passed through. + 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.""" @@ -229,6 +246,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.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 + @property def native_value(self) -> Any: """Return the state of the sensor.""" @@ -252,8 +307,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) @@ -309,15 +364,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):