393 files
This commit is contained in:
@@ -59,6 +59,8 @@ from .const import (
|
||||
CONF_TRIGGER_TO_STATE,
|
||||
CONF_TRIGGER_TYPE,
|
||||
DEFAULT_ENTITY_LOGIC,
|
||||
TRIGGER_FIELD_RANGES,
|
||||
TRIGGER_RUNTIME_HOURS_MAX,
|
||||
ScheduleType,
|
||||
TriggerType,
|
||||
)
|
||||
@@ -137,10 +139,24 @@ def _entity_logic_field(entity_ids: list[Any]) -> dict[Any, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _interval_warning_fields(hass: HomeAssistant, tc: dict[str, Any] | None = None) -> dict[Any, Any]:
|
||||
"""The safety-interval + warning-days tail shared by all four type steps."""
|
||||
def _interval_warning_fields(
|
||||
hass: HomeAssistant, tc: dict[str, Any] | None = None, stored: dict[str, Any] | None = None
|
||||
) -> dict[Any, Any]:
|
||||
"""The safety-interval + warning-days tail shared by all four type steps.
|
||||
|
||||
``stored``: when editing an existing trigger, the task's current interval,
|
||||
unit and warning days — without them the form showed "days" and the
|
||||
global warning default and saved them over the task (bug audit
|
||||
2026-09-26).
|
||||
"""
|
||||
stored = stored or {}
|
||||
interval_key = (
|
||||
vol.Optional(CONF_TASK_INTERVAL_DAYS, description={"suggested_value": stored["interval_days"]})
|
||||
if stored.get("interval_days")
|
||||
else vol.Optional(CONF_TASK_INTERVAL_DAYS)
|
||||
)
|
||||
return {
|
||||
vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector(
|
||||
interval_key: selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=INTERVAL_DAYS_RANGE[0],
|
||||
max=INTERVAL_DAYS_RANGE[1],
|
||||
@@ -148,7 +164,7 @@ def _interval_warning_fields(hass: HomeAssistant, tc: dict[str, Any] | None = No
|
||||
mode=selector.NumberSelectorMode.BOX,
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(),
|
||||
vol.Optional(CONF_TASK_INTERVAL_UNIT, default=stored.get("interval_unit") or "days"): interval_unit_selector(),
|
||||
vol.Optional(
|
||||
CONF_TRIGGER_COMBINATOR,
|
||||
default=(tc or {}).get(CONF_TRIGGER_COMBINATOR, DEFAULT_ENTITY_LOGIC),
|
||||
@@ -164,7 +180,7 @@ def _interval_warning_fields(hass: HomeAssistant, tc: dict[str, Any] | None = No
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_TASK_WARNING_DAYS,
|
||||
default=get_default_warning_days(hass),
|
||||
default=stored["warning_days"] if stored.get("warning_days") is not None else get_default_warning_days(hass),
|
||||
): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX
|
||||
@@ -195,6 +211,19 @@ def _state_selector(entity_id: str | None, *, multiple: bool = False) -> Any:
|
||||
return selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT))
|
||||
|
||||
|
||||
def _delta_target_invalid(user_input: dict[str, Any]) -> bool:
|
||||
"""A delta counter fires every N units of use — N must be positive.
|
||||
|
||||
The WS validator (``_validate_trigger_config``) refuses a target ≤ 0 in
|
||||
delta mode, but the flow-built trigger never went through it: a flow
|
||||
could save a counter that fired on every reading (bug audit 2026-09-26).
|
||||
"""
|
||||
if not user_input.get(CONF_TRIGGER_DELTA_MODE):
|
||||
return False
|
||||
target = user_input.get(CONF_TRIGGER_TARGET_VALUE)
|
||||
return target is None or target <= 0
|
||||
|
||||
|
||||
def _parse_states(raw: Any) -> list[str]:
|
||||
"""Normalize an on-states submission — list from the state selector,
|
||||
comma string from the legacy text fallback."""
|
||||
@@ -205,6 +234,76 @@ def _parse_states(raw: Any) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
# ─── Field builders shared by the plain type steps and the compound
|
||||
# conditions (DRY audit 2026-09-26 B): every selector below was spelled out
|
||||
# once per surface; the bounds come from const.TRIGGER_FIELD_RANGES — the
|
||||
# ranges the WS trigger validator enforces.
|
||||
|
||||
|
||||
def _decimal_selector() -> selector.NumberSelector:
|
||||
"""A free decimal box. step="any": the default step of 1 made the browser
|
||||
refuse a limit such as 0.5 bar (bug audit 2026-09-26)."""
|
||||
return selector.NumberSelector(selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX, step="any"))
|
||||
|
||||
|
||||
def _limit_fields() -> dict[Any, Any]:
|
||||
"""The four optional threshold limits (above / below / = / ≠)."""
|
||||
return {
|
||||
vol.Optional(key): _decimal_selector()
|
||||
for key in (CONF_TRIGGER_ABOVE, CONF_TRIGGER_BELOW, CONF_TRIGGER_EQUALS, CONF_TRIGGER_NOT_EQUALS)
|
||||
}
|
||||
|
||||
|
||||
def _for_minutes_field() -> dict[Any, Any]:
|
||||
"""#136 hold time: the limit / new state must persist this long (0 = at once)."""
|
||||
low, high = TRIGGER_FIELD_RANGES[CONF_TRIGGER_FOR_MINUTES]
|
||||
return {
|
||||
vol.Optional(CONF_TRIGGER_FOR_MINUTES, default=0): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=low, max=high, step=1, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="min"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _target_value_field() -> dict[Any, Any]:
|
||||
"""The counter's target (absolute, or the delta per cycle)."""
|
||||
return {vol.Required(CONF_TRIGGER_TARGET_VALUE): _decimal_selector()}
|
||||
|
||||
|
||||
def _target_changes_field() -> dict[Any, Any]:
|
||||
"""How many matching state changes fire the trigger."""
|
||||
low, high = TRIGGER_FIELD_RANGES[CONF_TRIGGER_TARGET_CHANGES]
|
||||
return {
|
||||
vol.Required(CONF_TRIGGER_TARGET_CHANGES, default=1): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(min=low, max=high, step=1, mode=selector.NumberSelectorMode.BOX)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _runtime_hours_field() -> dict[Any, Any]:
|
||||
"""The runtime target in hours."""
|
||||
return {
|
||||
vol.Required(CONF_TRIGGER_RUNTIME_HOURS): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
mode=selector.NumberSelectorMode.BOX,
|
||||
step=1,
|
||||
min=1,
|
||||
max=TRIGGER_RUNTIME_HOURS_MAX,
|
||||
unit_of_measurement="h",
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _states_from_input(user_input: dict[str, Any]) -> tuple[str, str]:
|
||||
"""(from, to) of a state-change submission, trimmed and lowercased — HA
|
||||
states are lowercase, so "ON"/"OFF" must match the state machine."""
|
||||
from_state = (user_input.get(CONF_TRIGGER_FROM_STATE) or "").strip().lower()
|
||||
to_state = (user_input.get(CONF_TRIGGER_TO_STATE) or "").strip().lower()
|
||||
return from_state, to_state
|
||||
|
||||
|
||||
class TriggerConfigMixin:
|
||||
"""Shared sensor trigger configuration logic for ConfigFlow and OptionsFlow.
|
||||
|
||||
@@ -264,6 +363,42 @@ class TriggerConfigMixin:
|
||||
schema_dict[vol.Optional("go_back", default=False)] = selector.BooleanSelector()
|
||||
return schema_dict
|
||||
|
||||
def _selected_entities(self, raw: Any) -> list[str] | None:
|
||||
"""The submitted trigger entities (a list from the multi-select, a
|
||||
bare id from older forms), or None when empty or one of them does not
|
||||
exist. Shared by the trigger's and a compound condition's entity step."""
|
||||
entity_ids = raw if isinstance(raw, list) else [raw]
|
||||
if not entity_ids or any(self.hass.states.get(eid) is None for eid in entity_ids):
|
||||
return None
|
||||
return entity_ids
|
||||
|
||||
def _apply_type_step_tail(self, tc: dict[str, Any], user_input: dict[str, Any]) -> None:
|
||||
"""Persist what the four plain type steps share (DRY audit 2026-09-26 B):
|
||||
the #53 recovery flag, the trigger∧interval combinator, the any/all
|
||||
entity logic (2+ entities), the sensor schedule type and the optional
|
||||
safety interval + warning days."""
|
||||
_apply_recovery_flag(tc, user_input)
|
||||
_apply_combinator(tc, user_input)
|
||||
if len(tc.get("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))
|
||||
|
||||
def _type_step_tail_fields(self) -> dict[Any, Any]:
|
||||
"""The form twin of ``_apply_type_step_tail``: recovery checkbox, entity
|
||||
logic, safety interval + unit, combinator and warning days — in this
|
||||
order at the end of every plain type step."""
|
||||
tc = self._current_task.get("trigger_config") or {}
|
||||
return {
|
||||
**_recovery_field(tc),
|
||||
**_entity_logic_field(tc.get("entity_ids", [])),
|
||||
**_interval_warning_fields(self.hass, tc, self._current_task.get("_edit_defaults")),
|
||||
}
|
||||
|
||||
async def _trigger_sensor_select(
|
||||
self,
|
||||
user_input: dict[str, Any] | None,
|
||||
@@ -284,26 +419,16 @@ class TriggerConfigMixin:
|
||||
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:
|
||||
# Validates all entities, not just the first.
|
||||
entity_ids = self._selected_entities(user_input[CONF_TRIGGER_ENTITY])
|
||||
if entity_ids is None:
|
||||
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()
|
||||
self._trigger_entity_id = entity_ids[0]
|
||||
self._trigger_entity_state = self.hass.states.get(entity_ids[0])
|
||||
# Store all selected entity_ids for multi-entity support
|
||||
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)
|
||||
@@ -362,6 +487,14 @@ class TriggerConfigMixin:
|
||||
if "trigger_runtime_max_session_seconds" in prev_tc
|
||||
else {}
|
||||
),
|
||||
# The trigger∧interval combinator: the type steps default
|
||||
# their field from it — rebuilt without it, every edit
|
||||
# silently turned "all" back into "any".
|
||||
**(
|
||||
{CONF_TRIGGER_COMBINATOR: prev_tc[CONF_TRIGGER_COMBINATOR]}
|
||||
if prev_tc.get(CONF_TRIGGER_COMBINATOR)
|
||||
else {}
|
||||
),
|
||||
}
|
||||
return await next_step()
|
||||
|
||||
@@ -518,62 +651,18 @@ class TriggerConfigMixin:
|
||||
if not_equals is not None:
|
||||
tc[CONF_TRIGGER_NOT_EQUALS] = not_equals
|
||||
tc[CONF_TRIGGER_FOR_MINUTES] = user_input.get(CONF_TRIGGER_FOR_MINUTES, 0)
|
||||
_apply_recovery_flag(tc, user_input)
|
||||
_apply_combinator(tc, user_input)
|
||||
|
||||
# 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)
|
||||
)
|
||||
|
||||
self._apply_type_step_tail(tc, user_input)
|
||||
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_EQUALS): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
mode=selector.NumberSelectorMode.BOX,
|
||||
step="any",
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_TRIGGER_NOT_EQUALS): 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)
|
||||
),
|
||||
**_recovery_field(self._current_task.get("trigger_config")),
|
||||
**_limit_fields(),
|
||||
**_for_minutes_field(),
|
||||
**self._type_step_tail_fields(),
|
||||
}
|
||||
schema_fields.update(_entity_logic_field(self._current_task.get("trigger_config", {}).get("entity_ids", [])))
|
||||
schema_fields.update(_interval_warning_fields(self.hass, self._current_task.get("trigger_config")))
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=step_id,
|
||||
@@ -590,37 +679,25 @@ class TriggerConfigMixin:
|
||||
on_complete: Callable[[], ConfigFlowResult],
|
||||
) -> ConfigFlowResult:
|
||||
"""Core logic for counter 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
|
||||
if _delta_target_invalid(user_input):
|
||||
errors[CONF_TRIGGER_TARGET_VALUE] = "invalid_delta_target"
|
||||
|
||||
if user_input is not None and not errors:
|
||||
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)
|
||||
_apply_recovery_flag(tc, user_input)
|
||||
_apply_combinator(tc, user_input)
|
||||
# Counting start value (#102/#103): editable here since the parity
|
||||
# round — an omitted field keeps the value the attribute step
|
||||
# carried over; the backend clears stale Store state on change.
|
||||
baseline = user_input.get("trigger_baseline_value")
|
||||
if baseline is not None and baseline >= 0:
|
||||
tc["trigger_baseline_value"] = baseline
|
||||
|
||||
# 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)
|
||||
)
|
||||
|
||||
self._apply_type_step_tail(tc, user_input)
|
||||
return on_complete()
|
||||
|
||||
current_value = ""
|
||||
@@ -643,13 +720,13 @@ class TriggerConfigMixin:
|
||||
else vol.Optional("trigger_baseline_value")
|
||||
)
|
||||
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(),
|
||||
**_target_value_field(),
|
||||
vol.Optional(
|
||||
CONF_TRIGGER_DELTA_MODE,
|
||||
default=bool(
|
||||
((self._current_task.get("_edit_defaults") or {}).get("trigger_config") or prev_tc).get(CONF_TRIGGER_DELTA_MODE, False)
|
||||
),
|
||||
): selector.BooleanSelector(),
|
||||
baseline_key: selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0,
|
||||
@@ -657,14 +734,13 @@ class TriggerConfigMixin:
|
||||
step="any",
|
||||
)
|
||||
),
|
||||
**_recovery_field(prev_tc),
|
||||
**self._type_step_tail_fields(),
|
||||
}
|
||||
schema_fields.update(_entity_logic_field(self._current_task.get("trigger_config", {}).get("entity_ids", [])))
|
||||
schema_fields.update(_interval_warning_fields(self.hass, self._current_task.get("trigger_config")))
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=step_id,
|
||||
data_schema=vol.Schema(self._mixin_add_go_back(schema_fields)),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"entity_id": self._trigger_entity_id or "",
|
||||
"attribute": attribute or "state",
|
||||
@@ -687,55 +763,25 @@ class TriggerConfigMixin:
|
||||
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()
|
||||
from_state, to_state = _states_from_input(user_input)
|
||||
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)
|
||||
# #136: the new state must HOLD this long before a change counts
|
||||
# (0 = count immediately — some sensors pulse only briefly).
|
||||
tc[CONF_TRIGGER_FOR_MINUTES] = user_input.get(CONF_TRIGGER_FOR_MINUTES, 0)
|
||||
_apply_recovery_flag(tc, user_input)
|
||||
_apply_combinator(tc, user_input)
|
||||
|
||||
# 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)
|
||||
)
|
||||
|
||||
self._apply_type_step_tail(tc, user_input)
|
||||
return on_complete()
|
||||
|
||||
schema_fields: dict[Any, Any] = {
|
||||
vol.Optional(CONF_TRIGGER_FROM_STATE): _state_selector(self._trigger_entity_id),
|
||||
vol.Optional(CONF_TRIGGER_TO_STATE): _state_selector(self._trigger_entity_id),
|
||||
vol.Required(CONF_TRIGGER_TARGET_CHANGES, default=1): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=1,
|
||||
max=10000,
|
||||
step=1,
|
||||
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)
|
||||
),
|
||||
**_recovery_field(self._current_task.get("trigger_config")),
|
||||
**_target_changes_field(),
|
||||
**_for_minutes_field(),
|
||||
**self._type_step_tail_fields(),
|
||||
}
|
||||
schema_fields.update(_entity_logic_field(self._current_task.get("trigger_config", {}).get("entity_ids", [])))
|
||||
schema_fields.update(_interval_warning_fields(self.hass, self._current_task.get("trigger_config")))
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=step_id,
|
||||
@@ -766,23 +812,7 @@ class TriggerConfigMixin:
|
||||
tc[CONF_TRIGGER_ON_STATES] = states
|
||||
else:
|
||||
tc.pop(CONF_TRIGGER_ON_STATES, None)
|
||||
_apply_recovery_flag(tc, user_input)
|
||||
_apply_combinator(tc, user_input)
|
||||
|
||||
# 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)
|
||||
)
|
||||
|
||||
self._apply_type_step_tail(tc, user_input)
|
||||
return on_complete()
|
||||
|
||||
# Pre-fill existing custom states for editing
|
||||
@@ -792,22 +822,12 @@ class TriggerConfigMixin:
|
||||
default_states: Any = list(existing_states) if self._trigger_entity_id else ", ".join(existing_states)
|
||||
|
||||
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",
|
||||
)
|
||||
),
|
||||
**_runtime_hours_field(),
|
||||
vol.Optional(CONF_TRIGGER_ON_STATES, default=default_states): _state_selector(
|
||||
self._trigger_entity_id, multiple=True
|
||||
),
|
||||
**_recovery_field(current_tc),
|
||||
**self._type_step_tail_fields(),
|
||||
}
|
||||
schema_fields.update(_entity_logic_field(self._current_task.get("trigger_config", {}).get("entity_ids", [])))
|
||||
schema_fields.update(_interval_warning_fields(self.hass, self._current_task.get("trigger_config")))
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=step_id,
|
||||
@@ -886,24 +906,18 @@ class TriggerConfigMixin:
|
||||
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:
|
||||
entity_ids = self._selected_entities(user_input[CONF_TRIGGER_ENTITY])
|
||||
if entity_ids is None:
|
||||
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()
|
||||
self._trigger_entity_id = entity_ids[0]
|
||||
self._trigger_entity_state = self.hass.states.get(entity_ids[0])
|
||||
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] = {
|
||||
@@ -984,7 +998,14 @@ class TriggerConfigMixin:
|
||||
if condition_type == TriggerType.THRESHOLD:
|
||||
above = user_input.get(CONF_TRIGGER_ABOVE)
|
||||
below = user_input.get(CONF_TRIGGER_BELOW)
|
||||
if threshold_limits_overlap(above, below):
|
||||
limits = (above, below, user_input.get(CONF_TRIGGER_EQUALS), user_input.get(CONF_TRIGGER_NOT_EQUALS))
|
||||
if all(limit is None for limit in limits):
|
||||
# A condition without any limit can never trigger; the
|
||||
# plain threshold step refused it, the compound one saved
|
||||
# it and the WS validator then rejected the task on its
|
||||
# next edit (bug audit 2026-09-26).
|
||||
errors["base"] = "invalid_threshold"
|
||||
elif threshold_limits_overlap(above, below):
|
||||
# Store nothing from the refused attempt: a field left
|
||||
# blank on the retry must not inherit its value (#156).
|
||||
errors["base"] = "overlapping_threshold"
|
||||
@@ -1003,13 +1024,15 @@ class TriggerConfigMixin:
|
||||
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)
|
||||
if _delta_target_invalid(user_input):
|
||||
errors[CONF_TRIGGER_TARGET_VALUE] = "invalid_delta_target"
|
||||
else:
|
||||
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()
|
||||
from_state, to_state = _states_from_input(user_input)
|
||||
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)
|
||||
@@ -1028,36 +1051,14 @@ class TriggerConfigMixin:
|
||||
self._current_compound_condition = {}
|
||||
return await on_complete()
|
||||
|
||||
# The same field builders as the plain type steps (DRY audit
|
||||
# 2026-09-26 B) — step="any" limits included.
|
||||
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_EQUALS): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX)
|
||||
),
|
||||
vol.Optional(CONF_TRIGGER_NOT_EQUALS): 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",
|
||||
)
|
||||
),
|
||||
}
|
||||
schema_fields = {**_limit_fields(), **_for_minutes_field()}
|
||||
elif condition_type == TriggerType.COUNTER:
|
||||
schema_fields = {
|
||||
vol.Required(CONF_TRIGGER_TARGET_VALUE): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX)
|
||||
),
|
||||
**_target_value_field(),
|
||||
vol.Optional(CONF_TRIGGER_DELTA_MODE, default=False): selector.BooleanSelector(),
|
||||
}
|
||||
elif condition_type == TriggerType.STATE_CHANGE:
|
||||
@@ -1065,26 +1066,11 @@ class TriggerConfigMixin:
|
||||
schema_fields = {
|
||||
vol.Optional(CONF_TRIGGER_FROM_STATE): _state_selector(cond_entity),
|
||||
vol.Optional(CONF_TRIGGER_TO_STATE): _state_selector(cond_entity),
|
||||
vol.Required(CONF_TRIGGER_TARGET_CHANGES, default=1): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=1,
|
||||
max=10000,
|
||||
step=1,
|
||||
mode=selector.NumberSelectorMode.BOX,
|
||||
)
|
||||
),
|
||||
**_target_changes_field(),
|
||||
}
|
||||
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",
|
||||
)
|
||||
),
|
||||
**_runtime_hours_field(),
|
||||
vol.Optional(
|
||||
CONF_TRIGGER_ON_STATES,
|
||||
default=[] if cond.get("entity_id") else "",
|
||||
@@ -1114,7 +1100,10 @@ class TriggerConfigMixin:
|
||||
return cancel
|
||||
|
||||
action = user_input.get("compound_action", "finish")
|
||||
if action == "add":
|
||||
# A compound needs two conditions (the WS validator's rule); with
|
||||
# one, "finish" saved a trigger every later panel edit refused
|
||||
# (bug audit 2026-09-26) — go on to the second condition instead.
|
||||
if action == "add" or len(self._compound_conditions) < 2:
|
||||
return await add_condition_step()
|
||||
|
||||
tc = self._current_task["trigger_config"]
|
||||
@@ -1126,12 +1115,16 @@ class TriggerConfigMixin:
|
||||
condition_count = len(self._compound_conditions)
|
||||
logic = getattr(self, "_compound_logic", "AND")
|
||||
|
||||
options = [
|
||||
selector.SelectOptionDict(
|
||||
value="finish",
|
||||
label=f"Finish ({condition_count} conditions, {logic})",
|
||||
),
|
||||
]
|
||||
options = (
|
||||
[
|
||||
selector.SelectOptionDict(
|
||||
value="finish",
|
||||
label=f"Finish ({condition_count} conditions, {logic})",
|
||||
),
|
||||
]
|
||||
if condition_count >= 2
|
||||
else []
|
||||
)
|
||||
if condition_count < 5:
|
||||
options.append(
|
||||
selector.SelectOptionDict(
|
||||
@@ -1141,7 +1134,7 @@ class TriggerConfigMixin:
|
||||
)
|
||||
|
||||
schema_dict: dict[Any, Any] = {
|
||||
vol.Required("compound_action", default="finish"): selector.SelectSelector(
|
||||
vol.Required("compound_action", default="finish" if condition_count >= 2 else "add"): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=options,
|
||||
mode=selector.SelectSelectorMode.LIST,
|
||||
|
||||
Reference in New Issue
Block a user