Environment
- pyscript 2.1.0 (HACS)
- Home Assistant Core 2026.8.2 (HAOS), Python 3.14
Summary
There is no @state_trigger form that fires on every state event of a
watched entity. Both the bare-name form and the
@state_trigger("True", watch=["sensor.x"]) form silently discard any
state_changed event whose value equals the previous value — which is
exactly what a sensor with force_update: true produces on repeated
readings. For an ADC-backed sensor holding steady, that can be a large
fraction of all events.
The docs for the expression + watch= form read as "evaluated on every
state event of the watched variables", which is what we relied on.
Mechanism (from the 2.1.0 source)
decorators/state.py (_cycle) routes each queued notify through:
if ident_any_values_changed(func_args, self.state_trig_ident_any):
trig_ok = True
elif ident_values_changed(func_args, self.state_trig_ident):
...
else:
continue # <- repeated-value events end here
and trigger.py::ident_any_values_changed() requires a value change even
for the watch= (state_trig_ident_any) path:
for check_var in ident:
if check_var == var_name and old_value != value: # <- filters equal values
return True
StateVal subclasses str, so two force_update events carrying the same
reading compare equal and the event is dropped before the trigger
expression is ever evaluated. Delivery is fine (the queue receives the
event); it is the change filter that discards it.
Real-world impact / how we found it
We used @state_trigger("True", watch=[SENSOR]) as a liveness heartbeat: an
ESPHome analog sensor publishes every 10s with force_update: true
(deliberately, so last_updated means "last heard"), and a watchdog
refreshed a hardware dead-man timer on each event. The trigger went silent
for >25s hundreds of times per night while the recorder had every row —
tripping the dead-man twice.
Recorder statistics for one night (12-bit ADC, physically stable input):
- 3,600 state events in 10h (one per 10s — none missing in HA itself)
- 34.5% of consecutive events carried an identical value
- 309 runs of ≥3 identical values in a row (≥25s of trigger silence each)
which matched the measured trigger-side gap count (357) in the same window.
@time_trigger("period(now, 10s)") ran 4,027 ticks at 10.000s ±5ms
alongside — the discrepancy is specific to the state-trigger change filter.
Workaround (works, worth documenting)
Watch the .last_updated virtual attribute instead — it changes on every
force_update event, so the filter passes:
@state_trigger("True", watch=["sensor.my_adc.last_updated"])
Suggestion
Either of these would close the gap:
- Document that all
@state_trigger forms are change-filtered, and
document the .last_updated workaround for every-event use-cases; or
- Add an opt-in kwarg (e.g.
@state_trigger(..., any_event=True)) that
skips the old_value != value check for watched variables.
Happy to provide more detail or test a patch.
Environment
Summary
There is no
@state_triggerform that fires on every state event of awatched entity. Both the bare-name form and the
@state_trigger("True", watch=["sensor.x"])form silently discard anystate_changedevent whose value equals the previous value — which isexactly what a sensor with
force_update: trueproduces on repeatedreadings. For an ADC-backed sensor holding steady, that can be a large
fraction of all events.
The docs for the expression +
watch=form read as "evaluated on everystate event of the watched variables", which is what we relied on.
Mechanism (from the 2.1.0 source)
decorators/state.py(_cycle) routes each queued notify through:and
trigger.py::ident_any_values_changed()requires a value change evenfor the
watch=(state_trig_ident_any) path:StateValsubclassesstr, so twoforce_updateevents carrying the samereading compare equal and the event is dropped before the trigger
expression is ever evaluated. Delivery is fine (the queue receives the
event); it is the change filter that discards it.
Real-world impact / how we found it
We used
@state_trigger("True", watch=[SENSOR])as a liveness heartbeat: anESPHome analog sensor publishes every 10s with
force_update: true(deliberately, so
last_updatedmeans "last heard"), and a watchdogrefreshed a hardware dead-man timer on each event. The trigger went silent
for >25s hundreds of times per night while the recorder had every row —
tripping the dead-man twice.
Recorder statistics for one night (12-bit ADC, physically stable input):
which matched the measured trigger-side gap count (357) in the same window.
@time_trigger("period(now, 10s)")ran 4,027 ticks at 10.000s ±5msalongside — the discrepancy is specific to the state-trigger change filter.
Workaround (works, worth documenting)
Watch the
.last_updatedvirtual attribute instead — it changes on everyforce_updateevent, so the filter passes:@state_trigger("True", watch=["sensor.my_adc.last_updated"])Suggestion
Either of these would close the gap:
@state_triggerforms are change-filtered, anddocument the
.last_updatedworkaround for every-event use-cases; or@state_trigger(..., any_event=True)) thatskips the
old_value != valuecheck for watched variables.Happy to provide more detail or test a patch.