To turn a Bluetooth signal strength (RSSI) into a distance, use the log-distance path-loss formula:

distance = 10 ^ ((TxPower - RSSI) / (10 * n)), where RSSI is the signal strength you received in dBm, TxPower is the RSSI that same device gives at exactly one metre, and n is the path-loss exponent that describes how fast the signal fades in your space. Both RSSI and TxPower are negative numbers, and a less negative RSSI means a stronger signal and a shorter distance.

Most pages stop there, with a TxPower of -59 dBm and n = 2 copied from somewhere. This one goes further: a table of what those two guesses do to the answer, 126 real RSSI readings from a Raspberry Pi 5 scan so you can see how much the raw signal moves around, what a 5-reading median does to that movement, and a standard-library Python script that fits TxPower and n from measurements you take yourself.

Here is the formula as a copy-paste Python function:

def rssi_to_distance(rssi, tx_power=-59.0, n=2.0):
    """Metres implied by an RSSI (dBm) under the log-distance model.

    tx_power: RSSI measured at 1 m from this device, in dBm.
    n: path-loss exponent (2 = free space; indoors is usually higher).
    """
    return 10 ** ((tx_power - rssi) / (10 * n))

rssi_to_distance(-80) returns 11.22 (metres) with the defaults. The rest of this page is about why you should not trust that number until you have calibrated both defaults.

What TxPower and n mean

TxPower here is a reference reading: the RSSI your receiver reports when the device is exactly one metre away. When RSSI equals TxPower the formula returns exactly one metre, and every decibel the signal falls below that reference pushes the answer further out.

Be careful with the name. The BlueZ documentation for the TxPower property a Linux Bluetooth stack exposes describes it as β€œAdvertised transmitted power level (inquiry or advertising)” (org.bluez.Device). That is the power at the transmitter, not the strength of the signal a metre away, so it is not the same quantity as the formula’s reference. Measuring the reference yourself, as in the calibration section below, avoids the confusion.

n is the path-loss exponent. n = 2 is the free-space value, the right answer only with nothing in the way. Indoors, walls, furniture and bodies absorb more, so a fitted n above 2 is normal. A larger n means the same drop in signal corresponds to a shorter real distance, so running the free-space n = 2 indoors makes distances come out too long.

What n and TxPower do to the answer

Every figure below is rssi_to_distance(rssi, -59, n) evaluated and rounded to two decimals. None of it is a measurement; it is the formula, laid out so you can see how much the choice of n matters:

RSSI (dBm)n = 2 (m)n = 2.5 (m)n = 3 (m)n = 3.5 (m)n = 4 (m)
-500.350.440.500.550.60
-601.121.101.081.071.06
-703.552.752.332.061.88
-8011.226.925.013.983.35
-9035.4817.3810.807.695.96
-100112.2043.6523.2614.8410.59
each extra 1 dB multiplies by1.1221.0961.0801.0681.059
6 dB multiplies by2.001.741.581.481.41

Read down the -90 dBm row: the same reading is 35.48 m at n = 2 and 5.96 m at n = 4. Near the device the choice hardly matters (every column is close to a metre at -60 dBm); far away it is the whole answer.

The last two rows are the error budget. Because the formula is exponential, a fixed error in dB is a fixed percentage error in distance at every signal level. At n = 2, one dB of error is 12.2% (the Bluetooth tracker project page rounds it to 12%) and 6 dB very nearly doubles the answer. What changes from one end of the table to the other is how many metres that percentage is:

  • Near end, -50 dBm: one dB weaker moves the answer by 0.04 m at every n in the table. Nobody will notice.
  • Far end, -100 dBm, n = 2: one dB weaker moves 112.20 m to 125.89 m, a jump of 13.69 m, and 6 dB turns it into 223.87 m.
  • Far end, -100 dBm, n = 4: one dB moves 10.59 m by 0.63 m, and 6 dB turns it into 14.96 m.

TxPower enters the formula exactly the way RSSI does, so a wrong TxPower is a constant dB error on every reading. Set TxPower 6 dB too high (say -53 dBm where the true one-metre reading is -59 dBm) and every distance comes out 2.00 times too long at n = 2, or 1.41 times at n = 4. Averaging many readings cannot remove that error, because it is the same on every one of them.

126 real readings: what Bluetooth RSSI actually looks like

The data comes from one scan session on 2025-02-19, from 16:52 to 17:12 UTC, run by the Node.js tracker described on the Raspberry Pi Bluetooth Tracker project page on a Raspberry Pi 5. That tracker saved distances rather than RSSI, but it always used TxPower = -59 dBm and n = 2 on whole-number RSSI values, so the conversion can be run backwards: RSSI = -59 - 20 * log10(distance). The script that did it, make-bluetooth-rssi-sample.mjs in this site’s source, asserts that every recovered RSSI is within one millionth of a whole number and stops with an error otherwise. All 126 recovered exactly, and none were the tracker’s failure value. Its summary line:

devices=23 readings=126 dropped_sentinels=0 worst_integer_error=0.00e+0

Download the data: bluetooth-rssi-2025-02-19.csv, 126 rows of device,timestamp_utc,rssi_dbm. Devices are labelled Obscured Device 1 to Obscured Device 23, as they were in the original file; no addresses are included.

The honest limit, stated plainly. The true distances of the devices in this session were never measured, so this data shows jitter and spread, not accuracy, and this page claims no accuracy figure. It tells you how much the input to the formula moves around, which is the first thing you need to know before trusting any output.

The whole session at a glance

The 126 readings ran from -103 dBm to -53 dBm, with a median of -82 dBm. Grouped into 5 dB bands, with one β–‡ for every two readings, rounded up:

RSSI band (dBm)ReadingsBar
-55 to -511β–‡
-60 to -563β–‡β–‡
-65 to -616β–‡β–‡β–‡
-70 to -665β–‡β–‡β–‡
-75 to -7126β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡
-80 to -7615β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡
-85 to -8130β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡
-90 to -8629β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡β–‡
-95 to -918β–‡β–‡β–‡β–‡
-100 to -962β–‡
-105 to -1011β–‡

Most of what the Pi heard was weak: 70 of the 126 readings were -81 dBm or weaker, which is exactly the region where the table above says one dB is worth metres.

The readings are not spread evenly across devices. 9 devices were heard once, 5 twice, 1 three times and 2 four times. Only 6 devices had 5 or more readings (7, 12, 13, 13, 15 and 36), and those six are the ones worth looking at for jitter.

How much one device’s RSSI moves

For each device with at least 5 readings (numbered as in the CSV, so 2 is Obscured Device 2): its RSSI range (max minus min), its sample standard deviation, and what that range would mean in metres with the formula at TxPower -59 dBm and n = 2.

DeviceReadingsMin to max (dBm)Median (dBm)Range (dB)Std dev (dB)Same device at n = 2 (m)
17-69 to -53-59165.40.50 to 3.16
236-91 to -73-79185.65.01 to 39.81
613-86 to -61-75257.51.26 to 22.39
1015-90 to -63-80278.11.58 to 35.48
1112-91 to -61-77308.61.26 to 39.81
2113-88 to -80-8882.211.22 to 28.18

Three things stand out.

The spread is large. Five of the six devices moved by 16 dB or more within a 20-minute session. At n = 2 that is a factor of 6.31 or more between the nearest and furthest distance reported for the same device. Some of it may be real movement: device 10’s first five readings average -72.6 dBm and its last five -85.4 dBm, and device 11 goes from -72.8 dBm to -79.6 dBm the same way, which looks like something moving away. Nothing recorded where anyone was, so this data cannot separate movement from jitter. Treat these figures as an upper bound on jitter, not a measurement of it.

One device alternated between two levels. Obscured Device 2 switched band on every single reading, upper, lower, upper, lower, for all 36: 18 readings between -76 and -73 dBm, and 18 between -91 and -82 dBm. Its median, -79 dBm, is a value it never once reported. The data does not say why; what it does say is that β€œthe average RSSI” of a device can describe a signal that does not exist.

A steady device can still spike. Obscured Device 21 read -88 dBm eleven times in a row, then -80 dBm once, then -88 dBm again. One reading was 8 dB off, which at n = 2 is the jump from 28.18 m to 11.22 m.

There is also a caveat about what the tracker got to see. The BlueZ documentation for device discovery says β€œDuring discovery RSSI delta-threshold is imposed.” (org.bluez.Adapter). The docs do not give the size of that threshold, but it means small RSSI changes may never have been reported during scan on, so this CSV is the changes BlueZ chose to pass on, not every packet the radio received.

Smoothing: a median of the last 5 readings

The usual first fix for jitter is to smooth the RSSI before converting it, and a median is a good choice because a single spike cannot move it. This version keeps the last five readings and returns nothing until it has five:

from collections import deque
from statistics import median


class RssiMedian:
    def __init__(self, window=5):
        self.buf = deque(maxlen=window)

    def update(self, rssi):
        self.buf.append(rssi)
        if len(self.buf) < self.buf.maxlen:
            return None
        return median(self.buf)

Run over the real CSV, one device at a time, in timestamp order, here is the spread before and after, for the six devices with at least 5 readings (a device with 7 readings yields 3 smoothed values):

DeviceSmoothed valuesRange raw (dB)Range smoothed (dB)Std dev raw (dB)Std dev smoothed (dB)
131655.42.9
23218105.64.4
692587.53.1
101127158.15.5
1183098.64.1
219802.20.0

A median of the last 5 readings cut the standard deviation of every device with enough readings, and removed isolated spikes completely, but it did not fix a signal that alternates between two levels. Device 21’s lone -80 dBm vanished and its smoothed value never moved from -88 dBm. Device 2 kept flipping, because any five consecutive readings of a two-level alternation hold three of one level and two of the other, so the median swaps sides every time a new reading arrives. For a signal like that, averaging (a mean, not a median) over an even-sized window is the smoother that lands between the levels.

Smoothing has a cost: it answers late. Device 2 was read about every 31 seconds (the median gap between its readings), so five readings is about two minutes of history. For a presence detector that is usually fine; for tracking someone walking through a house it is not.

Calibrate TxPower and n for your room

The defaults are guesses. You can replace both with two numbers measured in your own space, using a tape measure and the script below.

  1. Put the Bluetooth device at 1 m, 2 m, 4 m and 8 m from the receiver, in the room where you will use it, and take 10 readings at each distance.
  2. Write each reading as a line of distance_m rssi_dbm, for example 2 -66.
  3. Run python3 rssi_fit.py readings.txt.

Doubling the distance each time spreads the points evenly on the log scale the formula uses, which is what a straight-line fit needs. Take the readings with the device held the way it is really carried (in a pocket if it lives in a pocket), because the body in the way is part of your n.

Download: rssi_fit.py. It is standard-library Python 3, one file, no numpy. It fits RSSI = TxPower - 10 * n * log10(distance) by ordinary least squares: with log10(distance) on the x axis the model is a straight line whose intercept is TxPower and whose slope is -10 * n. It prints both, plus the RMS residual in dB, which is how much a single reading typically misses the fitted line. That residual is your jitter, and it is the number to put through the error rows of the table above.

To show that the fit recovers a known answer, the script has a self-test. It generates 10 whole-dBm readings at each of 1, 2, 4 and 8 m from a model with TxPower = -62 dBm and n = 2.7, adds seeded Gaussian noise of 3 dB (less than the raw jitter of every device above except device 21), fits them, and checks the result. This is its real output:

$ python3 rssi_fit.py --selftest
selftest: true TxPower = -62.0 dBm, true n = 2.70, noise sd = 3.0 dB, seed 20250219
          10 whole-dBm readings at each of 1, 2, 4 and 8 m
readings: 40 at 4 distances (1, 2, 4, 8 m)
TxPower = -61.9 dBm   (fitted RSSI at 1 m)
n       = 2.78       (path-loss exponent)
rms residual = 3.3 dB
  at 1 m: median RSSI -63 dBm -> model says 1.09 m
  at 2 m: median RSSI -70.5 dBm -> model says 2.04 m
  at 4 m: median RSSI -78.5 dBm -> model says 3.95 m
  at 8 m: median RSSI -87 dBm -> model says 7.98 m
selftest: PASS (|TxPower error| = 0.1 dB, |n error| = 0.08)

With 40 noisy readings it recovered TxPower to within 0.1 dB and n to within 0.08 of the true values. The fitted residual, 3.3 dB, comes back close to the 3 dB of noise that was put in, which is the sign the model fits and the rest is noise. If your own residual is far above your jitter, something other than distance is changing the signal between your calibration points, such as a body or a door in the way at one distance and not the others.

A calibration is only good for the room, the receiver and the device you did it with. Devices transmit at different power, so a reference measured on one does not carry over to another; fit each kind of device you care about.

Getting RSSI on a Raspberry Pi with bluetoothctl

These commands are written out, not run here: the machine that wrote this page has no Bluetooth adapter, so the bluetoothctl commands on this page were not run on it and no output from them is shown. The two commands below are the ones the tracker project runs, and their syntax is taken from the BlueZ bluetoothctl documentation (bluetoothctl.rst).

Start a scan, which the BlueZ docs describe as β€œScan for devices”:

bluetoothctl scan on

Then ask for one device’s details, where <id> is the address the scan printed:

bluetoothctl info <id>

The tracker reads the RSSI from the output of info with the regular expression RSSI:\s*(-?\d+), meaning a line containing RSSI: followed by a whole number, and it looks for TxPower: the same way. In the session above every one of the 126 readings parsed as a whole negative number. If your BlueZ version prints the value differently, check the pattern against your own output before trusting it. The BlueZ docs list the property as int16 RSSI [readonly, optional], so info can come back with no RSSI line at all; the tracker logs β€œRSSI or TxPower value not found in device info” when that happens.

If you only care about nearby devices, the scan submenu has a filter for exactly that: rssi -60, which the BlueZ scan docs describe as β€œOnly report devices with RSSI β‰₯ -60 dBm (close range)” (bluetoothctl-scan.rst). Per the Adapter docs quoted earlier, setting a filter like this also switches off the default RSSI delta-threshold, which may give you more frequent RSSI updates. That was not tested here.

Where the readings came from

The 126 readings were recorded by the Node.js tracker on the Raspberry Pi Bluetooth Tracker project page, which walks through its code line by line: how it drives bluetoothctl from Node, why its TxPower is hard-coded to -59 dBm, and why its distances stretch indoors. This page is the calibration it never had.

← Back to all posts

Follow new posts by RSS: paste dankdev.com/feed.xml into any feed reader. It is a plain XML file β€” no signup, no email address, no account. Posts land there the same day they go up, and there is nothing to unsubscribe from later.

Back to the top