[Guide] Automatically disable USB devices for battery savings

TL;DR: Run the script below to disable/enable USB expansion cards. When disabled, the expansion bays are plugged in but not drawing (as much) power.

I only have the microSD one and would appreciate help testing this to see how it works for the HDMI and USB-A expansion cards. /usr/bin/turn_usb --vendor-id=090c --product-id=3350 --driver-type='usb-storage' off

As noted in the linux battery life tuning thread, having anything besides the USB-C ports plugged into the expansion bays causes excess power draw. In my case, I have the microSD card, and it costs me about 1 Watt. From my investigation, the primary reason for the power draw is that it prevents the CPU from reaching the C10 sleep state, instead being stuck in C9.

I donā€™t use the SD slot very often, so I wanted to find a way to keep it plugged in, but disabled by default to lower the power draw. That sent me down the rabbit hole to find a programmatic solution to keep the expansion bay physically installed, but disable the driver for it.

I ended up creating the below script where I can call turn_usb --vendor-id=foo --product-id bar offto achieve exactly this! Itā€™s approximately the same as unplugging the USB device, but itā€™s still physically attached. Running powertop shows me savings of ~1 watt when I run the script, (and my device enters into C10).

If I want to use the device, I can just run turn_usb on to undo the changes.

DISCLAIMER

This is a bash script that needs to run as root. Bad things could happen. For example, maybe thereā€™s a bug and it unmounts your primary hard drive instead. Please note that this has the same effects as unplugging the USB device, so youā€™ll want to unmount any drives before trying the script. No support/warranties etc. Please donā€™t run the script if you donā€™t understand how it works and are willing to troubleshoot

Help needed

I only have access to the microSD card. Could folks try this with other expansion cards and:

  • report back the vendor-id, product-id, and driver-type args used
  • Try turning the port off, seeing if the power draw goes down
  • Keep the port off, then plug something in (hdmi cable, USB-A thingy) etc, and report back what happens
  • Unplug what you plugged in (just for safety)
  • Run the script to turn the port on, see if you can re-connect your accessory

If things donā€™t work, I would appreciate a lsusb -tv dump and the dump of the relevant uevent files (see the script)

Installation

  • Open a terminal, and elevate to root permissions
  • Navigate to /usr/bin/ and run: touch ./turn_usb
  • Give the file executable permissions by anyone: chmod 755 ./turn_usb
  • Open the file in your favorite text editor and paste in the contents of the script below
  • run lsusb to determine the vendor id and product id of what USB device you want. For example, my lsusb output has this partial snippet:

Bus 002 Device 002: ID 090c:3350 Silicon Motion, Inc. - Taiwan (formerly Feiya Technology Corp.) USB DISK
Bus 002 Device 001: ID 1d6b:0003 Linux Foundation 3.0 root hub
Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub

And so for the microSD card, the vendor id is 090c, and the product id is 3350

  • Run the script. In my case itā€™s turn_usb --vendor-id=090c --product-id=3350 off
  • If it doesnā€™t work, you may need to figure out what type of device it is. Run the following command: grep -iFl "PRODUCT=${short_vendor_id}/${short_product_id}" /sys/bus/usb/drivers/**/*/uevent where short_vendor_id and short_product_id are the values from above, but just with any leading zeroā€™s removed. That will print out a bunch of matching files (and some permission denied errors). Once you find the correct uevent file, that will tell you the driver type. For example, running grep -iFl "PRODUCT=90c/3350" /sys/bus/usb/drivers/**/*/uevent on my machine returns the file /sys/bus/usb/drivers/usb-storage/2-2:1.0/uevent

Once you have this file, then the driver-type is the folder name after /sys/bus/usb/drivers. For example, in the above case that would be usb-storage

And thatā€™s about it!

Run every boot

The state resets every time you turn off your machine, so I made a small service to run my command once every boot. Youā€™ll need to modify it to suit your desires

[Unit]
Description=Disable microsd

[Service]
Type=oneshot
ExecStart=/usr/bin/turn_usb --vendor-id=090c --product-id=3350 --driver-type='usb-storage' off
Restart=no

[Install]
WantedBy=multi-user.target

The script

Version 1.0, last modified July 19, 2022

#! /usr/bin/env bash

usage() {
  echo "
  Bind/unbind a USB device from a given vendor:product id
  Usage: $(basename "${BASH_SOURCE[0]}") [-hd] [--driver-type] --device-id=123 --product-id=abc on|off
  " >&2
  exit 1
}

# Variables used in the script

## Command line arguments
driver_type="usb-storage" # The sub-folder in /sys/bus/usb/drivers/ which is responsible for the usb device
dry_run= # If set to 1, print the message but don't actually bind/unbind
# vendor_id and product_id combine to identify the USB device requested to modify
# Users can use lsusb to figure out the parameters: https://wiki.debian.org/HowToIdentifyADevice/USB
vendor_id=
product_id=
usb_command= # set to either "bind" or "unbind"


## local variables
TEMP= # Used for parsing the command line arguments. Copy/pasta from the getopt template
short_vendor_id= # same as vendor_id, but with leading 0's stripped off
short_product_id= # same as vendor_id, but with leading 0's stripped off
usb_id= # The bus/port/? identifier. The main work of the script is to take the vendor/product id
# and figure out which usb_id corresponds to this device for this particular boot.
part_paths= # Split up a path (e.g. /sys/bus/foo) into an array (sys bus foo)
new_usb_id= # Temporary variable to see if this value is longer than the existing $usb_id. If so, replace usb_id with this value


# Step 1: Parse command line arguments into appropriate variables
TEMP=$(getopt -o 'dh' --longoptions 'dry-run,help,vendor-id:,product-id:,driver-type:' -n "$0" -- "$@")

if [ $? -ne 0 ]; then
    usage
fi

eval set -- "$TEMP"
unset TEMP

while true; do
	case "$1" in
    '-h'|'--help')
      usage
			shift 1
			continue
		;;
    '-d'|'--dry-run')
      dry_run=1
			shift 1
			continue
		;;
		'--vendor-id')
      vendor_id="$2"
			shift 2
			continue
		;;
		'--product-id')
      product_id="$2"
			shift 2
			continue
		;;
    '--driver-type')
      driver_type="$2"
			shift 2
			continue
		;;
		'--')
			shift
			break
		;;
		*)
      usage
		;;
	esac
done

if [ "$#" -ne 1 ]; then
 usage
fi

if [ "$1" == "on" ]; then
  usb_command="bind"
elif [ "$1" == "off" ]; then
  usb_command="unbind"
else
  echo "missing on/off" >&2
  exit 1
fi


if [ -z "$vendor_id" ]; then
  echo "missing vendor-id" >&2
  exit 1
fi

if [ -z "$product_id" ]; then
  echo "missing product-id" >&2
  exit 1
fi

# Strip leading zeros from the vendor:product id
short_vendor_id=$(printf "%X" "0x${vendor_id}")
short_product_id=$(printf "%X" "0x${product_id}")

# Step 2: Figure out the usb id for the device
# For both bound and unbound devices, the device will have at least one entry
# in /sys/bus/usb/devices/${usb_id}/uevent
# So, we begin by searching through all these files for the ones matching our USB device
# This will probably match multiple times. E.g. the device might be registered
# on /usb/2-2 and usb-storage/2-2:1-0
# We want to unbind at the lowest place in the tree, so loop through each match
# and choose the one with the longest usb_id
while IFS= read -r line; do
  path_parts=($(echo "$line" | cut -d "/"  --output-delimiter=" " -f 1-))
  new_usb_id="${path_parts[-2]}"
  if [ "${#path_parts[@]}" -lt 3 ]; then
    # Usually this happens if you try to bind/unbind/rebind too quickly
    continue
  fi
  new_usb_id="${path_parts[-2]}"
  
  if [ "${#new_usb_id}" -gt "${#usb_id}" ]; then
    usb_id=${new_usb_id}
    # We can detect the driver type automatically when unbinding via the uevent file, but not when binding
    # For consistency's sake, just make --driver-type a command line argument and require the user to set it
    # new_driver_type=$(awk '{ match($0, /DRIVER=(.*)/, arr); if (arr[1] != "") print arr[1] }' "$line")
    # =${new_driver_type:=usb-storage}
  fi
done <<< $(grep -iFl "PRODUCT=${short_vendor_id}/${short_product_id}" /sys/bus/usb/devices/*/uevent)

if [ -z "$usb_id" ]; then
  echo "could not calculate the usb-id" >&2
  exit 1
fi

# Step 3: Check to see if the device has already been bound/unbound and just no-op if there's nothing to do
if [ -d "/sys/bus/usb/drivers/${driver_type}/${usb_id}" ]; then 
  if [ "$usb_command" == "bind" ]; then
    echo "The device is already bound. Nothing to do"
    exit 0
  fi
elif [ "$usb_command" == "unbind" ]; then
  echo "The device is already unbound. Nothing to do"
  exit 0
fi

# Step 4; Bind/unbind the USB device by writing the usb_id to the correct bind or unbind file in sys/bus/usb
echo "Calling ${usb_command} for ${driver_type}/${usb_id}"

if [ -z "$dry_run" ]; then
  echo -n "$usb_id" | sudo tee /sys/bus/usb/drivers/${driver_type}/${usb_command} > /dev/null
fi
11 Likes

Can someone come up with a GUI for toggling the ports?

1 Like

@Anil_Kulkarni Havenā€™t tried out your script yet but the VENDORID:PRODUCTID for my USB-A and HDMI expansion cards are 27c6:609c and 32ac:0002. The USB-A expansion card uses the ā€˜usbā€™ driver-type. Iā€™m not entirely sure how Iā€™d go about identifying the driver used for the HDMI card :frowning:

If you uncomment out this part of the script it should find it for you: (maybe add an echo $new_driver_type to see)

 # new_driver_type=$(awk '{ match($0, /DRIVER=(.*)/, arr); if (arr[1] != "") print arr[1] }' "$line")

Thanks for the good work, I might turn this into a gnome extension, if we can make it reliable for all expansion cards.

I tested this with my 250gb expansion card (vendor-id: 13fe product-id: 6500). However, it failed with the generated driver_type and usb_id. Iā€™ll provide values that worked for me below. (I had to change usb_id manually in the script.

generated values: driver_type=ā€œusb-storageā€, usb_id=ā€œ2-2:1.0ā€
functioning values: driver_type=ā€œusbā€, usb_id=ā€œ2-2ā€

Maybe we could try both versions for usb_id in the script? I tried modifying it a bit, but Iā€™m running into errors I donā€™t understand (Iā€™m not very good at shell scripts).

Probably the easiest way is just to hardcode the vendor_id/product_id/storage type for each of the expansion cards. There arenā€™t that many of them and they all ought to have the same values. Then you only need steps 3 and 4 from the script above, which should be pretty straightforward. (Or use whatever other programming language)

1 Like

Iā€™ve changed the script and hard-coded some devices in the beginning. Moreover, Iā€™m using the shortest USB IDs, as longer ones didnā€™t work for me (Fedora 37, Kernel 6.1.11). You can use the script with a specified device (like --hdmi) to bind/unbind the HDMI expansion card. Iā€™m not sure what happens if you got more than one expansion card of the same type.

Iā€™ve executed a quick (kinda unscientific) test with powertop, looking at the discharge rate for some time. My baseline is 2.3-2.5W, TLP in battery mode and no usba, no hdmi expansion cards.
USB-A: Plugging USB-A makes little difference - maybe 2.4-2.5W. Unbinding the device seems to make no difference (maybe about 50mW less, might be within the error range). Aligns well with USB-A tests by anarcat
HDMI: Plugging HDMI increases the discharge rate by about 500mW (2.9-3.0W). Unbinding the device seems to make no difference. Manually activating powertops energy device option for HDMI seems to make no difference. Aligns well with HDMI tests by anarcat

anarcat got some more energy usage statistics around. See above links.

So overall, unbinding USB-A and HDMI expansion cards seem to make no difference in power usage. Unplugging at least the HDMI card makes a significant difference (on my distro & kernel).

This script is still WIP. Use at your own risk!

#! /usr/bin/env bash

usage() {
  echo "
  Bind/unbind a USB device from a given vendor:product id
  Usage: $(basename "${BASH_SOURCE[0]}") [-hd] [--hdmi|microsd|usba|ssd250] on|off
  " >&2
  exit 1
}

# hard coded framework modules
# (driver device-id vendor-id) from lsusb and sudo grep -iFl "PRODUCT=DEVICE-ID" /sys/bus/usb/drivers/**/*/uevent
hdmi=(usb 32ac 0002)
microsd=(usb-storage 090c 3350)
usba=(usb 27c6 609c)
ssd250=(usb 13fe 6500)

# Variables used in the script

## Command line arguments
driver_type= # The sub-folder in /sys/bus/usb/drivers/ which is responsible for the usb device
dry_run= # If set to 1, print the message but don't actually bind/unbind
# vendor_id and product_id combine to identify the USB device requested to modify
# Users can use lsusb to figure out the parameters: https://wiki.debian.org/HowToIdentifyADevice/USB
vendor_id=
product_id=
usb_command= # set to either "bind" or "unbind"

## local variables
TEMP= # Used for parsing the command line arguments. Copy/pasta from the getopt template
short_vendor_id= # same as vendor_id, but with leading 0's stripped off
short_product_id= # same as vendor_id, but with leading 0's stripped off
usb_id= # The bus/port/? identifier. The main work of the script is to take the vendor/product id and figure out which usb_id corresponds to this device for this particular boot.

# Step 1: Parse command line arguments into appropriate variables
TEMP=$(getopt -o 'dh' --longoptions 'dry-run,help,hdmi,microsd,usba,ssd250' -n "$0" -- "$@")

if [ $? -ne 0 ]; then
    usage
fi

eval set -- "$TEMP"
unset TEMP

while true; do
	case "$1" in
    '-h'|'--help')
      usage
			shift 1
			continue
		;;
    '-d'|'--dry-run')
      dry_run=1
			shift 1
			continue
		;;
		'--hdmi')
			driver_type="${hdmi[0]}"
      vendor_id="${hdmi[1]}"
      product_id="${hdmi[2]}"
			shift 1
			continue
		;;
		'--microsd')
      driver_type="${microsd[0]}"
      vendor_id="${microsd[1]}"
      product_id="${microsd[2]}"
			shift 1
			continue
		;;
    '--usba')
      driver_type="${usba[0]}"
      vendor_id="${usba[1]}"
      product_id="${usba[2]}"
			shift 1
			continue
		;;
		'--ssd250')
	    driver_type="${ssd250[0]}"
      vendor_id="${ssd250[1]}"
      product_id="${ssd250[2]}"
			shift 1
			continue
		;;
		'--')
			shift
			break
		;;
		*)
      usage
		;;
	esac
done

if [ "$#" -ne 1 ]; then
 usage
fi

if [ "$1" == "on" ]; then
  usb_command="bind"
elif [ "$1" == "off" ]; then
  usb_command="unbind"
else
  echo "missing on/off" >&2
  exit 1
fi


if [ -z "$vendor_id" ]; then # we only check for vendor_id, as this is only set when a device is specified
  echo "missing device" >&2
  exit 1
fi

# Strip leading zeros from the vendor:product id
short_vendor_id=$(printf "%X" "0x${vendor_id}")
short_product_id=$(printf "%X" "0x${product_id}")

# Step 2: Figure out the usb id for the device
# For both bound and unbound devices, the device will have at least one entry in /sys/bus/usb/devices/${usb_id}/uevent
# So, we begin by searching through all these files for the ones matching our USB device
# This will probably match multiple times. E.g. the device might be registered on /usb/2-2 and usb-storage/2-2:1-0
# We want to unbind at the lowest place in the tree, so loop through each match and choose the one with the longest usb_id
# EDIT Changed to use shortest id, as longest one didn't worked for me
while IFS= read -r line; do
  path_parts=($(echo "$line" | cut -d "/"  --output-delimiter=" " -f 1-))
  new_usb_id="${path_parts[-2]}"
  if [ "${#path_parts[@]}" -lt 3 ]; then
    # Usually this happens if you try to bind/unbind/rebind too quickly
    continue
  fi
  new_usb_id="${path_parts[-2]}"
  # echo $new_usb_id
  
  if [ "${#new_usb_id}" -gt "${#usb_id}" ]; then
  	if [[ "$new_usb_id" == *":"* ]]; then # skip long usb-ids
  		continue
  	fi
    usb_id=${new_usb_id}
    # Use these lines to detect the driver of an unknown expension card
    # sys_array=($(echo "$line" | tr "/" " "))
    # new_driver_type=${sys_array[2]}
    # echo $new_driver_type
    # exit 1
    echo "driver type: $driver_type"
    echo "usb id: $usb_id" 
  fi
done <<< $(grep -iFl "PRODUCT=${short_vendor_id}/${short_product_id}" /sys/bus/usb/devices/*/uevent)

if [ -z "$usb_id" ]; then
  echo "could not calculate the usb-id" >&2
  exit 1
fi

# Step 3: Check to see if the device has already been bound/unbound and just no-op if there's nothing to do
if [ -d "/sys/bus/usb/drivers/${driver_type}/${usb_id}" ]; then 
  if [ "$usb_command" == "bind" ]; then
    echo "The device is already bound. Nothing to do"
    exit 0
  fi
elif [ "$usb_command" == "unbind" ]; then
  echo "The device is already unbound. Nothing to do"
  exit 0
fi

# Step 4; Bind/unbind the USB device by writing the usb_id to the correct bind or unbind file in sys/bus/usb
echo "Calling ${usb_command} for ${driver_type}/${usb_id}"

if [ -z "$dry_run" ]; then
  echo -n "$usb_id" | sudo tee /sys/bus/usb/drivers/${driver_type}/${usb_command} > /dev/null
fi

For those coming by this, WIP is work in progress and should be considered as such. But this is great!

Iā€™ve updated the script to be more useful and added a --detect option. Also transferred it to Github as a gist - hope thatā€™s okay

1 Like