Build a Bluetooth LE key fob that unlocks a lock when you come close

Make a keyless entry system like the ones in modern cars. The ESP32 from the kit becomes a Bluetooth LE key fob that sends a new secret code every second, and an ESP32-S3 lock listens for it, checks the code,...
  • Level:Advanced
  • Duration: 2 h 30 min
  • Ages 12+
  • Platform: ESP32

Tools for this lesson

Helpful for this build. Open a tool to see tips and more projects that use it.

Build a Bluetooth LE key fob that unlocks a lock when you come close – EEMENTOR STEM™ lesson

Many cars open as soon as you walk up with the key in your pocket, and lock again when you walk away. The key keeps calling out by radio, and the car listens and measures how strong the signal is. In this capstone you build the same thing with Bluetooth Low Energy (BLE). The ESP32 from your kit becomes the key fob: every second it sends a new code that only works once. An ESP32-S3 is the lock: it listens to every BLE device nearby, picks out your fob, checks the code with a shared secret, and opens a servo latch when the fob is close. A buzzer beeps, and the S3's colour LED shows red, yellow or green. Along the way you'll find out why a code that never changes is a bad idea, and how real keyless systems are attacked.

What you'll learn

  • How Bluetooth LE devices advertise, and how a scanner hears all of them
  • How to put your own data into a BLE advertisement (manufacturer data)
  • How a rolling code with HMAC-SHA256 stops someone from recording and replaying your key
  • How to estimate distance from signal strength (RSSI), smooth it, and use two thresholds so the lock doesn't flicker
  • How to drive a servo from the ESP32-S3 with plain PWM

What you need

  • An ESP32-S3 board: the Espressif ESP32-S3-DevKitC-1 or a copy of it (for example N8R8 or N16R8), with a USB-C cable. It is not in the kit.
  • ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit): the key fob
  • SG90 servo motor (ESP32 IoT Starter Kit): the latch
  • Passive buzzer (ESP32 IoT Starter Kit)
  • Breadboard and jumper wires (ESP32 IoT Starter Kit)
  • A power bank or phone charger to carry the fob around
  • Optional: a small box with a lid, so the servo arm can hold it shut

About the ESP32-S3-DevKitC-1: it is longer than the ESP32 board from the kit and has two USB-C ports. Use the one labelled UART (or COM) for uploading and the Serial Monitor. The small LED near the ports is an RGB LED that the sketch can set to any colour. The GPIO numbers are printed next to the pins. The ESP32-S3 works at 3.3 V like the classic ESP32, so the same sensors work with it.

Set up the ESP32-S3 in the Arduino IDE

  1. In the Arduino IDE, open Boards Manager and install or update esp32 by Espressif Systems to version 3 or newer. The ESP32-S3 support, Wi-Fi, Bluetooth, LittleFS and ESP-NOW all come with it.
  2. Choose Tools → Board → esp32 → ESP32S3 Dev Module.
  3. Plug the board into the UART port and choose its port under Tools → Port.
  4. If an upload ever fails with "Failed to connect", hold the BOOT button, press and let go of RST, let go of BOOT, and upload again. Press RST once more afterwards to start the sketch.

The key fob is the ESP32 from your kit: for its sketch, choose ESP32 Dev Module as usual. With both boards plugged in, check the port before every upload.

Wiring

The fob needs no wiring. The servo and the buzzer go on the ESP32-S3 (the lock):

Breadboard wiring diagram: Build a Bluetooth LE key fob that unlocks a lock when you come close
Breadboard view. Check each connection against the wiring table below.

Build it step by step

Plug in all the parts first, then add the wires in this order. In each picture the new wires are bright and the ones from earlier steps are faded. Click a picture to see it larger.

Step 1 of 3: GND from the ESP32-S3 to the − rail
Step 1. GND from the ESP32-S3 to the − rail
Step 2 of 3: Passive buzzer: + → GPIO 6, − → − rail
Step 2. Passive buzzer: + → GPIO 6, − → − rail
Step 3 of 3: Servo: signal → GPIO 5, red → 5V, brown → − rail
Step 3. Servo: signal → GPIO 5, red → 5V, brown → − rail
From To
Servo orange wire (signal) ESP32-S3 GPIO 5
Servo red wire 5V (the pin may be labelled 5V0 or 5Vin)
Servo brown wire GND
Buzzer + (longer leg) ESP32-S3 GPIO 6
Buzzer − GND

One small servo can run from the 5V pin when the board is on USB. If the S3 restarts when the servo moves, use a good USB port or a powered hub.

The sketches

No extra libraries are needed: Bluetooth and the HMAC code come with the esp32 boards package. First change SECRET in both sketches to the same secret of your own, at least 16 characters long. Then upload the fob sketch to the kit's ESP32:

// BLE KEY FOB (the ESP32 from the kit): sends a new secret code every second
#include <BLEDevice.h>
#include <BLEAdvertising.h>
#include <Preferences.h>
#include "mbedtls/md.h"

// ---------- settings ----------
const char* SECRET = "change-this-secret";          // the same in the lock sketch, at least 16 characters
const unsigned long CODE_MS = 1000;                // a new code every second

Preferences memory;
BLEAdvertising* adv;
uint32_t counter;
unsigned long lastCode = 0;

// The code for one counter value: the first 8 bytes of HMAC-SHA256(SECRET, counter).
void makeCode(uint32_t c, uint8_t code[8]) {
  uint8_t msg[4], full[32];
  memcpy(msg, &c, 4);
  mbedtls_md_hmac(mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), (const uint8_t*)SECRET, strlen(SECRET), msg, 4, full);
  memcpy(code, full, 8);
}

void advertiseCode() {
  // manufacturer data: 0xFFFF (the test company ID), "EEKEY", the counter (4 bytes), the code (8 bytes)
  uint8_t data[19] = {0xFF, 0xFF, 'E', 'E', 'K', 'E', 'Y'};
  memcpy(data + 7, &counter, 4);
  makeCode(counter, data + 11);
  BLEAdvertisementData ad;
  ad.setFlags(0x06);                               // a Bluetooth LE device that anyone may see
  ad.setManufacturerData(String(data, 19));
  adv->stop();
  adv->setAdvertisementData(ad);
  adv->start();
}

void setup() {
  Serial.begin(115200);
  memory.begin("fob", false);
  counter = memory.getUInt("counter", 0) + 100;    // never reuse a code, even after a restart
  memory.putUInt("counter", counter);
  BLEDevice::init("EE Key");
  adv = BLEDevice::getAdvertising();
  adv->setMinInterval(128);                        // advertise every 80 to 100 ms (units of 0.625 ms)
  adv->setMaxInterval(160);
  advertiseCode();
  Serial.println("Key fob running");
}

void loop() {
  if (millis() - lastCode >= CODE_MS) {
    lastCode = millis();
    counter++;
    if (counter % 100 == 0) memory.putUInt("counter", counter);   // save now and then, not every second
    advertiseCode();
    Serial.printf("Code number %lu\n", (unsigned long)counter);
  }
}

And the lock sketch to the ESP32-S3:

// BLE PROXIMITY LOCK (ESP32-S3): opens a servo latch when the right key fob comes close
#include <BLEDevice.h>
#include <BLEScan.h>
#include <BLEAdvertisedDevice.h>
#include <Preferences.h>
#include "mbedtls/md.h"

// ---------- settings ----------
const char* SECRET = "change-this-secret";          // the same as in the fob sketch
const int UNLOCK_RSSI = -60;                       // stronger than this (in dBm): the fob is close, open
const int LOCK_RSSI = -75;                         // weaker than this: the fob has walked away, lock
const unsigned long GONE_MS = 5000;                // no valid code for 5 s: lock
const int SERVO_PIN = 5;
const int BUZZER_PIN = 6;
const int LOCKED_DEG = 0, OPEN_DEG = 90;           // servo angles for the latch
const int RGB_PIN = RGB_BUILTIN;                   // DevKitC-1 v1.1: use 38 instead

Preferences memory;
BLEScan* scan;
volatile float rssiAvg = -100;                     // smoothed signal strength of the fob
volatile int rssiLast = -100;
volatile unsigned long lastSeen = 0;               // millis() of the last valid code
volatile uint32_t lastCounter = 0, rejected = 0;
uint32_t savedCounter = 0;
bool unlocked = false;
unsigned long lastPrint = 0, lastSave = 0;

void makeCode(uint32_t c, uint8_t code[8]) {       // exactly the same as in the fob
  uint8_t msg[4], full[32];
  memcpy(msg, &c, 4);
  mbedtls_md_hmac(mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), (const uint8_t*)SECRET, strlen(SECRET), msg, 4, full);
  memcpy(code, full, 8);
}

// Called for every Bluetooth LE advertisement the S3 hears, from any device nearby.
class FobFinder : public BLEAdvertisedDeviceCallbacks {
  void onResult(BLEAdvertisedDevice dev) {
    if (!dev.haveManufacturerData()) return;
    String m = dev.getManufacturerData();
    if (m.length() != 19 || memcmp(m.c_str(), "\xFF\xFF" "EEKEY", 7) != 0) return;   // not a key fob
    uint32_t c;
    uint8_t code[8];
    memcpy(&c, m.c_str() + 7, 4);
    makeCode(c, code);
    if (memcmp(code, m.c_str() + 11, 8) != 0 || c < lastCounter) {   // wrong secret, or an old copied code
      rejected++;
      return;
    }
    lastCounter = c;
    int r = dev.getRSSI();
    rssiLast = r;
    if (lastSeen && millis() - lastSeen < 2000) rssiAvg = rssiAvg * 0.8 + r * 0.2;   // smooth out the jumps
    else rssiAvg = r;
    lastSeen = millis();
  }
};

void servoWrite(int deg) {                         // 50 pulses a second, 0.5 to 2.4 ms long
  int us = map(deg, 0, 180, 500, 2400);
  ledcWrite(SERVO_PIN, us * 16383L / 20000);
}

void unlock() {
  unlocked = true;
  servoWrite(OPEN_DEG);
  tone(BUZZER_PIN, 1568, 80);
  delay(100);
  tone(BUZZER_PIN, 2093, 120);
  Serial.println(">>> UNLOCKED");
}

void lock() {
  unlocked = false;
  servoWrite(LOCKED_DEG);
  tone(BUZZER_PIN, 784, 150);
  Serial.println(">>> LOCKED");
}

void setup() {
  Serial.begin(115200);
  ledcAttach(SERVO_PIN, 50, 14);                   // 14-bit PWM at 50 Hz for the servo
  servoWrite(LOCKED_DEG);
  memory.begin("lock", false);
  lastCounter = savedCounter = memory.getUInt("counter", 0);   // remember used codes after a restart too

  BLEDevice::init("");
  scan = BLEDevice::getScan();
  scan->setAdvertisedDeviceCallbacks(new FobFinder(), true);   // true: report every advertisement, not just new devices
  scan->setActiveScan(false);                      // only listen, never ask devices for more
  scan->setInterval(100);
  scan->setWindow(99);                             // listen almost all the time
  scan->start(0, nullptr, false);                  // 0 = scan forever, in the background
  Serial.println("Lock running: waiting for the key fob");
}

void loop() {
  unsigned long seenAt = lastSeen, now = millis();  // read once: the scanner may change it at any moment
  bool seen = seenAt && now - seenAt < GONE_MS;
  float rssi = rssiAvg;

  if (!unlocked && seen && rssi > UNLOCK_RSSI) unlock();
  else if (unlocked && (!seen || rssi < LOCK_RSSI)) lock();

  if (unlocked) rgbLedWrite(RGB_PIN, 0, 30, 0);            // green: open
  else if (seen) rgbLedWrite(RGB_PIN, 30, 18, 0);          // yellow: fob seen, but too far away
  else rgbLedWrite(RGB_PIN, 30, 0, 0);                     // red: locked, no fob

  if (millis() - lastPrint >= 1000) {              // for setting UNLOCK_RSSI and LOCK_RSSI
    lastPrint = millis();
    if (seen) Serial.printf("Fob: %d dBm, smoothed %.0f dBm, %s, %lu rejected\n", (int)rssiLast, rssi, unlocked ? "open" : "locked", (unsigned long)rejected);
    else Serial.printf("No fob, %s, %lu rejected\n", unlocked ? "open" : "locked", (unsigned long)rejected);
  }
  if (millis() - lastSave >= 60000 && lastCounter != savedCounter) {
    lastSave = millis();
    savedCounter = lastCounter;
    memory.putUInt("counter", savedCounter);
  }
  delay(50);
}

Try it

  1. Open the lock's Serial Monitor at 115200 baud. With the fob switched off it says "No fob, locked" every second, and the S3's LED is red.
  2. Power the fob from a power bank and hold it about 2 m away. The LED turns yellow and the Serial Monitor shows the signal, for example "Fob: -71 dBm, smoothed -70 dBm, locked".
  3. Walk closer. When the smoothed value rises above −60 dBm, the buzzer plays two rising notes, the servo turns to 90° and the LED turns green: unlocked.
  4. Walk away. Below −75 dBm, or 5 seconds after the last valid code (for example when you switch the fob off), the servo turns back and the buzzer gives one low beep.
  5. Find your own thresholds: watch the numbers while you stand where the lock should open, and where it should lock again. Set UNLOCK_RSSI a little below the first number and LOCK_RSSI about 15 dBm lower, and upload again.
  6. Test the security: change one letter of SECRET in the fob sketch and upload it. The fob still advertises, but the lock rejects every code and the "rejected" count goes up. Change it back.

What you should see

The lock's Serial Monitor as the fob comes close and walks away, with UNLOCKED and LOCKED lines, and the red, yellow and green LED states with the servo at 0° or 90°

How it works

The fob's 19 advertisement bytes with counter and HMAC code, which codes the lock accepts or rejects, and a chart of jumpy and smoothed RSSI crossing the −60 and −75 dBm thresholds

A Bluetooth LE device that wants to be found sends short advertisements on three radio channels, many times a second, to nobody in particular. Your phone sees headphones and watches this way. An advertisement has room for 31 bytes, and part of it can be manufacturer data, bytes that the maker of the device decides. The fob fills 19 bytes: the test company number 0xFFFF, the word EEKEY so the lock can recognise it, a counter and an 8-byte code. The lock runs a BLE scan forever and gets a call to onResult() for every advertisement from every device around it, then ignores everything that isn't a fob.

Why not just send a fixed password? Because anyone with a BLE scanner (there are free phone apps) could record the advertisement and play it back later: a replay attack. So the code changes every second. It is made with HMAC-SHA256: a mathematical "blender" that mixes the counter with the secret into 32 bytes that look random. Without the secret, nobody can work out the code for the next counter, and the lock only accepts a counter that is not lower than the last one it saw, so a recording of a code the lock has already heard is useless. Both counters are saved in flash (the lock saves its counter once a minute), so after a restart the lock still rejects codes that are more than about a minute old. There is one gap: the fob advertises everywhere, so someone who records it while you are away from the lock, for example at school, could play that newer code back once. Real systems close this gap with a challenge from the lock or a clock. This is the idea behind car keys and garage remotes, which call it a rolling code.

The lock also needs to know how close the fob is. Radio gets weaker with distance, so the received signal strength, RSSI, gives a rough idea: −40 dBm is right next to it, −70 dBm a few metres away, −90 dBm the edge of range. But RSSI jumps around by 10 dBm or more when you turn, when someone walks between, or when the fob is in a pocket. The sketch therefore smooths it: each new value only counts for 20 %, and the average for 80 %. And it uses two different thresholds: open above −60, lock below −75. With only one threshold the lock would click open and shut many times while you stand right at the border. Two thresholds with a gap in between are called hysteresis; your heating thermostat uses the same trick.

Is it safe enough for a front door? No. A thief could use two radios to relay your fob's live signal from your pocket to the door, and real car keys have been stolen exactly like this. Real systems also measure how long the radio signal takes to travel. For a box with your sweets in it, a rolling code and RSSI are plenty.

Try this next

  • Add a "panic" button to the fob: while you hold it, the fob sends a different word instead of EEKEY, and the lock sounds an alarm.
  • Teach the lock several fobs, each with its own secret and name, and print who just came home.
  • Instead of a servo, switch a relay or a lamp, like a light that switches on when you enter the room.
  • Use the ESP-NOW hub idea to show on a web page who is at home right now.
  • Make the fob last longer on a battery: advertise only every 500 ms and put it into light sleep in between.

Troubleshooting

  • The lock never sees the fob ("No fob"): check that the fob is running (its Serial Monitor counts code numbers) and that SECRET is exactly the same in both sketches, including capital letters. A different secret shows up as a rising "rejected" count.
  • It opens and closes again and again: make the gap between UNLOCK_RSSI and LOCK_RSSI bigger, for example −58 and −80.
  • It only opens when the fob touches the board: make UNLOCK_RSSI lower (for example −68). Don't cover the boards' antennas with your hand, and keep the fob out of metal tins.
  • Every code is rejected after you uploaded the fob sketch to a new board: the new board starts counting at 100 again, lower than the lock remembers. Let it run for a few minutes, or set a higher start number in the fob sketch.
  • The S3 restarts when the servo moves: the USB port can't give enough current. Use a different port or a powered USB hub.
  • The servo turns the wrong way: swap the numbers in LOCKED_DEG and OPEN_DEG, or use 180 for the open position.
  • Nothing appears in the lock's Serial Monitor: use the port labelled UART (or COM), or set Tools → USB CDC On Boot to Enabled.
  • The LED stays off: on the ESP32-S3-DevKitC-1 v1.1 the RGB LED is on GPIO 38. Set RGB_PIN = 38.

Build it yourself

Everything used in this lesson comes in one kit, with parts tested by our engineers.

ESP32 IoT Starter Kit | Arduino IDE Compatible | Free Tutorials $57.00

Shop the kit