1103 lines
44 KiB
Python
1103 lines
44 KiB
Python
"""Shared trigger configuration mixin for config flow and options flow.
|
|
|
|
This mixin provides the sensor trigger configuration steps that are shared
|
|
between MaintenanceSupporterConfigFlow and MaintenanceOptionsFlow. Each
|
|
consuming class provides thin async_step_* wrappers that delegate to the
|
|
mixin methods with the appropriate step_id and completion callback.
|
|
|
|
Consuming classes must provide:
|
|
- self._trigger_entity_id: str | None
|
|
- self._trigger_entity_state: Any (HA State object or None)
|
|
- self._current_task: dict[str, Any]
|
|
- self.hass: HomeAssistant
|
|
- self.async_show_form(): from ConfigFlow / OptionsFlow
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
from collections.abc import Awaitable, Callable
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Mapping
|
|
|
|
from homeassistant.core import HomeAssistant, State
|
|
|
|
import voluptuous as vol
|
|
from homeassistant.config_entries import ConfigFlowResult
|
|
from homeassistant.helpers import selector
|
|
|
|
from .config_flow_helpers import (
|
|
apply_interval_unit,
|
|
async_get_threshold_suggestions,
|
|
format_threshold_placeholders,
|
|
interval_unit_selector,
|
|
)
|
|
from .const import (
|
|
CONF_COMPOUND_CONDITIONS,
|
|
CONF_COMPOUND_LOGIC,
|
|
CONF_TASK_INTERVAL_DAYS,
|
|
CONF_TASK_INTERVAL_UNIT,
|
|
CONF_TASK_SCHEDULE_TYPE,
|
|
CONF_TASK_WARNING_DAYS,
|
|
CONF_TRIGGER_ABOVE,
|
|
CONF_TRIGGER_ATTRIBUTE,
|
|
CONF_TRIGGER_BELOW,
|
|
CONF_TRIGGER_DELTA_MODE,
|
|
CONF_TRIGGER_ENTITY,
|
|
CONF_TRIGGER_ENTITY_LOGIC,
|
|
CONF_TRIGGER_FOR_MINUTES,
|
|
CONF_TRIGGER_FROM_STATE,
|
|
CONF_TRIGGER_ON_STATES,
|
|
CONF_TRIGGER_RUNTIME_HOURS,
|
|
CONF_TRIGGER_TARGET_CHANGES,
|
|
CONF_TRIGGER_TARGET_VALUE,
|
|
CONF_TRIGGER_TO_STATE,
|
|
CONF_TRIGGER_TYPE,
|
|
DEFAULT_ENTITY_LOGIC,
|
|
ScheduleType,
|
|
TriggerType,
|
|
)
|
|
from .helpers.global_options import get_default_warning_days
|
|
from .helpers.task_fields import WARNING_DAYS_RANGE
|
|
|
|
# Domains allowed for trigger entity selection.
|
|
# Includes all domains from entity_attributes.DOMAIN_ATTRIBUTE_MAP plus
|
|
# input helpers and switches that are commonly used as trigger sources.
|
|
TRIGGER_ENTITY_DOMAINS = [
|
|
"sensor",
|
|
"binary_sensor",
|
|
"number",
|
|
"input_number",
|
|
"input_boolean",
|
|
"switch",
|
|
"climate",
|
|
"vacuum",
|
|
"cover",
|
|
"fan",
|
|
"light",
|
|
"water_heater",
|
|
"humidifier",
|
|
"media_player",
|
|
"weather",
|
|
"air_quality",
|
|
"valve",
|
|
"lawn_mower",
|
|
"lock",
|
|
]
|
|
|
|
|
|
class TriggerConfigMixin:
|
|
"""Shared sensor trigger configuration logic for ConfigFlow and OptionsFlow.
|
|
|
|
Consuming classes may set ``_on_cancel`` to a callable returning
|
|
``ConfigFlowResult`` to enable a *go back* toggle on every mixin form.
|
|
When ``_on_cancel`` is ``None`` (the default), no toggle is shown.
|
|
"""
|
|
|
|
# -- attributes provided by the consuming ConfigFlow / OptionsFlow class --
|
|
if TYPE_CHECKING:
|
|
hass: HomeAssistant
|
|
_current_task: dict[str, Any]
|
|
_trigger_entity_id: str | None
|
|
_trigger_entity_state: State | None
|
|
_trigger_entity_ids: list[str]
|
|
_compound_conditions: list[dict[str, Any]]
|
|
_compound_logic: str
|
|
_current_compound_condition: dict[str, Any]
|
|
|
|
def async_show_form(
|
|
self,
|
|
*,
|
|
step_id: str | None = None,
|
|
data_schema: vol.Schema | None = None,
|
|
errors: dict[str, str] | None = None,
|
|
description_placeholders: Mapping[str, str] | None = None,
|
|
last_step: bool | None = None,
|
|
preview: str | None = None,
|
|
) -> ConfigFlowResult: ...
|
|
|
|
def async_abort(
|
|
self,
|
|
*,
|
|
reason: str,
|
|
description_placeholders: Mapping[str, str] | None = None,
|
|
**kwargs: Any,
|
|
) -> ConfigFlowResult: ...
|
|
|
|
_on_cancel: Callable[[], ConfigFlowResult | Awaitable[ConfigFlowResult]] | None = None
|
|
|
|
async def _mixin_check_go_back(self, user_input: dict[str, Any] | None) -> ConfigFlowResult | None:
|
|
"""Return cancel result when user checked go_back, else None.
|
|
|
|
Handles both sync callbacks (options flow) and async callbacks
|
|
(config flow step methods).
|
|
"""
|
|
if user_input and user_input.get("go_back") and self._on_cancel is not None:
|
|
result = self._on_cancel()
|
|
if inspect.isawaitable(result):
|
|
return await result
|
|
return result
|
|
return None
|
|
|
|
def _mixin_add_go_back(self, schema_dict: dict[Any, Any]) -> dict[Any, Any]:
|
|
"""Append go_back toggle to schema dict when cancelling is enabled."""
|
|
if self._on_cancel is not None:
|
|
schema_dict[vol.Optional("go_back", default=False)] = selector.BooleanSelector()
|
|
return schema_dict
|
|
|
|
async def _trigger_sensor_select(
|
|
self,
|
|
user_input: dict[str, Any] | None,
|
|
*,
|
|
step_id: str,
|
|
next_step: Callable[[], Awaitable[ConfigFlowResult]],
|
|
default_entities: list[str] | None = None,
|
|
) -> ConfigFlowResult:
|
|
"""Core logic for sensor entity selection.
|
|
|
|
Accepts a single entity_id or a list of entity_ids. When multiple
|
|
entities are selected, uses the first one for attribute discovery.
|
|
"""
|
|
errors: dict[str, str] = {}
|
|
|
|
if user_input is not None:
|
|
cancel = await self._mixin_check_go_back(user_input)
|
|
if cancel is not None:
|
|
return cancel
|
|
|
|
raw = user_input[CONF_TRIGGER_ENTITY]
|
|
# EntitySelector with multiple=True returns a list
|
|
entity_ids = raw if isinstance(raw, list) else [raw]
|
|
|
|
if not entity_ids:
|
|
errors[CONF_TRIGGER_ENTITY] = "invalid_entity"
|
|
else:
|
|
# Validate all entities, not just the first
|
|
missing = [eid for eid in entity_ids if self.hass.states.get(eid) is None]
|
|
if missing:
|
|
errors[CONF_TRIGGER_ENTITY] = "invalid_entity"
|
|
else:
|
|
state = self.hass.states.get(entity_ids[0])
|
|
self._trigger_entity_id = entity_ids[0]
|
|
self._trigger_entity_state = state
|
|
# Store all selected entity_ids for multi-entity support
|
|
if not hasattr(self, "_trigger_entity_ids"):
|
|
self._trigger_entity_ids = []
|
|
self._trigger_entity_ids = entity_ids
|
|
return await next_step()
|
|
|
|
entity_key = (
|
|
vol.Required(CONF_TRIGGER_ENTITY, default=default_entities) if default_entities else vol.Required(CONF_TRIGGER_ENTITY)
|
|
)
|
|
schema_dict: dict[Any, Any] = {
|
|
entity_key: selector.EntitySelector(
|
|
selector.EntitySelectorConfig(
|
|
domain=TRIGGER_ENTITY_DOMAINS,
|
|
multiple=True,
|
|
)
|
|
),
|
|
}
|
|
return self.async_show_form(
|
|
step_id=step_id,
|
|
data_schema=vol.Schema(self._mixin_add_go_back(schema_dict)),
|
|
errors=errors,
|
|
)
|
|
|
|
async def _trigger_sensor_attribute(
|
|
self,
|
|
user_input: dict[str, Any] | None,
|
|
*,
|
|
step_id: str,
|
|
next_step: Callable[[], Awaitable[ConfigFlowResult]],
|
|
error_step_id: str,
|
|
) -> ConfigFlowResult:
|
|
"""Core logic for attribute selection."""
|
|
if user_input is not None:
|
|
cancel = await self._mixin_check_go_back(user_input)
|
|
if cancel is not None:
|
|
return cancel
|
|
|
|
attr = user_input.get(CONF_TRIGGER_ATTRIBUTE, "_state")
|
|
entity_ids = getattr(self, "_trigger_entity_ids", [self._trigger_entity_id])
|
|
# Rebuilding from form fields — carry over the panel-managed
|
|
# recovery flag (#53) so an options-flow trigger edit doesn't
|
|
# silently drop it.
|
|
prev_tc = self._current_task.get("trigger_config") or {}
|
|
self._current_task["trigger_config"] = {
|
|
"entity_id": entity_ids[0] if entity_ids else self._trigger_entity_id,
|
|
"entity_ids": entity_ids,
|
|
"attribute": None if attr == "_state" else attr,
|
|
**({"auto_complete_on_recovery": True} if prev_tc.get("auto_complete_on_recovery") else {}),
|
|
}
|
|
return await next_step()
|
|
|
|
# Build attribute options from entity
|
|
state = self._trigger_entity_state
|
|
if state is None:
|
|
return self.async_abort(reason="entity_unavailable")
|
|
options: list[selector.SelectOptionDict] = []
|
|
|
|
# Add state value — always offer _state so that state_change and
|
|
# runtime triggers work with non-numeric entities (e.g. input_boolean).
|
|
unit = state.attributes.get("unit_of_measurement", "")
|
|
options.append(
|
|
selector.SelectOptionDict(
|
|
value="_state",
|
|
label=f"State: {state.state} {unit}".strip(),
|
|
)
|
|
)
|
|
|
|
# Add numeric attributes
|
|
for attr_name, attr_value in state.attributes.items():
|
|
if attr_name.startswith("_"):
|
|
continue
|
|
try:
|
|
float(attr_value)
|
|
options.append(
|
|
selector.SelectOptionDict(
|
|
value=attr_name,
|
|
label=f"{attr_name}: {attr_value}",
|
|
)
|
|
)
|
|
except (ValueError, TypeError):
|
|
continue
|
|
|
|
if not options:
|
|
# No numeric data available - show error on entity select step
|
|
return self.async_show_form(
|
|
step_id=error_step_id,
|
|
data_schema=vol.Schema(
|
|
{
|
|
vol.Required(CONF_TRIGGER_ENTITY): selector.EntitySelector(
|
|
selector.EntitySelectorConfig(
|
|
domain=TRIGGER_ENTITY_DOMAINS,
|
|
)
|
|
),
|
|
}
|
|
),
|
|
errors={CONF_TRIGGER_ENTITY: "invalid_entity"},
|
|
)
|
|
|
|
current_state = state.state
|
|
unit = state.attributes.get("unit_of_measurement", "")
|
|
|
|
schema_dict: dict[Any, Any] = {
|
|
vol.Required(CONF_TRIGGER_ATTRIBUTE, default="_state"): selector.SelectSelector(
|
|
selector.SelectSelectorConfig(
|
|
options=options,
|
|
mode=selector.SelectSelectorMode.LIST,
|
|
)
|
|
),
|
|
}
|
|
return self.async_show_form(
|
|
step_id=step_id,
|
|
data_schema=vol.Schema(self._mixin_add_go_back(schema_dict)),
|
|
description_placeholders={
|
|
"entity_id": self._trigger_entity_id or "",
|
|
"current_state": str(current_state),
|
|
"unit": unit,
|
|
},
|
|
)
|
|
|
|
async def _trigger_type_select(
|
|
self,
|
|
user_input: dict[str, Any] | None,
|
|
*,
|
|
step_id: str,
|
|
threshold_step: Callable[[], Awaitable[ConfigFlowResult]],
|
|
counter_step: Callable[[], Awaitable[ConfigFlowResult]],
|
|
state_change_step: Callable[[], Awaitable[ConfigFlowResult]],
|
|
runtime_step: Callable[[], Awaitable[ConfigFlowResult]],
|
|
compound_step: Callable[[], Awaitable[ConfigFlowResult]] | None = None,
|
|
) -> ConfigFlowResult:
|
|
"""Core logic for trigger type selection."""
|
|
if user_input is not None:
|
|
cancel = await self._mixin_check_go_back(user_input)
|
|
if cancel is not None:
|
|
return cancel
|
|
|
|
trigger_type = user_input[CONF_TRIGGER_TYPE]
|
|
self._current_task["trigger_config"]["type"] = trigger_type
|
|
|
|
if trigger_type == TriggerType.THRESHOLD:
|
|
return await threshold_step()
|
|
if trigger_type == TriggerType.COUNTER:
|
|
return await counter_step()
|
|
if trigger_type == TriggerType.RUNTIME:
|
|
return await runtime_step()
|
|
if trigger_type == TriggerType.COMPOUND and compound_step:
|
|
return await compound_step()
|
|
return await state_change_step()
|
|
|
|
trigger_options = [t.value for t in TriggerType]
|
|
|
|
schema_dict: dict[Any, Any] = {
|
|
vol.Required(CONF_TRIGGER_TYPE, default=TriggerType.THRESHOLD): selector.SelectSelector(
|
|
selector.SelectSelectorConfig(
|
|
options=trigger_options,
|
|
mode=selector.SelectSelectorMode.LIST,
|
|
translation_key="trigger_type",
|
|
)
|
|
),
|
|
}
|
|
return self.async_show_form(
|
|
step_id=step_id,
|
|
data_schema=vol.Schema(self._mixin_add_go_back(schema_dict)),
|
|
)
|
|
|
|
async def _trigger_threshold_config(
|
|
self,
|
|
user_input: dict[str, Any] | None,
|
|
*,
|
|
step_id: str,
|
|
on_complete: Callable[[], ConfigFlowResult],
|
|
) -> ConfigFlowResult:
|
|
"""Core logic for threshold trigger configuration."""
|
|
errors: dict[str, str] = {}
|
|
|
|
if user_input is not None:
|
|
cancel = await self._mixin_check_go_back(user_input)
|
|
if cancel is not None:
|
|
return cancel
|
|
|
|
above = user_input.get(CONF_TRIGGER_ABOVE)
|
|
below = user_input.get(CONF_TRIGGER_BELOW)
|
|
|
|
if above is None and below is None:
|
|
errors["base"] = "invalid_threshold"
|
|
else:
|
|
tc = self._current_task["trigger_config"]
|
|
if above is not None:
|
|
tc[CONF_TRIGGER_ABOVE] = above
|
|
if below is not None:
|
|
tc[CONF_TRIGGER_BELOW] = below
|
|
tc[CONF_TRIGGER_FOR_MINUTES] = user_input.get(CONF_TRIGGER_FOR_MINUTES, 0)
|
|
|
|
# Multi-entity: store entity_logic if multiple entities selected
|
|
entity_ids = tc.get("entity_ids", [])
|
|
if len(entity_ids) > 1:
|
|
tc[CONF_TRIGGER_ENTITY_LOGIC] = user_input.get(CONF_TRIGGER_ENTITY_LOGIC, DEFAULT_ENTITY_LOGIC)
|
|
|
|
self._current_task[CONF_TASK_SCHEDULE_TYPE] = ScheduleType.SENSOR_BASED
|
|
interval = user_input.get(CONF_TASK_INTERVAL_DAYS)
|
|
if interval and interval > 0:
|
|
self._current_task[CONF_TASK_INTERVAL_DAYS] = interval
|
|
apply_interval_unit(self._current_task, user_input)
|
|
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
|
|
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
|
|
)
|
|
|
|
return on_complete()
|
|
|
|
# Get statistics-based suggestions
|
|
attribute = self._current_task.get("trigger_config", {}).get("attribute", "state")
|
|
suggestions = await async_get_threshold_suggestions(self.hass, self._trigger_entity_id, self._current_task)
|
|
|
|
# Build schema fields
|
|
schema_fields: dict[Any, Any] = {
|
|
vol.Optional(CONF_TRIGGER_ABOVE): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
step="any",
|
|
)
|
|
),
|
|
vol.Optional(CONF_TRIGGER_BELOW): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
step="any",
|
|
)
|
|
),
|
|
vol.Optional(CONF_TRIGGER_FOR_MINUTES, default=0): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(min=0, max=1440, step=1, mode=selector.NumberSelectorMode.BOX)
|
|
),
|
|
}
|
|
|
|
# Add entity_logic selector when multiple entities are selected
|
|
entity_ids = self._current_task.get("trigger_config", {}).get("entity_ids", [])
|
|
if len(entity_ids) > 1:
|
|
schema_fields[vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC)] = selector.SelectSelector(
|
|
selector.SelectSelectorConfig(
|
|
options=[
|
|
selector.SelectOptionDict(value="any", label="Any entity triggers"),
|
|
selector.SelectOptionDict(value="all", label="All entities must trigger"),
|
|
],
|
|
mode=selector.SelectSelectorMode.LIST,
|
|
translation_key="entity_logic",
|
|
)
|
|
)
|
|
|
|
schema_fields.update(
|
|
{
|
|
vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=1,
|
|
max=3650,
|
|
step=1,
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
),
|
|
vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(),
|
|
vol.Optional(
|
|
CONF_TASK_WARNING_DAYS,
|
|
default=get_default_warning_days(self.hass),
|
|
): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
|
|
)
|
|
),
|
|
}
|
|
)
|
|
|
|
return self.async_show_form(
|
|
step_id=step_id,
|
|
data_schema=vol.Schema(self._mixin_add_go_back(schema_fields)),
|
|
errors=errors,
|
|
description_placeholders=format_threshold_placeholders(self._trigger_entity_id, attribute, suggestions),
|
|
)
|
|
|
|
async def _trigger_counter_config(
|
|
self,
|
|
user_input: dict[str, Any] | None,
|
|
*,
|
|
step_id: str,
|
|
on_complete: Callable[[], ConfigFlowResult],
|
|
) -> ConfigFlowResult:
|
|
"""Core logic for counter trigger configuration."""
|
|
if user_input is not None:
|
|
cancel = await self._mixin_check_go_back(user_input)
|
|
if cancel is not None:
|
|
return cancel
|
|
|
|
tc = self._current_task["trigger_config"]
|
|
tc[CONF_TRIGGER_TARGET_VALUE] = user_input[CONF_TRIGGER_TARGET_VALUE]
|
|
tc[CONF_TRIGGER_DELTA_MODE] = user_input.get(CONF_TRIGGER_DELTA_MODE, False)
|
|
|
|
# Multi-entity: store entity_logic if multiple entities selected
|
|
entity_ids = tc.get("entity_ids", [])
|
|
if len(entity_ids) > 1:
|
|
tc[CONF_TRIGGER_ENTITY_LOGIC] = user_input.get(CONF_TRIGGER_ENTITY_LOGIC, DEFAULT_ENTITY_LOGIC)
|
|
|
|
self._current_task[CONF_TASK_SCHEDULE_TYPE] = ScheduleType.SENSOR_BASED
|
|
interval = user_input.get(CONF_TASK_INTERVAL_DAYS)
|
|
if interval and interval > 0:
|
|
self._current_task[CONF_TASK_INTERVAL_DAYS] = interval
|
|
apply_interval_unit(self._current_task, user_input)
|
|
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
|
|
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
|
|
)
|
|
|
|
return on_complete()
|
|
|
|
current_value = ""
|
|
unit = ""
|
|
attribute = self._current_task.get("trigger_config", {}).get("attribute", "state")
|
|
|
|
if self._trigger_entity_state:
|
|
state = self._trigger_entity_state
|
|
unit = state.attributes.get("unit_of_measurement", "")
|
|
attr = self._current_task.get("trigger_config", {}).get("attribute")
|
|
if attr:
|
|
current_value = str(state.attributes.get(attr, ""))
|
|
else:
|
|
current_value = state.state
|
|
|
|
schema_fields: dict[Any, Any] = {
|
|
vol.Required(CONF_TRIGGER_TARGET_VALUE): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
step="any",
|
|
)
|
|
),
|
|
vol.Optional(CONF_TRIGGER_DELTA_MODE, default=False): selector.BooleanSelector(),
|
|
}
|
|
|
|
# Add entity_logic selector when multiple entities are selected
|
|
entity_ids = self._current_task.get("trigger_config", {}).get("entity_ids", [])
|
|
if len(entity_ids) > 1:
|
|
schema_fields[vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC)] = selector.SelectSelector(
|
|
selector.SelectSelectorConfig(
|
|
options=[
|
|
selector.SelectOptionDict(value="any", label="Any entity triggers"),
|
|
selector.SelectOptionDict(value="all", label="All entities must trigger"),
|
|
],
|
|
mode=selector.SelectSelectorMode.LIST,
|
|
translation_key="entity_logic",
|
|
)
|
|
)
|
|
|
|
schema_fields.update(
|
|
{
|
|
vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=1,
|
|
max=3650,
|
|
step=1,
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
),
|
|
vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(),
|
|
vol.Optional(
|
|
CONF_TASK_WARNING_DAYS,
|
|
default=get_default_warning_days(self.hass),
|
|
): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
|
|
)
|
|
),
|
|
}
|
|
)
|
|
|
|
return self.async_show_form(
|
|
step_id=step_id,
|
|
data_schema=vol.Schema(self._mixin_add_go_back(schema_fields)),
|
|
description_placeholders={
|
|
"entity_id": self._trigger_entity_id or "",
|
|
"attribute": attribute or "state",
|
|
"current_value": current_value,
|
|
"unit": unit,
|
|
},
|
|
)
|
|
|
|
async def _trigger_state_change_config(
|
|
self,
|
|
user_input: dict[str, Any] | None,
|
|
*,
|
|
step_id: str,
|
|
on_complete: Callable[[], ConfigFlowResult],
|
|
) -> ConfigFlowResult:
|
|
"""Core logic for state change trigger configuration."""
|
|
if user_input is not None:
|
|
cancel = await self._mixin_check_go_back(user_input)
|
|
if cancel is not None:
|
|
return cancel
|
|
|
|
tc = self._current_task["trigger_config"]
|
|
# HA states are lowercase; lowercase user input so "ON"/"OFF"
|
|
# match the actual state machine values.
|
|
from_state = (user_input.get(CONF_TRIGGER_FROM_STATE) or "").strip().lower()
|
|
if from_state:
|
|
tc[CONF_TRIGGER_FROM_STATE] = from_state
|
|
to_state = (user_input.get(CONF_TRIGGER_TO_STATE) or "").strip().lower()
|
|
if to_state:
|
|
tc[CONF_TRIGGER_TO_STATE] = to_state
|
|
tc[CONF_TRIGGER_TARGET_CHANGES] = user_input.get(CONF_TRIGGER_TARGET_CHANGES, 1)
|
|
|
|
# Multi-entity: store entity_logic if multiple entities selected
|
|
entity_ids = tc.get("entity_ids", [])
|
|
if len(entity_ids) > 1:
|
|
tc[CONF_TRIGGER_ENTITY_LOGIC] = user_input.get(CONF_TRIGGER_ENTITY_LOGIC, DEFAULT_ENTITY_LOGIC)
|
|
|
|
self._current_task[CONF_TASK_SCHEDULE_TYPE] = ScheduleType.SENSOR_BASED
|
|
interval = user_input.get(CONF_TASK_INTERVAL_DAYS)
|
|
if interval and interval > 0:
|
|
self._current_task[CONF_TASK_INTERVAL_DAYS] = interval
|
|
apply_interval_unit(self._current_task, user_input)
|
|
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
|
|
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
|
|
)
|
|
|
|
return on_complete()
|
|
|
|
schema_fields: dict[Any, Any] = {
|
|
vol.Optional(CONF_TRIGGER_FROM_STATE): selector.TextSelector(
|
|
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
|
|
),
|
|
vol.Optional(CONF_TRIGGER_TO_STATE): selector.TextSelector(
|
|
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
|
|
),
|
|
vol.Required(CONF_TRIGGER_TARGET_CHANGES, default=1): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=1,
|
|
max=10000,
|
|
step=1,
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
),
|
|
}
|
|
|
|
# Add entity_logic selector when multiple entities are selected
|
|
entity_ids = self._current_task.get("trigger_config", {}).get("entity_ids", [])
|
|
if len(entity_ids) > 1:
|
|
schema_fields[vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC)] = selector.SelectSelector(
|
|
selector.SelectSelectorConfig(
|
|
options=[
|
|
selector.SelectOptionDict(value="any", label="Any entity triggers"),
|
|
selector.SelectOptionDict(value="all", label="All entities must trigger"),
|
|
],
|
|
mode=selector.SelectSelectorMode.LIST,
|
|
translation_key="entity_logic",
|
|
)
|
|
)
|
|
|
|
schema_fields.update(
|
|
{
|
|
vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=1,
|
|
max=3650,
|
|
step=1,
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
),
|
|
vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(),
|
|
vol.Optional(
|
|
CONF_TASK_WARNING_DAYS,
|
|
default=get_default_warning_days(self.hass),
|
|
): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
|
|
)
|
|
),
|
|
}
|
|
)
|
|
|
|
return self.async_show_form(
|
|
step_id=step_id,
|
|
data_schema=vol.Schema(self._mixin_add_go_back(schema_fields)),
|
|
description_placeholders={
|
|
"entity_id": self._trigger_entity_id or "",
|
|
},
|
|
)
|
|
|
|
async def _trigger_runtime_config(
|
|
self,
|
|
user_input: dict[str, Any] | None,
|
|
*,
|
|
step_id: str,
|
|
on_complete: Callable[[], ConfigFlowResult],
|
|
) -> ConfigFlowResult:
|
|
"""Core logic for runtime trigger configuration."""
|
|
if user_input is not None:
|
|
cancel = await self._mixin_check_go_back(user_input)
|
|
if cancel is not None:
|
|
return cancel
|
|
|
|
tc = self._current_task["trigger_config"]
|
|
tc[CONF_TRIGGER_RUNTIME_HOURS] = user_input[CONF_TRIGGER_RUNTIME_HOURS]
|
|
|
|
# Parse comma-separated ON states
|
|
raw_states = user_input.get(CONF_TRIGGER_ON_STATES, "")
|
|
if raw_states and raw_states.strip():
|
|
tc[CONF_TRIGGER_ON_STATES] = [s.strip().lower() for s in raw_states.split(",") if s.strip()]
|
|
else:
|
|
tc.pop(CONF_TRIGGER_ON_STATES, None)
|
|
|
|
# Multi-entity: store entity_logic if multiple entities selected
|
|
entity_ids = tc.get("entity_ids", [])
|
|
if len(entity_ids) > 1:
|
|
tc[CONF_TRIGGER_ENTITY_LOGIC] = user_input.get(CONF_TRIGGER_ENTITY_LOGIC, DEFAULT_ENTITY_LOGIC)
|
|
|
|
self._current_task[CONF_TASK_SCHEDULE_TYPE] = ScheduleType.SENSOR_BASED
|
|
interval = user_input.get(CONF_TASK_INTERVAL_DAYS)
|
|
if interval and interval > 0:
|
|
self._current_task[CONF_TASK_INTERVAL_DAYS] = interval
|
|
apply_interval_unit(self._current_task, user_input)
|
|
self._current_task[CONF_TASK_WARNING_DAYS] = user_input.get(
|
|
CONF_TASK_WARNING_DAYS, get_default_warning_days(self.hass)
|
|
)
|
|
|
|
return on_complete()
|
|
|
|
# Pre-fill existing custom states for editing
|
|
current_tc = self._current_task.get("trigger_config", {})
|
|
existing_states = current_tc.get(CONF_TRIGGER_ON_STATES)
|
|
default_states = ", ".join(existing_states) if existing_states else ""
|
|
|
|
schema_fields: dict[Any, Any] = {
|
|
vol.Required(CONF_TRIGGER_RUNTIME_HOURS): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
step=1,
|
|
min=1,
|
|
max=100000,
|
|
unit_of_measurement="h",
|
|
)
|
|
),
|
|
vol.Optional(CONF_TRIGGER_ON_STATES, default=default_states): selector.TextSelector(
|
|
selector.TextSelectorConfig(
|
|
type=selector.TextSelectorType.TEXT,
|
|
)
|
|
),
|
|
}
|
|
|
|
# Add entity_logic selector when multiple entities are selected
|
|
entity_ids = self._current_task.get("trigger_config", {}).get("entity_ids", [])
|
|
if len(entity_ids) > 1:
|
|
schema_fields[vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC)] = selector.SelectSelector(
|
|
selector.SelectSelectorConfig(
|
|
options=[
|
|
selector.SelectOptionDict(value="any", label="Any entity triggers"),
|
|
selector.SelectOptionDict(value="all", label="All entities must trigger"),
|
|
],
|
|
mode=selector.SelectSelectorMode.LIST,
|
|
translation_key="entity_logic",
|
|
)
|
|
)
|
|
|
|
schema_fields.update(
|
|
{
|
|
vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=1,
|
|
max=3650,
|
|
step=1,
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
),
|
|
vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(),
|
|
vol.Optional(
|
|
CONF_TASK_WARNING_DAYS,
|
|
default=get_default_warning_days(self.hass),
|
|
): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
|
|
)
|
|
),
|
|
}
|
|
)
|
|
|
|
return self.async_show_form(
|
|
step_id=step_id,
|
|
data_schema=vol.Schema(self._mixin_add_go_back(schema_fields)),
|
|
description_placeholders={
|
|
"entity_id": self._trigger_entity_id or "",
|
|
},
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Compound trigger configuration steps
|
|
# ------------------------------------------------------------------
|
|
|
|
async def _trigger_compound_logic(
|
|
self,
|
|
user_input: dict[str, Any] | None,
|
|
*,
|
|
step_id: str,
|
|
next_step: Callable[[], Awaitable[ConfigFlowResult]],
|
|
) -> ConfigFlowResult:
|
|
"""Select compound trigger logic (AND/OR)."""
|
|
if user_input is not None:
|
|
cancel = await self._mixin_check_go_back(user_input)
|
|
if cancel is not None:
|
|
return cancel
|
|
|
|
logic = user_input.get(CONF_COMPOUND_LOGIC, "AND").upper()
|
|
# Carry over the panel-managed recovery flag (#53) across the
|
|
# compound rebuild, mirroring the flat-trigger path above.
|
|
prev_tc = self._current_task.get("trigger_config") or {}
|
|
self._current_task["trigger_config"] = {
|
|
"type": TriggerType.COMPOUND,
|
|
CONF_COMPOUND_LOGIC: logic,
|
|
CONF_COMPOUND_CONDITIONS: [],
|
|
**({"auto_complete_on_recovery": True} if prev_tc.get("auto_complete_on_recovery") else {}),
|
|
}
|
|
if not hasattr(self, "_compound_conditions"):
|
|
self._compound_conditions: list[dict[str, Any]] = []
|
|
self._compound_conditions = []
|
|
self._compound_logic = logic
|
|
return await next_step()
|
|
|
|
schema_dict: dict[Any, Any] = {
|
|
vol.Required(CONF_COMPOUND_LOGIC, default="and"): selector.SelectSelector(
|
|
selector.SelectSelectorConfig(
|
|
options=[
|
|
selector.SelectOptionDict(
|
|
value="and",
|
|
label="AND (all conditions must trigger)",
|
|
),
|
|
selector.SelectOptionDict(
|
|
value="or",
|
|
label="OR (any condition triggers)",
|
|
),
|
|
],
|
|
mode=selector.SelectSelectorMode.LIST,
|
|
translation_key="compound_logic",
|
|
)
|
|
),
|
|
}
|
|
return self.async_show_form(
|
|
step_id=step_id,
|
|
data_schema=vol.Schema(self._mixin_add_go_back(schema_dict)),
|
|
)
|
|
|
|
async def _trigger_compound_condition_entity(
|
|
self,
|
|
user_input: dict[str, Any] | None,
|
|
*,
|
|
step_id: str,
|
|
next_step: Callable[[], Awaitable[ConfigFlowResult]],
|
|
) -> ConfigFlowResult:
|
|
"""Select entity for a compound condition."""
|
|
errors: dict[str, str] = {}
|
|
|
|
if user_input is not None:
|
|
cancel = await self._mixin_check_go_back(user_input)
|
|
if cancel is not None:
|
|
return cancel
|
|
|
|
raw = user_input[CONF_TRIGGER_ENTITY]
|
|
entity_ids = raw if isinstance(raw, list) else [raw]
|
|
if not entity_ids:
|
|
errors[CONF_TRIGGER_ENTITY] = "invalid_entity"
|
|
else:
|
|
missing = [eid for eid in entity_ids if self.hass.states.get(eid) is None]
|
|
if missing:
|
|
errors[CONF_TRIGGER_ENTITY] = "invalid_entity"
|
|
else:
|
|
state = self.hass.states.get(entity_ids[0])
|
|
self._trigger_entity_id = entity_ids[0]
|
|
self._trigger_entity_state = state
|
|
self._trigger_entity_ids = entity_ids
|
|
self._current_compound_condition = {
|
|
"entity_id": entity_ids[0],
|
|
"entity_ids": entity_ids,
|
|
}
|
|
return await next_step()
|
|
|
|
cond_num = len(getattr(self, "_compound_conditions", [])) + 1
|
|
schema_dict: dict[Any, Any] = {
|
|
vol.Required(CONF_TRIGGER_ENTITY): selector.EntitySelector(
|
|
selector.EntitySelectorConfig(
|
|
domain=["sensor", "binary_sensor", "number", "input_number", "input_boolean", "switch"],
|
|
multiple=True,
|
|
)
|
|
),
|
|
}
|
|
return self.async_show_form(
|
|
step_id=step_id,
|
|
data_schema=vol.Schema(self._mixin_add_go_back(schema_dict)),
|
|
errors=errors,
|
|
description_placeholders={"condition_num": str(cond_num)},
|
|
)
|
|
|
|
async def _trigger_compound_condition_type(
|
|
self,
|
|
user_input: dict[str, Any] | None,
|
|
*,
|
|
step_id: str,
|
|
threshold_step: Callable[[], Awaitable[ConfigFlowResult]],
|
|
counter_step: Callable[[], Awaitable[ConfigFlowResult]],
|
|
state_change_step: Callable[[], Awaitable[ConfigFlowResult]],
|
|
runtime_step: Callable[[], Awaitable[ConfigFlowResult]],
|
|
) -> ConfigFlowResult:
|
|
"""Select trigger type for a compound condition."""
|
|
if user_input is not None:
|
|
cancel = await self._mixin_check_go_back(user_input)
|
|
if cancel is not None:
|
|
return cancel
|
|
|
|
trigger_type = user_input[CONF_TRIGGER_TYPE]
|
|
self._current_compound_condition["type"] = trigger_type
|
|
|
|
if trigger_type == TriggerType.THRESHOLD:
|
|
return await threshold_step()
|
|
if trigger_type == TriggerType.COUNTER:
|
|
return await counter_step()
|
|
if trigger_type == TriggerType.RUNTIME:
|
|
return await runtime_step()
|
|
return await state_change_step()
|
|
|
|
trigger_options = [t.value for t in TriggerType if t != TriggerType.COMPOUND]
|
|
|
|
schema_dict: dict[Any, Any] = {
|
|
vol.Required(CONF_TRIGGER_TYPE, default=TriggerType.THRESHOLD): selector.SelectSelector(
|
|
selector.SelectSelectorConfig(
|
|
options=trigger_options,
|
|
mode=selector.SelectSelectorMode.LIST,
|
|
translation_key="trigger_type",
|
|
)
|
|
),
|
|
}
|
|
return self.async_show_form(
|
|
step_id=step_id,
|
|
data_schema=vol.Schema(self._mixin_add_go_back(schema_dict)),
|
|
)
|
|
|
|
async def _trigger_compound_condition_config(
|
|
self,
|
|
user_input: dict[str, Any] | None,
|
|
condition_type: str,
|
|
*,
|
|
step_id: str,
|
|
on_complete: Callable[[], Awaitable[ConfigFlowResult]],
|
|
) -> ConfigFlowResult:
|
|
"""Configure a compound condition's type-specific settings."""
|
|
cond = self._current_compound_condition
|
|
|
|
if user_input is not None:
|
|
cancel = await self._mixin_check_go_back(user_input)
|
|
if cancel is not None:
|
|
return cancel
|
|
|
|
if condition_type == TriggerType.THRESHOLD:
|
|
above = user_input.get(CONF_TRIGGER_ABOVE)
|
|
below = user_input.get(CONF_TRIGGER_BELOW)
|
|
if above is not None:
|
|
cond["trigger_above"] = above
|
|
if below is not None:
|
|
cond["trigger_below"] = below
|
|
for_min = user_input.get(CONF_TRIGGER_FOR_MINUTES)
|
|
if for_min:
|
|
cond["trigger_for_minutes"] = for_min
|
|
elif condition_type == TriggerType.COUNTER:
|
|
cond["trigger_target_value"] = user_input.get(CONF_TRIGGER_TARGET_VALUE, 0)
|
|
cond["trigger_delta_mode"] = user_input.get(CONF_TRIGGER_DELTA_MODE, False)
|
|
elif condition_type == TriggerType.STATE_CHANGE:
|
|
from_state = (user_input.get(CONF_TRIGGER_FROM_STATE) or "").strip().lower()
|
|
if from_state:
|
|
cond["trigger_from_state"] = from_state
|
|
to_state = (user_input.get(CONF_TRIGGER_TO_STATE) or "").strip().lower()
|
|
if to_state:
|
|
cond["trigger_to_state"] = to_state
|
|
cond["trigger_target_changes"] = user_input.get(CONF_TRIGGER_TARGET_CHANGES, 1)
|
|
elif condition_type == TriggerType.RUNTIME:
|
|
cond["trigger_runtime_hours"] = user_input[CONF_TRIGGER_RUNTIME_HOURS]
|
|
raw_states = user_input.get(CONF_TRIGGER_ON_STATES, "")
|
|
if raw_states and raw_states.strip():
|
|
cond["trigger_on_states"] = [s.strip().lower() for s in raw_states.split(",") if s.strip()]
|
|
|
|
entity_ids = cond.get("entity_ids", [])
|
|
if len(entity_ids) > 1 and user_input.get(CONF_TRIGGER_ENTITY_LOGIC):
|
|
cond["entity_logic"] = user_input[CONF_TRIGGER_ENTITY_LOGIC]
|
|
|
|
self._compound_conditions.append(cond)
|
|
self._current_compound_condition = {}
|
|
return await on_complete()
|
|
|
|
schema_fields: dict[Any, Any] = {}
|
|
if condition_type == TriggerType.THRESHOLD:
|
|
schema_fields = {
|
|
vol.Optional(CONF_TRIGGER_ABOVE): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX)
|
|
),
|
|
vol.Optional(CONF_TRIGGER_BELOW): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX)
|
|
),
|
|
vol.Optional(CONF_TRIGGER_FOR_MINUTES, default=0): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=0,
|
|
max=1440,
|
|
step=1,
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
unit_of_measurement="min",
|
|
)
|
|
),
|
|
}
|
|
elif condition_type == TriggerType.COUNTER:
|
|
schema_fields = {
|
|
vol.Required(CONF_TRIGGER_TARGET_VALUE): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX)
|
|
),
|
|
vol.Optional(CONF_TRIGGER_DELTA_MODE, default=False): selector.BooleanSelector(),
|
|
}
|
|
elif condition_type == TriggerType.STATE_CHANGE:
|
|
schema_fields = {
|
|
vol.Optional(CONF_TRIGGER_FROM_STATE): selector.TextSelector(
|
|
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
|
|
),
|
|
vol.Optional(CONF_TRIGGER_TO_STATE): selector.TextSelector(
|
|
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
|
|
),
|
|
vol.Required(CONF_TRIGGER_TARGET_CHANGES, default=1): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=1,
|
|
max=10000,
|
|
step=1,
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
),
|
|
}
|
|
elif condition_type == TriggerType.RUNTIME:
|
|
schema_fields = {
|
|
vol.Required(CONF_TRIGGER_RUNTIME_HOURS): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
step=1,
|
|
min=1,
|
|
max=100000,
|
|
unit_of_measurement="h",
|
|
)
|
|
),
|
|
vol.Optional(CONF_TRIGGER_ON_STATES, default=""): selector.TextSelector(
|
|
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
|
|
),
|
|
}
|
|
|
|
entity_ids = cond.get("entity_ids", [])
|
|
if len(entity_ids) > 1:
|
|
schema_fields[vol.Optional(CONF_TRIGGER_ENTITY_LOGIC, default=DEFAULT_ENTITY_LOGIC)] = selector.SelectSelector(
|
|
selector.SelectSelectorConfig(
|
|
options=[
|
|
selector.SelectOptionDict(value="any", label="Any entity triggers"),
|
|
selector.SelectOptionDict(value="all", label="All entities must trigger"),
|
|
],
|
|
mode=selector.SelectSelectorMode.LIST,
|
|
translation_key="entity_logic",
|
|
)
|
|
)
|
|
|
|
return self.async_show_form(
|
|
step_id=step_id,
|
|
data_schema=vol.Schema(self._mixin_add_go_back(schema_fields)),
|
|
)
|
|
|
|
async def _trigger_compound_review(
|
|
self,
|
|
user_input: dict[str, Any] | None,
|
|
*,
|
|
step_id: str,
|
|
add_condition_step: Callable[[], Awaitable[ConfigFlowResult]],
|
|
on_complete: Callable[[], ConfigFlowResult],
|
|
) -> ConfigFlowResult:
|
|
"""Review compound trigger conditions and optionally add more."""
|
|
if user_input is not None:
|
|
cancel = await self._mixin_check_go_back(user_input)
|
|
if cancel is not None:
|
|
return cancel
|
|
|
|
action = user_input.get("compound_action", "finish")
|
|
if action == "add":
|
|
return await add_condition_step()
|
|
|
|
tc = self._current_task["trigger_config"]
|
|
tc[CONF_COMPOUND_CONDITIONS] = list(self._compound_conditions)
|
|
tc[CONF_COMPOUND_LOGIC] = self._compound_logic
|
|
self._current_task[CONF_TASK_SCHEDULE_TYPE] = ScheduleType.SENSOR_BASED
|
|
return on_complete()
|
|
|
|
condition_count = len(self._compound_conditions)
|
|
logic = getattr(self, "_compound_logic", "AND")
|
|
|
|
options = [
|
|
selector.SelectOptionDict(
|
|
value="finish",
|
|
label=f"Finish ({condition_count} conditions, {logic})",
|
|
),
|
|
]
|
|
if condition_count < 5:
|
|
options.append(
|
|
selector.SelectOptionDict(
|
|
value="add",
|
|
label="Add another condition",
|
|
),
|
|
)
|
|
|
|
schema_dict: dict[Any, Any] = {
|
|
vol.Required("compound_action", default="finish"): selector.SelectSelector(
|
|
selector.SelectSelectorConfig(
|
|
options=options,
|
|
mode=selector.SelectSelectorMode.LIST,
|
|
)
|
|
),
|
|
}
|
|
return self.async_show_form(
|
|
step_id=step_id,
|
|
data_schema=vol.Schema(self._mixin_add_go_back(schema_dict)),
|
|
description_placeholders={
|
|
"condition_count": str(condition_count),
|
|
"compound_logic": logic,
|
|
},
|
|
)
|