What You'll Build
An ESP32 Bluetooth Low Energy (BLE) scanner that listens for every BLE device around you — phones, fitness bands, earbuds, smart tags, beacons, other ESP32 boards — and prints a clean, sorted table to the Serial Monitor: address, signal strength (RSSI), a rough distance estimate, the manufacturer, and whether the device is an iBeacon.
No extra hardware is needed. The ESP32 has a Bluetooth radio built in, and the BLE library ships with the board package, so everything in this guide runs on a bare DevKit and a USB cable.
What you will learn:
- How BLE advertising works, and why scanning is simply "listening" for those adverts
- How to set up the Arduino IDE for ESP32 BLE and run a 25-line scanner
- How to average RSSI, filter weak devices and de-duplicate repeated adverts
- How to decode the manufacturer ID and spot an iBeacon in the raw advert bytes
- Why RSSI-to-distance estimates are rough, and how to calibrate them
- How to turn the scanner into a presence detector that switches an LED
Difficulty: Beginner | Build time: about 20 minutes | Board: any ESP32 with BLE (ESP32, S3, C3, C6)
What Is BLE, and How Is It Different from Classic Bluetooth?
"Bluetooth" is really two different radio systems that share a name and a frequency band (2.4 GHz):
| Classic Bluetooth (BR/EDR) | Bluetooth Low Energy (BLE) | |
|---|---|---|
| Typical use | Audio streaming, serial links, file transfer | Sensors, beacons, wearables, small bursts of data |
| Data rate | Up to about 2–3 Mbps | 125 kbps to 2 Mbps (BLE 5) |
| Power use | Higher | Very low — designed for coin cells |
| Connection time | Seconds | A few milliseconds |
| Can broadcast without connecting | No | Yes — advertising |
That last row is what makes a scanner possible. A BLE device does not need a connection to announce itself: it simply transmits a small packet — an advertisement — every so often, and anyone in range can read it. Your scanner never pairs with anything; it just listens.
Which ESP32 Boards Support BLE?
| Chip | Classic Bluetooth | BLE |
|---|---|---|
| ESP32 (original) | Yes | 4.2 |
| ESP32-S2 | No | No Bluetooth at all |
| ESP32-S3 | No | 5 |
| ESP32-C3 | No | 5 |
| ESP32-C6 | No | 5 |
| ESP32-H2 | No | 5 |
The code in this guide uses the standard Arduino BLE library and works on every chip that has BLE. The one board to avoid is the ESP32-S2, which has Wi-Fi but no Bluetooth radio.
How BLE Advertising Works
A BLE device that wants to be found cycles through a simple loop:
- It wakes up and sends the same advertisement on three dedicated advertising channels — 37, 38 and 39 (2402, 2426 and 2480 MHz). These three sit in the gaps between the busiest Wi-Fi channels so they interfere with each other as little as possible.
- It goes back to sleep for the advertising interval — anywhere from 20 ms to 10.24 s. A smart tag might advertise once a second to save its battery; a beacon in a shop might do it ten times a second.
- It repeats, forever.
What Is Inside an Advertisement?
A classic (legacy) advertisement carries at most 31 bytes, organised as a list of small records called AD structures. Each record is length, type, data. Common ones include:
- Flags — whether the device is discoverable and whether it supports classic Bluetooth
- Complete or shortened local name — the text you see in your phone's Bluetooth list
- Service UUIDs — what the device offers, such as heart rate or battery
- TX power level — the power the device claims to transmit at
- Manufacturer-specific data — a 2-byte company ID followed by whatever the manufacturer wants to put there. Apple, Xiaomi and most beacon makers use this record.
A device can also answer a scan request with a second 31-byte packet, the scan response. Many devices keep their name in the scan response to leave room for other data in the main advert.
Active vs Passive Scanning
A passive scanner only listens. An active scanner listens and, when it hears an advert, sends back a scan request to ask for the scan response. Active scanning gives you names and extra data that passive scanning would miss, at the cost of a little more airtime and power. The sketches below use active scanning.
Scan Interval and Window
The scanner does not have to listen continuously. It listens for a window (say 99 ms) once every interval (say 100 ms). If the window equals the interval the radio listens all the time; a shorter window saves power but you will miss more adverts. The window can never be longer than the interval.
Components
4 itemsSetting Up the Arduino IDE
If you have never programmed an ESP32 with the Arduino IDE, add the board package first:
- Open File → Preferences and paste the Espressif board manager URL into Additional boards manager URLs (the current link is in Espressif's Arduino-ESP32 documentation).
- Open Tools → Board → Boards Manager, search for esp32 and install esp32 by Espressif Systems. Use version 3.0 or newer — the sketches here are written for the 3.x core.
- Select your board under Tools → Board (for a generic DevKit, ESP32 Dev Module) and the correct port under Tools → Port.
You do not need to install a BLE library. BLEDevice.h and friends are part of the ESP32 package.
If you get "Sketch too big": the BLE stack is large. In Tools → Partition Scheme pick Huge APP (3MB No OTA/1MB SPIFFS) and upload again.
Make a Test Beacon with Your Phone
Scanning an empty room is boring. On an Android phone, install nRF Connect for Mobile, open the Advertiser tab, create a new advertiser with a recognisable local name such as SolderHubTest, and switch it on. Your phone is now a BLE beacon the ESP32 can find. A fitness band or a smart tag works just as well.
Sketch 1: The Minimal Scanner
Start with the smallest sketch that does something useful. It scans for five seconds, prints every device it hears, clears the result list, waits two seconds and repeats.
What Each Setting Does
| Call | What it does | Value used |
|---|---|---|
BLEDevice::init("") | Starts the Bluetooth stack. The name only matters if you advertise, which a scanner does not. | empty |
setAdvertisedDeviceCallbacks(cb) | Runs your onResult() function every time an advert arrives | your class |
setActiveScan(true) | Sends scan requests so devices reply with names and extra data | true |
setInterval(100) | Time between the start of two scan windows, in ms | 100 |
setWindow(99) | How long the radio listens inside each interval, in ms (must be ≤ interval) | 99 |
start(5, false) | Scans for 5 seconds. It blocks until the scan finishes. | 5 |
clearResults() | Frees the memory the library uses to remember devices | after every scan |
Always call
clearResults(). Without it the library keeps every device it has ever seen, and a scanner left running in a busy place will eventually run out of RAM.
Open the Serial Monitor at 115200 baud. Within a few seconds you should see a stream of lines, one per device. Names are often missing and addresses look random — that is normal, and the second sketch makes sense of it.
Sketch 2: A Proper Scanner — RSSI, Vendor and iBeacon Table
The minimal sketch prints every advert as it arrives, which quickly becomes a wall of text: one phone may advertise ten times in five seconds. This version keeps a small table in RAM and does four useful things:
- De-duplicates by address, so each device appears once
- Averages the RSSI over every advert heard during the scan, which smooths out the jitter
- Decodes the manufacturer ID from the raw bytes and names the common ones
- Flags iBeacons and whether the device is connectable
At the end of each scan it sorts the table strongest-first and prints it.
How the Code Works
Why a Fixed Table and char Arrays?
The scanner callback runs on a different task from loop(), and it can fire dozens of times a second. Using fixed-size arrays and plain char buffers instead of String objects avoids repeated heap allocation, which keeps memory stable for hours and avoids fragmentation. The table holds up to 40 devices; raise MAX_DEVICES if you scan somewhere crowded.
setAdvertisedDeviceCallbacks(cb, true)
The second argument is wantDuplicates. With false the library calls you only for the first advert from each address. With true it calls you for every advert — which is what we want, because averaging many RSSI readings is how we get a stable number.
Finding the Manufacturer
getManufacturerData() returns the raw bytes of the manufacturer-specific record. The first two bytes are the Bluetooth SIG company identifier, stored low byte first (little-endian), so Apple's 0x004C arrives as the bytes 4C 00. The line
s.company = (uint8_t)md[0] | ((uint8_t)md[1] << 8);
rebuilds the 16-bit value. The (uint8_t) casts matter: a plain char is signed on the ESP32, and a byte above 0x7F would otherwise turn negative and corrupt the result.
Spotting an iBeacon
An iBeacon is just a manufacturer-specific record with a fixed layout, which the next section describes. The sketch checks for Apple's ID, a type byte of 0x02 and a length byte of 0x15 (21), plus at least 25 bytes in total.
Sorting and the Flags Column
sortByRssi() is a small insertion sort — more than fast enough for 40 entries. In the FLAGS column, C means the device is connectable (you could open a connection to it) and B means iBeacon. ADVS is how many adverts were heard during the scan, a handy hint at the device's advertising interval.
Example Output
This is what the Serial Monitor looks like. Yours will differ, because it depends entirely on what is around you.
=== 4 devices in 5 s ===
# ADDRESS RSSI ~DIST ADVS VENDOR FLAGS NAME
1 aa:bb:cc:00:11:02 -55 0.7 1 Xiaomi C- Mi Band
2 aa:bb:cc:00:11:03 -60 1.1 1 Apple -B (no name)
3 aa:bb:cc:00:11:01 -72 3.3 2 Apple C- (no name)
4 aa:bb:cc:00:11:05 -80 6.9 1 - C- NoMfr
Many devices will show (no name). That is not a bug: plenty of phones and tags never put a name in their adverts.
RSSI and Distance: Useful, But Never Exact
RSSI (Received Signal Strength Indicator) is how strongly the scanner received an advert, in dBm. It is always negative; closer to zero is stronger.
| RSSI | Signal | Typical meaning |
|---|---|---|
| −30 to −50 dBm | Excellent | Within about a metre |
| −50 to −70 dBm | Good | Same room |
| −70 to −85 dBm | Fair | Across a room, or one wall away |
| −85 to −95 dBm | Poor | Edge of range, adverts get missed |
| Below −95 dBm | Unusable | Practically out of range |
Turning RSSI into Distance
A common approximation is the log-distance path-loss model:
distance (m) = 10 ^ ((RSSI at 1 m − measured RSSI) / (10 × n))
RSSI at 1 m is the signal you measure with the device one metre away, and n is the path-loss exponent: about 2 in open air, 2.5–4 indoors. With the sketch's defaults (−59 dBm and n = 2.5) you get:
| Measured RSSI | n = 2.0 | n = 2.5 | n = 3.0 |
|---|---|---|---|
| −50 dBm | 0.4 m | 0.4 m | 0.5 m |
| −60 dBm | 1.1 m | 1.1 m | 1.1 m |
| −70 dBm | 3.5 m | 2.8 m | 2.3 m |
| −80 dBm | 11.2 m | 6.9 m | 5.0 m |
| −90 dBm | 35.5 m | 17.4 m | 10.8 m |
Notice how much the answer swings with n at weak signals: −90 dBm could mean 11 m or 35 m. That is the honest reality of RSSI ranging.
Why RSSI Lies
- Multipath fading. 2.4 GHz waves bounce off walls, floors and furniture and add up or cancel out at the antenna. Moving a few centimetres can change RSSI by 10 dB.
- People. Water-filled bodies absorb 2.4 GHz. A phone in a pocket reads weaker than the same phone on a table.
- Different transmit powers. Two devices at the same distance can differ by 10 dB or more, because each one transmits at its own power.
- Antenna orientation and interference. Wi-Fi, microwave ovens and other BLE devices all raise the noise.
How to Get a Better Number
- Calibrate
RSSI_AT_1M. Place your beacon exactly one metre from the ESP32, run the scanner for a minute and use the average asRSSI_AT_1M. - Average. The table already averages every advert in a scan. For a calmer reading, also average across several scans.
- Use zones, not metres. "Near", "in the room" and "far" are reliable. "3.2 m" is not.
Reading Manufacturer Data and iBeacons
Every manufacturer-specific record starts with a 2-byte company ID. Here are the ones the sketch recognises (the complete list is the Bluetooth SIG's Assigned Numbers document):
| Company ID | Company | Typically seen on |
|---|---|---|
0x004C | Apple | iPhones, AirPods, AirTags, Apple Watch |
0x0006 | Microsoft | Windows laptops and Surface devices |
0x0075 | Samsung | Galaxy phones, watches, SmartTags |
0x00E0 | Android and Nest devices | |
0x0059 | Nordic Semiconductor | Many beacons and dev kits |
0x02E5 | Espressif | ESP32-based products |
0x0087 | Garmin | Watches and bike computers |
0x038F | Xiaomi | Mi Band and other wearables |
The iBeacon Layout
An iBeacon is Apple's beacon format. It is nothing more than a manufacturer-specific record with 25 bytes:
| Bytes | Value | Meaning |
|---|---|---|
| 0–1 | 4C 00 | Apple company ID |
| 2 | 02 | iBeacon type |
| 3 | 15 | Remaining length: 21 bytes |
| 4–19 | 16 bytes | Proximity UUID — identifies your deployment |
| 20–21 | 2 bytes | Major — a group, such as a floor or a shop |
| 22–23 | 2 bytes | Minor — an individual beacon |
| 24 | 1 signed byte | Measured power at 1 m, used for distance estimates |
Byte 24 is the beacon's own calibration value, and it is a better RSSI_AT_1M than a guess — if you want to extend the sketch, read it and use it. Retail beacons from many makers follow this layout, which is why the scanner can flag them.
Project: A BLE Presence Detector
Here is a practical use for a scanner: switch an LED on while a particular beacon is nearby, and off when it has gone for ten seconds. Think of a "welcome home" light, or a reminder that you have left your keys tag behind.
The sketch does not need the table. It runs a short scan, remembers when it last saw a device with the target name, and compares the signal strength with a threshold. Change TARGET_NAME to the name your beacon advertises — the SolderHubTest advertiser from your phone works perfectly.
Tuning the Detector
NEAR_RSSIsets the "close" radius. Raise it toward −55 for a small zone; lower it toward −80 for a whole room. Test with the beacon in your pocket, since your body weakens the signal.TIMEOUT_MSstops the LED flickering when a few adverts are missed. Ten seconds is a good starting point.- Name matching works when the beacon advertises a fixed name. To match by address instead, compare
dev.getAddress().toString().c_str()with a fixed string — but read the next section first.
Why Your Phone Is Hard to Track: Address Privacy
You may notice that your phone's address changes between scans, or that the same phone appears under several addresses. This is deliberate. Modern phones, earbuds and many wearables use random private addresses that rotate every few minutes (often around 15) so that strangers cannot follow them around by logging a fixed address. Only devices that already share a secret key with the phone — the "identity resolving key" — can recognise it again.
What that means for your projects:
- Beacons and tags with a fixed address or name are easy to detect, which is why presence detectors use them.
- Phones are unreliable targets. If you need to know whether your phone is near, make it advertise a fixed name with an app, or use a dedicated tag.
- Be thoughtful. A scanner only reads what devices broadcast openly, but logging and following people's devices without their knowledge is a privacy problem — and illegal in many places. Use your scanner to learn BLE and to build things for yourself, not to track other people.
Troubleshooting
| Problem | Likely cause | Fix |
|---|---|---|
Sketch too big error | BLE stack fills the default partition | Tools → Partition Scheme → Huge APP (3MB No OTA/1MB SPIFFS) |
Compile errors about BLEScan or onResult | Old ESP32 core | Update esp32 by Espressif Systems to 3.0 or newer in the Boards Manager |
| Nothing is ever found | ESP32-S2 board, or no BLE devices nearby | Confirm your chip has BLE (not the S2); switch on your test beacon |
| Names are all missing | Passive scanning | Keep setActiveScan(true) |
| The same device shows up many times | Random address rotation, or you used the minimal sketch | Use the table sketch; remember phones change address |
| Only a few devices appear | Scan window much shorter than the interval | Use setInterval(100) and setWindow(99) |
| Distance numbers look wrong | Uncalibrated RSSI_AT_1M or wrong n | Calibrate at 1 m and treat the result as a zone, not a measurement |
| Board resets or hangs after a while | Results never cleared, or a weak power supply | Keep clearResults(); use a good USB cable and port |
| Wi-Fi gets slow while scanning | Wi-Fi and Bluetooth share the same 2.4 GHz radio | Scan in shorter bursts, or pause scanning during heavy Wi-Fi traffic |
| Serial Monitor shows garbage | Wrong baud rate | Set the Serial Monitor to 115200 |
Ideas to Extend the Project
- Send results to the network. Publish the table to an MQTT broker and chart it in Home Assistant.
- Count nearby devices over the day for a simple occupancy trend — counts only, with no logging of individual addresses.
- Log to an SD card with timestamps for a signal-strength survey of a building.
- Filter by service UUID to find just heart-rate straps or just beacons from your own deployment.
- Read the iBeacon UUID, major and minor and show them in the table.
- Combine with a sensor. A BLE scanner on one ESP32 can collect readings that other ESP32 boards advertise in their manufacturer data — a neat way to move small values without Wi-Fi or pairing.
- Try the NimBLE-Arduino library, an alternative BLE stack that uses noticeably less RAM and flash. The scanning ideas in this guide carry over directly.
Frequently Asked Questions
Can an ESP32 scan for Bluetooth and Wi-Fi at the same time? Yes, the radio is shared between them, so both work but each gets less airtime. Expect some slowdown, especially during heavy Wi-Fi traffic.
Can this scanner see classic Bluetooth devices such as speakers and headsets? Only if they are also advertising over BLE. Classic Bluetooth discovery uses a different mechanism that this library does not use.
What is the maximum BLE range? Typically 10–30 m indoors and up to roughly 100 m in open air with a good line of sight. BLE 5's Coded PHY can go much further, but this scanner listens for the standard advertisements.
Why does my phone not appear in the list? Many phones only advertise when Bluetooth is on and an app or system service is using it, and they use random addresses. Use the nRF Connect advertiser to make your phone visible with a fixed name.
Is RSSI the same on every device? No. Different chips, antennas and transmit powers give different readings for the same physical distance, which is why calibration matters.
Can I connect to a device I find?
Yes — the library has a BLEClient class for connecting to a device's services. That is the next step after scanning, and a good follow-up project.
Does scanning drain the ESP32's battery? Continuous scanning uses a fairly high current, because the radio is listening almost all the time. For battery projects, scan in short bursts and use deep sleep in between.
Do I need to pair with a device to read its advert? No. Advertisements are broadcast openly, so no pairing or connection is involved.
Related on SolderHub
- ESP32 Pinout Guide: Every GPIO, ADC, PWM, and Restricted Pin Explained
- ESP-NOW on ESP32: Send Sensor Data Wirelessly Without Wi-Fi or a Router
- ESP32 Deep Sleep Guide: Extend Battery Life from Days to Months
- ESP32 MQTT Tutorial: Publish and Subscribe with Arduino IDE
- LoRa with ESP32 and Arduino Uno: Long-Range Wireless
Published on SolderHub — the electronics platform with circuit diagrams, simulators and production-ready code.