Mac NeoPixel notification light 2.0

I wrote Make a Mac ‘Task Done’ NeoPixel notification light just over four years ago. Not long after the post was penned, macOS’ security provisions became tighter and so the unsigned third-party kernel extensions used to drive the NeoPixel were no longer available.

That was that. I marked the post as ‘no longer working’ and put the project aside.

IMG_0224

Skip forward four years. While reading a blog post about something else entirely, I spotted a comment referencing a new product for delivering GPIO functionality on a Mac (or other computer) via USB. It uses a Microchip MCP2221 USB interface chip; the Adafruit FT232H Breakout used in my original post was based on an FTDI part. I’d like to continue to using the FT232H because it supports SPI and the MCP2221 doesn’t, and why waste old kit if you don’t need to?

So I did a bit of digging and discovered that the FT232H isn’t quite as dead and unsupported as it was a year or so ago when I last looked.

Adafruit has added some instructions for driving the FT232H using Python, specifically its own MicroPython derivative, CircuitPython. Adafruit is putting a lot of effort into CircuitPython-compatible device driver libraries, which makes my NeoPixel notifier code more compact, streamlines the installation process and — best of all — removes the need for macOS kernel extensions.

So here is the new setup sequence. Run each step at the command line.

  1. brew install libusb
  2. pip3 install pyftdi adafruit-blinka adafruit-circuitpython-neopixel-spi
  3. echo 'export BLINKA_FT232H=1' >> ~/.bash_profile
  4. echo '0.0.0.0' > "$HOME/.status"

Note The above instructions assume you’re on a Mac; you’ll probably use apt rather than brew on another Unix-based machine, and you might not have a .bash_profile file. For other folks, here are Windows instructions.

This is how you wire up the FT232H and the NeoPixel:

spi_neopixel

I’ve taken the opportunity to not only rewrite my notification driver code to support Adafruit’s new tools, but also to improve how it operates: it’ll do three parallel notification colours now. The code is below, but you can also find it on my GitHub repo, which also contains a bash script you can use to control the notification light in your own scripts.

Here’s the driver code:

#!/usr/bin/env python

"""
TaskLight - a notification light controller
Requires Adafruit's FT232H Breakout 
(https://www.adafruit.com/product/2264) 
and a single NeoPixel.
"""

# IMPORTS
import time
import sys
import os
import board
import neopixel_spi as neopixel

# CONSTANTS
FLASH_DELAY = 0.3
MAX_COLOURS = 3

# FUNCTIONS
def clear(ps):
    # Turn the NeoPixel off
    ps[0] = (0, 0, 0)
    ps.show()

def cycle(c):
    # Increment c and cycle to 0 if necessary
    c += 1
    if c >= MAX_COLOURS: c = 0
    return c

# START
if __name__ == '__main__':

    # Set up the NeoPixel array, as per the library and clear it
    # NOTE There's only one NeoPixel
    pixels = neopixel.NeoPixel_SPI(board.SPI(),
                                   1,
                                   pixel_order = neopixel.GRB,
                                   auto_write = False)
    clear(pixels)

    # Initialize key variables
    filename = os.path.expanduser("~") + '/.status'
    brightness = 30 # Brightness control as a percentage
    notification = [False, False, False]
    colour = [0, 0, 0]
    count = 0
    flashState = True

    # Run the loop
    while True:
        try:
            # Set the colours of each component in the NeoPixel
            # This will alternate between 2 and 3 colours, and
            # alternate one colour with black (off)
            numberOfAlerts = 0
            for i in range(0, MAX_COLOURS):
                colour[i] = 0
                if notification[i] is True:
                     numberOfAlerts += 1
                     if i == count and flashState is True:
                        colour[i] = 255

            if numberOfAlerts == 1:
                flashState = not flashState
                count = notification.index(True)
            elif numberOfAlerts > 1:
                if not flashState: flashState = True
                while True:
                    count = cycle(count)
                    if notification[count] is True: break

            # Draw and display the Neopixel (first and only item in 'pixels' array)
            pixels[0] = (int(colour[0] * brightness / 100), int(colour[1] * brightness / 100), int(colour[2] * brightness / 100))
            pixels.show()

            # Check the status file
            if os.path.exists(filename):
                file = open(filename)
                text = file.read()
                file.close()
                # File contains four values, eg. 0.0.0.0
                # First is a marker for the red light (1 = on; 0 = off), etc.
                # Fourth is a 'stop script' marker for debugging
                items = text.split('.')
                if len(items) > MAX_COLOURS:
                    if items[MAX_COLOURS] == '1':
                        # Halt called, so switch off the LED and bail
                        clear(pixels)
                        sys.exit(0)

                # Check each item -- can do better error checking here!
                for i in range(0, MAX_COLOURS):
                    if items[i] != '0':
                        # Status file indicates notification light should be on
                        # NOTE Any value other that '0' will work
                        if notification[i] is False:
                            notification[i] = True
                    else:
                        # Turn of the light if it's currently on
                        if notification[i] is True:
                            notification[i] = False
            else:
                # No status file - warn the user
                print('[ERROR] No .status file')
                sys.exit(1)

            time.sleep(FLASH_DELAY)
        except KeyboardInterrupt:
            clear(pixels)
sys.exit(-1)

To run the code save it as, say, task.py and then run it in the background using

python task.py &

Here’s the controller script:

#!/usr/bin/env bash

# Manage a TaskLight .status file
# Version 1.0.0

# Function to show help info - keeps this out of the code
function showHelp {
    echo -e "\ntask 1.0.0\n"
    echo -e "Manage a TaskLight .status file\n"
    echo -e "Usage:\n    task.sh [-r value] [-g value] [-b value]\n"
    echo    "Options:"
    echo    "    -r / --red   [value]  Set the red pixel on (1) or off (0)."
    echo    "    -g / --green [value]  Set the green pixel on (1) or off (0)."
    echo    "    -b / --blue  [value]  Set the blue pixel on (1) or off (0)."
    echo    "    -o / --off            Turn all the pixels off (eg. clear notifications)."
    echo    "    -h / --help           This help screen."
    echo
}

# Initialize variables
argCount=0
argIsAValue=0
red=0
blue=0
green=0
dostop=0
statusfile="$HOME/.status"
# Get the current values
if [ -e "$statusfile" ]; then
    line=$(head -n 1 "$statusfile")
    IFS='.' read -r -a parts <<< "$line"
    red=${parts[0]}
    green=${parts[1]}
    blue=${parts[2]}
fi

# Parse the command line arguments
for arg in "$@"; do
    if [[ "$argIsAValue" -gt 0 ]]; then
        # The argument should be a value (previous argument was an option)
        if [[ ${arg:0:1} = "-" ]]; then
            # Next value is an option: ie. missing value
            echo "Error: Missing value for ${args[((argIsAValue - 1))]}"
            exit 1
        fi

        # Set the appropriate internal value
        case "$argIsAValue" in
            1)  red=$arg ;;
            2)  green=$arg ;;
            3)  blue=$arg ;;
            *)  echo "Error: Unknown argument"; exit 1 ;;
        esac
        argIsAValue=0
    else
        # Make the argument lowercase
        arg=${arg,,}

        if [[ $arg = "-r" || $arg = "--red" ]]; then
            argIsAValue=1
        elif [[ $arg = "-g" || $arg = "--green" ]]; then
            argIsAValue=2
        elif [[ $arg = "-b" || $arg = "--blue" ]]; then
            argIsAValue=3
        elif [[ $arg = "-s" || $arg = "--stop" ]]; then
            dostop=1
        elif [[ $arg = "-h" || $arg = "--help" ]]; then
            showHelp
            exit 0
        elif [[ $arg = "-o" || $arg = "--off" ]]; then
            echo "0.0.0.0." > "$statusfile"
            exit 0
        else
            echo "[ERROR] Unknown switch: $arg"
            exit 1
        fi
    fi

    ((argCount++))

    if [[ "$argCount" -eq $# && "$argIsAValue" -ne 0 ]]; then
        echo "[Error] Missing value for $arg"
        exit 1
    fi
done

# Write out the file
echo "$red.$green.$blue.$dostop." > "$statusfile"

Save it as task.sh and then make it executable with

chmod +x task.sh

You can set the light flashing red and blue by calling

./task.sh --red 1 --blue 1

Turn off blue, by calling

./task.sh --blue 0

Make all the colours flash with

./task.sh --green 1 --blue 1

And turn off the three notifications with

./task.sh -off

It’s pretty easy to control the notification lights from other applications and scripts by making calls like those presented above.

This is just one possible use of the FT232H Breakout, and I’ll be covering some further applications of this great bit of kit in future posts.