Smart Home Fix

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.

UPS battery health evidence flow Multiple measurements are combined into advisory, warning and critical fault states. Estimated runtimeProtected loadCharge recoveryMains and telemetry Evidence model direction + context agreement + duration Advisory dashboard only Warning one push Critical repeat + audible
Runtime only becomes comparable when load, mains state and fresh telemetry are considered with it.

The evidence model

A useful alarm does not promote one noisy reading straight to an emergency. It combines measurements that fail in different ways:

  1. Value: estimated runtime and battery charge provide immediate capacity clues.
  2. Direction: runtime at a comparable load and post-test charge recovery reveal deterioration over time.
  3. Agreement: runtime, charge, load and a self-test result can corroborate a weak battery.
  4. 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.

MeasurementWhy it mattersRequired value
sensor.ups_battery_chargeState of chargeWatch 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_runtimeCapacity estimateRead the runtime this sensor reports at a defined, repeatable load, and confirm its units against the entity's own attributes
sensor.ups_output_powerComparison contextRecord the idle and test-load wattage range from this sensor's History across a normal day and a test cycle
binary_sensor.ups_on_batteryMains contextCapture the on/off transitions in History from a real outage or an approved self-test, never an unsafe unplug test
sensor.ups_telemetry_heartbeatFreshness proofNote 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

  1. Confirm the runtime entity's unit and whether the integration reports seconds, minutes or a duration value.
  2. Record a normal week of runtime estimates at several stable load bands.
  3. Run only the UPS maker's approved self-test or preserve a genuine brief outage, noting load, charge drop, runtime estimate and recovery.
  4. 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 entityPurposeSetting
input_number.ups_comparison_load_min_wLower edge of the calibrated load bandSet this from the bottom of the load range you recorded for the output power sensor
input_number.ups_comparison_load_max_wUpper edge of the calibrated load bandSet this from the top of the load range you recorded for the output power sensor
input_number.ups_min_healthy_runtime_minutesMinimum healthy estimate in that load bandSet this below the normal runtime you measured at a comparable load, so a real decline still trips it
input_number.ups_critical_runtime_minutesUrgent runtime during a mains outageSet this to the runtime you need to safely shut down or ride out an outage on your own equipment
input_number.ups_battery_hold_minutesPersistence before WarningSet 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_enabledMaster notification gateLeave this off until you've validated every threshold above against your own data, then switch it on
input_number.ups_battery_critical_repeat_minutesCritical repeat spacingChoose 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

TierMeaningAction
AdvisoryRuntime at a comparable load is drifting below its previous healthy envelope or recovery is slowing.Dashboard only
WarningEstimated runtime stays below the calibrated minimum at a comparable load while mains is present.One push notification
CriticalThe 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

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.