diff --git a/.devcontainer-lock.json b/.devcontainer-lock.json new file mode 100644 index 0000000..c33ec62 --- /dev/null +++ b/.devcontainer-lock.json @@ -0,0 +1,19 @@ +{ + "features": { + "ghcr.io/devcontainers-extra/features/apt-packages:1": { + "version": "1.0.6", + "resolved": "ghcr.io/devcontainers-extra/features/apt-packages@sha256:55c54412112da81b9381e470cdbbe55278564950d1ff536ce925b1e8e096babd", + "integrity": "sha256:55c54412112da81b9381e470cdbbe55278564950d1ff536ce925b1e8e096babd" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "1.3.5", + "resolved": "ghcr.io/devcontainers/features/git@sha256:27905dc196c01f77d6ba8709cb82eeaf330b3b108772e2f02d1cd0d826de1251", + "integrity": "sha256:27905dc196c01f77d6ba8709cb82eeaf330b3b108772e2f02d1cd0d826de1251" + }, + "ghcr.io/devcontainers/features/node:2": { + "version": "2.0.0", + "resolved": "ghcr.io/devcontainers/features/node@sha256:fedd4c11f7adfb64283b578dddc7da906728daa25fa293351c9d913231acf12f", + "integrity": "sha256:fedd4c11f7adfb64283b578dddc7da906728daa25fa293351c9d913231acf12f" + } + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json index ca55ab9..2ffeb44 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,14 @@ { "git.autofetch": true, "python.createEnvironment.trigger": "off", + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", + "python.analysis.extraPaths": [ + "${workspaceFolder}", + "${workspaceFolder}/custom_components" + ], + "python.analysis.diagnosticSeverityOverrides": { + "reportUnsortedImports": "none" + }, "editor.defaultFormatter": "charliermarsh.ruff", "editor.formatOnSave": true, "editor.codeActionsOnSave": { diff --git a/custom_components/periodic_min_max/__init__.py b/custom_components/periodic_min_max/__init__.py index 8bfb5c7..729121a 100644 --- a/custom_components/periodic_min_max/__init__.py +++ b/custom_components/periodic_min_max/__init__.py @@ -9,24 +9,24 @@ import voluptuous as vol from awesomeversion.awesomeversion import AwesomeVersion -from homeassistant.core import HomeAssistant, callback +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ENTITY_ID, __version__ as HA_VERSION, # noqa: N812 ) -from homeassistant.helpers import entity_registry as er, config_validation as cv -from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.typing import ConfigType +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.helper_integration import ( async_handle_source_entity_changes, async_remove_helper_config_entry_from_source_device, ) +from homeassistant.helpers.typing import ConfigType from .const import ( DOMAIN, LOGGER, - PLATFORMS, MIN_HA_VERSION, + PLATFORMS, ) from .services import async_setup_services diff --git a/custom_components/periodic_min_max/config_flow.py b/custom_components/periodic_min_max/config_flow.py index 7ac1b1f..1c480d3 100644 --- a/custom_components/periodic_min_max/config_flow.py +++ b/custom_components/periodic_min_max/config_flow.py @@ -2,22 +2,22 @@ from __future__ import annotations -from typing import Any, cast from collections.abc import Mapping +from typing import Any, cast import voluptuous as vol -from homeassistant.const import CONF_TYPE, CONF_ENTITY_ID -from homeassistant.helpers import selector +from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN +from homeassistant.const import CONF_ENTITY_ID, CONF_TYPE +from homeassistant.helpers import selector from homeassistant.helpers.schema_config_entry_flow import ( - SchemaFlowFormStep, SchemaConfigFlowHandler, + SchemaFlowFormStep, ) -from .const import DOMAIN +from .const import CONF_EQUAL_UPDATES, DOMAIN _STATISTIC_MEASURES = ["min", "max"] @@ -35,6 +35,7 @@ options=_STATISTIC_MEASURES, translation_key=CONF_TYPE ), ), + vol.Required(CONF_EQUAL_UPDATES, default=False): selector.BooleanSelector(), } ) diff --git a/custom_components/periodic_min_max/const.py b/custom_components/periodic_min_max/const.py index 8912fd0..7128093 100644 --- a/custom_components/periodic_min_max/const.py +++ b/custom_components/periodic_min_max/const.py @@ -13,4 +13,6 @@ PLATFORMS = [Platform.SENSOR] +CONF_EQUAL_UPDATES = "equal_updates" + ATTR_LAST_MODIFIED = "last_modified" diff --git a/custom_components/periodic_min_max/sensor.py b/custom_components/periodic_min_max/sensor.py index 2ca6eac..0500fd4 100644 --- a/custom_components/periodic_min_max/sensor.py +++ b/custom_components/periodic_min_max/sensor.py @@ -2,39 +2,36 @@ from __future__ import annotations -from typing import Any from datetime import datetime +from typing import Any import voluptuous as vol -from homeassistant.core import Event, HomeAssistant, EventStateChangedData, callback -from homeassistant.util import dt as dt_util +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_TYPE, - STATE_UNKNOWN, + ATTR_UNIT_OF_MEASUREMENT, CONF_ENTITY_ID, + CONF_TYPE, STATE_UNAVAILABLE, - ATTR_UNIT_OF_MEASUREMENT, + STATE_UNKNOWN, ) +from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import ( - async_track_state_change_event, async_track_entity_registry_updated_event, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.typing import StateType -from homeassistant.components.sensor import ( - SensorEntity, - SensorStateClass, - SensorDeviceClass, + async_track_state_change_event, ) from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util -from .const import ( - LOGGER, - ATTR_LAST_MODIFIED, -) +from .const import ATTR_LAST_MODIFIED, CONF_EQUAL_UPDATES, LOGGER ATTR_MIN_VALUE = "min_value" ATTR_MAX_VALUE = "max_value" @@ -91,6 +88,7 @@ async def async_setup_entry( device_id = source_entity.device_id if source_entity else None sensor_type = config_entry.options[CONF_TYPE] + equal_updates = config_entry.options.get(CONF_EQUAL_UPDATES, False) async def async_registry_updated( event: Event[er.EventEntityRegistryUpdatedData], @@ -138,6 +136,7 @@ async def async_registry_updated( source_entity_id, config_entry.title, sensor_type, + equal_updates, config_entry.entry_id, ) ] @@ -155,18 +154,20 @@ class PeriodicMinMaxSensor(SensorEntity, RestoreEntity): _state_had_real_change = False _attr_last_modified: str = dt_util.utcnow().isoformat() - def __init__( + def __init__( # noqa: PLR0913 self, hass: HomeAssistant, source_entity_id: str, name: str | None, sensor_type: str, + equal_updates: bool, unique_id: str | None, ) -> None: """Initialize the min/max sensor.""" self._attr_unique_id = unique_id self._source_entity_id = source_entity_id self._sensor_type = sensor_type + self._equal_updates = equal_updates if name: self._attr_name = name @@ -325,18 +326,23 @@ def _calc_values(self) -> None: """Calculate the values.""" self._state_had_real_change = False - """Calculate min value, honoring unknown states.""" + if self._state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: + return + if self._sensor_attr == ATTR_MIN_VALUE: - if self._state not in [STATE_UNKNOWN, STATE_UNAVAILABLE] and ( - self.min_value is None or self.min_value > self._state + if self.min_value is None or ( + self.min_value >= self._state + if self._equal_updates + else self.min_value > self._state ): self.min_value = self._state self._state_had_real_change = True - """Calculate max value, honoring unknown states.""" if self._sensor_attr == ATTR_MAX_VALUE: - if self._state not in [STATE_UNKNOWN, STATE_UNAVAILABLE] and ( - self.max_value is None or self.max_value < self._state + if self.max_value is None or ( + self.max_value <= self._state + if self._equal_updates + else self.max_value < self._state ): self.max_value = self._state self._state_had_real_change = True diff --git a/custom_components/periodic_min_max/services.py b/custom_components/periodic_min_max/services.py index ad1c21a..e0242d0 100644 --- a/custom_components/periodic_min_max/services.py +++ b/custom_components/periodic_min_max/services.py @@ -2,9 +2,9 @@ from __future__ import annotations +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import service -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from .const import DOMAIN diff --git a/custom_components/periodic_min_max/translations/de.json b/custom_components/periodic_min_max/translations/de.json index 86be593..e3155d3 100644 --- a/custom_components/periodic_min_max/translations/de.json +++ b/custom_components/periodic_min_max/translations/de.json @@ -6,8 +6,14 @@ "description": "Create a sensor that is the minimum or maximum of another sensor, resetttable via an action.", "data": { "entity_id": "Input entity", + "equal_updates": "Update on equal values", "name": "Name", - "type": "Statistikmerkmal" + "type": "Statistic characteristic" + }, + "data_description": { + "entity_id": "The entity that the sensor will track. The sensor will calculate the minimum or maximum value of this entity until reset via the `reset` action.", + "equal_updates": "If enabled, the sensor will update the `last_modified` attribute even if the input entity value is the same as the current min/max.", + "type": "Whether the sensor should track the minimum or maximum value of the input entity" } } } @@ -17,7 +23,13 @@ "init": { "data": { "entity_id": "Input entity", - "type": "Statistikmerkmal" + "equal_updates": "Update on equal values", + "type": "Statistic characteristic" + }, + "data_description": { + "entity_id": "The entity that the sensor will track. The sensor will calculate the minimum or maximum value of this entity until reset via the `reset` action.", + "equal_updates": "If enabled, the sensor will update the `last_modified` attribute even if the input entity value is the same as the current min/max.", + "type": "Whether the sensor should track the minimum or maximum value of the input entity" } } } @@ -32,11 +44,11 @@ }, "services": { "reset": { - "name": "Zurücksetzen", + "name": "Reset", "description": "Reset a Periodic Min/Max sensor to the current input entity value.", "fields": { "entity_id": { - "name": "Entität", + "name": "Entity", "description": "Select the Periodic Min/Max sensor." } } diff --git a/custom_components/periodic_min_max/translations/en.json b/custom_components/periodic_min_max/translations/en.json index 9d6cccf..e3155d3 100644 --- a/custom_components/periodic_min_max/translations/en.json +++ b/custom_components/periodic_min_max/translations/en.json @@ -6,8 +6,14 @@ "description": "Create a sensor that is the minimum or maximum of another sensor, resetttable via an action.", "data": { "entity_id": "Input entity", + "equal_updates": "Update on equal values", "name": "Name", "type": "Statistic characteristic" + }, + "data_description": { + "entity_id": "The entity that the sensor will track. The sensor will calculate the minimum or maximum value of this entity until reset via the `reset` action.", + "equal_updates": "If enabled, the sensor will update the `last_modified` attribute even if the input entity value is the same as the current min/max.", + "type": "Whether the sensor should track the minimum or maximum value of the input entity" } } } @@ -17,7 +23,13 @@ "init": { "data": { "entity_id": "Input entity", + "equal_updates": "Update on equal values", "type": "Statistic characteristic" + }, + "data_description": { + "entity_id": "The entity that the sensor will track. The sensor will calculate the minimum or maximum value of this entity until reset via the `reset` action.", + "equal_updates": "If enabled, the sensor will update the `last_modified` attribute even if the input entity value is the same as the current min/max.", + "type": "Whether the sensor should track the minimum or maximum value of the input entity" } } } diff --git a/custom_components/periodic_min_max/translations/fr.json b/custom_components/periodic_min_max/translations/fr.json index 4bfa4e8..e3155d3 100644 --- a/custom_components/periodic_min_max/translations/fr.json +++ b/custom_components/periodic_min_max/translations/fr.json @@ -6,8 +6,14 @@ "description": "Create a sensor that is the minimum or maximum of another sensor, resetttable via an action.", "data": { "entity_id": "Input entity", - "name": "Nom", - "type": "Indicateur statistique" + "equal_updates": "Update on equal values", + "name": "Name", + "type": "Statistic characteristic" + }, + "data_description": { + "entity_id": "The entity that the sensor will track. The sensor will calculate the minimum or maximum value of this entity until reset via the `reset` action.", + "equal_updates": "If enabled, the sensor will update the `last_modified` attribute even if the input entity value is the same as the current min/max.", + "type": "Whether the sensor should track the minimum or maximum value of the input entity" } } } @@ -17,7 +23,13 @@ "init": { "data": { "entity_id": "Input entity", - "type": "Indicateur statistique" + "equal_updates": "Update on equal values", + "type": "Statistic characteristic" + }, + "data_description": { + "entity_id": "The entity that the sensor will track. The sensor will calculate the minimum or maximum value of this entity until reset via the `reset` action.", + "equal_updates": "If enabled, the sensor will update the `last_modified` attribute even if the input entity value is the same as the current min/max.", + "type": "Whether the sensor should track the minimum or maximum value of the input entity" } } } @@ -32,11 +44,11 @@ }, "services": { "reset": { - "name": "Réinitialiser", + "name": "Reset", "description": "Reset a Periodic Min/Max sensor to the current input entity value.", "fields": { "entity_id": { - "name": "Entité", + "name": "Entity", "description": "Select the Periodic Min/Max sensor." } } diff --git a/pyproject.toml b/pyproject.toml index e8bd8cb..d780522 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,7 +105,7 @@ ignore = [ max-complexity = 25 [tool.ruff.lint.isort] -length-sort = true +length-sort = false section-order = [ "future", "standard-library", diff --git a/tests/conftest.py b/tests/conftest.py index 7b85084..cd7a02b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,21 +2,21 @@ from __future__ import annotations +from collections.abc import Generator from typing import Any from unittest.mock import AsyncMock, patch -from collections.abc import Generator import pytest from custom_components.periodic_min_max.const import DOMAIN from pytest_homeassistant_custom_component.common import MockConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( + CONF_ENTITY_ID, CONF_NAME, CONF_TYPE, - CONF_ENTITY_ID, ) -from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant from .test_sensor import VALUES_NUMERIC diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 78d7664..fccba31 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -4,11 +4,11 @@ from unittest.mock import AsyncMock -from custom_components.periodic_min_max.const import DOMAIN +from custom_components.periodic_min_max.const import CONF_EQUAL_UPDATES, DOMAIN from homeassistant import config_entries +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant -from homeassistant.const import CONF_NAME, CONF_TYPE, CONF_ENTITY_ID from homeassistant.data_entry_flow import FlowResultType from .const import DEFAULT_NAME @@ -39,6 +39,7 @@ async def test_form_sensor(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> CONF_NAME: DEFAULT_NAME, CONF_ENTITY_ID: "sensor.test_periodic_min_max", CONF_TYPE: "max", + CONF_EQUAL_UPDATES: False, } assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/test_init.py b/tests/test_init.py index a5f0093..386c352 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -5,9 +5,9 @@ from custom_components.periodic_min_max.const import DOMAIN from pytest_homeassistant_custom_component.common import MockConfigEntry +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.config_entries import ConfigEntryState from .const import DEFAULT_NAME diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 6b93c22..6bce1f8 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -1,11 +1,16 @@ """The test for the periodic_min_max sensor platform.""" -from custom_components.periodic_min_max.const import DOMAIN +import pytest +from custom_components.periodic_min_max.const import ( + ATTR_LAST_MODIFIED, + CONF_EQUAL_UPDATES, + DOMAIN, +) from pytest_homeassistant_custom_component.common import MockConfigEntry +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass VALUES_NUMERIC = [17, 20, 15.2, 5, 3.8, 9.2, 6.7, 14, 6] VALUES_ERROR = [17, "string", 15.3] @@ -120,3 +125,95 @@ async def test_value_error( state = hass.states.get(periodic_min_max_entity_id) assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + +async def test_max_sensor_equal_updates_with_initial_none( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test equal updates when max sensor has no prior value.""" + sensor_entity_entry = entity_registry.async_get_or_create( + "sensor", "test_equal", "unique_equal", suggested_object_id="test_equal" + ) + assert sensor_entity_entry.entity_id == "sensor.test_equal" + + periodic_min_max_entity_id = "sensor.my_periodic_min_max" + + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My periodic min max", + "entity_id": "sensor.test_equal", + "type": "max", + CONF_EQUAL_UPDATES: True, + }, + title="My periodic_min_max", + ) + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + hass.states.async_set(sensor_entity_entry.entity_id, 3.0) + await hass.async_block_till_done() + + state = hass.states.get(periodic_min_max_entity_id) + assert state is not None + assert state.state == "3.0" + + +@pytest.mark.parametrize( + ("equal_updates", "last_modified_should_change"), + [(False, False), (True, True)], +) +async def test_equal_updates_controls_last_modified_on_equal_value( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + freezer, + equal_updates: bool, + last_modified_should_change: bool, +) -> None: + """Test if equal values update last_modified based on equal_updates option.""" + sensor_entity_entry = entity_registry.async_get_or_create( + "sensor", + "test_equal_updates", + "unique_equal_updates", + suggested_object_id="test_equal_updates", + ) + assert sensor_entity_entry.entity_id == "sensor.test_equal_updates" + + periodic_min_max_entity_id = "sensor.my_periodic_min_max" + + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My periodic min max", + "entity_id": "sensor.test_equal_updates", + "type": "max", + CONF_EQUAL_UPDATES: equal_updates, + }, + title="My periodic_min_max", + ) + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + freezer.move_to("2026-01-01 00:00:00+00:00") + hass.states.async_set(sensor_entity_entry.entity_id, 7.0, {"source": "one"}) + await hass.async_block_till_done() + + first_state = hass.states.get(periodic_min_max_entity_id) + assert first_state is not None + first_last_modified = first_state.attributes[ATTR_LAST_MODIFIED] + + freezer.move_to("2026-01-01 00:00:01+00:00") + hass.states.async_set(sensor_entity_entry.entity_id, 7.0, {"source": "two"}) + await hass.async_block_till_done() + + second_state = hass.states.get(periodic_min_max_entity_id) + assert second_state is not None + second_last_modified = second_state.attributes[ATTR_LAST_MODIFIED] + + assert (first_last_modified != second_last_modified) is last_modified_should_change diff --git a/tests/test_services.py b/tests/test_services.py index 90331e6..e8d307f 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -5,8 +5,8 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component from .test_sensor import LAST_VALUE