My office frequently hosts many guests. Restrooms are shared with other businesses on the floor and require a key to access which guests are given and quite often forget to return. We are not allowed to duplicate the keys and the landlord charges a fee when they go missing. Attaching a bigger key chain like a hubcap was frowned upon by management so I came up with a Bluetooth solution using the Adafruit Feather nRF52840 Express board.

The Feather nRF52840 Express has many great features that this project leverages:

  • Support for Bluetooth Low Energy
  • Built-in NeoPixel RGB LED
  • I²S digital audio interface
  • 21 GPIO pins
  • Code can be written in CircuitPython
  • Built-in LiPo battery port with charging circuit

Key fobs powered by the nRF52840 will reside in wireless charging cradles in the office lobby.  The fobs will determine their proximity by scanning Bluetooth BLE beacons placed throughout the office.  The Feather's built-in NeoPixel color will indicate location and battery status.  If the fobs are removed from the vicinity then a warning tone and message will be played.

Parts List

Angled shot of a Adafruit Feather nRF52840 Express.
The Adafruit Feather nRF52840 Express is the new Feather family member with Bluetooth Low Energy and native USB support featuring the nRF52840!  It's...
$24.95
In Stock
Angled shot of a Adafruit I2S 3W Class D Amplifier Breakout.
Listen to this good news - we now have an all in one digital audio amp breakout board that works incredibly well with the 
$5.95
In Stock
Angled shot of two inductive chargers.
The squarish board with two chips on it is the transmitter (power with 9V).  The longer board is the output and you can connect that to the part of your project that...
$9.95
In Stock
Angled shot of a Lithium Ion Polymer Battery 3.7V 500mAh with JST-PH connector.
Lithium-ion polymer (also known as 'lipo' or 'lipoly') batteries are thin, light, and powerful. The output ranges from 4.2V when completely charged to 3.7V. This...
Out of Stock
Angle Shot of the Compact Switching Power Supply - Selectable Output 3-12VDC
Wow, is this not the most useful thing you did not know existed or what? It's a switching wall adapter where you can easily change the voltage! It's like a benchtop supply you...
$14.95
In Stock
2 x Waterproof Speakers
DB 67 Series Waterproof Speaker 8 Ohms 1.5 W (SW390608-1)
3 x Ibeacons
USB Ibeacon BLE 4.0 Module
2 x Cooling Fans
60 mm 1600 RPM 11 dba Fan
2 x Reed Switches
Reed Switch 14mm Plastic Anti-Interference Normally Open
2 x Micro USB Plugs
Micro USB Male Type B, 5 Pin Solder Plug
1 x DC Power Jack
DC Power Jack 5.5 mm x 2.1 mm
2 x Bangles
Silicone Bangle Key Ring Wrist Keychains
2 x Magnets
10 X 4 mm N35 Magnets
8 x Threaded Brass Inserts
M2.5-0.4 Threaded Heat Set Inserts for 3D Printing
8 x Screws
M2.5 x 10 mm Stainless Steel Hex Head Screws
8 x Bumpers
Adhesive Rubber Bumpers 12 mm x 7.5 mm

CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY drive to iterate.

The following instructions will show you how to install CircuitPython. If you've already installed CircuitPython but are looking to update it or reinstall it, the same steps work for that as well!

Set up CircuitPython Quick Start!

Follow this quick step-by-step for super-fast Python power :)

Click the link above to download the latest UF2 file.

 

Download and save it to your desktop (or wherever is handy).

Plug your Feather nRF52840 into your computer using a known-good USB cable.

A lot of people end up using charge-only USB cables and it is very frustrating! So make sure you have a USB cable you know is good for data sync.

Double-click the Reset button next to the USB connector on your board, and you will see the NeoPixel RGB LED turn green (identified by the arrow in the image). If it turns red, check the USB cable, try another USB port, etc. Note: The little red LED next to the USB connector will pulse red. That's ok!

If double-clicking doesn't work the first time, try again. Sometimes it can take a few tries to get the rhythm right!

You will see a new disk drive appear called FTHR840BOOT.

 

 

 

Drag the adafruit_circuitpython_etc.uf2 file to FTHR840BOOT.

The LED will flash. Then, the FTHR840BOOT drive will disappear and a new disk drive called CIRCUITPY will appear.

 

That's it, you're done! :)

We'll be using CircuitPython for this project. Are you new to using CircuitPython? No worries, there is a full getting started guide here.

For more info on the Feather nRF52840 Express, check out this guide.

Adafruit suggests using the Mu editor to edit your code and have an interactive REPL in CircuitPython. You can learn about Mu and its installation in this tutorial.

Note: This project uses the Bluetooth functionality available in CircuitPython 5.0.0-beta.0 and later, on nRF52840 boards such as the Feather nRF52840 Express and Circuit Playground Bluefruit. Make sure you are using that version or later.

Libraries

You'll also need to add the following libraries for this project. Follow this guide on adding libraries.

Plug your nRF Feather board into your computer via a USB cable. Please be sure the cable is a good power+data cable so the computer can talk to the board.

A new disk should appear in your computer's file explorer/finder called CIRCUITPY. This is the place we'll copy the code and code library. If you can only get a drive named FTHR840BOOT, load CircuitPython per the previous page.

Download the latest CircuitPython libraries to your computer using the green button below. Match the library you get to the version of CircuitPython you are using. Save to your computer's hard drive where you can find it.

With your file explorer/finder, browse to the bundle and open it up. Copy the following folders and files from the library bundle to your CIRCUITPY lib directory you made earlier:

The ones you'll need are:

  • adafruit_ble (folder)
  • adafruit_bus_device (folder)
  • adafruit_led_animation (folder)
  • neopixel.mpy (file)

All of the other necessary code is baked into CircuitPython!

CircuitPython Code

Copy the program below and paste it into a new document in Mu. Then, save it from Mu onto your CIRCUITPY flash drive as code.py

# SPDX-FileCopyrightText: 2020 Anne Barela for Adafruit Industries
#
# SPDX-License-Identifier: MIT

"""Bluetooth Key Tracker."""
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

battery = AnalogIn(BATTERY)

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

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)

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


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()


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 = 0

while True:
    if reed_switch.value:  # Not Docked
        hits = 0
        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))
        hit_count = len([ones for ones in bin(hits) if ones == '1'])
        solid.color = hit_status[hit_count]
        solid.animate()
        sleep(1)
        if hit_count == 0:
            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:
            boundary_violations = 0

    else:  # Docked
        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()

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.

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.

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.

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.

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.

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.

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.

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.

# 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.

# 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.

# 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.

# 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()

The key fobs play a warning message if they are taken outside the vicinity of the office. An 8 Ω waterproof speaker plays the message. The speaker is driven by an Adafruit I²S 3 watt amplifier which uses the Max98357A chip. This allows the nRF52840 to output a fully digital sound protocol which the amp then translates directly to the speaker. 

The positive and negative leads from the speaker are connected to the corresponding terminals on the amp. Connecting the amp to the Feather only requires a few wires.

  • The grounds are connected.
  • The RX receive pin is connected to LRC which is the left/right clock.
  • TX transmit is connected to BCLK which is the bit clock input.
  • GPIO 9 is connected to DIN which is the digital input signal.
  • The gain pin is grounded via a 100K Ω resistor which gives the maximum 15 decibels of gain.
  • GPIO 6 is connected to SD which is the shutdown pin.
  • The battery pin is connected to the VIN pin.

A magnetic reed switch determines if the fob is in the charging cradle. GPIO 5 is connected to one terminal of the reed switch. The other is connected to ground. A mechanical switch extends the built-in nRF52840 reset button. The RST pin is connected to one terminal of the switch and the other to ground.

The cases for the key fobs were created using the free version of SketchUp.  Click the green button below to download all the SketchUp files used in this project.

A gender cut out affords space for a translucent insert to be illuminated by the built-in NeoPixel of the nRF52840 board mounted to the underside of the cover. There are sections to hold all the other components.  The 2 halves of the case snap together and are secured with 4 M2.5 screws. The top is 3D printed in blue PLA and the gender icons in white. The icons are glued in place with #16 fast setting clear acrylic cement which works great on PLA.

All the component wiring is soldered. 26 AWG wire with PTFE insulation is used for the data connections. I prefer the PTFE which is more commonly known as Teflon because the insulation is very resistant to heat so it does shrivel up during soldering. A thicker 20 AWG wire is used between the speaker and the amp. It’s best to keep the data wires between the Feather and the amp as short as possible.

Since the key fobs are powered by a rechargeable battery, one design challenge is to make the charging as user-friendly as possible.  I didn’t want to have deal with wires or replacing batteries.  Therefore, I implemented a wireless charging system similar to what’s available for mobile phones and electric toothbrushes. Energy is transferred through inductive coupling.  An alternating current is run through a coil in a cradle which creates a fluctuating magnetic field and thanks to Faraday’s law of induction this generates an alternating current in a secondary coil which will be inside the fob.

The charging cradle was designed using the free version of SketchUp.

The cradle is 3D printed using a 2 color scheme. The first 11 layers are green PLA and the rest is a black PLA with a bespeckled finish.

The front panel holds the key fob. It covers the main prism shaped piece which holds the transmitting coil and a magnet designed to activate a reed switch. The transmitting coil is powered by a 9 VDC power supply.

The charging coil generates heat which gets hot enough to deform the PLA. Therefore, the base of the cradle holds a quiet 60 mm fan which sucks air in from the vents on the bottom and blows it over a ferrite plate (38 mm x 38 mm x 2 mm) which is adhered behind the coil. Beside acting as a heatsink, the plate should also improve charging efficiency by concentrating and directing magnetic flux between the transmitter and receiver.

Multiple charging cradles can be snapped together and powered by the same 9 VDC power supply.

Ibeacons are used to determine if the fob’s have been removed from the office suite. I chose USB Ibeacons to obviate battery replacement. The beacons can be plugged into a common 5V USB phone charger.

Since the office suite encompasses 3 of the public restrooms’ walls. I placed a beacon inside the suite on each of the shared walls.

One of the walls is in the reception lobby where the key fobs are kept. Therefore, the key fobs stay in range of the beacons while carried to the restrooms but if a guest wanders off the fob icon will change colors. Green indicates 3 beacons in range, yellow equals 2 beacons, orange equals 1 beacon. Red indicates out of range of all beacons.

Once out of range, the fob will play a warning tone and the following message.

This guide was first published on Jul 22, 2020. It was last updated on Mar 29, 2024.