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
47 changes: 47 additions & 0 deletions custom_components/adaptive_lighting/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import base64
import math
from typing import Any


def clamp(value: float, minimum: float, maximum: float) -> float:
Expand Down Expand Up @@ -83,3 +84,49 @@ def color_difference_redmean(
green_term = 4 * delta_g**2
blue_term = (2 + (255 - r_hat) / 256) * delta_b**2
return math.sqrt(red_term + green_term + blue_term)


def ensure_bool(val: Any, name: str) -> bool:
"""Ensures that val is a true Boolean and converts common string representations.

This function validates and converts values from external sources (Service-Calls,
configurations) to true Boolean values. It prevents problems with string Booleans
like "true"/"false", which in Python could be interpreted as truthy/falsy.

Parameters
----------
val
The value to validate
name
Name of the parameter for better error messages

Returns
-------
bool
The validated Boolean value

Raises
------
ValueError
If the value cannot be converted to a Boolean

Examples
--------
>>> ensure_bool(True, "test")
True
>>> ensure_bool("true", "test")
True
>>> ensure_bool("false", "test")
False
>>> ensure_bool("invalid", "test")
ValueError: Parameter 'test' must be a Boolean, but is: 'invalid'

"""
if isinstance(val, bool):
return val
if isinstance(val, str):
if val.lower() in ("true", "on", "yes", "1"):
return True
if val.lower() in ("false", "off", "no", "0"):
return False
raise ValueError(f"Parameter '{name}' must be a Boolean, but is: {val!r}")
60 changes: 46 additions & 14 deletions custom_components/adaptive_lighting/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@
from .helpers import (
clamp,
color_difference_redmean,
ensure_bool,
int_to_base36,
remove_vowels,
short_hash,
Expand Down Expand Up @@ -473,7 +474,10 @@ async def handle_apply(service_call: ServiceCall):
all_lights = _expand_light_groups(hass, lights)
switch.manager.lights.update(all_lights)
for light in all_lights:
if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light):
if ensure_bool(data[CONF_TURN_ON_LIGHTS], CONF_TURN_ON_LIGHTS) or is_on(
hass,
light,
):
context = switch.create_context(
"service",
parent=service_call.context,
Expand All @@ -482,9 +486,18 @@ async def handle_apply(service_call: ServiceCall):
light,
context=context,
transition=data[CONF_TRANSITION],
adapt_brightness=data[ATTR_ADAPT_BRIGHTNESS],
adapt_color=data[ATTR_ADAPT_COLOR],
prefer_rgb_color=data[CONF_PREFER_RGB_COLOR],
adapt_brightness=ensure_bool(
data[ATTR_ADAPT_BRIGHTNESS],
ATTR_ADAPT_BRIGHTNESS,
),
adapt_color=ensure_bool(
data[ATTR_ADAPT_COLOR],
ATTR_ADAPT_COLOR,
),
prefer_rgb_color=ensure_bool(
data[CONF_PREFER_RGB_COLOR],
CONF_PREFER_RGB_COLOR,
),
force=True,
)

Expand All @@ -503,7 +516,7 @@ async def handle_set_manual_control(service_call: ServiceCall):
all_lights = switch.lights
else:
all_lights = _expand_light_groups(hass, lights)
if service_call.data[CONF_MANUAL_CONTROL]:
if ensure_bool(service_call.data[CONF_MANUAL_CONTROL], CONF_MANUAL_CONTROL):
for light in all_lights:
_fire_manual_control_event(switch, light, service_call.context)
else:
Expand Down Expand Up @@ -860,9 +873,16 @@ def _set_changeable_settings(
self._transition = data[CONF_TRANSITION]
self._adapt_delay = data[CONF_ADAPT_DELAY]
self._send_split_delay = data[CONF_SEND_SPLIT_DELAY]
self._take_over_control = data[CONF_TAKE_OVER_CONTROL]
if not data[CONF_TAKE_OVER_CONTROL] and (
data[CONF_DETECT_NON_HA_CHANGES] or data[CONF_ADAPT_ONLY_ON_BARE_TURN_ON]
self._take_over_control = ensure_bool(
data[CONF_TAKE_OVER_CONTROL],
CONF_TAKE_OVER_CONTROL,
)
if not self._take_over_control and (
ensure_bool(data[CONF_DETECT_NON_HA_CHANGES], CONF_DETECT_NON_HA_CHANGES)
or ensure_bool(
data[CONF_ADAPT_ONLY_ON_BARE_TURN_ON],
CONF_ADAPT_ONLY_ON_BARE_TURN_ON,
)
):
_LOGGER.warning(
"%s: Config mismatch: `detect_non_ha_changes` or `adapt_only_on_bare_turn_on` "
Expand All @@ -871,13 +891,25 @@ def _set_changeable_settings(
self._name,
)
self._take_over_control = True
self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES]
self._adapt_only_on_bare_turn_on = data[CONF_ADAPT_ONLY_ON_BARE_TURN_ON]
self._detect_non_ha_changes = ensure_bool(
data[CONF_DETECT_NON_HA_CHANGES],
CONF_DETECT_NON_HA_CHANGES,
)
self._adapt_only_on_bare_turn_on = ensure_bool(
data[CONF_ADAPT_ONLY_ON_BARE_TURN_ON],
CONF_ADAPT_ONLY_ON_BARE_TURN_ON,
)
self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL]
self._skip_redundant_commands = data[CONF_SKIP_REDUNDANT_COMMANDS]
self._intercept = data[CONF_INTERCEPT]
self._multi_light_intercept = data[CONF_MULTI_LIGHT_INTERCEPT]
if not data[CONF_INTERCEPT] and data[CONF_MULTI_LIGHT_INTERCEPT]:
self._skip_redundant_commands = ensure_bool(
data[CONF_SKIP_REDUNDANT_COMMANDS],
CONF_SKIP_REDUNDANT_COMMANDS,
)
self._intercept = ensure_bool(data[CONF_INTERCEPT], CONF_INTERCEPT)
self._multi_light_intercept = ensure_bool(
data[CONF_MULTI_LIGHT_INTERCEPT],
CONF_MULTI_LIGHT_INTERCEPT,
)
if not self._intercept and self._multi_light_intercept:
_LOGGER.warning(
"%s: Config mismatch: `multi_light_intercept` set to `true` requires `intercept`"
" to be enabled. Adjusting config and continuing setup with"
Expand Down
65 changes: 65 additions & 0 deletions tests/test_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Tests for helper functions."""

import pytest

from custom_components.adaptive_lighting.helpers import ensure_bool


class TestEnsureBool:
"""Test the ensure_bool function."""

def test_boolean_values(self):
"""Test that boolean values are returned as-is."""
assert ensure_bool(True, "test") is True
assert ensure_bool(False, "test") is False

def test_string_true_values(self):
"""Test that string representations of true are converted correctly."""
assert ensure_bool("true", "test") is True
assert ensure_bool("True", "test") is True
assert ensure_bool("TRUE", "test") is True
assert ensure_bool("on", "test") is True
assert ensure_bool("On", "test") is True
assert ensure_bool("ON", "test") is True
assert ensure_bool("yes", "test") is True
assert ensure_bool("Yes", "test") is True
assert ensure_bool("YES", "test") is True
assert ensure_bool("1", "test") is True

def test_string_false_values(self):
"""Test that string representations of false are converted correctly."""
assert ensure_bool("false", "test") is False
assert ensure_bool("False", "test") is False
assert ensure_bool("FALSE", "test") is False
assert ensure_bool("off", "test") is False
assert ensure_bool("Off", "test") is False
assert ensure_bool("OFF", "test") is False
assert ensure_bool("no", "test") is False
assert ensure_bool("No", "test") is False
assert ensure_bool("NO", "test") is False
assert ensure_bool("0", "test") is False

def test_invalid_values(self):
"""Test that invalid values raise ValueError."""
with pytest.raises(ValueError, match="Parameter ‘test’ must be a Boolean"):
ensure_bool("invalid", "test")

with pytest.raises(ValueError, match="Parameter 'test' must be a Boolean"):
ensure_bool(123, "test")

with pytest.raises(ValueError, match="Parameter 'test' must be a Boolean"):
ensure_bool(None, "test")

with pytest.raises(ValueError, match="Parameter 'test' must be a Boolean"):
ensure_bool([], "test")

with pytest.raises(ValueError, match="Parameter 'test' must be a Boolean"):
ensure_bool({}, "test")

def test_error_message_includes_value(self):
"""Test that error messages include the actual value."""
with pytest.raises(ValueError, match="but is: 'invalid'"):
ensure_bool("invalid", "test")

with pytest.raises(ValueError, match="but is: 123"):
ensure_bool(123, "test")