Categories
Uncategorized

Raspberry Pi NUT Server for UPS Monitoring and Home Assistant

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.)
  • 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

ProblemWhat protects against itPart
Not knowing the power is out, or how long the UPS will lastNUT reads the UPS, and Home Assistant shows it and sends an alert6–9
Not enough power for the Pi (weak supply)A check now, and a check in the daily email3, 14
Wi-Fi going to sleep and dropping off the networkWi-Fi power saving turned off4
SD card wearing outFewer writes to the card5
NUT losing touch with the UPS (“Data stale”)NUT watchdog restarts it automatically10
The whole Pi freezingHardware watchdog reboots it11
Wi-Fi disconnecting and not coming backWi-Fi watchdog reconnects, then reboots if needed12
Problems nobody notices for weeks (including a UPS battery that needs replacing)Daily health-report email with fix instructions13, 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 model64-bit OS32-bit OSuname -m on the 64-bit OSuname -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+✓✓aarch64armv7l
Pi 4, Pi 400, Compute Module 4✓✓aarch64aarch64 *
Pi 5, Pi 500 / 500+, Compute Module 5✓✓aarch64aarch64 *

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:

  1. 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.)
  2. 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.
  3. 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.
  4. An SSH client (PuTTY on Windows).
  5. 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 UPS ups. Use your own Pi’s name wherever you see ups01.

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”.) List of Boards in the Raspberry Pi Imager software

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.) First list of Operating Systems in the Raspberry Pi Imager software Second list for other Operating Systems in the Raspberry Pi Imager software

1.3. Choose the storage: your SD card. List of available removable storage devices in the Raspberry Pi Imager software

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. Raspberry Pi Imager confirmation message

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.

  • noatime means Linux doesn’t write to the card every time a file is simply read. If the first line doesn’t include noatime, stop and look into it before editing anything. A mistake in /etc/fstab can stop the Pi from booting.
  • tmpfs means /tmp lives in RAM. That’s the Trixie default, and it’s fine.
  • The last two commands should print nothing. If either prints a tmpfs line, read the box below.

Don’t put /var/tmp or /var/log on a small RAM disk. Many older Pi guides (and an Ansible playbook I wrote myself) add lines like these to /etc/fstab to 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 0

I 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 with No space left on device, and that Pi would probably not have booted if I’d restarted it at that point. A /var/log in 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-reload

Expected result: both lines now start with #, and findmnt --verify reports 0 parse errors, 0 errors. (A warning that systemd “still uses the old version” is normal before daemon-reload.) If it reports any errors, put the original back with sudo cp /etc/fstab.bak /etc/fstab and 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:

  • swapon shows only /dev/zram0 (no /var/swap file).
  • The first ls says No such file or directory (the 2 GB swap file has been removed).
  • The second ls prints /var/log/journal. That folder only exists when the log is kept on the SD card.
  • The systemd-analyze command prints these two lines. The last Storage= setting is the one that wins, and it must be persistent:# /etc/systemd/journald.conf.d/90-ups.conf
    Storage=persistent(If a file that sorts later sets Storage=volatile, rename 90-ups.conf to 99-ups.conf, then sudo systemctl restart systemd-journald.)
  • Both findmnt commands 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:

PieceWhat it doesSettings file
The driverTalks to the UPS over USBups.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 emptyupsmon.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.

  1. 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.
  2. Find your phone’s notify action. Go to Settings → Developer tools → Actions and type notify.mobile_app. Your phone appears as something like notify.mobile_app_pixel_8.
  3. 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
  1. 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:

  1. 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.)
  2. For the app name, type ups01 and click Create.
  3. 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:

CheckFlagged when
NUT servicesThe driver, server or monitor isn’t running, or the server or monitor won’t start at boot
UPS statusNUT 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 chargeBelow 50% and not charging
UPS loadAbove 80%
Power eventsThe UPS switched to battery, or NUT lost contact with the UPS, in the last 24 hours
NUT watchdogIt had to restart NUT in the last 24 hours, or its timer isn’t running
Pi power supplyvcgencmd get_throttled is not 0x0. It tells a power problem apart from a heat problem, and gives fix suggestions for each.
Temperature75 °C or hotter
Wi-Fi power saving and signalPower saving is on, or the signal is weaker than -70 dBm (skipped on a wired Pi)
Wi-Fi watchdogIt had to reconnect or reboot in the last 24 hours, or it isn’t running (skipped if not installed)
Hardware watchdogTurned off (with fix instructions)
Disk spaceAny filesystem with less than 20% free, or /var/tmp or /var/log on a RAM disk
SD card healthThe 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 shows OL (or OL 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-release shows trixie, and uname -m / dpkg --print-architecture match your Pi and OS
  • DHCP reservation created for ups01
  • The Pi is plugged into a battery-backed outlet on the UPS
  • vcgencmd get_throttled shows throttled=0x0
  • (Wi-Fi only) iw dev wlan0 get power_save shows Power save: off, and the signal is -70 dBm or better
  • swapon --show lists only /dev/zram0, and /var/swap is gone
  • Journal capped at 50 MB; apt/man-db timers disabled; /var/tmp and /var/log are not RAM disks
  • lsusb lists the UPS, and nut-scanner -U found its driver
  • nut-server, nut-monitor and nut-driver@ups are running and start at boot
  • upsc ups@localhost ups.status shows OL, and upsc -c ups@localhost lists 127.0.0.1
  • Unplugging the UPS showed OB DISCHRG, and plugging it back in showed OL
  • The UPS appears in Home Assistant, and the on-battery notification arrived during the test
  • nut-watchdog checksum matches, both dry-run tests behave as shown, and the timer is scheduled
  • systemctl show -p RuntimeWatchdogUSec --value shows 15s
  • (Wi-Fi only) wifi-watchdog checksum matches, both dry-run tests behave as shown, and the service is running
  • Test email received through msmtp
  • nut-health.sh checksum 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 3493 must show 0.0.0.0:3493. If it shows 127.0.0.1:3493, check /etc/nut/upsd.conf (step 7.4) and run sudo 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 homeassistant password in /etc/nut/upsd.users and 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.

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.

Categories
Raspberry Pi Uncategorized

Revisiting the Raspberry Pi Camera

The camera has been more stable but needed restarting quite often then finally failed to restart. Using AI, the following steps were generated in an attempt to improve the stability/availability of the Raspberry Pi Camera over time.

This project uses a Raspberry Pi Zero 2 W and a Raspberry Pi Camera Module 3 to build a Wi-Fi camera that works with Frigate, Blue Iris, or any other Network Video Recorder (NVR) that can read an RTSP stream.

Updated September 2026 for Raspberry Pi OS “Trixie” and MediaMTX v1.21.1. This version also adds the pieces that keep the camera 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.

Updated October 11, 2026. This guide was corrected after I used the same steps to build my UPS monitor Pis. The most important fix: the system log was not actually being kept on the SD card, so the evidence from a crash was lost at the next reboot. If you followed an earlier version, see What has Changed (2026-10-11) near the end for the list of changes and the exact steps to bring your camera up to date.

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.

What this guide sets up

ProblemWhat protects against itPart
Not enough power (long cable, weak supply)A check now, and a check in the daily email3, 12
Wi-Fi going to sleep and dropping off the networkWi-Fi power saving turned off4
SD card wearing outFewer writes to the card5
MediaMTX crashingsystemd restarts it automatically8
The whole Pi freezingHardware watchdog reboots it9
Wi-Fi disconnecting and not coming backWi-Fi watchdog reconnects, then reboots if needed10
Problems nobody notices for weeksDaily health-report email with fix instructions11, 12

Before you start

You will need:

  1. A DHCP reservation for the camera on your router, so its IP address never changes. Your NVR connects by IP address, so this matters. (You can add it after the Pi first connects, once you can see it in the router’s client list.)
  2. A Gmail account with 2-Step Verification turned on. You’ll create a Gmail app password in Part 11 so the Pi can send the daily email. Create a new app password just for this camera, so you can revoke it without affecting anything else.
  3. An SSH client (PuTTY on Windows) and VLC for testing the stream.

How to read this guide:

  • Every command block starts with a comment line such as # On picam03, 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 picam03. Use your own camera’s name wherever you see it.

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 Raspberry Pi OS Lite (64-bit) 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 the device: “Raspberry Pi Zero 2 W”. List of Boards in the Raspberry Pi Imager software

1.2. Choose the operating system: “Raspberry Pi OS (other)”, then “Raspberry Pi OS Lite (64-bit)”. First list of Operating Systems in the Raspberry Pi Imager software Second list for other Operating Systems in the Raspberry Pi Imager software

1.3. Choose the storage: your SD card. List of available removable storage devices in the Raspberry Pi Imager software

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: picam03
  • 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 2 W only supports 2.4 GHz 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. Raspberry Pi Imager confirmation message

1.6. Give it 3–5 minutes. The first boot on a Pi Zero 2 W 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 picam03) 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 picam03, as your user
grep VERSION_CODENAME /etc/os-release
uname -m

Expected result:

VERSION_CODENAME=trixie
aarch64

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 says armv7l instead of aarch64, you picked the 32-bit OS. Write the card again with the 64-bit Lite version.

2.3. Install all updates. This can take 15–30 minutes on a Pi Zero 2 W.

# On picam03, 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.)

2.4. Install the tools the rest of this guide uses. Most are already installed, and listing them again does no harm.

# On picam03, as your user
sudo apt install -y curl iw rpicam-apps-lite

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 picam03, as your user
sudo reboot

Part 3 – Check the camera and the power

3.1. Make sure the Pi can see the camera:

# On picam03, as your user
rpicam-hello --list-cameras

Expected result: one camera listed. Camera Module 3 shows up as imx708, for example:

Available cameras
-----------------
0 : imx708 [4608x2592 10-bit RGGB] (/base/soc/i2c0mux/i2c@1/imx708@1a)

If it says No cameras available!, shut down (sudo poweroff), unplug the power, reseat both ends of the ribbon cable, and try again. The contacts must face the board, and the latches must be pushed fully closed.

3.2. Check the power. Power problems are the most common reason a Pi camera stops working, so don’t skip this. Run it with the camera mounted where it will live, on the real cable and power supply:

# On picam03, 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 a shorter or thicker cable, or a better power supply. Then reboot and check again. (Part 12’s daily email keeps checking this, because under-voltage often shows up only under load, once the camera is streaming.)

Part 4 – Make Wi-Fi reliable

4.1. Turn off Wi-Fi power saving. When it’s on, the Pi’s Wi-Fi dozes off and the camera disappears from the network. This setting file works however your Wi-Fi connection was created:

# On picam03, 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 picam03, as your user
sudo reboot
# On picam03, as your user
iw dev wlan0 get power_save

Expected result:

Power save: off

4.3. Check the signal strength where the camera is mounted:

# On picam03, 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 for video, so move the access point or the camera before continuing.

4.4. Check that your router answers a ping. The Wi-Fi watchdog in Part 10 uses this to decide whether the network is up.

# On picam03, 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 10.3.

Part 5 – Reduce writes to the SD card

SD cards wear out from being written to. A camera that runs 24/7 doesn’t need to write much, so these steps cut out the writing it doesn’t need. Nothing here affects the video.

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 picam03, 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 picam03, as your user
sudo mkdir -p /etc/systemd/journald.conf.d
sudo tee /etc/systemd/journald.conf.d/90-picam.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-.

Followed an earlier version of this guide? It named this file 10-picam.conf, which loses to the Raspberry Pi OS file, so your logs were still being kept in RAM. Rename it, and restart the logging service:

# On picam03, as your user - ONLY if you have 10-picam.conf from an earlier version
sudo mv /etc/systemd/journald.conf.d/10-picam.conf /etc/systemd/journald.conf.d/90-picam.conf
sudo systemctl restart systemd-journald

Then do the checks in step 5.7.

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 picam03, 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 picam03, 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 camera 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 picam03, as your user
sudo systemctl disable --now apt-daily.timer
# On picam03, as your user
sudo systemctl disable --now apt-daily-upgrade.timer
# On picam03, 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 two things Raspberry Pi OS should already do (nothing to change if they match):

# On picam03, as your user
findmnt -no OPTIONS /
findmnt -no FSTYPE /tmp

Expected result:

rw,noatime
tmpfs

noatime means Linux doesn’t write to the card every time a file is simply read. tmpfs means /tmp lives in RAM. If the first line doesn’t include noatime, stop and look into it before editing anything. A mistake in /etc/fstab can stop the Pi from booting.

5.6. Reboot to apply the swap and log changes, then log back in:

# On picam03, as your user
sudo reboot

5.7. Verify:

# On picam03, as your user
swapon --show
ls -l /var/swap
journalctl --disk-usage
ls -d /var/log/journal
systemd-analyze cat-config systemd/journald.conf | grep -E '^# /|^Storage' | grep -B1 '^Storage' | tail -n 2

Expected result:

  • swapon shows only /dev/zram0 (no /var/swap file).
  • The first ls says No such file or directory (the 2 GB swap file has been removed).
  • journalctl reports a few MB, and never more than 50 MB.
  • The second ls prints /var/log/journal. That folder only exists when the log is kept on the SD card.
  • The systemd-analyze command prints these two lines. The last Storage= setting is the one that wins, and it must be persistent:# /etc/systemd/journald.conf.d/90-picam.conf
    Storage=persistent

From the next reboot on, journalctl --list-boots --no-pager lists every boot, and the Troubleshooting section’s journalctl -b -1 commands can read the log from before a crash.

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 – Install MediaMTX

6.1. Download MediaMTX v1.21.1 (the latest release as of September 2026), along with the official checksum file:

# On picam03, as your user
cd ~
wget https://github.com/bluenviron/mediamtx/releases/download/v1.21.1/mediamtx_v1.21.1_linux_arm64.tar.gz
wget https://github.com/bluenviron/mediamtx/releases/download/v1.21.1/checksums.sha256

Note: the file name changed in newer releases. It’s now linux_arm64, not linux_arm64v8 as in older guides.

6.2. Check that the download is complete and hasn’t been tampered with:

# On picam03, as your user
sha256sum --check --ignore-missing checksums.sha256

Expected result:

mediamtx_v1.21.1_linux_arm64.tar.gz: OK

If it says FAILED, delete the file and download it again. Do not continue with a file that fails.

6.3. Unpack it and install the program and its config file in the standard system locations:

# On picam03, as your user
mkdir -p ~/mediamtx
tar -xzf mediamtx_v1.21.1_linux_arm64.tar.gz -C ~/mediamtx
sudo install -m 755 ~/mediamtx/mediamtx /usr/local/bin/mediamtx
sudo mkdir -p /usr/local/etc
sudo install -m 644 ~/mediamtx/mediamtx.yml /usr/local/etc/mediamtx.yml
/usr/local/bin/mediamtx --version

Expected result: the last command prints v1.21.1.

6.4. Change the settings. This turns off everything except RTSP (the protocol your NVR uses), and turns on the local status API that the health email uses. The API only answers on the Pi itself (127.0.0.1), never on the network. Paste the whole block at once:

# On picam03, as your user
sudo sed -i \
-e 's/^rtspTransports: \[udp, multicast, tcp\]$/rtspTransports: [tcp]/' \
-e 's/^rtmp: true$/rtmp: false/' \
-e 's/^hls: true$/hls: false/' \
-e 's/^webrtc: true$/webrtc: false/' \
-e 's/^srt: true$/srt: false/' \
-e 's/^moq: true$/moq: false/' \
-e 's/^api: false$/api: true/' \
-e 's/^apiAddress: :9997$/apiAddress: 127.0.0.1:9997/' \
/usr/local/etc/mediamtx.yml
grep -nE '^(api|apiAddress|rtspTransports|rtmp|hls|webrtc|srt|moq):' /usr/local/etc/mediamtx.yml

Expected result: exactly these 8 lines, with these values (the line numbers may be slightly different):

147:api: true
149:apiAddress: 127.0.0.1:9997
245:rtspTransports: [tcp]
290:rtmp: false
314:hls: false
377:webrtc: false
437:srt: false
445:moq: false

If any line still shows its old value, open the file with sudo nano /usr/local/etc/mediamtx.yml, find that line with Ctrl+W, and change it by hand.

Why each change: rtspTransports: [tcp] makes the stream use TCP only, which holds up much better over Wi-Fi than UDP. RTMP, HLS, WebRTC, SRT and MoQ are other streaming protocols this camera doesn’t use. Turning them off saves memory and CPU on the Pi Zero, and leaves fewer network ports open.

6.5. Replace the paths: section at the bottom of the file with the camera settings. The first command deletes everything from the paths: line to the end of the file. The second adds the new section.

# On picam03, as your user
sudo sed -i '/^paths:/,$d' /usr/local/etc/mediamtx.yml
sudo tee -a /usr/local/etc/mediamtx.yml >/dev/null <<'EOF'
paths:
cam:
source: rpiCamera
rpiCameraWidth: 1280
rpiCameraHeight: 720
rpiCameraFPS: 15
rpiCameraBitrate: 1500000
rpiCameraIDRPeriod: 30
EOF
tail -n 8 /usr/local/etc/mediamtx.yml

Expected result: the last 8 lines of the file are exactly the paths: block above, and paths: appears only once in the file.

Why these settings:

  • 720p at 15 frames per second and 1.5 Mbps is comfortable for a Pi Zero 2 W and for 2.4 GHz Wi-Fi.
  • rpiCameraIDRPeriod: 30 sends a full “key frame” every 2 seconds. The default is every 4 seconds. With more frequent key frames, Frigate and Blue Iris connect faster and their recordings cut more cleanly.
  • The default all_others: entry is left out on purpose. Without it, cam is the only stream that exists on this camera, and nobody on the network can push a different stream into it.

6.6. Have MediaMTX check the config file for mistakes:

# On picam03, as your user
/usr/local/bin/mediamtx --validate-conf=/usr/local/etc/mediamtx.yml

Expected result:

configuration file: /usr/local/etc/mediamtx.yml
configuration file is valid

If it prints an ERR: line instead, it names the setting that’s wrong. Fix it with sudo nano /usr/local/etc/mediamtx.yml and run the check again.

6.7. Run MediaMTX by hand once to test it:

# On picam03, as your user
sudo /usr/local/bin/mediamtx /usr/local/etc/mediamtx.yml

Expected result: it keeps running and shows lines similar to these, with no ERR lines:

INF MediaMTX v1.21.1
INF configuration loaded from /usr/local/etc/mediamtx.yml
INF [RTSP] started with listeners on :8554 (TCP/RTSP)
INF [API] started with listener on 127.0.0.1:9997 (TCP/HTTP)

followed by some camera start-up lines. A WAR line mentioning sdn.cpp (legacy SDN tuning) is harmless. Leave it running for Part 7.

Part 7 – Test the stream in VLC

7.1. On your Windows PC, open VLC, click the “Media” menu, and choose “Open Network Stream…”. Windows VLC Media Menu items

7.2. In the “Please enter a network URL:” box, enter the following (with the Pi’s IP address) and press “Play”: rtsp://<picam03-ip>:8554/cam VLC Open Network Stream Dialog

Expected result: video from the camera appears within a few seconds, and the PuTTY window shows a line like:

INF [RTSP] [session 861b8faa] is reading from path 'cam', with TCP, 1 track (H264)
VLC showing video stream

If you see repeated write queue is full warnings, the Wi-Fi can’t keep up and frames are being dropped. Don’t ignore these. Improve the signal (step 4.3), or lower rpiCameraBitrate.

7.3. Close VLC. In PuTTY, stop MediaMTX with Ctrl+C. Only one program can use the camera at a time, so it has to be stopped before the service in Part 8 can start it.

Part 8 – Run MediaMTX as a service that restarts itself

8.1. Create the service file. Restart=always is the key line: if MediaMTX ever crashes, systemd starts it again 5 seconds later instead of leaving the camera dead.

# On picam03, as your user
sudo tee /etc/systemd/system/mediamtx.service >/dev/null <<'EOF'
[Unit]
Description=MediaMTX RTSP server for the Pi camera
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=0

[Service]
ExecStart=/usr/local/bin/mediamtx /usr/local/etc/mediamtx.yml
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

Expected result: no output.

8.2. Load it, start it, and make it start at every boot. These are separate commands, each on its own line:

# On picam03, as your user
sudo systemctl daemon-reload
sudo systemctl enable --now mediamtx
systemctl status mediamtx --no-pager

Expected result: the status shows Loaded: ... enabled and Active: active (running). Check VLC again. The stream should play.

8.3. Prove that the automatic restart works by force-killing MediaMTX:

# On picam03, as your user
sudo systemctl kill --signal=SIGKILL mediamtx

Wait 10 seconds, then:

# On picam03, as your user
systemctl status mediamtx --no-pager
journalctl -u mediamtx -n 20 --no-pager | grep "Scheduled restart"

Expected result: Active: active (running) with a start time from just a few seconds ago, and a line containing Scheduled restart job, restart counter is at 1. (Tomorrow’s health email will report “MediaMTX crashed and was restarted 1 time”. That’s this test, and it only appears once.)

Useful commands from now on:

systemctl status mediamtx --no-pager        # is it running?
sudo systemctl restart mediamtx # restart it (e.g. after editing the .yml)
sudo journalctl -u mediamtx -n 50 --no-pager # its last 50 log lines
sudo journalctl -u mediamtx -f # watch its log live (Ctrl+C to stop)

Part 9 – 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 camera stays frozen until someone pulls the plug.

9.1. Create the setting file:

# On picam03, 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.)

9.2. Reboot, log back in, and check it:

# On picam03, as your user
sudo reboot
# On picam03, as your user
systemctl show -p RuntimeWatchdogUSec --value

Expected result:

15s

9.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 picam03, 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 the stream plays in VLC.

Part 10 – Install the Wi-Fi watchdog

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.

10.1. Create the script. Paste the whole block at once. It’s long, and it ends with the line SCRIPT_END:

# On picam03, 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 10.1 again.

10.2. Test it without letting it change anything (DRY_RUN=1). First, against your real router, where it should stay quiet:

# On picam03, 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 10.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 picam03, 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

(Each line may start with a date and <13>. That’s normal. Test runs log as wifi-watchdog-test so the health email doesn’t count them as real events.)

10.3. Only if your router did not answer pings in step 4.4 or 10.2: pick another device on your own network that is always on and does answer pings (for example your DNS server), 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 camera for as long as the outage lasts:

# On picam03, 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 10.2. (The checksum in step 10.1 won’t match after this edit. That’s expected.)

10.4. Create the service that keeps the watchdog running:

# On picam03, 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 picam03, 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 11 – Let the Pi send email (through Gmail)

11.1. Create a Gmail app password for this camera. Do this on your PC:

  1. 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.)
  2. For the app name, type picam03 and click Create.
  3. 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 cameras? One app password each, or one shared? A shared one works. Gmail accepts the same app password from any number of devices. One per camera (named picam01, picam02, and so on) is safer, though. The password is stored on the camera’s SD card, so if a card is ever lost, or thrown away without being wiped, you can revoke just that camera’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 cameras 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 12.2.

11.2. Install msmtp, a small program that sends email through Gmail:

# On picam03, 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.

11.3. Create the mail settings file. It uses placeholders, so your password never ends up in your command history:

# On picam03, 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

11.4. Put in your real values:

# On picam03, 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 picam03, as your user
ls -l /etc/msmtprc

Expected result: it starts with -rw------- 1 root root, which means only root can read the password.

11.5. Send a test email (replace the address with where you want camera emails to go):

# On picam03, as your user
printf 'Subject: picam03 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 12 – Daily health-report email

Once a day at 7:00 AM, this script checks the camera and emails you a report. The subject line tells you at a glance: [picam03] Daily health: OK or [picam03] Daily health: 2 PROBLEM(S) FOUND. Every problem in the email comes with step-by-step instructions to fix it.

What it checks:

CheckFlagged when
Power supplyvcgencmd get_throttled is not 0x0. It tells a power problem apart from a heat problem, and gives fix suggestions for each.
Temperature75 °C or hotter
Wi-Fi power savingIt’s on (includes the instructions to turn it off)
Wi-Fi signalWeaker than -70 dBm
Wi-Fi watchdogIt had to reconnect or reboot in the last 24 hours, or it isn’t running
MediaMTXNot set to restart automatically (with fix instructions), not running, not starting at boot, or crashed in the last 24 hours
CameraNot producing video, even though MediaMTX is running
Hardware watchdogTurned off (with fix instructions)
Disk spaceAny filesystem with less than 20% free
SD card healthThe card has switched to read-only, filesystem errors are recorded, the kernel logged read/write errors, or much more is being written than normal

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.

12.1. Create the script. Paste the whole block at once. It’s long, and it ends with the line SCRIPT_END:

# On picam03, as your user
sudo tee /usr/local/sbin/picam-health.sh >/dev/null <<'SCRIPT_END'
#!/bin/bash
# picam-health.sh - daily health report for a Raspberry Pi camera running MediaMTX.
#
# Checks power, temperature, Wi-Fi, MediaMTX, the hardware watchdog, disk space and
# the SD card, then emails a plain-text report with fix instructions for anything wrong.
#
# sudo picam-health.sh build the report and email it
# sudo picam-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"
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
MEDIAMTX_API="http://127.0.0.1:9997"
CAMERA_PATH="cam"
STATE_DIR="/var/lib/picam-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"; }

########################################################################
# 1. Power supply and temperature
########################################################################
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 "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. It is the most
common reason a Pi camera "just stops working". To fix it, try these in order:
1. Use a shorter USB cable. Long cables (for example 10 ft / 3 m) drop too much
voltage. 3 ft / 1 m or shorter is best.
2. Use a thicker cable. Look for 20 AWG or 22 AWG power wires on the packaging;
cheap charging cables are often 28 AWG and too thin.
3. Change the power supply. Use the official Raspberry Pi 5.1 V 2.5 A micro-USB
supply (it has its own attached cable), not a phone charger.
4. If the camera has to be far from an outlet, run an extension cord to it and
keep the USB cable short, rather than using a long USB cable.
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. A camera in a sunny
window can get very hot. To fix it:
1. Add a small heatsink to the Pi's processor.
2. Keep direct sun off the Pi board (shade it, or let the case breathe).
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. Keep direct sun off the Pi board.
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

########################################################################
# 2. Wi-Fi
########################################################################
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 camera drops off the
network. 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
;;
*)
problem "Could not read the Wi-Fi power-save setting" <<EOF
The command 'iw dev $WIFI_IF get power_save' returned nothing. Run it by hand to see
the error, and check that the Wi-Fi interface is really called $WIFI_IF:
iw dev
EOF
;;
esac

LINK=$(iw dev "$WIFI_IF" link 2>/dev/null)
SIGNAL=$(echo "$LINK" | awk '/signal:/ { print $2; exit }')
SSID=$(echo "$LINK" | sed -n 's/^[[:space:]]*SSID: //p')
if [ -z "$SIGNAL" ]; then
detail "Wi-Fi: could not read the signal strength (not connected?)"
elif [ "$SIGNAL" -lt "$WIFI_SIGNAL_WARN" ]; then
problem "Weak Wi-Fi signal: $SIGNAL dBm (network: ${SSID:-unknown})" <<EOF
Anything weaker than $WIFI_SIGNAL_WARN dBm is unreliable for video. (Closer to 0 is
stronger: -55 is good, -75 is poor.) Weak signal causes dropped frames and the
"write queue is full" warnings in the MediaMTX log. To improve it:
1. Move the access point closer, or add one near the camera.
2. Reduce the bitrate in /usr/local/etc/mediamtx.yml (rpiCameraBitrate).
3. Metal window frames and low-E window film block Wi-Fi - try another spot.
EOF
else
ok "Wi-Fi signal: $SIGNAL dBm (network: ${SSID:-unknown})"
fi

WD_EVENTS=$(journalctl -t wifi-watchdog --since "24 hours ago" --no-pager -q -o short 2>/dev/null | grep -E "RECONNECT|REBOOTING")
if [ -n "$WD_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:

$WD_EVENTS

An occasional event is fine. If this shows up every day, check the power problem
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"
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
You want to see: Active: active (running)
EOF
fi

########################################################################
# 3. MediaMTX
########################################################################
if [ "$(systemctl show -p LoadState --value mediamtx)" != "loaded" ]; then
problem "The mediamtx service does not exist" <<'EOF'
The file /etc/systemd/system/mediamtx.service is missing or broken.
Follow the "Run MediaMTX as a service" part of picam_setup.md again.
EOF
else
RESTART=$(systemctl show -p Restart --value mediamtx)
case "$RESTART" in
always|on-failure|on-abnormal)
ok "MediaMTX restarts automatically if it crashes (Restart=$RESTART)" ;;
*)
problem "MediaMTX will NOT restart by itself if it crashes (Restart=$RESTART)" <<'EOF'
If MediaMTX crashes, the camera stays down until someone reboots the Pi. To fix it:
1. Open the service file:
sudo nano /etc/systemd/system/mediamtx.service
2. In the [Service] section, under the ExecStart= line, add these two lines:
Restart=always
RestartSec=5
3. In the [Unit] section, add this line:
StartLimitIntervalSec=0
4. Save (Ctrl+O, Enter) and exit (Ctrl+X), then reload and restart:
sudo systemctl daemon-reload
sudo systemctl restart mediamtx
5. Check it:
systemctl show -p Restart --value mediamtx
You want to see: always
EOF
;;
esac

if [ "$(systemctl is-enabled mediamtx 2>/dev/null)" != "enabled" ]; then
problem "MediaMTX is not set to start at boot" <<'EOF'
Fix it with:
sudo systemctl enable mediamtx
EOF
fi

if ! systemctl is-active --quiet mediamtx; then
problem "MediaMTX is NOT running" <<'EOF'
See why with:
sudo journalctl -u mediamtx -n 50 --no-pager
Try starting it with:
sudo systemctl start mediamtx
EOF
else
ok "MediaMTX is running"
fi

CRASHES=$(journalctl -u mediamtx --since "24 hours ago" --no-pager -q 2>/dev/null | grep -c "Scheduled restart job")
if [ "${CRASHES:-0}" -gt 0 ]; then
problem "MediaMTX crashed and was restarted $CRASHES time(s) in the last 24 hours" <<'EOF'
The automatic restart worked, but MediaMTX should not be crashing. See why with:
sudo journalctl -u mediamtx --since "24 hours ago" --no-pager | grep -iE "ERR|panic|fail"
(If you tested the auto-restart yourself yesterday, that test is counted here.)
EOF
fi
fi

API_OUT=$(curl -s -m 5 "$MEDIAMTX_API/v3/paths/get/$CAMERA_PATH" 2>/dev/null)
if [ -z "$API_OUT" ]; then
problem "Could not ask MediaMTX whether the camera is working" <<'EOF'
The MediaMTX API did not answer on 127.0.0.1:9997. Either MediaMTX is not running
(see above), or the API is not turned on. Check /usr/local/etc/mediamtx.yml:
grep -nE '^(api|apiAddress):' /usr/local/etc/mediamtx.yml
You want to see:
api: true
apiAddress: 127.0.0.1:9997
EOF
elif echo "$API_OUT" | grep -q '"ready":true'; then
READERS=$(echo "$API_OUT" | grep -o '"readers":\[[^]]*\]' | grep -o '"type"' | wc -l)
ok "Camera stream '$CAMERA_PATH' is live, $READERS viewer(s) connected right now"
else
problem "The camera is NOT producing video (stream '$CAMERA_PATH' is not ready)" <<'EOF'
MediaMTX is running but the camera is not sending video. Most often this is the
ribbon cable. To check:
1. See the MediaMTX errors:
sudo journalctl -u mediamtx -n 50 --no-pager
2. Stop MediaMTX (it holds the camera) and ask the Pi to list its cameras:
sudo systemctl stop mediamtx
rpicam-hello --list-cameras
sudo systemctl start mediamtx
You want to see the camera listed (Camera Module 3 shows as "imx708").
3. If no camera is listed: power off, reseat both ends of the ribbon cable
(contacts facing the right way), and power on again.
EOF
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)

########################################################################
# 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. Re-image it with Raspberry Pi Imager and follow picam_setup.md 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 picam_setup.md.
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)"
MTX_VER=$(/usr/local/bin/mediamtx --version 2>/dev/null)
detail "MediaMTX version: ${MTX_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/picam-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/picam-health.sh
sha256sum /usr/local/sbin/picam-health.sh

Expected result:

eb90668be34687b7c31d52aa0b6a1960468f737ebc0ddf7ec666044b470e80eb  /usr/local/sbin/picam-health.sh

If the checksum is different, something was lost or changed while pasting. Run step 12.1 again.

12.2. Set the address the report goes to. Open the script:

# On picam03, as your user
sudo nano /usr/local/sbin/picam-health.sh

Near the top (line 13), 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 picam03, as your user
grep -n '^MAIL_TO=' /usr/local/sbin/picam-health.sh

Expected result: 13:MAIL_TO="<your address>"

12.3. Run the report without sending it, and read through it:

# On picam03, as your user
sudo /usr/local/sbin/picam-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 crash you caused on purpose in step 8.3. The PASSED CHECKS section lists everything else, and the camera line shows how many viewers are connected. (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.

12.4. Send one real report now to make sure it arrives:

# On picam03, as your user
sudo /usr/local/sbin/picam-health.sh

Expected result: no output, and the email arrives. Tip: in Gmail, create a filter on the subject [picam03] so the daily reports go to their own label.

12.5. Schedule it for 7:00 AM every day:

# On picam03, as your user
sudo tee /etc/systemd/system/picam-health.service >/dev/null <<'EOF'
[Unit]
Description=Send the daily picam health report
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/picam-health.sh
EOF
sudo tee /etc/systemd/system/picam-health.timer >/dev/null <<'EOF'
[Unit]
Description=Daily picam 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 picam-health.timer
systemctl list-timers picam-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 13 – Final check

13.1. Reboot one last time, to prove everything comes back by itself:

# On picam03, as your user
sudo reboot

13.2. Wait 2–3 minutes, then:

  • VLC plays rtsp://<picam03-ip>:8554/cam.
  • Log in and run sudo /usr/local/sbin/picam-health.sh --dry-run. The only problem it should report is the step 8.3 test crash, if that happened in the last 24 hours.

13.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.

13.4. Point your NVR (Frigate, Blue Iris, etc.) at rtsp://<picam03-ip>:8554/cam. If the NVR has an RTSP transport option, set it to TCP.

Checklist

  • grep VERSION_CODENAME /etc/os-release shows trixie, and uname -m shows aarch64
  • DHCP reservation created for picam03
  • rpicam-hello --list-cameras lists imx708
  • vcgencmd get_throttled shows throttled=0x0 with the camera mounted and streaming
  • iw dev wlan0 get power_save shows Power save: off
  • Wi-Fi signal is -70 dBm or better
  • swapon --show lists only /dev/zram0, and /var/swap is gone
  • Journal capped at 50 MB; apt/man-db timers disabled
  • mediamtx --version shows v1.21.1, and --validate-conf says the file is valid
  • The stream plays in VLC
  • mediamtx service enabled and running; the SIGKILL test restarted it
  • systemctl show -p RuntimeWatchdogUSec --value shows 15s
  • wifi-watchdog checksum matches, both dry-run tests behave as shown, and the service is running
  • Test email received through msmtp
  • picam-health.sh checksum 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 picam03, as your user
sudo apt update
sudo apt full-upgrade -y
sudo reboot

After it comes back, check that the stream still plays in VLC.

To update MediaMTX later (replace 1.21.1 with the new version number everywhere below, and read that release’s notes on GitHub for anything that changed):

# On picam03, as your user
cd ~
wget https://github.com/bluenviron/mediamtx/releases/download/v1.21.1/mediamtx_v1.21.1_linux_arm64.tar.gz
wget -O checksums.sha256 https://github.com/bluenviron/mediamtx/releases/download/v1.21.1/checksums.sha256
sha256sum --check --ignore-missing checksums.sha256

Only if that says OK:

# On picam03, as your user
rm -rf ~/mediamtx-new && mkdir ~/mediamtx-new
tar -xzf mediamtx_v1.21.1_linux_arm64.tar.gz -C ~/mediamtx-new
~/mediamtx-new/mediamtx --validate-conf=/usr/local/etc/mediamtx.yml

Only if that says configuration file is valid (this keeps your existing settings file):

# On picam03, as your user
sudo systemctl stop mediamtx
sudo install -m 755 ~/mediamtx-new/mediamtx /usr/local/bin/mediamtx
sudo systemctl start mediamtx
/usr/local/bin/mediamtx --version
systemctl status mediamtx --no-pager

Troubleshooting

If the camera goes down again, 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 picam03, 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/picam-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.

No video / the health email says the camera isn’t producing video: check sudo journalctl -u mediamtx -n 50 --no-pager. Then run sudo systemctl stop mediamtx followed by rpicam-hello --list-cameras. If no camera is listed, power off and reseat the ribbon cable. Start MediaMTX again with sudo systemctl start mediamtx.

Image keeps drifting in and out of focus (often at night, through glass): Camera Module 3 autofocuses constantly by default. Lock the focus instead by adding these two lines under cam: in /usr/local/etc/mediamtx.yml, indented the same as the other rpiCamera... lines:

    rpiCameraAfMode: manual
  rpiCameraLensPosition: 0.0

0.0 focuses at infinity. 0.5 focuses at 2 m, and 1.0 at 1 m (the value is 1 divided by the distance in metres). Then run /usr/local/bin/mediamtx --validate-conf=/usr/local/etc/mediamtx.yml and sudo systemctl restart mediamtx.

write queue is full warnings, or choppy video: the Wi-Fi can’t keep up. Improve the signal, or lower rpiCameraBitrate (for example to 1000000) and restart MediaMTX.

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.

No daily email: run systemctl list-timers picam-health.timer --no-pager to confirm it’s scheduled, sudo journalctl -u picam-health -n 30 --no-pager to see the last run, and sudo journalctl -t msmtp -n 5 --no-pager to see whether sending failed.

What has Changed (2026-10-11)

I found these problems while following the same steps on other Pis. Here’s what changed, and what to do if your camera was set up with the September 2026 version of this guide.

What changedWhereDo you need to do anything?
The system log wasn’t really kept on the SD card. The setting file was named 10-picam.conf, and a Raspberry Pi OS file named 40-rpi-volatile-storage.conf overrode it, so the log stayed in RAM and was lost at every reboot. It’s now named 90-picam.conf, and step 5.7 checks that it works.Steps 5.2, 5.7Yes, step U1 below
The daily email could falsely report “Heavy writing to the SD card”. It measured over as little as an hour, so a few reboots looked like a busy card. It now waits for at least 12 hours of data.Part 12 (script), step 12.3Yes, step U2 below
Warning against using an internet address (like 8.8.8.8) as the Wi-Fi watchdog target. If your internet goes down, the watchdog would keep rebooting the camera.Step 10.3Only if you changed the target, step U3 below
Note on sharing one Gmail app password between several Pis, and using a separate Gmail account just for alerts.Step 11.1Optional, step U4 below
Clearer expected results for the Wi-Fi watchdog tests (the second test takes 20–30 seconds, and the service status may briefly show no log line).Steps 10.2, 10.4No

Do the steps below in order. They don’t touch the camera settings or MediaMTX, so the stream keeps running throughout (except during the reboot in step U5).

U1. Fix the system log (everyone who followed the earlier version)

U1.1. See which setting file you have:

# On picam03, as your user
ls /etc/systemd/journald.conf.d/

Expected result: 10-picam.conf. (If it already says 90-picam.conf, skip to step U1.3.)

U1.2. Rename it so it sorts after the Raspberry Pi OS file, restart the logging service so it reads the new setting, and then move the log that’s been kept in RAM so far onto the SD card:

# On picam03, as your user
sudo mv /etc/systemd/journald.conf.d/10-picam.conf /etc/systemd/journald.conf.d/90-picam.conf
sudo systemctl restart systemd-journald
sudo journalctl --flush

Expected result: no output. (Without the --flush, the log keeps going to RAM until the next boot, so the reboot in step U5 would still lose this session.)

U1.3. Check it:

# On picam03, as your user
ls -d /var/log/journal
systemd-analyze cat-config systemd/journald.conf | grep -E '^# /|^Storage' | grep -B1 '^Storage' | tail -n 2
sudo journalctl --header | grep -m1 'File path'

Expected result:

/var/log/journal
# /etc/systemd/journald.conf.d/90-picam.conf
Storage=persistent
File path: /var/log/journal/<long id>/system.journal

(The file name at the end may be different, for example user-1000.journal. What matters is that the path starts with /var/log/journal.) If the Storage= line says volatile instead, run systemd-analyze cat-config systemd/journald.conf | grep -E '^# /|^Storage' to see which file sets it, and make sure 90-picam.conf is the last one listed with a Storage= line. If the file path starts with /run/log/journal instead, the log is still in RAM, so run sudo journalctl --flush and check again.

U2. Update the daily health-report script (everyone)

U2.1. Paste the whole step 12.1 block again (it replaces the old script), and check the new checksum:

Expected result:

eb90668be34687b7c31d52aa0b6a1960468f737ebc0ddf7ec666044b470e80eb  /usr/local/sbin/picam-health.sh

U2.2. Pasting the script put the placeholder email address back. Set your address again, exactly as in step 12.2 (line 13 of the script), and check it:

# On picam03, as your user
sudo nano /usr/local/sbin/picam-health.sh
# On picam03, as your user
grep -n '^MAIL_TO=' /usr/local/sbin/picam-health.sh

Expected result: 13:MAIL_TO="<your address>"

There’s nothing else to do. The timer from step 12.5 runs the new script from tomorrow on, and the earlier write measurement it saved is kept.

U3. Check the Wi-Fi watchdog target (only if you followed step 10.3)

# On picam03, as your user
grep -n '^TARGET=' /usr/local/sbin/wifi-watchdog.sh

Expected result: TARGET="${TARGET:-}" (pings your router, the default), or TARGET="${TARGET:-<ip>}" where <ip> is a device on your own network, such as 192.168.1.10. If it’s an internet address such as 8.8.8.8 or 1.1.1.1, change it back. Open the script with sudo nano /usr/local/sbin/wifi-watchdog.sh, change that line to TARGET="${TARGET:-}" (or to a device on your own network that answers pings), save and exit, and then restart the watchdog:

# On picam03, as your user - ONLY if you changed the target
sudo systemctl restart wifi-watchdog

U4. (Optional) Use a separate Gmail account for alerts

Read the note in step 11.1. If you decide to switch, create the new Gmail account and an app password for it (step 11.1), then put the new address and app password into /etc/msmtprc (step 11.4), and send a test email (step 11.5). Your reports can still go to your everyday address.

U5. Reboot, and confirm the log now survives it

# On picam03, as your user
sudo reboot

After 2–3 minutes, log back in and run:

# On picam03, as your user
journalctl --list-boots --no-pager | tail -n 3
sudo /usr/local/sbin/picam-health.sh --dry-run

Expected result:

  • --list-boots lists at least 2 boots. Before the fix, it only ever listed the current one. (If it still lists only one, the --flush in step U1.2 was probably skipped. The log is kept from this boot on, so it will list 2 after the next reboot.) From now on, sudo journalctl -b -1 shows the log from before the last restart, which is what the Troubleshooting section relies on.
  • The health report runs without errors, and the stream still plays in VLC. Its DETAILS section may show SD card writes: measured once 12 hours have passed.... That’s expected right after updating.

References

I want to call out a few resources that helped me get this set up.

Final Thoughts

I was able to get things working but I have seen the Raspberry Pi not able to serve up the stream occasionally but it is starting to look like it may be usable. I’m going to make a case for this so that it can mount on a window where I have the ESP32-CAM cameras mounted. If they run well for a month or so, I may replace the three ESP32-CAM cameras that I have. I’m thinking about adding the ability to move the camera’s as well using some servo motors. I will have to determine what would be required to do that with Blue Iris and the Raspberry Pi GPIO pins.

Categories
Raspberry Pi

PiBox and KubeSail Are No More

Unfortunately PiBox and KubeSail ended operations in September 2025. I had not used my PiBox too much, just as a scratchpad for WordPress. I decided to reflash it with Raspbian. Below are the steps that I took to reuse my PiBox.

Oddly, the only post that I could find about it was at https://lemmy.world/post/38039322. I did go to the PiBox and KubeSail websites and they appear to be gone.

Flashing Raspbian

I’m using a Windows PC to flash the PiBox. I followed the directions at https://docs.kubesail.com/guides/pibox/rpiboot/. One thing that was not clear is that rpiboot.exe should be run before plugging the PiBox into the PC. It looked like there were errors but it still works. The errors are about optional files for configuring the connection, so they may be ignored. The connection is slow even with a high quality USB-C to USB-C cable.

  1. Remove the cover from the PiBox and move the internal switch from “normal” to “rpiboot”.
  2. Remove the carrier board from the backplane
  3. Run rpiboot.exe on the PC.
  4. Plug the PiBox into the PC using a USB-C Cable.
  5. Open Raspberry Pi Imager.
  6. Click Storage and make certain that the PiBox is showing as “RPi-MSD- 0001”.
  7. Click “Choose Device” and select “No filtering”.
  8. Click Operating System and select the 64-bit version you wish to install.
  9. Click the “Next” button to write the image to the PiBox.
  10. Once the image is written and verified, unplug the PiBox from the PC.
  11. Move the internal switch from “rpiboot” to “normal”.
  12. Reassemble the PiBox.

Updating Raspbian

If SSH is enabled and the Wi-Fi settings are configured or it is connected via ethernet , you may open an SSH session to run commands. If not, you may need to connect a monitor and keyboard to continue.

sudo apt update
sudo apt upgrade

Setup SSD Drives

I have two 1TB Samsung SSDs installed. I wanted to wipe them and remount them to the PiBox. Below are the steps that I followed.

/dev/sda

  1. Run the parted tool
    sudo parted /dev/sda
  2. Change the units used by parted
    unit s
  3. Delete the existing partitions
    • Run the print command to list all the partitions.
      print
    • Remove each partition (Repeat if more than one partition is present.)
      rm 1
  4. Create a single partition for the whole drive
    mkpart primary ext4 0% 100%
  5. Exit parted
    quit
  6. Format the partition
    sudo mkfs.ext4 -L data1 /dev/sda1
  7. Create a mounts point for the drive (The rancher mount point is only so it will display on the LCD display.)
    sudo mkdir -p /var/lib/rancher

/dev/sdb

  1. Run the parted tool
    sudo parted /dev/sdb
  2. Change the units used by parted
    unit s
  3. Delete the existing partitions
    • Run the print command to list all the partitions.
      print
    • Remove each partition (Repeat if more than one partition is present.)
      rm 1
  4. Create a single partition for the whole drive
    mkpart primary ext4 0% 100%
  5. Exit parted
    quit
  6. Format the partition
    sudo mkfs.ext4 -L data2 /dev/sdb1
  7. Create a mount point for the drive
    sudo mkdir -p /mnt/data2

Update fstab

  1. Open text editor to edit fstab
    sudo nano /etc/fstab
  2. Add the following lines
    /dev/sda1 /var/lib/rancher ext4 defaults,nofail,noatime,discard,errors=remount-ro 0 0

    /dev/sdb1 /mnt/data2 ext4 defaults,nofail,noatime,discard,errors=remount-ro 0 0

Enabling PWM Fan Support

  1. Clone the PiBox OS Scripts
    git clone https://github.com/kubesail/pibox-os.git
  2. Change to the Fan folder
    cd pibox-os/pwm-fan
  3. Uncompress the Fan scripts
    tar zxvf bcm2835-1.68.tar.gz
  4. Change to the Fan scripts folder
    cd bcm2835-1.68
  5. Run the make command
    ./configure && make && sudo make install
  6. Go to the parent folder
    cd ..
  7. Make and run the installation
    make && sudo make install
  8. The fan should run quieter now.

Optimize system for EMMC

  1. Store system logs in memory, instead of writing to disk, and lower log verbosity
    sudo sed -i "s/#Storage.*/Storage=volatile/" /etc/systemd/journald.conf

    sudo sed -i 's/.MaxLevelStore.*/MaxLevelStore=info/' /etc/systemd/journald.conf

    sudo sed -i 's/.MaxLevelSyslog.*/MaxLevelSyslog=info/' /etc/systemd/journald.conf

    sudo systemctl restart systemd-journald.service
  2. Disable swapfile
    sudo swapoff -a
    sudo dphys-swapfile swapoff
  3. Mount /var/tmp as tmpfs filesystems to extend EMMC lifetime
    echo "tmpfs /var/tmp tmpfs defaults,noatime,nosuid,nodev,noexec,mode=0755,size=1M 0 0" | sudo tee -a /etc/fstab

Enabling the 1.3” LCD display

  1. Return to the home directory
    cd ~
  2. Install additional drivers for Raspbian / Pibox-OS
    sudo apt-get install raspberrypi-kernel-headers

    pushd pibox-os/st7789_module

    make

    sudo mv fb_st7789v.ko /lib/modules/"$(uname -r)"/kernel/drivers/staging/fbtft/fb_st7789v.ko

    popd

    sudo dtc --warning no-unit_address_vs_reg -I dts -O dtb -o /boot/overlays/drm-minipitft13.dtbo pibox-os/overlays/minipitft13-overlay.dts
  3. Update config.txt
    cat <> /boot/firmware/config.txt
    dtoverlay=spi0-1cs
    # dtoverlay=dwc2,dr_mode=host
    hdmi_force_hotplug=1
    dtoverlay=drm-minipitft13,rotate=0,fps=60
    EOF
  4. Download pibox-framebuffer binary
    curl -s https://raw.githubusercontent.com/kubesail/pibox-os/main/update-framebuffer.sh | sudo bash

Optional if you want the ability to control the top RGB LED

  • Install the RPi.GPIO library.
    sudo apt install python3-RPi.GPIO
  • Open Nano to create a script file.
    nano blinkLED.py
import RPi.GPIO as GPIO
import time

# Red 17
# Green 27
# Blue 23
leds = [17, 27, 23]

GPIO.setwarnings(False)
GPIO.setmode(GPIO.BCM)
for led in leds:
    GPIO.setup(led, GPIO.OUT)

for led in leds:
    for i in range(3):
        GPIO.output(led, GPIO.HIGH)
        time.sleep(0.5)
        GPIO.output(led, GPIO.LOW)
        time.sleep(0.5)

# Commented next line so the LED stays off.
# Added GPIO.setwarnings(False) at the start of the script
# to hide warnings that the channel is in use on future runs.
# GPIO.cleanup()
  • Run the script and observe the top LED on the front of the PiBox.
    python ./blinkLED.py

Categories
Home Assistant

Home Assistant – Wi-Fi

It is recommended to use Home Assistant with a wired connection, however you may need or want to use a Wi-Fi connection. Follow these steps to setup a Wi-Fi connection within Home Assistant.

Setup Wi-Fi

  1. Navigate to your Home Assistant server and login.
  2. Select Settings near the bottom of the left menu, then click on “System”.
    Home Assistant Browser Window - Settings options screen
  3. Select “Network” from the System Options.
    Home Assistant Browser Window - System options screen
  4. Click the “wlan0” tab in “Configure network interfaces” then click the “Search networks” button.
    Home Assistant Browser Window - Network screen
  5. Select the network SSID from the list, select the encryption method and enter the password. Once finished, click the “Save” button.
    Home Assistant Browser Window - Wi-Fi settings
  6. You may now connect to Home Assistant using the Wi-Fi address if needed. You may view the IP address by expanding the “IPv4” box under the “wlan0” tab of the “Configure network interfaces”.

You have successfully connected Home Assistant server to your Wi-Fi network.

Categories
Home Assistant Raspberry Pi

Home Assistant on Raspberry Pi

I can’t believe I have not written up my Home Assistant setup. Perhaps that is a good thing as I needed to become comfortable with the setup and how to use it. I’ve been using it for nearly a year at this point. What prompted me to do a writeup now is I’m setting up another one for my mom so I can control some things remotely and to help her manage some things around the house.

My current installation was on a Raspberry Pi 4 setup to boot from a USB drive. This has been working well, but I’m setting up a Raspberry Pi 5 for mom’s installation with a NVMe SSD drive as the boot device.

Bill of Materials for this project

All product links are affiliate links to Amazon Products.

Installation Steps

  1. The first step is fitting the NVMe hat to the Raspberry Pi 5. I have found that the 3cm cable that came with my hat did not work properly. I purchased a set of cables to swap out and found that the 3cm cable in the new set had the same issue. I tried the longer 5cm cable and the hat worked well. I would have thought that a shorter cable would be better but I suspect that there is an impedance or timing mismatch with the shorter cable.
  2. Write the image to the NVMe drive using the steps outlined at https://www.martinrowan.co.uk/2024/02/installing-home-assistant-on-raspberry-pi-5-nvme-storage/.
  3. Once my Raspberry Pi rebooted, it was running Home Assistant.
    The Raspberry Pi boot screen for Home Assistant
  4. I opened a browser window and navigated to http://homeassistant.local:8123/. It took a couple of minutes before Home Assistant fully loaded.
    Home Assistant Browser Window - Retrying to connect
    Home Assistant Browser Window - Login Screen
  5. Click on the “Create my smart home” button.
  6. Create a user by enter the information and clicking the “Create account” button.
    Home Assistant Browser Window - Create User
  7. Set the home location. You may try using your address. If that does not work, use the latitude and longitude. Once entered, click the “Next” button.
    Home Assistant Browser Window - Home Location
  8. Select information that you are willing to share with the Home Assistant team and click the “Next” button.
    Home Assistant Browser Window - Help us help you
  9. Click the “Finish” button on the devices screen.
    Home Assistant Browser Window - We found compatible devices!

Home Assistant is now setup and ready to be configured.

Categories
Miscellaneous

iPad as a second PC Monitor

When I travel, I need to have a second monitor. I have a small Wisecoco monitor that I purchased from Amazon. It works well, but it is one more thing that I take with me when I travel. I had thought about turning my iPad mini into a monitor as it is about the same size as the Wisecoco monitor.

I started looking around and found some options, but not pleased as many seem to require an internet connection or at least a monthly subscription. I don’t mind paying a small fee for software so I can use it for many years but do not want to pay on a reoccurring basis for something that will only be used occasionally.

I found a blog post on Joey’s Retro Handhelds that talked about setting up Apollo on the PC and using a client such as Moonlight on the iPad to add the iPad as a second monitor. I was able to follow the post at https://www.joeysretrohandhelds.com/guides/apollo-artemis-streaming-setup-guide/ and have it setup in no time. I did run into one issue while playing Udemy videos, but there is a quick fix in Chrome, which is to disable graphics acceleration in the System settings. Once I did that, I could use the iPad. The audio does play on both the iPad and PC no matter which way the audio settings are setup. I will need to play with it a bit more to have it only on one or the other. This has an easy workaround, which is to mute the audio on one of the devices.

Hope this helps you lighten your load and increase your productivity on the road.

Categories
Uncategorized

Write the Raspbian OS to an SD Card

When working with a Raspberry Pi, you will need to format an SD Card to boot the Raspberry Pi the first time. The most straight forward way to get the Raspberry Pi Operating System (OS), Raspbian, onto an SD Card is to use the Raspberry Pi Imager software. It may be downloaded by visiting https://www.raspberrypi.com/software/.

There are alternative methods for flashing the OS to an SD Card, such as balenaEtcher, but they will not be covered in this guide.

Materials Needed

  • SD Card – Most Raspberry Pi boards use microSD cards. The original Raspberry Pi boards used a full-size SD card.
  • PC with SD Card Reader – This may be Linux, Windows, or Mac.
  • Optional – If your PC has only a full size SD Card reader, you will need a microSD to SD card adapter.

Steps for Flashing the SD Card

Once you have Raspberry Pi Imager installed, you may follow the steps below to flash the OS to the SD Card.

  1. Click the “Choose Device” button and select the Raspberry Pi model from the list. In the following examples, “Raspberry Pi 3” will be used. Replace “Raspberry Pi 3” to match your Raspberry Pi board.
    List of Boards in the Raspberry Pi Imager software
  2. Click the “Choose OS” button and select “Raspberry Pi OS (64-bit)”.
    NOTE: Raspberry Pi 1 and 2 as well as Raspberry Pi Zero and Zero W only support 32-bit OS. For those models, select 32-bit OS rather than a 64-bit OS. (64-bit should not be an option if one of those devices are selected.)
    First list of Operating Systems in the Raspberry Pi Imager software
  3. Click the “Choose Storage” button and select the SD Card from the list.
    List of available removable storage devices in the Raspberry Pi Imager software
  4. Click the “Next” button and follow the prompts. Once the card is validated and you receive a confirmation message that the image has been written to the SD Card, remove the card from the computer and move it to the Raspberry Pi Zero 2 W board and power it on.
    Raspberry Pi Imager confirmation message
Categories
Raspberry Pi

Disable On-Screen Keyboard

When using a touchscreen with your Raspberry Pi, depending on the screen size, you may soon be annoyed with the amount of screen real estate the on-screen keyboard utilizes. While it can be useful if you do not have a keyboard attached, it can make it difficult if not impossible to complete forms an know what you are entering.

You may want to connect a physical keyboard via USB or Bluetooth but find that the on-screen keyboard pops up anyway. How do you disable it? Follow the steps below to disable the on-screen keyboard for touch screens.

  1. Go to the Start Menu and select Preferences > Raspberry Pi Configuration.
    Menu - Raspberry Pi Configuration
  2. Go to the Display Tab, then select “Disabled” from the On-screen Keyboard option.
    Disable on-screen Keyboard
  3. Click the “OK” button.
  4. If prompted to reboot, select “Yes”
    Reboot confirmation

You will find that the on-screen keyboard no longer blocks the screen content.

Categories
Photography Raspberry Pi

Revisit Photobooth Project

I had built a Photobooth in 2019 based on Wyolum’s Raspberry Pi Photobooth project as posted in Make Magazine. Caroline Dunn made a YouTube video of her build, which explains different aspects of the project.

I had two events where the photobooths could be used, so I pulled them off the self and tested them. The first one started just fine, but the SD Card soon became corrupted and would no longer boot. I flashed the SD Card with the latest Raspbian OS and installed the software, but it would not run. There have been changes to Raspbian and in particular with Python on the latest Raspbian build which will not allow the previous code to run. The Wyolum TouchSelfie project has not been updated in 6 years, so there have been no updates to allow it to run on the latest Raspbian distribution. There is the option of using the older distribution, but I did not want to take that path.

I moved forward by attempting to make fixes to the software but ran into several issues, which led me to do a complete rewrite. Not all features are working at this time, but the important ones, such as taking the photo, creating collages & animated GIFs, adding frames with logos, and uploading the photos to a Google Album are working. I also have some ideas for taking the project further, which will be covered at the end.

Bill of Materials (BOM)

  • Raspberry Pi 3 or newer
    I would recommend a Raspberry Pi 4 or 5 as it may handle rendering of the photos better than the Raspberry Pi 3, although the Raspberry Pi 3 works well.
  • Raspberry Camera Module 2 or newer with cable
    NOTE: Raspberry Pi 5 requires a different cable than earlier versions.
  • Raspberry Pi 7″ touch screen
    NOTE: The original Raspberry Pi 7″ touch screen (800×600) was used but the new version 2 (720×1280) will work as well. Raspberry Pi 5 requires a different cable than earlier versions.
  • Power Supply
    NOTE: The official Raspberry Pi Power Adapter for the Raspberry Pi 3 was not sufficient. It required a 12W iPad charger to work. Amazon has some Anker 2-Pack Dual Port 12W USB A Charger Blocks that I purchased, which work as well.
  • (Optional) Waveshare Barcode Scanner Module
    Plugs into Raspberry Pi USB port and Acts like a keyboard. Makes it convenient to run specific commands or shortcuts without connecting a keyboard. Also may be used to enter email addresses for sending photos.
  • (Optional) GPS USB Dongle
    Allows geotagging of photos
  • 1/8″ plywood cut with the SVG files at https://github.com/richteel/Photobooth
    Path: /fabricate/Rich/

Write the Raspbian OS to an SD Card

  1. Write the latest Raspbian image to an SD Card using Raspberry Pi Imager.
    • Click the “Choose Device” button and select “Raspberry Pi 3” from the list.
      List of Boards in the Raspberry Pi Imager software
    • Click the “Choose OS” button and select “Raspberry Pi OS (64-bit)”.
      First list of Operating Systems in the Raspberry Pi Imager software
    • Click the “Choose Storage” button and select the SD Card from the list.
      List of available removable storage devices in the Raspberry Pi Imager software
    • Click the “Next” button and follow the prompts. Once the card is validated and you receive a confirmation message that the image has been written to the SD Card, remove the card from the computer and move it to the Raspberry Pi Zero 2 W board and power it on.
      Raspberry Pi Imager confirmation message
  2. Once the Raspberry Pi boots up, and finishes setting up, log into the Raspberry Pi. You may do this directly with an attached keyboard and monitor or by connecting with SSH.
  3. Connect to the Raspberry Pi or use a connected keyboard.
    • If you had edited the settings prior to writing the OS to the SD Card and the setting included the information to connect to your WiFi and enabled SSH, you may SSH into the Raspberry Pi to run the following commands.
    • If you did not setup those options or if wish to use Raspberry Pi Connect, you may connect a keyboard to the Raspberry or use the on-screen keyboard.
      Recommend using a connected keyboard if you working directly with the Pi.
    • Recommend Turning on Raspberry Pi Connect, Sign In, and work from another computer.

Update Raspbian and Install Packages

  1. Update the Raspberry Pi OS and install packages by running the following code.
    sudo apt update -y && sudo apt upgrade -y

    # Install ImageTk, Image from PIL
    sudo apt install python3-pillow
    sudo apt install python3-pil
    sudo apt-get install python3-pil.imagetk
    sudo apt install python3-numpy

    # Install ImageMagick for the 'Animation' mode
    sudo apt-get install imagemagick

    # Install picamera2 depenancies
    sudo apt install libcap-dev
    sudo apt install libcamera-dev
    sudo apt install libcamera-apps

    # Add support for Pi Camera ?
    sudo apt install python3-picamera2

    # Add support to write extended EXIF data
    sudo apt install python3-piexif

    # Create virtual environment for gdata
    python -m venv ~/photobooth
    source ~/photobooth/bin/activate

    # Install google data api and upgrade it
    pip install gdata
    pip install --upgrade google-api-python-client
    pip install --upgrade oauth2client

    # Install ImageTk, Image from PIL
    # Appears we need to do this in the virtual environment too.
    pip install pillow
    # pip install numpy

    # Leave the virtual environment
    deactivate

Setup Google API Access

  1. Open a web browser and log into your Google Account. If you do not have one, create one.
    NOTE: You may want to create a separate account, just for the photobooth to keep it separate from your main account.
  2. Go to the Google API Console
    • If you are not in the correct profile, select the correct profile.
  3. Create a project
    Click on the project selector near the top of the screen, then click the “New project” button in the dialog.
    Google API Console New Project
  4. Enter a name for the project and click the “Create” button.
    Google API New Project Name
  5. Make certain the project is the selected project in the Console. Look at the upper left corner. If the project is not displayed, click and select the project before continuing.
    Google API Select Project
  6. Add access rights to the services you need (namely Gmail Service and Google Photos Library Service)
  7. Click on the “+ Enable APIs and services” button
    Google API Enable APIs and Services
  8. Search for Gmail using the Search box and click on “Gmail API”
    GMail API
  9. Click the “Enable” button to enable the Gmail API
    Enable GMail access
  10. Return to the Google API Console, click the “+ Enable APIs and services” button, search for Photos, and click on “Google Photos Library API”
    Select Photos API
  11. Click the “Enable” button to enable the Google Photos Library API
    Photos API Enable
  12. Return to the Google API Console and select “Credentials” on the left menu.
  13. Click the “+ Create credentials” button on the top and select “OAuth client ID”
    Add OAuth
  14. If you are prompted to “Configure consent screen”, do that, then return to the next step.
    NOTE: The values entered are not important as you will be the only one authenticating to the app.
    • App Information
      • App Name: Only you will see this.
      • User support email: Enter a valid email.
    • Audience
      • External is the only option available
    • Contact Information
      • Enter a valid email
    • Finish
      • Check the checkbox and click the create button
  15. Select the “Desktop app” for Application type, enter a name, and click the “Create” button.
    OAuth Client Id
  16. Copy the Client ID, Client secret, and Download JSON. Keep the information in a safe place.
    OAuth keys
  17. Back to the Credentials, click on the OAuth Client ID that you created.
  18. Select “Audience” from the left panel, and click the “Publish app” button.
  19. Click on the “Confirm” link to publish the app.

Get the Code

  1. Grab the code from GitHub
    wget -P ~/ https://github.com/richteel/Photobooth/archive/refs/heads/main.zip
    unzip ~/main.zip
    mv ~/Photobooth-main ~/pb

    mv ~/pb/home/pi/TouchSelfie ~/
    mv ~/pb/home/pi/Desktop/Photobooth.desktop ~/Desktop/Photobooth.desktop
    mv ~/pb/home/pi/.config/autostart ~/.config/
    mv ~/pb/home/pi/*.sh ~/
    mv ~/pb/home/pi/*.py ~/

    # Clean up empty folders & zip file
    rm -r ~/pb/home
    rm ~/main.zip

    # Make BASH scripts and desktop files executable
    chmod +x ~/*.sh
    chmod +x ~/TouchSelfie/*.sh
    chmod +x ~/Desktop/*.desktop
    chmod +x ~/.config/autostart/*.desktop

    # cd ~/TouchSelfie
    # ./setup.sh

Disable On-Screen Keyboard (Optional)

This step is optional but highly recommended as the on-screen keyboard takes up a significant amount of available screen space, making it difficult to navigate and see the screen. This is especially true with the original Raspberry Pi Touch Screen.

  1. We will turn off the on-screen keyboard to get it out of the way.
    Screenshot of the Raspberry Pi Desktop with on-screen keyboard
    • Go to the Start Menu and select Preferences > Raspberry Pi Configuration.
      Menu - Raspberry Pi Configuration
    • Go to the Display Tab, then select “Disabled” from the On-screen Keyboard option.
      Disable on-screen Keyboard
    • Click the “OK” button.
    • When prompted to reboot, select “Yes”
      Reboot confirmation
    • When the Raspberry Pi reboot, the Photobooth application should be running.
      Photobooth application screenshot
    • Press <ctrl> + q to exit the application and click the “Yes” button on the dialog.
      Photobooth app showing quit dialog

    Authenticate to the Google APIs

    1. Copy the JSON file that was downloaded from the Google API Console and rename it to “google_client_id.json”.
      • If you did not download it, go to the Google API Console, go to Credentials, then click on the Client Id (link).
      • You may transfer the JSON file to the Raspberry Pi using an application such as WinSCP or other method and place it in the /home/pi/TouchSelfie/scripts/ folder.
    2. Copy the “google_client_id.json” file to “OpenSelfie.json”. You may copy it by running the following command in the terminal.
      cp ~/TouchSelfie/scripts/google_client_id.json ~/TouchSelfie/scripts/OpenSelfie.json

      NOTE: There should be two JSON files in the scripts folder, google_client_id.json and OpenSelfie.json.
    3. On the Raspberry Pi, open a terminal and type the following commands to run the setup application.
      cd TouchSelfie
      ./setup.sh

      Terminal window to run setup.sh
    4. The “TouchSelfie – Options” window opens. Select the options you wish to enable, then click the “Next” button.
      NOTES:
      – It is recommended NOT to enable the software keyboard if using the Raspberry Pi touchscreen as screen resolution is too small.
      – Image Effects is not implemented at this time so enabling it has no impact.
    5. The “TouchSelfie – Credentials” window opens. Click the “Get AppID” button.
    6. The “TouchSelfie – Credentials” second window opens. Click the “Launch browser” button.
    7. The browser will open prompting you to log into Google.
    8. Enter your Google Account and click the “Next” button.
    9. Enter your account password and click the “Next” button.
    10. If you have 2-step verification setup, then complete the 2-step verification process.
    11. You will see a page stating that Google hasn’t verified this app. Click the “Advanced” link at the bottom of the page.
    12. Click on the “Go to … (unsafe)” link at the bottom of the page.
    13. Check the “Select all” checkbox to select all the permissions, then click the “Continue” button.
    14. The browser window will state “The authentication flow has completed” and the TouchSelfie – Options screen will be displayed.
      NOTE: There is a new file in the /TouchSelfie/scripts/ folder named, google_credentials.dat.
    15. Modify any options if necessary and click the “Next” button.
    16. Enter the Google Account, verify that the Credentials have green checkmarks, and click the “Next” button.
    17. Enter an email address that you can check and change any items you wish for the test email. Click the “Send test email to test the Gmail API connection. Check your email for the test message, then click the “Next” button.

    18. The select Photo Album screen needs some work yet. The list must be navigated with the up/down keys on the keyboard and selection is done with the spacebar. This will be fixed in a future release. If you do not have an album, choose “<Create New>” and click tab to the “Select” button and press the spacebar. It will create a new album named “TouchSelfie” and add a small single colored small image to the album. Do not delete the image from the album unless you have uploaded another image. There must be an image in the album.
    19. Click the “Test Upload” button. You may go out to Google Photos to verify that the album has been created and the image was uploaded.

    20. If necessary, modify the options for snapshots, then click the “Save” button.
    21. You may now close the terminal window and browser.

    Customizing the frames for the photos and collages

    The \TouchSelfie\logos\ folder has the frames that are applied over the photos.

    • Single Photo: single_logo.png
    • 4 Collage Photo: collage_four_square_logo.png
    • 9 Collage Photo: collage_nine_square_logo.png

    There is a Frames.svg that may be used for creating or updating the photos. The folder also contains some examples from various events.

    Configuration File Settings

    The configuration file is in the /TouchSelfie/scripts/ folder and is named, configuration.json. It is a JSON file with several options.

    • archive_to_all_usb_drives
      • Type: boolean
      • Values: true or false
      • Allows archiving of photos to a USB drive if one is attached to the Raspberry Pi
      • NOTE: This functionality is not behaving properly. Some photos are archived but not all. All are written to the SD card though. This will be fixed in a later release.
    • cameraMake
      • Type: string
      • A quoted string value indicating the make of the camera. You may set this to any value you wish. The value is written to the image Exchangeable Image File Format (EXIF) data.
    • cameraModel
      • Type: string
      • A quoted string value indicating the model of the camera. You may set this to any value you wish. The value is written to the image EXIF data.
    • countdown_before_snap
      • Type: integer
      • The seconds between button press and taking the photo. There needs to be images in the /TouchSelfie/scripts/resources/ folder to support the count. Currently there are 5 images named count_down _1.png to count_down _5.png. The images are 128×128 pixels in size.
    • countdown_inter_snap
      • Type integer
      • The seconds between taking photos for collage images
    • email_body
      • Type: string
      • A quoted string value for the body of the emails being sent from the application.
    • email_subject
      • Type: string
      • A quoted string value for the subject of the emails being sent from the application.
    • enable_effects
      • Type: boolean
      • Values: true or false
      • Indicates if the effects button will be shown.
    • enable_email
      • Type: boolean
      • Values: true or false
      • Indicates if the mail button will be shown.
    • enable_email_logging
      • Type: boolean
      • Values: true or false
      • Indicates if emails will be logged to a file.
    • enable_hardware_buttons
      • Type: boolean
      • Values: true or false
      • Indicates if hardware buttons are available for application functions. Hardware buttons are not supported at this time.
    • enable_print
      • Type: boolean
      • Values: true or false
      • Indicates if the print button will be shown
    • enable_upload
      • Type: boolean
      • Values: true or false
      • Indicates if photos will be uploaded to a Google Album
    • full_screen
      • Type: boolean
      • Values: true or false
      • This is deprecated and may be removed from future releases.
    • gmail_user
      • Type: string
      • A quoted value for the Google Account email address.
    • google_photo_album_id
      • Type: string
      • A quoted text value that is the id of the Google Album. It is a Globally Unique Identifier (GUID) that is inserted by the setup program.
    • google_photo_album_name
      • Type: string
      • A quoted text value of the name of the Photo Album.
    • imageArtist
      • Type: string
      • A quoted string for the name of the artist/photographer. You may set this to any value you wish. The value is written to the image EXIF data.
    • imageComment
      • Type: string
      • A quoted string for the note or comment to be added to the EXIF data. You may set this to any value you wish. The value is written to the image EXIF data.
    • imageDescription
      • Type: string
      • A quoted string for the description to be added to the EXIF data. You may set this to any value you wish. The value is written to the image EXIF data.
    • imageKeyWords
      • Type: string
      • A quoted string for comma separated keywords to be added to the EXIF data. You may set this to any value you wish. The value is written to the image EXIF data.
    • local_archive
      • Type: boolean
      • Values: true or false
      • If true, photos are saved to the SD card in addition to being uploaded to the Google Album. Recommended to have this value set to true.
    • local_archive_dir
      • Type: string
      • A quoted string with the path to the local archive folder.
    • location_lat
      • Type: float
      • A quoted decimal value for the latitude. The value is written to the image EXIF data.
    • location_long
      • Type: float
      • A quoted decimal value for the longitude. The value is written to the image EXIF data.
    • logo_file
    • selected_printer
    • snap_caption
    • software
      • Type: string
      • A quoted string for the software to be added to the EXIF data. You may set this to any value you wish. The value is written to the image EXIF data.
    • test_email_address
      • Type: string
      • A quoted value for the test email address. This is only used in the setup.

    (Optional) Stop Execution File dialog box

    You may want to stop seeing the Execute File dialog when clicking on the Photobooth desktop icon. Below are the steps to stop displaying the dialog.

    1. Click on the File Manager at the top of the screen.
    2. From the menu, select Edit > Preferences
    3. With “General” selected on the left panel, check the “Don’t ask options on launch executable file” option.
    4. Close File Manager and click on the Photobooth icon on the desktop. The application will launch.

    Next Steps

    There are several things that need to be completed and some changes that plan to be made. Below is a list of those items.

    • The code is looking for the client id in two different JSON files. Modify the code so only one copy of the JSON file is needed. The two JSON files are:
      • google_client_id.json
      • OpenSelfie.json
    • Use connected GPS to geotag photos. Currently the configuration file has location_lat and location_long which are used to geotag photos, but that requires that the configuration file is updated when the photobooth is brought to a new location.
    • Add text to the buttons to make it easier for users to know what each button is used for. An alternative may be to add an info button.
    • Email photo(s) function. Would like the option to review and select photos to email.
    • Add effects back into the application.
    • Add support to print photos.
    • Improve the UI to let people know when a photo will be taken for each image of the collage and animation. Not certain how to make this intuitive.
    • Fix the issues with the setup application and add additional options.

    Categories
    Uncategorized

    VK-172 USB GPS on the Raspberry Pi

    30 June 2025 UPDATE: The fix outlined here seemed to work, but I noticed that some things were not right yesterday. I dug in deeper and I seem to have it working now. It appears that gpsd may have been the problem from the start. Below is a Copilot session that seemed to zero in on gpsd and the need to build a newer version.

    Copilot session to fix GPS: PDF (430 KB)

    After applying the changes, I tested three times and each time the correct location was identified, yes that was an issue, and the GPS had a fix in a reasonable time.

    • Uptime 11 Minutes to fix (reboot)
    • Uptime 9 Minutes to fix (reboot)
    • Uptime 21 Minutes to fix (shutdown, power removed, sat for a few minutes and restarted)

    The rest of this post is here for information only. I plan to write another post once I test on another SD Card.


    I purchased a WWZMDiB VK-172 USB GPS Dongle Receiver from Amazon to use with my Raspberry Pi Photobooth but ran into some issues with it. The Raspberry Pi recognized the GPS module straight away but I noticed the GPS was not getting a location fix even when it was outside with a clear view of the sky. I had read that some people had a similar issue or it would take nearly an hour or longer to get a fix on the location. Obviously this is not acceptable. My experience with u-blox products is that their products are very good, so this was not normal. Something else must have been amiss.

    For context, I had already installed and configured gpsd on the Pi, using tools like cgps and xgps for monitoring. The cgps application appeared that everything was working, but it was not getting a fix.

    Screenshot of cgps not having a fix.

    To verify that the GPS could obtain a location fix and how long it would take, I connected the GPS to my Windows PC and installed the u-blox u-center software. On the Windows PC, I was able to get a location fix quickly. This helped me to target the configuration on the Raspberry Pi.

    U-center running on the PC with a fix. (Lat/Long blacked out.)

    Back on the Raspberry Pi, I looked at several things but suspected that the GPS unit was not configured properly. With the help of Copilot, I attempted to resolve the issue. What I uncovered is the GPS was not configured to send the proper NMEA messages.

    I was able to properly configure the GPS to send NMEA messages but the GPS unit would not retain the configuration as it has no battery. I was able to write a script to run as a service and run on startup. All seemed to work well, but I needed to leave it and come back the next day. When I tested it again, things were no longer working as expected. Upon further investigation, it was noted that unplugging the USB GPS dongle and plugging it back in got everything working as expected.

    I was able to land on a solution that mimicked the unplugging and plugging the dongle in. This final configuration seems to allow the dongle to work properly on the Raspberry Pi. I have not verified, but other Raspberry Pi versions may or may not have this issue.

    Screen capture of cgps with location fix after applying the changes.

    Below is the list of steps necessary to get the GPS module to work properly with gpsd on the Raspberry Pi.

    My setup

    • Raspberry Pi 3
      • Raspbian bookworm
      • All updates applied as of 28 June 2025

    Steps

    • Update the Raspberry Pi OS and install packages by running the following code.
      • sudo apt update -y && sudo apt upgrade -y
    • Install gpsd and related packages
      • sudo apt -y install gpsd gpsd-tools gpsd-clients
    • Install Python GPS client library
      • sudo apt install python3-gps
    • Configure gpsd
      • sudo nano /etc/default/gpsd
      • Edit the file to include the following content
        DEVICES="/dev/ttyACM0" USBAUTO="true" GPSD_OPTIONS="-n"
    • Enable and start gpsd service
      • sudo systemctl enable gpsd
      • sudo systemctl start gpsd
    • Reboot to ensure all changes take effect
      • sudo reboot now
    • Create the following script at /home/pi/setup-gps.sh to configure the USB GPS dongle on startup.
      • nano /home/pi/setup-gps.sh
        #!/bin/bash
        # VK-172 USB GPS dongle was having issues sending messages
        # dmesg | tail -n 20
        # Showed that the USB Bus was having with dwc_otg_hcd_urb_dequeue
        # The following commands mimic unplugging the USB and plugging it back in.

        # Look for a serial device with 'u-blox' in the ID string
        DEVICE_PATH=$(readlink -f /dev/serial/by-id/*u-blox*)

        # Extract the most specific USB path (e.g. 1-1.3)
        USB_ID=$(udevadm info -a -n "$DEVICE_PATH" | awk -F'"' '/KERNELS=="1-/ { if ($2 !~ /:/) { print $2; exit } }')

        # Log what we found (optional for debugging)
        echo "Reinitializing USB device at $USB_ID"

        # Rebind to simulate replug
        echo "$USB_ID" > /sys/bus/usb/drivers/usb/unbind
        sleep 1
        echo "$USB_ID" > /sys/bus/usb/drivers/usb/bind


        sleep 5 # wait for USB to settle
        ubxtool -f /dev/ttyACM0 -P 14 -p CFG-MSG,240,0,1 # GGA
        ubxtool -f /dev/ttyACM0 -P 14 -p CFG-MSG,240,4,1 # RMC
        ubxtool -f /dev/ttyACM0 -P 14 -p CFG-MSG,240,2,1 # GSA
        ubxtool -f /dev/ttyACM0 -P 14 -p CFG-MSG,240,3,1 # GSV
        ubxtool -f /dev/ttyACM0 -P 14 -p CFG-PRT,3,0,0,0,0,0,0 # ensure USB outputs NMEA

        # The following command seemed to make no difference.
        # If enabled systemctl will show errors but that is to be expected
        # gpsctl -s -f /dev/ttyACM0
    • Make it executable
      • chmod +x /home/pi/setup-gps.sh
    • Create a systemd Service (e.g., /etc/systemd/system/gps-init.service)
      • sudo nano /etc/systemd/system/gps-init.service
        [Unit]
        Description=Configure u-blox GPS at Boot
        After=multi-user.target
        Before=gpsd.service
        Wants=gpsd.service
        
        [Service]
        ExecStart=/home/pi/setup-gps.sh
        Type=oneshot
        RemainAfterExit=true
        
        [Install]
        WantedBy=multi-user.target
    • Enable the Service
      • sudo systemctl daemon-reexec
      • sudo systemctl enable gps-init.service
    • Reboot to ensure all changes take effect
      • sudo reboot now

    NOTES

    Your USB Dongle may be appear as device other than /dev/ttyACM0. Running the command dmesg | grep tty or ls /dev/tty* tip will allow you to verify if your GPS is appearing as /dev/ttyACM0 or another device path.

    Some of the commands used to troubleshoot were the following.

    • Disabling gpsd so it will not become active between reboots.
      • sudo systemctl disable gpsd.socket
      • sudo systemctl disable gpsd
      • sudo systemctl mask gpsd.socket
      • sudo systemctl mask gpsd
      • Run one of the following sets of commands
        • sudo systemctl stop gpsd.socket
        • sudo systemctl stop gpsd
          — or —
        • sudo reboot now
    • Reenabling gpsd once done troubleshooting.
      • sudo systemctl unmask gpsd.socket
      • sudo systemctl unmask gpsd
      • sudo systemctl enable gpsd.socket
      • sudo systemctl enable gpsd
      • Run one of the following sets of commands
        • sudo systemctl start gpsd.socket
        • sudo systemctl start gpsd
        • or
        • sudo reboot now
    • Checking if the GPS is sending NEMA messages.
      First disable gpsd
      Run one of the following commands
      • cat /dev/ttyACM0
      • cat < /dev/ttyACM0
      • stty -F /dev/ttyACM0 9600
      • With either command, you should see GPS NEMA messages being streamed to the output. If the command immediately returns with no output, then the GPS is not sending messages, which is a problem.
    • View USB Devices
      • lsusb
        • Sample output
          pi@PhotoBooth1:~/TouchSelfie $ lsusb
          Bus 001 Device 007: ID 1546:01a7 U-Blox AG [u-blox 7]
          Bus 001 Device 004: ID 090c:1000 Silicon Motion, Inc. - Taiwan (formerly Feiya Technology Corp.) Flash Drive
          Bus 001 Device 005: ID 2010:7638 USBKey Chip USBKey Module
          Bus 001 Device 006: ID 0424:7800 Microchip Technology, Inc. (formerly SMSC) 
          Bus 001 Device 003: ID 0424:2514 Microchip Technology, Inc. (formerly SMSC) USB 2.0 Hub
          Bus 001 Device 002: ID 0424:2514 Microchip Technology, Inc. (formerly SMSC) USB 2.0 Hub
          Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
          pi@PhotoBooth1:~/TouchSelfie $ 
    • View the last 20 debug messages
      • dmesg | tail -n 20
        • Sample output
          This is a capture after the fix was applied.
          pi@PhotoBooth1:~/TouchSelfie $ dmesg | tail -n 20
          [ 25.607372] HCSPLT @0xFFFFFFC080F905C4 : 0x8001C183
          [ 25.607382] HCINT @0xFFFFFFC080F905C8 : 0x00000000
          [ 25.607393] HCINTMSK @0xFFFFFFC080F905CC : 0x00000000
          [ 25.607403] HCTSIZ @0xFFFFFFC080F905D0 : 0x00080008
          [ 25.607414] HCDMA @0xFFFFFFC080F905D4 : 0xC6718080
          [ 25.607424] Host Channel 7 Specific Registers
          [ 25.607432] HCCHAR @0xFFFFFFC080F905E0 : 0x01409808
          [ 25.607443] HCSPLT @0xFFFFFFC080F905E4 : 0x8001C183
          [ 25.607453] HCINT @0xFFFFFFC080F905E8 : 0x00000000
          [ 25.607463] HCINTMSK @0xFFFFFFC080F905EC : 0x00000000
          [ 25.607473] HCTSIZ @0xFFFFFFC080F905F0 : 0x00080008
          [ 25.607483] HCDMA @0xFFFFFFC080F905F4 : 0xC6718080

          [ 25.607598] WARN::dwc_otg_handle_mode_mismatch_intr:67: Mode Mismatch Interrupt: currently in Host mode

          [ 26.641294] cdc_acm 1-1.3:1.0: ttyACM0: USB ACM device
          [ 57.963631] pps_ldisc: PPS line discipline registered

          [ 117.664194] WARN::dwc_otg_hcd_handle_hc_fsm:2441: Unexpected IRQ state on FSM transaction:dev_addr=7 ep=2 fsm=7, hcint=0x00000022

          pi@PhotoBooth1:~/TouchSelfie $
        • If there are several WARN messages such as below, then you may be having the same issue with the USB dongle not working unless it is unplugged and reinserted.
          The output below is before the issue was resolved and includes unplugging and plugging in the USB GPS dongle.
          pi@PhotoBooth1:~ $ dmesg | tail -n 20
          [ 39.437463] WARN::dwc_otg_hcd_urb_dequeue:639: Timed out waiting for FSM NP transfer to complete on 6
          [ 39.437954] WARN::dwc_otg_hcd_urb_dequeue:639: Timed out waiting for FSM NP transfer to complete on 5
          [ 39.438512] WARN::dwc_otg_hcd_urb_dequeue:639: Timed out waiting for FSM NP transfer to complete on 0
          [ 39.439038] WARN::dwc_otg_hcd_urb_dequeue:639: Timed out waiting for FSM NP transfer to complete on 3
          [ 111.617441] WARN::dwc_otg_hcd_urb_dequeue:639: Timed out waiting for FSM NP transfer to complete on 5
          [ 111.617895] WARN::dwc_otg_hcd_urb_dequeue:639: Timed out waiting for FSM NP transfer to complete on 6
          [ 111.618337] WARN::dwc_otg_hcd_urb_dequeue:639: Timed out waiting for FSM NP transfer to complete on 1
          [ 138.125231] usb 1-1.3: USB disconnect, device number 6
          [ 142.162832] usb 1-1.3: new full-speed USB device number 7 using dwc_otg
          [ 142.253315] usb 1-1.3: New USB device found, idVendor=1546, idProduct=01a7, bcdDevice= 1.00
          [ 142.253362] usb 1-1.3: New USB device strings: Mfr=1, Product=2, SerialNumber=0
          [ 142.253379] usb 1-1.3: Product: u-blox 7 - GPS/GNSS Receiver
          [ 142.253393] usb 1-1.3: Manufacturer: u-blox AG - www.u-blox.com
          [ 142.255827] cdc_acm 1-1.3:1.0: ttyACM0: USB ACM device
          pi@PhotoBooth1:~ $
        • Check the status of the gps-init service we created.
          • sudo systemctl status gps-init.service
          • Sample output
            The error, “Failed to start gps-init.service – Configure u-blox GPS at Boot.” may be ignored as it is expected when the line “gpsctl -s -f /dev/ttyACM0” is not commented out in /home/pi/setup-gps.sh.
            pi@PhotoBooth1:~/TouchSelfie $ sudo systemctl status gps-init.service
            × gps-init.service - Configure u-blox GPS at Boot
            Loaded: loaded (/etc/systemd/system/gps-init.service; enabled; preset: enabled)
            Active: failed (Result: exit-code) since Sun 2025-06-29 14:21:24 EDT; 39min ago
            Process: 904 ExecStart=/home/pi/setup-gps.sh (code=exited, status=1/FAILURE)
            Main PID: 904 (code=exited, status=1/FAILURE)
            CPU: 15.794s

            Jun 29 14:21:14 PhotoBooth1 setup-gps.sh[1378]: inProtoMask (UBX NMEA RTCM2)
            Jun 29 14:21:14 PhotoBooth1 setup-gps.sh[1378]: outProtoMask (UBX NMEA)
            Jun 29 14:21:14 PhotoBooth1 setup-gps.sh[1378]: UBX-ACK-ACK:
            Jun 29 14:21:14 PhotoBooth1 setup-gps.sh[1378]: ACK to Class x06 (CFG) ID x00 (PRT)
            Jun 29 14:21:24 PhotoBooth1 setup-gps.sh[1383]: gpsctl:ERROR: timed out after 8 seconds
            Jun 29 14:21:24 PhotoBooth1 setup-gps.sh[1383]: gpsctl:ERROR: no DEVICES response received.
            Jun 29 14:21:24 PhotoBooth1 systemd[1]: gps-init.service: Main process exited, code=exited, status=1/FAILURE
            Jun 29 14:21:24 PhotoBooth1 systemd[1]: gps-init.service: Failed with result 'exit-code'.
            Jun 29 14:21:24 PhotoBooth1 systemd[1]: Failed to start gps-init.service - Configure u-blox GPS at Boot.
            Jun 29 14:21:24 PhotoBooth1 systemd[1]: gps-init.service: Consumed 15.794s CPU time.
            pi@PhotoBooth1:~/TouchSelfie $
    • Once cgps is showing that gpsd is working properly, telnet may be used to view the messages being sent by gpsd.
      • If telnet is not installed, install telnet with the following command.
        • sudo apt install telnet
      • telnet localhost 2947
        • Sample output
          pi@PhotoBooth1:~/TouchSelfie $ telnet localhost 2947
          Trying ::1...
          Connected to localhost.
          Escape character is '^]'.
          {"class":"VERSION","release":"3.22","rev":"3.22","proto_major":3,"proto_minor":14}
        • Issue the following command to view polling output.
          • ?POLL;
        • Issue the following command to see JSON output.
          • ?WATCH={“enable”:true,”json”:true}
        • Press <Ctrl>+] then type “quit” to exit the telnet session.
    • CAUTION: I did notice one time that the location reported by gpsd was off by 1,000 miles to the NW. Not certain what happened but I could not replicate it. I had let cgps run for about 10 minutes to see if gpsd would update with the correct location, but it did not.
    • Some other useful commands to see what is happening with USB and GPS on boot.
      • journalctl | grep -i gps
      • journalctl | grep -i usb
    • Additional commands to monitor GPS output while gpsd is running.
      • gpspipe -r
      • gpsmon