Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions custom_components/climate_scheduler/common.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,31 @@
"""Common data structures for Climate Scheduler."""

import logging
from collections import namedtuple
from dataclasses import dataclass
from datetime import timedelta
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from .schedule import ClimateSchedulerSchedule

ComputedClimateData = namedtuple(
"ComputedClimateData",
["hvac_mode", "fan_mode", "swing_mode", "min_temp", "max_temp"],
)


@dataclass
class ResolvedScheduleEntry:
"""Entry with resolved time."""

time: timedelta
schedule: "ClimateSchedulerSchedule"


class PrefixAdapter(logging.LoggerAdapter):
"""Logger adapter to add a prefix to messages."""

def process(self, msg, kwargs):
"""Process the message."""
return f"{self.extra['prefix']} {msg}", kwargs
1 change: 1 addition & 0 deletions custom_components/climate_scheduler/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
CONF_SCHEDULE_MAX_TEMP = "max_temp"
CONF_SCHEDULE_FAN_MODE = "fan_mode"
CONF_SCHEDULE_SWING_MODE = "swing_mode"
CONF_SCHEDULE_OFFSET = "offset"

ATTR_IS_ON = "is_on"
ATTR_PROFILE = "current_profile"
Expand Down
13 changes: 10 additions & 3 deletions custom_components/climate_scheduler/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@
"version": "0.1.0",
"documentation": "https://github.com/FrancisLab/hass-climate-scheduler",
"issue_tracker": "https://github.com/FrancisLab/hass-climate-scheduler/issues",
"dependencies": ["input_select", "switch", "climate"],
"codeowners": ["@FrancisLab"],
"dependencies": [
"input_select",
"switch",
"climate",
"input_datetime"
],
"codeowners": [
"@FrancisLab"
],
"config_flow": false,
"iot_class" : "calculated"
"iot_class": "calculated"
}
95 changes: 72 additions & 23 deletions custom_components/climate_scheduler/profile.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"""Profile class for Climate Scheduler."""

import logging
from datetime import timedelta

import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from homeassistant.components.climate import HVAC_MODES
from homeassistant.core import HomeAssistant

from .common import ComputedClimateData
from .common import ComputedClimateData, ResolvedScheduleEntry
from .const import (
CONF_PROFILE_DEFAULT_FAN_MODE,
CONF_PROFILE_DEFAULT_HVAC_MODE,
Expand All @@ -19,6 +21,8 @@
from .schedule import SCHEDULE_SCHEMA, ClimateSchedulerSchedule
from .validation import unique_schedule_times

_LOGGER = logging.getLogger(__name__)

PROFILES_SCHEMA = vol.Schema(
[
{
Expand All @@ -37,8 +41,9 @@
class ClimateSchedulerProfile:
"""Representation of a profile."""

def __init__(self, config: dict) -> None:
def __init__(self, config: dict, logger: logging.Logger = _LOGGER) -> None:
"""Initialize the profile."""
self._logger = logger
self._id: str = config.get(CONF_PROFILE_ID)

self._default_hvac_mode = config.get(CONF_PROFILE_DEFAULT_HVAC_MODE)
Expand All @@ -47,17 +52,33 @@ def __init__(self, config: dict) -> None:
self._default_min_temp = config.get(CONF_PROFILE_DEFAULT_MIN_TEMP)
self._default_max_temp = config.get(CONF_PROFILE_DEFAULT_MAX_TEMP)

self._schedules = [ClimateSchedulerSchedule(c) for c in config.get(CONF_PROFILE_SCHEDULE)]
self._schedules.sort(key=lambda x: x.time.total_seconds())
self._schedules = [ClimateSchedulerSchedule(c, logger) for c in config.get(CONF_PROFILE_SCHEDULE)]

@property
def profile_id(self) -> str:
"""Return the profile ID."""
return self._id

def compute_climate(self, time_of_day: timedelta) -> ComputedClimateData:
def get_time_entities(self) -> set[str]:
"""Return a set of entity IDs used in this profile's schedules."""
entities = set()
for schedule in self._schedules:
if schedule.entity_id:
entities.add(schedule.entity_id)
return entities

def compute_climate(self, time_of_day: timedelta, hass: HomeAssistant) -> ComputedClimateData:
"""Compute the climate settings for a specific time of day."""
schedule = self._find_schedule(time_of_day)
resolved_schedules = self._resolve_schedules(hass)
schedule = self._find_schedule(time_of_day, resolved_schedules)

self._logger.debug(
"Computed climate for profile %s at %s. Using schedule: %s",
self._id,
time_of_day,
schedule,
)

if schedule is None:
return ComputedClimateData(
self._default_hvac_mode,
Expand All @@ -75,30 +96,58 @@ def compute_climate(self, time_of_day: timedelta) -> ComputedClimateData:
schedule.max_temp if schedule.max_temp else self._default_max_temp,
)

def get_trigger_times(self) -> list[timedelta]:
def get_trigger_times(self, hass: HomeAssistant) -> list[timedelta]:
"""Return a list of times when the schedule changes."""
return [s.time for s in self._schedules]

def _find_schedule(self, time_of_day: timedelta) -> ClimateSchedulerSchedule | None:
if len(self._schedules) == 0:
# Use a dict to deduplicate times, then return sorted list
# or just set
resolved = self._resolve_schedules(hass)
return sorted(list({entry.time for entry in resolved}))

def _resolve_schedules(self, hass: HomeAssistant) -> list[ResolvedScheduleEntry]:
"""Resolve all schedules to static times and sort them."""
resolved_entries = []
for schedule in self._schedules:
time = schedule.resolve_time(hass)
if time is not None:
resolved_entries.append(ResolvedScheduleEntry(time, schedule))

resolved_entries.sort(key=lambda x: x.time.total_seconds())

# Check for collisions
if len(resolved_entries) > 1:
for i in range(len(resolved_entries) - 1):
if resolved_entries[i].time == resolved_entries[i + 1].time:
self._logger.warning(
"Collision detected in profile %s for time %s. Using the last defined schedule.",
self._id,
resolved_entries[i].time,
)

return resolved_entries

def _find_schedule(
self, time_of_day: timedelta, resolved_schedules: list[ResolvedScheduleEntry]
) -> ClimateSchedulerSchedule | None:
if len(resolved_schedules) == 0:
return None

if len(self._schedules) == 1:
return self._schedules[0]
if len(resolved_schedules) == 1:
return resolved_schedules[0].schedule

# If the current time is earlier than the first schedule, wrap around and
# return the last schedule of the day
if time_of_day < self._schedules[0].time:
return self._schedules[-1]
if time_of_day < resolved_schedules[0].time:
return resolved_schedules[-1].schedule

for index, schedule in enumerate(self._schedules):
# Search for a schedule starting earlier than the current time of day
# which appears right before a schedule which starts later than the
# current time of the day or which is the last schedule of the day.
for index, entry in enumerate(resolved_schedules):
schedule = entry.schedule
schedule_time = entry.time

next_schedule = None
if index < len(self._schedules) - 1:
next_schedule = self._schedules[index + 1]
next_entry = None
if index < len(resolved_schedules) - 1:
next_entry = resolved_schedules[index + 1]

if time_of_day >= schedule.time and (next_schedule is None or time_of_day < next_schedule.time):
if time_of_day >= schedule_time and (next_entry is None or time_of_day < next_entry.time):
return schedule

return None
77 changes: 69 additions & 8 deletions custom_components/climate_scheduler/schedule.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,35 @@
"""Schedule class for Climate Scheduler."""

import logging
from datetime import timedelta

import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from homeassistant.components.climate import HVAC_MODES
from homeassistant.core import HomeAssistant
from homeassistant.util.dt import parse_time

from .const import (
CONF_SCHEDULE_FAN_MODE,
CONF_SCHEDULE_HVAC,
CONF_SCHEDULE_MAX_TEMP,
CONF_SCHEDULE_MIN_TEMP,
CONF_SCHEDULE_OFFSET,
CONF_SCHEDULE_SWING_MODE,
CONF_SCHEDULE_TIME,
)
from .validation import less_than_24h
from .validation import valid_offset, valid_time_or_entity

_LOGGER = logging.getLogger(__name__)


SCHEDULE_SCHEMA = vol.Schema(
[
{
vol.Required(CONF_SCHEDULE_TIME): vol.All(
cv.positive_time_period,
less_than_24h,
valid_time_or_entity,
),
vol.Optional(CONF_SCHEDULE_OFFSET): valid_offset,
vol.Optional(CONF_SCHEDULE_HVAC): vol.All(cv.string, vol.In(HVAC_MODES)),
vol.Optional(CONF_SCHEDULE_MIN_TEMP): vol.Coerce(float),
vol.Optional(CONF_SCHEDULE_MAX_TEMP): vol.Coerce(float),
Expand All @@ -36,19 +43,39 @@
class ClimateSchedulerSchedule:
"""Representation of a single schedule entry."""

def __init__(self, config: dict) -> None:
def __init__(self, config: dict, logger: logging.Logger = _LOGGER) -> None:
"""Initialize the schedule."""
self._time: timedelta = config.get(CONF_SCHEDULE_TIME)
self._logger = logger
self._static_time: timedelta | None = None
self._time_entity_id: str | None = None
self._offset: timedelta = timedelta(0)
self._parse_time_config(config)

def _parse_time_config(self, config: dict) -> None:
"""Parse time configuration."""
time_val = config.get(CONF_SCHEDULE_TIME)
if isinstance(time_val, timedelta):
self._static_time = time_val
else:
self._time_entity_id = time_val

self._offset = config.get(CONF_SCHEDULE_OFFSET, timedelta(0))

self._hvac_mode: str | None = config.get(CONF_SCHEDULE_HVAC)
self._fan_mode: str | None = config.get(CONF_SCHEDULE_FAN_MODE)
self._swing_mode: str | None = config.get(CONF_SCHEDULE_SWING_MODE)
self._min_temp: int | None = config.get(CONF_SCHEDULE_MIN_TEMP)
self._max_temp: int | None = config.get(CONF_SCHEDULE_MAX_TEMP)

@property
def time(self) -> timedelta:
"""Return the time of the schedule."""
return self._time
def is_dynamic(self) -> bool:
"""Return True if the schedule time depends on an entity."""
return self._time_entity_id is not None

@property
def entity_id(self) -> str | None:
"""Return the entity ID if dynamic."""
return self._time_entity_id

@property
def hvac_mode(self) -> str | None:
Expand All @@ -74,3 +101,37 @@ def min_temp(self) -> float | None:
def max_temp(self) -> float | None:
"""Return the max temp."""
return self._max_temp

def resolve_time(self, hass: HomeAssistant) -> timedelta | None:
"""Resolve the schedule time, accounting for entities and offsets."""
base_time = self._static_time

if self._time_entity_id:
state = hass.states.get(self._time_entity_id)
self._logger.debug(f"resolving {self._time_entity_id}, state found: {state}")
if state is None:
self._logger.warning("Entity %s not found for schedule", self._time_entity_id)
return None

# parse_time handles strings like "10:00:00"
parsed = parse_time(state.state)
if parsed is None:
self._logger.warning("Invalid time state %s for entity %s", state.state, self._time_entity_id)
return None

base_time = timedelta(hours=parsed.hour, minutes=parsed.minute, seconds=parsed.second)

if base_time is None:
return None

# Apply offset
final_time = base_time + self._offset

# Handle wrap around (modulo 24h)
total_seconds = final_time.total_seconds() % 86400
return timedelta(seconds=total_seconds)

def __str__(self) -> str:
if self._time_entity_id:
return f"Dynamic: {self._time_entity_id} + {self._offset}"
return f"Static: {self._static_time} + {self._offset}"
Loading