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
- 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.
- Choose Tools → Board → esp32 → ESP32S3 Dev Module.
- Plug the board into the UART port and choose its port under Tools → Port.
- 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):

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.



| 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
- 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.
- 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".
- 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.
- 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.
- Find your own thresholds: watch the numbers while you stand where the lock should open, and where it should lock again. Set
UNLOCK_RSSIa little below the first number andLOCK_RSSIabout 15 dBm lower, and upload again. - Test the security: change one letter of
SECRETin 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

How it works

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
SECRETis 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_RSSIandLOCK_RSSIbigger, for example −58 and −80. -
It only opens when the fob touches the board: make
UNLOCK_RSSIlower (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_DEGandOPEN_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.