From 450c57969adefdb3097704a3caf6a106af38e77b Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 14 May 2024 14:20:59 +0100 Subject: [PATCH] Add diagnostic platform to utility_meter (#114967) * Add diagnostic to identify next_reset * Add test * add next_reset attr * Trigger CI * set as _unrecorded_attributes --- .../components/utility_meter/const.py | 1 + .../components/utility_meter/diagnostics.py | 35 +++++ .../components/utility_meter/sensor.py | 12 +- .../snapshots/test_diagnostics.ambr | 65 +++++++++ .../utility_meter/test_diagnostics.py | 127 ++++++++++++++++++ 5 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/utility_meter/diagnostics.py create mode 100644 tests/components/utility_meter/snapshots/test_diagnostics.ambr create mode 100644 tests/components/utility_meter/test_diagnostics.py diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py index 49799ba1e67..d1990463cbd 100644 --- a/homeassistant/components/utility_meter/const.py +++ b/homeassistant/components/utility_meter/const.py @@ -43,6 +43,7 @@ ATTR_TARIFF = "tariff" ATTR_TARIFFS = "tariffs" ATTR_VALUE = "value" ATTR_CRON_PATTERN = "cron pattern" +ATTR_NEXT_RESET = "next_reset" SIGNAL_START_PAUSE_METER = "utility_meter_start_pause" SIGNAL_RESET_METER = "utility_meter_reset" diff --git a/homeassistant/components/utility_meter/diagnostics.py b/homeassistant/components/utility_meter/diagnostics.py new file mode 100644 index 00000000000..57850beb0fb --- /dev/null +++ b/homeassistant/components/utility_meter/diagnostics.py @@ -0,0 +1,35 @@ +"""Diagnostics support for Utility Meter.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DATA_TARIFF_SENSORS, DATA_UTILITY + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + tariff_sensors = [] + + for sensor in hass.data[DATA_UTILITY][entry.entry_id][DATA_TARIFF_SENSORS]: + restored_last_extra_data = await sensor.async_get_last_extra_data() + + tariff_sensors.append( + { + "name": sensor.name, + "entity_id": sensor.entity_id, + "extra_attributes": sensor.extra_state_attributes, + "last_sensor_data": restored_last_extra_data, + } + ) + + return { + "config_entry": entry, + "tariff_sensors": tariff_sensors, + } diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 223e54d7d9f..a3b94a519ee 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -58,6 +58,7 @@ from homeassistant.util.enum import try_parse_enum from .const import ( ATTR_CRON_PATTERN, + ATTR_NEXT_RESET, ATTR_VALUE, BIMONTHLY, CONF_CRON_PATTERN, @@ -373,6 +374,7 @@ class UtilityMeterSensor(RestoreSensor): _attr_translation_key = "utility_meter" _attr_should_poll = False + _unrecorded_attributes = frozenset({ATTR_NEXT_RESET}) def __init__( self, @@ -424,6 +426,7 @@ class UtilityMeterSensor(RestoreSensor): self._sensor_periodically_resetting = periodically_resetting self._tariff = tariff self._tariff_entity = tariff_entity + self._next_reset = None def start(self, attributes: Mapping[str, Any]) -> None: """Initialize unit and state upon source initial update.""" @@ -564,13 +567,14 @@ class UtilityMeterSensor(RestoreSensor): """Program the reset of the utility meter.""" if self._cron_pattern is not None: tz = dt_util.get_time_zone(self.hass.config.time_zone) + self._next_reset = croniter(self._cron_pattern, dt_util.now(tz)).get_next( + datetime + ) # we need timezone for DST purposes (see issue #102984) self.async_on_remove( async_track_point_in_time( self.hass, self._async_reset_meter, - croniter(self._cron_pattern, dt_util.now(tz)).get_next( - datetime - ), # we need timezone for DST purposes (see issue #102984) + self._next_reset, ) ) @@ -754,6 +758,8 @@ class UtilityMeterSensor(RestoreSensor): # in extra state attributes. if last_reset := self._last_reset: state_attr[ATTR_LAST_RESET] = last_reset.isoformat() + if self._next_reset is not None: + state_attr[ATTR_NEXT_RESET] = self._next_reset.isoformat() return state_attr diff --git a/tests/components/utility_meter/snapshots/test_diagnostics.ambr b/tests/components/utility_meter/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..9858973d912 --- /dev/null +++ b/tests/components/utility_meter/snapshots/test_diagnostics.ambr @@ -0,0 +1,65 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + }), + 'disabled_by': None, + 'domain': 'utility_meter', + 'minor_version': 1, + 'options': dict({ + 'cycle': 'monthly', + 'delta_values': False, + 'name': 'Energy Bill', + 'net_consumption': False, + 'offset': 0, + 'periodically_resetting': True, + 'source': 'sensor.input1', + 'tariffs': list([ + 'tariff0', + 'tariff1', + ]), + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Energy Bill', + 'unique_id': None, + 'version': 2, + }), + 'tariff_sensors': list([ + dict({ + 'entity_id': 'sensor.energy_bill_tariff0', + 'extra_attributes': dict({ + 'cron pattern': '0 0 1 * *', + 'last_period': '0', + 'last_reset': '2024-04-05T00:00:00+00:00', + 'last_valid_state': 'None', + 'meter_period': 'monthly', + 'next_reset': '2024-05-01T00:00:00-07:00', + 'source': 'sensor.input1', + 'status': 'collecting', + 'tariff': 'tariff0', + }), + 'last_sensor_data': None, + 'name': 'Energy Bill tariff0', + }), + dict({ + 'entity_id': 'sensor.energy_bill_tariff1', + 'extra_attributes': dict({ + 'cron pattern': '0 0 1 * *', + 'last_period': '0', + 'last_reset': '2024-04-05T00:00:00+00:00', + 'last_valid_state': 'None', + 'meter_period': 'monthly', + 'next_reset': '2024-05-01T00:00:00-07:00', + 'source': 'sensor.input1', + 'status': 'paused', + 'tariff': 'tariff1', + }), + 'last_sensor_data': None, + 'name': 'Energy Bill tariff1', + }), + ]), + }) +# --- diff --git a/tests/components/utility_meter/test_diagnostics.py b/tests/components/utility_meter/test_diagnostics.py new file mode 100644 index 00000000000..083fd965e90 --- /dev/null +++ b/tests/components/utility_meter/test_diagnostics.py @@ -0,0 +1,127 @@ +"""Test Utility Meter diagnostics.""" + +from aiohttp.test_utils import TestClient +from freezegun import freeze_time +from syrupy import SnapshotAssertion + +from homeassistant.auth.models import Credentials +from homeassistant.components.utility_meter.const import DOMAIN +from homeassistant.components.utility_meter.sensor import ATTR_LAST_RESET +from homeassistant.core import HomeAssistant, State + +from tests.common import ( + CLIENT_ID, + MockConfigEntry, + MockUser, + mock_restore_cache_with_extra_data, +) +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def generate_new_hass_access_token( + hass: HomeAssistant, hass_admin_user: MockUser, hass_admin_credential: Credentials +) -> str: + """Return an access token to access Home Assistant.""" + await hass.auth.async_link_user(hass_admin_user, hass_admin_credential) + + refresh_token = await hass.auth.async_create_refresh_token( + hass_admin_user, CLIENT_ID, credential=hass_admin_credential + ) + return hass.auth.async_create_access_token(refresh_token) + + +def _get_test_client_generator( + hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, new_token: str +): + """Return a test client generator."".""" + + async def auth_client() -> TestClient: + return await aiohttp_client( + hass.http.app, headers={"Authorization": f"Bearer {new_token}"} + ) + + return auth_client + + +def limit_diagnostic_attrs(prop, path) -> bool: + """Mark attributes to exclude from diagnostic snapshot.""" + return prop in {"entry_id"} + + +@freeze_time("2024-04-06 00:00:00+00:00") +async def test_diagnostics( + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + hass_admin_user: MockUser, + hass_admin_credential: Credentials, + socket_enabled: None, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a config entry.""" + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "cycle": "monthly", + "delta_values": False, + "name": "Energy Bill", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": "sensor.input1", + "tariffs": [ + "tariff0", + "tariff1", + ], + }, + title="Energy Bill", + ) + + last_reset = "2024-04-05T00:00:00+00:00" + + # Set up the sensors restore data + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + "sensor.energy_bill_tariff0", + "3", + attributes={ + ATTR_LAST_RESET: last_reset, + }, + ), + {}, + ), + ( + State( + "sensor.energy_bill_tariff1", + "7", + attributes={ + ATTR_LAST_RESET: last_reset, + }, + ), + {}, + ), + ], + ) + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Since we are freezing time only when we enter this test, we need to + # manually create a new token and clients since the token created by + # the fixtures would not be valid. + new_token = await generate_new_hass_access_token( + hass, hass_admin_user, hass_admin_credential + ) + + diag = await get_diagnostics_for_config_entry( + hass, _get_test_client_generator(hass, aiohttp_client, new_token), config_entry + ) + + assert diag == snapshot(exclude=limit_diagnostic_attrs)