diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index e09d1fad4c3..3f3e88a0166 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -47,6 +47,7 @@ from homeassistant.const import ( VOLUME_CUBIC_METERS, ) from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import entity_sources import homeassistant.util.dt as dt_util import homeassistant.util.pressure as pressure_util @@ -120,6 +121,8 @@ UNIT_CONVERSIONS: dict[str, dict[str, Callable]] = { # Keep track of entities for which a warning about decreasing value has been logged SEEN_DIP = "sensor_seen_total_increasing_dip" WARN_DIP = "sensor_warn_total_increasing_dip" +# Keep track of entities for which a warning about negative value has been logged +WARN_NEGATIVE = "sensor_warn_total_increasing_negative" # Keep track of entities for which a warning about unsupported unit has been logged WARN_UNSUPPORTED_UNIT = "sensor_warn_unsupported_unit" WARN_UNSTABLE_UNIT = "sensor_warn_unstable_unit" @@ -256,6 +259,24 @@ def _normalize_states( return DEVICE_CLASS_UNITS[device_class], fstates +def _suggest_report_issue(hass: HomeAssistant, entity_id: str) -> str: + """Suggest to report an issue.""" + domain = entity_sources(hass).get(entity_id, {}).get("domain") + custom_component = entity_sources(hass).get(entity_id, {}).get("custom_component") + report_issue = "" + if custom_component: + report_issue = "report it to the custom component author." + else: + report_issue = ( + "create a bug report at " + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" + ) + if domain: + report_issue += f"+label%3A%22integration%3A+{domain}%22" + + return report_issue + + def warn_dip(hass: HomeAssistant, entity_id: str) -> None: """Log a warning once if a sensor with state_class_total has a decreasing value. @@ -277,11 +298,26 @@ def warn_dip(hass: HomeAssistant, entity_id: str) -> None: return _LOGGER.warning( "Entity %s %shas state class total_increasing, but its state is " - "not strictly increasing. Please create a bug report at %s", + "not strictly increasing. Please %s", entity_id, f"from integration {domain} " if domain else "", - "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" - "+label%3A%22integration%3A+recorder%22", + _suggest_report_issue(hass, entity_id), + ) + + +def warn_negative(hass: HomeAssistant, entity_id: str) -> None: + """Log a warning once if a sensor with state_class_total has a negative value.""" + if WARN_NEGATIVE not in hass.data: + hass.data[WARN_NEGATIVE] = set() + if entity_id not in hass.data[WARN_NEGATIVE]: + hass.data[WARN_NEGATIVE].add(entity_id) + domain = entity_sources(hass).get(entity_id, {}).get("domain") + _LOGGER.warning( + "Entity %s %shas state class total_increasing, but its state is " + "negative. Please %s", + entity_id, + f"from integration {domain} " if domain else "", + _suggest_report_issue(hass, entity_id), ) @@ -295,6 +331,10 @@ def reset_detected( if 0.9 * previous_state <= state < previous_state: warn_dip(hass, entity_id) + if state < 0: + warn_negative(hass, entity_id) + raise HomeAssistantError + return state < 0.9 * previous_state @@ -473,17 +513,20 @@ def compile_statistics( # noqa: C901 entity_id, fstate, ) - elif state_class == STATE_CLASS_TOTAL_INCREASING and ( - old_state is None - or reset_detected(hass, entity_id, fstate, new_state) - ): - reset = True - _LOGGER.info( - "Detected new cycle for %s, value dropped from %s to %s", - entity_id, - new_state, - fstate, - ) + elif state_class == STATE_CLASS_TOTAL_INCREASING: + try: + if old_state is None or reset_detected( + hass, entity_id, fstate, new_state + ): + reset = True + _LOGGER.info( + "Detected new cycle for %s, value dropped from %s to %s", + entity_id, + new_state, + fstate, + ) + except HomeAssistantError: + continue if reset: # The sensor has been reset, update the sum diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index be204ebaa6f..f04949c0caa 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -734,7 +734,10 @@ class Entity(ABC): Not to be extended by integrations. """ if self.platform: - info = {"domain": self.platform.platform_name} + info = { + "domain": self.platform.platform_name, + "custom_component": "custom_components" in type(self).__module__, + } if self.platform.config_entry: info["source"] = SOURCE_CONFIG_ENTRY diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 1c9caf07982..9baa0aaf460 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -8,6 +8,7 @@ from unittest.mock import patch import pytest from pytest import approx +from homeassistant import loader from homeassistant.components.recorder import history from homeassistant.components.recorder.const import DATA_INSTANCE from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat @@ -609,6 +610,114 @@ def test_compile_hourly_sum_statistics_nan_inf_state( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + "entity_id,warning_1,warning_2", + [ + ( + "sensor.test1", + "", + "bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue", + ), + ( + "sensor.today_energy", + "from integration demo ", + "bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+demo%22", + ), + ( + "sensor.custom_sensor", + "from integration test ", + "report it to the custom component author", + ), + ], +) +@pytest.mark.parametrize("state_class", ["total_increasing"]) +@pytest.mark.parametrize( + "device_class,unit,native_unit,factor", + [ + ("energy", "kWh", "kWh", 1), + ], +) +def test_compile_hourly_sum_statistics_negative_state( + hass_recorder, + caplog, + entity_id, + warning_1, + warning_2, + state_class, + device_class, + unit, + native_unit, + factor, +): + """Test compiling hourly statistics with negative states.""" + zero = dt_util.utcnow() + hass = hass_recorder() + hass.data.pop(loader.DATA_CUSTOM_COMPONENTS) + recorder = hass.data[DATA_INSTANCE] + + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + mocksensor = platform.MockSensor(name="custom_sensor") + mocksensor._attr_should_poll = False + platform.ENTITIES["custom_sensor"] = mocksensor + + setup_component( + hass, "sensor", {"sensor": [{"platform": "demo"}, {"platform": "test"}]} + ) + hass.block_till_done() + attributes = { + "device_class": device_class, + "state_class": state_class, + "unit_of_measurement": unit, + } + seq = [15, 16, 15, 16, 20, -20, 20, 10] + + states = {entity_id: []} + if state := hass.states.get(entity_id): + states[entity_id].append(state) + one = zero + for i in range(len(seq)): + one = one + timedelta(seconds=5) + _states = record_meter_state(hass, one, entity_id, attributes, seq[i : i + 1]) + states[entity_id].extend(_states[entity_id]) + + hist = history.get_significant_states( + hass, + zero - timedelta.resolution, + one + timedelta.resolution, + significant_changes_only=False, + ) + assert dict(states)[entity_id] == dict(hist)[entity_id] + + recorder.do_adhoc_statistics(start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert { + "statistic_id": entity_id, + "unit_of_measurement": native_unit, + } in statistic_ids + stats = statistics_during_period(hass, zero, period="5minute") + assert stats[entity_id] == [ + { + "statistic_id": entity_id, + "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(factor * seq[7]), + "sum": approx(factor * 15), # (15 - 10) + (10 - 0) + }, + ] + assert "Error while processing event StatisticsTask" not in caplog.text + assert ( + f"Entity {entity_id} {warning_1}has state class total_increasing, but its state is negative" + in caplog.text + ) + assert warning_2 in caplog.text + + @pytest.mark.parametrize( "device_class,unit,native_unit,factor", [ @@ -823,16 +932,14 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( assert ( "Entity sensor.test1 has state class total_increasing, but its state is not " "strictly increasing. Please create a bug report at https://github.com/" - "home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A" - "+recorder%22" + "home-assistant/core/issues?q=is%3Aopen+is%3Aissue" ) not in caplog.text recorder.do_adhoc_statistics(start=period2) wait_recording_done(hass) assert ( "Entity sensor.test1 has state class total_increasing, but its state is not " "strictly increasing. Please create a bug report at https://github.com/" - "home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A" - "+recorder%22" + "home-assistant/core/issues?q=is%3Aopen+is%3Aissue" ) in caplog.text statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 7c43d34d9a6..447f38f9a9c 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -922,12 +922,14 @@ async def test_entity_source_admin(hass, websocket_client, hass_admin_user): assert msg["success"] assert msg["result"] == { "test_domain.entity_1": { - "source": entity.SOURCE_PLATFORM_CONFIG, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_PLATFORM_CONFIG, }, "test_domain.entity_2": { - "source": entity.SOURCE_PLATFORM_CONFIG, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_PLATFORM_CONFIG, }, } @@ -942,8 +944,9 @@ async def test_entity_source_admin(hass, websocket_client, hass_admin_user): assert msg["success"] assert msg["result"] == { "test_domain.entity_2": { - "source": entity.SOURCE_PLATFORM_CONFIG, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_PLATFORM_CONFIG, }, } @@ -962,12 +965,14 @@ async def test_entity_source_admin(hass, websocket_client, hass_admin_user): assert msg["success"] assert msg["result"] == { "test_domain.entity_1": { - "source": entity.SOURCE_PLATFORM_CONFIG, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_PLATFORM_CONFIG, }, "test_domain.entity_2": { - "source": entity.SOURCE_PLATFORM_CONFIG, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_PLATFORM_CONFIG, }, } @@ -1001,8 +1006,9 @@ async def test_entity_source_admin(hass, websocket_client, hass_admin_user): assert msg["success"] assert msg["result"] == { "test_domain.entity_2": { - "source": entity.SOURCE_PLATFORM_CONFIG, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_PLATFORM_CONFIG, }, } diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 8142f563f01..21811c3bfdc 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -707,13 +707,15 @@ async def test_setup_source(hass): assert entity.entity_sources(hass) == { "test_domain.platform_config_source": { - "source": entity.SOURCE_PLATFORM_CONFIG, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_PLATFORM_CONFIG, }, "test_domain.config_entry_source": { - "source": entity.SOURCE_CONFIG_ENTRY, "config_entry": platform.config_entry.entry_id, + "custom_component": False, "domain": "test_platform", + "source": entity.SOURCE_CONFIG_ENTRY, }, }