I have built a few localized sensor systems and pieces of roomware over the years, usually because I wanted a room, robot, or other collection of equipment to be slightly more aware of what was going on around it. This particular project started because I had an ESP32-C3 and wanted to see how far I could push it as a very small, very inexpensive edge node.
The ESP32 is obviously not new, and neither is scanning for Bluetooth devices. The part I was interested in was whether one small board could collect useful local radio observations, turn them into reasonably complete telemetry, briefly connect to Wi-Fi, send everything to a local Fabric API, and then get out of the way. Eventually I want the node to run from a battery and small solar panel, which makes leaving every radio running continuously a less attractive solution.
BLE covered most of the devices I expected to encounter. This would ordinarily be a reasonable place to stop, but I also had an HC-05.
Why BLE was not quite enough
Bluetooth Low Energy is ideal for current sensors, beacons, environmental devices, wearables, and the expanding category of small objects that advertise information whether anyone asked them to or not. It is also built into the ESP32-C3, so getting useful observations from BLE is fairly direct.
But Blue Room-style operations and older roomware systems do not contain only new equipment. There are still serial adapters, older embedded controllers, test fixtures, audio devices, and miscellaneous modules built around Bluetooth Classic or Bluetooth Serial. I wanted the observer to see both sides of that divide instead of quietly pretending everything made before BLE had ceased to exist.
The solution was to let the ESP32-C3 handle BLE, Wi-Fi, formatting, and orchestration, while an HC-05 performed Classic Bluetooth inquiry. It is a slightly unusual arrangement because the HC-05 is normally used to create a serial link. Here, it became a second set of ears.
The extremely advanced breadboard phase

The physical build is currently an ESP32-C3 on a breadboard with the HC-05 wired beside it. Nothing is enclosed, weatherproof, or particularly impressive to look at. That is useful at this stage because everything remains accessible, and because an enclosure would imply a confidence in the wiring that had not yet been earned.
The working serial connection ended up using GPIO 4 as the ESP receive pin and GPIO 5 as transmit, with the HC-05 running at 38,400 baud in full AT command mode:
constexpr int HC05_RX_PIN = 4; // ESP RX <- HC-05 TX
constexpr int HC05_TX_PIN = 5; // ESP TX -> HC-05 RX
constexpr uint32_t HC05_BAUD = 38400;
HardwareSerial HC05(1);

That looks simple now. It was preceded by the normal ritual of checking which pins on this particular C3 board were actually useful, confirming which direction RX and TX were being described from, trying the likely baud rates, and determining why a module that had responded a moment ago had decided it no longer wished to discuss the matter.
The HC-05 command-mode portion of the program
Classic discovery depends on the HC-05 remaining in full AT mode. On the module I used, KEY, sometimes labeled EN depending on the breakout board, needed to remain high. Holding the button only during power-up was enough to produce confusing behavior later, especially when the LED pattern changed during inquiry and made it appear that the module had abandoned command mode.
The useful diagnostic was not interpreting the LED. It was sending another AT after inquiry and checking whether the module still replied OK. Electronics are often easier when asked direct questions.
The Classic scan sequence became:
AT
AT+ROLE=1
AT+INQM=0,9,9
AT+INIT
AT+INQ
AT+ROLE=1 puts the module into master role, AT+INQM configures inquiry behavior, and AT+INQ returns visible Classic devices. AT+INIT often answers with ERROR:(17), which in this case is not a crisis. It means the SPP profile is already initialized. The firmware now recognizes that response and continues without holding a small funeral for the scan cycle.
A raw inquiry result looks something like this:
+INQ:23:9:12426,1F00,7FFF
That provides an address, the raw Bluetooth Class of Device value, and an RSSI field. The HC-05 sometimes returns 7FFF instead of a useful signal value, so the payload preserves the raw field and reports the interpreted RSSI as null rather than inventing a number.
After inquiry, the firmware makes an opportunistic AT+RNAME? request for each Classic result. The word opportunistic is doing useful work here. Some cycles return a device name, some do not, and the same device may be more cooperative one minute than the next. The JSON therefore records both name_lookup_attempted and name_lookup_succeeded. A missing name and a failed lookup are not necessarily the same event.
The raw Class of Device value is also retained, then decoded into its format type, major class, minor class, service bits, and recognizable service names where possible. That gives the server useful categories without throwing away the original radio response in case my interpretation is incomplete, which it inevitably will be for at least one strange device manufactured in 2009.
BLE had more data, because of course it did
The ESP32-C3 performs a 12-second BLE scan after the Classic inquiry. For every stored advertisement it can preserve the address and address type, RSSI, advertised name, TX power, appearance, manufacturer data, service UUIDs, service data, and the full raw advertisement in hexadecimal.
Keeping the raw advertisement turned out to be important. During testing, one service-data advertiser appeared under several randomized addresses while carrying the same underlying service data. Treating every address as a permanent device identity would have made the database look more certain than the radio observations actually were. The raw advertisement gives the API and future analysis code something more substantial to work with.
A complete collection cycle took roughly 36 to 42 seconds in the test configuration. Most of that time came from the HC-05 inquiry, remote-name waits, and the 12-second BLE window. The firmware then waits 60 seconds after the cycle actually finishes before beginning the next one, so the commissioning setup produces a complete observation batch about every minute and a half.
One batch, then Wi-Fi
The first version was prepared to connect to Wi-Fi and post individual observations as they appeared. This worked conceptually and was an excellent way to spend time waiting for repeated connection attempts.
The C3 shares its 2.4 GHz radio resources between BLE and Wi-Fi, and repeatedly reconnecting for every device was both inefficient and unnecessarily complicated. It was also the wrong direction for a node intended to run on a small power source.
The final scan cycle is more deliberate:
- Keep Wi-Fi off.
- Run the HC-05 Classic inquiry and remote-name lookups.
- Run the ESP32-C3 BLE scan.
- Build one normalized JSON batch in memory.
- Turn Wi-Fi on and connect once.
- POST the complete batch to the Fabric API.
- Turn Wi-Fi off again.
- Wait for the next cycle.
This reduced radio contention, removed repeated network overhead, and gave the server one coherent scan batch instead of a collection of loosely related requests. It is also much closer to the eventual solar version, where the ESP32 and HC-05 can be powered only for a scan, report their findings, and spend most of their existence doing very little.
The unified payload includes a scanner ID, scan ID, start and finish uptime, result counts, a Classic array, and a BLE array. One successful early batch was just over 2 KB and contained two Classic observations plus three BLE observations. The code pre-reserves 16 KB for the JSON string to avoid repeated heap reallocations when a scan is busier.
{
"scanner_id": "solar-bt-01",
"scan_id": 1,
"scan_started_uptime_ms": 5410,
"scan_finished_uptime_ms": 42226,
"classic_count": 2,
"ble_count": 3,
"classic": [
{
"name": "PP010A",
"name_lookup_succeeded": true,
"class_raw": "1F00",
"rssi_dbm": null
}
],
"ble": [
{
"name": "TC100_805C",
"rssi": -87,
"service_uuid": "00008801-0000-1000-8000-00805f9b34fb"
}
]
}
A brief problem involving too much program
Adding the BLE stack, Wi-Fi, HTTP client, Classic parsing, and JSON handling pushed the compiled sketch to 1,381,233 bytes. The default application partition allowed 1,310,720 bytes. The board had enough physical flash, but the selected partition scheme did not believe I deserved to use it.
Switching the Arduino ESP32 partition scheme to Huge APP (3MB No OTA) solved the immediate problem without stripping out useful fields. OTA updates can be reconsidered later. For a breadboard observer sitting within reach of a USB cable, preserving the complete diagnostic firmware was more useful.
The current ESP32 firmware is available in the bluetooth-scanner-esp repository on GitHub. This is the useful place to start if you would rather use the working code than reproduce the historical pin, baud-rate, and HC-05 command-mode negotiations.
The Fabric API
The edge node deliberately does not obtain or manage real-world time yet. It sends scan IDs and uptime values, while the receiving server adds the authoritative UTC timestamp when the batch arrives. That keeps the microcontroller free of NTP and timezone logic, and makes all locally received telemetry use one clock.
The receiving service runs on a local Windows system using Flask, Waitress, and SQLite. The scanner posts to:
POST http://LOCAL-SENSORHUB-HOST:8000/api/v1/bluetooth/scans
The server address is intended to remain stable through a router DHCP reservation. This is less glamorous than service discovery and considerably more useful when a small embedded node needs a dependable location on the local network.
On receipt, the API validates the batch, creates a scan-batch record, stores Classic and BLE observations in related tables, preserves the original JSON, and returns a server timestamp and database batch ID. The first successful end-to-end response was:
{
"ble_stored": 1,
"classic_stored": 0,
"ok": true,
"received_at": "2026-08-31T16:29:35.255591Z",
"scan_batch_id": 1
}
That was the point where the project stopped being a Bluetooth scanner attached to a serial monitor. The complete path was working: ESP32-C3, radio scan, Wi-Fi reconnect, HTTP POST, server timestamp, SQLite storage, and a 201 Created response back to the node.
SensorHub and Fabric Metrics

I did not want the API to become a Bluetooth-specific dead end. SensorHub is meant to be a centralized local repository for observations from many small nodes, eventually including environmental, power, robotics, weather, and other telemetry. Bluetooth is simply the first endpoint that arrived with enough data to make the dashboard interesting.
The dashboard now shows total scan batches, BLE and Classic counts, unique observed addresses, active scanner nodes, recent scan volume, protocol distribution, observation tables, and the most recent batches. Selecting an observation opens the stored JSON so I can see exactly what the node sent rather than trusting that the chart understood it.
Recent scan volume also acts as a rough system-health display. A quiet chart may mean a quiet room. It may also mean the scanner is offline, Wi-Fi is unavailable, or the server has found a new way to be unhelpful.
The RSSI proximity view places recent BLE devices against concentric signal-strength rings. It is not a directional map, because one scanner and one RSSI value do not provide direction. It is a relative view of what appears nearer or farther from the node, which is still useful and looks sufficiently like a radar display to justify keeping it.
Where this goes next
The current version is a satisfactory edge-fabric experiment. It collects both Bluetooth generations, preserves more of the radio data than I initially expected, survives intermittent Wi-Fi, and stores coherent batches through a small local API. It is inexpensive, understandable, and not dependent on a cloud service to notice that a speaker exists twelve feet away.
The next hardware version needs a proper enclosure, controlled power to the HC-05, deep sleep between longer scan intervals, local buffering for batches that cannot be delivered immediately, and a battery with a small solar panel. Multiple nodes could then report into the same Fabric API and provide room-by-room observations for Blue-aware roomware and other localized systems.
The true next step is less ambitious and more immediately useful: remove the alligator clip. The clip is currently keeping the HC-05 KEY/EN condition where it needs to be for full AT mode, which works, but only just qualifies as an electrical control system. The ESP32-C3 can take over that job with a GPIO-controlled KEY/EN line and, eventually, a switched power path for the HC-05. Then each cycle can set KEY/EN high, power the Classic radio, wait for a deterministic full-AT startup, run inquiry, and shut the module down again. This should be more repeatable, use less power, and reduce the number of structural duties assigned to office supplies.
There is also the possibility of using it to build the world’s most unnecessarily comprehensive missing-headphone finder. This would require additional nodes, historical signal trends, and a dashboard normally associated with more consequential operations.
After that, broader telemetry, robot integration, environmental sensing, and the usual incremental path toward world domination.
Science!
