diff --git a/custom_components/measureit/sensor.py b/custom_components/measureit/sensor.py index 7353470..28a7558 100644 --- a/custom_components/measureit/sensor.py +++ b/custom_components/measureit/sensor.py @@ -9,6 +9,7 @@ from typing import Any from croniter import croniter +from dateutil import tz from homeassistant.components.sensor import (SensorDeviceClass, SensorEntity, SensorStateClass) from homeassistant.config_entries import ConfigEntry @@ -348,7 +349,11 @@ def schedule_next_reset(self, next_reset: datetime | None = None): return elif not next_reset: if self._reset_pattern not in [None, "noreset", "forever", "none", "session"]: - next_reset = croniter(self._reset_pattern, tznow).get_next(datetime) + # we have a known issue with croniter that does not correctly determine the end of the month/week reset when DST is involved + # https://github.com/kiorky/croniter/issues/1 + next_reset = dt_util.as_local(croniter(self._reset_pattern, tznow.replace(tzinfo=None)).get_next(datetime)) + if not tz.datetime_exists(next_reset): + next_reset = dt_util.as_local(croniter(self._reset_pattern, next_reset.replace(tzinfo=None)).get_next(datetime)) else: self._next_reset = None return diff --git a/tests/unit/test_sensor.py b/tests/unit/test_sensor.py index b04a70d..ab23f4c 100644 --- a/tests/unit/test_sensor.py +++ b/tests/unit/test_sensor.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta from unittest import mock from unittest.mock import AsyncMock, MagicMock +from zoneinfo import ZoneInfo import pytest from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass @@ -47,6 +48,30 @@ def fixture_day_sensor(hass: HomeAssistant, test_now: datetime): yield sensor sensor.unsub_reset_listener() +@pytest.fixture(name="month_sensor") +def fixture_month_sensor(hass: HomeAssistant, test_now: datetime): + """Fixture for creating a MeasureIt sensor which resets monthly.""" + mockMeter = MagicMock() + mockMeter.measured_value = 0 + with mock.patch( + "homeassistant.helpers.condition.dt_util.now", + return_value=test_now, + ): + sensor = MeasureItSensor( + hass, + MagicMock(), + mockMeter, + "test_sensor_month", + "test_sensor_month", + PREDEFINED_PERIODS["month"], + lambda x: x, + SensorStateClass.TOTAL, + SensorDeviceClass.DURATION, + "h", + ) + sensor.entity_id = "sensor.test_sensor_month" + yield sensor + sensor.unsub_reset_listener() @pytest.fixture(name="real_meter_sensor") def fixture_real_meter_sensor(hass: HomeAssistant, test_now: datetime): @@ -139,7 +164,6 @@ def test_day_sensor_init(day_sensor: MeasureItSensor, test_now: datetime): assert day_sensor.state_class == SensorStateClass.TOTAL assert day_sensor.device_class == SensorDeviceClass.DURATION - def test_none_sensor_init(none_sensor: MeasureItSensor, test_now: datetime): """Test sensor initialization.""" assert none_sensor.native_value == 0 @@ -169,7 +193,6 @@ def test_sensor_state_on_condition_timewindow_change( assert sensor.sensor_state == SensorState.WAITING_FOR_TIME_WINDOW assert sensor.meter.measuring is False - def test_scheduled_reset_in_past(day_sensor: MeasureItSensor, test_now: datetime): """Test sensor reset when scheduled in past.""" with mock.patch( @@ -390,6 +413,12 @@ async def test_added_to_hass(day_sensor: MeasureItSensor, test_now: datetime): hour=0, tzinfo=dt_util.DEFAULT_TIME_ZONE ) +async def test_added_to_hass_with_month_period(month_sensor: MeasureItSensor, test_now: datetime): + """Test sensor added to hass.""" + await month_sensor.async_added_to_hass() + assert month_sensor._coordinator.async_register_sensor.call_count == 1 + assert month_sensor._next_reset == datetime(2025, 2, 1, 0, 0, 0, tzinfo=dt_util.DEFAULT_TIME_ZONE) + assert month_sensor.extra_state_attributes["sensor_next_reset"] == "2025-02-01T00:00:00-08:00" async def test_added_to_hass_with_restore(restore_sensor: MeasureItSensor): """Test sensor added to hass.""" @@ -421,3 +450,117 @@ def test_extra_restore_state_data_property(day_sensor: MeasureItSensor): day_sensor.on_condition_template_change(False) stored_data = day_sensor.extra_restore_state_data assert stored_data.condition_active is False + +@pytest.mark.parametrize("input,expected,tz,cron", + [ + ( + datetime(2024, 2, 2, 4, 0, tzinfo=ZoneInfo("America/Los_Angeles")), + datetime(2024, 3, 1, 0, 0, tzinfo=ZoneInfo("America/Los_Angeles")), + ZoneInfo("America/Los_Angeles"), + PREDEFINED_PERIODS["month"] + ), + ( + datetime(2024, 3, 2, 4, 0, tzinfo=ZoneInfo("America/Los_Angeles")), + datetime(2024, 4, 1, 0, 0, tzinfo=ZoneInfo("America/Los_Angeles")), + ZoneInfo("America/Los_Angeles"), + PREDEFINED_PERIODS["month"] + ), # start DST + ( + datetime(2024, 11, 2, 4, 0, tzinfo=ZoneInfo("America/Los_Angeles")), + datetime(2024, 12, 1, 0, 0, tzinfo=ZoneInfo("America/Los_Angeles")), + ZoneInfo("America/Los_Angeles"), + PREDEFINED_PERIODS["month"] + ), # end DST + ( + datetime(2024, 2, 2, 4, 0, tzinfo=ZoneInfo("Europe/Brussels")), + datetime(2024, 3, 1, 0, 0, tzinfo=ZoneInfo("Europe/Brussels")), + ZoneInfo("Europe/Brussels"), + PREDEFINED_PERIODS["month"] + ), + ( + datetime(2024, 3, 2, 4, 0, tzinfo=ZoneInfo("Europe/Brussels")), + datetime(2024, 4, 1, 0, 0, tzinfo=ZoneInfo("Europe/Brussels")), + ZoneInfo("Europe/Brussels"), + PREDEFINED_PERIODS["month"] + ), # start DST + ( + datetime(2024, 3, 10, 1, 0, tzinfo=ZoneInfo("America/Los_Angeles")), + datetime(2024, 3, 10, 3, 0, tzinfo=ZoneInfo("America/Los_Angeles")), + ZoneInfo("America/Los_Angeles"), + PREDEFINED_PERIODS["hour"] + ), + ( + datetime(2024, 11, 3, 1, 0, tzinfo=ZoneInfo("America/Los_Angeles")), + datetime(2024, 11, 3, 2, 0, tzinfo=ZoneInfo("America/Los_Angeles")), + ZoneInfo("America/Los_Angeles"), + PREDEFINED_PERIODS["hour"] + ), + ( + datetime(2024, 11, 3, 2, 0, tzinfo=ZoneInfo("America/Los_Angeles")), + datetime(2024, 11, 3, 3, 0, tzinfo=ZoneInfo("America/Los_Angeles")), + ZoneInfo("America/Los_Angeles"), + PREDEFINED_PERIODS["hour"] + ), + ( + datetime(2024, 2, 2, 4, 0, tzinfo=ZoneInfo("Europe/Brussels")), + datetime(2024, 2, 2, 5, 0, tzinfo=ZoneInfo("Europe/Brussels")), + ZoneInfo("Europe/Brussels"), + PREDEFINED_PERIODS["hour"] + ), + ( + datetime(2024, 3, 31, 1, 0, tzinfo=ZoneInfo("Europe/Brussels")), + datetime(2024, 3, 31, 3, 0, tzinfo=ZoneInfo("Europe/Brussels")), + ZoneInfo("Europe/Brussels"), + PREDEFINED_PERIODS["hour"] + ), + ( + datetime(2024, 3, 31, 3, 0, tzinfo=ZoneInfo("Europe/Brussels")), + datetime(2024, 3, 31, 4, 0, tzinfo=ZoneInfo("Europe/Brussels")), + ZoneInfo("Europe/Brussels"), + PREDEFINED_PERIODS["hour"] + ), + ( + datetime(2024, 10, 26, 1, 0, tzinfo=ZoneInfo("Europe/Brussels")), + datetime(2024, 10, 26, 2, 0, tzinfo=ZoneInfo("Europe/Brussels")), + ZoneInfo("Europe/Brussels"), + PREDEFINED_PERIODS["hour"] + ), + ( + datetime(2024, 10, 26, 2, 0, tzinfo=ZoneInfo("Europe/Brussels")), + datetime(2024, 10, 26, 3, 0, tzinfo=ZoneInfo("Europe/Brussels")), + ZoneInfo("Europe/Brussels"), + PREDEFINED_PERIODS["hour"] + ), + ( + datetime(2024, 10, 26, 3, 0, tzinfo=ZoneInfo("Europe/Brussels")), + datetime(2024, 10, 26, 4, 0, tzinfo=ZoneInfo("Europe/Brussels")), + ZoneInfo("Europe/Brussels"), + PREDEFINED_PERIODS["hour"] + ), + ] + ) +def test_next_reset_with_dst(hass: HomeAssistant, input, expected, tz, cron): + """Test next reset for hour period with DST.""" + with mock.patch( + "homeassistant.helpers.condition.dt_util.now", + return_value=input, + ): + dt_util.DEFAULT_TIME_ZONE = tz + sensor = MeasureItSensor( + hass, + MagicMock(), + CounterMeter(), + "test_sensor_hour", + "test_sensor_hour", + cron, + lambda x: x, + SensorStateClass.TOTAL, + ) + assert sensor._next_reset is None + + sensor.schedule_next_reset() + assert sensor._next_reset == expected + sensor.unsub_reset_listener() + + +