4
\$\begingroup\$

Revised from: Bash scripts and udev rules to handle USB auto mounting / unmounting

Tested:
Uses USB insert/remove to control a headless Raspberry Pi 3 with Raspian Jessie Lite

Changes:

  • Implement more functions
  • Improve comments
  • Modify if-fi exit handling
  • Add optional flag for auto processing on insert/remove
  • Optional function for USB insert - copy file (future: read config/start streaming process)
  • Optional auto shutdown for USB removal (future: close process started by insert)

Goals:

  • Improve bash coding and learn by implementing suggestions
  • Improve my understanding of if-fi blocks and using inline commands (I'm not confident at all when it comes to streamlining code)

Current Code:
Uses udev rules to automount USB and create folder for device. Optionally can use an init constant to cause the automount/dismount process to auto start a process (in this case copy a config file) and shutdown pi on removal.

Work Flow:

uDev rules 
   usb-initloader.sh   
      Insert -> usb-automount.sh
      Remove -> usb-unloader.sh

udev rules

# /etc/udev/rules.d/85-usb-loader.rules
# ADD rule: 
#       if  USB inserted, 
#       and flash drive loaded as sd#
#           pass on dev name and device formatting type
#       run short script to fork another processing script
#       run script to initiate another script (first script must finish quickly)
#             to mkdir and mount, process file
#
# reload rules on PI by: 
#                        sudo udevadm control --reload-rules
#
ACTION=="add", KERNEL=="sd*[0-9]", SUBSYSTEMS=="usb", RUN+="/home/pi/scripts/usb-initloader.sh ADD %k $env{ID_FS_TYPE}"
ACTION=="remove", KERNEL=="sd*[0-9]", SUBSYSTEMS=="usb", RUN+="/home/pi/scripts/usb-initloader.sh %k"

usb-initloader.sh

#!/bin/bash
#
# /home/pi/scripts/usb-initloader.sh
#
# this script uses udev rules 
# is initiated when usb device is inserted or removed
#
# ** DEVICE INSERTED - new USB device inserted **
# ---------------------------------------------
# should be called from a udev rule like:that passes 
#   1. "ADD", 
#   2. kernel device  (%k)
#   3. filesystem type $env(ID_FS_TYPE)
#
# ACTION=="add", KERNEL=="sd*[0-9]", SUBSYSTEMS=="usb", RUN+="/home/pi/scripts/usb-initloader.sh ADD %k $env(ID_FS_TYPE)"
#
# Mounts usb device on /media/<dev>
# Logs changes to /var/log/syslog
# use tail /var/log/syslog to look at latest events in log
#
# ** DEVICE REMOVED - USB device removed **
# ---------------------------------------------
# on remove - we only need the kernel device (%k)
# should be called from a udev rule like:
#
# ACTION=="remove", KERNEL=="sd*[0-9]", SUBSYSTEMS=="usb", RUN+="/home/pi/scripts/usb-initloader.sh %k"
#
# CONFIGURATION
#
# Location of the three scripts (** MUST match your udev rules **)
SCRIPT_DIR=/home/pi/scripts
#
# Location of Log File
LOG_DIR=/home/pi/logs
LOG_FILE="${LOG_DIR}/usb-automount.log"
#
# Mount folder (sda1 will be added underneath this)
MOUNT_DIR=/media
#
# Optional parameter to:
#   - auto start a program on ADD
#   - auto end program and shutdown pi on REMOVE
#
AUTO_START_FINISH=1 # Set to 0 if false; 1 if true

#
# Call speciality script and leave this one (with trailing "&")
#
if [ "$1" == "ADD" ]; then
    DEVICE="$2"    # USB device name (kernel passed from udev rule)
    DEVTYPE="$3"   # USB device formatting type
    echo "==> Adding USB Device $DEVICE" >> "$LOG_FILE"
    ${SCRIPT_DIR}/usb-automount.sh "$LOG_FILE" "$MOUNT_DIR" "$DEVICE" "$DEVTYPE" "$AUTO_START_FINISH" >> "$LOG_FILE" 2>&1&
else
    DEVICE="$1"    # USB device name (kernel passed from udev rule)
    echo "==> Unmounting USB Device $DEVICE" >> "$LOG_FILE"
    ${SCRIPT_DIR}/usb-unloader.sh "$LOG_FILE" "$MOUNT_DIR" "$DEVICE" "$AUTO_START_FINISH" >> "$LOG_FILE" 2>&1&
fi

usb-automount.sh

#!/bin/bash
#
# Script: /home/pi/scripts/usb-automount.sh
# make sure to chmod 0755 on this script
#
# USAGE: usb-automount.sh DEVICE FILESYSTEM
#   LOG_FILE    is the error/activity log file for shell (eg /home/pi/logs/usbloader.log)
#   MOUNT_DIR   is the full mount folder for device (/media/sda1)
#   DEVICE      is the actual device node at /dev/DEVICE (returned by udev rules %k parameter) (eg sda1)
#   FILESYSTEM  is the FileSystem type returned by rules (returned by udev rules %E{ID_FS_TYPE} or $env{ID_FS_TYPE} (eg vfat)
#
# In case the process of mounting takes too long for udev
# we call this script from /home/pi/scripts/usb-initloader.sh
# then fork out to speciality scripts
#
# Adapted for Raspberry Pi - Raspbian O/S
# from previous code found at:
# http://superuser.com/questions/53978/automatically-mount-external-drives-to-media-label-on-boot-without-a-user-logge
# and mount manager example at
# http://solvedforhome.com/?p=2806&v=3a52f3c22ed6
#
# Edited with many suggestions from @janos (https://codereview.stackexchange.com/users/12390/janos)
#
# Acknowledgement to:
#   http://www.shellcheck.net/
#   dostounix utility

LOG_FILE="$1"
MOUNT_DIR="$2"
DEVICE="$3"  # USB device name (from kernel parameter passed from rule)
FILESYSTEM="$4"
AUTO_START="$5" # Do we want to auto-start a new process? 0 - No; 1 - Yes

# check for defined log file
if [ -z "$LOG_FILE" ]; then
    exit 1
fi

# Define all parameters needed to auto-start program
if [ "$AUTO_START" == "1" ]; then
    CONFIG_FILE="autostart-settings.cfg" # Check for this file on USB to initiate program with user-defined settings
    STARTUP_FOLDER="/home/pi/autostart"  # Start up folder to copy settings file into
fi

# Functions:
# #########
#
# automount function to test/make/mount USB device 
#
automount() {

    dt=$(date '+%Y-%m-%d/ %H:%M:%S')
    echo "--- USB Auto Mount --- $dt"

    #check input parameters
    if [ -z "$MOUNT_DIR" ]; then
        echo "Missing Parameter: MOUNT_DIR"
        exit 1
    fi
    if [ -z "$DEVICE" ]; then
        echo "Missing Parameter: DEVICE"
        exit 1
    fi
    if [ -z "$FILESYSTEM" ]; then
        echo "Missing Parameter: FILESYSTEM"
        exit 1
    fi

    # Allow time for device to be added
    sleep 2

    # test for this device is already mounted
    device_mounted=$(grep "$DEVICE" /etc/mtab)
    if [ "$device_mounted" ]; then
        echo "Error: seems /dev/$DEVICE is already mounted"
        exit 1
    fi

    # test mountpoint - it shouldn't exist
    if [ -e "$MOUNT_DIR/$DEVICE" ]; then
        echo "Error: seems mountpoint $MOUNT_DIR/$DEVICE already exists"
        exit 1
    fi

    # make the mountpoint
    sudo mkdir "$MOUNT_DIR/$DEVICE"

    # make sure the pi user owns this folder
    sudo chown -R pi:pi "$MOUNT_DIR/$DEVICE"

    # mount the device base on USB file system
    case "$FILESYSTEM" in

        # most common file system for USB sticks
        vfat)  sudo mount -t vfat -o utf8,uid=pi,gid=pi "/dev/$DEVICE" "$MOUNT_DIR/$DEVICE"
              ;;

        # use locale setting for ntfs
        ntfs)  sudo mount -t auto -o uid=pi,gid=pi,locale=en_US.UTF-8 "/dev/$DEVICE" "$MOUNT_DIR/$DEVICE"
              ;;

        # ext2/3/4 do not like uid option
        ext*)  sudo mount -t auto -o sync,noatime "/dev/$DEVICE" "$MOUNT_DIR/$DEVICE"
              ;;
    esac

    device_mounted=$(grep "$DEVICE" /etc/mtab)
    if [ "$device_mounted" == "" ]; then
        echo "Error: Failed to Mount $MOUNT_DIR/$DEVICE"
        exit 1
    fi

    echo "SUCCESS: /dev/$DEVICE successfully mounted as $MOUNT_DIR/$DEVICE"
}

# Auto Start Funmction
autostart() {

    echo "--- USB Auto Start Program ---"

    look_for_cfg="$MOUNT_DIR/$DEVICE/$CONFIG_FILE"
    if [ -e "$look_for_cfg" ]; then
        echo "Copying Startup File to $STARTUP_FOLDER"
        cp -u  -p "$look_for_cfg" "$STARTUP_FOLDER"
    fi
}

automount >> "$LOG_FILE" 2>&1

if [ "$AUTO_START" == "1" ]; then
    autostart >> "$LOG_FILE" 2>&1
fi

usb-unloader.sh

#!/bin/bash
# /home/pi/scripts/usb-unloader.sh
#
# Called from {SCRIPT_DIR}/usb-initloader.sh
#
# USAGE: usb-automount.sh DEVICE FILESYSTEM
#   LOG_FILE    is the error/activity log file for shell (eg /home/pi/logs/usbloader.log)
#   MOUNT_DIR   is the full mount folder for device (/media)
#   DEVICE      is the actual device node at /dev/DEVICE (returned by udev rules %k parameter) (eg sda1)
#
# UnMounts usb device on /media/<device>
# Logs changes to /var/log/syslog and local log folder
# use tail /var/log/syslog to look at latest events in log
#
# SUPPLIED PARAMETERS
#####################
LOG_FILE="$1"
MOUNT_DIR="$2"
DEVICE="$3"  # USB device name (from kernel parameter passed from rule)
AUTO_END="$4"  # Set to 0 if not wanting to shutdown pi, 1 otherwise
#
# check for defined log file
if [ -z "$LOG_FILE" ]; then
    exit 1
fi

#
# FUNCTIONS
###########
#
# autounload function to unmount USB device and remove mount folder
#
autounload() {

    dt=$(date '+%Y-%m-%d %H:%M:%S')
    echo "--- USB UnLoader --- $dt"

    if [ -z "$MOUNT_DIR" ]; then
        echo "Failed to supply Mount Dir parameter"
        exit 1
    fi
    if [ -z "$DEVICE" ]; then
        echo "Failed to supply DEVICE parameter"
        exit 1
    fi

    # Unmount device
    sudo umount "/dev/$DEVICE"

    # Wait for a second to make sure async  umount has finished
    sleep 1

    # Remove folder after unmount
    sudo rmdir "$MOUNT_DIR/$DEVICE"

    # test that this device has disappeared from mounted devices
    device_mounted=$(grep "$DEVICE" /etc/mtab)
    if [ "$device_mounted" ]; then
        echo "/dev/$DEVICE failed to Un-Mount"
        exit 1
    fi

    echo "/dev/$DEVICE successfully Un-Mounted"

}

autounload >> "$LOG_FILE" 2>&1

# kill auto-start process and shutdown
if [[ "$AUTO_END" == "1" ]]; then
    sudo shutdown -H 0
fi
\$\endgroup\$
3
\$\begingroup\$

Introduce a helper function: is_mounted

As I suggested in the previous review, I recommend to replace this code:

device_mounted=$(grep "$DEVICE" /etc/mtab)
if [ "$device_mounted" ]; then
    echo "Error: seems /dev/$DEVICE is already mounted"
    exit 1
fi

# ...

device_mounted=$(grep "$DEVICE" /etc/mtab)
if [ "$device_mounted" == "" ]; then
    echo "Error: Failed to Mount $MOUNT_DIR/$DEVICE"
    exit 1
fi

With this:

if is_mounted "$DEVICE"; then
    echo "Error: seems /dev/$DEVICE is already mounted"
    exit 1
fi

# ...

if ! is_mounted "$DEVICE"; then
    echo "Error: Failed to Mount $MOUNT_DIR/$DEVICE"
    exit 1
fi

Where the implementation of is_mounted:

is_mounted() {
    grep -q "$1" /etc/mtab
}

It's shorter and actually quite intuitive.

Introduce a helper function: fatal

Another repeating pattern I see is the check-then-exit combos, like this:

if some_requirement_fails; then
    echo "Error: Failed some_requirement"
    exit 1
fi

You could create a helper function to make repeated usages slightly easier:

fatal() {
    echo "Error: $*"
    exit 1
}

if some_requirement_fails; then
    fatal "Failed some_requirement"
fi

Actually, this form opens up the possibility for a more compact syntax:

some_requirement || fatal "Failed some_requirement"

With this and the earlier suggestions, automount could be written like this:

automount() {

    dt=$(date '+%Y-%m-%d/ %H:%M:%S')
    echo "--- USB Auto Mount --- $dt"

    # check input parameters
    [ "$MOUNT_DIR" ] || fatal "Missing Parameter: MOUNT_DIR"
    [ "$DEVICE" ] || fatal "Missing Parameter: DEVICE"
    [ "$FILESYSTEM" ] || fatal "Missing Parameter: FILESYSTEM"

    # Allow time for device to be added
    sleep 2

    is_mounted "$DEVICE" && fatal "seems /dev/$DEVICE is already mounted"

    # test mountpoint - it shouldn't exist
    [ -e "$MOUNT_DIR/$DEVICE" ] && fatal "seems mountpoint $MOUNT_DIR/$DEVICE already exists"

    # make the mountpoint
    sudo mkdir "$MOUNT_DIR/$DEVICE"

    # make sure the pi user owns this folder
    sudo chown -R pi:pi "$MOUNT_DIR/$DEVICE"

    # mount the device base on USB file system
    case "$FILESYSTEM" in

        # most common file system for USB sticks
        vfat)  sudo mount -t vfat -o utf8,uid=pi,gid=pi "/dev/$DEVICE" "$MOUNT_DIR/$DEVICE"
              ;;

        # use locale setting for ntfs
        ntfs)  sudo mount -t auto -o uid=pi,gid=pi,locale=en_US.UTF-8 "/dev/$DEVICE" "$MOUNT_DIR/$DEVICE"
              ;;

        # ext2/3/4 do not like uid option
        ext*)  sudo mount -t auto -o sync,noatime "/dev/$DEVICE" "$MOUNT_DIR/$DEVICE"
              ;;
    esac

    is_mounted "$DEVICE" || fatal "Failed to Mount $MOUNT_DIR/$DEVICE"

    echo "SUCCESS: /dev/$DEVICE successfully mounted as $MOUNT_DIR/$DEVICE"
}

Notice that the calls to fatal can be chained using || or &&, depending on whether the requirement checked should be true or false, respectively.

If you're not comfortable yet with chaining commands with || and &&, you can stick to the if-fi syntax, there's nothing wrong with that.

Explanation about exit codes, grep -q, if, && and ||

You mentioned in comment that the grep -q part is not exactly easy to understand, so here's a bit more explanation, I hope it helps.

grep exits with exit code 0 if there was a match, and some non-zero exit code if there was no match. For example:

$ echo hello | grep e
hello
$ echo $?
0
$ echo hello | grep x
$ echo $?
1

The $? variable stores the exit code of the last command.

We can build conditions using the exit codes of commands, for example:

$ if echo hello | grep e; then echo success; else echo failure; fi
hello
success
$ if echo hello | grep x; then echo success; else echo failure; fi
failure

Notice that in case of success, the matched pattern is printed. Of course. That's why we normally use grep, to find matching lines. If we don't care about the matching line, if we just want to know if there was a matching line or not, then we can suppress the output using the -q flag. Rerunning the above using the -q flag:

$ if echo hello | grep -q e; then echo success; else echo failure; fi
success
$ if echo hello | grep -q x; then echo success; else echo failure; fi
failure

Notice the difference from earlier: no more "hello" line, the matched pattern was not printed.

Lastly, the same example using && and || instead of if statement:

$ echo hello | grep -q e && echo success || echo failure
success
$ echo hello | grep -q x && echo success || echo failure
failure

Slightly more compact, but equivalent solution. But this is by no means a preferred syntax. It's "ok" to use this syntax when the condition is simple and easy to understand. It's not well-suited and can get very confusing with more complex conditions, and then it's not recommended.

\$\endgroup\$
  • 1
    \$\begingroup\$ Thanks again - and especially clarifying The $? variable stores the exit code of the last command. - and that grep returns 0 on success. Both of those simple statements help a lot in understanding the code. There's so much to learn and massaging code found on Internet often works but doesn't help with understanding. This site has been amazing. I can share my knowledge of PC dbs and vb coding - and learn from others on the Linux side. Soaking it all in! \$\endgroup\$ – dbmitch Jul 8 '16 at 20:35
  • \$\begingroup\$ Just read things over and Ooh - I really like your fatal suggestion \$\endgroup\$ – dbmitch Jul 9 '16 at 3:07
  • \$\begingroup\$ Great, glad I could help! \$\endgroup\$ – janos Jul 9 '16 at 5:50
  • \$\begingroup\$ Just a quick update - I had NO problems whatever implementing your suggestions and the code looks so much more streamlined than before. I'm going to try and emulate that style in my other programs coming up. \$\endgroup\$ – dbmitch Jul 9 '16 at 16:03

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.