A beacon signal near a doorway is not yet a position. RSSI, calibration, anchor placement, filtering, movement, walls, and uncertainty decide whether a location claim is useful or misleading.
Read this chapter from the final decision backward. Ask what the system will do with the position, then check whether the beacon evidence is strong enough for that action.
Phoebe’s Field Notes: Why A Few dB Becomes A Few Metres
Phoebe’s Why
The trilateration geometry later in this chapter is exact – three circles genuinely meet at one point. What is not exact is the radius of each circle, because RSSI-to-distance conversion is a logarithmic relationship: distance sits in an exponent, not a coefficient. That means a fixed dB error does not add a fixed number of metres; it multiplies the distance estimate by a fixed ratio. A small, fast RSSI jitter (frame-to-frame noise) stays close to that ratio’s linear approximation, but a large swing from a wall, a body, or a reflection does not – the exponential curve pulls away from the straight-line approximation fast enough that using the small-signal formula on a big swing quietly understates the real error.
Worked Numbers: Recomputing This Chapter’s Own Claim
Checking the quiz’s own \(\pm8\) dBm claim (2 m reference, \(n=2\), a standard indoor exponent): a \(-8\) dB (weaker) offset gives \(\hat d = 2\times10^{8/20}=5.02\) m – matching the stated “5.0 m” upper bound closely. A \(+8\) dB (stronger) offset gives \(\hat d = 2\times10^{-8/20}=0.796\) m, not the stated “1.4m” lower bound. Reaching exactly 1.4 m from \(+8\) dB would need \(n\approx5.16\), a much steeper exponent than the one that fits the upper bound – worth a source check, though it does not change the lesson: even the “closer” side of the noise band still swings the estimate by close to a metre.
Why the linear formula cannot be trusted at 8 dB: the small-signal formula predicts a \(92.1\%\) increase at \(\delta=8\); the exact ratio gives \(151\%\). The two only agree for small \(\delta\) – another reason environmental RSSI swings from walls and bodies need the exact exponential form, not the derivative shortcut.
A regime where the linear form is fair – fast RSSI jitter: at \(n=2\), each dB of measurement noise moves the estimate by \(\ln(10)/20=11.5\%\). Applied to this chapter’s own trilateration distances (1.78 m, 4.47 m, 2.82 m) with a modest \(\pm1\) dB frame-to-frame jitter: \(\pm0.205\) m, \(\pm0.515\) m, and \(\pm0.325\) m respectively – the same 11.5% fraction of each anchor’s own range, which is why the farthest beacon contributes the largest absolute wobble to the least-squares fit even when every beacon has identically noisy RSSI.
15.2 From Beacons to a Position
In Building BLE Apps on ESP32 you built a scanner, a GATT temperature server, and an iBeacon transmitter. This chapter takes those beacons and answers a bigger question: can a device work out where it is indoors, where GPS cannot reach? The answer is yes — with three beacons, their signal strengths, and some honest math about how approximate radio distance really is.
15.3 Build the Positioning System, Step by Step
Three beacons, three distance estimates, one intersection: trilateration turns RSSI into a position — honest accuracy is 2–5 metres.
A single beacon can say “near” or “far.” Three beacons let you estimate a position. That follows naturally from the previous step: convert each beacon RSSI to an approximate distance, draw a circle around each fixed beacon, and find the point where the circles overlap most closely.
System element
Role in the positioning lab
What the code uses
Beacon 1
Fixed anchor at (0, 0)
RSSI converted to distance d1
Beacon 2
Fixed anchor at (5, 0)
RSSI converted to distance d2
Beacon 3
Fixed anchor at (0, 5)
RSSI converted to distance d3
Mobile device
Unknown point being estimated
Best-fit (x, y) position
Residual error
Quality check for the estimate
Larger error means noisy RSSI or a poor path-loss model
Think of each distance as drawing a circle around a beacon. The estimated position is where the three circles overlap most closely.
import numpy as npfrom scipy.optimize import least_squaresdef trilaterate(beacons, distances):""" Calculate position from beacon positions and distances. Args: beacons: List of (x, y) beacon positions distances: List of distances to each beacon Returns: (x, y) estimated position """def residuals(point, beacons, distances):return [ np.sqrt((point[0] - b[0])**2+ (point[1] - b[1])**2) - dfor b, d inzip(beacons, distances) ]# Initial guess: centroid of beacons x0 = np.mean([b[0] for b in beacons]) y0 = np.mean([b[1] for b in beacons]) result = least_squares( residuals, [x0, y0], args=(beacons, distances) )returntuple(result.x)# Example usagebeacons = [(0, 0), (5, 0), (0, 5)]distances = [1.78, 4.47, 2.82] # From RSSIposition = trilaterate(beacons, distances)print(f"Estimated position: {position}")
Try It: BLE Trilateration Simulator
Place 3 beacons and adjust the measured distance from each to see where trilateration estimates your position. The circles represent the distance measurement from each beacon — the estimated position is where they intersect.
{// Beacon positionsconst b1 = [0,0], b2 = [5,0], b3 = [0,5];const d1 = tri_d1, d2 = tri_d2, d3 = tri_d3;// Trilateration via linearization (algebraic solution)// From circle equations: (x-x1)^2 + (y-y1)^2 = d1^2, etc.// Subtract eq1 from eq2 and eq3 to get linear systemconst A =2* (b2[0] - b1[0]);const B =2* (b2[1] - b1[1]);const C = d1*d1 - d2*d2 - b1[0]*b1[0] + b2[0]*b2[0] - b1[1]*b1[1] + b2[1]*b2[1];const D =2* (b3[0] - b1[0]);constE=2* (b3[1] - b1[1]);const F = d1*d1 - d3*d3 - b1[0]*b1[0] + b3[0]*b3[0] - b1[1]*b1[1] + b3[1]*b3[1];const det = A *E- B * D;const px = det !==0? (C *E- F * B) / det :2.5;const py = det !==0? (A * F - D * C) / det :2.5;// Check residualsconst r1 =Math.abs(Math.sqrt(px*px + py*py) - d1);const r2 =Math.abs(Math.sqrt((px-5)**2+ py*py) - d2);const r3 =Math.abs(Math.sqrt(px*px + (py-5)**2) - d3);const avgErr = ((r1 + r2 + r3) /3).toFixed(2);// SVG visualizationconst scale =50;// pixels per meterconst ox =40, oy =310;// origin offsetconst toX = m => ox + m * scale;const toY = m => oy - m * scale;const beacons = [[b1, d1,"#E74C3C","B1"], [b2, d2,"#3498DB","B2"], [b3, d3,"#16A085","B3"]];const circles = beacons.map(([b, d, c, l]) =>`<circle cx="${toX(b[0])}" cy="${toY(b[1])}" r="${d * scale}" fill="none" stroke="${c}" stroke-width="1.5" stroke-dasharray="5,5" opacity="0.5"/> <circle cx="${toX(b[0])}" cy="${toY(b[1])}" r="6" fill="${c}"/> <text x="${toX(b[0])}" y="${toY(b[1]) -10}" text-anchor="middle" font-size="12" fill="${c}" font-weight="bold">${l} (${b[0]},${b[1]})</text>` ).join("");const qualColor =parseFloat(avgErr) <0.5?"#16A085":parseFloat(avgErr) <1.5?"#E67E22":"#E74C3C";const qualLabel =parseFloat(avgErr) <0.5?"Good fix":parseFloat(avgErr) <1.5?"Moderate":"Poor — distances inconsistent";returnhtml`<div style="background:#f8f9fa;padding:1rem;border-radius:8px;border-left:4px solid #9B59B6;margin-top:0.5rem;"> <svg width="340" height="340" style="background:white;border:1px solid #dee2e6;border-radius:4px;"> <!-- Grid -->${Array.from({length:6}, (_, i) =>`<line x1="${toX(i)}" y1="${toY(0)}" x2="${toX(i)}" y2="${toY(5)}" stroke="#f0f0f0"/><line x1="${toX(0)}" y1="${toY(i)}" x2="${toX(5)}" y2="${toY(i)}" stroke="#f0f0f0"/><text x="${toX(i)}" y="${toY(-0.4)}" text-anchor="middle" font-size="10" fill="#999">${i}m</text>`).join("")} <!-- Distance circles -->${circles} <!-- Estimated position --> <circle cx="${toX(px)}" cy="${toY(py)}" r="8" fill="#9B59B6" stroke="white" stroke-width="2"/> <text x="${toX(px) +12}" y="${toY(py) +4}" font-size="11" fill="#9B59B6" font-weight="bold">(${px.toFixed(1)}, ${py.toFixed(1)})</text> </svg> <p style="font-size:13px;margin:8px 0 0;"><strong>Estimated position:</strong> (${px.toFixed(2)}, ${py.toFixed(2)}) | <strong>Residual error:</strong> <span style="color:${qualColor}">${avgErr}m — ${qualLabel}</span></p> </div>`;}
Checkpoint: you should now be able to explain why three noisy distances produce a best-fit position rather than a guaranteed exact location. The residual error tells you whether the three circles agree.