Detect a sensor that has frozen on a believable value
The dangerous sensor failure is not always unavailable. Sometimes it is a perfectly believable number that never changes again. A watchdog must ask whether the physical process moved while the sensor did not.
The fault this project detects
The target is stale measurement behaviour: the primary entity remains numeric beyond its normal unchanged period, disagrees with an independent reference, or stays flat while the monitored equipment operates.
The example uses a primary plant sensor, an independent reference sensor and a plant-running binary sensor. The measured quantity may be temperature, pressure, humidity or level, provided both numeric sensors use the same unit and represent the same process closely enough to compare.
The evidence model
A useful alarm does not promote one noisy reading straight to an emergency. It combines measurements that fail in different ways:
- State age. A trigger-based template checks last_changed even if no new source event arrives.
- Availability. Unknown and unavailable states remain explicit faults rather than being coerced into zero.
- Independent comparison. A second sensor provides evidence that the process changed or that the two measurements diverged.
- Process context. A flat reading is more suspicious while a compressor, pump, fan or heater has been active.
A stable value is not automatically stale. The calibration must include the longest legitimate unchanged period caused by rounding, reporting policy and a genuinely steady process.
Freshness is separate evidence
An entity can keep a plausible old value without becoming unavailable. Inspect last_changed on a source that should genuinely change, and work out your own longest healthy interval between meaningful state changes by scrolling back through that entity's History over a normal week and timing the longest gap between real updates. If the measured process can legitimately stay flat, add a changing source heartbeat or timestamp; do not treat an unchanged temperature, level or power value as proof that the transport is alive.
Rate maths: never copy a gradient
The Trend integration expresses min_gradient in units per second. The arithmetic example from the project brief is 4 °C/hour ÷ 3600 = 0.00111 °C/s. That is an example conversion, not a threshold for this equipment. Calculate the rate from your own timestamped readings: pick two points from that entity's History, subtract the earlier value from the later one for your measured change, divide by the number of seconds between their timestamps for the elapsed seconds, and that division gives you the site-specific gradient. A Derivative helper can instead display a friendlier per-hour unit when configured that way.
Collect this data before choosing a threshold
Measure the normal reporting interval and the longest period the displayed state remains identical. Check whether attributes update without the state changing; this design intentionally uses last_changed because the alarm is about a frozen value.
| Measurement | Why it matters | Required value |
|---|---|---|
sensor.plant_primary_value | Normal update interval and unchanged duration | Check this sensor's History and note its normal update interval and how long it can stay unchanged during genuinely healthy operation. |
sensor.plant_reference_value | Normal difference from primary | Compare this sensor against the primary value over a normal period and note the usual difference between them, in the same units. |
binary_sensor.plant_running | Periods when the process should cause movement | Look at this binary sensor's on/off History and note the actual times the process was running versus idle. |
recorder history | Rounding steps and repeated identical values | Export this entity's History from the History page and check for rounding steps or repeated identical values that could mask a frozen reading. |
failure response | Whether the device goes unavailable or holds its last state | Disconnect or power down the device briefly and safely, then watch whether Home Assistant marks it unavailable or simply holds its last reading; that observed behaviour decides which check below you actually need. |
The experiment that makes this article defensible
- Export a steady operating period and find the longest legitimate primary last_changed age.
- Capture a full equipment cycle and confirm that the primary and reference both move enough to be observable.
- Disconnect only the sensor through a safe test method and record whether Home Assistant shows unavailable or preserves the old number.
- Run the watchdog with notifications disabled until normal flat periods no longer create Warnings.
Keep your own record as you go: timestamped readings, your Home Assistant version, the exact entity IDs you used, and one or two History or dashboard screenshots. Do not stage an unsafe electrical, refrigeration, pressure, battery or water fault merely to make a graph.
Create the helpers
Use Settings → Devices & services → Helpers. Create the named Number or Toggle helpers before loading the templates. Where this guide uses a Derivative, Statistics, History Stats, Integral, Schedule or Utility Meter helper, the current Home Assistant interface can create it; the source links below describe the current options.
| Helper or derived entity | Purpose | Setting |
|---|---|---|
input_number.primary_sensor_stale_minutes | Longest allowed unchanged primary state | Set this to a few minutes longer than the longest healthy unchanged period you saw in the sensor's own History. |
input_number.primary_reference_difference_limit | Largest normal same-unit difference | Set this a little above the largest normal difference you measured between the primary and reference sensors, in their shared units. |
input_number.sensor_watchdog_hold_minutes | Persistence before Warning | Set a hold time long enough to avoid normal short dropouts, based on what you saw in your own History. |
input_boolean.primary_sensor_safety_critical | Marks loss of this measurement as urgent | Decide for your own site whether losing this measurement is safety-critical, such as a freezer or medical sensor, and set this switch accordingly. |
input_boolean.frozen_sensor_monitoring_enabled | Master notification gate | Leave this switched off until you have tested the automation end to end, then turn it on. |
input_number.frozen_sensor_critical_repeat_minutes | Critical repeat spacing | Set a repeat interval greater than zero that matches how often you actually want to be reminded of a critical fault. |
Current Home Assistant YAML
Merge top-level keys with your existing configuration instead of duplicating them. Replace the example entity IDs consistently. If you keep automations in automations.yaml, paste only the automation list items there, without a top-level automation: wrapper.
Re-evaluate state age even when nothing changes
The time-pattern trigger wakes the template each minute. It does not pretend that the source reported; it lets Home Assistant notice that last_changed is getting older.
template:
- sensor:
- name: "Plant sensor difference"
unique_id: plant_sensor_difference
default_entity_id: sensor.plant_sensor_difference
state_class: measurement
availability: >
{{ has_value('sensor.plant_primary_value')
and has_value('sensor.plant_reference_value') }}
state: >
{{ ((states('sensor.plant_primary_value') | float)
- (states('sensor.plant_reference_value') | float))
| abs | round(2) }}
- triggers:
- trigger: time_pattern
minutes: "*"
- trigger: state
entity_id:
- sensor.plant_primary_value
- sensor.plant_reference_value
binary_sensor:
- name: "Primary sensor stale"
unique_id: primary_sensor_stale
default_entity_id: binary_sensor.primary_sensor_stale
device_class: problem
state: >
{% set source = states.sensor.plant_primary_value %}
{% set limit = states('input_number.primary_sensor_stale_minutes') %}
{{ source is not defined
or source.state in ['unknown', 'unavailable']
or (is_number(limit)
and (as_timestamp(now()) - as_timestamp(source.last_changed))
> ((limit | float) * 60)) }}
- binary_sensor:
- name: "Sensor watchdog warning"
unique_id: sensor_watchdog_warning
default_entity_id: binary_sensor.sensor_watchdog_warning
device_class: problem
delay_on:
minutes: >
{{ states('input_number.sensor_watchdog_hold_minutes') | int(0) }}
state: >
{{ is_state('binary_sensor.primary_sensor_stale', 'on')
or (has_value('sensor.plant_sensor_difference')
and (states('sensor.plant_sensor_difference') | float)
>= (states('input_number.primary_reference_difference_limit') | float)) }}
attributes:
reason: >-
Primary stale: {{ states('binary_sensor.primary_sensor_stale') }};
difference: {{ states('sensor.plant_sensor_difference') }}.
- name: "Sensor watchdog critical"
unique_id: sensor_watchdog_critical
default_entity_id: binary_sensor.sensor_watchdog_critical
device_class: problem
state: >
{{ is_state('binary_sensor.primary_sensor_stale', 'on')
and is_state('binary_sensor.plant_running', 'on')
and is_state('input_boolean.primary_sensor_safety_critical', 'on') }}Warning and Critical notification actions
Replace the mobile notification action and media-player entity with your own. Put critical-monitoring-alert.mp3 in /media, test it manually, and leave the monitoring Toggle off until calibration is complete.
automation:
- id: frozen_sensor_warning_push
alias: "Sensor watchdog - Warning push"
mode: single
triggers:
- trigger: state
entity_id: binary_sensor.sensor_watchdog_warning
to: "on"
conditions:
- condition: state
entity_id: input_boolean.frozen_sensor_monitoring_enabled
state: "on"
actions:
- action: notify.mobile_app_your_phone
data:
title: "Sensor watchdog warning"
message: >-
{{ state_attr('binary_sensor.sensor_watchdog_warning', 'reason')
or 'The warning condition is active. Check Home Assistant for evidence.' }}
- id: frozen_sensor_critical_repeat
alias: "Sensor watchdog - Critical repeat"
mode: restart
triggers:
- trigger: state
entity_id: binary_sensor.sensor_watchdog_critical
to: "on"
conditions:
- condition: state
entity_id: input_boolean.frozen_sensor_monitoring_enabled
state: "on"
actions:
- action: notify.mobile_app_your_phone
data:
title: "Sensor watchdog critical"
message: "The critical condition is active. Check the equipment and evidence now."
- action: media_player.play_media
target:
entity_id: media_player.alert_speaker
data:
media_content_id: "media-source://media_source/local/critical-monitoring-alert.mp3"
media_content_type: "audio/mpeg"
- repeat:
while:
- condition: state
entity_id: binary_sensor.sensor_watchdog_critical
state: "on"
- condition: template
value_template: >-
{{ states('input_number.frozen_sensor_critical_repeat_minutes') | int(0) > 0 }}
sequence:
- delay:
minutes: >-
{{ states('input_number.frozen_sensor_critical_repeat_minutes') | int(0) }}
- condition: state
entity_id: binary_sensor.sensor_watchdog_critical
state: "on"
- action: notify.mobile_app_your_phone
data:
title: "Sensor watchdog still critical"
message: "The critical condition remains active."
- action: media_player.play_media
target:
entity_id: media_player.alert_speaker
data:
media_content_id: "media-source://media_source/local/critical-monitoring-alert.mp3"
media_content_type: "audio/mpeg"
Why the logic works
last_changed exposes a state that has stopped moving, the reference catches drift or a stuck primary, and plant state tells you whether movement should be expected. Keeping unknown and unavailable separate avoids the common mistake of turning missing data into a harmless zero.
The design is intentionally diagnostic rather than controlling. It reports an impossible or abnormal relationship; it does not bypass equipment protection or decide that a single suspected cause is proven.
Severity tiers without alert fatigue
| Tier | Meaning | Action |
|---|---|---|
| Advisory | The primary is older than usual or the two sensors are beginning to separate. | Dashboard only |
| Warning | Staleness or disagreement persists beyond normal rounding and reporting behaviour. | One push notification |
| Critical | A safety-critical primary remains stale while the monitored plant is operating. | Push, repeat and audible alert |
A weak clue remains visible without interrupting you. A Warning requires persistence or corroboration. Critical is reserved for direct danger or several independent signals. That separation is what stops a useful monitoring system becoming notification wallpaper.
Troubleshooting
- A stable room causes stale warnings
- Increase the state-age limit from measured history or use a source that reports enough precision to show real movement.
- The device reports but last_changed stays old
- That is expected when the numeric state is identical. If packet receipt matters instead, expose a heartbeat or timestamp as a separate entity.
- Both sensors freeze together
- Independence is weak if they share power, radio path, gateway or conversion code. Separate the failure domains.
- The difference sensor is unavailable
- Check that both sources are numeric and use compatible units; do not coerce unknown to zero.
- Restart resets a pending delay
- Template delay timers are not a certified persistence mechanism across reloads. Use a persisted deadline helper where restart survival is essential.
Safety and limits
A watchdog does not validate calibration or prove which sensor is correct. Safety-rated processes need certified instruments and independent protection. Do not let a derived Home Assistant entity override those systems.
If a webhook is added later, treat its webhook_id like a password, keep local_only: true unless a reviewed design requires otherwise, and never use an unauthenticated webhook for locks, garage doors, power isolation or anything destructive.
Official sources
These are the primary documentation pages used for the design. Check them against the Home Assistant or ESPHome version you are actually running.
Related Smart Home Fix guides
- Fix unreliable entities before trusting alarms
- Separate smart-home devices without breaking discovery
- Reduce false alarms without hiding real faults
FAQ
Why not check only for unavailable?
Some devices keep their last numeric state when communication or sensing fails. That value looks valid, so availability alone can miss the failure.
What is the difference between last_changed and last_updated?
last_changed tracks when the entity's state value changed. Attributes can update without changing that state. This watchdog is deliberately interested in a value that remains identical.
Can a healthy sensor remain unchanged for hours?
Yes, especially when it rounds heavily or monitors a stable process. Measure the longest normal unchanged period before choosing a stale limit.
Does disagreement show which sensor is wrong?
No. It shows that the pair cannot both be trusted as equivalent measurements. Placement, calibration, response time and actual sensor failure must then be checked.
Should both sensors use the same network?
They can, but independence is stronger when they do not share every failure point. Separate power, radio paths or technologies may be worthwhile for critical monitoring.