Macbook-style Suspend on Linux

July 2, 2015

Macbook envy

Macbooks enter suspend really gracefully, which is something I never really missed on my Arch box until I actually started using suspend1. Here’s how I got my computer to:

  • Dim the display twenty seconds before suspend

  • Cancel dimming and suspend if any mouse or keyboard input was detected during that twenty seconds

  • Not dim/suspend if there was any sound playing

How even do you suspend?

A quick trip to the Arch wiki tells me that suspend works out of the box with a systemctl suspend. That was great, but I didn’t want to manually put my computer to sleep every time I thought I wouldn’t be using it for a little while.

Attempt 1: xautolock

xautolock checks if there isn’t any X Window activity within a certain interval, then calls an arbitrary command. I tried it out:

xautolock -time 2 -corners x-xx -locker "systemctl suspend"

The -corners x-xx parameter meant that moving the mouse into the top right-hand corner would disable the autosuspend, which I thought would be pretty useful for watching videos.

This worked fine, except that I’d be looking at something for a little while, and if I didn’t touch my mouse or keyboard, my computer would suddenly suspend itself. Fortunately, xautolock has a -notify flag, which lets you run a command some interval before the actual suspend.

Some people had the -notify set up for a notify-send call, but I didn’t want to start using an entire notification system just for getting notified of an impending standby, and the notification systems I did try didn’t play very nicely with i3.

I decided to keep looking.

Attempt 2: xautolock and brightd

brightd is a daemon that can dim your display once you’re idle for a certain period of time. This sounded like how Macbooks and smartphones work, and it’s a natural and unobtrusive way of signalling an impending suspend. But when I tried it out, nothing happened. I tried setting the brightness manually:

tee /sys/class/backlight/acpi_video0/brightness <<< 50

…and nothing happened either. Maybe it only works on laptops, or maybe it was because I use a TV as my monitor. Sure enough, running xbacklight just gave me the sad message:

> xbacklight
"No outputs have backlight property."

Attempt 3: xcalib and xautolock

xcalib allows you to set the brightness/contrast of your display, and it’s purely a software implementation, so it shouldn’t be affected by backlight properties and whatnot. I tried changing the contrast:

xcalib -co 60 -a

…and it looked almost exactly how I wanted it to. The only problem was that it would persist these changes and then exit, requiring another call to xcalib -clear to reset the contrast levels. xautolock would correctly notify me of a suspend by dimming the screen, but cancelling the suspend would leave me stuck with a dim monitor.

I stared at the flags for xautolock for a while, but there didn’t seem to be any flag that would be called once you reset the idle timer.

Attempt 4: xcalib and xprintidle

It looked as though I’d have to check the idle myself, so I starting writing a shell script that used xprintidle, a utility that prints the milliseconds you’ve been idle for:

# Check initial idle
initial_idle=$(xprintidle | bc)

# Dim the screen if we've been idle for more than 2 minutes
if [ "$initial_idle" -gt 120000 ]; then
  echo "Dimming screen"
  xcalib -co 60 -a

  idle=$(xprintidle | bc)
  # Keep looping if we're still idle
  while [ "$idle" -gt 2000 ]
  do
    idle=$(xprintidle | bc)

    # Suspend 20 seconds after screen dims
    if [ "$idle" -gt $(($initial_idle + 20000)) ];
    then
      echo "Suspending"
      systemctl suspend
    fi
    sleep 0.1
  done

  # Reset the display contrast once user activity is detected
  xcalib -clear
fi

It worked! Every time the script is called, we just check if we’ve been idle for more than two minutes, then dim the screen. After twenty seconds of this dim-screen warning, we suspend. If we make any user input before the twenty seconds, it’ll reset the idle timer, and we’ll set the screen back to full contrast.

Now we just need to set up a cron job, and we’re done!

Attempt 5: cron

I set up the cronjob, and… it didn’t work. Right, the script’s probably not executable:

chmod 600 suspend.sh

…still nothing. Let’s echo out some things to see what’s going on.

initial_idle=$(xprintidle | bc)

echo "$initial_idle"

Run it from the shell, and it gives me this output:

2

…but when I log the cronjob output, I get absolutely nothing. This kind of disparity between your shell and cron usually means that there’s something missing from cron’s rather sparse environment.

A bit of Googling revealed that the display environment variable was missing, so I added that in:

export DISPLAY=:0

…and it worked! The fact that cron only runs every minute meant the job would be delayed up to a minute, but I was fine with that.

Well, it half worked. It dimmed the screen alright, but it didn’t enter suspend. I suspected it was because calling systemctl suspend from cron would require root permissions, so I set up a root crontab that called systemctl suspend, and the computer suspended. I really didn’t want to run this script as root, so…

Attempt 6: systemd

systemd has timers, which are basically cronjobs, but better2:

  • Timers are decoupled from services, so you can run multiple services from one timer definition.

  • This decoupling also lets you run the service on demand, without waiting for the timer3.

  • All output is pushed to the systemd journal, so you can do stuff like:
journalctl -u suspend --since "5 min ago"
  • You have more options for the service environment, instead of the just getting the barebones cron env. This is what let us run systemctl suspend without any issues.

We basically just create /etc/systemd/system/suspend.timer:

[Unit]
Description=Suspend if user is idle

[Timer]
OnCalendar=minutely

[Install]
WantedBy=timers.target

And /etc/systemd/system/suspend.service:

[Unit]
Description=Suspend if user is idle

[Service]
Environment="DISPLAY=:0"
ExecStart=~/dotfiles/suspend.sh

We can test out the service:

systemctl start suspend

and enable the timer with:

systemctl enable suspend.timer

All was well.

Videos

I was pretty satisfied, so I decided to take a break and watch some South Park. Two minutes in, everything dimmed, and I had to scramble to stop the computer from suspending.

This just wouldn’t do, so I investigated some methods for detecting fullscreen video playing, but they seemed sort of hacky, requiring you to maintain some whitelist of windows that played video. It would probably be easier to check if any audio devices are playing:

grep -r "RUNNING" /proc/asound | wc -l

This gave me 0 if no sound was playing, and 1 if there was sound playing, or it had just recently stopped. This led to the final iteration of the script, which is also available from my dotfiles repository:

initial_idle=$(xprintidle | bc)

echo "Initial idle:"
echo "$initial_idle"

# Dim the screen if there's been no X activity for more than 2 minutes and there's no sound playing
if [ "$initial_idle" -gt 120000 ] && [ $(grep -r "RUNNING" /proc/asound | wc -l) -eq 0 ]; then
  echo "Dimming screen"
  xcalib -co 60 -a

  idle=$(xprintidle | bc)
  # Keep looping if we're still idle
  while [ "$idle" -gt 2000 ]
  do
    idle=$(xprintidle | bc)

    # Suspend 20 seconds after screen dims
    if [ "$idle" -gt $(($initial_idle + 20000)) ];
    then
      echo "Suspending"
      systemctl suspend
    fi
    sleep 0.1
  done

  # Reset the display contrast once user activity is detected
  xcalib -clear
fi

  1. I started using suspend once one of my electricity bills was a lot higher than I would’ve liked. For some reason, I’d never gotten into the habit of suspending my computer, so that sounded like a pretty good place to start.

  2. I’m going to stay out of the whole systemd debate otherwise.

  3. This would have been really useful when I was echoing the cronjob output to a logfile and just sitting in front of tail -f, waiting for the cronjob to go off.