"""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_COMBINATOR, CONF_TRIGGER_DELTA_MODE, CONF_TRIGGER_ENTITY, CONF_TRIGGER_ENTITY_LOGIC, CONF_TRIGGER_EQUALS, CONF_TRIGGER_FOR_MINUTES, CONF_TRIGGER_FROM_STATE, CONF_TRIGGER_NOT_EQUALS, 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, TRIGGER_FIELD_RANGES, TRIGGER_RUNTIME_HOURS_MAX, ScheduleType, TriggerType, ) from .helpers.global_options import get_default_warning_days from .helpers.task_fields import INTERVAL_DAYS_RANGE, WARNING_DAYS_RANGE from .helpers.trigger_fallback import threshold_limits_overlap # 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", ] def _apply_recovery_flag(tc: dict[str, Any], user_input: dict[str, Any]) -> None: """Write the #53 auto-complete-on-recovery flag from a form submission. Editable since the dialog/flow parity round — before that the flow only carried a previously stored value. Absence in trigger_config means off. """ if user_input.get("auto_complete_on_recovery"): tc["auto_complete_on_recovery"] = True else: tc.pop("auto_complete_on_recovery", None) def _recovery_default(tc: dict[str, Any] | None) -> bool: return bool((tc or {}).get("auto_complete_on_recovery")) def _recovery_field(tc: dict[str, Any] | None) -> dict[Any, Any]: """The #53 recovery checkbox — one schema entry, six call sites.""" return { vol.Optional( "auto_complete_on_recovery", default=_recovery_default(tc), ): selector.BooleanSelector() } def _entity_logic_field(entity_ids: list[Any]) -> dict[Any, Any]: """The any/all selector, shown only with 2+ entities — identical on every trigger-type step (and per compound condition).""" if len(entity_ids) < 2: return {} return { 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", ) ) } 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 { interval_key: selector.NumberSelector( selector.NumberSelectorConfig( min=INTERVAL_DAYS_RANGE[0], max=INTERVAL_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX, ) ), 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), ): selector.SelectSelector( selector.SelectSelectorConfig( options=[ selector.SelectOptionDict(value="any", label="Trigger or interval (whichever first)"), selector.SelectOptionDict(value="all", label="Trigger and interval (both required)"), ], mode=selector.SelectSelectorMode.DROPDOWN, translation_key="trigger_combinator", ) ), vol.Optional( CONF_TASK_WARNING_DAYS, 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 ) ), } def _apply_combinator(tc: dict[str, Any], user_input: dict[str, Any]) -> None: """Store the trigger∧interval combinator; absence means the default "any".""" if user_input.get(CONF_TRIGGER_COMBINATOR) == "all": tc[CONF_TRIGGER_COMBINATOR] = "all" else: tc.pop(CONF_TRIGGER_COMBINATOR, None) def _state_selector(entity_id: str | None, *, multiple: bool = False) -> Any: """State field bound to the trigger entity (#129 follow-up). Suggests the entity's known states instead of free text — typo'd states were a real failure mode. Falls back to a plain text field when no entity is available (defensive; the entity step always runs first). """ if entity_id: return selector.StateSelector( selector.StateSelectorConfig(entity_id=entity_id, multiple=multiple) ) 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.""" if isinstance(raw, list): return [s.strip().lower() for s in raw if isinstance(s, str) and s.strip()] if isinstance(raw, str): return [s.strip().lower() for s in raw.split(",") if s.strip()] 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. 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 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, *, 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 # 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: 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) ) 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 panel-managed keys the # flow has no fields for, so an options-flow trigger edit doesn't # silently drop them: the recovery flag (#53) and the counting # start value (#102/#103 class; the flow's counter step re-writes # target+delta but never the baseline). 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 {}), **( {"trigger_baseline_value": prev_tc["trigger_baseline_value"]} if "trigger_baseline_value" in prev_tc else {} ), # #149: the session cap is panel-managed (the flow grew no # field) — carry it so an options-flow trigger edit doesn't # silently drop it. **( {"trigger_runtime_max_session_seconds": prev_tc["trigger_runtime_max_session_seconds"]} 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() # 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) equals = user_input.get(CONF_TRIGGER_EQUALS) not_equals = user_input.get(CONF_TRIGGER_NOT_EQUALS) tc = self._current_task["trigger_config"] if above is None and below is None and equals is None and not_equals is None: errors["base"] = "invalid_threshold" elif threshold_limits_overlap( # Blank fields keep the stored value — judge the merged pair (#156). above if above is not None else tc.get(CONF_TRIGGER_ABOVE), below if below is not None else tc.get(CONF_TRIGGER_BELOW), ): errors["base"] = "overlapping_threshold" else: if above is not None: tc[CONF_TRIGGER_ABOVE] = above if below is not None: tc[CONF_TRIGGER_BELOW] = below if equals is not None: tc[CONF_TRIGGER_EQUALS] = equals 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) 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) schema_fields: dict[Any, Any] = { **_limit_fields(), **_for_minutes_field(), **self._type_step_tail_fields(), } 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.""" 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) # 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 self._apply_type_step_tail(tc, user_input) 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 prev_tc = self._current_task.get("trigger_config") or {} baseline_key = ( vol.Optional("trigger_baseline_value", default=prev_tc["trigger_baseline_value"]) if "trigger_baseline_value" in prev_tc else vol.Optional("trigger_baseline_value") ) schema_fields: dict[Any, Any] = { **_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, mode=selector.NumberSelectorMode.BOX, step="any", ) ), **self._type_step_tail_fields(), } 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", "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"] from_state, to_state = _states_from_input(user_input) if from_state: tc[CONF_TRIGGER_FROM_STATE] = from_state 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) 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), **_target_changes_field(), **_for_minutes_field(), **self._type_step_tail_fields(), } 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] states = _parse_states(user_input.get(CONF_TRIGGER_ON_STATES)) if states: tc[CONF_TRIGGER_ON_STATES] = states else: tc.pop(CONF_TRIGGER_ON_STATES, None) self._apply_type_step_tail(tc, user_input) 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) or [] # The state selector takes/returns a LIST; the text fallback a comma string. default_states: Any = list(existing_states) if self._trigger_entity_id else ", ".join(existing_states) schema_fields: dict[Any, Any] = { **_runtime_hours_field(), vol.Optional(CONF_TRIGGER_ON_STATES, default=default_states): _state_selector( self._trigger_entity_id, multiple=True ), **self._type_step_tail_fields(), } 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() self._current_task["trigger_config"] = { "type": TriggerType.COMPOUND, CONF_COMPOUND_LOGIC: logic, CONF_COMPOUND_CONDITIONS: [], } _apply_recovery_flag(self._current_task["trigger_config"], user_input) 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", ) ), **_recovery_field(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_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 entity_ids = self._selected_entities(user_input[CONF_TRIGGER_ENTITY]) if entity_ids is None: errors[CONF_TRIGGER_ENTITY] = "invalid_entity" else: 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] = { 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 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 condition_type == TriggerType.THRESHOLD: above = user_input.get(CONF_TRIGGER_ABOVE) below = user_input.get(CONF_TRIGGER_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" else: if above is not None: cond["trigger_above"] = above if below is not None: cond["trigger_below"] = below equals = user_input.get(CONF_TRIGGER_EQUALS) if equals is not None: cond["trigger_equals"] = equals not_equals = user_input.get(CONF_TRIGGER_NOT_EQUALS) if not_equals is not None: cond["trigger_not_equals"] = not_equals for_min = user_input.get(CONF_TRIGGER_FOR_MINUTES) if for_min: cond["trigger_for_minutes"] = for_min elif condition_type == TriggerType.COUNTER: 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, to_state = _states_from_input(user_input) if from_state: cond["trigger_from_state"] = from_state 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] states = _parse_states(user_input.get(CONF_TRIGGER_ON_STATES)) if states: cond["trigger_on_states"] = states 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] if not errors: self._compound_conditions.append(cond) 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 = {**_limit_fields(), **_for_minutes_field()} elif condition_type == TriggerType.COUNTER: schema_fields = { **_target_value_field(), vol.Optional(CONF_TRIGGER_DELTA_MODE, default=False): selector.BooleanSelector(), } elif condition_type == TriggerType.STATE_CHANGE: cond_entity = cond.get("entity_id") schema_fields = { vol.Optional(CONF_TRIGGER_FROM_STATE): _state_selector(cond_entity), vol.Optional(CONF_TRIGGER_TO_STATE): _state_selector(cond_entity), **_target_changes_field(), } elif condition_type == TriggerType.RUNTIME: schema_fields = { **_runtime_hours_field(), vol.Optional( CONF_TRIGGER_ON_STATES, default=[] if cond.get("entity_id") else "", ): _state_selector(cond.get("entity_id"), multiple=True), } schema_fields.update(_entity_logic_field(cond.get("entity_ids", []))) return self.async_show_form( step_id=step_id, data_schema=vol.Schema(self._mixin_add_go_back(schema_fields)), errors=errors, ) 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") # 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"] 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 >= 2 else [] ) 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" if condition_count >= 2 else "add"): 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, }, )