diff --git a/custom_components/average/__init__.py b/custom_components/average/__init__.py index 541089f..088bf08 100644 --- a/custom_components/average/__init__.py +++ b/custom_components/average/__init__.py @@ -2,7 +2,8 @@ # Creative Commons BY-NC-SA 4.0 International Public License # (see LICENSE.md or https://creativecommons.org/licenses/by-nc-sa/4.0/) -"""The Average Sensor. +""" +The Average Sensor. For more details about this sensor, please refer to the documentation at https://github.com/Limych/ha-average/ @@ -10,25 +11,27 @@ from __future__ import annotations import logging +from typing import TYPE_CHECKING import voluptuous as vol - from homeassistant.const import SERVICE_RELOAD -from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers.reload import async_reload_integration_platforms -from homeassistant.helpers.typing import ConfigType + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant, ServiceCall + from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS, STARTUP_MESSAGE _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ARG001 """Set up the platforms.""" # Print startup message _LOGGER.info(STARTUP_MESSAGE) - async def reload_service_handler(service: ServiceCall) -> None: + async def reload_service_handler(service: ServiceCall) -> None: # noqa: ARG001 """Reload all average sensors from config.""" await async_reload_integration_platforms(hass, DOMAIN, PLATFORMS) diff --git a/custom_components/average/const.py b/custom_components/average/const.py index ff2e171..eafe3ea 100644 --- a/custom_components/average/const.py +++ b/custom_components/average/const.py @@ -1,4 +1,5 @@ -"""The Average Sensor. +""" +The Average Sensor. For more details about this sensor, please refer to the documentation at https://github.com/Limych/ha-average/ @@ -7,9 +8,9 @@ from datetime import timedelta from typing import Final -# Base component constants from homeassistant.const import Platform +# Base component constants NAME: Final = "Average Sensor" DOMAIN: Final = "average" VERSION: Final = "2.3.5-alpha" diff --git a/custom_components/average/manifest.json b/custom_components/average/manifest.json index 982f7ee..0c1533f 100644 --- a/custom_components/average/manifest.json +++ b/custom_components/average/manifest.json @@ -14,6 +14,8 @@ "documentation": "https://github.com/Limych/ha-average", "iot_class": "calculated", "issue_tracker": "https://github.com/Limych/ha-average/issues", - "requirements": [], + "requirements": [ + "pip>=21.3.1" + ], "version": "2.3.5-alpha" } \ No newline at end of file diff --git a/custom_components/average/sensor.py b/custom_components/average/sensor.py index da70109..86bb119 100644 --- a/custom_components/average/sensor.py +++ b/custom_components/average/sensor.py @@ -2,25 +2,31 @@ # Creative Commons BY-NC-SA 4.0 International Public License # (see LICENSE.md or https://creativecommons.org/licenses/by-nc-sa/4.0/) -"""The Average Sensor. +""" +The Average Sensor. For more details about this sensor, please refer to the documentation at https://github.com/Limych/ha-average/ """ from __future__ import annotations -from collections.abc import Mapping -import datetime import logging import math import numbers -from typing import Any +from typing import TYPE_CHECKING, Any -from _sha1 import sha1 -import voluptuous as vol +from homeassistant.helpers.group import expand_entity_ids +if TYPE_CHECKING: + from collections.abc import Mapping + + from homeassistant.helpers.entity_platform import AddEntitiesCallback + from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +import homeassistant.util.dt as dt_util +import voluptuous as vol +from _sha1 import sha1 from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN -from homeassistant.components.group import expand_entity_ids from homeassistant.components.recorder import get_instance, history from homeassistant.components.sensor import ( SensorDeviceClass, @@ -42,7 +48,6 @@ ) from homeassistant.core import ( Event, - EventStateChangedData, HomeAssistant, State, callback, @@ -53,7 +58,6 @@ from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.helpers.event import async_track_state_change_event from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util from homeassistant.util.unit_conversion import TemperatureConverter from homeassistant.util.unit_system import TEMPERATURE_UNITS @@ -81,10 +85,10 @@ _LOGGER = logging.getLogger(__name__) -def check_period_keys(conf): +def check_period_keys(conf: ConfigType) -> ConfigType: """Ensure maximum 2 of CONF_PERIOD_KEYS are provided.""" count = sum(param in conf for param in CONF_PERIOD_KEYS) - if (count == 1 and CONF_DURATION not in conf) or count > 2: + if (count == 1 and CONF_DURATION not in conf) or count > 2: # noqa: PLR2004 raise vol.Invalid( "You must provide none, only " + CONF_DURATION @@ -113,31 +117,13 @@ def check_period_keys(conf): # pylint: disable=unused-argument async def async_setup_platform( - hass: HomeAssistant, config, async_add_entities, discovery_info=None -): + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, # noqa: ARG001 +) -> None: """Set up platform.""" - start = config.get(CONF_START) - end = config.get(CONF_END) - - for template in [start, end]: - if template is not None: - template.hass = hass - - async_add_entities( - [ - AverageSensor( - hass, - config.get(CONF_UNIQUE_ID), - config.get(CONF_NAME), - start, - end, - config.get(CONF_DURATION), - config.get(CONF_ENTITIES), - config.get(CONF_PRECISION), - config.get(CONF_PROCESS_UNDEF_AS), - ) - ] - ) + async_add_entities([AverageSensor(hass, config)]) # pylint: disable=too-many-instance-attributes @@ -158,36 +144,35 @@ class AverageSensor(SensorEntity): ) # pylint: disable=too-many-arguments - def __init__( - self, - hass: HomeAssistant, - unique_id: str | None, - name: str, - start, - end, - duration, - entity_ids: list, - precision: int, - undef, - ): + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: """Initialize the sensor.""" + unique_id = config.get(CONF_UNIQUE_ID) + start = config.get(CONF_START) + end = config.get(CONF_END) + duration = config.get(CONF_DURATION) + + for template in [start, end]: + if template is not None: + template.hass = hass + self._start_template = start self._end_template = end self._duration = duration self._period = self.start = self.end = None - self._precision = precision - self._undef = undef + self._precision = config.get(CONF_PRECISION, DEFAULT_PRECISION) + self._undef = config.get(CONF_PROCESS_UNDEF_AS) self._temperature_mode = None self._actual_end = None - self.sources = expand_entity_ids(hass, entity_ids) + self.hass = hass + self.sources = expand_entity_ids(hass, config.get(CONF_ENTITIES, [])) self.count_sources = len(self.sources) self.available_sources = 0 self.count = 0 self.trending_towards = None self.min_value = self.max_value = None - self._attr_name = name + self._attr_name = config.get(CONF_NAME, DEFAULT_NAME) self._attr_native_value = None self._attr_native_unit_of_measurement = None self._attr_icon = None @@ -228,33 +213,30 @@ def available(self) -> bool: @property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return entity specific state attributes.""" - state_attr = { + return { attr: getattr(self, attr) for attr in ATTR_TO_PROPERTY if getattr(self, attr) is not None } - return state_attr async def async_added_to_hass(self) -> None: """Register callbacks.""" # pylint: disable=unused-argument @callback - async def async_sensor_state_listener( - event: Event[EventStateChangedData], - ) -> None: + async def async_sensor_state_listener(event: Event) -> None: # noqa: ARG001 """Handle device state changes.""" last_state = self._attr_native_value await self._async_update_state() if last_state != self._attr_native_value: - self.async_schedule_update_ha_state(True) + self.async_schedule_update_ha_state(force_refresh=True) # pylint: disable=unused-argument @callback - async def async_sensor_startup(event): + async def async_sensor_startup(event: Event) -> None: # noqa: ARG001 """Update template on startup.""" if self._has_period: - self.async_schedule_update_ha_state(True) + self.async_schedule_update_ha_state(force_refresh=True) else: async_track_state_change_event( self.hass, self.sources, async_sensor_state_listener @@ -264,7 +246,7 @@ async def async_sensor_startup(event): self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, async_sensor_startup) @staticmethod - def _has_state(state) -> bool: + def _has_state(state: str | None) -> bool: """Return True if state has any value.""" return state is not None and state not in [ STATE_UNKNOWN, @@ -294,8 +276,8 @@ def _get_temperature(self, state: State) -> float | None: temperature = TemperatureConverter.convert( float(temperature), entity_unit, ha_unit ) - except ValueError as exc: - _LOGGER.error('Could not convert value "%s" to float: %s', state, exc) + except ValueError: + _LOGGER.exception('Could not convert value "%s" to float', state) return None return temperature @@ -308,8 +290,8 @@ def _get_state_value(self, state: State) -> float | None: try: state = float(state) - except ValueError as exc: - _LOGGER.error('Could not convert value "%s" to float: %s', state, exc) + except ValueError: + _LOGGER.exception('Could not convert value "%s" to float', state) return None self.count += 1 @@ -322,13 +304,13 @@ def _get_state_value(self, state: State) -> float | None: return state @Throttle(UPDATE_MIN_TIME) - async def async_update(self): + async def async_update(self) -> None: """Update the sensor state if it needed.""" if self._has_period: await self._async_update_state() @staticmethod - def handle_template_exception(exc, field): + def handle_template_exception(exc: Exception, field: str) -> None: """Log an error nicely if the template cannot be interpreted.""" if exc.args and exc.args[0].startswith( "UndefinedError: 'None' has no attribute" @@ -337,9 +319,9 @@ def handle_template_exception(exc, field): _LOGGER.warning(exc) else: - _LOGGER.error('Error parsing template for field "%s": %s', field, exc) + _LOGGER.exception('Error parsing template for field "%s": %s', field, exc) - async def _async_update_period(self): # pylint: disable=too-many-branches + async def _async_update_period(self) -> None: # noqa: PLR0912 """Parse the templates and calculate a datetime tuples.""" start = end = None now = dt_util.now() @@ -360,7 +342,7 @@ async def _async_update_period(self): # pylint: disable=too-many-branches dt_util.utc_from_timestamp(math.floor(float(start_rendered))) ) except ValueError: - _LOGGER.error( + _LOGGER.exception( 'Parsing error: field "start" must be a datetime or a timestamp' ) return @@ -381,7 +363,7 @@ async def _async_update_period(self): # pylint: disable=too-many-branches dt_util.utc_from_timestamp(math.floor(float(end_rendered))) ) except ValueError: - _LOGGER.error( + _LOGGER.exception( 'Parsing error: field "end" must be a datetime or a timestamp' ) return @@ -414,7 +396,7 @@ async def _async_update_period(self): # pylint: disable=too-many-branches self.start = start.replace(microsecond=0).isoformat() self.end = end.replace(microsecond=0).isoformat() - def _init_mode(self, state: State): + def _init_mode(self, state: State) -> None: """Initialize sensor mode.""" if self._temperature_mode is not None: return @@ -439,9 +421,7 @@ def _init_mode(self, state: State): _LOGGER.debug("%s is NOT a temperature entity.", state.entity_id) self._attr_icon = state.attributes.get(ATTR_ICON) - async def _async_update_state( - self, - ): # pylint: disable=too-many-locals,too-many-branches,too-many-statements + async def _async_update_state(self) -> None: # noqa: PLR0912, PLR0915 """Update the sensor state.""" _LOGGER.debug('Updating sensor "%s"', self.name) start = end = start_ts = end_ts = None @@ -451,7 +431,7 @@ async def _async_update_state( await self._async_update_period() if self._period is not None: - now = datetime.datetime.now() + now = dt_util.now() start, end = self._period if p_period is None: p_start = p_end = now @@ -485,7 +465,6 @@ async def _async_update_state( self.min_value = self.max_value = None last_values = [] - # pylint: disable=too-many-nested-blocks for entity_id in self.sources: _LOGGER.debug('Processing entity "%s"', entity_id) @@ -502,7 +481,6 @@ async def _async_update_state( elapsed = 0 trending_last_state = None - if self._period is None: # Get current state value = self._get_state_value(state) @@ -569,7 +547,7 @@ async def _async_update_state( if isinstance(value, numbers.Number): values.append(value) self.available_sources += 1 - + if isinstance(trending_last_state, numbers.Number): last_values.append(trending_last_state) @@ -593,10 +571,13 @@ async def _async_update_state( else: self.trending_towards = None - _LOGGER.debug("Current trend: %s", self.trending_towards) - _LOGGER.debug( "Total average state: %s %s", self._attr_native_value, self._attr_native_unit_of_measurement, ) + _LOGGER.debug( + "Current trend: %s %s", + self.trending_towards, + self._attr_native_unit_of_measurement, + ) diff --git a/tests/__init__.py b/tests/__init__.py index 4e57908..4cf3704 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,9 +1,10 @@ """Tests for integration.""" -import pathlib + import traceback +from pathlib import Path -def get_fixture_path(filename: str) -> pathlib.Path: +def get_fixture_path(filename: str) -> Path: """Get path of fixture.""" start_path = traceback.extract_stack()[-1].filename - return pathlib.Path(start_path).parent.joinpath("fixtures", filename) + return Path(start_path).parent.joinpath("fixtures", filename) diff --git a/tests/conftest.py b/tests/conftest.py index 0aaa4b3..5702575 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,18 +27,19 @@ # This fixture enables loading custom integrations in all tests. # Remove to enable selective use of this fixture @pytest.fixture(autouse=True) -def auto_enable_custom_integrations(enable_custom_integrations): +def _auto_enable_custom_integrations(enable_custom_integrations) -> None: """Automatically enable loading custom integrations in all tests.""" - yield + return -# This fixture is used to prevent HomeAssistant from attempting to create and dismiss persistent -# notifications. These calls would fail without this fixture since the persistent_notification -# integration is never loaded during a test. +# This fixture is used to prevent HomeAssistant from attempting to create and dismiss +# persistent notifications. These calls would fail without this fixture since the +# persistent_notification integration is never loaded during a test. @pytest.fixture(name="skip_notifications", autouse=True) -def skip_notifications_fixture(): +def _skip_notifications_fixture() -> None: """Skip notification calls.""" - with patch("homeassistant.components.persistent_notification.async_create"), patch( - "homeassistant.components.persistent_notification.async_dismiss" + with ( + patch("homeassistant.components.persistent_notification.async_create"), + patch("homeassistant.components.persistent_notification.async_dismiss"), ): yield diff --git a/tests/const.py b/tests/const.py index 0e2f161..3227b88 100644 --- a/tests/const.py +++ b/tests/const.py @@ -1,4 +1,5 @@ """Constants for tests.""" + from __future__ import annotations from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN diff --git a/tests/test__init.py b/tests/test__init.py index 5d3b986..874fb01 100644 --- a/tests/test__init.py +++ b/tests/test__init.py @@ -1,15 +1,17 @@ """The test for the average integration.""" + # pylint: disable=redefined-outer-name from __future__ import annotations from unittest.mock import patch -from custom_components.average.const import DOMAIN from homeassistant import config as hass_config from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import SERVICE_RELOAD from homeassistant.setup import async_setup_component +from custom_components.average.const import DOMAIN + from . import get_fixture_path from .const import MOCK_CONFIG, TEST_NAME diff --git a/tests/test_sensor.py b/tests/test_sensor.py index f8aa9f4..32c3252 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -1,25 +1,18 @@ """The test for the average sensor platform.""" + # pylint: disable=redefined-outer-name from __future__ import annotations +import logging from asyncio import sleep from datetime import timedelta -import logging from unittest.mock import MagicMock, patch +import homeassistant.util.dt as dt_util import pytest -from pytest import raises -from pytest_homeassistant_custom_component.common import assert_setup_component -from voluptuous import Invalid - -from custom_components.average.const import CONF_DURATION, CONF_END, CONF_START, DOMAIN -from custom_components.average.sensor import ( - AverageSensor, - async_setup_platform, - check_period_keys, -) from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR, SensorDeviceClass +from homeassistant.components.sensor import DOMAIN as SENSOR +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.const import ( @@ -29,6 +22,7 @@ CONF_ENTITIES, CONF_NAME, CONF_PLATFORM, + CONF_UNIQUE_ID, STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfTemperature, @@ -36,28 +30,45 @@ from homeassistant.core import HomeAssistant, State from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import TEMPERATURE_UNITS +from pytest_homeassistant_custom_component.common import assert_setup_component +from voluptuous import Invalid + +from custom_components.average.const import ( + CONF_DURATION, + CONF_END, + CONF_PRECISION, + CONF_START, + DOMAIN, +) +from custom_components.average.sensor import ( + AverageSensor, + async_setup_platform, + check_period_keys, +) from .const import TEST_ENTITY_IDS, TEST_NAME, TEST_UNIQUE_ID -@pytest.fixture() -def default_sensor(hass: HomeAssistant): +@pytest.fixture +def default_sensor_config(): + """Return default test config for AverageSensor.""" + return { + CONF_UNIQUE_ID: TEST_UNIQUE_ID, + CONF_NAME: TEST_NAME, + # CONF_START: None, + CONF_END: Template("{{ now() }}"), + CONF_DURATION: timedelta(minutes=3), + CONF_ENTITIES: TEST_ENTITY_IDS, + CONF_PRECISION: 2, + # CONF_PROCESS_UNDEF_AS: None, + } + + +@pytest.fixture +def default_sensor(hass: HomeAssistant, default_sensor_config): """Create an AverageSensor with default values.""" - entity = AverageSensor( - hass, - TEST_UNIQUE_ID, - TEST_NAME, - None, - Template("{{ now() }}"), - timedelta(minutes=3), - TEST_ENTITY_IDS, - 2, - None, - ) - entity.hass = hass - return entity + return AverageSensor(hass, default_sensor_config) async def test_valid_check_period_keys(hass: HomeAssistant): @@ -89,19 +100,19 @@ async def test_valid_check_period_keys(hass: HomeAssistant): async def test_invalid_check_period_keys(hass: HomeAssistant): """Test period keys check.""" - with raises(Invalid): + with pytest.raises(Invalid): check_period_keys( { CONF_END: 20, } ) - with raises(Invalid): + with pytest.raises(Invalid): check_period_keys( { CONF_START: 21, } ) - with raises(Invalid): + with pytest.raises(Invalid): check_period_keys( { CONF_START: 22, @@ -127,7 +138,9 @@ async def test_setup_platform(hass: HomeAssistant): assert async_add_entities.called -async def test_entity_initialization(hass: HomeAssistant, default_sensor): +async def test_entity_initialization( + hass: HomeAssistant, default_sensor, default_sensor_config +): """Test sensor initialization.""" expected_attributes = { "available_sources": 0, @@ -145,32 +158,16 @@ async def test_entity_initialization(hass: HomeAssistant, default_sensor): assert default_sensor.icon is None assert default_sensor.extra_state_attributes == expected_attributes - entity = AverageSensor( - hass, - None, - TEST_NAME, - None, - Template("{{ now() }}"), - timedelta(minutes=3), - TEST_ENTITY_IDS, - 2, - None, - ) - + config = default_sensor_config.copy() + config[CONF_UNIQUE_ID] = None + entity = AverageSensor(hass, config) + # assert entity.unique_id is None - entity = AverageSensor( - hass, - "__legacy__", - TEST_NAME, - None, - Template("{{ now() }}"), - timedelta(minutes=3), - TEST_ENTITY_IDS, - 2, - None, - ) - + config = default_sensor_config.copy() + config[CONF_UNIQUE_ID] = "__legacy__" + entity = AverageSensor(hass, config) + # assert entity.unique_id in ( "2ef66732fb7155dce84ad53afe910beba59cfad4", "ca6b83d00637cf7d1e2f5c30b4f4f0402ad2fc53", @@ -429,5 +426,4 @@ async def test_update(default_sensor): # pylint: disable=protected-access async def test__update_period(default_sensor): """Test period updater.""" - # default_sensor._update_period() - # todo; pylint: disable=fixme + # TODO(Limych): ; #noqa: TD003