Filtering bad GPS fixes is one problem. Getting no fixes at all is another. When you drive into a tunnel, the location callbacks simply stop arriving, and a naive speedometer will hold whatever number it last saw, forever. Here is how I taught Speedometer Tiny to notice the silence, fall back to the motion sensors, and recover the instant the sky comes back.
The silent failure
Most of the work in a speedometer turns out to be defensive. In LocationManager, the didUpdateLocations delegate method runs a stack of filters before it trusts a single reading: it rejects cached fixes older than fifteen seconds, throws out negative horizontal accuracy, discards anything worse than 100m (200m during a startup grace window), and refuses to report a suspiciously high speed in the first ten seconds because that is usually a stale GPS lock flashing on launch. Those are all cases where CoreLocation hands you a value and you have to decide whether to believe it.
A tunnel is the opposite case. The device hands you nothing. CoreLocation does not call didFailWithError when you lose the sky; the delegate just goes quiet. The last accepted reading stays in speedInMetersPerSecond, and unless something actively challenges it, the big number on screen keeps reporting the speed you were doing at the tunnel mouth. That is a tricky kind of bug in a measurement app, because it looks completely plausible: the display is steady, the units are right, and the value is simply wrong. I needed a way to treat the absence of data as data.
A watchdog outside the stream
The location callback cannot detect its own silence, so I do not rely on it. The key decision was to put the watcher outside the data stream it is watching. A repeating Timer fires every two seconds, completely independent of CoreLocation, and asks one question: how long has it been since any location actually arrived?
To answer that, I keep a single timestamp, lastGPSLocationTime, and I update it at the very top of didUpdateLocations, before any of the quality filters run. That detail matters. The watchdog is not asking "when did I last accept a good fix?" It is asking "when did the OS last hand me anything at all?" Even a fix I later reject still proves the radio is alive, so it counts as a heartbeat. The filters decide what to display; the timestamp only decides whether GPS is breathing.
// Fired every 2s, independent of the CoreLocation delegate
let now = Date()
let timeSinceLastGPS = now.timeIntervalSince(lastGPSLocationTime)
let timeSinceAppStart = now.timeIntervalSince(appStartTime)
// Give GPS 5 seconds to warm up after launch
guard timeSinceAppStart > 5.0 else { return }
// No location of any kind for 8+ seconds: assume signal loss
if timeSinceLastGPS > 8.0 {
declareSignalLost() // drop quality, switch to motion, show status
}
The watchdog does not wait to be told the signal dropped. It infers it from how long the stream has been quiet, which is the only signal a tunnel ever gives you.
Two thresholds, not one
The harder part was not noticing the loss. It was deciding how patient to be. A real GPS signal is noisy even in the open. Fixes do not arrive on a perfect cadence, and a brief gap under an overpass or beside a tall building is normal, not a tunnel. If I declared signal lost the instant a callback ran late, the app would flicker constantly between measured and estimated speed on an ordinary drive.
So the timer uses two numbers, not one trigger.
- A five-second warmup guard. The check is skipped entirely until the app has been running longer than five seconds, so it never panics before the first real fixes have had a chance to land. This pairs with the warmup logic inside
didUpdateLocations, which is also more forgiving early on: the accuracy ceiling is 200m for the first fifteen seconds and tightens to 100m after. - An eight-second silence threshold. After warmup, a gap has to exceed eight seconds before the watchdog declares loss. A normal noisy signal with the occasional late fix never crosses that line, but a genuine tunnel does.
Eight seconds feels long when you read it, but on the road it is the difference between a calm, trustworthy readout and one that twitches every time a building goes by. I would rather wait a moment than show a wrong number quickly.
Falling back to motion
Once the timer declares the signal lost, it does not just blank the screen. The app has been quietly running two other sensors the whole time, and now they take over.
The first is the accelerometer, sampled through CMMotionManager at 60Hz. On each sample I take the magnitude of userAcceleration (gravity already removed by device motion) and compare it against a small threshold tuned for walking. If it crosses, isMoving goes true; if the device has been still for more than half a second, it goes false. That gives me a near-instant yes/no on whether you are moving at all, even with no satellites.
let a = motion.userAcceleration
let magnitude = sqrt(a.x*a.x + a.y*a.y + a.z*a.z)
if magnitude > 0.08 {
isMoving = true
lastMovementTime = now
} else {
isMoving = now.timeIntervalSince(lastMovementTime) < 0.5
}
The second is CMPedometer, which reports currentPace in seconds per meter. Inverting it gives a real walking speed: speed = 1.0 / pace. So when the signal drops, the fallback is a small decision tree. If you are not moving, the speed is zero. If you are moving and the pedometer has a pace, I show that measured walking speed. Otherwise I fall back to a fixed indoor estimate of 1.12 m/s, about 2.5 mph. Either way I set isSpeedEstimated = true and drop gpsQuality to .poor, so any signal-strength indicator tells the truth instead of implying a connection that no longer exists.
Saying so, and recovering
The last piece matters as much as the logic. The number in a tunnel is a best guess, and the honest move is to say so rather than present an estimate with the same confidence as a satellite fix. So the watchdog also sets a plain status string: "No GPS signal - Using motion sensor estimation."
Recovery is automatic and falls out of the same timestamp. When a real fix returns on the far side of the tunnel, didUpdateLocations fires again, refreshes lastGPSLocationTime, and runs its normal path. At the end of that path there is a small guard: if the current error message contains "No GPS signal", clear it. So the moment measured GPS speed comes back, the estimate flag flips off, the warning erases itself, and the display returns to satellite speed without the user touching anything. I deliberately scoped that clear to the GPS-loss message so it would not stomp on an unrelated error like a denied-permission notice.
One detail I am glad I got right: I never let an estimated value pollute the recorded maximum. The all-time max speed is only updated when isEstimated is false, so a pedometer guess in a tunnel can never quietly become your new record.
The display should never look more certain than the data behind it. A tunnel is exactly where that rule earns its keep.
What I took away
This was a small feature with an outsized effect on how trustworthy the app feels, and it taught me a few things I want to carry forward.
- Absence of data is information. A stream that goes quiet needs a watcher that lives outside it, on its own clock, comparing a timestamp rather than waiting for an error that never comes.
- Track the heartbeat, not the verdict. Stamping the time on every raw callback before filtering, not just on accepted fixes, is what lets the watchdog tell "signal is bad" apart from "signal is gone."
- Patience beats reflexes. A warmup guard plus a silence limit prevents the flicker that a single trigger would cause on a real, noisy drive.
- Have a fallback already running. Because the accelerometer and pedometer were always live, switching to motion estimation was a state change, not a cold start.
- State the confidence level, then undo it. When the number becomes an estimate, say so, and clear the message the instant real data returns.
A speedometer is only as good as the moments you cannot see coming. The tunnel is the test, and the answer that felt right was to fail honestly: drop to zero or a sensor estimate, tell the user exactly what changed, and recover the moment the sky comes back.