UWB Indoor Positioning System With ESP32 and Qorvo DWM3000
by ElectroScope Archive in Circuits > Arduino
49 Views, 0 Favorites, 0 Comments
UWB Indoor Positioning System With ESP32 and Qorvo DWM3000

Okay, quick version: We've built a GPS tracker using ESP32 before, but GPS sucks indoors and UWB does not. I built a real-time indoor tracker using an ESP32 and Qorvo DWM3000 modules. You get centimeter-level-ish accuracy by measuring time of flight between a moving tag and fixed anchors. This writeup has the wiring, the firmware setup, the ranging math, the JSON format I used to stream data, and the Python visualization. Copy paste and go.
How it actually works (short and honest)
- DWM3000 sends and listens to very short radio pulses and gives you timestamps.
- We use Double-Sided Two-Way Ranging, or DS-TWR, to cancel clock offsets. That gives distances between tag and anchors.
- With distances to three anchors you do trilateration and get an (x, y) fix. With a fourth anchor you can go 3D.
- Tag runs on an ESP32, does the DS-TWR math, then sends distances or raw timing over Wi-Fi or serial to a computer. The computer runs Python to compute final position and plot it.
Supplies
- 4 × ESP32 dev boards (any common WROOM style)
- 4 × Qorvo DWM3000 UWB modules
- Breadboard, jumper wires, USB cables
- PC or laptop for visualization
- Python with these libs: pyserial, numpy, matplotlib, optionally scipy if you want least squares solver
- Optional but useful: 3D printed mounts, tripods, stable USB power banks
Quick Hardware Notes About the DWM3000

- Built on Qorvo DW3110, supports IEEE 802.15.4z and FiRa PHY/MAC.
- Comes with an onboard UWB antenna, crystal oscillator, power management. No RF design required.
- Channels: it supports Channel 5 (6.5 GHz) and Channel 9 (8 GHz). I used Channel 5.
- Data rate: up to 6.8 Mbps which lets measurements run faster.
- Range: open field up to ~200 m. Indoors expect 50 to 100 m useful range with typical accuracy near 10 cm depending on environment.
- You must calibrate antenna delay or you will be off by a few cm. Typical TX antenna delay I used: 16350 ticks.
Wiring and SPI Pin Mapping

Hook each DWM3000 to an ESP32 over SPI. Same wiring for tag and anchors. Keep wires short.
- ESP32 3V3 -> DWM3000 VIN
- ESP32 GND -> DWM3000 GND
- ESP32 GPIO18 -> DWM3000 SCK
- ESP32 GPIO19 -> DWM3000 MISO
- ESP32 GPIO23 -> DWM3000 MOSI
- ESP32 GPIO5 -> DWM3000 CS
- ESP32 GPIO27 -> DWM3000 IRQ
- ESP32 GPIO22 -> DWM3000 RST
Label your boards as Anchor 1, Anchor 2, Anchor 3, Tag. Put anchors at similar heights and point the antenna roughly the same way.
- Important: DWM3000 runs on 3.3 V. Do not feed it 5 V.
Firmware and Libraries
- Arduino IDE with ESP32 board support installed.
- Qorvo DWM3000 Arduino library (get it from Qorvo GitHub or the library manager).
- Example sketches exist for tag and anchor. Use them as a base. I modified them to:
- assign unique ANCHOR_ID values to anchors (for example 1, 2, 3)
- set the tag ID and antenna delay values
- send JSON over TCP or serial with measured distances and some raw info for debugging
Example of the JSON I used from the tag after each ranging cycle:
I find JSON easy to parse in Python and it is readable for debugging.
What Gets Initialized in Setup on the Tag
- Initialize serial for debug.
- initializeAnchors() with coordinates or IDs.
- connectToWiFi() if you want Wi-Fi streaming.
- DWM3000.begin(), hardReset(), check SPI, wait for IDLE, softReset, DWM3000.init().
- Configure GPIOs and set antenna delay: DWM3000.setTXAntennaDelay(16350) (store and tweak if you calibrate).
- DWM3000.setSenderID(TAG_ID) and configure as TX mode then clear system status.
If DWM3000.checkSPI() fails, stop and debug wiring.
The DS-TWR Flow in Human Terms
DS-TWR is a three exchange trick so clocks do not mess you up:
- Tag sends Poll at T1.
- Anchor receives Poll at T2, then sends Response at T3.
- Tag receives Response at T4 and sends Final with its timestamps. Anchor has its timestamps too.
- Using T1..T6 (tag and anchor timestamps) you compute time of flight while canceling the unknown clock offset.
You do two round trips, combine them, and compute Time of Flight (ToF). Convert ToF ticks to seconds using the DWM3000 tick period and multiply by speed of light to get distance.
Why double-sided: single round trip will error if clocks run at slightly different speeds. Double-sided cancels most of that.
Antenna Delay and Calibration
- DWM3000 has internal TX and RX path delays. If you skip calibration you are off by a few centimeters.
- Set antenna delay using setTXAntennaDelay and setRXAntennaDelay if available, and save it in firmware. I used TX delay 16350 as a starting point.
- Calibration procedure: place tag and anchor at a known distance, measure ToF, adjust antenna delay until reported distance matches the known distance. Repeat for each unit.
Making Measurements Reliable
- UWB is resistant to multipath, but not magic. Poor anchor geometry and nearby metal cause problems.
- Use median filtering or simple averaging on distance samples before trilateration.
- Monitor RSSI and first path power to detect potential NLOS cases. If RSSI drops or first-path is weak, ignore or flag that sample.
- Keep anchors away from metal racks and pipes if possible.
Trilateration and Math
You get distances d1, d2, d3 to anchors at known positions (x1,y1), (x2,y2), (x3,y3).
Ideal math: solve circle intersection. In practice circles almost never intersect exactly because of noise. Use least squares to find best fit.
I used SciPy least_squares on the residuals function that computes differences between measured distances and distances from guessed (x,y). That gives stable results. If you do not want SciPy, you can implement a small iterative solver or do algebraic solution with matrix ops but least squares is easiest.
Python Visualization

Install:
Flow:
- Python listens on serial or TCP.
- Parse incoming JSON with distances and anchor IDs.
- Convert distances into meters.
- Run least_squares to get x,y.
- Real-time plot with matplotlib showing anchors as fixed points and tag as a moving dot. I also display RSSI over anchors for debugging.
If you want smooth movement, add a Kalman filter on the computed positions. That helps a lot when measurements jitter.
Quick test layout I used
- Anchor 1 at (0.0, 0.0)
- Anchor 2 at (3.0, 0.0)
- Anchor 3 at (0.0, 3.0)
Bring the tag around and watch the Python plot. If the tag bounces or is wrong by 30 cm, check wiring, antenna delay, or anchor placement. With decent calibration and geometry, expect around 10 to 15 cm typical indoors.
Troubleshooting Checklist
- Are all modules powered with clean 3.3 V?
- SPI wiring correct and short?
- Antenna orientation similar across anchors?
- Anchors at similar heights?
- Are anchors placed so geometry is not degenerate? Avoid all anchors in a line.
- Check RSSI and first path values for NLOS. Filter those samples.
- Re-run antenna delay calibration per unit.
What I Actually Used for This Build
- Double-Sided Two-Way Ranging mode for tag-anchor distances.
- Channel 5 (6.5 GHz) operation.
- SPI communication to ESP32.
- Antenna delay calibration. Typical start value: 16350 ticks.
- High data rate 6.8 Mbps for faster cycles.
- JSON over serial or TCP for sending measurements to host.
- SciPy least_squares for trilateration on host.
- Optional: Kalman filter for smoothing.
Ideas to Level This Up Later
- Add anchor 4 for 3D position.
- Do TDoA with synchronized anchors for more scalable multi-tag setups.
- Forward position over MQTT or WebSocket to a dashboard.
- Fuse IMU data on the tag to survive short UWB dropouts.
- Integrate with ROS for robot navigation.
This guide is based on the comprehensive material from: UWB Indoor Positioning System using ESP32