Skip to content
Open
Show file tree
Hide file tree
Changes from 16 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
40 changes: 36 additions & 4 deletions custom_components/adaptive_lighting/color_and_brightness.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,16 @@ class SunLightSettings:
sunset_offset: datetime.timedelta = datetime.timedelta()
timezone: datetime.tzinfo = UTC

independent_color: bool = False
color_sunrise_time: datetime.time | None = None
color_min_sunrise_time: datetime.time | None = None
color_max_sunrise_time: datetime.time | None = None
color_sunset_time: datetime.time | None = None
color_min_sunset_time: datetime.time | None = None
color_max_sunset_time: datetime.time | None = None
color_sunrise_offset: datetime.timedelta = datetime.timedelta()
color_sunset_offset: datetime.timedelta = datetime.timedelta()

@cached_property
def sun(self) -> SunEvents:
"""Return the SunEvents object."""
Expand All @@ -248,6 +258,26 @@ def sun(self) -> SunEvents:
timezone=self.timezone,
)

@cached_property
def sun_color(self) -> SunEvents:
"""Return the SunEvents object for color temperature."""
if not self.independent_color:
return self.sun

return SunEvents(
name=self.name,
astral_location=self.astral_location,
sunrise_time=self.color_sunrise_time,
sunrise_offset=self.color_sunrise_offset,
min_sunrise_time=self.color_min_sunrise_time,
max_sunrise_time=self.color_max_sunrise_time,
sunset_time=self.color_sunset_time,
sunset_offset=self.color_sunset_offset,
min_sunset_time=self.color_min_sunset_time,
max_sunset_time=self.color_max_sunset_time,
timezone=self.timezone,
)

def _brightness_pct_default(self, dt: datetime.datetime) -> float:
"""Calculate the brightness percentage using the default method."""
sun_position = self.sun.sun_position(dt)
Expand Down Expand Up @@ -347,6 +377,7 @@ def brightness_and_color(
) -> dict[str, Any]:
"""Calculate the brightness and color."""
sun_position = self.sun.sun_position(dt)
sun_position_color = self.sun_color.sun_position(dt)
rgb_color: tuple[int, int, int]
# Variable `force_rgb_color` is needed for RGB color after sunset (if enabled)
force_rgb_color = False
Expand All @@ -357,7 +388,7 @@ def brightness_and_color(
elif (
self.sleep_rgb_or_color_temp == "rgb_color"
and self.adapt_until_sleep
and sun_position < 0
and sun_position_color < 0
):
# Feature requested in
# https://github.com/basnijholt/adaptive-lighting/issues/624
Expand All @@ -367,12 +398,12 @@ def brightness_and_color(
rgb_color = lerp_color_hsv(
min_color_rgb,
self.sleep_rgb_color,
sun_position,
sun_position_color,
)
color_temp_kelvin = self.color_temp_kelvin(sun_position)
color_temp_kelvin = self.color_temp_kelvin(sun_position_color)
force_rgb_color = True
else:
color_temp_kelvin = self.color_temp_kelvin(sun_position)
color_temp_kelvin = self.color_temp_kelvin(sun_position_color)
r, g, b = color_temperature_to_rgb(color_temp_kelvin)
rgb_color = (round(r), round(g), round(b))
# backwards compatibility for versions < 1.3.1 - see #403
Expand All @@ -387,6 +418,7 @@ def brightness_and_color(
"xy_color": xy_color,
"hs_color": hs_color,
"sun_position": sun_position,
"sun_position_color": sun_position_color,
"force_rgb_color": force_rgb_color,
}

Expand Down
62 changes: 62 additions & 0 deletions custom_components/adaptive_lighting/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,51 @@ class TakeOverControlMode(Enum):
"Set the latest virtual sunset time (HH:MM:SS), allowing for earlier sunsets. 🌇"
)

CONF_INDEPENDENT_COLOR, DEFAULT_INDEPENDENT_COLOR = (
"independent_color",
False,
)
DOCS[CONF_INDEPENDENT_COLOR] = (
"Whether to use separate sunrise/sunset timing for color temperature. 🌈"
)

CONF_COLOR_SUNRISE_OFFSET, DEFAULT_COLOR_SUNRISE_OFFSET = "color_sunrise_offset", 0
DOCS[CONF_COLOR_SUNRISE_OFFSET] = (
"Adjust color sunrise time with a positive or negative offset in seconds. ⏰"
)

CONF_COLOR_SUNRISE_TIME = "color_sunrise_time"
DOCS[CONF_COLOR_SUNRISE_TIME] = "Set a fixed time (HH:MM:SS) for color sunrise. 🌅"

CONF_COLOR_MIN_SUNRISE_TIME = "color_min_sunrise_time"
DOCS[CONF_COLOR_MIN_SUNRISE_TIME] = (
"Set the earliest virtual color sunrise time (HH:MM:SS), allowing for later sunrises. 🌅"
)

CONF_COLOR_MAX_SUNRISE_TIME = "color_max_sunrise_time"
DOCS[CONF_COLOR_MAX_SUNRISE_TIME] = (
"Set the latest virtual color sunrise time (HH:MM:SS), allowing"
" for earlier sunrises. 🌅"
)

CONF_COLOR_SUNSET_OFFSET, DEFAULT_COLOR_SUNSET_OFFSET = "color_sunset_offset", 0
DOCS[CONF_COLOR_SUNSET_OFFSET] = (
"Adjust color sunset time with a positive or negative offset in seconds. ⏰"
)

CONF_COLOR_SUNSET_TIME = "color_sunset_time"
DOCS[CONF_COLOR_SUNSET_TIME] = "Set a fixed time (HH:MM:SS) for color sunset. 🌇"

CONF_COLOR_MIN_SUNSET_TIME = "color_min_sunset_time"
DOCS[CONF_COLOR_MIN_SUNSET_TIME] = (
"Set the earliest virtual color sunset time (HH:MM:SS), allowing for later sunsets. 🌇"
)

CONF_COLOR_MAX_SUNSET_TIME = "color_max_sunset_time"
DOCS[CONF_COLOR_MAX_SUNSET_TIME] = (
"Set the latest virtual color sunset time (HH:MM:SS), allowing for earlier sunsets. 🌇"
)

CONF_BRIGHTNESS_MODE, DEFAULT_BRIGHTNESS_MODE = "brightness_mode", "default"
DOCS[CONF_BRIGHTNESS_MODE] = (
"Brightness mode to use. Possible values are `default`, `linear`, and `tanh` "
Expand Down Expand Up @@ -405,6 +450,15 @@ 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_INCLUDE_CONFIG_IN_ATTRIBUTES, DEFAULT_INCLUDE_CONFIG_IN_ATTRIBUTES, bool),
(CONF_INDEPENDENT_COLOR, DEFAULT_INDEPENDENT_COLOR, bool),
(CONF_COLOR_SUNRISE_TIME, NONE_STR, str),
(CONF_COLOR_MIN_SUNRISE_TIME, NONE_STR, str),
(CONF_COLOR_MAX_SUNRISE_TIME, NONE_STR, str),
(CONF_COLOR_SUNRISE_OFFSET, DEFAULT_COLOR_SUNRISE_OFFSET, int),
(CONF_COLOR_SUNSET_TIME, NONE_STR, str),
(CONF_COLOR_MIN_SUNSET_TIME, NONE_STR, str),
(CONF_COLOR_MAX_SUNSET_TIME, NONE_STR, str),
(CONF_COLOR_SUNSET_OFFSET, DEFAULT_COLOR_SUNSET_OFFSET, int),
]


Expand All @@ -430,6 +484,14 @@ def timedelta_as_int(value: timedelta) -> float:
CONF_MAX_SUNSET_TIME: (cv.time, str),
CONF_BRIGHTNESS_MODE_TIME_LIGHT: (cv.time_period, timedelta_as_int),
CONF_BRIGHTNESS_MODE_TIME_DARK: (cv.time_period, timedelta_as_int),
CONF_COLOR_SUNRISE_OFFSET: (cv.time_period, timedelta_as_int),
CONF_COLOR_SUNRISE_TIME: (cv.time, str),
CONF_COLOR_MIN_SUNRISE_TIME: (cv.time, str),
CONF_COLOR_MAX_SUNRISE_TIME: (cv.time, str),
CONF_COLOR_SUNSET_OFFSET: (cv.time_period, timedelta_as_int),
CONF_COLOR_SUNSET_TIME: (cv.time, str),
CONF_COLOR_MIN_SUNSET_TIME: (cv.time, str),
CONF_COLOR_MAX_SUNSET_TIME: (cv.time, str),
}


Expand Down
18 changes: 18 additions & 0 deletions custom_components/adaptive_lighting/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,17 @@
CONF_BRIGHTNESS_MODE,
CONF_BRIGHTNESS_MODE_TIME_DARK,
CONF_BRIGHTNESS_MODE_TIME_LIGHT,
CONF_COLOR_MAX_SUNRISE_TIME,
CONF_COLOR_MAX_SUNSET_TIME,
CONF_COLOR_MIN_SUNRISE_TIME,
CONF_COLOR_MIN_SUNSET_TIME,
CONF_COLOR_SUNRISE_OFFSET,
CONF_COLOR_SUNRISE_TIME,
CONF_COLOR_SUNSET_OFFSET,
CONF_COLOR_SUNSET_TIME,
CONF_DETECT_NON_HA_CHANGES,
CONF_INCLUDE_CONFIG_IN_ATTRIBUTES,
CONF_INDEPENDENT_COLOR,
CONF_INITIAL_TRANSITION,
CONF_INTERCEPT,
CONF_INTERVAL,
Expand Down Expand Up @@ -969,6 +978,15 @@ def _set_changeable_settings(
brightness_mode=data[CONF_BRIGHTNESS_MODE],
brightness_mode_time_dark=data[CONF_BRIGHTNESS_MODE_TIME_DARK],
brightness_mode_time_light=data[CONF_BRIGHTNESS_MODE_TIME_LIGHT],
independent_color=data[CONF_INDEPENDENT_COLOR],
color_sunrise_time=data[CONF_COLOR_SUNRISE_TIME],
color_min_sunrise_time=data[CONF_COLOR_MIN_SUNRISE_TIME],
color_max_sunrise_time=data[CONF_COLOR_MAX_SUNRISE_TIME],
color_sunset_time=data[CONF_COLOR_SUNSET_TIME],
color_min_sunset_time=data[CONF_COLOR_MIN_SUNSET_TIME],
color_max_sunset_time=data[CONF_COLOR_MAX_SUNSET_TIME],
color_sunrise_offset=data[CONF_COLOR_SUNRISE_OFFSET],
color_sunset_offset=data[CONF_COLOR_SUNSET_OFFSET],
timezone=zoneinfo.ZoneInfo(self.hass.config.time_zone),
)
_LOGGER.debug(
Expand Down
156 changes: 156 additions & 0 deletions tests/verify_independent_color.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import datetime
import logging
import sys
from datetime import timedelta, timezone
from pathlib import Path
from unittest.mock import MagicMock

# Add the parent directory to sys.path to import the component
sys.path.append(str(Path(__file__).parent.parent.resolve()))

from custom_components.adaptive_lighting.color_and_brightness import SunLightSettings

_LOGGER = logging.getLogger(__name__)

# Mock astral location
mock_location = MagicMock()
mock_location.sunrise.return_value = datetime.datetime.now(timezone.utc).replace(
hour=6,
minute=0,
second=0,
microsecond=0,
)
mock_location.sunset.return_value = datetime.datetime.now(timezone.utc).replace(
hour=18,
minute=0,
second=0,
microsecond=0,
)
mock_location.noon.return_value = datetime.datetime.now(timezone.utc).replace(
hour=12,
minute=0,
second=0,
microsecond=0,
)
mock_location.midnight.return_value = datetime.datetime.now(timezone.utc).replace(
hour=0,
minute=0,
second=0,
microsecond=0,
)


def test_independent_color():
_LOGGER.info("Testing Independent Color Control Logic...")

base_settings = {
"name": "test",
"astral_location": mock_location,
"adapt_until_sleep": False,
"max_brightness": 100,
"min_brightness": 50,
"max_color_temp": 6000,
"min_color_temp": 3000,
"sleep_brightness": 1,
"sleep_color_temp": 2000,
"sleep_rgb_color": (255, 0, 0),
"sleep_rgb_or_color_temp": "color_temp",
"sunrise_time": datetime.time(6, 0),
"min_sunrise_time": None,
"max_sunrise_time": None,
"sunset_time": datetime.time(18, 0),
"min_sunset_time": None,
"max_sunset_time": None,
"sunrise_offset": timedelta(0),
"sunset_offset": timedelta(0),
"brightness_mode_time_dark": timedelta(seconds=900),
"brightness_mode_time_light": timedelta(seconds=3600),
"brightness_mode": "default",
"timezone": timezone.utc,
# New independent color params
"independent_color": False,
"color_sunrise_time": datetime.time(8, 0), # Different from main
"color_min_sunrise_time": None,
"color_max_sunrise_time": None,
"color_sunset_time": datetime.time(20, 0), # Different from main
"color_min_sunset_time": None,
"color_max_sunset_time": None,
"color_sunrise_offset": timedelta(0),
"color_sunset_offset": timedelta(0),
}

# Test Case 1: Independent Control Disabled
_LOGGER.info("\n[Case 1] Independent Control Disabled")
settings = SunLightSettings(**base_settings)

# At 7:00 (after main sunrise 6:00, before color sunrise 8:00)
# Brightness should be high (sun is up), Color should be high (sun is up) because it follows main schedule
dt = datetime.datetime.now(timezone.utc).replace(
hour=7,
minute=0,
second=0,
microsecond=0,
)

res = settings.brightness_and_color(dt, is_sleep=False)
_LOGGER.info("Time: 07:00 (Sunrise: 06:00, Color Sunrise: 08:00)")
_LOGGER.info(f"Brightness: {res['brightness_pct']} (Expected > min)")
_LOGGER.info(f"Color Temp: {res['color_temp_kelvin']} (Expected > min)")

if res["color_temp_kelvin"] <= 3000:
_LOGGER.info(
"FAIL: Color temp should be rising as sun is up (following 06:00 sunrise)",
)
else:
_LOGGER.info("PASS: Color temp is following main schedule")

# Test Case 2: Independent Control Enabled
_LOGGER.info("\n[Case 2] Independent Control Enabled")
base_settings["independent_color"] = True
settings = SunLightSettings(**base_settings)

# At 7:00 (after main sunrise 6:00, before color sunrise 8:00)
# Brightness should be high (sun is up)
# Color should be LOW (color sun is NOT up yet, sunrise is 8:00)

res = settings.brightness_and_color(dt, is_sleep=False)
_LOGGER.info("Time: 07:00 (Sunrise: 06:00, Color Sunrise: 08:00)")
_LOGGER.info(f"Brightness: {res['brightness_pct']} (Expected > min)")
_LOGGER.info(f"Color Temp: {res['color_temp_kelvin']} (Expected approx min)")

if res["brightness_pct"] <= 50:
_LOGGER.info("FAIL: Brightness should be > min (sun is up)")
else:
_LOGGER.info("PASS: Brightness is up")

# Note: sun_position for color might be -1 if it's before sunrise,
# but the calculation logic might give something close to min_color_temp

if res["color_temp_kelvin"] > 3100: # Allowing small margin
_LOGGER.info(
f"FAIL: Color temp {res['color_temp_kelvin']} is too high! It should be near min {base_settings['min_color_temp']} because color sunrise is 08:00",
)
else:
_LOGGER.info(
f"PASS: Color temp {res['color_temp_kelvin']} is low, following color schedule",
)

# At 9:00 (after both sunrises)
dt_9am = datetime.datetime.now(timezone.utc).replace(
hour=9,
minute=0,
second=0,
microsecond=0,
)
res_9am = settings.brightness_and_color(dt_9am, is_sleep=False)
_LOGGER.info("\nTime: 09:00")
_LOGGER.info(f"Color Temp: {res_9am['color_temp_kelvin']}")
if res_9am["color_temp_kelvin"] <= 3100:
_LOGGER.info("FAIL: Color temp should be high now")
else:
_LOGGER.info("PASS: Color temp is rising")


if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(message)s")
test_independent_color()
Loading
Loading