Building a Dead-Simple UWB Indoor Tracker That Actually Works
You know that frustrating moment when your GPS tracker shows you're somewhere in the general vicinity of "inside a building"? Yeah, I got tired of that too. So I decided to build a proper indoor positioning system using UWB (Ultra-Wideband) technology. Turns out, you can get centimeter-level accuracy indoors for a reasonable price.
Why UWB Doesn't Suck (Unlike Everything Else)
Look, I've tried the usual suspects:
- WiFi triangulation: Accuracy measured in "which room are you maybe in" (5-15 meters)
- Bluetooth beacons: Cool if you like 1-3 meter error margins
- GPS indoors: laughs in concrete walls
UWB actually measures time-of-flight between devices with nanosecond precision. It's like GPS but for people who care about accuracy.

The Hardware: Keep It Simple
I'm using Qorvo DWM3000 modules because they're basically plug-and-play UWB transceivers. No RF black magic required – they come with the antenna already sorted out. Pair each one with an ESP32 board and you're golden.
Shopping list:
- 4x DWM3000 modules (one for the tag you're tracking, three for anchors)
- 4x ESP32-WROOM boards (because ESP32s are cheap and have WiFi)
- USB cables for power and programming
- Breadboards or whatever mounting solution doesn't make you cry
Wiring (do this 4 times):
DWM3000 → ESP32 VCC → 3.3V (NOT 5V or you'll have a bad time) GND → GND SCK → GPIO18 MOSI → GPIO23 MISO → GPIO19 CS → GPIO4 RST → GPIO27 IRQ → GPIO34 (optional but nice to have)

How This Thing Actually Works
- Stick three anchor modules in your room at known coordinates (I used masking tape and measured carefully)
- The tag module (the thing you're tracking) sends UWB pulses
- Each anchor measures how long the signal took to arrive
- Math happens (trilateration, for the nerds)
- Python script plots the position on your floorplan
The real magic is Double-Sided Two-Way Ranging (DS-TWR). Basically:
- Tag pings Anchor: "Hey, what time is it?"
- Anchor replies: "It's now o'clock"
- Tag pings again: "Cool, so how long did that take?"
- Both sides calculate time-of-flight
- Convert to distance:
distance = time × speed_of_light
Because we do this twice (hence "double-sided"), clock drift errors mostly cancel out. Pretty clever.
The Code: Anchor Side
The anchors just sit there and respond to ranging requests:
void loop() {
switch (curr_stage) {
case 0: // Wait for ranging request
if (DWM3000.receivedFrameSucc() == 1 &&
DWM3000.getDestinationID() == ANCHOR_ID) {
curr_stage = 1;
}
break;
case 1: // Send first response, record timestamps
DWM3000.ds_sendFrame(2);
rx = DWM3000.readRXTimestamp();
tx = DWM3000.readTXTimestamp();
t_replyB = tx - rx;
curr_stage = 2;
break;
case 2: // Wait for second request
// Check for valid frame...
curr_stage = 3;
break;
case 3: // Send timing info back to tag
rx = DWM3000.readRXTimestamp();
t_roundB = rx - tx;
DWM3000.ds_sendRTInfo(t_roundB, t_replyB);
curr_stage = 0; // Back to listening
break;
}
}
Each anchor gets a unique ID (1, 2, 3). That's literally it.
The Code: Tag Side
The tag does all the heavy lifting:
void loop() {
switch (curr_stage) {
case 0: // Start ranging with current anchor
DWM3000.setDestinationID(getCurrentAnchorId());
DWM3000.ds_sendFrame(1);
currentAnchor->tx = DWM3000.readTXTimestamp();
curr_stage = 1;
break;
case 1: // Wait for response...
case 2: // Send second frame...
case 3: // Wait for timing data...
case 4: // Calculate distance
int ranging_time = DWM3000.ds_processRTInfo(...);
currentAnchor->distance = DWM3000.convertToCM(ranging_time);
updateFilteredDistance(*currentAnchor);
// Got valid distances from all 3 anchors?
if (allAnchorsHaveValidData()) {
sendDataOverWiFi(); // JSON to Python script
}
switchToNextAnchor(); // Round-robin through anchors
curr_stage = 0;
break;
}
}
The tag cycles through all three anchors, measures distance to each, then sends the data as JSON over WiFi:
{
"tag_id": 10,
...
Read more »
ElectroScope Archive
glgorman
Dan Fay
Ian Dunn