This project turns a Raspberry Pi into a small, always-on server that watches a UPS (uninterruptible power supply) over USB using Network UPS Tools (NUT), and shares what it sees with Home Assistant. Home Assistant then shows the battery charge, load and runtime, and can warn you the moment the power goes out.
Written October 2026 for Raspberry Pi OS “Trixie” and NUT 2.8.1. It uses the same approach as my Raspberry Pi camera guide to keep the Pi running for months unattended: automatic restarts, a hardware watchdog, a Wi-Fi watchdog, fewer writes to the SD card, and a daily health-report email.
Unlike the camera guide, this one works on any Raspberry Pi and with either the 32-bit or the 64-bit OS. See Which Raspberry Pi, and which OS? below.
Parts Used in this Project
Here are some links to the items that I used in this project.
The Amazon links are affiliate links that will provide me some income to offset the cost of this site and allow me to continue posting similar content. I have also provided links to PiShop.us in case items are out of stock at Amazon.
- Raspberry Pi Zero 2 W (any Raspberry Pi works. See the table in the next section.)
- Amazon: https://amzn.to/3Bo6dSi
- PiShop.us: https://www.pishop.us/product/raspberry-pi-zero-2-w/
- Micro-USB OTG adapter (Pi Zero models only. It lets the Pi Zero’s micro-USB port take the UPS’s normal USB-A cable.)
- Power Supply for Raspberry Pi or if the UPS has a USB Power Port, a cable to power the Raspberry Pi from the UPS (check the result with step 3, as some UPS USB ports can’t supply enough power).
- A UPS with a USB data port, and the USB cable that came with it.
- Note: some APC models use a special cable with an RJ45/RJ50-style plug on the UPS end. You must use APC’s own cable. A normal network cable will not work.
- microSD card, 16 GB or larger
- Better choice for a Pi that runs 24/7: a “high endurance” card, such as SanDisk High Endurance or Samsung PRO Endurance. They are built for dash cams and security cameras and last much longer.
What this guide sets up
| Problem | What protects against it | Part |
|---|---|---|
| Not knowing the power is out, or how long the UPS will last | NUT reads the UPS, and Home Assistant shows it and sends an alert | 6–9 |
| Not enough power for the Pi (weak supply) | A check now, and a check in the daily email | 3, 14 |
| Wi-Fi going to sleep and dropping off the network | Wi-Fi power saving turned off | 4 |
| SD card wearing out | Fewer writes to the card | 5 |
| NUT losing touch with the UPS (“Data stale”) | NUT watchdog restarts it automatically | 10 |
| The whole Pi freezing | Hardware watchdog reboots it | 11 |
| Wi-Fi disconnecting and not coming back | Wi-Fi watchdog reconnects, then reboots if needed | 12 |
| Problems nobody notices for weeks (including a UPS battery that needs replacing) | Daily health-report email with fix instructions | 13, 14 |
Which Raspberry Pi, and which OS?
My camera guide insisted on the 64-bit OS. That was because the camera software it uses (MediaMTX) only comes as a 64-bit program for the Pi. NUT doesn’t have that limit: it’s part of Raspberry Pi OS for both the 32-bit and the 64-bit versions, and it needs very little CPU or memory. So any Raspberry Pi will do, with whichever OS it can run:
| Raspberry Pi model | 64-bit OS | 32-bit OS | uname -m on the 64-bit OS | uname -m on the 32-bit OS |
|---|---|---|---|---|
| Pi 1 (A, B, A+, B+), Pi Zero, Pi Zero W / WH | ✗ | ✓ | – | armv6l |
| Pi 2 | ✗ | ✓ | – | armv7l |
| Pi 3 (B, B+, A+), Pi Zero 2 W, Compute Module 3 / 3+ | ✓ | ✓ | aarch64 | armv7l |
| Pi 4, Pi 400, Compute Module 4 | ✓ | ✓ | aarch64 | aarch64 * |
| Pi 5, Pi 500 / 500+, Compute Module 5 | ✓ | ✓ | aarch64 | aarch64 * |
Which should you pick? If your Pi can run the 64-bit OS, use it. That’s what Raspberry Pi recommends, and it’s what newer software is built for first. If it can’t (an original Pi Zero W, for example), the 32-bit OS works just as well for this project. Every command in this guide is the same on both.
About uname -m: this command prints the type of processor the running Linux kernel (the core of the operating system) was built for. You’ll use it in step 2.2 to confirm you’re on the OS you meant to install:
armv6l: the oldest 32-bit design (Pi 1 and the original Pi Zero / Zero W).armv7l: a newer 32-bit design (Pi 2, or a 64-bit-capable Pi running the 32-bit OS).aarch64: 64-bit.
* There’s one catch. On a Pi 4, 400 or 5, the 32-bit OS still starts a 64-bit kernel, so uname -m says aarch64 even though everything else on the Pi is 32-bit. To see what the rest of the OS really is, use this command too:
dpkg --print-architecture
It prints arm64 for the 64-bit OS and armhf for the 32-bit OS, on every model.
Before you start
You will need:
- A DHCP reservation for the Pi on your router, so its IP address never changes. Home Assistant connects to it by IP address, and won’t notice if the address changes. (You can add it after the Pi first connects, once you can see it in the router’s client list.)
- A Home Assistant installation that can reach the Pi over the network on TCP port 3493. If your network has VLANs or firewall rules between Home Assistant and the Pi, allow that port.
- A Gmail account with 2-Step Verification turned on. You’ll create a Gmail app password in Part 13 so the Pi can send the daily email. Create a new app password just for this Pi, so you can revoke it without affecting anything else.
- An SSH client (PuTTY on Windows).
- Somewhere to plug the Pi in. Plug the Pi’s power supply into one of the UPS’s battery-backed outlets (not a surge-only one). That way the Pi stays on during an outage and can keep reporting, which is the whole point.
How to read this guide:
- Every command block starts with a comment line such as
# On ups01, as your user. That tells you where to run it. The#line is just a comment, so it’s harmless to paste it along with the command. - Run the commands in the order shown. Don’t skip ahead.
- After most steps there is an Expected result. If what you see is different, stop and sort it out before moving on.
- Anything written
REPLACE_...or<like-this>is a placeholder you must change to your own value. - This guide uses the hostname
ups01, and calls the UPSups. Use your own Pi’s name wherever you seeups01.
Part 1 – Write Raspberry Pi OS to the SD card
If you’ve already written the card: you’re fine to continue, as long as you picked a Raspberry Pi OS Lite version that suits your Pi (see the table above), and set the hostname, username/password, Wi-Fi and SSH in the customization screens (step 1.4). If you skipped customization, it’s quickest to write the card again.
1.1. Open Raspberry Pi Imager and choose your device. (The original Pi Zero W is listed as “Raspberry Pi Zero”.) 
1.2. Choose the operating system: “Raspberry Pi OS (other)”, then “Raspberry Pi OS Lite (64-bit)” if your Pi supports it, or “Raspberry Pi OS Lite (32-bit)” if it doesn’t. Lite means no desktop, which this Pi doesn’t need. (Imager only lists the versions your device can run.)

1.3. Choose the storage: your SD card. 
1.4. When Imager offers to customize the OS, say yes and set all of these. (The screenshots are from an older Imager version, and newer versions lay these screens out differently, but the settings are the same.)
- Hostname:
ups01 - Time zone and keyboard layout: yours. The time zone controls when the daily email arrives.
- Username and password: your choice. Don’t reuse a password from anywhere else.
- Wi-Fi: your network name (SSID), password, and Wi-Fi country (US). The Pi Zero W and Zero 2 W only support 2.4 GHz Wi-Fi. (If your Pi has an Ethernet port and you’ll plug it in, you can skip Wi-Fi.)
- SSH: enabled.
1.5. Write the card. When Imager confirms it’s done and verified, move the card to the Pi and power it on. Don’t connect the UPS’s USB cable yet. That comes in Part 6. 
1.6. Give it 3–5 minutes. The first boot on a Pi Zero is slow, and it reboots itself once while it applies your settings.
Part 2 – First login and updates
2.1. Find the Pi’s IP address in your router’s client list (look for ups01) and add the DHCP reservation now. Then connect with PuTTY to that IP address, port 22, and log in with the username and password you set in Imager.
2.2. Confirm you’re on the right operating system:
# On ups01, as your user
grep VERSION_CODENAME /etc/os-release
uname -m
dpkg --print-architecture
Expected result: VERSION_CODENAME=trixie, then the uname -m value from the table above for your Pi and OS, then arm64 (64-bit OS) or armhf (32-bit OS). For example, an original Pi Zero W shows:
VERSION_CODENAME=trixie
armv6l
armhf
and a Pi Zero 2 W with the 64-bit OS shows:
VERSION_CODENAME=trixie
aarch64
arm64
If it says bookworm, you have an older image. Update Raspberry Pi Imager and write the card again, because Part 5 of this guide is written for Trixie. If it shows armhf and you meant to install the 64-bit OS, write the card again with the 64-bit Lite version. (NUT would work either way, but it’s easier to fix now than later.)
2.3. Install all updates. This can take 15–30 minutes on a Pi Zero 2 W, and 30–60 minutes on an original Pi Zero W.
# On ups01, as your user
sudo apt update
sudo apt full-upgrade -y
Expected result: it ends back at the prompt with no lines starting with E:. (W: warnings are okay.) If you see No space left on device near an update-initramfs line, don’t reboot. See the Troubleshooting section.
2.4. Install the tools the rest of this guide uses. They’re probably already installed, and listing them again does no harm.
# On ups01, as your user
sudo apt install -y iw usbutils
Expected result: it ends back at the prompt with no E: lines.
2.5. Reboot so the updates take effect, then log back in with PuTTY after a minute or two.
# On ups01, as your user
sudo reboot
Part 3 – Check the power
Power problems are the most common reason a Pi becomes unreliable, so don’t skip this. Run it with the Pi on the power supply and cable it will use for good:
# On ups01, as your user
vcgencmd get_throttled
Expected result:
throttled=0x0
Any other value, such as throttled=0x50000 or throttled=0x50005, means the voltage has dropped too low since the Pi booted. Fix that before going any further: use the official Raspberry Pi power supply for your model, or a shorter or thicker cable. Then reboot and check again. (Part 14’s daily email keeps checking this.)
Part 4 – Make Wi-Fi reliable
If your Pi is plugged into Ethernet and not using Wi-Fi, skip this part.
4.1. Turn off Wi-Fi power saving. When it’s on, the Pi’s Wi-Fi dozes off and the Pi disappears from the network, and Home Assistant marks the UPS as unavailable. This setting file works however your Wi-Fi connection was created:
# On ups01, as your user
sudo tee /etc/NetworkManager/conf.d/wifi-powersave-off.conf >/dev/null <<'EOF'
[connection]
wifi.powersave = 2
EOF
Expected result: no output. (2 means “disabled” in NetworkManager’s settings.)
4.2. Reboot, log back in, and check it:
# On ups01, as your user
sudo reboot
# On ups01, as your user
iw dev wlan0 get power_save
Expected result:
Power save: off
4.3. Check the signal strength where the Pi will live:
# On ups01, as your user
iw dev wlan0 link | grep -E "SSID|signal"
Expected result: something like signal: -58 dBm. Closer to 0 is stronger. Around -60 or better is good. Weaker than -70 (for example -75) is unreliable. A UPS sitting at the bottom of a metal rack is a bad spot for Wi-Fi, so try to keep the Pi outside the rack, or use Ethernet if your Pi has a port.
4.4. Check that your router answers a ping. The Wi-Fi watchdog in Part 12 uses this to decide whether the network is up.
# On ups01, as your user
ip route show default
ping -c 3 $(ip route show default | awk '{ print $3; exit }')
Expected result: the first line shows default via <router-ip> dev wlan0 ..., and the ping ends with 3 packets transmitted, 3 received, 0% packet loss. If the router does not answer pings (100% packet loss, even though you’re connected over SSH), make a note of it. You’ll need to point the watchdog at something else in step 12.3.
Part 5 – Reduce writes to the SD card
SD cards wear out from being written to. A NUT server that runs 24/7 doesn’t need to write much, so these steps cut out the writing it doesn’t need.
5.1. Keep swap in RAM only. Trixie uses compressed swap in RAM (called zram), but by default it also copies idle memory to a 2 GB file on the SD card every day. This drop-in file turns off the SD card part:
# On ups01, as your user
sudo mkdir -p /etc/rpi/swap.conf.d
sudo tee /etc/rpi/swap.conf.d/90-zram-only.conf >/dev/null <<'EOF'
[Main]
Mechanism=zram
EOF
Expected result: no output. The change takes effect at the reboot in step 5.6.
5.2. Keep the system log on the SD card, but cap its size. Out of the box, Raspberry Pi OS keeps the log in RAM only, so it’s lost at every reboot, including the reboot after a crash, when you need it most. This setting keeps it on the SD card, limited to 50 MB, so it can’t grow forever:
# On ups01, as your user
sudo mkdir -p /etc/systemd/journald.conf.d
sudo tee /etc/systemd/journald.conf.d/90-ups.conf >/dev/null <<'EOF'
[Journal]
Storage=persistent
SystemMaxUse=50M
EOF
Expected result: no output.
Why 90-? Raspberry Pi OS has its own setting file, 40-rpi-volatile-storage.conf, that says to keep the log in RAM. When several files set the same thing, the one whose name sorts last wins, so this file’s name has to come after 40-. I learned this the hard way: with a name starting 10-, the Pi OS file won, and the logs I needed after an outage were gone.
5.3. Make sure logs aren’t written twice. Older systems ran a second logger (rsyslog) that copied every message into /var/log/syslog as well:
# On ups01, as your user
systemctl is-active rsyslog
Expected result: inactive, or an error that the unit could not be found. Either is fine. Only if it says active, turn it off:
# On ups01, as your user - ONLY if the previous command said "active"
sudo systemctl disable --now rsyslog
5.4. Stop automatic background package downloads. These timers download package lists and rebuild the manual-page index on a schedule. Turning them off saves writes, and it also means nothing changes on the Pi unless you choose to update it (see Maintenance at the end). Run these one at a time. If one of them says it doesn’t exist, that’s fine; just go on to the next.
# On ups01, as your user
sudo systemctl disable --now apt-daily.timer
# On ups01, as your user
sudo systemctl disable --now apt-daily-upgrade.timer
# On ups01, as your user
sudo systemctl disable --now man-db.timer
Expected result: each prints a Removed "/etc/systemd/system/timers.target.wants/..." line, or nothing if it was already off.
5.5. Check what Raspberry Pi OS should already do, and what it shouldn’t (nothing to change if they match):
# On ups01, as your user
findmnt -no OPTIONS /
findmnt -no FSTYPE /tmp
findmnt /var/tmp
findmnt /var/log
Expected result:
rw,noatime
tmpfs
and nothing at all from the last two commands.
noatimemeans Linux doesn’t write to the card every time a file is simply read. If the first line doesn’t includenoatime, stop and look into it before editing anything. A mistake in/etc/fstabcan stop the Pi from booting.tmpfsmeans/tmplives in RAM. That’s the Trixie default, and it’s fine.- The last two commands should print nothing. If either prints a
tmpfsline, read the box below.
Don’t put
/var/tmpor/var/logon a small RAM disk. Many older Pi guides (and an Ansible playbook I wrote myself) add lines like these to/etc/fstabto save SD card writes:tmpfs /var/log tmpfs defaults,noatime,nosuid,nodev,mode=0755,size=50m 0 0
tmpfs /var/tmp tmpfs defaults,noatime,nosuid,nodev,mode=1777,size=30m 0 0I learned the hard way that this backfires. Every kernel update builds a startup file (the initramfs) in
/var/tmp, and it needs more than 30 MB. On one of my NUT servers, the update failed partway through withNo space left on device, and that Pi would probably not have booted if I’d restarted it at that point. A/var/login RAM also throws away every log at each reboot, which is exactly when you need them. Step 5.2 already keeps logging small, so these lines aren’t needed. To turn them off:# On ups01, as your user - ONLY if findmnt showed tmpfs for /var/tmp or /var/log
sudo cp /etc/fstab /etc/fstab.bak
sudo sed -i 's|^tmpfs /var/tmp |#tmpfs /var/tmp |' /etc/fstab
sudo sed -i 's|^tmpfs /var/log |#tmpfs /var/log |' /etc/fstab
grep tmpfs /etc/fstab
sudo findmnt --verify
sudo systemctl daemon-reloadExpected result: both lines now start with
#, andfindmnt --verifyreports0 parse errors, 0 errors. (A warning that systemd “still uses the old version” is normal beforedaemon-reload.) If it reports any errors, put the original back withsudo cp /etc/fstab.bak /etc/fstaband stop. The reboot in step 5.6 puts both folders back on the SD card.
5.6. Reboot to apply the swap and log changes, then log back in:
# On ups01, as your user
sudo reboot
5.7. Verify:
# On ups01, as your user
swapon --show
ls -l /var/swap
ls -d /var/log/journal
systemd-analyze cat-config systemd/journald.conf | grep -E '^# /|^Storage' | grep -B1 '^Storage' | tail -n 2
findmnt /var/tmp
findmnt /var/log
Expected result:
swaponshows only/dev/zram0(no/var/swapfile).- The first
lssaysNo such file or directory(the 2 GB swap file has been removed). - The second
lsprints/var/log/journal. That folder only exists when the log is kept on the SD card. - The
systemd-analyzecommand prints these two lines. The lastStorage=setting is the one that wins, and it must bepersistent:# /etc/systemd/journald.conf.d/90-ups.conf
Storage=persistent(If a file that sorts later setsStorage=volatile, rename90-ups.confto99-ups.conf, thensudo systemctl restart systemd-journald.) - Both
findmntcommands print nothing.
From the next reboot on, journalctl --list-boots --no-pager lists every boot, and you can read the log from before a crash with sudo journalctl -b -1. Right now it only lists the current boot, because the boot before step 5.6 was still logging to RAM.
Optional, for later: Raspberry Pi OS can also make the whole SD card read-only (
sudo raspi-config→ Performance Options → Overlay File System). That protects the card completely, but every change you make, and every log, is lost at reboot, and you have to turn it off to install updates. Only consider it once everything has been running well for a while.
Part 6 – Connect the UPS and install NUT
6.1. Plug the UPS’s USB data cable into the Pi.
- On a Pi Zero, Zero W or Zero 2 W, there are two micro-USB ports. Use the one marked USB (closer to the middle of the board) through the micro-USB OTG adapter. The one marked PWR, at the corner, is power only.
- On any other Pi, use any of the USB-A ports.
6.2. Check that the Pi can see the UPS:
# On ups01, as your user
lsusb
Expected result: a line for your UPS among the others. The name varies by brand, for example:
Bus 001 Device 002: ID 051d:0002 American Power Conversion Uninterruptible Power Supply
(CyberPower units usually show ID 0764:0501, and Eaton units ID 0463:ffff.) If nothing new appears, check both ends of the cable, try the other port on a Pi Zero, and make sure the UPS is switched on.
6.3. Install NUT:
# On ups01, as your user
sudo apt install -y nut
Expected result: it installs nut, nut-client and nut-server, and ends back at the prompt with no E: lines. Messages that NUT is not configured yet are normal at this point. That’s the next part.
6.4. Ask NUT to identify the UPS:
# On ups01, as your user
sudo nut-scanner -U
Expected result: a block like this one (your values will differ):
[nutdev1]
driver = "usbhid-ups"
port = "auto"
vendorid = "051D"
productid = "0002"
product = "Back-UPS ES 600M1 FW:..."
serial = "..."
vendor = "American Power Conversion"
bus = "001"
The line that matters is driver. Most UPSes made in the last 15 years use usbhid-ups. Make a note of it if yours shows something else. If nut-scanner finds nothing even though lsusb lists the UPS, look up your model in the NUT hardware compatibility list to find its driver.
Part 7 – Configure NUT
NUT is made of three pieces, and each one has its own settings file in /etc/nut:
| Piece | What it does | Settings file |
|---|---|---|
| The driver | Talks to the UPS over USB | ups.conf |
The server (upsd) | Shares the UPS readings on the network, port 3493. This is what Home Assistant connects to. | upsd.conf, upsd.users |
The monitor (upsmon) | Watches the UPS and safely shuts down this Pi when the battery is nearly empty | upsmon.conf |
A fourth file, nut.conf, says which pieces to run.
7.1. Make up two passwords. Run this twice and keep both results somewhere safe (a password manager is ideal). The first is for the local monitor, and the second is for Home Assistant:
# On ups01, as your user
openssl rand -hex 12
Expected result: a random 24-character string of letters and numbers each time, such as 3f9c0a1b7d2e4c6f8a9b0c1d. (Letters and numbers only, so nothing in them can confuse NUT’s settings files.)
7.2. Tell NUT to run as a network server:
# On ups01, as your user
sudo sed -i 's/^MODE=.*/MODE=netserver/' /etc/nut/nut.conf
grep '^MODE=' /etc/nut/nut.conf
Expected result:
MODE=netserver
If it prints nothing, add the line yourself with echo 'MODE=netserver' | sudo tee -a /etc/nut/nut.conf and run the grep again.
7.3. Describe the UPS to the driver. This replaces the example file:
# On ups01, as your user
sudo tee /etc/nut/ups.conf >/dev/null <<'EOF'
# /etc/nut/ups.conf - the UPS connected to this Pi
# Try 3 times to reach the UPS when starting (USB can be slow to wake up at boot)
maxretry = 3
[ups]
driver = usbhid-ups
port = auto
desc = "UPS connected to ups01"
EOF
Expected result: no output. [ups] is the name NUT and Home Assistant will use for this UPS. If nut-scanner showed a driver other than usbhid-ups in step 6.4, open the file (sudo nano /etc/nut/ups.conf) and change the driver = line to match.
7.4. Make the server listen on the network:
# On ups01, as your user
sudo tee /etc/nut/upsd.conf >/dev/null <<'EOF'
# /etc/nut/upsd.conf - where the NUT server listens
# 0.0.0.0 means every IPv4 address on this Pi, including 127.0.0.1, so don't
# add a separate LISTEN 127.0.0.1 line (it would clash with this one).
LISTEN 0.0.0.0 3493
EOF
Expected result: no output.
Note: NUT lets anyone who can reach port 3493 read the UPS readings without a password. Only changes and commands need a login. That’s normal for NUT on a home network. If you want to limit who can connect, do it with your network’s firewall.
7.5. Create the two logins. The passwords are placeholders for now, so they never end up in your command history:
# On ups01, as your user
sudo tee /etc/nut/upsd.users >/dev/null <<'EOF'
# /etc/nut/upsd.users - who may log in to the NUT server
# The monitor on this Pi
[upsmon]
password = REPLACE_UPSMON_PASSWORD
upsmon primary
# Home Assistant (read-only)
[homeassistant]
password = REPLACE_HA_PASSWORD
EOF
Expected result: no output.
Why is Home Assistant read-only? With command rights (actions = SET and instcmds = ALL), Home Assistant shows buttons that can switch the UPS’s outlets off or start a battery test, and anyone with access to your Home Assistant could press them by mistake. Reading the UPS doesn’t need any rights. If you do want those buttons later, add those two lines under [homeassistant] and restart the server with sudo systemctl restart nut-server.
7.6. Tell the monitor which UPS to watch and how to shut down:
# On ups01, as your user
sudo tee /etc/nut/upsmon.conf >/dev/null <<'EOF'
# /etc/nut/upsmon.conf - watch the UPS and shut this Pi down when the battery runs low
MONITOR ups@localhost 1 upsmon REPLACE_UPSMON_PASSWORD primary
MINSUPPLIES 1
SHUTDOWNCMD "/sbin/shutdown -h +0"
POWERDOWNFLAG /etc/killpower
EOF
Expected result: no output.
primary means this Pi is the one connected to the UPS, so it’s in charge. When the battery gets low during an outage, it shuts the Pi down cleanly and then tells the UPS to switch its outlets off. When the power comes back, the UPS turns its outlets back on and the Pi starts up by itself. Anything else on the UPS’s battery outlets loses power at that point too, but the battery was about to run out anyway. (Some UPS models ignore the “switch off” command. They just run until the battery is empty.)
NOTE: This step will shut down the Raspberry Pi itself when the UPS battery runs low. What’s not covered is shutting down the other machines powered through the UPS.
7.7. Put in the real passwords. Open each file, replace the placeholder with the matching password from step 7.1, save (Ctrl+O, Enter) and exit (Ctrl+X):
# On ups01, as your user
sudo nano /etc/nut/upsd.users
Replace REPLACE_UPSMON_PASSWORD with the first password, and REPLACE_HA_PASSWORD with the second.
# On ups01, as your user
sudo nano /etc/nut/upsmon.conf
Replace REPLACE_UPSMON_PASSWORD with the first password (the same one as [upsmon] above).
7.8. Check that no placeholders are left, and that only NUT can read the passwords:
# On ups01, as your user
sudo grep -l REPLACE_ /etc/nut/*
ls -l /etc/nut/
Expected result: the grep prints nothing. In the ls list, upsd.users and upsmon.conf (and probably the others) start with -rw-r----- 1 root nut, which means only root and NUT itself can read them. If grep names a file, open it and replace what’s left. If the permissions are different, fix them with:
# On ups01, as your user - ONLY if the permissions were different
sudo chown root:nut /etc/nut/*.conf /etc/nut/upsd.users
sudo chmod 640 /etc/nut/*.conf /etc/nut/upsd.users
7.9. Reboot. NUT starts its three pieces in the right order at boot. The reboot also makes sure the UPS’s USB connection gets the permissions NUT installed for it (the UPS was plugged in before NUT existed):
# On ups01, as your user
sudo reboot
7.10. Log back in after a couple of minutes and check every piece:
# On ups01, as your user
systemctl is-active nut-server nut-monitor
systemctl is-enabled nut-server nut-monitor
systemctl list-units 'nut-driver@*' --no-pager
Expected result:
active
active
enabled
enabled
and a line containing nut-driver@ups.service loaded active running.
# On ups01, as your user
upsc ups@localhost
Expected result: a long list of readings, including lines like these:
battery.charge: 100
battery.runtime: 1980
device.mfr: American Power Conversion
device.model: Back-UPS ES 600M1
ups.load: 23
ups.status: OL
ups.status: OL means “on line”, which is running from the wall. OL CHRG (on line, charging) is also fine. (A first line saying Init SSL without certificate database is harmless.)
# On ups01, as your user
upsc -c ups@localhost
sudo ss -tlnp | grep 3493
Expected result: upsc -c lists 127.0.0.1, which is the monitor, logged in with the upsmon password. ss shows a line with 0.0.0.0:3493 and upsd, which means the server is listening on the network. If upsc -c lists nothing, the two REPLACE_UPSMON_PASSWORD values don’t match. Check them with sudo grep -n password /etc/nut/upsd.users and sudo grep -n MONITOR /etc/nut/upsmon.conf, then sudo systemctl restart nut-server nut-monitor.
Part 8 – Test a power failure
This proves the whole chain works before you depend on it. Only do it when the equipment on the UPS can take a short outage, which is the UPS’s whole job anyway.
8.1. Unplug the UPS from the wall (leave everything plugged into the UPS). Wait about 10 seconds, then:
# On ups01, as your user
upsc ups@localhost ups.status
Expected result: OB DISCHRG (on battery, discharging). PuTTY may also show a Broadcast message from nut@ups01 saying UPS ups@localhost on battery. That’s normal.
8.2. Plug the UPS back in. Wait about 30 seconds, then check again:
# On ups01, as your user
upsc ups@localhost ups.status
journalctl -u nut-monitor -n 5 --no-pager
Expected result: OL CHRG (or OL), and the log ends with lines saying UPS ups@localhost on battery and then UPS ups@localhost on line power.
Don’t test the low-battery shutdown by running the battery down. It’s hard on the battery, and step 7.6 already describes what will happen.
Part 9 – Add the UPS to Home Assistant
9.1. In Home Assistant, go to Settings → Devices & services.
- If you see a Discovered card for Network UPS Tools (NUT), click Add on it. (The NUT server announces itself on the network, so Home Assistant often finds it on its own.)
- Otherwise, click + Add Integration, search for Network UPS Tools, and select it.
9.2. Fill in the form:
- Host: the Pi’s IP address (the one you reserved in step 2.1). An IP address is more dependable than the hostname here.
- Port:
3493 - Username:
homeassistant - Password: the second password from step 7.1
Click Submit. If it asks which UPS to monitor, choose ups.
Expected result: a success message, and a new device named after your UPS model. If it says it can’t connect, see Troubleshooting.
9.3. Open the new device. You’ll see sensors such as Status (for example “Online”), Status data (the raw OL), Battery charge, Battery runtime, Load and Input voltage. The exact list depends on what your UPS reports. Some extra sensors are added but turned off to save space in Home Assistant’s database. To use one, click +N entities not shown on the device page, select it, open its settings (gear icon), and turn on Enabled.
9.4. (Recommended.) Rename the device to say where it is, for example “UPS – network rack”, and assign it to an Area. That makes alerts and dashboards much easier to read, especially if you have more than one UPS.
9.5. (Recommended.) Get a phone notification when the power goes out. You’ll need the Home Assistant app on your phone.
- Find two entity IDs. On the device page, click the Status data sensor, then the gear icon, and copy the Entity ID (something like
sensor.back_ups_es_600m1_status_data). Do the same for Battery charge. - Find your phone’s notify action. Go to Settings → Developer tools → Actions and type
notify.mobile_app. Your phone appears as something likenotify.mobile_app_pixel_8. - Go to Settings → Automations & scenes → + Create automation → Create new automation. Click the ⋮ menu (top right) → Edit in YAML, replace everything with the following, and change the three
REPLACE_...values to what you found above:
alias: UPS on battery - ups01
description: Phone notification when the UPS on ups01 switches to battery
triggers:
- trigger: template
value_template: "{{ 'OB' in states('sensor.REPLACE_status_data').split() }}"
conditions: []
actions:
- action: notify.REPLACE_mobile_app
data:
title: Power outage
message: >-
The UPS on ups01 is on battery
({{ states('sensor.REPLACE_battery_charge') }}% charged).
mode: single
- Click Save. To test it, repeat the unplug test from Part 8. The notification should arrive within about 30 seconds.
Tip: copy the automation, change 'OB' in to 'OL' in, and change the message, to also get a “power is back” notification.
9.6. If you have more than one NUT server, repeat this part for each one. Each Pi shows up as its own device.
Part 10 – Keep NUT running (the NUT watchdog)
The most common NUT problem on a Pi is the USB link to the UPS hiccuping. The driver loses touch, upsc says Data stale or Driver not connected, and Home Assistant shows the UPS as unavailable. Usually it sorts itself out, but sometimes it stays stuck until someone restarts NUT. This script checks every 5 minutes:
- If NUT answers, it does nothing, and writes nothing to the log.
- If NUT doesn’t answer, it restarts the driver and the server, checks again, and logs what happened.
- It doesn’t check in the first 5 minutes after boot, while NUT is still starting.
10.1. Create the script. Paste the whole block at once. It ends with the line SCRIPT_END:
# On ups01, as your user
sudo tee /usr/local/sbin/nut-watchdog.sh >/dev/null <<'SCRIPT_END'
#!/bin/bash
# nut-watchdog.sh - restarts NUT if it stops answering for the UPS.
#
# Run every few minutes by nut-watchdog.timer. It asks the local NUT server for
# the UPS status. If that fails (for example "Data stale" or "Driver not
# connected", which happen when the USB link to the UPS hiccups), it restarts the
# UPS driver and the NUT server, then checks again.
# It does nothing in the first MIN_UPTIME seconds after boot, while NUT starts up.
# Log lines use the tag nut-watchdog, which the daily health email reads.
#
# Every setting can be overridden from the environment, which is how the test in
# the guide runs it. DRY_RUN=1 prints what it would do instead of doing it.
UPS_NAME="${UPS_NAME:-ups}"
MIN_UPTIME="${MIN_UPTIME:-300}" # 5 minutes
DRY_RUN="${DRY_RUN:-0}"
# Test runs log under a different name so the daily health email doesn't
# mistake them for real restarts.
LOG_TAG="nut-watchdog"
[ "$DRY_RUN" = "1" ] && LOG_TAG="nut-watchdog-test"
log() {
# -s also prints the message to the terminal when run by hand
logger -s -t "$LOG_TAG" "$1"
}
# Prints the UPS status (for example "OL CHRG"). On failure, prints upsc's
# error message instead and returns non-zero.
ups_status() {
local out err rc
err=$(mktemp)
out=$(upsc "$UPS_NAME@localhost" ups.status 2>"$err")
rc=$?
if [ "$rc" -ne 0 ] || [ -z "$out" ]; then
# upsc can also print a harmless "Init SSL" line, so keep only the last line
tail -n 1 "$err"
rm -f "$err"
return 1
fi
rm -f "$err"
echo "$out"
}
uptime_s=$(cut -d. -f1 /proc/uptime)
if [ "$uptime_s" -lt "$MIN_UPTIME" ]; then
[ "$DRY_RUN" = "1" ] && log "only ${uptime_s}s since boot - not checking yet"
exit 0
fi
if status=$(ups_status); then
[ "$DRY_RUN" = "1" ] && log "UPS $UPS_NAME is answering, status: $status"
exit 0
fi
log "UPS $UPS_NAME is not answering: ${status:-no reply}"
log "RESTARTING: nut-driver@$UPS_NAME and nut-server"
if [ "$DRY_RUN" = "1" ]; then
log "DRY_RUN: would run: systemctl restart nut-driver@$UPS_NAME nut-server"
exit 0
fi
systemctl restart "nut-driver@$UPS_NAME"
sleep 10
systemctl restart nut-server
sleep 10
if status=$(ups_status); then
log "UPS $UPS_NAME is answering again after the restart, status: $status"
else
log "UPS $UPS_NAME is STILL not answering after the restart: ${status:-no reply}"
fi
SCRIPT_END
sudo chmod 755 /usr/local/sbin/nut-watchdog.sh
sha256sum /usr/local/sbin/nut-watchdog.sh
Expected result:
dbfec97c7f60f987dfcf1652504d4159dd001af86da6b7f1d86bd18abca51f37 /usr/local/sbin/nut-watchdog.sh
If the checksum is different, something was lost or changed while pasting. Run step 10.1 again.
10.2. Test it without letting it change anything (DRY_RUN=1). First, against your real UPS, where it should find nothing wrong:
# On ups01, as your user
DRY_RUN=1 MIN_UPTIME=0 /usr/local/sbin/nut-watchdog.sh
Expected result:
nut-watchdog-test: UPS ups is answering, status: OL
(Each line may start with a date and <13>. That’s normal. Test runs log as nut-watchdog-test, so the health email doesn’t count them as real events.)
If it says UPS ups is not answering: Error: Unknown UPS instead, your UPS has a different name. That happens if NUT was set up some other way, such as another guide or an Ansible playbook. See the name with upsc -l (for example apc-ups), and test again with it: DRY_RUN=1 MIN_UPTIME=0 UPS_NAME=apc-ups /usr/local/sbin/nut-watchdog.sh. Once that shows the UPS answering, make it the script’s default (replace apc-ups with your name):
# On ups01, as your user - ONLY if your UPS is not called "ups"
sudo sed -i 's/^UPS_NAME="${UPS_NAME:-ups}"/UPS_NAME="${UPS_NAME:-apc-ups}"/' /usr/local/sbin/nut-watchdog.sh
grep -n '^UPS_NAME=' /usr/local/sbin/nut-watchdog.sh
The grep should show 14:UPS_NAME="${UPS_NAME:-apc-ups}". The checksum in step 10.1 won’t match after this edit. That’s expected. Use your UPS’s name in place of ups everywhere else in this guide too (for example upsc apc-ups@localhost), and set it on line 15 of the health script in step 14.2.
Next, against a UPS name that doesn’t exist, where it should decide to restart NUT. It only prints what it would do:
# On ups01, as your user
DRY_RUN=1 MIN_UPTIME=0 UPS_NAME=nosuchups /usr/local/sbin/nut-watchdog.sh
Expected result: three lines, similar to:
nut-watchdog-test: UPS nosuchups is not answering: Error: Unknown UPS
nut-watchdog-test: RESTARTING: nut-driver@nosuchups and nut-server
nut-watchdog-test: DRY_RUN: would run: systemctl restart nut-driver@nosuchups nut-server
10.3. Create the service and the timer that runs it every 5 minutes:
# On ups01, as your user
sudo tee /etc/systemd/system/nut-watchdog.service >/dev/null <<'EOF'
[Unit]
Description=Restart NUT if the UPS stops answering
After=nut-server.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/nut-watchdog.sh
# Keep the routine "Starting/Finished" lines out of the log every 5 minutes
LogLevelMax=notice
EOF
sudo tee /etc/systemd/system/nut-watchdog.timer >/dev/null <<'EOF'
[Unit]
Description=Check NUT every 5 minutes
[Timer]
OnBootSec=10min
OnUnitActiveSec=5min
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now nut-watchdog.timer
systemctl list-timers nut-watchdog.timer --no-pager
Expected result: one timer listed, nut-watchdog.timer. Enabling it runs the first check straight away, so LAST shows a time from just a moment ago. You may see - under NEXT and LEFT. That’s normal: the listing appeared while that first check was still running, and the next run is only scheduled once it finishes. Wait a few seconds and list it again:
# On ups01, as your user
systemctl list-timers nut-watchdog.timer --no-pager
Expected result: NEXT now shows a time about 5 minutes after LAST, and LEFT shows something like 4min 30s. For example:
NEXT LEFT LAST PASSED UNIT ACTIVATES
Sun 2026-10-11 11:34:59 EDT 4min Sun 2026-10-11 11:29:59 EDT 32s ago nut-watchdog.timer nut-watchdog.service
If NEXT still shows - after a minute, check that the check itself works with sudo systemctl start nut-watchdog.service followed by systemctl status nut-watchdog.service --no-pager. The status should show Deactivated successfully (or inactive (dead)) with no errors.
To see what the watchdog has done at any time:
journalctl -t nut-watchdog --since "7 days ago" --no-pager
Part 11 – Turn on the hardware watchdog
The Pi has a built-in hardware timer. Once it’s switched on, the Pi’s system manager (systemd) resets the timer every few seconds. If the Pi freezes completely, the timer runs out and the hardware reboots the Pi about 15 seconds later. Without this, a frozen Pi stays frozen until someone pulls the plug, and your UPS goes unwatched.
11.1. Create the setting file:
# On ups01, as your user
sudo mkdir -p /etc/systemd/system.conf.d
sudo tee /etc/systemd/system.conf.d/watchdog.conf >/dev/null <<'EOF'
[Manager]
RuntimeWatchdogSec=15
EOF
Expected result: no output. (15 seconds is the longest the Pi’s watchdog supports. Don’t set it higher.)
11.2. Reboot, log back in, and check it:
# On ups01, as your user
sudo reboot
# On ups01, as your user
systemctl show -p RuntimeWatchdogUSec --value
Expected result:
15s
11.3. (Optional, but it proves the watchdog works.) This deliberately crashes the Linux kernel. With the watchdog on, the Pi should reboot by itself within about 30 seconds, and PuTTY will disconnect. Only do this once everything above is working. It’s an unclean crash, much like pulling the power.
# On ups01, as your user - OPTIONAL: deliberately crashes the Pi
echo c | sudo tee /proc/sysrq-trigger
Expected result: PuTTY stops responding. After 1–2 minutes, you can log in again, and upsc ups@localhost ups.status shows OL.
Part 12 – Install the Wi-Fi watchdog
If your Pi is plugged into Ethernet and not using Wi-Fi, skip this part.
Sometimes a Pi Zero loses Wi-Fi and never reconnects, even though Linux itself is still running fine, so the hardware watchdog won’t step in. This script pings your router every minute:
- After 3 failed minutes in a row, it turns Wi-Fi off and back on.
- After 10 failed minutes in a row, it reboots the Pi.
- It never reboots within 30 minutes of booting, so a long router or access-point outage can’t cause a reboot loop.
- It keeps its failure count in memory, so the only thing it writes to the SD card is its log lines, and only when something goes wrong.
This is the same script as in my camera guide, so if you’ve installed it there, the checksum below matches too.
12.1. Create the script. Paste the whole block at once. It’s long, and it ends with the line SCRIPT_END:
# On ups01, as your user
sudo tee /usr/local/sbin/wifi-watchdog.sh >/dev/null <<'SCRIPT_END'
#!/bin/bash
# wifi-watchdog.sh - keeps a Wi-Fi-only Raspberry Pi reachable.
#
# Every CHECK_INTERVAL seconds it pings the default gateway (or TARGET).
# - After FAILS_BEFORE_RECONNECT failed checks in a row it turns Wi-Fi off and on.
# - After FAILS_BEFORE_REBOOT failed checks in a row it reboots the Pi,
# but never sooner than MIN_UPTIME_FOR_REBOOT seconds after boot, so a long
# router/access-point outage can't cause a fast reboot loop.
# The failure counter is kept in memory only, so this never writes to the SD card
# except for its log lines (tag: wifi-watchdog), which the daily health email reads.
#
# Every setting can be overridden from the environment, which is how the test in
# picam_setup.md runs it. DRY_RUN=1 logs what it would do instead of doing it.
TARGET="${TARGET:-}" # empty = ping the default gateway
CHECK_INTERVAL="${CHECK_INTERVAL:-60}" # seconds between checks
FAILS_BEFORE_RECONNECT="${FAILS_BEFORE_RECONNECT:-3}" # ~3 minutes offline
FAILS_BEFORE_REBOOT="${FAILS_BEFORE_REBOOT:-10}" # ~10 minutes offline
MIN_UPTIME_FOR_REBOOT="${MIN_UPTIME_FOR_REBOOT:-1800}" # 30 minutes
STARTUP_DELAY="${STARTUP_DELAY:-120}" # let Wi-Fi connect after boot
DRY_RUN="${DRY_RUN:-0}"
# Test runs log under a different name so the daily health email doesn't
# mistake them for real reconnects/reboots.
LOG_TAG="wifi-watchdog"
[ "$DRY_RUN" = "1" ] && LOG_TAG="wifi-watchdog-test"
log() {
# -s also prints the message to the terminal when run by hand
logger -s -t "$LOG_TAG" "$1"
}
target_to_ping() {
if [ -n "$TARGET" ]; then
echo "$TARGET"
else
ip route show default 2>/dev/null | awk '{ print $3; exit }'
fi
}
log "started (check every ${CHECK_INTERVAL}s, reconnect after ${FAILS_BEFORE_RECONNECT}, reboot after ${FAILS_BEFORE_REBOOT} failed checks)"
sleep "$STARTUP_DELAY"
fails=0
while true; do
target=$(target_to_ping)
if [ -n "$target" ] && ping -c 3 -W 2 -q "$target" >/dev/null 2>&1; then
if [ "$fails" -gt 0 ]; then
log "network is back after $fails failed check(s)"
fi
fails=0
else
fails=$((fails + 1))
log "check failed ($fails in a row), target: ${target:-no default route}"
uptime_s=$(cut -d. -f1 /proc/uptime)
if [ "$fails" -ge "$FAILS_BEFORE_REBOOT" ] && [ "$uptime_s" -ge "$MIN_UPTIME_FOR_REBOOT" ]; then
log "REBOOTING: network unreachable for $fails checks"
if [ "$DRY_RUN" = "1" ]; then
log "DRY_RUN: would reboot now - stopping test"
exit 0
fi
systemctl reboot
exit 0
elif [ $((fails % FAILS_BEFORE_RECONNECT)) -eq 0 ]; then
log "RECONNECT: turning Wi-Fi off and on"
if [ "$DRY_RUN" = "1" ]; then
log "DRY_RUN: would run: nmcli radio wifi off; nmcli radio wifi on"
else
nmcli radio wifi off
sleep 5
nmcli radio wifi on
fi
fi
fi
sleep "$CHECK_INTERVAL"
done
SCRIPT_END
sudo chmod 755 /usr/local/sbin/wifi-watchdog.sh
sha256sum /usr/local/sbin/wifi-watchdog.sh
Expected result:
8661acc8ac396cd24f46ca8e453b55a7a32d0b99e0648fc94b723a69ce6017ae /usr/local/sbin/wifi-watchdog.sh
If the checksum is different, something was lost or changed while pasting. Run step 12.1 again.
12.2. Test it without letting it change anything (DRY_RUN=1). First, against your real router, where it should stay quiet:
# On ups01, as your user
DRY_RUN=1 STARTUP_DELAY=0 CHECK_INTERVAL=5 /usr/local/sbin/wifi-watchdog.sh
Expected result: one line wifi-watchdog-test: started (...), and then nothing more. Every ping is succeeding. Wait about 20 seconds, then press Ctrl+C. (If you see check failed lines instead, your router doesn’t answer pings. See step 12.3.)
Next, against an address that doesn’t exist (192.0.2.1 is reserved for testing), where it should go through the reconnect and reboot steps. It only prints what it would do:
# On ups01, as your user
DRY_RUN=1 STARTUP_DELAY=0 CHECK_INTERVAL=1 TARGET=192.0.2.1 FAILS_BEFORE_RECONNECT=2 FAILS_BEFORE_REBOOT=4 MIN_UPTIME_FOR_REBOOT=0 /usr/local/sbin/wifi-watchdog.sh
Expected result: after about 20–30 seconds it stops by itself, having printed (each failed check takes a few seconds, because it waits for 3 pings to time out):
wifi-watchdog-test: started (check every 1s, reconnect after 2, reboot after 4 failed checks)
wifi-watchdog-test: check failed (1 in a row), target: 192.0.2.1
wifi-watchdog-test: check failed (2 in a row), target: 192.0.2.1
wifi-watchdog-test: RECONNECT: turning Wi-Fi off and on
wifi-watchdog-test: DRY_RUN: would run: nmcli radio wifi off; nmcli radio wifi on
wifi-watchdog-test: check failed (3 in a row), target: 192.0.2.1
wifi-watchdog-test: check failed (4 in a row), target: 192.0.2.1
wifi-watchdog-test: REBOOTING: network unreachable for 4 checks
wifi-watchdog-test: DRY_RUN: would reboot now - stopping test
12.3. Only if your router did not answer pings in step 4.4 or 12.2: pick another device on your own network that is always on and does answer pings (for example your DNS server, or the Home Assistant machine), and set it as the target. Don’t use an internet address such as 8.8.8.8. If your internet goes down while your home network is fine, the watchdog would keep rebooting the Pi, often during the same storm that knocks out the power, which is exactly when you need it watching the UPS:
# On ups01, as your user - ONLY if your router doesn't answer pings
sudo nano /usr/local/sbin/wifi-watchdog.sh
Change the line TARGET="${TARGET:-}" to TARGET="${TARGET:-<ip-of-that-device>}", save (Ctrl+O, Enter), exit (Ctrl+X), and repeat the first test in step 12.2. (The checksum in step 12.1 won’t match after this edit. That’s expected.)
12.4. Create the service that keeps the watchdog running:
# On ups01, as your user
sudo tee /etc/systemd/system/wifi-watchdog.service >/dev/null <<'EOF'
[Unit]
Description=Wi-Fi watchdog (reconnect, then reboot, if the network is lost)
Wants=network-online.target
After=network-online.target
[Service]
ExecStart=/usr/local/sbin/wifi-watchdog.sh
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now wifi-watchdog
systemctl status wifi-watchdog --no-pager
Expected result: Loaded: ... enabled and Active: active (running). Because the status appears less than a second after the service starts, the script may not have written its first log line yet, and the process list may briefly show two wifi-watchdog.sh lines. Both are normal. A few seconds later, check its log:
# On ups01, as your user
journalctl -t wifi-watchdog -n 5 --no-pager
Expected result:
... wifi-watchdog[...]: started (check every 60s, reconnect after 3, reboot after 10 failed checks)
After that it stays quiet, unless the network actually drops.
To see what the watchdog has done at any time:
journalctl -t wifi-watchdog --since "7 days ago" --no-pager
Part 13 – Let the Pi send email (through Gmail)
13.1. Create a Gmail app password for this Pi. Do this on your PC:
- Go to https://myaccount.google.com/apppasswords and sign in. (If Google says app passwords aren’t available, turn on 2-Step Verification first, under Security.)
- For the app name, type
ups01and click Create. - Google shows a 16-letter password in four groups, like
abcd efgh ijkl mnop. Copy it somewhere safe for the next few minutes. You’ll type it without the spaces:abcdefghijklmnop.
Setting up several Pis? One app password each, or one shared? A shared one works. Gmail accepts the same app password from any number of devices. One per Pi (named
ups01,ups02, and so on) is safer, though. The password is stored on the Pi’s SD card, so if a card is ever lost, or thrown away without being wiped, you can revoke just that Pi’s password while the others keep sending.Better still, use a separate Gmail account just for alerts. An app password doesn’t only send mail: anyone who has it can also sign in and read that account’s email. If the Pis send from a dedicated account (for example
myhouse.alerts@gmail.com), a password taken from an SD card exposes nothing but health reports. The reports can still be delivered to your everyday address, which you set in step 14.2.
13.2. Install msmtp, a small program that sends email through Gmail:
# On ups01, as your user
sudo apt install -y msmtp
Expected result: it installs. If a blue screen asks “Enable AppArmor support?”, choose No (the default) and press Enter.
13.3. Create the mail settings file. It uses placeholders, so your password never ends up in your command history:
# On ups01, as your user
sudo tee /etc/msmtprc >/dev/null <<'EOF'
# /etc/msmtprc - lets this Pi send email through Gmail
defaults
auth on
tls on
tls_trust_file /etc/ssl/certs/ca-certificates.crt
syslog LOG_MAIL
account gmail
host smtp.gmail.com
port 587
from REPLACE_GMAIL_ADDRESS
user REPLACE_GMAIL_ADDRESS
password REPLACE_APP_PASSWORD
account default : gmail
EOF
sudo chmod 600 /etc/msmtprc
13.4. Put in your real values:
# On ups01, as your user
sudo nano /etc/msmtprc
Replace both REPLACE_GMAIL_ADDRESS with your Gmail address, and REPLACE_APP_PASSWORD with the 16-letter app password (no spaces). Save (Ctrl+O, Enter) and exit (Ctrl+X). Then check who can read the file:
# On ups01, as your user
ls -l /etc/msmtprc
Expected result: it starts with -rw------- 1 root root, which means only root can read the password.
13.5. Send a test email (replace the address with where you want the Pi’s emails to go):
# On ups01, as your user
printf 'Subject: ups01 test email\n\nIf you can read this, the Pi can send email.\n' | sudo msmtp REPLACE_EMAIL_TO
Expected result: no output, and the email arrives within a minute or so (check Spam the first time). If it fails, see what happened with sudo journalctl -t msmtp -n 5 --no-pager. An authentication failed error means the app password or Gmail address is wrong. A connection timed out error means something (such as a firewall rule on your network) is blocking outgoing port 587.
Part 14 – Daily health-report email
Once a day at 7:00 AM, this script checks the UPS and the Pi and emails you a report. The subject line tells you at a glance: [ups01] Daily health: OK or [ups01] Daily health: 2 PROBLEM(S) FOUND. Every problem in the email comes with step-by-step instructions to fix it.
What it checks:
| Check | Flagged when |
|---|---|
| NUT services | The driver, server or monitor isn’t running, or the server or monitor won’t start at boot |
| UPS status | NUT can’t read the UPS, or the UPS reports being on battery, a low battery, a battery that needs replacing, an overload, or an unusual state |
| Battery charge | Below 50% and not charging |
| UPS load | Above 80% |
| Power events | The UPS switched to battery, or NUT lost contact with the UPS, in the last 24 hours |
| NUT watchdog | It had to restart NUT in the last 24 hours, or its timer isn’t running |
| Pi power supply | vcgencmd get_throttled is not 0x0. It tells a power problem apart from a heat problem, and gives fix suggestions for each. |
| Temperature | 75 °C or hotter |
| Wi-Fi power saving and signal | Power saving is on, or the signal is weaker than -70 dBm (skipped on a wired Pi) |
| Wi-Fi watchdog | It had to reconnect or reboot in the last 24 hours, or it isn’t running (skipped if not installed) |
| Hardware watchdog | Turned off (with fix instructions) |
| Disk space | Any filesystem with less than 20% free, or /var/tmp or /var/log on a RAM disk |
| SD card health | The card has switched to read-only, filesystem errors are recorded, the kernel logged read/write errors, or much more is being written than normal |
The details section also lists the UPS model, the estimated runtime on battery, and the battery date if the UPS reports one, which is handy when it’s time to order a replacement battery. (Some UPSes report a meaningless factory date instead. Many APC Back-UPS models, for example, always say 2001/09/25. If yours shows a date that can’t be right, ignore it, and write the date on a sticker on the battery when you replace it.)
About SD card health: ordinary SD cards don’t report how worn out they are the way SSDs do, so there’s no “percent life left” to read. Instead, the script watches for the warning signs that show up before a card fails: filesystem errors, read/write errors and timeouts in the kernel log, and the card switching itself to read-only. It also reports how much is written to the card each day, so you can see that Part 5 is working.
14.1. Create the script. Paste the whole block at once. It’s long, and it ends with the line SCRIPT_END:
# On ups01, as your user
sudo tee /usr/local/sbin/nut-health.sh >/dev/null <<'SCRIPT_END'
#!/bin/bash
# nut-health.sh - daily health report for a Raspberry Pi NUT (UPS monitor) server.
#
# Checks the UPS, the NUT services, power, temperature, Wi-Fi, the hardware
# watchdog, disk space and the SD card, then emails a plain-text report with fix
# instructions for anything wrong.
#
# sudo nut-health.sh build the report and email it
# sudo nut-health.sh --dry-run build the report and print it instead
#
# Run as root (the systemd timer does): reading the SD card superblock, the kernel
# log and /etc/msmtprc all need it.
MAIL_TO="REPLACE_EMAIL_TO"
UPS_NAME="ups" # the [name] in /etc/nut/ups.conf
UPS_LOAD_WARN_PCT=80 # flag a UPS loaded more than this
UPS_CHARGE_WARN_PCT=50 # flag a battery below this that is not charging
DISK_WARN_PCT=80 # flag a filesystem with less than 20% free
WIFI_IF="wlan0"
WIFI_SIGNAL_WARN=-70 # dBm; a weaker (more negative) signal is flagged
TEMP_WARN_C=75 # the Pi starts slowing itself down at 80 C
WRITE_WARN_MB_PER_DAY=1024 # flag more than this much written to the SD card per day
WRITE_MIN_HOURS=12 # judge the write rate only over at least this many hours
STATE_DIR="/var/lib/nut-health"
DRY_RUN=0
[ "${1:-}" = "--dry-run" ] && DRY_RUN=1
HOST=$(hostname)
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
PROBLEMS="$WORK/problems"
OKS="$WORK/ok"
DETAILS="$WORK/details"
: >"$PROBLEMS"; : >"$OKS"; : >"$DETAILS"
NPROB=0
# problem "<one-line summary>" (how-to-fix text is read from stdin)
problem() {
NPROB=$((NPROB + 1))
{
echo "PROBLEM $NPROB: $1"
echo "------------------------------------------------------------"
cat
echo
} >>"$PROBLEMS"
}
ok() { echo " [OK] $1" >>"$OKS"; }
detail() { echo " $1" >>"$DETAILS"; }
# upsvar <variable> - prints one value from the UPS, or nothing
upsvar() { upsc "$UPS_NAME@localhost" "$1" 2>/dev/null; }
########################################################################
# 1. The UPS and NUT
########################################################################
for SVC in nut-server nut-monitor "nut-driver@$UPS_NAME"; do
if [ "$(systemctl show -p LoadState --value "$SVC" 2>/dev/null)" != "loaded" ]; then
problem "The $SVC service does not exist" <<EOF
NUT is not installed or not set up for a UPS called "$UPS_NAME".
- Check that NUT is installed: dpkg -l nut-server nut-client
- Check the UPS name: grep '^\[' /etc/nut/ups.conf
If the name in ups.conf is not "$UPS_NAME", change UPS_NAME near the top of
/usr/local/sbin/nut-health.sh to match.
EOF
elif ! systemctl is-active --quiet "$SVC"; then
problem "$SVC is NOT running" <<EOF
See why with:
sudo journalctl -u $SVC -n 50 --no-pager
Try starting it with:
sudo systemctl restart $SVC
EOF
else
ok "$SVC is running"
fi
done
for SVC in nut-server nut-monitor; do
if [ "$(systemctl show -p LoadState --value "$SVC" 2>/dev/null)" = "loaded" ] \
&& [ "$(systemctl is-enabled "$SVC" 2>/dev/null)" != "enabled" ]; then
problem "$SVC is not set to start at boot" <<EOF
Fix it with:
sudo systemctl enable $SVC
EOF
fi
done
STATUS=$(upsc "$UPS_NAME@localhost" ups.status 2>"$WORK/upsc.err")
if [ $? -ne 0 ] || [ -z "$STATUS" ]; then
ERR=$(tail -n 1 "$WORK/upsc.err")
problem "NUT cannot read the UPS ($UPS_NAME): ${ERR:-no reply}" <<EOF
The NUT server did not return the UPS status. Home Assistant will show the UPS as
unavailable. Try these in order:
1. Check that the Pi can see the UPS on USB (the UPS maker's name should be listed):
lsusb
If it is missing, check the USB cable at both ends. On a Pi Zero, it must be in
the port marked USB (not PWR), through a micro-USB OTG adapter.
2. Restart the UPS driver and the NUT server:
sudo systemctl restart nut-driver@$UPS_NAME
sudo systemctl restart nut-server
3. Check again:
upsc $UPS_NAME@localhost ups.status
You want to see: OL (or OL CHRG)
4. If it still fails, see the driver's errors:
sudo journalctl -u nut-driver@$UPS_NAME -n 30 --no-pager
EOF
else
STATUS_OK=1
case " $STATUS " in *" OB "*)
STATUS_OK=0
problem "The UPS is ON BATTERY right now (status: $STATUS)" <<'EOF'
The UPS is not getting power from the wall. If there is no power outage, check that
the UPS is plugged in and its wall outlet or breaker is on.
EOF
;; esac
case " $STATUS " in *" LB "*)
STATUS_OK=0
problem "The UPS battery is LOW (status: $STATUS)" <<'EOF'
If the power is out, this Pi will shut itself down very soon. If the power is on,
the battery should recharge over the next few hours. If it stays low, replace it.
EOF
;; esac
case " $STATUS " in *" RB "*)
STATUS_OK=0
problem "The UPS says its BATTERY NEEDS REPLACING (status: $STATUS)" <<'EOF'
The UPS failed its own battery test. During a power outage it may shut off almost
immediately. Order a replacement battery for your UPS model (see the "UPS model"
line in DETAILS below) and replace it.
EOF
;; esac
case " $STATUS " in *" OVER "*)
STATUS_OK=0
problem "The UPS is OVERLOADED (status: $STATUS)" <<'EOF'
Too much equipment is plugged into the battery outlets. Move something to a
surge-only outlet or to another UPS.
EOF
;; esac
case " $STATUS " in *" OFF "*|*" BYPASS "*|*" FSD "*)
STATUS_OK=0
problem "The UPS reports an unusual state (status: $STATUS)" <<'EOF'
OFF means the UPS output is off, BYPASS means the battery is not protecting the
equipment, and FSD means a forced shutdown is in progress. Check the UPS itself
(its display or lights) and its manual.
EOF
;; esac
[ "$STATUS_OK" -eq 1 ] && ok "UPS status: $STATUS (OL = on line power)"
CHARGE=$(upsvar battery.charge); CHARGE=${CHARGE%%.*}
if [ -n "$CHARGE" ]; then
case " $STATUS " in
*" CHRG "*) detail "Battery charge: $CHARGE% (charging)" ;;
*)
if [ "$CHARGE" -lt "$UPS_CHARGE_WARN_PCT" ]; then
problem "The UPS battery is only at $CHARGE% and is not charging" <<'EOF'
After a power outage the battery should recharge within a few hours. If it stays
this low, the battery is probably worn out and needs replacing.
EOF
else
ok "Battery charge: $CHARGE%"
fi
;;
esac
fi
LOAD=$(upsvar ups.load); LOAD=${LOAD%%.*}
if [ -n "$LOAD" ]; then
if [ "$LOAD" -gt "$UPS_LOAD_WARN_PCT" ]; then
problem "The UPS is heavily loaded: $LOAD%" <<EOF
Above $UPS_LOAD_WARN_PCT% the battery runs out very quickly during an outage, and the
UPS may overload. Move some equipment to another UPS or to a surge-only outlet.
EOF
else
ok "UPS load: $LOAD%"
fi
fi
RUNTIME=$(upsvar battery.runtime); RUNTIME=${RUNTIME%%.*}
[ -n "$RUNTIME" ] && detail "Estimated runtime on battery at the current load: $((RUNTIME / 60)) minutes"
MODEL="$(upsvar device.mfr) $(upsvar device.model)"
[ -n "${MODEL// /}" ] && detail "UPS model: $MODEL"
BATT_DATE=$(upsvar battery.date)
[ -z "$BATT_DATE" ] && BATT_DATE=$(upsvar battery.mfr.date)
[ -n "$BATT_DATE" ] && detail "Battery date reported by the UPS: $BATT_DATE"
fi
MON_LOG=$(journalctl -u nut-monitor --since "24 hours ago" --no-pager -q -o short 2>/dev/null)
ONBATT=$(echo "$MON_LOG" | grep -c "on battery")
if [ "${ONBATT:-0}" -gt 0 ]; then
problem "The UPS switched to battery $ONBATT time(s) in the last 24 hours" <<EOF
Nothing needs fixing if the power really did go out or flicker. If you don't think
it did, the UPS may be switching over because the incoming power is poor, or a
breaker or wall outlet is loose. The events were:
$(echo "$MON_LOG" | grep -E "on battery|on line power" | tail -n 10)
EOF
else
ok "Power events: the UPS did not switch to battery in the last 24 hours"
fi
COMM=$(echo "$MON_LOG" | grep -E "Communications with UPS .* lost|is unavailable" | tail -n 10)
if [ -n "$COMM" ]; then
problem "NUT lost contact with the UPS in the last 24 hours" <<EOF
The UPS stopped answering over USB for a while:
$COMM
An occasional event is fine. If this shows up every day, try another USB cable
(and OTG adapter on a Pi Zero), and check the power section of this email.
EOF
fi
WD_EVENTS=$(journalctl -t nut-watchdog --since "24 hours ago" --no-pager -q -o short 2>/dev/null | grep -E "RESTARTING|STILL")
if [ -n "$WD_EVENTS" ]; then
problem "The NUT watchdog had to restart NUT in the last 24 hours" <<EOF
NUT stopped answering, so the watchdog restarted it:
$WD_EVENTS
An occasional restart is fine. If this happens every day, see the "Troubleshooting"
section of the guide for the "Data stale" error.
To see the full watchdog log:
journalctl -t nut-watchdog --since "2 days ago" --no-pager
EOF
else
ok "NUT watchdog: no restarts needed in the last 24 hours"
fi
if [ "$(systemctl show -p LoadState --value nut-watchdog.timer 2>/dev/null)" = "loaded" ]; then
if ! systemctl is-active --quiet nut-watchdog.timer; then
problem "The NUT watchdog timer is not running" <<'EOF'
Start it and make it start at boot:
sudo systemctl enable --now nut-watchdog.timer
EOF
fi
else
detail "NUT watchdog: not installed"
fi
########################################################################
# 2. Power supply and temperature (of the Pi itself)
########################################################################
THROTTLED=$(vcgencmd get_throttled 2>/dev/null | cut -d= -f2)
if [ -z "$THROTTLED" ]; then
problem "Could not read the power status (vcgencmd get_throttled)" <<EOF
The command 'vcgencmd get_throttled' returned nothing. Run it by hand to see the error:
vcgencmd get_throttled
EOF
elif [ "$THROTTLED" = "0x0" ]; then
ok "Pi power: no under-voltage or throttling since boot (throttled=0x0)"
else
T=$((THROTTLED))
NOW=""; EVER=""
[ $((T & 0x1)) -ne 0 ] && NOW="$NOW under-voltage,"
[ $((T & 0x2)) -ne 0 ] && NOW="$NOW CPU-speed-capped,"
[ $((T & 0x4)) -ne 0 ] && NOW="$NOW throttled,"
[ $((T & 0x8)) -ne 0 ] && NOW="$NOW soft-temperature-limit,"
[ $((T & 0x10000)) -ne 0 ] && EVER="$EVER under-voltage,"
[ $((T & 0x20000)) -ne 0 ] && EVER="$EVER CPU-speed-capped,"
[ $((T & 0x40000)) -ne 0 ] && EVER="$EVER throttled,"
[ $((T & 0x80000)) -ne 0 ] && EVER="$EVER soft-temperature-limit,"
if [ $((T & 0x10001)) -ne 0 ]; then
problem "POWER PROBLEM - the Pi is not getting enough voltage (throttled=$THROTTLED)" <<EOF
Happening right now:${NOW:- nothing}
Has happened since boot:${EVER:- nothing}
Low voltage causes freezes, Wi-Fi dropouts and SD card corruption. To fix it, try
these in order:
1. Use the official Raspberry Pi power supply for your model, not a phone charger.
2. Use a short, thick USB power cable (20-22 AWG), or the supply's attached cable.
3. On a Pi Zero, make sure the UPS's USB cable is in the port marked USB and the
power supply is in the port marked PWR.
The "since boot" flags stay set until the next reboot. After fixing the power,
reboot and check tomorrow's email, or run: vcgencmd get_throttled
You want to see: throttled=0x0
EOF
else
problem "HEAT - the Pi has slowed itself down (throttled=$THROTTLED)" <<EOF
Happening right now:${NOW:- nothing}
Has happened since boot:${EVER:- nothing}
There was no under-voltage, so this is almost certainly heat. Behind a rack or on
top of a UPS can get warm. To fix it:
1. Add a small heatsink to the Pi's processor.
2. Give the Pi some airflow (don't seal it in a case with no vents).
3. Check the temperature with: vcgencmd measure_temp
The flags stay set until the next reboot.
EOF
fi
fi
TEMP=$(vcgencmd measure_temp 2>/dev/null | sed -n "s/^temp=\([0-9]*\).*/\1/p")
if [ -n "$TEMP" ]; then
if [ "$TEMP" -ge "$TEMP_WARN_C" ]; then
problem "HEAT - the processor is at ${TEMP} C right now" <<EOF
The Pi slows itself down at 80 C and gets unreliable when it runs this hot for long.
1. Add a small heatsink to the Pi's processor.
2. Give the Pi some airflow.
EOF
else
ok "Temperature: ${TEMP} C"
fi
fi
UV_COUNT=$(journalctl _TRANSPORT=kernel --since "24 hours ago" --no-pager -q 2>/dev/null | grep -ci "undervoltage")
if [ "${UV_COUNT:-0}" -gt 0 ]; then
detail "Kernel logged 'Undervoltage detected' $UV_COUNT time(s) in the last 24 hours."
fi
########################################################################
# 3. Wi-Fi (skipped when the Pi is not using Wi-Fi)
########################################################################
LINK=$(iw dev "$WIFI_IF" link 2>/dev/null)
SSID=$(echo "$LINK" | sed -n 's/^[[:space:]]*SSID: //p')
if [ -z "$SSID" ]; then
detail "Wi-Fi: $WIFI_IF is not connected, so the Wi-Fi checks were skipped (wired Pi?)"
else
PS=$(iw dev "$WIFI_IF" get power_save 2>/dev/null)
case "$PS" in
*off*)
ok "Wi-Fi power saving: off" ;;
*on*)
problem "Wi-Fi power saving is ON" <<'EOF'
With power saving on, the Pi's Wi-Fi goes to sleep and the Pi drops off the
network, so Home Assistant loses the UPS. To turn it off for good:
1. Create the setting file:
sudo nano /etc/NetworkManager/conf.d/wifi-powersave-off.conf
2. Put exactly these two lines in it, then save (Ctrl+O, Enter) and exit (Ctrl+X):
[connection]
wifi.powersave = 2
3. Reboot:
sudo reboot
4. After it comes back, check it:
iw dev wlan0 get power_save
You want to see: Power save: off
EOF
;;
*)
detail "Wi-Fi: could not read the power-save setting (iw dev $WIFI_IF get power_save)" ;;
esac
SIGNAL=$(echo "$LINK" | awk '/signal:/ { print $2; exit }')
if [ -z "$SIGNAL" ]; then
detail "Wi-Fi: could not read the signal strength"
elif [ "$SIGNAL" -lt "$WIFI_SIGNAL_WARN" ]; then
problem "Weak Wi-Fi signal: $SIGNAL dBm (network: $SSID)" <<EOF
Anything weaker than $WIFI_SIGNAL_WARN dBm is unreliable. (Closer to 0 is stronger:
-55 is good, -75 is poor.) To improve it:
1. Move the access point closer, or move the Pi out from behind the UPS or rack.
2. Metal cabinets and racks block Wi-Fi. Keep the Pi outside them if you can.
3. If the Pi has an Ethernet port, use it.
EOF
else
ok "Wi-Fi signal: $SIGNAL dBm (network: $SSID)"
fi
fi
if [ "$(systemctl show -p LoadState --value wifi-watchdog 2>/dev/null)" = "loaded" ]; then
WIFI_EVENTS=$(journalctl -t wifi-watchdog --since "24 hours ago" --no-pager -q -o short 2>/dev/null | grep -E "RECONNECT|REBOOTING")
if [ -n "$WIFI_EVENTS" ]; then
problem "The Wi-Fi watchdog had to step in during the last 24 hours" <<EOF
The Pi lost its network connection and the watchdog reconnected or rebooted it:
$WIFI_EVENTS
An occasional event is fine. If this shows up every day, check the power and Wi-Fi
signal sections of this email, and the access point itself.
To see the full watchdog log:
journalctl -t wifi-watchdog --since "2 days ago" --no-pager
EOF
else
ok "Wi-Fi watchdog: no reconnects or reboots in the last 24 hours"
fi
if ! systemctl is-active --quiet wifi-watchdog; then
problem "The Wi-Fi watchdog service is not running" <<'EOF'
Start it and make it start at boot:
sudo systemctl enable --now wifi-watchdog
Then check it:
systemctl status wifi-watchdog --no-pager
You want to see: Active: active (running)
EOF
fi
else
detail "Wi-Fi watchdog: not installed"
fi
########################################################################
# 4. Hardware watchdog
########################################################################
WDT=$(systemctl show -p RuntimeWatchdogUSec --value 2>/dev/null)
if [ -z "$WDT" ] || [ "$WDT" = "0" ] || [ "$WDT" = "off" ]; then
problem "The hardware watchdog is OFF - a frozen Pi will stay frozen" <<'EOF'
With the watchdog on, the Pi reboots itself about 15 seconds after it freezes.
To turn it on:
1. Create the folder and the setting file:
sudo mkdir -p /etc/systemd/system.conf.d
sudo nano /etc/systemd/system.conf.d/watchdog.conf
2. Put exactly these two lines in it, then save (Ctrl+O, Enter) and exit (Ctrl+X):
[Manager]
RuntimeWatchdogSec=15
3. Reboot:
sudo reboot
4. After it comes back, check it:
systemctl show -p RuntimeWatchdogUSec --value
You want to see: 15s
EOF
else
ok "Hardware watchdog: on (reboots the Pi $WDT after a freeze)"
fi
########################################################################
# 5. Disk space
########################################################################
while read -r MNT PCT AVAIL; do
PCT=${PCT%\%}
case "$PCT" in ''|*[!0-9]*) continue ;; esac
FREE=$((100 - PCT))
if [ "$PCT" -gt "$DISK_WARN_PCT" ]; then
problem "Low disk space on $MNT: only $FREE% free ($AVAIL left)" <<EOF
Find what is using the space (biggest folders listed last):
sudo du -xh --max-depth=2 $MNT 2>/dev/null | sort -h | tail -n 15
Common fixes:
sudo apt clean (removes downloaded update files)
sudo journalctl --vacuum-size=20M (shrinks the system log)
EOF
else
ok "Disk $MNT: $FREE% free ($AVAIL left)"
fi
done < <(df -h --output=target,pcent,avail -x tmpfs -x devtmpfs -x efivarfs -x squashfs -x overlay 2>/dev/null | tail -n +2)
for DIR in /var/tmp /var/log; do
if [ "$(findmnt -no FSTYPE "$DIR" 2>/dev/null)" = "tmpfs" ]; then
SIZE=$(df -h --output=size "$DIR" 2>/dev/null | tail -n 1 | tr -d ' ')
problem "$DIR is a small RAM disk ($SIZE)" <<EOF
A line in /etc/fstab puts $DIR in RAM. A small /var/tmp makes kernel updates fail
with "No space left on device", and a /var/log in RAM loses every log at reboot.
See "Part 5" of the guide to put it back on the SD card. Find the line with:
grep tmpfs /etc/fstab
EOF
fi
done
########################################################################
# 6. SD card health
# Ordinary SD cards do not report their wear level to Linux, so these are
# the signs that show up before a card fails completely.
########################################################################
ROOT_DEV=$(findmnt -no SOURCE /)
ROOT_OPTS=$(findmnt -no OPTIONS /)
ROOT_PART=$(basename "$ROOT_DEV")
SD_DISK=$(lsblk -no PKNAME "$ROOT_DEV" 2>/dev/null | head -n 1)
SD_DISK=${SD_DISK:-mmcblk0}
case ",$ROOT_OPTS," in
*,ro,*)
problem "CRITICAL: the SD card has been switched to READ-ONLY" <<'EOF'
Linux does this when it finds errors on the card, to protect your data. The card
is probably failing. Plan to replace it:
1. Buy a new high-endurance card.
2. Write your backup image to it, or re-image it and follow the guide again.
Nothing new can be saved until then (including logs and settings).
EOF
;;
*)
ok "SD card: mounted read-write (normal)" ;;
esac
TUNE=$(tune2fs -l "$ROOT_DEV" 2>/dev/null)
FS_STATE=$(echo "$TUNE" | sed -n 's/^Filesystem state:[[:space:]]*//p')
FS_ERRORS=$(echo "$TUNE" | sed -n 's/^FS Error count:[[:space:]]*//p')
if [ -z "$TUNE" ]; then
detail "SD card: could not read the filesystem details (tune2fs -l $ROOT_DEV)"
elif echo "$FS_STATE" | grep -qi error || [ "${FS_ERRORS:-0}" -gt 0 ]; then
FIRST=$(echo "$TUNE" | sed -n 's/^First error time:[[:space:]]*//p')
LAST=$(echo "$TUNE" | sed -n 's/^Last error time:[[:space:]]*//p')
problem "Filesystem errors recorded on the SD card (state: $FS_STATE, errors: ${FS_ERRORS:-0})" <<EOF
First error: ${FIRST:-unknown}
Last error: ${LAST:-unknown}
The filesystem has been damaged at least once, usually by a power problem or a
worn-out card. The Pi checks and repairs a filesystem marked like this when it
boots, so reboot it:
sudo reboot
Then see whether the errors were cleared:
sudo tune2fs -l $ROOT_DEV | grep -iE "state|error"
If it still says "with errors", or new errors appear in later emails, replace the
SD card.
EOF
else
ok "SD card filesystem: $FS_STATE, no errors recorded"
fi
MMC_HOST=$(basename "$(readlink -f "/sys/block/$SD_DISK/device" 2>/dev/null)" 2>/dev/null | cut -d: -f1)
KERR=$(journalctl _TRANSPORT=kernel --since "24 hours ago" --no-pager -q -o short 2>/dev/null \
| grep -E "${MMC_HOST:-mmc0}: |$SD_DISK|EXT4-fs|I/O error" \
| grep -Ei "error|timeout|timed out|fail|corrupt|medium" | tail -n 10)
if [ -n "$KERR" ]; then
problem "The kernel logged SD card or filesystem errors in the last 24 hours" <<EOF
Last 10 matching messages:
$KERR
Occasional errors can come from a power dip (see the power section). Errors every
day mean the SD card is failing - replace it soon.
EOF
else
ok "SD card: no read/write errors in the kernel log in the last 24 hours"
fi
LIFE_FILE="/sys/fs/ext4/$ROOT_PART/lifetime_write_kbytes"
if [ -r "$LIFE_FILE" ]; then
LIFE_KB=$(cat "$LIFE_FILE")
NOW_S=$(date +%s)
mkdir -p "$STATE_DIR"
SAVE=1
if [ -r "$STATE_DIR/last-write" ]; then
read -r PREV_KB PREV_S <"$STATE_DIR/last-write"
MINUTES=$(( (NOW_S - PREV_S) / 60 ))
if [ "$LIFE_KB" -lt "$PREV_KB" ]; then
detail "SD card writes: counter went backwards (new card?) - starting a new measurement"
elif [ "$MINUTES" -lt $((WRITE_MIN_HOURS * 60)) ]; then
# Too soon to judge: reboots and updates make short windows look busy.
# Keep the old starting point, so the daily report still covers ~24 hours.
SAVE=0
detail "SD card writes: measured once $WRITE_MIN_HOURS hours have passed since the last measurement ($((MINUTES / 60))h $((MINUTES % 60))m so far)"
else
MB_PER_DAY=$(( (LIFE_KB - PREV_KB) * 1440 / MINUTES / 1024 ))
if [ "$MB_PER_DAY" -gt "$WRITE_WARN_MB_PER_DAY" ]; then
problem "Heavy writing to the SD card: about $MB_PER_DAY MB per day" <<'EOF'
This wears the card out faster. Something is writing much more than normal - often a
program logging too much. Find the busiest logs with:
sudo journalctl --since "24 hours ago" --no-pager -o cat | sort | uniq -c | sort -n | tail -n 20
Then re-check the "Reduce writes to the SD card" part of the guide.
EOF
else
ok "SD card writes: about $MB_PER_DAY MB per day"
fi
fi
fi
[ "$SAVE" -eq 1 ] && echo "$LIFE_KB $NOW_S" >"$STATE_DIR/last-write"
detail "SD card: $((LIFE_KB / 1024 / 1024)) GB written since the card was imaged."
fi
CARD_NAME=$(cat "/sys/block/$SD_DISK/device/name" 2>/dev/null)
CARD_DATE=$(cat "/sys/block/$SD_DISK/device/date" 2>/dev/null)
CARD_SIZE=$(lsblk -dno SIZE "/dev/$SD_DISK" 2>/dev/null | tr -d ' ')
[ -n "$CARD_NAME" ] && detail "SD card: model $CARD_NAME, $CARD_SIZE, made $CARD_DATE."
########################################################################
# 7. General information
########################################################################
detail "Up since: $(uptime -s) ($(uptime -p))"
detail "IP address: $(hostname -I 2>/dev/null)"
detail "OS: $(. /etc/os-release && echo "$PRETTY_NAME"), kernel $(uname -r), $(dpkg --print-architecture)"
NUT_VER=$(dpkg-query -W -f='${Version}' nut-server 2>/dev/null)
detail "NUT version: ${NUT_VER:-unknown}"
########################################################################
# Build and send the report
########################################################################
if [ "$NPROB" -eq 0 ]; then
SUBJECT="[$HOST] Daily health: OK"
else
SUBJECT="[$HOST] Daily health: $NPROB PROBLEM(S) FOUND"
fi
REPORT="$WORK/report"
{
echo "To: $MAIL_TO"
echo "Subject: $SUBJECT"
echo "Content-Type: text/plain; charset=UTF-8"
echo
echo "Daily health report for $HOST - $(date '+%A %Y-%m-%d %H:%M %Z')"
echo
if [ "$NPROB" -eq 0 ]; then
echo "Everything looks good. No action needed."
else
echo "$NPROB problem(s) need attention. Each one has instructions below."
echo
cat "$PROBLEMS"
fi
echo
echo "PASSED CHECKS"
echo "------------------------------------------------------------"
cat "$OKS"
echo
echo "DETAILS"
echo "------------------------------------------------------------"
cat "$DETAILS"
echo
echo "-- Sent by /usr/local/sbin/nut-health.sh on $HOST"
} >"$REPORT"
if [ "$DRY_RUN" -eq 1 ]; then
cat "$REPORT"
else
msmtp "$MAIL_TO" <"$REPORT"
fi
SCRIPT_END
sudo chmod 755 /usr/local/sbin/nut-health.sh
sha256sum /usr/local/sbin/nut-health.sh
Expected result:
b4459fde0ff3d4e868b0d5875ffa90cbff7d06c1f2e16ff1cbedd1995980472a /usr/local/sbin/nut-health.sh
If the checksum is different, something was lost or changed while pasting. Run step 14.1 again.
14.2. Set the address the report goes to. Open the script:
# On ups01, as your user
sudo nano /usr/local/sbin/nut-health.sh
Near the top (line 14), change MAIL_TO="REPLACE_EMAIL_TO" to your address, for example MAIL_TO="you@example.com". Save (Ctrl+O, Enter) and exit (Ctrl+X). Then check it:
# On ups01, as your user
grep -n '^MAIL_TO=' /usr/local/sbin/nut-health.sh
Expected result: 14:MAIL_TO="<your address>"
(If you named your UPS something other than ups in step 7.3, also change UPS_NAME="ups" on line 15 to match.)
14.3. Run the report without sending it, and read through it:
# On ups01, as your user
sudo /usr/local/sbin/nut-health.sh --dry-run
Expected result: a report starting with To: and Subject:. If you’ve done every part above, the subject shows OK, or just 1 PROBLEM(S) FOUND for the power failure you tested in Part 8 (it reports power events from the last 24 hours). The PASSED CHECKS section lists everything else. (The “SD card writes per day” check only gives a number once at least 12 hours have passed since its first measurement. Until then, the DETAILS section shows how long it has been. Measuring over a shorter time, for example across the reboots in this guide, would make a quiet card look busy.) Fix anything else it reports before going on.
14.4. Send one real report now to make sure it arrives:
# On ups01, as your user
sudo /usr/local/sbin/nut-health.sh
Expected result: no output, and the email arrives. Tip: in Gmail, create a filter on the subject Daily health so the daily reports from all your Pis go to their own label.
14.5. Schedule it for 7:00 AM every day:
# On ups01, as your user
sudo tee /etc/systemd/system/nut-health.service >/dev/null <<'EOF'
[Unit]
Description=Send the daily NUT server health report
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/nut-health.sh
EOF
sudo tee /etc/systemd/system/nut-health.timer >/dev/null <<'EOF'
[Unit]
Description=Daily NUT server health report at 7:00 AM
[Timer]
OnCalendar=*-*-* 07:00:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now nut-health.timer
systemctl list-timers nut-health.timer --no-pager
Expected result: one timer listed, with NEXT showing 7:00 AM tomorrow (or today, if it’s still before 7:00) in your time zone. If the time zone is wrong, fix it with sudo raspi-config → Localisation Options → Timezone. (Persistent=true means that if the Pi was off at 7:00, the report is sent as soon as it’s back on.)
If the daily email stops arriving, treat that as a problem too. It usually means the Pi is off, off the network, or its SD card has failed.
Part 15 – Final check
15.1. Reboot one last time, to prove everything comes back by itself:
# On ups01, as your user
sudo reboot
15.2. Wait 2–3 minutes, then:
- In Home Assistant, the UPS device shows current values again (not “Unavailable”).
- Log in and run
upsc ups@localhost ups.status. It showsOL(orOL CHRG). - Run
sudo /usr/local/sbin/nut-health.sh --dry-run. The only problem it should report is the Part 8 power test, if that happened in the last 24 hours.
15.3. Back up the finished card. Once everything works, shut down (sudo poweroff), put the SD card in your PC, and use Win32 Disk Imager’s Read button to save an image of it. If the card ever dies, you can write that image to a new card and be back up in minutes, instead of following this guide again.
Checklist
grep VERSION_CODENAME /etc/os-releaseshowstrixie, anduname -m/dpkg --print-architecturematch your Pi and OS- DHCP reservation created for
ups01 - The Pi is plugged into a battery-backed outlet on the UPS
vcgencmd get_throttledshowsthrottled=0x0- (Wi-Fi only)
iw dev wlan0 get power_saveshowsPower save: off, and the signal is -70 dBm or better swapon --showlists only/dev/zram0, and/var/swapis gone- Journal capped at 50 MB; apt/man-db timers disabled;
/var/tmpand/var/logare not RAM disks lsusblists the UPS, andnut-scanner -Ufound its drivernut-server,nut-monitorandnut-driver@upsare running and start at bootupsc ups@localhost ups.statusshowsOL, andupsc -c ups@localhostlists127.0.0.1- Unplugging the UPS showed
OB DISCHRG, and plugging it back in showedOL - The UPS appears in Home Assistant, and the on-battery notification arrived during the test
nut-watchdogchecksum matches, both dry-run tests behave as shown, and the timer is scheduledsystemctl show -p RuntimeWatchdogUSec --valueshows15s- (Wi-Fi only)
wifi-watchdogchecksum matches, both dry-run tests behave as shown, and the service is running - Test email received through msmtp
nut-health.shchecksum matches, the dry run looks right, the real email arrived, and the timer is scheduled for 7:00 AM- After the final reboot, everything comes back without help
- SD card image backed up
Maintenance
About once a month, install updates, since automatic updates are turned off in Part 5:
# On ups01, as your user
sudo apt update
sudo apt full-upgrade -y
Read the end of the output before rebooting. If it ends with Errors were encountered, don’t reboot. See Troubleshooting. Otherwise:
# On ups01, as your user
sudo reboot
After it comes back, check that upsc ups@localhost ups.status shows OL and that Home Assistant has current values.
Replace the UPS battery when the daily email says it needs replacing, or every 3–5 years. Most UPS batteries wear out long before the UPS itself. After replacing it, the UPS may need a self-test or a day of charging before it stops reporting RB.
Troubleshooting
If the Pi or NUT goes down, collect this before you wipe the card. The logs are kept across reboots (Part 5.2), so the evidence is still there after a restart:
# On ups01, as your user
journalctl --list-boots --no-pager | tail -n 5
sudo journalctl -b -1 -p warning --no-pager | tail -n 50
sudo journalctl -b -1 -n 30 --no-pager
vcgencmd get_throttled
sudo /usr/local/sbin/nut-health.sh --dry-run
-b -1 means “the previous boot”. The last lines before a sudden stop often show the cause, such as under-voltage, SD card errors, or Wi-Fi dropping. If the last lines are ordinary messages with no clean shutdown after them, the Pi lost power or froze.
upsc says Error: Data stale or Error: Driver not connected: the driver has lost touch with the UPS. The NUT watchdog (Part 10) restarts it automatically within 5 minutes. To do it by hand:
sudo systemctl restart nut-driver@ups
sudo systemctl restart nut-server
If it keeps happening, check lsusb (is the UPS still listed?), try a different USB cable (and OTG adapter on a Pi Zero), and look at the driver’s log with sudo journalctl -u nut-driver@ups -n 50 --no-pager.
The driver won’t start, and its log mentions permissions or “Can’t claim USB device”: NUT’s USB permissions weren’t applied to the UPS. Unplug the UPS’s USB cable from the Pi, wait 5 seconds, plug it back in, and run sudo systemctl restart nut-driver@ups. Rebooting also fixes it.
Home Assistant says it can’t connect:
- On the Pi,
sudo ss -tlnp | grep 3493must show0.0.0.0:3493. If it shows127.0.0.1:3493, check/etc/nut/upsd.conf(step 7.4) and runsudo systemctl restart nut-server. - Make sure Home Assistant can reach the Pi on TCP port 3493 (VLANs and firewall rules).
- If it connects but says the login failed, the
homeassistantpassword in/etc/nut/upsd.usersand the one you typed into Home Assistant don’t match.
apt full-upgrade fails with No space left on device after update-initramfs: /var/tmp is almost certainly a small RAM disk (see the box in step 5.5). Don’t reboot. Finish the update using a folder on the SD card instead, then fix /etc/fstab as shown in step 5.5:
sudo mkdir -p /root/initramfs-tmp
sudo env TMPDIR=/root/initramfs-tmp dpkg --configure -a
sudo apt full-upgrade
sudo rm -rf /root/initramfs-tmp
The dpkg command should end without Errors were encountered, and apt full-upgrade should show Upgrading: 0, Installing: 0, Removing: 0.
Broadcast message from nut@ups01 keeps appearing in PuTTY: that’s the monitor announcing UPS events (on battery, on line power, and so on) to everyone logged in. It’s normal during a power blip.
The Pi keeps rebooting: run journalctl -t wifi-watchdog --since "2 days ago" --no-pager to see whether the Wi-Fi watchdog is doing it, and why. If the router is being replaced or will be down for a long time, pause the watchdog with sudo systemctl stop wifi-watchdog, and start it again afterwards with sudo systemctl start wifi-watchdog. If the log shows it shut down because of a low battery, that’s NUT doing its job during an outage.
No daily email: run systemctl list-timers nut-health.timer --no-pager to confirm it’s scheduled, sudo journalctl -u nut-health -n 30 --no-pager to see the last run, and sudo journalctl -t msmtp -n 5 --no-pager to see whether sending failed.
Setting up more than one Pi? Automate it with Ansible
I have five UPS units, so I do all of this with Ansible, a free tool that runs the same setup steps on many computers from one place. You don’t need it. Everything above works fine by hand, and doing it by hand once is the best way to understand what each piece does. But if you’re looking after several of these Pis, it saves a lot of typing and keeps them all set up exactly the same.
References
I want to call out a few resources that helped me get this set up.
- Network UPS Tools (NUT) – User manual: https://networkupstools.org/docs/user-manual.chunked/index.html
- Network UPS Tools – Hardware compatibility list (find your UPS’s driver): https://networkupstools.org/stable-hcl.html
- Network UPS Tools –
upsd.userssettings: https://networkupstools.org/docs/man/upsd.users.html - Network UPS Tools –
upsmon.confsettings: https://networkupstools.org/docs/man/upsmon.conf.html - Debian –
nut-serverpackage (version 2.8.1 in Trixie): https://packages.debian.org/trixie/nut-server - Home Assistant – Network UPS Tools (NUT) integration: https://www.home-assistant.io/integrations/nut/
- Raspberry Pi – Operating system images (which OS runs on which Pi): https://www.raspberrypi.com/software/operating-systems/
- Raspberry Pi –
config.txtdocumentation (arm_64bit: why a Pi 4 runs a 64-bit kernel with the 32-bit OS): https://www.raspberrypi.com/documentation/computers/config_txt.html - Raspberry Pi – Trixie, the new version of Raspberry Pi OS: https://www.raspberrypi.com/news/trixie-the-new-version-of-raspberry-pi-os/
- Raspberry Pi Forums – rpi-swap (Trixie only), zram-based swap: https://forums.raspberrypi.com/viewtopic.php?t=390708
- The Homelab Postmortem – Your Pi’s 2 GB swap file isn’t swap (rpi-swap zram-only setting used in Part 5.1): https://homelabpostmortem.com/2026/08/19/trixie-rpi-swap-writeback-file/
- Debian 13 “Trixie” release notes – issues to be aware of (/tmp is now in RAM): https://www.debian.org/releases/stable/release-notes/issues.html
- msmtp documentation: https://marlam.de/msmtp/documentation/
- My Raspberry Pi camera guide, which this guide’s stability parts come from: https://teelsys.com/revisiting-the-raspberry-pi-camera/
Final Thoughts
- Safely shutting down machines when the UPS battery reaches a threshold is not covered by this guide. The installation of NUT is good to know the status of the UPS but it does not help if the machines connected to the UPS do not safely shut down.

































































You must be logged in to post a comment.