One of the first steps towards Raspberry Pi fun is installing a Raspberry Pi operating system image on an SD card and this project was no different. Initially it’s common to use a GUI tool like the recently announced Raspberry Pi Imager or Balena Etcher. You might think this is more a one-time setup step but I found this happening more often for a few reasons:

  1. Start fresh – Often I make temporary changes on the Pi while developing and debugging and want to start with a clean state to make sure I’m not relying on some manual change I’m likely to forget about later.
  2. Multiple Raspberry Pi devices – While I’m not building a Raspberry Pi Kubernetes Cluster (yet), I’ve certainly gone through more than one Pi device. They’re cheap and there are lots of project ideas and new models coming out.
  3. SD Card upgrade – Sometimes I might start with a small SD card and later realize I need more storage.
  4. SD Card failure – SD cards have a limited number of I/O operations and will eventually go bad, especially when opting for a cheap one.

As the need arose to repeatedly flash Pi SD cards, I wrote sd-card-write.sh to automate this on my Mac. Primary functions include:

  • Gathering external disk info
  • Downloading Raspbian Lite zip and extracting the image
  • Formatting the SD card
  • Copying the OS image to the SD card
  • Dealing with disk mounting, unmounting, and ejection
  • Configuring SSH and Wi-Fi
  • Copying helper scripts that setup the Pi and pull application images

Parameters

Currently the script just has 1 parameter for the final host name which is used to remove any prior ssh keys for the host and to set the host name in a setup script copied to the SD card.

#!/bin/sh

# ./sd-card-write.sh --host catsirenpi

# Final host name (not initial login)
host_name=""

while [[ $# -ge 1 ]]; do
    i="$1"
    case $i in
        -h|--host)
            host_name=$2
            shift
            ;;
        *)
            echo "Unrecognized option $1"
            exit 1
            ;;
    esac
    shift
done

if [ -z "$host_name" ]; then
  echo "Final Pi host name is required (-h | --host)" >&2
  exit 1
fi

Gathering Disk Info

First the script gathers some information on external disks in order to confirm the disk to format without relying on the user to supply the disk name. It stops if it doesn’t find 1 external disk, which suited my needs.

disk_name=$(diskutil list external | grep -o '^/dev\S*')
if [ -z "$disk_name" ]; then
    echo "Didn't find an external disk" ; exit -1
fi

matches=$(echo -n "$disk_name" | grep -c '^')
if [ $matches -ne 1 ]; then
    echo "Found ${matches} external disk(s); expected 1" ; exit -1
fi

disk_free=$(df -l -h | grep "$disk_name" | egrep -oi '(\s+/Volumes/.*)' | egrep -o '(/.*)')
if [ -z "$disk_free" ]; then
    echo "Disk ${disk_name} doesn't appear mounted. Try reinserting SD card" ; exit -1
fi

volume=$(echo "$disk_free" | sed -e 's/\/.*\///g')

# Spit out disk info for user confirmation
diskutil list external
echo $disk_free
echo

read -p "Format ${disk_name} (${volume}) (y/n)?" CONT
if [ "$CONT" = "n" ]; then
  exit -1
fi

The above gets the script execution to this point:

It goes without saying but be extra sure that the right disk is being formatted!

Download and Extraction

The next step is downloading the Raspbian Lite zip file and extracting the image from it.

image_path=./downloads
image_zip="$image_path/image.zip"
image_iso="$image_path/image.img"

# Consider checking latest ver/sha online, download only if newer
# https://downloads.raspberrypi.org/raspbian_lite/images/?C=M;O=D
# For now just delete any prior download zip to force downloading latest version
if [ ! -f $image_zip ]; then
  mkdir -p ./downloads
  echo "Downloading latest Raspbian lite image"
  # curl often gave "error 18 - transfer closed with outstanding read data remaining"
  wget -O $image_zip "https://downloads.raspberrypi.org/raspbian_lite_latest"

  if [ $? -ne 0 ]; then
    echo "Download failed" ; exit -1;
  fi
fi

echo "Extracting ${image_zip} ISO"
unzip -p $image_zip > $image_iso

if [ $? -ne 0 ]; then
    echo "Unzipping image ${image_zip} failed" ; exit -1;
fi

Flashing the Disk

The disk is then formatted and unmounted so the image can be copied to it.

echo "Formatting ${disk_name} as FAT32"
sudo diskutil eraseDisk FAT32 PI MBRFormat "$disk_name"

if [ $? -ne 0 ]; then
    echo "Formatting disk ${disk_name} failed" ; exit -1;
fi

echo "Unmounting ${disk_name} before writing image"
diskutil unmountdisk "$disk_name"

if [ $? -ne 0 ]; then
    echo "Unmounting disk ${disk_name} failed" ; exit -1;
fi

Data duplicator (dd) is used to copy the image to the SD card. This takes a while but pressing ctrl+t can be done at any time for progress. Depending upon the machine, the bs argument (I/O block size) might need adjusting from the 1 MB value used here.

echo "Copying ${image_iso} to ${disk_name}. ctrl+t as desired for status"
sudo dd bs=1m if="$image_iso" of="$disk_name" conv=sync

if [ $? -ne 0 ]; then
  echo "Copying ${image_iso} to ${disk_name} failed" ; exit -1
fi

Configuring SSH and Wi-Fi

After the image is written the script remounts the drive to make some modifications – namely writing files to enable SSH and Wi-Fi and to copy over some helper scripts. It also does some cleanup by deleting the image file; currently it leaves the zip to avoid downloading again later – it’s easy enough to just delete the zip to force a new download if a later version is published.

The loop and sleep commands are a failsafe as sometimes there are timing issues from the copy and the disk is not yet ready for mounting again.

# Remount for further SD card mods. Drive may not be quite ready.
attempt=0
until [ $attempt -ge 3 ]
do
  sleep 2s
  echo "Remounting ${disk_name}"
  diskutil mountDisk "$disk_name" && break
  attempt=$[$attempt+1]
done

echo "Removing ${image_iso}. Re-extract later if needed from ${image_zip}"
rm $image_iso

Enabling SSH is as simple as writing a ssh file to the SD card root.

volume="/Volumes/boot"

echo "Enabling ssh"
touch "$volume"/ssh

if [ $? -ne 0 ]; then
  echo "Configuring ssh failed" ; exit -1
fi

I didn’t want to use a wired connection or plug in a monitor so the script determines the current host WiFi network name (SSID) and then prompts for the password. Afterwards it writes the Wi-Fi credentials to wpa_supplicant.conf on the SD card root.

echo "Configuring Wi-Fi"
wifi_ssid=$(/System/Library/PrivateFrameworks/Apple80211.framework/Resources/airport -I | awk -F: '/ SSID/{print $2}')
wifi_ssid=`echo $wifi_ssid | sed 's/^ *//g'` # trim

echo "Wi-Fi password for ${wifi_ssid}:" 
read -s wifi_pwd

cat >"$volume"/wpa_supplicant.conf <<EOL
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
update_config=1
country=US

network={
	ssid="${wifi_ssid}"
	psk="${wifi_pwd}"
}
EOL

if [ $? -ne 0 ]; then
  echo "Configuring wifi failed" ; exit -1
fi

Copying Scripts and Disk Ejection

After the configuration, the script copies previously written script files that help further setup the Pi by installing apps and updates, setting configuration, and pulling application Docker images (more on these scripts in upcoming posts). After the copy operations the disk is ejected.

echo "Copying setup script. After Pi boot, run: sudo /boot/setup.sh"
cp setup.sh "$volume"

echo "Modifying setup script"
# Replace "${host}" placeholder in the setup script on SD card with final host name passed to script
sed -i -e "s/\${host}/${host_name}/" "$volume/setup.sh"

echo "Copying docker pull script for app updates"
cp pull.sh "$volume"

echo "Image burned. Remove SD card, insert in PI and power on"
sudo diskutil eject "$disk_name"

SSH Prep

Finally the script uses ssh-keygen to remove any prior keys to the target Pi host. Otherwise there’d be issues connecting to the same device that’s been re-flashed.

echo "Removing any prior PI SSH known hosts entry"
ssh-keygen -R raspberrypi.local # initial
ssh-keygen -R "$host_name.local"

echo "Power up the PI and give it a minute then"
echo "  ssh pi@raspberrypi.local"
echo "  yes, raspberry"
echo "  sudo /boot/setup.sh"

The initial ssh might generate Connection refused until the Pi is fully ready, which might be up to a minute or so after powering on. A monitor has the advantage of seeing what’s happening here but that hookup isn’t worth the hassle for me. If the ssh hangs, the Wi-Fi configuration details may be incorrect.

Source

The full script can be found here: sd-card-write.sh.

Other Automation Options

Another option of interest is using pi-gen to create pre-configured Pi images. It has a number of configuration options like setting the default username and password, locale info, Wi-Fi and SSH details, host name and more. I ended up doing that later on here. See Using Pi-Gen to Build a Custom Raspbian Lite Image for more on this approach.

Up Next

Automating Raspberry Pi Setup – The next post in this series covers automating Raspberry Pi system configuration and installing applications.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.