diff --git a/custom_components/daily_schedule/binary_sensor.py b/custom_components/daily_schedule/binary_sensor.py index ead840e..df4c9b5 100644 --- a/custom_components/daily_schedule/binary_sensor.py +++ b/custom_components/daily_schedule/binary_sensor.py @@ -14,7 +14,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import ATTR_NEXT_TOGGLE, CONF_FROM, CONF_SCHEDULE, CONF_TO, SERVICE_SET +from .const import ( + ATTR_NEXT_TOGGLE, + CONF_DISABLED, + CONF_FROM, + CONF_SCHEDULE, + CONF_TO, + SERVICE_SET, +) from .schedule import Schedule @@ -27,6 +34,7 @@ def remove_micros_and_tz(time: datetime.time) -> str: { vol.Required(CONF_FROM): vol.All(cv.time, remove_micros_and_tz), vol.Required(CONF_TO): vol.All(cv.time, remove_micros_and_tz), + vol.Optional(CONF_DISABLED): cv.boolean, }, extra=vol.ALLOW_EXTRA, ) diff --git a/custom_components/daily_schedule/const.py b/custom_components/daily_schedule/const.py index fc276a2..6a38276 100644 --- a/custom_components/daily_schedule/const.py +++ b/custom_components/daily_schedule/const.py @@ -5,6 +5,7 @@ DOMAIN: Final = "daily_schedule" LOGGER = logging.getLogger(__package__) +CONF_DISABLED = "disabled" CONF_FROM: Final = "from" CONF_TO: Final = "to" CONF_SCHEDULE: Final = "schedule" diff --git a/custom_components/daily_schedule/schedule.py b/custom_components/daily_schedule/schedule.py index cb38fd2..ba8fefc 100644 --- a/custom_components/daily_schedule/schedule.py +++ b/custom_components/daily_schedule/schedule.py @@ -3,19 +3,28 @@ import datetime -from .const import CONF_FROM, CONF_TO +from .const import CONF_DISABLED, CONF_FROM, CONF_TO class TimeRange: """Time range with start and end (since "from" is a reserved word).""" - def __init__(self, start: str, end: str) -> None: + def __init__(self, start: str, end: str, disabled: bool) -> None: """Initialize the object.""" self.start: datetime.time = datetime.time.fromisoformat(start) self.end: datetime.time = datetime.time.fromisoformat(end) + self.disabled = disabled + + @property + def enabled(self) -> bool: + """Return if time range is enabled.""" + return not self.disabled def containing(self, time: datetime.time) -> bool: """Check if the time is inside the range.""" + if self.disabled: + return False + # If the range crosses the day boundary. if self.end <= self.start: return self.start <= time or time < self.end @@ -25,8 +34,11 @@ def containing(self, time: datetime.time) -> bool: def to_dict(self) -> dict[str, str]: """Serialize the object as a dict.""" return { - CONF_FROM: self.start.isoformat(), - CONF_TO: self.end.isoformat(), + **{ + CONF_FROM: self.start.isoformat(), + CONF_TO: self.end.isoformat(), + }, + **({CONF_DISABLED: True} if self.disabled else {}), } def to_str(self) -> str: @@ -40,17 +52,24 @@ class Schedule: def __init__(self, schedule: list[dict[str, str]]) -> None: """Create a list of TimeRanges representing the schedule.""" self._schedule = [ - TimeRange(time_range[CONF_FROM], time_range[CONF_TO]) + TimeRange( + time_range[CONF_FROM], + time_range[CONF_TO], + time_range.get(CONF_DISABLED, False), + ) for time_range in schedule ] if not self._schedule: return self._schedule.sort(key=lambda time_range: time_range.start) self._validate() - self._to_on = [time_range.start for time_range in self._schedule] + self._to_on = [ + time_range.start for time_range in self._schedule if time_range.enabled + ] # Remove "on to on" transitions of adjusted time ranges (as state doesn't cahnge to off). self._to_off = sorted( - {time_range.end for time_range in self._schedule} - set(self._to_on) + {time_range.end for time_range in self._schedule if time_range.enabled} + - set(self._to_on) ) def _validate(self) -> None: @@ -61,7 +80,6 @@ def _validate(self) -> None: # Check all except the last time range of the schedule. for i in range(len(self._schedule) - 1): - # The end time should be greater than the start time. if not self._schedule[i].end > self._schedule[i].start: raise ValueError( @@ -101,10 +119,6 @@ def to_list(self) -> list[dict[str, str]]: """Serialize the object as a list.""" return [time_range.to_dict() for time_range in self._schedule] - def to_str(self) -> str: - """Serialize the object as a string.""" - return ", ".join([time_range.to_str() for time_range in self._schedule]) - def next_update(self, date: datetime.datetime) -> datetime.datetime | None: """Schedule a timer for the point when the state should be changed.""" if not self._schedule: diff --git a/tests/test_schedule.py b/tests/test_schedule.py index 5b09a2d..bb7e3a9 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -5,19 +5,20 @@ import pytest -from custom_components.daily_schedule.const import CONF_TO, CONF_FROM +from custom_components.daily_schedule.const import CONF_DISABLED, CONF_TO, CONF_FROM from custom_components.daily_schedule.schedule import Schedule, TimeRange @pytest.mark.parametrize( - ["start", "end", "time", "result"], + ["start", "end", "time", "disabled", "result"], [ - ("05:00", "10:00", "05:00", True), - ("05:00", "10:00", "10:00", False), - ("22:00", "05:00", "23:00", True), - ("22:00", "05:00", "04:00", True), - ("22:00", "05:00", "12:00", False), - ("00:00", "00:00", "00:00", True), + ("05:00", "10:00", "05:00", False, True), + ("05:00", "10:00", "10:00", False, False), + ("22:00", "05:00", "23:00", False, True), + ("22:00", "05:00", "04:00", False, True), + ("22:00", "05:00", "12:00", False, False), + ("00:00", "00:00", "00:00", False, True), + ("00:00", "00:00", "00:00", True, False), ], ids=[ "contained", @@ -26,11 +27,15 @@ "cross day morning", "cross day not contained", "entire day", + "entire day diabled", ], ) -def test_time_range(start: str, end: str, time: str, result: bool): +def test_time_range(start: str, end: str, time: str, disabled: bool, result: bool): """Test for TimeRange class.""" - assert TimeRange(start, end).containing(datetime.time.fromisoformat(time)) is result + assert ( + TimeRange(start, end, disabled).containing(datetime.time.fromisoformat(time)) + is result + ) @pytest.mark.parametrize( @@ -39,7 +44,7 @@ def test_time_range(start: str, end: str, time: str, result: bool): ], [ ({CONF_FROM: "05:00:00", CONF_TO: "10:00:00"},), - ({CONF_FROM: "10:00:00", CONF_TO: "05:00:00"},), + ({CONF_FROM: "10:00:00", CONF_TO: "05:00:00", CONF_DISABLED: True},), ({CONF_FROM: "05:00:00", CONF_TO: "05:00:00"},), ], ids=[ @@ -50,7 +55,12 @@ def test_time_range(start: str, end: str, time: str, result: bool): ) def test_time_range_to_dict(param: dict[str, str]): """Test TimeRange to_dict.""" - assert TimeRange(param[CONF_FROM], param[CONF_TO]).to_dict() == param + assert ( + TimeRange( + param[CONF_FROM], param[CONF_TO], param.get(CONF_DISABLED, False) + ).to_dict() + == param + ) @pytest.mark.parametrize( @@ -67,12 +77,14 @@ def test_time_range_to_dict(param: dict[str, str]): "23:00", True, ), + ([{CONF_FROM: "00:00", CONF_TO: "00:00", CONF_DISABLED: True}], "12:00", False), ], ids=[ "empty", "contained", "not contained", "2 ranges contained", + "disabled range", ], ) def test_schedule_containing(schedule: list[dict[str, str]], time: str, result: bool): @@ -92,6 +104,7 @@ def test_schedule_containing(schedule: list[dict[str, str]], time: str, result: { CONF_FROM: "07:08:09", CONF_TO: "07:08:09", + CONF_DISABLED: True, }, { CONF_FROM: "10:11:12", @@ -122,6 +135,7 @@ def test_schedule_containing(schedule: list[dict[str, str]], time: str, result: { CONF_FROM: "01:02:03", CONF_TO: "04:05:06", + CONF_DISABLED: True, }, ], "overlap", @@ -131,6 +145,7 @@ def test_schedule_containing(schedule: list[dict[str, str]], time: str, result: { CONF_FROM: "07:08:09", CONF_TO: "01:02:04", + CONF_DISABLED: True, }, { CONF_FROM: "01:02:03", @@ -165,6 +180,7 @@ def test_invalid(schedule: list[dict[str, str]], reason: str): { CONF_FROM: "03:00:00", CONF_TO: "04:00:00", + CONF_DISABLED: True, }, { CONF_FROM: "01:00:00", @@ -182,37 +198,16 @@ def test_to_list(schedule: list[dict[str, str]]) -> None: assert str_list == schedule -def test_to_str() -> None: - """Test schedule to string function.""" - schedule = Schedule( - [ - { - CONF_FROM: "03:00:00", - CONF_TO: "04:00:00", - }, - { - CONF_FROM: "01:00:00", - CONF_TO: "02:00:00", - }, - ] - ) - assert schedule.to_str() == ", ".join( - [ - f"{time_period[CONF_FROM]} - {time_period[CONF_TO]}" - for time_period in schedule.to_list() - ] - ) - - @pytest.mark.parametrize( ["schedule", "next_update_sec_offset"], [ - ([(-5, 5)], 5), - ([(-10, -5)], datetime.timedelta(days=1).total_seconds() - 10), - ([(5, 10)], 5), - ([(0, 0)], None), - ([(100, 200), (200, 100)], None), - ([(-100, 100), (100, 200)], 200), + ([(-5, 5, False)], 5), + ([(-10, -5, False)], datetime.timedelta(days=1).total_seconds() - 10), + ([(5, 10, False)], 5), + ([(0, 0, False)], None), + ([(100, 200, False), (200, 100, False)], None), + ([(-100, 100, False), (100, 200, False)], 200), + ([(-100, 100, True), (100, 200, False)], 100), ], ids=[ "inside range", @@ -221,6 +216,7 @@ def test_to_str() -> None: "entire_day_1_range", "entire_day_2_ranges", "adjusted_ranges", + "disabled_range", ], ) def test_next_update( @@ -238,8 +234,9 @@ def test_next_update( CONF_TO: (now + datetime.timedelta(seconds=to_sec_offset)) .time() .isoformat(), + CONF_DISABLED: disabled, } - for (from_sec_offset, to_sec_offset) in schedule + for (from_sec_offset, to_sec_offset, disabled) in schedule ] ).next_update(now) == ( now + datetime.timedelta(seconds=next_update_sec_offset)