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

  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:

  • 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 »