Skip to content

Commit

Permalink
Merge pull request #99 from danieldotnl/next_reset_dst
Browse files Browse the repository at this point in the history
Fix issue with dertermining end of pattern with dst and add tests
  • Loading branch information
danieldotnl authored Mar 16, 2024
2 parents 78c6127 + 1c7b7d2 commit 93a216c
Show file tree
Hide file tree
Showing 2 changed files with 151 additions and 3 deletions.
7 changes: 6 additions & 1 deletion custom_components/measureit/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
147 changes: 145 additions & 2 deletions tests/unit/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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()



0 comments on commit 93a216c

Please sign in to comment.