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:

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:

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

  1. Stick three anchor modules in your room at known coordinates (I used masking tape and measured carefully)
  2. The tag module (the thing you're tracking) sends UWB pulses
  3. Each anchor measures how long the signal took to arrive
  4. Math happens (trilateration, for the nerds)
  5. Python script plots the position on your floorplan

The real magic is Double-Sided Two-Way Ranging (DS-TWR). Basically:

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:

  1. Put the tag at a known distance from an anchor (use a tape measure, be precise)
  2. Adjust ANTENNA_DELAY until the measured distance matches reality
  3. 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

  1. 3.3V power only – Feeding 5V to the DWM3000 will release the magic smoke
  2. Antenna orientation matters – Keep modules mounted consistently
  3. Multipath is real – Metal objects near the anchors cause wonky readings
  4. Anchor placement – Spread them out in a triangle, not a line
  5. Calibration is per-module – Each DWM3000 has slightly different delays

What's Next?

Some ideas I'm playing with:

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.