Skip to content

Power loss notification (UPS)

When the power goes out, you have exactly as much time as the UPS holds — usually a few minutes. That’s enough to shut the server down gracefully by hand, or at least to understand that everything is about to power off. A push with the current battery charge and remaining runtime lets you react while there is still some reserve left. Sudden power loss often correlates with unexpected reboots — see unexpected reboot.

The script polls the UPS via NUT (upsc) every minute and looks at the ups.status field:

  • OL — on line power (everything is fine);
  • OB / ONBATT — the server is running on battery (power lost);
  • LB — low charge, shutdown is imminent.

Notification logic:

  • switch to battery — a high-priority (9) message 🔌;
  • low battery charge — priority 10 🔋, the loudest alert;
  • return to line power — a calm recovery message, priority 4 ✅.

In the message we show the battery charge (battery.charge) and the estimated runtime (battery.runtime). To avoid receiving the same thing every minute, we store the state (ok / onbatt / lowbatt) in /var/lib/notifly-ups/.

This assumes NUT is already configured and upsc <ups_name>@localhost returns data. You can find the UPS name with upsc -l. Save as /usr/local/bin/notifly-ups-check:

#!/usr/bin/env bash
set -eu
set -a; source /etc/notifly.env; set +a
UPS="${1:-ups@localhost}" # UPS name, see `upsc -l`
STATE_DIR=/var/lib/notifly-ups
mkdir -p "$STATE_DIR"
STATE_FILE="$STATE_DIR/state"
HOST=$(hostname -s)
# Read the current UPS readings
STATUS=$(upsc "$UPS" ups.status 2>/dev/null || echo "UNKNOWN")
CHARGE=$(upsc "$UPS" battery.charge 2>/dev/null || echo "?")
RUNTIME=$(upsc "$UPS" battery.runtime 2>/dev/null || echo "")
# battery.runtime comes in seconds — convert to minutes for humans
if [ -n "$RUNTIME" ]; then
MINS=$(( RUNTIME / 60 ))
RUNLEFT="${MINS} мин"
else
RUNLEFT="н/д"
fi
# Determine the level from the flags in ups.status
LEVEL=ok
case " $STATUS " in
*" OB "*|*" ONBATT "*) LEVEL=onbatt ;;
esac
case " $STATUS " in
*" LB "*) LEVEL=lowbatt ;; # low charge takes priority over onbatt
esac
LAST=$(cat "$STATE_FILE" 2>/dev/null || echo ok)
if [ "$LEVEL" != "$LAST" ]; then
case "$LEVEL" in
onbatt)
/usr/local/bin/notifly-send \
"🔌 Пропало питание — $HOST" \
"Сервер перешёл на ИБП. Заряд $CHARGE%, хватит на $RUNLEFT." 9
;;
lowbatt)
/usr/local/bin/notifly-send \
"🔋 Низкий заряд ИБП — $HOST" \
"Осталось $CHARGE% ($RUNLEFT). Скоро сервер выключится!" 10
;;
ok)
/usr/local/bin/notifly-send \
"✅ Питание восстановлено — $HOST" \
"Сервер снова от сети. Заряд батареи $CHARGE%." 4
;;
esac
echo "$LEVEL" > "$STATE_FILE"
fi

Make it executable:

Окно терминала
sudo chmod +x /usr/local/bin/notifly-ups-check

If you use apcupsd instead of NUT, the data is read with apcaccess status. The STATUS field contains ONLINE or ONBATT, BCHARGE is the charge in percent, and TIMELEFT is the remaining time. A variant of the script:

#!/usr/bin/env bash
set -eu
set -a; source /etc/notifly.env; set +a
STATE_DIR=/var/lib/notifly-ups
mkdir -p "$STATE_DIR"
STATE_FILE="$STATE_DIR/state"
HOST=$(hostname -s)
# apcaccess returns "KEY : value" — pull out the fields we need
STAT=$(apcaccess status 2>/dev/null || echo "")
STATUS=$(echo "$STAT" | awk -F: '/^STATUS/ {gsub(/ /,"",$2); print $2}')
CHARGE=$(echo "$STAT" | awk -F: '/^BCHARGE/ {gsub(/ /,"",$2); print $2+0}')
RUNLEFT=$(echo "$STAT" | awk -F: '/^TIMELEFT/ {print $2}' | xargs)
LEVEL=ok
case "$STATUS" in
*ONBATT*) LEVEL=onbatt ;;
esac
# apcupsd reports the low-charge flag via "STATUS: ONBATT LOWBATT"
case "$STATUS" in
*LOWBATT*) LEVEL=lowbatt ;;
esac
LAST=$(cat "$STATE_FILE" 2>/dev/null || echo ok)
if [ "$LEVEL" != "$LAST" ]; then
case "$LEVEL" in
onbatt)
/usr/local/bin/notifly-send \
"🔌 Пропало питание — $HOST" \
"Сервер перешёл на ИБП. Заряд $CHARGE%, хватит на $RUNLEFT." 9
;;
lowbatt)
/usr/local/bin/notifly-send \
"🔋 Низкий заряд ИБП — $HOST" \
"Осталось $CHARGE% ($RUNLEFT). Скоро сервер выключится!" 10
;;
ok)
/usr/local/bin/notifly-send \
"✅ Питание восстановлено — $HOST" \
"Сервер снова от сети. Заряд батареи $CHARGE%." 4
;;
esac
echo "$LEVEL" > "$STATE_FILE"
fi

Power needs to be checked more often than disk — once a minute, so as not to miss a short UPS discharge.

Окно терминала
sudo crontab -e

Add the line (pass your UPS name if it isn’t ups):

* * * * * /usr/local/bin/notifly-ups-check ups@localhost >/dev/null 2>&1

/etc/systemd/system/notifly-ups.service:

[Unit]
Description=Notifly UPS power check
[Service]
Type=oneshot
ExecStart=/usr/local/bin/notifly-ups-check ups@localhost

/etc/systemd/system/notifly-ups.timer:

[Unit]
Description=Run notifly-ups every minute
[Timer]
OnBootSec=1min
OnUnitActiveSec=1min
[Install]
WantedBy=timers.target

Activate it:

Окно терминала
sudo systemctl daemon-reload
sudo systemctl enable --now notifly-ups.timer
sudo systemctl list-timers notifly-ups.timer

Many consumer UPS units on Windows have no daemon of their own, but expose the battery charge to the operating system. We read Win32_Battery via CIM: the BatteryStatus field (1 = on battery / discharging, 2 = on AC power), EstimatedChargeRemaining is the charge in percent, and EstimatedRunTime is the remaining time in minutes. We use the common function Send-Notifly from the sysadmin template/index.

C:\scripts\Notifly-UPS-Check.ps1
. C:\scripts\Notifly.ps1
$StateDir = "C:\ProgramData\Notifly\ups-state"
New-Item -ItemType Directory -Path $StateDir -Force | Out-Null
$StateFile = Join-Path $StateDir "state.txt"
$Host = $env:COMPUTERNAME
$bat = Get-CimInstance Win32_Battery | Select-Object -First 1
if (-not $bat) { return } # no battery/UPS — exit
$charge = $bat.EstimatedChargeRemaining
$runtime = $bat.EstimatedRunTime # minutes; 71582788 = "not calculated"
$runLeft = if ($runtime -and $runtime -lt 1000000) { "$runtime мин" } else { "н/д" }
# BatteryStatus: 1 = discharging (on battery), 2 = on AC power
$level = "ok"
if ($bat.BatteryStatus -eq 1) { $level = "onbatt" }
if ($charge -le 20 -and $bat.BatteryStatus -eq 1) { $level = "lowbatt" }
$last = if (Test-Path $StateFile) { Get-Content $StateFile -Raw } else { "ok" }
if ($level -ne $last.Trim()) {
switch ($level) {
"onbatt" { Send-Notifly -Title "🔌 Пропало питание — $Host" `
-Message "Сервер перешёл на ИБП. Заряд $charge%, хватит на $runLeft." -Priority 9 }
"lowbatt" { Send-Notifly -Title "🔋 Низкий заряд ИБП — $Host" `
-Message "Осталось $charge% ($runLeft). Скоро сервер выключится!" -Priority 10 }
"ok" { Send-Notifly -Title "✅ Питание восстановлено — $Host" `
-Message "Сервер снова от сети. Заряд батареи $charge%." -Priority 4 }
}
$level | Set-Content $StateFile
}

Register in Task Scheduler (as administrator, every minute as SYSTEM):

Окно терминала
$Action = New-ScheduledTaskAction `
-Execute "powershell.exe" `
-Argument "-NoProfile -ExecutionPolicy Bypass -File C:\scripts\Notifly-UPS-Check.ps1"
$Trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) `
-RepetitionInterval (New-TimeSpan -Minutes 1)
$Princ = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName "Notifly UPS Check" `
-Action $Action -Trigger $Trigger -Principal $Princ -Description "Notifly: контроль питания ИБП"
  • You have time to shut the server down by hand — the push arrives the moment it switches to battery, while you still have a few minutes of reserve, not after a hard power-off.
  • You see the remaining battery right in the notification — the charge and the estimated runtime immediately tell you how much time you have.
  • Three levels without spam (okonbattlowbatt): as long as the state doesn’t change, there are no notifications; a separate loud alert (priority 10) on low charge won’t let you sleep through the shutdown.
  • Add priority 10 with a repeating sound specifically for the low-charge event — to wake the on-call engineer.
  • Attach the full output of upsc ups@localhost (voltage, load, temperature) via the extras field.
  • Tie it together with the unexpected reboot detector: if the server did reboot after the power loss, you’ll get both stories.