A battery BitBar bonanza

I started reading this thread on the Keyboard Maestro forum because I’ve been interested in the Stream Deck (yes, that’s an affiliate link) for a while and will probably be getting one soon. I kept reading because TJ Luoma’s answer made me realize I didn’t need the Stream Deck to use the ideas in his solution; it would work just as well with BitBar.

The idea is to use the output of ioreg to get the battery levels of the keyboard, trackpad, and mouse, and to then use that information to tell the user it’s time to recharge. You may be thinking, “isn’t that what the low battery notifications are for?” Yes, but the problem is those notifications always seem to appear when you’re in the middle of work and don’t want to be interrupted. The idea behind a notice on your Stream Deck or in your menu bar is that you don’t need to dismiss it to get back to work, and it’s still there to remind you when you have time to plug in or change batteries.

As a practical matter, I couldn’t just tweak TJ’s solution to make it work with BitBar. TJ is a shell scripting wizard, and his script is strong evidence of that. Although I can follow the logic of what he’s doing, I would never feel comfortable trying to adjust it to my needs. So I took his ideas, rewrote them in Python, and added the parts necessary to drive BitBar.

The script, or plugin, as BitBar likes to call it, is batteries.1h.py, and it gives me a menu bar item that looks like this:1

BitBar batteries

When one of the input devices gets low on battery, the menu bar icon changes from a battery,๐Ÿ”‹, to a plug, ๐Ÿ”Œ, to tell me its time to plug in. And if I ever connect an I/O device that isn’t a keyboard, trackpad, or mouse, the icon will change to a joystick, ๐Ÿ•น.

Here’s the source code of batteries.1h.py

python:
 1:  #!/usr/bin/python3
 2:  
 3:  import subprocess
 4:  import re
 5:  
 6:  # Initialize
 7:  limits = {'keyboard': 20, 'trackpad': 15, 'mouse': 15}
 8:  deviceTypes = limits.keys()
 9:  anyLow = False
10:  anyOdd = False
11:  menuString = ['---']
12:  
13:  # Regexes for capturing product names and battery percentages
14:  productRE = re.compile(r'Product" = "(.+?)"')
15:  batteryRE = re.compile(r'"BatteryPercent" = (\d+)')
16:  
17:  # Capture the output of ioreg. Remarkably, the output is
18:  # encoded in MacRoman, which will rear its ugly head if a
19:  # device has a name like Drangโ€™s Mouse (w/ curly apostrophe).
20:  cmd = 'ioreg -r -k BatteryPercent'.split()
21:  ioreg = subprocess.check_output(cmd).decode('macroman')
22:  
23:  # The ioreg output is a series of paragraphs, one for each
24:  # product. Go through each, looking for low batteries and
25:  # adding the appropriate item to menuString. Low batteries are
26:  # printed in red; weird results, like unknown devices and
27:  # missing battery percentages, are printed in purple.
28:  for p in ioreg.split('\n\n'):
29:    productMatch = productRE.search(p)
30:    batteryMatch = batteryRE.search(p)
31:    if productMatch and batteryMatch:
32:      name = productMatch.group(1)
33:      battery = int(batteryMatch.group(1))
34:      device = ''
35:      for d in deviceTypes:
36:        if d in name.lower():
37:          device = d
38:          break
39:      if device:
40:        if battery < limits[device]:
41:          menuString.append(f'{device.capitalize()} {battery}%|color=#AA0000')
42:          anyLow = True
43:        else:
44:          menuString.append(f'{device.capitalize()} {battery}%')
45:      else:
46:        menuString.append(f'{name}|color=purple')
47:        anyOdd = True
48:  
49:  # BitBar output
50:  if anyLow:
51:    print('๐Ÿ”Œ')
52:  elif anyOdd:
53:    print('๐Ÿ•น')
54:  else:
55:    print('๐Ÿ”‹')
56:  print('\n'.join(menuString))

The key to this was the recognition that the output of

ioreg -r -k BatteryPercent

can be thought of as a series of paragraphs, one for each I/O device with an entry named BatteryPercent. The script captures the output of this command in Line 21, splits it into paragraphs on Line 28, and extracts the product name and battery level for each device. This information is used to construct the multiline text output in Lines 50โ€“56 that BitBar parses to assemble the menu.

One weird thing I found while writing this script is that ioreg uses the old MacRoman text encoding for its output. Both of my devices used possessives in their product names, e.g., “Dr. Drang’s Trackpad,” and the curly apostrophe has a byte value (in hex) of 0xD5. When I first started looking through ioreg’s output, I saw that this was rendered in my terminal as “Dr. Drangร•s Trackpad,” because 0xD5 is ร• in UTF-8 (and Latin-1). Thus the call to decode('macroman') in Line 21. I realize updating ioreg is not one of Apple’s most pressing concerns, but it’s been two decades, Craig. None of Apple’s command line utilities should be spitting out MacRoman anymore.

I must note that TJ’s script is more tolerant of unusual output from ioreg than mine is and handles it in a more granular way. I flatly ignore certain useless outputs, because I just don’t think they’ll ever show up. It’s part of my devil-may-care personality.

Anyway, this was a fun exercise, and I now have another potentially useful item in my menu bar. It’ll be months before I know whether I take heed of this warning, but one thing I do know is that I’ve been dismissing low battery notifications for years; a warning in the menu bar has to be better than that.


  1. Yes, I’m also using the air quality BitBar plugin that Jason Snell wrote a few days ago.