Skip to content

Commit

Permalink
Disabled time range
Browse files Browse the repository at this point in the history
  • Loading branch information
amitfin committed May 22, 2023
1 parent 8adb25d commit 1236203
Show file tree
Hide file tree
Showing 4 changed files with 74 additions and 54 deletions.
10 changes: 9 additions & 1 deletion custom_components/daily_schedule/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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,
)
Expand Down
1 change: 1 addition & 0 deletions custom_components/daily_schedule/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
38 changes: 26 additions & 12 deletions custom_components/daily_schedule/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
79 changes: 38 additions & 41 deletions tests/test_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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(
Expand All @@ -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=[
Expand All @@ -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(
Expand All @@ -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):
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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(
Expand All @@ -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)
Expand Down

0 comments on commit 1236203

Please sign in to comment.