Skip to content
Open
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
145 changes: 144 additions & 1 deletion custom_components/adaptive_lighting/color_and_brightness.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
import logging
import math
from dataclasses import dataclass
from datetime import UTC, timedelta
from datetime import timedelta, timezone

UTC = timezone.utc
from enum import Enum
from functools import cached_property, partial
from typing import TYPE_CHECKING, Any, Literal, cast
Expand Down Expand Up @@ -203,6 +205,15 @@ def closest_event(
raise ValueError(msg)


@dataclass(frozen=True)
class SchedulePoint:
"""A single point in the manual schedule."""

time: datetime.time
brightness_pct: float
color_temp_kelvin: int


@dataclass(frozen=True)
class SunLightSettings:
"""Track the state of the sun and associated light settings."""
Expand Down Expand Up @@ -230,6 +241,7 @@ class SunLightSettings:
sunrise_offset: datetime.timedelta = datetime.timedelta()
sunset_offset: datetime.timedelta = datetime.timedelta()
timezone: datetime.tzinfo = UTC
manual_schedule: list[SchedulePoint] | None = None

@cached_property
def sun(self) -> SunEvents:
Expand Down Expand Up @@ -340,12 +352,143 @@ def color_temp_kelvin(self, sun_position: float) -> int:
msg = "Should not happen"
raise ValueError(msg)

def _get_schedule_values(self, dt: datetime.datetime) -> tuple[float, int]:
"""Get the interpolated brightness and color temperature from the schedule."""
assert self.manual_schedule is not None

# Convert dt to configured timezone for schedule comparison
# dt comes in as UTC from get_settings()
if self.timezone and dt.tzinfo is not None:
dt = dt.astimezone(self.timezone)

# Sort schedule by time just in case
schedule = sorted(self.manual_schedule, key=lambda x: x.time)

target_time = dt.time()

# specific to how we want to handle wrapping over midnight:
# we need to find the "previous" point and the "next" point.
# since the list is sorted, we can iterate to find where the current time fits.

prev_point = schedule[-1] # Default to last point (wrapping)
next_point = schedule[0] # Default to first point (wrapping)

for i, point in enumerate(schedule):
if point.time > target_time:
next_point = point
prev_point = schedule[i - 1]
break
# If we reach the end, meaning target_time is after all points,
# then prev_point is effectively the last element (already set in loop or init)
# and next_point is the first element (wrapping)
prev_point = point

# Calculate interpolation factor
# We need to handle datetime wrapping carefully.

# Create concrete datetimes for comparison
dt_current = dt

# Construct datetimes for prev and next points relative to the current date
# If prev_point.time > next_point.time, it means we wrapped over midnight.

dt_prev = dt.replace(
hour=prev_point.time.hour,
minute=prev_point.time.minute,
second=prev_point.time.second,
microsecond=0,
)
dt_next = dt.replace(
hour=next_point.time.hour,
minute=next_point.time.minute,
second=next_point.time.second,
microsecond=0,
)

# Adjust for wrapping
if dt_prev > dt_current:
dt_prev -= timedelta(days=1)
if dt_next < dt_prev:
dt_next += timedelta(days=1)
# If next is still before current (e.g. current is late night, next is early morning tomorrow)
if dt_next < dt_current:
dt_next += timedelta(days=1)

# Double check validity
# We expect dt_prev <= dt_current <= dt_next
# but due to my simple logic above let's ensure:

if dt_prev > dt_current:
# this shouldn't happen with the logic above unless I messed up logic
dt_prev -= timedelta(days=1)

# Recalculate duration
total_duration = (dt_next - dt_prev).total_seconds()
elapsed = (dt_current - dt_prev).total_seconds()

if total_duration == 0:
return prev_point.brightness_pct, prev_point.color_temp_kelvin

fraction = elapsed / total_duration

brightness = (
prev_point.brightness_pct
+ (next_point.brightness_pct - prev_point.brightness_pct) * fraction
)
color_temp = (
prev_point.color_temp_kelvin
+ (next_point.color_temp_kelvin - prev_point.color_temp_kelvin) * fraction
)

return brightness, int(color_temp)

def brightness_and_color(
self,
dt: datetime.datetime,
is_sleep: bool,
) -> dict[str, Any]:
"""Calculate the brightness and color."""
if self.manual_schedule:
# Manual schedule overrides everything except sleep?
# User requirement: "I want all the other features and functionality (sleep mode, the intercepts, etc) to stay the same."
# So sleep overrides schedule.

if is_sleep:
brightness_pct = self.sleep_brightness
color_temp_kelvin = self.sleep_color_temp
if self.sleep_rgb_or_color_temp == "rgb_color":
rgb_color = self.sleep_rgb_color
# We force RGB if sleep is RGB
force_rgb_color = True
else:
r, g, b = color_temperature_to_rgb(color_temp_kelvin)
rgb_color = (round(r), round(g), round(b))
force_rgb_color = False

# Dummy values for unused vars
sun_position = 0.0
else:
brightness_pct, color_temp_kelvin = self._get_schedule_values(dt)
r, g, b = color_temperature_to_rgb(color_temp_kelvin)
rgb_color = (round(r), round(g), round(b))
force_rgb_color = False
sun_position = 0.0 # Schedule doesn't use sun position

# backwards compatibility for versions < 1.3.1 - see #403
color_temp_mired: float = math.floor(1000000 / color_temp_kelvin)
xy_color: tuple[float, float] = color_RGB_to_xy(*rgb_color)
hs_color: tuple[float, float] = color_xy_to_hs(*xy_color)
return {
"brightness_pct": brightness_pct,
"color_temp_kelvin": color_temp_kelvin,
"color_temp_mired": color_temp_mired,
"rgb_color": rgb_color,
"xy_color": xy_color,
"hs_color": hs_color,
"sun_position": sun_position,
"force_rgb_color": force_rgb_color,
}

sun_position = self.sun.sun_position(dt)
rgb_color: tuple[int, int, int]
# Variable `force_rgb_color` is needed for RGB color after sunset (if enabled)
Expand Down
11 changes: 10 additions & 1 deletion custom_components/adaptive_lighting/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,16 @@
from homeassistant import config_entries
from homeassistant.const import CONF_NAME
from homeassistant.core import callback
from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig
from homeassistant.helpers.selector import (
EntitySelector,
EntitySelectorConfig,
TextSelector,
TextSelectorConfig,
)

from .const import ( # pylint: disable=unused-import
CONF_LIGHTS,
CONF_MANUAL_SCHEDULE,
DOMAIN,
EXTRA_VALIDATION,
NONE_STR,
Expand Down Expand Up @@ -152,6 +158,9 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None):
multiple=True,
),
),
CONF_MANUAL_SCHEDULE: TextSelector(
TextSelectorConfig(multiline=True),
),
}

options_schema = {}
Expand Down
5 changes: 5 additions & 0 deletions custom_components/adaptive_lighting/const.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Constants for the Adaptive Lighting integration."""

from __future__ import annotations

from datetime import timedelta
from enum import Enum
from typing import Any
Expand Down Expand Up @@ -287,6 +289,8 @@ class TakeOverControlMode(Enum):
CONF_MANUAL_CONTROL = "manual_control"
DOCS[CONF_MANUAL_CONTROL] = "Whether to manually control the lights. 🔒"
SERVICE_APPLY = "apply"
CONF_MANUAL_SCHEDULE, DEFAULT_MANUAL_SCHEDULE = "manual_schedule", ""
DOCS[CONF_MANUAL_SCHEDULE] = "YAML configuration for manual schedule. 📝"
CONF_TURN_ON_LIGHTS = "turn_on_lights"
DOCS[CONF_TURN_ON_LIGHTS] = "Whether to turn on lights that are currently off. 🔆"
SERVICE_CHANGE_SWITCH_SETTINGS = "change_switch_settings"
Expand Down Expand Up @@ -404,6 +408,7 @@ def int_between(min_int: int, max_int: int) -> vol.All:
),
(CONF_INTERCEPT, DEFAULT_INTERCEPT, bool),
(CONF_MULTI_LIGHT_INTERCEPT, DEFAULT_MULTI_LIGHT_INTERCEPT, bool),
(CONF_MANUAL_SCHEDULE, DEFAULT_MANUAL_SCHEDULE, cv.string),
(CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, DEFAULT_INCLUDE_CONFIG_IN_ATTRIBUTES, bool),
]

Expand Down
42 changes: 41 additions & 1 deletion custom_components/adaptive_lighting/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import homeassistant.util.dt as dt_util
import ulid_transform
import voluptuous as vol
import yaml
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
Expand Down Expand Up @@ -82,7 +83,7 @@
manual_control_event_attribute_to_flags,
prepare_adaptation_data,
)
from .color_and_brightness import SunLightSettings
from .color_and_brightness import SchedulePoint, SunLightSettings
from .const import (
ADAPT_BRIGHTNESS_SWITCH,
ADAPT_COLOR_SWITCH,
Expand All @@ -103,6 +104,7 @@
CONF_INTERVAL,
CONF_LIGHTS,
CONF_MANUAL_CONTROL,
CONF_MANUAL_SCHEDULE,
CONF_MAX_BRIGHTNESS,
CONF_MAX_COLOR_TEMP,
CONF_MAX_SUNRISE_TIME,
Expand Down Expand Up @@ -944,6 +946,38 @@ def _set_changeable_settings(
)
self._multi_light_intercept = False
self._expand_light_groups() # updates manual control timers

self.manual_schedule: list[SchedulePoint] | None = None
if schedule_yaml := data.get(CONF_MANUAL_SCHEDULE):
try:
schedule_data = yaml.safe_load(schedule_yaml)
if isinstance(schedule_data, list):
self.manual_schedule = []
for item in schedule_data:
if not isinstance(item, dict):
continue
t_str = str(item.get("time"))
try:
# Try robust parsing with cv.time (handles 7:00)
t = cv.time(t_str)
except (ValueError, vol.Invalid):
# Fallback to pure ISO if cv fails or for standard compliance
t = datetime.time.fromisoformat(t_str)

self.manual_schedule.append(
SchedulePoint(
time=t,
brightness_pct=float(item["brightness_pct"]),
color_temp_kelvin=int(item["color_temp_kelvin"]),
)
)
_LOGGER.info(
"Loaded manual schedule with %s points",
len(self.manual_schedule),
)
except Exception as e:
_LOGGER.error("Failed to parse manual schedule: %s", e)

location, _ = get_astral_location(self.hass)

self._sun_light_settings = SunLightSettings(
Expand All @@ -970,6 +1004,7 @@ def _set_changeable_settings(
brightness_mode_time_dark=data[CONF_BRIGHTNESS_MODE_TIME_DARK],
brightness_mode_time_light=data[CONF_BRIGHTNESS_MODE_TIME_LIGHT],
timezone=zoneinfo.ZoneInfo(self.hass.config.time_zone),
manual_schedule=self.manual_schedule,
)
_LOGGER.debug(
"%s: Set switch settings for lights '%s'. now using data: '%s'",
Expand Down Expand Up @@ -1485,6 +1520,11 @@ async def _update_attrs_and_maybe_adapt_lights(
light,
context.id,
)
_LOGGER.info(
"%s: Light '%s' is manually controlled. Schedule will NOT apply until reset.",
self._name,
light,
)
continue

_LOGGER.debug(
Expand Down
Loading