Macbook-style Suspend on Linux
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 runsystemctl 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
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.↩
I’m going to stay out of the whole
systemd
debate otherwise.↩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.↩