Catch a weak UPS battery before the next outage
A UPS can report 100% charge and still collapse quickly under load. Track runtime estimate at a known load, charge recovery after real or manufacturer-approved tests, mains state and telemetry freshness so nominal charge is not mistaken for battery health.
The fault this project detects
This project detects a declining UPS runtime estimate under comparable load, slow charge recovery and stale UPS telemetry. Critical is reserved for a live mains outage with too little estimated runtime, not an ageing clue on an ordinary day.
The structure assumes a UPS integration exposes battery charge, estimated runtime, output load or power, and on-battery state. Entity names and units vary by NUT, vendor and model, so confirm every entity in Developer Tools before adapting the template.
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: estimated runtime and battery charge provide immediate capacity clues.
- Direction: runtime at a comparable load and post-test charge recovery reveal deterioration over time.
- Agreement: runtime, charge, load and a self-test result can corroborate a weak battery.
- Context and time: mains state separates routine health assessment from an active outage, while telemetry age prevents stale optimism.
Charge percentage is state of charge, not a direct capacity test. A repeatable load context and recovery trace are more useful evidence of whether the battery can still do its job.
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 the longest gap you'd expect between meaningful state changes on a normal day by reading that entity's own History graph over a few representative days. 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: pull two points from the relevant History graph, take the change between them, divide by the number of seconds between those two timestamps, and that gives you the site-specific gradient for your equipment. A Derivative helper can instead display a friendlier per-hour unit when configured that way.
Collect this data before choosing a threshold
Record the same UPS at comparable protected load. Preserve real outage traces and use only manufacturer-approved self-tests; never disconnect mains unsafely to create data.
| Measurement | Why it matters | Required value |
|---|---|---|
sensor.ups_battery_charge | State of charge | Watch this sensor's History after a real or approved self-test to see the normal recovery curve and how long it takes |
sensor.ups_battery_runtime | Capacity estimate | Read the runtime this sensor reports at a defined, repeatable load, and confirm its units against the entity's own attributes |
sensor.ups_output_power | Comparison context | Record the idle and test-load wattage range from this sensor's History across a normal day and a test cycle |
binary_sensor.ups_on_battery | Mains context | Capture the on/off transitions in History from a real outage or an approved self-test, never an unsafe unplug test |
sensor.ups_telemetry_heartbeat | Freshness proof | Note the normal update interval and the maximum age you'd tolerate before treating this heartbeat as stale, both read from its own History |
The experiment that makes this article defensible
- Confirm the runtime entity's unit and whether the integration reports seconds, minutes or a duration value.
- Record a normal week of runtime estimates at several stable load bands.
- Run only the UPS maker's approved self-test or preserve a genuine brief outage, noting load, charge drop, runtime estimate and recovery.
- Repeat under comparable conditions later; do not compare a lightly loaded test with a heavily loaded one.
Keep your own record as you go: save the timestamped readings from your test cycles, note the Home Assistant version you're running, note the exact entity IDs you used, and take 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.ups_comparison_load_min_w | Lower edge of the calibrated load band | Set this from the bottom of the load range you recorded for the output power sensor |
input_number.ups_comparison_load_max_w | Upper edge of the calibrated load band | Set this from the top of the load range you recorded for the output power sensor |
input_number.ups_min_healthy_runtime_minutes | Minimum healthy estimate in that load band | Set this below the normal runtime you measured at a comparable load, so a real decline still trips it |
input_number.ups_critical_runtime_minutes | Urgent runtime during a mains outage | Set this to the runtime you need to safely shut down or ride out an outage on your own equipment |
input_number.ups_battery_hold_minutes | Persistence before Warning | Set this long enough to reject brief dips you saw in your own History, short enough to still catch a real fault |
input_boolean.ups_battery_monitoring_enabled | Master notification gate | Leave this off until you've validated every threshold above against your own data, then switch it on |
input_number.ups_battery_critical_repeat_minutes | Critical repeat spacing | Choose any repeat interval greater than zero that matches how urgently you want reminders |
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.
Normalise runtime units and compare only like with like
This example assumes the source runtime entity reports seconds. If your integration already reports minutes or a native duration differently, change the conversion after checking the actual state and attributes.
template:
- sensor:
- name: "UPS runtime minutes"
unique_id: ups_runtime_minutes
default_entity_id: sensor.ups_runtime_minutes
device_class: duration
state_class: measurement
unit_of_measurement: "min"
availability: >
{{ has_value('sensor.ups_battery_runtime') }}
state: >
{{ ((states('sensor.ups_battery_runtime') | float) / 60) | round(1) }}
- binary_sensor:
- name: "UPS battery warning"
unique_id: ups_battery_warning
default_entity_id: binary_sensor.ups_battery_warning
device_class: problem
delay_on:
minutes: >
{{ states('input_number.ups_battery_hold_minutes') | int(0) }}
availability: >
{{ has_value('sensor.ups_runtime_minutes')
and has_value('sensor.ups_output_power') }}
state: >
{% set load = states('sensor.ups_output_power') | float %}
{% set comparable = load >= (states('input_number.ups_comparison_load_min_w') | float)
and load <= (states('input_number.ups_comparison_load_max_w') | float) %}
{% set weak = (states('sensor.ups_runtime_minutes') | float)
<= (states('input_number.ups_min_healthy_runtime_minutes') | float) %}
{{ comparable and weak and is_state('binary_sensor.ups_on_battery', 'off') }}
attributes:
reason: >-
Runtime estimate is {{ states('sensor.ups_runtime_minutes') }} min at
{{ states('sensor.ups_output_power') }} W and
{{ states('sensor.ups_battery_charge') }}% charge.
- name: "UPS battery critical"
unique_id: ups_battery_critical
default_entity_id: binary_sensor.ups_battery_critical
device_class: problem
state: >
{{ is_state('binary_sensor.ups_on_battery', 'on')
and has_value('sensor.ups_runtime_minutes')
and (states('sensor.ups_runtime_minutes') | float)
<= (states('input_number.ups_critical_runtime_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: ups_battery_warning_push
alias: "UPS battery - Warning push"
mode: single
triggers:
- trigger: state
entity_id: binary_sensor.ups_battery_warning
to: "on"
conditions:
- condition: state
entity_id: input_boolean.ups_battery_monitoring_enabled
state: "on"
actions:
- action: notify.mobile_app_your_phone
data:
title: "UPS battery warning"
message: >-
{{ state_attr('binary_sensor.ups_battery_warning', 'reason')
or 'The warning condition is active. Check Home Assistant for evidence.' }}
- id: ups_battery_critical_repeat
alias: "UPS battery - Critical repeat"
mode: restart
triggers:
- trigger: state
entity_id: binary_sensor.ups_battery_critical
to: "on"
conditions:
- condition: state
entity_id: input_boolean.ups_battery_monitoring_enabled
state: "on"
actions:
- action: notify.mobile_app_your_phone
data:
title: "UPS battery 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.ups_battery_critical
state: "on"
- condition: template
value_template: >-
{{ states('input_number.ups_battery_critical_repeat_minutes') | int(0) > 0 }}
sequence:
- delay:
minutes: >-
{{ states('input_number.ups_battery_critical_repeat_minutes') | int(0) }}
- condition: state
entity_id: binary_sensor.ups_battery_critical
state: "on"
- action: notify.mobile_app_your_phone
data:
title: "UPS battery 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 runtime estimate becomes meaningful only inside a comparable load band. Mains state separates health monitoring from live outage urgency, while charge and fresh telemetry help explain whether the estimate is current and the battery has recovered.
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 | Runtime at a comparable load is drifting below its previous healthy envelope or recovery is slowing. | Dashboard only |
| Warning | Estimated runtime stays below the calibrated minimum at a comparable load while mains is present. | One push notification |
| Critical | The UPS is on battery and its current runtime estimate falls below the site-specific response window. | 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
- Runtime jumps whenever load changes
- That is expected. Compare only within a stable, documented load band or model runtime against load.
- The runtime unit is wrong
- Inspect the source entity's unit and device class, then remove or change the seconds-to-minutes conversion.
- Charge is 100% but Warning is on
- Full charge does not prove capacity. Check the comparable runtime evidence and approved self-test result.
- Telemetry freezes at a healthy value
- Add a changing heartbeat or integration update timestamp and alarm on age; do not trust the displayed last value.
- A self-test interrupts equipment
- Stop testing and follow the UPS manufacturer's procedure. Critical services need a planned maintenance window and verified shutdown path.
Safety and limits
UPS batteries and mains outputs can deliver dangerous current. Do not open the UPS, bypass protection or pull mains plugs to stage a fault. Follow manufacturer replacement and self-test procedures, and use a licensed electrician for fixed wiring.
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
Does 100% UPS charge mean the battery is healthy?
No. It means the charger considers the battery full. Usable capacity under load can still be poor.
Why compare runtime only at a similar load?
UPS runtime changes strongly with load. Comparing unlike loads can make a healthy battery look weak or hide genuine deterioration.
Can I unplug the UPS to test it?
Do not improvise a mains-disconnection test. Use the manufacturer's approved self-test and a maintenance plan that protects connected equipment.
Why monitor telemetry age?
A frozen healthy-looking value is more dangerous than an explicit unavailable state. A changing heartbeat or timestamp proves the data path is alive.
Can Home Assistant shut down my server?
It can participate in a carefully tested shutdown design, but this diagnostic guide does not implement control. Validate native UPS signalling and shutdown behaviour separately.