@@ -353,9 +353,14 @@ class EntityTriggerBase(Trigger):
353353 """Trigger for entity state changes."""
354354
355355 _domain_specs : Mapping [str , DomainSpec ]
356+ # States filtered from the to_state pre-filter (and `_should_include`).
356357 _excluded_states : Final [frozenset [str ]] = frozenset (
357358 {STATE_UNAVAILABLE , STATE_UNKNOWN }
358359 )
360+ # States filtered from the from_state pre-filter. Defaults to
361+ # `_excluded_states`. Subclasses can override to relax the origin
362+ # check.
363+ _excluded_from_states : ClassVar [frozenset [str ]] = _excluded_states
359364 _schema : vol .Schema = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST
360365 # When True, indirect target expansion (via device/area/floor) skips
361366 # entities with an entity_category.
@@ -389,13 +394,28 @@ def _get_tracked_value(self, state: State) -> Any:
389394 return state .state
390395 return state .attributes .get (domain_spec .value_source )
391396
392- @abc .abstractmethod
393397 def is_valid_transition (self , from_state : State , to_state : State ) -> bool :
394- """Check if the origin state is valid and the state has changed."""
398+ """Check if the transition should fire the trigger.
399+
400+ Called only after `from_state.state` has been filtered against
401+ `_excluded_from_states` and `to_state.state` against
402+ `_excluded_states`, so subclasses don't need to repeat those
403+ checks. Default: any state change. Override to add semantics
404+ (specific from/to states, value changed across a threshold,
405+ etc.).
406+ """
407+ return from_state .state != to_state .state
395408
396- @abc .abstractmethod
397409 def is_valid_state (self , state : State ) -> bool :
398- """Check if the new state matches the expected state(s)."""
410+ """Check if the state is a target state for the trigger.
411+
412+ Called only after `state.state` has been filtered against
413+ `_excluded_states`, so subclasses don't need to repeat that
414+ check. Default: any non-excluded state is a target. Override
415+ to restrict (specific to_states, value within a threshold,
416+ etc.).
417+ """
418+ return True
399419
400420 def _should_include (self , state : State ) -> bool :
401421 """Check if an entity should participate in all/count checks.
@@ -473,19 +493,26 @@ def state_still_valid(
473493 )
474494 return matches >= 1
475495 # Behavior any: check the individual entity's state
476- if not to_state :
496+ if not to_state or to_state . state in self . _excluded_states :
477497 return False
478498 return self .is_valid_state (to_state )
479499
480500 if not from_state or not to_state :
481501 return
482502
483- # The trigger should never fire if the new state is not valid
484- if not self .is_valid_state (to_state ):
503+ # The trigger should never fire if the new state is excluded
504+ # or not a target state.
505+ if to_state .state in self ._excluded_states or not self .is_valid_state (
506+ to_state
507+ ):
485508 return
486509
487- # The trigger should never fire if the transition is not valid
488- if not self .is_valid_transition (from_state , to_state ):
510+ # The trigger should never fire if the origin state is excluded
511+ # or the transition is not valid.
512+ if (
513+ from_state .state in self ._excluded_from_states
514+ or not self .is_valid_transition (from_state , to_state )
515+ ):
489516 return
490517
491518 if behavior == BEHAVIOR_LAST :
@@ -570,10 +597,7 @@ class EntityTargetStateTriggerBase(EntityTriggerBase):
570597 _to_states : set [str ]
571598
572599 def is_valid_transition (self , from_state : State , to_state : State ) -> bool :
573- """Check if the origin state is valid and the state has changed."""
574- if from_state .state in (STATE_UNAVAILABLE , STATE_UNKNOWN ):
575- return False
576-
600+ """Check the value changed and the origin was not already a target state."""
577601 from_value = self ._get_tracked_value (from_state )
578602 return (
579603 from_value != self ._get_tracked_value (to_state )
@@ -593,9 +617,6 @@ class EntityTransitionTriggerBase(EntityTriggerBase):
593617
594618 def is_valid_transition (self , from_state : State , to_state : State ) -> bool :
595619 """Check if the origin state matches the expected ones."""
596- if from_state .state in (STATE_UNAVAILABLE , STATE_UNKNOWN ):
597- return False
598-
599620 from_value = self ._get_tracked_value (from_state )
600621 return (
601622 from_value != self ._get_tracked_value (to_state )
@@ -620,34 +641,21 @@ def is_valid_transition(self, from_state: State, to_state: State) -> bool:
620641 )
621642
622643 def is_valid_state (self , state : State ) -> bool :
623- """Check if the new state is valid."""
624- return state .state not in (STATE_UNAVAILABLE , STATE_UNKNOWN ) and bool (
625- self ._get_tracked_value (state ) != self ._from_state
626- )
644+ """Check that the new state is different from the origin state."""
645+ return bool (self ._get_tracked_value (state ) != self ._from_state )
627646
628647
629648class StatelessEntityTriggerBase (EntityTriggerBase ):
630649 """Trigger for entities that don't carry meaningful state.
631650
632651 Used for stateless entities (buttons, scenes, doorbells, events)
633652 whose `state.state` is just a timestamp of the last activation.
653+ `STATE_UNKNOWN` is a legitimate prior state — the first activation
654+ after startup must still fire the trigger.
634655 """
635656
636657 _schema : vol .Schema = ENTITY_STATE_TRIGGER_SCHEMA
637-
638- def is_valid_transition (self , from_state : State , to_state : State ) -> bool :
639- """Check if the origin state is available and the state has changed.
640-
641- STATE_UNKNOWN is allowed as the origin state so the first
642- activation fires.
643- """
644- if from_state .state == STATE_UNAVAILABLE :
645- return False
646- return from_state .state != to_state .state
647-
648- def is_valid_state (self , state : State ) -> bool :
649- """Check that the entity has been activated at least once."""
650- return state .state not in self ._excluded_states
658+ _excluded_from_states : ClassVar [frozenset [str ]] = frozenset ({STATE_UNAVAILABLE })
651659
652660
653661NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA .extend (
@@ -826,10 +834,7 @@ class EntityNumericalStateChangedTriggerBase(EntityNumericalStateTriggerBase):
826834 _schema = NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA
827835
828836 def is_valid_transition (self , from_state : State , to_state : State ) -> bool :
829- """Check if the origin state is valid and the state has changed."""
830- if from_state .state in (STATE_UNAVAILABLE , STATE_UNKNOWN ):
831- return False
832-
837+ """Check if the tracked numeric value has changed."""
833838 return self ._get_tracked_value (from_state ) != self ._get_tracked_value (to_state )
834839
835840
@@ -888,10 +893,7 @@ class EntityNumericalStateCrossedThresholdTriggerBase(EntityNumericalStateTrigge
888893 _schema = NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA
889894
890895 def is_valid_transition (self , from_state : State , to_state : State ) -> bool :
891- """Check if the origin state is valid and the state has changed."""
892- if from_state .state in (STATE_UNAVAILABLE , STATE_UNKNOWN ):
893- return False
894-
896+ """Check that the tracked value crossed into the threshold range."""
895897 return not self .is_valid_state (from_state )
896898
897899
0 commit comments