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,
"anchors": {
"A1": {"distance": 116.82, "rssi": -62.23},
"A2": {"distance": 112.60, "rssi": -68.32},
"A3": {"distance": 123.86, "rssi": -68.28}
}
}
The Math: Trilateration For Dummies
You've got three distances. Each anchor is at the center of a circle with that radius. Where do the circles intersect? That's where the tag is.
In practice, circles never intersect perfectly (thanks, noise), so we use least-squares optimization:
def trilaterate(distances, anchor_positions):
def equations(p):
x, y = p
return [
np.sqrt((x - anchor_positions[0][0])**2 +
(y - anchor_positions[0][1])**2) - distances[0],
np.sqrt((x - anchor_positions[1][0])**2 +
(y - anchor_positions[1][1])**2) - distances[1],
np.sqrt((x - anchor_positions[2][0])**2 +
(y - anchor_positions[2][1])**2) - distances[2],
]
initial_guess = np.mean(anchor_positions, axis=0)
result = least_squares(equations, initial_guess)
return result.x if result.success else None
SciPy does the hard work. We just give it the equations and it finds the best-fit position.
The Visualization: Pretty Dots On A Map
Python script receives JSON over TCP, runs trilateration, plots it live:
def update(frame):
global latest_data
with data_lock:
if latest_data:
d1, d2, d3 = latest_data
pos = trilaterate([d1, d2, d3], ANCHOR_POSITIONS)
if pos is not None:
x_cm, y_cm = pos
tag_dot.set_data([x_cm], [y_cm])
path_x.append(x_cm)
path_y.append(y_cm)
path_line.set_data(path_x, path_y)
return tag_dot, path_line
ani = animation.FuncAnimation(fig, update, interval=100)
plt.show()
Throw your floorplan image as a background and watch the magic happen.
Calibration: The Annoying But Necessary Part
The antenna delay is critical. If you don't set it correctly, every measurement will be off by a constant amount. I'm using 16350 as the delay value, which works for my setup. Your mileage may vary.
To calibrate:
- Put the tag at a known distance from an anchor (use a tape measure, be precise)
- Adjust
ANTENNA_DELAYuntil the measured distance matches reality - Once it's close, that value works for all modules
Results: Does It Actually Work?
Accuracy: ~10cm in open space. In my cluttered office with metal furniture everywhere, more like 15-20cm. Still way better than any other indoor positioning tech I've tried.
Update rate: About 10-15 position updates per second with three anchors.
Range: Reliable up to about 15 meters indoors. The DWM3000 specs say up to 200 meters in open air, but realistically you're looking at 50-100 meters indoors depending on obstacles.
Things That Will Bite You
- 3.3V power only – Feeding 5V to the DWM3000 will release the magic smoke
- Antenna orientation matters – Keep modules mounted consistently
- Multipath is real – Metal objects near the anchors cause wonky readings
- Anchor placement – Spread them out in a triangle, not a line
- Calibration is per-module – Each DWM3000 has slightly different delays
What's Next?
Some ideas I'm playing with:
- Fourth anchor for redundancy and better geometry
- 3D tracking by mounting anchors at different heights
- TDoA instead of TWR to track multiple tags simultaneously
- Kalman filtering for smoother position estimates
- Zone detection to trigger events when entering/leaving areas
The Code
Full code, circuits, and Python scripts are all available as part of this comprehensive guide: UWB Indoor Positioning System using ESP32
Final Thoughts
This was way easier than I expected. The DWM3000 does all the RF heavy lifting, the ESP32 handles the WiFi and number crunching, and Python makes it pretty.
If you've been wanting to play with UWB but thought it was too complicated, give this a shot. It's basically just SPI communication and some high school geometry.
ElectroScope Archive