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

 1:  #!/usr/bin/python3
 3:  import subprocess
 4:  import re
 6:  # Initialize
 7:  limits = {'keyboard': 20, 'trackpad': 15, 'mouse': 15}
 8:  deviceTypes = limits.keys()
 9:  anyLow = False
10:  anyOdd = False
11:  menuString = ['---']
13:  # Regexes for capturing product names and battery percentages
14:  productRE = re.compile(r'Product" = "(.+?)"')
15:  batteryRE = re.compile(r'"BatteryPercent" = (\d+)')
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')
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
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 updatingioreg` 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. 

RSS and the pleasure of not thinking

I listened to the recent Mac Power Users episode on RSS while on a long walk the other day, and I really enjoyed it. Partly, of course, because I just like listening to Stephen and David, but mainly because I didn’t feel I had any stake in it.

As I’ve mentioned here several times, I have a homemade system for reading RSS feeds. And although I am willing to switch to a different setup, that different setup would have to be a tremendous improvement. Here are the advantages of what I have:

Honestly, it’s the “not thinking” part that’s the best. Over the 35 years I’ve been a computer user, way too much of my time has been spent thinking about the “right” software to buy. Some of this has been forced on me—when an app or service stops working, there’s no way to avoid thinking about the alternatives—but a lot has been self-inflicted. It’s nice to have one part of my computing life that’s stable and should continue to be stable for years to come.2

In some ways, I suppose, listening to Stephen and David talk about RSS was akin to schadenfreude. I could walk along, smug in the knowledge that I wouldn’t be balancing the upsides and downsides. I could just turn off my mind, relax, and float downstream.

  1. My experience is, admittedly, now well out of date, as I haven’t looked into aggregators in several years. I suspect stale articles aren’t much of a problem if you use one of the big, popular aggregators. 

  2. Was the time I spent writing my RSS scripts more than the time I would now spend thinking about the “best” RSS aggregator and reader? Doesn’t matter. I enjoyed writing the scripts. I learned new things and got satisfaction out of seeing them run correctly. I get nothing like that out of comparing apps and services. 

A new old Python

You may have noticed something new in yesterday’s scripts: the shebang lines were


I’ve been using Python 3 for quite a while, but it’s been a version installed through Anaconda, not one that came from Apple. The reasons are

  1. Apple didn’t provide a Python 3 until Catalina; and
  2. I didn’t install Catalina on either of my Macs until this past month.

I intend to keep using the Anaconda-installed version as my regular Python because its environment has all the tools I regularly use in my work: NumPy, SciPy, Pandas, and Matplotlib. But the BitBar scripts were a good way to try out Apple’s Python 3; they needed Python 3’s UTF-8 support1 and didn’t need any of those math/science libraries.

While I said above that Apple provides Python 3 in Catalina, that may be stretching the definition of “provide.” If you look in /usr/bin, you’ll find something called python3, but that something may be just a placeholder. If you haven’t installed the Command Line Developer Tools, trying to execute a script via /usr/bin/python3 will get you an error message about an “invalid active developer path.” This happened to me on one of my Macs; presumably, the CLDTs had already been installed on the other Mac and were updated when I switched to Catalina.

If you need to install the CLDTs, this explanation by Flavio Copes of how to do so via the xcode-select command is clear and concise. Once you’ve done so, you can test your new Python 3 by running

/usr/bin/python3 --version

at the command line. You should get Python 3.7.3 as the response. This was the version released over a year ago, which means its remarkably fresh for an Apple-supplied command-line tool.

If you need to install third-party libraries, as I did with the Mechanize and BeautifulSoup libraries used in my library BitBar script, you’ll have to run the Python 3 version of pip like this:

/usr/bin/pip3 install mechanize

You’ll probably get a warning that your version of pip isn’t up to date. As with Python 3 itself, the pip that comes from Apple is over a year old. It’ll still work.

As I said earlier, I don’t expect to be using Apple’s Python 3 in the future, but it’s nice to see that Mac users can use a modern Python without resort to third-party systems like Homebrew or Anaconda.

  1. OK, they didn’t need UTF-8 support, but I did. I’m too old to keep doing the encode/decode dance

BitBar, SuperDuper, and library books

Jason Snell’s recent post on BitBar inspired me to build a couple of menu bar notices of my own.

The first was a rewrite of the SuperDuper notice I used to use with a similar menu bar utility called AnyBar. It puts a thumbs up in the menu bar if the most recent scheduled SuperDuper backup was successful, and a thumbs down if it wasn’t. Clicking on the item brings up a summary of SuperDuper’s log file:

BitBar SuperDuper notice

The log summary is in gray text because it doesn’t do anything—it’s like a series of disabled menu items.

The second BitBar notice is a little more complicated. It gets the status of items my family has checked out or on hold at our local library and presents them in the menu. If a checked-out item is due soon (or overdue) or if an item on hold is ready to be picked up, the book item in the menu bar is red; otherwise, it’s blue. Either way, the items are listed in the submenus.1

BitBar Library

In addition to the submenus, this one also allows some items in the menu to be chosen. If an item is due soon, it will be enabled in the menu, and choosing it will open Safari to the library’s login page. This speeds up renewing a book if I want to keep it longer.

BitBar uses the daunting words “plugin” and “API” to describe the code that configures these menu bar items, but they’re just programs that write lines of text to standard output. If you’ve ever written any kind of program in any language, you can write a BitBar plugin. There are several rules for the output, but the main ones are:

  • Multiple lines will be cycled through over and over.
  • If your output contains a line consisting only of ---, the lines below it will appear in the dropdown for that plugin, but won’t appear in the menu bar itself.
  • Lines beginning with -- will appear in submenus.

You save the programs in a folder that you set up when you run BitBar the first time (before you have any plugins written). I use ~/.bitbar.

My SuperDuper plugin, named superduper.6h.py in accordance with the BitBar plugin naming format, is this Python script:

 1:  #!/usr/bin/python3
 3:  import os
 5:  # Where the SuperDuper! log files are.
 6:  logdir = (os.environ["HOME"] +
 7:            "/Library/Application Support/" +
 8:            "SuperDuper!/Scheduled Copies/" +
 9:            "Smart Update Backup from Macintosh HD.sdsp/Logs/")
11:  def sdinfo(s):
12:    "Return just the timestamp and process information from a SuperDuper line."
13:    parts = s.split('|')
14:    ratespot = parts[3].find("at an effective transfer rate")
15:    if ratespot > -1:
16:      parts[3] = parts[3][:ratespot]
17:    detailspot = parts[3].find("(")
18:    if detailspot > -1:
19:      parts[3] = parts[3][:detailspot]
20:    return "%s: %s" % (parts[1].strip(), parts[3].strip(' \\\n'))
22:  # Get the last log file.
23:  logfiles = [x for x in os.listdir(logdir) if x[-5:] == 'sdlog']
24:  logfiles.sort()
25:  lastlog = logdir + logfiles[-1]
27:  # Collect data for BitBar
28:  notices = []
29:  with open(lastlog) as f:
30:    for line in f:
31:      for signal in ["Started on", "PHASE:", "Copied", "Cloned", "Copy complete"]:
32:          if signal in line:
33:            notices.append(sdinfo(line))
35:  # Format output for BitBar
36:  if "Copy complete." in notices[-1]:
37:    print("👍🏻")
38:  else:
39:    print("👎🏻")
40:  print("---")
41:  print("\n".join(notices))

The bulk of this script was taken from the predecessor to my AnyBar script, which I wrote for GeekTool.2 It reads through the long SuperDuper log file and plucks out just the lines of interest, putting them in the list notices.

The last section, Lines 36–41, looks at the last item in notices to see if the backup finished. If it did, it prints a 👍🏻; if not, it prints a 👎🏻. Then comes the --- separator in Line 40 to divide the menu title from the stuff in the dropdown. Finally, all the items in notices are printed line by line to populate the menu itself.

The library plugin, library.3h.py, consists of the following Python code, most of which was copied from an old script that sends me a daily email with the status of the family library accounts.

  1:  #!/usr/bin/python3
  3:  import mechanize
  4:  from bs4 import BeautifulSoup
  5:  from datetime import timedelta, datetime
  6:  import re
  7:  import textwrap
  9:  # Family library cards
 10:  cardList = [
 11:  {'patron' : 'Dad',  'code' : '12345678901234', 'pin' : '1234'},
 12:  {'patron' : 'Mom', 'code' : '98765432109876', 'pin' : '9876'},
 13:  {'patron' : 'Son1',   'code' : '91827364555555', 'pin' : '4321'},
 14:  {'patron' : 'Son2',  'code' : '11223344556677', 'pin' : '5678'}]
 17:  # The login URL for the library's account information.
 18:  lURL = 'https://library.naperville-lib.org/iii/cas/login?service=https%3A%2F%2Flibrary.naperville-lib.org%3A443%2Fpatroninfo~S1%2FIIITICKET&scope=1'
 20:  # Initialize the lists of checked-out and on-hold items.
 21:  checkedOut = []
 22:  onHold = []
 24:  # Dates to compare with due dates. "Soon" is 2 days from today.
 25:  today = datetime.now()
 26:  soon = datetime.now() + timedelta(2)
 28:  # Function that returns a truncated string
 29:  def shortened(s, length=30):
 30:    try:
 31:      out = s[:s.index(' / ')]
 32:    except ValueError:
 33:      out = s
 34:    out = out.strip()
 35:    lines = textwrap.wrap(out, width=length)
 36:    if len(lines) > 1:
 37:      return lines[0] + '…'
 38:    else:
 39:      return out
 41:  # Go through each card, collecting the lists of items.
 42:  for card in cardList:
 43:    # Open a browser and login
 44:    br = mechanize.Browser()
 45:    br.set_handle_robots(False)
 46:    br.open(lURL)
 47:    br.select_form(nr=0)
 48:    br.form['code'] = card['code']
 49:    br.form['pin'] = card['pin']
 50:    br.submit()
 52:    # We're now on either the page for checked-out items or for holds.
 53:    # Get the URL and figure out which page we're on.
 54:    pURL = br.response().geturl()
 55:    if pURL[-5:] == 'items':                            # checked-out items
 56:      cHtml = br.response().read()                        # get the HTML
 57:      br.follow_link(text_regex='requests? \(holds?\)')   # go to holds
 58:      hHtml = br.response().read()                        # get the HTML
 59:    elif pURL[-5:] == 'holds':                          # holds
 60:      hHtml = hHtml = br.response().read()                # get the HTML
 61:      br.follow_link(text_regex='currently checked out')  # go to checked-out
 62:      cHtml = br.response().read()                        # get the HTML
 63:    else:
 64:      continue
 66:    # Parse the HTML.
 67:    cSoup = BeautifulSoup(cHtml, features="html5lib")
 68:    hSoup = BeautifulSoup(hHtml, features="html5lib")
 70:    # Collect the table rows that contain the items.
 71:    loans = cSoup.findAll('tr', {'class' : 'patFuncEntry'})
 72:    holds = hSoup.findAll('tr', {'class' : 'patFuncEntry'})
 74:    # Due dates and pickup dates are of the form mm-dd-yy.
 75:    itemDate = re.compile(r'\d\d-\d\d-\d\d')
 77:    # Go through each row of checked out items, keeping only the title and due date.
 78:    for item in loans:
 79:      # The title is everything before the spaced slash in the patFuncTitle
 80:      # string. Some titles have a patFuncVol span after the title string;
 81:      # that gets filtered out by contents[0]. Interlibrary loans
 82:      # don't appear as links, so there's no <a></a> inside the patFuncTitle
 83:      # item.
 84:      title = item.find('td', {'class' : 'patFuncTitle'}).text
 86:      # The due date is somewhere in the patFuncStatus cell.
 87:      dueString = itemDate.findall(item.find('td', {'class' : 'patFuncStatus'}).contents[0])[0]
 88:      due = datetime.strptime(dueString, '%m-%d-%y')
 89:      # Add the item to the checked out list. Arrange tuple so items
 90:      # get sorted by due date.
 91:      checkedOut.append((due, card['patron'], title))
 93:    # Go through each row of holds, keeping only the title and place in line.
 94:    for item in holds:
 95:      # Again, the title is everything before the spaced slash. Interlibrary loans
 96:      # are holds that don't appear as links, so there's no <a></a> inside the
 97:      # patFuncTitle item.
 98:      title = item.find('td', {'class' : 'patFuncTitle'}).text
100:      # The book's status in the hold queue will be either:
101:      # 1. 'n of m holds'
102:      # 2. 'Ready. Must be picked up by mm-dd-yy' (obsolete?)
103:      # 3. 'DUE mm-dd-yy'
104:      # 4. 'IN TRANSIT'
105:      status = item.find('td', {'class' : 'patFuncStatus'}).contents[0].strip()
106:      n = status.split()[0]
107:      if n.isdigit():                         # possibility 1
108:        n = int(n)
109:        status = status.replace(' holds', '')
110:      elif n[:5].lower() == 'ready' or n[:3].lower() == 'due':  # possibilities 2 & 3
111:        n = -1
112:        readyString = itemDate.findall(status)[0]
113:        ready = datetime.strptime(readyString, '%m-%d-%y')
114:        status = 'Ready<br/> ' + ready.strftime('%b %d')
115:      else:                                   # possibility 4
116:        n = 0
118:      # Add the item to the on hold list. Arrange tuple so items
119:      # get sorted by position in queue. The position is faked for
120:      # items ready for pickup and in transit within the library.
121:      onHold.append((n, card['patron'], title, status))
124:  # Sort the lists.
125:  checkedOut.sort()
126:  onHold.sort()
128:  # Assemble the information for the menu
129:  checkedAlert = False
130:  holdAlert = False
131:  checkedLines = []
132:  holdLines = []
133:  for item in checkedOut:
134:    suffix = ''
135:    if item[0] <= soon:
136:      suffix = f'|href="{lURL}"'
137:      checkedAlert = True
138:    checkedLines.append(item[0].strftime('--%b %-d  ') + shortened(item[2]) + suffix)
139:  for item in onHold:
140:    suffix = ''
141:    if "Ready" in item[3]:
142:      suffix = '|color=#700000'
143:      holdAlert = True
144:    holdLines.append('--' + shortened(item[2]) + suffix)
146:  # Print the information in BitBar format
147:  if checkedAlert or holdAlert:
148:    print('📕')
149:  else:
150:    print('📘')
151:  print('---')
152:  if checkedAlert:
153:    print('Checked out|color=#CC0000')
154:  else:
155:    print('Checked out')
156:  print('\n'.join(checkedLines))
157:  if holdAlert:
158:    print('Holds|color=#700000')
159:  else:
160:    print('Holds')
161:  print('\n'.join(holdLines))

The BitBar-specific stuff starts on Line 128. It’s more complicated than the SuperDuper script, in that:

Still, there’s not much to the BitBar part of either of these scripts. Now I’m wondering what other informational scripts I have that can be converted into BitBar form.

  1. No, the Nero Wolfe video wasn’t actually overdue. Because of the pandemic, our library quarantines returned items for a few days before updating their status in the system and reshelving. So until the Current Situation is over, this notice will be red more than it should be. 

  2. Seems longer than six years since I used GeekTool; I stopped because I usually have too many windows open to see information written on the desktop.