mirror of
https://github.com/home-assistant/core.git
synced 2026-03-24 13:08:42 +00:00
Compare commits
4 Commits
todo_trigg
...
numeric-th
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dae0db0bd | ||
|
|
1ef246d04f | ||
|
|
5b83cc0c03 | ||
|
|
49068ad9bc |
@@ -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."""
|
||||
|
||||
|
||||
@@ -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,))],
|
||||
|
||||
Reference in New Issue
Block a user