diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98dbeb4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.py[cod] +.venv/ diff --git a/custom_components/daily_min_max/__init__.py b/custom_components/daily_min_max/__init__.py index 84f195c..26dde90 100644 --- a/custom_components/daily_min_max/__init__.py +++ b/custom_components/daily_min_max/__init__.py @@ -1,6 +1,23 @@ +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import service +from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.typing import ConfigType + DOMAIN = "daily_min_max" PLATFORMS = ["sensor"] +SERVICE_RESET = "reset" + -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Daily Min/Max integration from YAML.""" + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_RESET, + entity_domain=SENSOR_DOMAIN, + schema={}, + func="async_reset", + ) + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) return True diff --git a/custom_components/daily_min_max/manifest.json b/custom_components/daily_min_max/manifest.json index 0395648..abeae42 100644 --- a/custom_components/daily_min_max/manifest.json +++ b/custom_components/daily_min_max/manifest.json @@ -9,5 +9,5 @@ ], "quality_scale": "internal", "iot_class": "local_push", - "version": "1.2.0" -} \ No newline at end of file + "version": "1.2.1" +} diff --git a/custom_components/daily_min_max/sensor.py b/custom_components/daily_min_max/sensor.py index 8e2966f..8c11aa5 100644 --- a/custom_components/daily_min_max/sensor.py +++ b/custom_components/daily_min_max/sensor.py @@ -1,28 +1,24 @@ +import hashlib import logging from datetime import time as dtime -import hashlib +import homeassistant.helpers.config_validation as cv import voluptuous as vol -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - SensorEntity, - RestoreSensor -) +from homeassistant.components.sensor import PLATFORM_SCHEMA, RestoreSensor, SensorEntity from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_TYPE, STATE_UNAVAILABLE, - STATE_UNKNOWN + STATE_UNKNOWN, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import ( async_track_state_change_event, - async_track_time_change + async_track_time_change, ) -from homeassistant.helpers.reload import async_setup_reload_service -from . import DOMAIN, PLATFORMS + +from . import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -57,8 +53,6 @@ ATTR_MAX_VALUE: "max", } -SERVICE_RESET = "reset" - PLATFORM_SCHEMA = vol.All( PLATFORM_SCHEMA.extend( { @@ -107,8 +101,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= reset_time = dtime.fromisoformat(time_str) - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - # Create a stable unique_id for this aggregated sensor so it can be tracked # in the entity registry. Use YAML value if provided, otherwise a short sha1 # of the sorted entity_ids + sensor_type. @@ -129,10 +121,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) async_add_entities([entity]) - hass.services.async_register( - DOMAIN, SERVICE_RESET, entity.async_reset - ) - class DailyMinMaxSensor(RestoreSensor, SensorEntity): _attr_should_poll = False @@ -270,13 +258,13 @@ def _calc_values(self): self.max_entity_id, self.max_value = max_id, max_val @callback - def reset(self, _now): - if self._manual_reset_only: + def reset(self, now): + if self._manual_reset_only and now is not None: return self.min_value = self.max_value = self.last self.min_entity_id = self.max_entity_id = self.last_entity_id = None self.async_write_ha_state() - async def async_reset(self, _call=None): + async def async_reset(self): _LOGGER.debug("Manual reset %s", self._name) self.reset(None) diff --git a/custom_components/daily_min_max/services.yaml b/custom_components/daily_min_max/services.yaml index b86d461..30da88c 100644 --- a/custom_components/daily_min_max/services.yaml +++ b/custom_components/daily_min_max/services.yaml @@ -4,7 +4,7 @@ reload: reset: name: Reset - description: Resets the counter of a daily min max sensor. + description: Resets only the targeted daily min max sensor. target: entity: - domain: sensor \ No newline at end of file + domain: sensor diff --git a/readme.md b/readme.md index 322d52f..9c2c9fa 100644 --- a/readme.md +++ b/readme.md @@ -23,16 +23,21 @@ Place the folder for the custom component `daily_min_max` in your `config/custom time: "03:30:00" - platform: daily_min_max - name: Manually reset Only + name: Manually Reset Only type: min entity_ids: - sensor.fuel_consumption - manual_reset_only: True + manual_reset_only: true ``` -To reset a sensor manually invoke the service e.g. +`manual_reset_only: true` disables scheduled resets but still permits manual +resets through `daily_min_max.reset`. + +To reset a sensor manually, target the entity to reset. Other daily min/max +entities are not changed: + ```yaml -service: daily_min_max.reset +action: daily_min_max.reset target: entity_id: sensor.outdoor_daily_max -``` \ No newline at end of file +``` diff --git a/tests/test_daily_min_max.py b/tests/test_daily_min_max.py new file mode 100644 index 0000000..67ab926 --- /dev/null +++ b/tests/test_daily_min_max.py @@ -0,0 +1,212 @@ +from datetime import UTC, datetime, time +from tempfile import TemporaryDirectory +from types import SimpleNamespace +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock, Mock + +from homeassistant.const import ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.entity_platform import DATA_DOMAIN_PLATFORM_ENTITIES + +from custom_components.daily_min_max import DOMAIN, SERVICE_RESET, async_setup +from custom_components.daily_min_max.sensor import ( + ATTR_LAST, + ATTR_LAST_ENTITY_ID, + ATTR_MAX_ENTITY_ID, + ATTR_MAX_VALUE, + ATTR_MIN_ENTITY_ID, + ATTR_MIN_VALUE, + CONF_ENTITY_IDS, + CONF_MANUAL_RESET_ONLY, + CONF_NAME, + CONF_ROUND_DIGITS, + CONF_TIME, + CONF_TYPE, + DailyMinMaxSensor, + async_setup_platform, +) + + +class DailyMinMaxTestCase(IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.config_dir = TemporaryDirectory() + self.hass = HomeAssistant(self.config_dir.name) + + async def asyncTearDown(self): + self.hass.import_executor.shutdown() + self.config_dir.cleanup() + + def make_sensor( + self, + *, + entity_id="sensor.daily_test", + manual_reset_only=False, + sensor_type="min", + ): + entity = DailyMinMaxSensor( + ["sensor.source"], + entity_id.removeprefix("sensor."), + sensor_type, + 2, + time(0, 0), + manual_reset_only, + entity_id, + ) + entity.hass = self.hass + entity.entity_id = entity_id + entity.async_write_ha_state = Mock() + return entity + + async def test_scheduled_and_manual_reset_matrix(self): + for manual_reset_only, scheduled_should_reset in ( + (False, True), + (True, False), + ): + with self.subTest( + manual_reset_only=manual_reset_only, + reset_type="scheduled", + ): + entity = self.make_sensor( + manual_reset_only=manual_reset_only, + ) + entity.min_value = 1 + entity.max_value = 9 + entity.last = 5 + + entity.reset(datetime.now(UTC)) + + expected = 5 if scheduled_should_reset else 1 + self.assertEqual(entity.min_value, expected) + self.assertEqual(entity.max_value, 5 if scheduled_should_reset else 9) + + for manual_reset_only in (False, True): + with self.subTest( + manual_reset_only=manual_reset_only, + reset_type="manual", + ): + entity = self.make_sensor( + manual_reset_only=manual_reset_only, + ) + entity.min_value = 1 + entity.max_value = 9 + entity.last = 5 + + await entity.async_reset() + + self.assertEqual(entity.min_value, 5) + self.assertEqual(entity.max_value, 5) + + async def test_targeted_service_resets_only_selected_entity(self): + await async_setup(self.hass, {}) + + entity_a = self.make_sensor( + entity_id="sensor.daily_a", + manual_reset_only=True, + ) + entity_b = self.make_sensor( + entity_id="sensor.daily_b", + manual_reset_only=False, + ) + entity_a.min_value, entity_a.max_value, entity_a.last = 1, 10, 6 + entity_b.min_value, entity_b.max_value, entity_b.last = 2, 20, 7 + self.hass.data[DATA_DOMAIN_PLATFORM_ENTITIES] = { + ("sensor", DOMAIN): { + entity_a.entity_id: entity_a, + entity_b.entity_id: entity_b, + } + } + + await self.hass.services.async_call( + DOMAIN, + SERVICE_RESET, + {ATTR_ENTITY_ID: entity_a.entity_id}, + blocking=True, + ) + + self.assertEqual((entity_a.min_value, entity_a.max_value), (6, 6)) + self.assertEqual((entity_b.min_value, entity_b.max_value), (2, 20)) + + async def test_multiple_platform_setups_do_not_replace_reset_service(self): + await async_setup(self.hass, {}) + registered_service = self.hass.services.async_services()[DOMAIN][SERVICE_RESET] + entities = [] + + def add_entities(new_entities): + entities.extend(new_entities) + + for name in ("Daily A", "Daily B"): + await async_setup_platform( + self.hass, + { + CONF_ENTITY_IDS: ["sensor.source"], + CONF_NAME: name, + CONF_TYPE: "min", + CONF_ROUND_DIGITS: 2, + CONF_TIME: "00:00:00", + CONF_MANUAL_RESET_ONLY: False, + }, + add_entities, + ) + + self.assertEqual(len(entities), 2) + self.assertIs( + self.hass.services.async_services()[DOMAIN][SERVICE_RESET], + registered_service, + ) + + async def test_min_and_max_aggregation(self): + entity = self.make_sensor(sensor_type="min") + + for entity_id, value in ( + ("sensor.one", "5"), + ("sensor.two", "3"), + ("sensor.one", "8"), + ): + entity._entity_ids = ["sensor.one", "sensor.two"] + entity._async_sensor_state_listener( + SimpleNamespace( + data={ + "entity_id": entity_id, + "new_state": State( + entity_id, + value, + {ATTR_UNIT_OF_MEASUREMENT: "°C"}, + ), + } + ) + ) + + self.assertEqual(entity.min_value, 3) + self.assertEqual(entity.max_value, 8) + self.assertEqual(entity.last, 8) + self.assertEqual(entity.min_entity_id, "sensor.two") + self.assertEqual(entity.max_entity_id, "sensor.one") + + async def test_restored_state_behavior(self): + entity = self.make_sensor(entity_id="sensor.restored") + restored_attributes = { + ATTR_MIN_VALUE: 2, + ATTR_MIN_ENTITY_ID: "sensor.low", + ATTR_MAX_VALUE: 12, + ATTR_MAX_ENTITY_ID: "sensor.high", + ATTR_LAST: 7, + ATTR_LAST_ENTITY_ID: "sensor.latest", + } + entity.async_get_last_state = AsyncMock( + return_value=State("sensor.restored", "2", restored_attributes) + ) + + await entity.async_added_to_hass() + + self.assertEqual(entity.min_value, 2) + self.assertEqual(entity.min_entity_id, "sensor.low") + self.assertEqual(entity.max_value, 12) + self.assertEqual(entity.max_entity_id, "sensor.high") + self.assertEqual(entity.last, 7) + self.assertEqual(entity.last_entity_id, "sensor.latest") + + +if __name__ == "__main__": + import unittest + + unittest.main()