The code for the key fob requires several imports. BLE Radio is part of the CircuitPython Bundle.  It affords control of the nRF52840 BLE radio. The Adafruit LED Animation library is included in the bundle too. This helper library facilitates creating LED animations. AnalogIn is used to read the Feather’s ADC pins. Array is imported to create arrays with typed elements. I2SOut exposes the I²S interface. RawSample and WaveFile play audio samples and recorded WAV files. DigitalInOut controls the GPIO pins. The NeoPixel library exposes the Feather’s built-in NeoPixel.

Download: file
from adafruit_ble import BLERadio
from adafruit_led_animation.animation import Pulse, Solid
import adafruit_led_animation.color as color
from analogio import AnalogIn
from array import array
from audiobusio import I2SOut
from audiocore import RawSample, WaveFile
from board import BATTERY, D5, D6, D9, NEOPIXEL, RX, TX
from digitalio import DigitalInOut, Direction, Pull
from math import pi, sin
from neopixel import NeoPixel
from time import sleep

AnalogIn is used to track the current battery voltage. The BLE Radio is instantiated. Hit status is a list of colors which represents the number of beacons hit by the BLE scan. Red indicates no beacons found, orange is 1 beacon, amber is 2 beacons and green is all 3 beacons.

Download: file
battery = AnalogIn(BATTERY)

ble = BLERadio()
hit_status = [color.RED, color.ORANGE, color.AMBER, color.GREEN]

The Feather’s built-in NeoPixel is instantiated. A pulse LED animation is defined. It will pulse a purplish pink for the female icon and cyan for the male icon. A solid LED animation is defined and the initial state is green. This really isn’t an animation because it just sets the NeoPixel to the specified color.

Download: file
pixel = NeoPixel(NEOPIXEL, 1)
pulse = Pulse(pixel,
              speed=0.01,
              color=color.PURPLE,  # Use CYAN for Male Key
              period=3,
              min_intensity=0.0,
              max_intensity=0.5)

solid = Solid(pixel, color.GREEN)

The magnetic reed switch is defined and set to GPIO 5. When the switch is exposed to a magnetic field, the pin will be pulled low. The amp enable pin is defined and set to GPIO 6. It is an output and the initial state is set low to disable the amp.

Download: file
reed_switch = DigitalInOut(D5)
reed_switch.direction = Direction.INPUT
reed_switch.pull = Pull.UP

amp_enable = DigitalInOut(D6)
amp_enable.direction = Direction.OUTPUT
amp_enable.value = False

A method called PlayTone is defined. It generates a loud warning tone. The length of the array is the sample rate divided by the frequency in Hertz. A sample is created using the RawSample method and passed the sine wave array. An I²S interface is implemented using the I2SOut method. The play method plays the sample. Loop equals true causes the sample to repeat indefinitely. A 1 second sleep, plays the sample for 1 second. Then stop cancels playback. Deinit is used to dispose of the sample and the interface.

Download: file
def play_tone():
    """Generate tone and transmit to I2S amp."""
    length = 4000 // 440
    sine_wave = array("H", [0] * length)
    for i in range(length):
        sine_wave[i] = int(sin(pi * 2 * i / 18) * (2 ** 15) + 2 ** 15)

    sample = RawSample(sine_wave, sample_rate=8000)
    i2s = I2SOut(TX, RX, D9)
    i2s.play(sample, loop=True)
    sleep(1)
    i2s.stop()
    sample.deinit()
    i2s.deinit()

A method called PlayMessage is defined. It will play a recorded WAV audio file. A WAV file called D1 is loaded. The WaveFile method instantiates the loaded WAV file. An I²S interface is implemented. The play method plays the WAV file. A while loop suspends the program until the playback completes. Again deinit cleans up.

Download: file
def play_message():
    """Play recorded WAV message and transmit to I2S amp."""
    with open("d1.wav", "rb") as file:
        wave = WaveFile(file)
        i2s = I2SOut(TX, RX, D9)
        i2s.play(wave)
        while i2s.playing:
            pass
        wave.deinit()
        i2s.deinit()

Boundary_violations tracks the amount of time that a fob resides outside the range of the BLE beacons. The main program loop is an infinite while. A high reed switch indicates the fob is not docked in the charging cradle. Hits tracks how many BLE beacons are within range.

Download: file
boundary_violations = 0

while True:
    if reed_switch.value:  # Not docked
        hits = 0

The BLE commands are wrapped in a try statement to catch errors. Advertisements stores the results of a BLE scan. Timeout limits the scan to 3 seconds. BLE devices transmit advertisements so they can be identified by other BLE devices. A for loop processes all detected Bluetooth BLE advertisements. Addr holds the advertisement address details. The BLE beacons will show up as a scan response with a type of random static. All other advertisements can be ignored. Each BLE beacon broadcasts a unique name. If the name matches the name of the first beacon then the hits variable is or’d by 1.  Bitwise operations are used instead of incrementing a counter because the BLE scan often returns duplicate advertisements which would result in an incorrect beacon count. If the 2nd or 3rd beacon is detected then the corresponding digit of the hits variable is or’d. Any errors are printed to the console.

Download: file
# Wrap BLE commands in try to catch errors
        try:
            advertisements = ble.start_scan(timeout=3)
            for advertisement in advertisements:
                addr = advertisement.address
                if (advertisement.scan_response and
                   addr.type == addr.RANDOM_STATIC):
                    if advertisement.complete_name == '<Your 1st beacon name here>':
                        hits |= 0b001
                    elif advertisement.complete_name == '<Your 2nd beacon name here>':
                        hits |= 0b010
                    elif advertisement.complete_name == '<Your 3rd beacon name here>':
                        hits |= 0b100
        except Exception as e:
            print(repr(e))

The actual beacon hit count is determined by using list comprehension to add up the flipped bits of the hits byte. The corresponding color is then set using the hit status list indexed color. Solid animate displays the color on the NeoPixel. The LED color warns the user as the FOB strays from proximity to the office.

Download: file
# Retrieve actual beacon hit count and update NeoPixel
        hit_count = len([ones for ones in bin(hits) if ones == '1'])
        solid.color = hit_status[hit_count]
        solid.animate()
        sleep(1)

If the hit count is zero then the fob has been taken out of range of all the beacons. The remainder of boundary violations divided by 60 will issue the audible alarm every 60 cycles. Each BLE scan takes more than a second. So, the alarm fires every few minutes. The I²S amp is enabled. The programs sleeps for a second. The warning tone is played. Another 1 second sleep then the WAV file message is played. After 1 second pause and the amp is disabled. Boundary violations is incremented. If at least 1 beacon is hit then the boundary violations is reset to zero.

Download: file
# Determine key fob proximity
        if hit_count == 0:  # Key fob out of range of beacons
            if boundary_violations % 60 == 0:  # Play message every 60 cycles
                amp_enable.value = True
                sleep(1)
                play_tone()
                sleep(1)
                play_message()
                sleep(1)
                amp_enable.value = False
            boundary_violations += 1
        else:  # Key fob in range of at least 1 beacon
            boundary_violations = 0

If the fob is docked in the cradle then none of the BLE code above needs to run. Instead the boundary violations is reset. The battery voltage is retrieved using the battery.value to poll the board’s ADC pin. The ADC value is multiplied by the reference voltage of 3.3 volts and divided by the ADC resolution. This converts the ADC value to the actual voltage which still needs to multiplied by 2 because there is a voltage divider on the battery ADC pin. The fully charged voltage of the LiPo battery can exceed 4 volts which is greater than the ADC’s maximum rating. A voltage divider scales the voltage to an acceptable range. If the voltage is less than the nominal LiPo battery voltage of 3.7 volts than the NeoPixel pulse period is reduced to 1 second to speed up the flashing.

Download: file
# Key fob docked in cradle
    else:  
        boundary_violations = 0
        voltage = battery.value * 3.3 / 65535 * 2
        if voltage < 3.7:
            pulse.period = 1  # Speed up LED pulse for low battery
        else:
            pulse.period = 3
        pulse.animate()
This guide was first published on Jul 22, 2020. It was last updated on Jul 22, 2020.
This page (Code Review) was last updated on Sep 24, 2020.