Detect a silent remote shed sensor before you need it
A remote shed can look perfectly normal for days when the dashboard is only displaying its last received value. Make the node prove that it is alive with a changing heartbeat, then distinguish node silence from gateway, backhaul and power faults.
The fault this project detects
This project detects a frozen or silent environmental node in an off-grid or distant shed. The MQTT heartbeat changes on every transmission, its age exposes silence, and it is interpreted alongside an independently monitored gateway and shed-power context. Optional MQTT expiry should be added only after the healthy reporting gap is measured.
The example assumes a battery or solar node sends a JSON heartbeat through a local MQTT broker, possibly via a LoRa gateway. It is transport-agnostic after the gateway: the important property is that every payload contains a changing value and is not retained as fresh evidence.
The evidence model
A useful alarm does not promote one noisy reading straight to an emergency. It combines measurements that fail in different ways:
- Value: the payload carries a sequence or timestamp that changes every transmission.
- Direction: age only moves upwards between genuine messages.
- Agreement: gateway and independent power signals show whether silence is local to the node or wider.
- Context and time: the expected reporting interval and a hold period prevent one missed packet becoming an emergency.
Availability alone is not enough if a retained payload or stuck value still appears plausible. A changing state makes last_changed useful evidence of a real heartbeat.
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 representative stretch of normal operation and timing the biggest gap between genuine changes. 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: open History for the entity, pick two points recorded during normal operation, and divide the change in value between them by the number of seconds that elapsed to get the site-specific gradient to enter as min_gradient. A Derivative helper can instead display a friendlier per-hour unit when configured that way.
Collect this data before choosing a threshold
Record arrival intervals through normal rain, heat, low-battery periods and gateway restarts. For radio links, use the longest legitimate gap rather than the advertised transmit interval.
| Measurement | Why it matters | Required value |
|---|---|---|
sensor.shed_heartbeat | Changing message evidence | Log this sensor's own arrival timestamps over a normal reporting period and work out the maximum gap between genuine changes. |
binary_sensor.shed_gateway_online | Gateway or bridge health | Pull this entity's own History across at least one real outage or restart so you can see how it behaves in both states. |
binary_sensor.shed_power_available | Independent site-power context | Check this entity's own History for both its normal and outage states so you know what each looks like. |
sensor.shed_node_battery | Energy context | Watch this sensor over a full charge/discharge cycle, including an overnight or poor-weather stretch, to learn its own healthy curve. |
sensor.shed_temperature | Payload plausibility | Review this sensor's own History for normal day-to-day changes and, if you have one, a known silence event, to judge what a plausible payload looks like. |
The experiment that makes this article defensible
- Record at least one normal reporting day and calculate the longest gap between changing heartbeat states.
- Temporarily stop only the test node during a supervised maintenance window and verify heartbeat expiry without disturbing safety equipment.
- Restart the gateway deliberately and record how node, gateway and MQTT entities recover.
- If the shed is solar powered, include overnight and poor-weather data before selecting the hold time.
Keep your own record as you calibrate: timestamped History readings, your Home Assistant version, the exact entity IDs you used, and a screenshot or two of the History or dashboard views that show the pattern. 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.shed_heartbeat_warning_minutes | Maximum acceptable heartbeat age before Warning | Enter the maximum heartbeat age, in minutes, that's still normal for your own node - the longest healthy gap you measured above, plus a margin. |
input_number.shed_heartbeat_critical_minutes | Long silence before Critical | Enter a longer age, in minutes, that represents genuine prolonged silence for your own setup. |
input_boolean.shed_heartbeat_monitoring_enabled | Master notification gate | Leave this switched off until you've tested the automation and are ready to receive real alerts. |
input_number.shed_heartbeat_critical_repeat_minutes | Critical repeat spacing | Enter a repeat interval, in minutes greater than zero, based on how often you want repeat critical alerts. |
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.
Make every MQTT heartbeat visibly different
Publish a changing sequence or ISO timestamp to the topic on every heartbeat. After measuring the longest healthy gap, you may also configure expire_after to a justified number of seconds. Do not retain the state message when expiry is your freshness control; a retained old value can be replayed after a broker or Home Assistant restart.
mqtt:
sensor:
- name: "Shed heartbeat"
unique_id: shed_heartbeat
default_entity_id: sensor.shed_heartbeat
state_topic: "property/shed/node_1/heartbeat"
value_template: "{{ value_json.sequence }}"
force_update: true
availability:
- topic: "property/shed/node_1/status"
payload_available: "online"
payload_not_available: "offline"
template:
- sensor:
- name: "Shed heartbeat age"
unique_id: shed_heartbeat_age
default_entity_id: sensor.shed_heartbeat_age
device_class: duration
state_class: measurement
unit_of_measurement: "min"
availability: >
{{ states('sensor.shed_heartbeat') not in ['unknown', 'unavailable'] }}
state: >
{{ ((as_timestamp(now())
- as_timestamp(states.sensor.shed_heartbeat.last_changed)) / 60) | round(1) }}
- binary_sensor:
- name: "Shed heartbeat warning"
unique_id: shed_heartbeat_warning
default_entity_id: binary_sensor.shed_heartbeat_warning
device_class: problem
state: >
{% set missing = states('sensor.shed_heartbeat') in ['unknown', 'unavailable'] %}
{% set old = states('sensor.shed_heartbeat_age') | float(999999) %}
{{ missing or old >= (states('input_number.shed_heartbeat_warning_minutes') | float) }}
attributes:
reason: >-
Last changing heartbeat was
{{ relative_time(states.sensor.shed_heartbeat.last_changed) if states.sensor.shed_heartbeat is defined else 'not recorded' }} ago;
gateway is {{ states('binary_sensor.shed_gateway_online') }} and shed power is
{{ states('binary_sensor.shed_power_available') }}.
- name: "Shed heartbeat critical"
unique_id: shed_heartbeat_critical
default_entity_id: binary_sensor.shed_heartbeat_critical
device_class: problem
state: >
{% set missing = states('sensor.shed_heartbeat') in ['unknown', 'unavailable'] %}
{% set old = states('sensor.shed_heartbeat_age') | float(999999) %}
{{ is_state('binary_sensor.shed_gateway_online', 'on')
and is_state('binary_sensor.shed_power_available', 'on')
and (missing or old >= (states('input_number.shed_heartbeat_critical_minutes') | float)) }}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: shed_heartbeat_warning_push
alias: "Shed heartbeat - Warning push"
mode: single
triggers:
- trigger: state
entity_id: binary_sensor.shed_heartbeat_warning
to: "on"
conditions:
- condition: state
entity_id: input_boolean.shed_heartbeat_monitoring_enabled
state: "on"
actions:
- action: notify.mobile_app_your_phone
data:
title: "Shed heartbeat warning"
message: >-
{{ state_attr('binary_sensor.shed_heartbeat_warning', 'reason')
or 'The warning condition is active. Check Home Assistant for evidence.' }}
- id: shed_heartbeat_critical_repeat
alias: "Shed heartbeat - Critical repeat"
mode: restart
triggers:
- trigger: state
entity_id: binary_sensor.shed_heartbeat_critical
to: "on"
conditions:
- condition: state
entity_id: input_boolean.shed_heartbeat_monitoring_enabled
state: "on"
actions:
- action: notify.mobile_app_your_phone
data:
title: "Shed heartbeat 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.shed_heartbeat_critical
state: "on"
- condition: template
value_template: >-
{{ states('input_number.shed_heartbeat_critical_repeat_minutes') | int(0) > 0 }}
sequence:
- delay:
minutes: >-
{{ states('input_number.shed_heartbeat_critical_repeat_minutes') | int(0) }}
- condition: state
entity_id: binary_sensor.shed_heartbeat_critical
state: "on"
- action: notify.mobile_app_your_phone
data:
title: "Shed heartbeat 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
A changing heartbeat makes stale data observable. MQTT expiry adds a second freshness mechanism, while gateway and power agreement prevent a whole-site outage being mislabelled as one failed sensor.
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 | Heartbeat spacing is longer than recent normal behaviour but within the operational allowance. | Dashboard only |
| Warning | The heartbeat is expired or older than the calibrated warning age. | One push notification |
| Critical | The node remains silent beyond the critical age while the independent gateway and power signals say it should be reachable. | 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 restart makes an old heartbeat look new
- Do not retain the heartbeat state topic; publish a genuinely changing value and inspect <code>last_changed</code>.
- One missed LoRa packet causes a warning
- Set the hold from measured worst-case arrival gaps, not the nominal transmit interval.
- The age sensor is unavailable
- Confirm the heartbeat has received at least one valid changing state and that its entity ID matches the template.
- Gateway and node fail together
- Create a separate gateway or backhaul alarm; the critical node rule deliberately requires the gateway to remain online.
- Battery reporting is also frozen
- Treat it as part of the same stale payload, not independent corroboration, unless it travels through a separate path.
Safety and limits
Do not rely on a hobby radio heartbeat as the sole fire, security, livestock or life-safety system. Comply with Australian radio requirements, keep critical local protection independent, and use suitable enclosures and licensed electrical work.
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 must the heartbeat value change?
A changing value updates <code>last_changed</code> and proves a new message was processed. Repeating a plausible temperature can hide a frozen data path.
Should the MQTT heartbeat be retained?
Not when retained state would be mistaken for fresh evidence after a restart. Publish current availability separately and make the freshness design explicit.
Is <code>unavailable</code> enough to detect silence?
No. Some devices and integrations keep their last value. Use a changing heartbeat, age and expiry rather than trusting availability alone.
How long should the heartbeat timeout be?
Measure the longest legitimate gap through poor radio and low-power conditions, then add a justified margin. Do not copy the example expiry blindly.
Can this be a life-safety monitor?
Not by itself. Critical safety systems need approved local protection, suitable communications and a risk-reviewed design independent of Home Assistant.