Compare commits

...

4 Commits

Author SHA1 Message Date
Bram Kragten
7dae0db0bd review 2026-03-24 13:40:19 +01:00
Bram Kragten
1ef246d04f review 2026-03-24 11:32:12 +01:00
Bram Kragten
5b83cc0c03 extract relevant data 2026-03-24 09:29:48 +01:00
Bram Kragten
49068ad9bc Add numeric threshold selector 2026-03-23 14:55:15 +01:00
2 changed files with 385 additions and 0 deletions

View File

@@ -1316,6 +1316,218 @@ class NumberSelector(Selector[NumberSelectorConfig]):
return value
class NumericThresholdSelectorConfig(BaseSelectorConfig, total=False):
"""Class to represent a numeric threshold selector config."""
unit_of_measurement: list[str]
number: NumberSelectorConfig
entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig]
class NumericThresholdType(StrEnum):
"""Possible threshold types for a numeric threshold selector."""
ABOVE = "above"
BELOW = "below"
BETWEEN = "between"
OUTSIDE = "outside"
class NumericThresholdActiveChoice(StrEnum):
"""Possible active choices for a numeric threshold value entry."""
NUMBER = "number"
ENTITY = "entity"
def _extract_numeric_threshold_entry(data: dict[str, Any]) -> dict[str, Any]:
"""Extract only the relevant fields from a threshold value entry.
When active_choice is present, keep only the chosen field and
unit_of_measurement (only for the number choice), then drop active_choice.
"""
active_choice = data.get("active_choice")
if active_choice is None:
return data
if active_choice == NumericThresholdActiveChoice.ENTITY:
return {"entity": data["entity"]}
result: dict[str, Any] = {"number": data["number"]}
if "unit_of_measurement" in data:
result["unit_of_measurement"] = data["unit_of_measurement"]
return result
def _validate_numeric_threshold_active_choice(
data: dict[str, Any],
) -> dict[str, Any]:
"""Validate that active_choice matches an existing key in the entry."""
active_choice = data.get("active_choice")
if active_choice is not None and active_choice not in data:
raise vol.Invalid(
f"active_choice is '{active_choice}' but '{active_choice}' key is missing"
)
if active_choice is None and "number" in data and "entity" in data:
raise vol.Invalid(
"Value entry contains both 'number' and 'entity';"
" set 'active_choice' to disambiguate"
)
return data
_NUMERIC_THRESHOLD_VALUE_ENTRY_SCHEMA = vol.All(
vol.Schema(
{
vol.Optional("active_choice"): vol.All(
vol.Coerce(NumericThresholdActiveChoice), lambda val: val.value
),
vol.Optional("number"): vol.Coerce(float),
vol.Optional("entity"): cv.entity_id,
vol.Optional("unit_of_measurement"): str,
}
),
vol.Any(
vol.Schema({vol.Required("number"): object}, extra=vol.ALLOW_EXTRA),
vol.Schema({vol.Required("entity"): object}, extra=vol.ALLOW_EXTRA),
msg="Value entry must contain at least one of 'number' or 'entity'",
),
_validate_numeric_threshold_active_choice,
_extract_numeric_threshold_entry,
)
def _validate_numeric_threshold_range[_T: dict[str, Any]](value: _T) -> _T:
"""Validate that value_min is not greater than value_max for numeric entries."""
threshold_type = value.get("type")
if threshold_type not in (
NumericThresholdType.BETWEEN,
NumericThresholdType.OUTSIDE,
):
return value
min_entry = value.get("value_min", {})
max_entry = value.get("value_max", {})
min_number = min_entry.get("number")
max_number = max_entry.get("number")
if min_number is not None and max_number is not None and min_number > max_number:
raise vol.Invalid(
f"value_min ({min_number}) must not be greater than"
f" value_max ({max_number})"
)
return value
_NUMERIC_THRESHOLD_VALUE_SCHEMA = vol.All(
vol.Any(
vol.Schema(
{
vol.Required("type"): vol.In(
[NumericThresholdType.ABOVE, NumericThresholdType.BELOW]
),
vol.Required("value"): _NUMERIC_THRESHOLD_VALUE_ENTRY_SCHEMA,
}
),
vol.Schema(
{
vol.Required("type"): vol.In(
[NumericThresholdType.BETWEEN, NumericThresholdType.OUTSIDE]
),
vol.Required("value_min"): _NUMERIC_THRESHOLD_VALUE_ENTRY_SCHEMA,
vol.Required("value_max"): _NUMERIC_THRESHOLD_VALUE_ENTRY_SCHEMA,
}
),
),
_validate_numeric_threshold_range,
)
def _validate_numeric_threshold_unit[_T: dict[str, Any]](
allowed_units: list[str],
) -> Callable[[_T], _T]:
"""Generate a validator that checks unit_of_measurement against an allowed list."""
def _validate(value: _T) -> _T:
threshold_type = value.get("type")
if threshold_type in (
NumericThresholdType.ABOVE,
NumericThresholdType.BELOW,
):
entries: tuple[dict[str, Any], ...] = (value.get("value", {}),)
else:
entries = (value.get("value_min", {}), value.get("value_max", {}))
for entry in entries:
unit = entry.get("unit_of_measurement")
if unit is not None and unit not in allowed_units:
raise vol.Invalid(
f"Invalid unit_of_measurement '{unit}',"
f" expected one of {allowed_units}"
)
return value
return _validate
def _validate_numeric_threshold_number_range[_T: dict[str, Any]](
number_config: dict[str, Any],
) -> Callable[[_T], _T]:
"""Generate a validator that checks numeric values against min/max config."""
min_value: float | None = number_config.get("min")
max_value: float | None = number_config.get("max")
def _validate(value: _T) -> _T:
threshold_type = value.get("type")
if threshold_type in (
NumericThresholdType.ABOVE,
NumericThresholdType.BELOW,
):
entries: tuple[dict[str, Any], ...] = (value.get("value", {}),)
else:
entries = (value.get("value_min", {}), value.get("value_max", {}))
for entry in entries:
number = entry.get("number")
if number is None:
continue
if min_value is not None and number < min_value:
raise vol.Invalid(
f"Value {number} is less than the minimum {min_value}"
)
if max_value is not None and number > max_value:
raise vol.Invalid(
f"Value {number} is greater than the maximum {max_value}"
)
return value
return _validate
@SELECTORS.register("numeric_threshold")
class NumericThresholdSelector(Selector[NumericThresholdSelectorConfig]):
"""Selector of a numeric threshold condition."""
selector_type = "numeric_threshold"
CONFIG_SCHEMA = make_selector_config_schema(
{
vol.Optional("unit_of_measurement"): [str],
vol.Optional("number"): NumberSelector.CONFIG_SCHEMA,
vol.Optional("entity"): vol.All(
cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA]
),
}
)
def __init__(self, config: NumericThresholdSelectorConfig | None = None) -> None:
"""Instantiate a selector."""
super().__init__(config)
def __call__(self, data: Any) -> Any:
"""Validate the passed selection."""
validators: list[Callable[[Any], Any]] = [_NUMERIC_THRESHOLD_VALUE_SCHEMA]
if allowed_units := self.config.get("unit_of_measurement"):
validators.append(_validate_numeric_threshold_unit(allowed_units))
if number_config := cast(dict[str, Any] | None, self.config.get("number")):
validators.append(_validate_numeric_threshold_number_range(number_config))
return vol.All(*validators)(data)
class ObjectSelectorField(TypedDict, total=False):
"""Class to represent an object selector fields dict."""

View File

@@ -517,6 +517,179 @@ def test_number_selector_schema_error(schema) -> None:
selector.validate_selector({"number": schema})
@pytest.mark.parametrize(
("schema", "valid_selections", "invalid_selections"),
[
(
None,
({"type": "above", "value": {"number": 10}},),
(),
),
(
{},
(
{"type": "above", "value": {"number": 10}},
{"type": "below", "value": {"entity": "sensor.temperature"}},
{
"type": "between",
"value_min": {"number": 10},
"value_max": {"number": 20},
},
{
"type": "outside",
"value_min": {"number": 10},
"value_max": {"entity": "sensor.max_temp"},
},
),
(
None,
"not_a_dict",
{},
{"type": "above"}, # Missing value
{"type": "below"}, # Missing value
{"type": "between", "value_min": {"number": 10}}, # Missing value_max
{"type": "outside", "value_max": {"number": 20}}, # Missing value_min
{"type": "above", "value": {}}, # Entry missing number and entity
{"type": "above", "value": {"number": 10}, "extra": "key"},
{"type": "invalid_type", "value": {"number": 10}},
{
"type": "above",
"value": {"active_choice": "invalid", "number": 10},
}, # Invalid active_choice
{
"type": "above",
"value": {"active_choice": "number", "entity": "sensor.foo"},
}, # active_choice "number" but only entity key present
{
"type": "above",
"value": {"active_choice": "entity", "number": 10},
}, # active_choice "entity" but only number key present
{
"type": "between",
"value_min": {"number": 20},
"value_max": {"number": 10},
}, # value_min > value_max
{
"type": "above",
"value": {"number": 10, "entity": "sensor.foo"},
}, # Both number and entity without active_choice
),
),
(
{"unit_of_measurement": ["°C", "°F"]},
(
{
"type": "between",
"value_min": {"number": 10},
"value_max": {"number": 20},
},
{
"type": "above",
"value": {"number": 10, "unit_of_measurement": "°C"},
},
),
(
{
"type": "above",
"value": {"number": 10, "unit_of_measurement": "K"},
}, # Unit not in allowed list
),
),
(
{"number": {"min": 0, "max": 100}},
({"type": "above", "value": {"number": 50}},),
(
{"type": "above", "value": {"number": -1}}, # Below min
{"type": "above", "value": {"number": 101}}, # Above max
),
),
(
{"entity": {"domain": "sensor"}},
({"type": "above", "value": {"entity": "sensor.temperature"}},),
(),
),
(
{"entity": [{"domain": "sensor"}, {"domain": "input_number"}]},
({"type": "above", "value": {"entity": "sensor.temperature"}},),
(),
),
],
)
def test_numeric_threshold_selector_schema(
schema: dict[str, Any],
valid_selections: tuple[Any, ...],
invalid_selections: tuple[Any, ...],
) -> None:
"""Test numeric threshold selector."""
_test_selector("numeric_threshold", schema, valid_selections, invalid_selections)
@pytest.mark.parametrize(
("value_in", "value_out"),
[
# No active_choice: pass through unchanged
(
{"type": "above", "value": {"number": 10}},
{"type": "above", "value": {"number": 10.0}},
),
(
{"type": "below", "value": {"entity": "sensor.temperature"}},
{"type": "below", "value": {"entity": "sensor.temperature"}},
),
# active_choice "number": keep number + unit_of_measurement, drop rest
(
{"type": "above", "value": {"active_choice": "number", "number": 10}},
{"type": "above", "value": {"number": 10.0}},
),
(
{
"type": "above",
"value": {
"active_choice": "number",
"number": 5,
"unit_of_measurement": "°C",
},
},
{"type": "above", "value": {"number": 5.0, "unit_of_measurement": "°C"}},
),
# active_choice "entity": keep only entity, drop unit_of_measurement
(
{
"type": "below",
"value": {
"active_choice": "entity",
"entity": "sensor.temperature",
"unit_of_measurement": "°C",
},
},
{"type": "below", "value": {"entity": "sensor.temperature"}},
),
# active_choice in value_min / value_max
(
{
"type": "between",
"value_min": {"active_choice": "number", "number": 10},
"value_max": {
"active_choice": "entity",
"entity": "sensor.max_temp",
},
},
{
"type": "between",
"value_min": {"number": 10.0},
"value_max": {"entity": "sensor.max_temp"},
},
),
],
)
def test_numeric_threshold_selector_active_choice_extraction(
value_in: Any, value_out: Any
) -> None:
"""Test that active_choice is stripped and only the active field is kept."""
vol_schema = vol.Schema({"selection": selector.selector({"numeric_threshold": {}})})
assert vol_schema({"selection": value_in}) == {"selection": value_out}
@pytest.mark.parametrize(
("schema", "valid_selections", "invalid_selections"),
[({}, ("abc123",), (None,))],