Mimsy Were the Borogoves

Hacks: Articles about programming in Python, Perl, Swift, BASIC, and whatever else I happen to feel like hacking at.

Bluetooth battery early warning system

Jerry Stratton, December 7, 2022

Mouse battery warning: A yellow mouse battery warning using the batteries script in GeekTool.; GeekTool; batteries

Mouse is yellow: might want to plug it in when you’re done working today.

The notification from macOS that a mouse battery is running low usually comes too late and at inopportune times. When it happens I soon need to stop everything or get a USB mouse somewhere.

What I usually end up doing is taking an unplanned break for a few minutes to get the mouse battery back up to a safe level, and then go back to work. And forget to plug it in for the long term when I’m done working.

It would be nice if I could customize the warning to happen earlier and be both obvious and unobtrusive when it does happen. GeekTool is perfect for obvious and unobtrusive. All I need is a script to generate the text to display and it will show up on the desktop, and only when it needs to.

If you always want to see the battery level of all battery-operated devices, use ~/bin/batteries. In GeekTool (or on the command line) you can add the --colors switch to display different levels with different colors; there is a warning level set to yellow, a critical level set to red, and anything else is green.

  • use Term::ANSIColor;
  • $normal = color 'green';
  • $warning = color 'yellow';
  • $critical = color 'red';
  • $clear = color 'reset';
  • $warningLevel = 30;
  • $criticalLevel = $warningLevel/2;

I’ve set the warnings to start at 30%, with critical explicitly at half that. I may change my mind later.

Using the actual color names—green, yellow, and red—requires that you have Term::ANSIColor installed. If you don’t, you can replace those codes with the actual color escape sequences:

  • $normal = "\e[32m"; #green
  • $warning ="\e[33m"; #yellow
  • $critical = "\e[31m"; #red
  • $clear = "\e[0m";
  • $warningLevel = 30;
  • $criticalLevel = $warningLevel/2;

If you only want to see the battery level of devices at or below the warning level, use ~/bin/batteries --warnings with or without --colors.

If you want to see devices that are charging, even if they’re above the warning level, add --wired to the command line.

The options are detected using a simple while loop.

[toggle code]

  • #check for command-line switches
  • while ($option = shift) {
    • if ($option =~ /^--colors$/) {
      • $colors = 1;
    • } elsif ($option =~ /^--warnings$/) {
      • $warningsOnly = 1;
    • } elsif ($option =~ /^--wired$/) {
      • $showWired = 1;
    • } elsif ($option =~ /^--help$/) {
      • help();
    • } elsif ($option =~ /^--verbose$/) {
      • $verbose = 1;
    • } else {
      • help("Unknown option $option");
    • }
  • }

If you wanted, you could add your own abbreviations for those command-line switches, something like

  • if ($option =~ /^-(c|-colors)$/) {

This would make both -c and --colors count as the command-line switch for using ANSI color.

As you can see, I’ve also set up a --verbose switch. This was to show where the device name came from when testing. I’m assuming that if the device name came from Bluetooth Product Name, it is on bluetooth, and if the device name came from Product, then it is not on bluetooth. From testing, it appears that all devices have a Product name; only devices connected via Bluetooth have a Bluetooth Product Name.

What this means is that if a device has a Product name but not a Bluetooth Product Name, it is wired in. And the main reason for wiring a battery-operated device in is to charge the battery. By using --wired along with --warnings, the script will continue to display devices that are charging, giving you visual feedback on when it’s okay to disconnect them. Thus, the full script that I use in GeekTool is

  • ~/bin/batteries --warnings --wired --colors

What if you aren’t using GeekTool but still want to know when a battery is getting low? Well, with the notification script from 42 Astounding Scripts, you can do that easily in Perl.

[toggle code]

  • #!/usr/bin/perl
  • #notify when a battery level is low.
  • #Jerry Stratton astoundingscripts.com
  • if ($notification = `~/bin/batteries --warnings`) {
    • `~/bin/notify '$notification'`;
  • }

If you don’t have 42 Astounding Scripts you should buy it. In the meantime, you can call osascript directly to get the job done.

[toggle code]

  • #!/usr/bin/perl
  • #notify when a battery level is low.
  • #Jerry Stratton astoundingscripts.com
  • if ($notification = `~/bin/batteries --warnings`) {
    • $notification =~ s/\n/\\n/g;
    • `/usr/bin/osascript -e "display notification \\"$notification\\" sound name \\"beep\\""`;
  • }

Save this as something like batteryWarning and you can then enter this script into either your crontab file or into your launchd settings so that it runs regularly. For launchd, I recommend Lingon X. Lingon X can also be used to make editing your crontab file easier if you’re not comfortable using crontab -e. Either way, something like this in the crontab file will do it:

[toggle code]

  • 0       *       *       *       *       $HOME/bin/batteryWarning

This will run the script every hour on the hour, generating a warning if any device’s battery percentage is lower than the warning level.

The batteries script works by first collecting battery data, and then displaying it. These two sections of the script are marked by comments.

[toggle code]

  • #collect battery levels
  • @ioText = `/usr/sbin/ioreg -l`;
  • for (@ioText) {
    • $bluetoothName = $1 if m/"Bluetooth Product Name" = "([^"]*)"/;
    • $wiredName = $1 if m/"Product" = "([^"]*)"/;
    • if (m/"BatteryPercent" = ([0-9]+)/) {
      • my $level = $1;
      • my $device = 'Unknown Device';
      • my $source = 'No name found';
      • if ($bluetoothName ne '') {
        • $device = $bluetoothName;
        • $source = 'bluetooth';
      • } elsif ($wiredName) {
        • $device = $wiredName;
        • $source = 'wired';
      • }
      • $devices{$device} = $level;
      • $sources{$device} = $source;
      • $maxDeviceWidth = length($device) if length($device) > $maxDeviceWidth;
      • $maxLevelWidth = length($level) if length($level) > $maxLevelWidth;
      • undef $bluetoothName;
      • undef $wiredName;
    • }
  • }

Collecting the data is literally a matter of using regular expressions to detect a product name and remember it, and then every time a battery level is detected, use the most recently-detected product name for that battery level. The bluetooth name takes precedence over the general name.

The collection loop also keeps track of the longest product name and the longest battery level, so that the two can be aligned correctly in the display loop.

[toggle code]

  • #display battery levels
  • for $device (sort keys %devices) {
    • $level = $devices{$device};
    • $source = $sources{$device};
    • next if $warningsOnly && $level > $warningLevel && (!$showWired || $source ne 'wired');
    • $spaces = 1;
    • $spaces += $maxDeviceWidth-length($device);
    • $spaces += $maxLevelWidth-length($level);
    • $alignment = ' ' x $spaces;
    • if ($colors) {
      • if ($level <= $criticalLevel) {
        • print $critical;
      • } elsif ($level <= $warningLevel) {
        • print $warning;
      • } else {
        • print $normal;
      • }
    • }
    • print "$device:$alignment$level";
    • print " ($source)" if $verbose;
    • print $clear if $colors;
    • print "\n";
  • }

The display loop sorts the device names alphabetically and displays each device with its battery percent. If --warnings is set, the loop skips any devices that are above the warning level and (if --wired is switched on) do not seem to be charging.

Here’s the full code:

[toggle code]

  • #!/usr/bin/perl
  • # show battery level for bluetooth devices
  • # Jerry Stratton astoundingscripts.com
  • use Term::ANSIColor;
  • $normal = color 'green';
  • $warning = color 'yellow';
  • $critical = color 'red';
  • $clear = color 'reset';
  • $warningLevel = 30;
  • $criticalLevel = $warningLevel/2;
  • #check for command-line switches
  • while ($option = shift) {
    • if ($option =~ /^--colors$/) {
      • $colors = 1;
    • } elsif ($option =~ /^--warnings$/) {
      • $warningsOnly = 1;
    • } elsif ($option =~ /^--wired$/) {
      • $showWired = 1;
    • } elsif ($option =~ /^--help$/) {
      • help();
    • } elsif ($option =~ /^--verbose$/) {
      • $verbose = 1;
    • } else {
      • help("Unknown option $option");
    • }
  • }
  • #collect battery levels
  • @ioText = `/usr/sbin/ioreg -l`;
  • for (@ioText) {
    • $bluetoothName = $1 if m/"Bluetooth Product Name" = "([^"]*)"/;
    • $wiredName = $1 if m/"Product" = "([^"]*)"/;
    • if (m/"BatteryPercent" = ([0-9]+)/) {
      • my $level = $1;
      • my $device = 'Unknown Device';
      • my $source = 'No name found';
      • if ($bluetoothName ne '') {
        • $device = $bluetoothName;
        • $source = 'bluetooth';
      • } elsif ($wiredName) {
        • $device = $wiredName;
        • $source = 'wired';
      • }
      • $devices{$device} = $level;
      • $sources{$device} = $source;
      • $maxDeviceWidth = length($device) if length($device) > $maxDeviceWidth;
      • $maxLevelWidth = length($level) if length($level) > $maxLevelWidth;
      • undef $bluetoothName;
      • undef $wiredName;
    • }
  • }
  • #display battery levels
  • for $device (sort keys %devices) {
    • $level = $devices{$device};
    • $source = $sources{$device};
    • next if $warningsOnly && $level > $warningLevel && (!$showWired || $source ne 'wired');
    • $spaces = 1;
    • $spaces += $maxDeviceWidth-length($device);
    • $spaces += $maxLevelWidth-length($level);
    • $alignment = ' ' x $spaces;
    • if ($colors) {
      • if ($level <= $criticalLevel) {
        • print $critical;
      • } elsif ($level <= $warningLevel) {
        • print $warning;
      • } else {
        • print $normal;
      • }
    • }
    • print "$device:$alignment$level";
    • print " ($source)" if $verbose;
    • print $clear if $colors;
    • print "\n";
  • }
  • sub help {
    • my $message = shift;
    • print "$0 [--warnings]\n";
    • print "\tshow battery levels of any product with a battery level.\n";
    • print "\t--colors:\tdisplay different battery warning levels in different colors.\n";
    • print "\t--verbose:\tdisplay where the device name came from.\n";
    • print "\t--warnings:\tonly show levels at warning level ($warningLevel%) or below.\n";
    • print "\t--wired:\tshow all wired devices, even if above the warning level.\n";
    • print "\n";
    • print "$message.\n" if $message;
    • exit;
  • }

You can use the edit script from Astounding Scripts to enter that into your ~/bin directory. Or, you can also download the full script (Zip file, 1.6 KB). If you don’t have Term::ANSIColor and don’t want to install it, you’ll need to replace the color names with the raw escape sequences as described above. You can also, of course, adjust the warning level as you wish by altering $warningLevel. My guess is that for most people a value between 30 and 40 will be most useful.

Batteries script setup in GeekTool: Sample setup of the batteries warning script in GeekTool.; GeekTool; batteries

Possible settings for the batteries script in GeekTool. This updates the display every five minutes (300 seconds) and displays it in the lower right.

  1. <- Perl .ics creator