Live-traffic routing and spoken road alerts for OsmAnd, from a home server
OsmAnd has offline maps, no account, and an API other apps can talk to. It has no live traffic and no idea the lane ahead is closed. This guide adds both, from a server at home.
What you get: OsmAnd routes through your own router, on live traffic. A small app on the phone draws road closures as pins and speaks the ones ahead of the car, between OsmAnd’s own turn instructions, on the phone and on Android Auto.
What you need:
- a Linux box with Docker and about 20 GB free
- a domain name that reaches it over HTTPS (any reverse proxy)
- an Android phone with OsmAnd
- for live traffic, a free developer key from a traffic-flow provider
The server side is Python, standard library only. The app is Kotlin with OsmAnd’s published API library. Every snippet below runs; where the real thing is bigger, I say so.
The build order, each step useful on its own, so stop wherever you have enough:
- Valhalla: the router, one compose file
- The shim: 50 lines that make OsmAnd accept it
- The token path: the one secret, in the URL
- Live traffic: writing speeds and closures into Valhalla
- The alert feed: official closures, filtered to the car’s road
- The app: pins and speech through OsmAnd’s API
- Android Auto: three things that only show up in the car
- Browser pages: optional
- What went wrong: read this one first if you like
1. Valhalla
Valhalla is the open-source router. The valhalla-scripted image builds tiles from an
OSM extract and serves them, and it creates the traffic.tar we fill later when you give
it a traffic name. A compose file that does both:
services:
valhalla:
image: ghcr.io/valhalla/valhalla-scripted:latest
ports: ["8002:8002"]
volumes: ["./valhalla:/custom_files"]
environment:
tile_urls: https://download.geofabrik.de/north-america/canada/ontario-latest.osm.pbf
serve_tiles: "True"
build_admins: "True"
build_time_zones: "True"
use_default_speeds_config: "True"
traffic_name: traffic # creates /custom_files/traffic.tar, one record per edge
server_threads: "4"
Pick an extract that covers where you drive; a province builds in under an hour, a continent in several. The first start downloads the extract and builds; later starts serve what is there. Check it:
curl -s -H 'Content-Type: application/json' http://127.0.0.1:8002/route -d '{
"locations": [{"lat": 45.42, "lon": -75.69}, {"lat": 45.58, "lon": -75.40}],
"costing": "auto", "format": "osrm"}' | python3 -c 'import json,sys; r=json.load(sys.stdin)["routes"][0]; print(r["distance"], r["duration"])'
"format": "osrm" does most of the next section’s work: Valhalla can already answer
in OSRM’s shape.
You have now: a router on port 8002 that answers in OSRM’s format.
2. The shim: OSRM in, Valhalla out
OsmAnd’s online routing speaks a few protocols, and one is OSRM’s:
GET /route/v1/driving/<lon>,<lat>;<lon>,<lat>?alternatives=true. Valhalla wants a POST
with JSON. The shim between them is this, and this is a complete program that OsmAnd
accepts:
#!/usr/bin/env python3
import json, os, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlsplit
VALHALLA = os.environ.get("VALHALLA_URL", "http://127.0.0.1:8002")
TOKEN = os.environ.get("NAV_TOKEN", "") # the header nginx adds (section 3); empty = no gate
def osrm_error(code, msg):
return json.dumps({"code": code, "message": msg}).encode()
def to_valhalla(profile, coords, params):
locations = []
for pair in coords.split(";"):
lon, lat = (float(v) for v in pair.split(",")[:2])
locations.append({"lon": lon, "lat": lat, "type": "break"})
if len(locations) < 2:
raise ValueError("need at least two coordinates")
req = {"locations": locations, "costing": "auto", "format": "osrm", "units": "kilometers",
"shape_format": "polyline6" if params.get("geometries", [""])[0] == "polyline6" else "polyline5"}
if params.get("alternatives", ["false"])[0] == "true":
req["alternates"] = 2
return req
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if TOKEN and self.headers.get("X-Nav-Token") != TOKEN:
return self.send(404, osrm_error("InvalidUrl", "not found"))
u = urlsplit(self.path)
if not u.path.startswith("/route/v1/"):
return self.send(404, osrm_error("InvalidUrl", "not found"))
profile, _, coords = u.path[len("/route/v1/"):].partition("/")
try:
body = json.dumps(to_valhalla(profile, coords.removesuffix(".json"), parse_qs(u.query))).encode()
except ValueError as e:
return self.send(400, osrm_error("InvalidQuery", str(e)))
req = urllib.request.Request(VALHALLA + "/route", data=body, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as r:
self.send(200, r.read())
except urllib.error.HTTPError as e:
self.send(e.code, e.read())
def send(self, status, body):
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *a):
pass
if __name__ == "__main__":
ThreadingHTTPServer(("127.0.0.1", int(os.environ.get("PORT", "8080"))), Handler).serve_forever()
Run it with VALHALLA_URL pointing at the container and ask it the OSRM way:
curl -s 'http://127.0.0.1:8080/route/v1/driving/-75.6972,45.4215;-75.4000,45.5760?alternatives=true' \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["code"], len(d["routes"]), "routes")'
# Ok 2 routes
My real shim grew from this. Three additions matter for a car, in the order I added them:
- Heading: OsmAnd never sends the car’s heading, so a route asked while driving can
start with a U-turn. The shim takes the heading from the car’s recent trail (the app
posts positions, section 6) and adds
"heading"and"heading_tolerance"to the first location. - Avoid flags: OsmAnd’s “avoid tolls” and “avoid ferries” go into
costing_options.autoasuse_tolls: 0anduse_ferry: 0. - Memory: The shim keeps the last few routes it served, per client, because every later feature asks “where on the route is the car”.
You have now: OsmAnd-compatible routing on your LAN.
3. The token path
OsmAnd cannot send a header, so the secret has to be in the URL. The shim is reachable only under a path that is a random token, and nginx turns the path into the header the shim checks on every request:
location /<token>/ {
proxy_set_header X-Nav-Token <token>;
proxy_set_header X-Real-IP $remote_addr;
rewrite ^/<token>/(.*)$ /$1 break;
proxy_pass http://192.0.2.10:8080;
}
location / { return 404; }
Make the token with python3 -c 'import secrets; print(secrets.token_urlsafe(32))', put
it in a file the shim reads, and compare it with hmac.compare_digest, not ==. The
shim should refuse everything when the file is missing rather than serve openly.
In OsmAnd: Settings, your Driving profile, Navigation settings, Navigation type, “Add
online routing engine”, type OSRM, server URL https://nav.example.com/<token>/route/v1/,
vehicle car. Save, select it, and route somewhere.
Once you have more than one engine (I have four: plain, no tolls, no highways, no tolls
and no ferries, each a different path prefix the shim maps to costing options), generate
an OsmAnd import file (.osf) instead of typing URLs on each phone. The item type is
ONLINE_ROUTING_ENGINES and each engine is {"type": "OSRM", "params": {"KEY": ...,
"CUSTOM_NAME": ..., "CUSTOM_URL": ..., "VEHICLE_KEY": "car"}}. Keep KEY stable across
imports, or the profile’s selected engine silently vanishes.
You have now: OsmAnd routing through your server from anywhere, and nothing else can.
4. Live traffic into Valhalla
Valhalla reads traffic.tar live: a tar whose first member is an index and whose other
members are one traffic tile per routing tile, each a 32-byte header followed by one
8-byte record per directed edge, in the routing tile’s edge order. A record holds a speed
in units of 2 km/h, three optional sub-segment speeds with breakpoints, a congestion
level, and an incident bit. All zero means no data; speed 0 with breakpoint 255 means
closed.
Nobody fills it for you. Writing a record is an aligned 8-byte store into a
shared mmap of the file, which Valhalla sees at once, no restart:
import mmap, os, struct
INDEX_ENTRY = struct.Struct("<QLL") # tile GraphId, data offset, size
TRAFFIC_HEADER = struct.Struct("<2Q4I") # tile id, last update, edge count, version, spare, spare
LEVEL_BITS, TILE_BITS = 3, 22
def gid_parts(value): # a GraphId as trace_attributes reports it (edge.id)
return value & 7, (value >> LEVEL_BITS) & ((1 << TILE_BITS) - 1), value >> (LEVEL_BITS + TILE_BITS)
def tile_of(value):
level, tile, _ = gid_parts(value)
return level | (tile << LEVEL_BITS)
def encode_speed(kph, congestion=0): # the whole edge at one speed (2..252 km/h)
s = max(1, min(126, int(round(kph / 2.0))))
return s | (s << 7) | (s << 14) | (s << 21) | (255 << 28) | (255 << 36) | (congestion << 44)
def encode_closed():
return (255 << 28) | (255 << 36) | (63 << 44)
class TrafficTar:
def __init__(self, path):
self.fd = os.open(path, os.O_RDWR)
head = os.pread(self.fd, 512, 0)
assert head[:9] == b"index.bin"
size = int(head[124:136].strip(b"\0 "), 8)
data = os.pread(self.fd, size, 512)
self.index = {}
for off in range(0, size, INDEX_ENTRY.size):
offset, tile, length = INDEX_ENTRY.unpack_from(data, off)
self.index[tile] = (offset, length)
self.map = mmap.mmap(self.fd, os.fstat(self.fd).st_size, mmap.MAP_SHARED, mmap.PROT_READ | mmap.PROT_WRITE)
self.words = memoryview(self.map).cast("Q")
def write(self, edge, value):
offset, _ = self.index[tile_of(edge)]
_, _, count, version, _, _ = TRAFFIC_HEADER.unpack_from(self.map, offset)
ident = gid_parts(edge)[2]
assert ident < count and version == 3
self.words[(offset + TRAFFIC_HEADER.size) // 8 + ident] = value & 0xFFFFFFFFFFFFFFFF
The shim’s container mounts the Valhalla folder read-write for this one file. Two questions remain: which edges, and what speed.
Which edges
Map-match the geometry you have onto the graph with Valhalla’s own trace_attributes,
asking for edge.id and the matched points’ distance_along_edge. A closure’s polyline,
or a traffic provider’s measured stretch, becomes a list of edge ids with the share of
each edge the line covers; keep the edges that are mostly covered.
def match_edges(post, points): # post(path, body) -> Valhalla's JSON answer
body = {"shape": [{"lat": a, "lon": b} for a, b in points], "costing": "auto", "shape_match": "map_snap",
"filters": {"attributes": ["edge.id", "matched.edge_index", "matched.type",
"matched.distance_along_edge", "matched.distance_from_trace_point"],
"action": "include"}}
ans = post("/trace_attributes", body)
edges, matched = ans.get("edges", []), [m for m in ans.get("matched_points", []) if m.get("type") == "matched"]
if not edges or not matched or max(m["distance_from_trace_point"] for m in matched) > 40:
return None # not on the roads we think: write nothing
i0, i1 = matched[0]["edge_index"], matched[-1]["edge_index"]
out = []
for i in range(i0, i1 + 1):
lo = matched[0]["distance_along_edge"] if i == i0 else 0.0
hi = matched[-1]["distance_along_edge"] if i == i1 else 1.0
if hi - lo >= 0.5:
out.append(edges[i]["id"])
return out
What speed
A traffic-flow API gives, for a corridor, stretches with a current speed and a free-flow speed. The shim:
- asks only for corridors around routes it served in the last minutes, so one car costs a few calls an hour;
- writes an edge’s speed only when the measured speed is under 90 % of free flow; everything else stays “no data”, which Valhalla treats as its usual estimate;
- writes closures from the feeds in section 5 as closed edges;
- gives every record a TTL in its own state file, sets it back to zero when it expires, and zeroes everything on start.
Shortcuts: closures work on city streets and not on highways
That symptom cost me days. Valhalla’s upper hierarchy levels have shortcut edges that
stand for chains of base edges, and a motorway route takes the shortcut, so a closure
written only on the base edge is ignored. The writer has to find the shortcut that covers
an edge and write that too. That means reading the routing tiles (valhalla_tiles.tar)
to walk from the edge to its shortcut, a page of code I will not paste here.
A canary: random roads closed after a tile rebuild
Tile ids change when tiles are rebuilt, and a writer that keeps running against new tiles closes random roads. On every start the shim takes a known short route, closes one edge in its middle (and its shortcut), routes again, clears it, routes again, and refuses to run unless the time went up and came back:
canary passed: 1/48704/43342 + shortcut 1/48704/43350: 173 s -> 223 s -> 173 s
A faster route, pushed to OsmAnd
With the file filled, the shim can answer “is there a faster route now?”: every ten minutes while OsmAnd navigates, it asks Valhalla for the current route and alternatives from the car’s position and compares durations. OsmAnd has no “take this route” call, but it has road blocks. The app blocks one point of the current route for fifteen seconds; OsmAnd recalculates around it, asks the shim, and gets the faster route; then the block is lifted. Crude, and it works.
You have now: routes that avoid closures and slow traffic, and reroutes when a faster one appears.
5. The alert feed
Every province and most states publish road events: an Open511 feed, a WZDx work-zone feed (USDOT keeps a registry of them), a 511 developer API with a free key you ask for by email. I started with WZDx because it is one format for dozens of agencies. A reader for one feed, into the one shape everything downstream uses:
def wzdx_items(url, code):
with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "nav-shim"}), timeout=60) as r:
feed = json.load(r)
out = []
for f in feed.get("features", []):
p = f.get("properties") or {}
core = p.get("core_details") if isinstance(p.get("core_details"), dict) else p # WZDx 3 keeps them flat
if str(core.get("event_type") or "work-zone") not in ("work-zone", "work_zone"):
continue
impact = str(p.get("vehicle_impact") or "").lower()
if impact == "all-lanes-closed":
kind, label = "closure", "Road closed"
elif "closed" in impact or "alternating" in impact:
kind, label = "hazard", "Lane closed"
else:
continue
g = f.get("geometry") or {}
c = g.get("coordinates") or []
pts = [(y, x) for x, y, *_ in (c if g.get("type") in ("LineString", "MultiPoint") else [c] if g.get("type") == "Point" else [])]
if not pts:
continue
roads = core.get("road_names") or []
out.append({"id": f"{code}:{f.get('id') or p.get('road_event_id')}", "kind": kind, "label": label,
"lat": pts[0][0], "lon": pts[0][1], "street": roads[0] if roads else "",
"text": str(core.get("description") or "")[:200], "pts": pts})
return out
Run against one province’s feed today: 165 work zones, of which 18 close something. The other feeds each get a reader like it; the rules that bite are small (one feed reports every impact as “unknown” and says “all lanes closed” in the description; one uses MultiPoint geometry for a stretch). Each reader has a cache with a TTL and a per-source budget, so a feed is fetched at most every few minutes whoever asks, and never for a map pan.
Filtering matters more than parsing. A feed for a whole province has a thousand items; the car cares about the ones on its road, ahead of it. The shim answers
GET /alerts?lat&lon&heading&speed with:
- while OsmAnd navigates (the shim knows the route it just served), the items within
300 m of the next 60 km of the route, each tagged with where along it they lie
(
route_m), so the app can say them in order and merge the ones of one work zone. A grid index over the route makes “nearest route vertex” cheap:
class RouteIndex:
def __init__(self, points):
self.points, self.grid = points, {}
for i, p in enumerate(points):
self.grid.setdefault((int(p[0] * 1000), int(p[1] * 1000)), []).append(i)
def nearest(self, p, max_m): # index of the nearest route point within max_m, else None
cx, cy = int(p[0] * 1000), int(p[1] * 1000)
r = max(1, math.ceil(max_m / 111.0)); best, best_d = None, max_m
for dx in range(-r, r + 1):
for dy in range(-r, r + 1):
for i in self.grid.get((cx + dx, cy + dy), ()):
d = haversine_m(p, self.points[i])
if d < best_d: best, best_d = i, d
return best
- the road the car is on, from the same
trace_attributescall on the car’s trail: a report on the other carriageway, on the cross street, or on the overpass above is dropped. This filter turned the feed from noise into something I leave on. The way to judge it is a drive log: the app posts what it said and what it stayed silent about, with the reason, and I read it after each drive. - without a route, a disc around the car, and the app speaks only what is in a cone ahead.
Add one /alerts endpoint to the shim that returns {"alerts": [...], "expires_in": 60}
and you have a server. Give each provider a deadline (mine is three seconds in total;
what has not arrived is served on the next request) or one cold feed holds every answer.
You have now: a feed of what is closed on the road ahead, nothing else.
6. The app
OsmAnd’s AIDL API is published as an .aar (search for osmand-android-aidl-lib); add
it to your Gradle dependencies, declare the OsmAnd packages in <queries>, and bind:
Intent intent = new Intent("net.osmand.aidl.OsmandAidlServiceV2");
intent.setPackage("net.osmand"); // or net.osmand.plus
app.bindService(intent, connection, Context.BIND_AUTO_CREATE);
// connection.onServiceConnected: api = IOsmAndAidlInterface.Stub.asInterface(service)
Then, once per connection, subscribe to what you need:
api.registerForOsmandInitListener(callback); // OsmAnd restarted: your layers are gone
ANavigationUpdateParams nav = new ANavigationUpdateParams();
nav.setSubscribeToUpdates(true);
api.registerForNavigationUpdates(nav, callback); // updateNavigationInfo: metres to the next turn
ANavigationVoiceRouterMessageParams voice = new ANavigationVoiceRouterMessageParams();
voice.setSubscribeToUpdates(true);
api.registerForVoiceRouterMessages(voice, callback); // onVoiceRouterNotify: what OsmAnd just said
The app is a foreground service (foregroundServiceType="specialUse", with the
PROPERTY_SPECIAL_USE_FGS_SUBTYPE property explaining why). It starts when OsmAnd’s
navigation notification appears (a notification listener), when Android Auto connects
(CarConnection from the Car App Library), or when the car’s Bluetooth connects.
Every five seconds it reads getAppInfo() for the car’s position and the destination.
About every 30 seconds while the car moves it fetches /alerts and pushes the pins:
AMapPoint p = new AMapPoint(id, caption, label, where, LAYER_ID, color, new ALatLon(lat, lon), details,
Map.of(AMapPoint.POINT_IMAGE_URI_PARAM, iconUri)); // a content:// URI your own provider serves
AMapLayer layer = new AMapLayer(LAYER_ID, "Alerts", 5.5f, points);
layer.setImagePoints(true);
api.addMapLayer(new AddMapLayerParams(layer));
addMapLayer adds or replaces; updateMapLayer only adds or updates points, so a point
that went away has to be removed by id, and after many removals it is cheaper to add the
layer afresh. One AIDL call carries the whole layer and Android’s binder buffer is 1 MB,
so cap the pins (I send the nearest 250).
Speaking without talking over OsmAnd
Whether you keep the app on depends on this part. The app speaks with the
phone’s TTS engine through AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE and
requests AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK, so music lowers. Two apps talking over each
other is worse than silence, so before saying a warning the app waits when any of these
holds, all read from the callbacks above:
- OsmAnd started a prompt in the last few seconds.
- A turn is close enough that OsmAnd is about to announce it, and the warning lies beyond the turn.
- The warning would not end before OsmAnd’s next prompt is due. The distances at which OsmAnd speaks are predictable from the speed, so this is an estimate of when the next prompt comes against how long the sentence takes, at about 13 characters a second.
If OsmAnd cuts in anyway, the audio-focus loss callback fires, the app stops at once, and it says the warning again afterwards if less than half of it was heard.
The wording is the report’s kind, the distance, and the street (“Lane closed ahead, 850 metres, on the highway”), with a small normaliser so the voice says “County Road 12 east” and not “CR-12 E”. One warning at a time, nearest first, about 40 seconds before the car reaches it.
Two more AIDL calls are worth wiring up:
- Road blocks:
addRoadBlock(lat, lon, name, profile)makes OsmAnd route around a point; the app uses it for the faster-route switch and for an “Avoid” button on every pin’s menu (addContextMenuButtons). Name your blocks with a prefix and only ever remove blocks whose name carries it, becausegetBlockedRoadslists the user’s own too. - “Welcome to Ontario”: The shim extracts province and state outlines from the OSM
extract once (
osmium tags-filteronadmin_level=4) and finds where a route crosses a line; the app says it the moment the car is past, before any warning except one that is seconds away.
You have now: pins on the map and warnings in your ear that stay out of OsmAnd’s way.
7. Android Auto
I could not find any of this written down, so here it is.
Pins show on the phone but not on the car, or not until OsmAnd is opened on the phone
OsmAnd’s car screen is a Car App Library navigation template. On
top of the map it draws only its own widgets, so a plugin widget added through AIDL never
reaches the car. Map layers do reach it, because the car renders the phone’s map view
with its layers, with one condition that cost me a drive to understand: OsmAnd attaches a
layer added through AIDL only while its phone map screen exists (the add-layer receiver
is registered in the map activity). When Android Auto starts OsmAnd by itself, that
screen does not exist, addMapLayer answers true, and nothing is drawn anywhere.
Two fixes. Re-add the layers (not update) whenever getAppInfo().isMapVisible turns
true. And, if you want it automatic, ask OsmAnd to show a point at the car’s position,
the one AIDL call that opens the map screen:
api.showMapPoint(new ShowMapPointParams(LAYER_ID, new AMapPoint("car", "", "", "", LAYER_ID, 0,
new ALatLon(lat, lon), new ArrayList<>(), new HashMap<>())));
// then add the layers again a few seconds later
Warnings come late, or “Welcome to” arrives a kilometre past the line
The position OsmAnd reports over AIDL can stand still for a minute while the car moves, then jump, while OsmAnd’s own guidance keeps moving. The app listens to the phone’s GPS while Android Auto projects. When OsmAnd’s position has not changed for six seconds while the phone’s has moved more than 25 m, it uses the phone’s until OsmAnd catches up. Keep the previous heading across the switch, or a warning is re-judged as “behind the car” for one tick.
Everything disappears for a minute mid-drive
Wireless Android Auto drops, and OsmAnd
reloads after it. Treat a drop as a grace period rather than a reason to stop, re-add
everything when OsmAnd’s service comes back (onServiceConnected fires again, and
onAppInitialized after a restart), and keep what you already said in persistent storage
so a restart repeats nothing.
8. Browser pages without the token
The shim also serves a setup page (where the phone downloads the app and the OsmAnd
import file) and a trips page. Opening them from the app used to put the token URL into
the browser’s history. Now the app asks the shim for a one-time link (POST /link, with
the token header), the browser opens /l/<random>, which works once within a minute,
sets an HttpOnly cookie scoped to /s/, and redirects to the page under /s/. nginx
serves /l/ and /s/ without adding the token and drops any token header a client
sends there:
location ^~ /l/ { proxy_set_header X-Nav-Token ""; proxy_pass http://192.0.2.10:8080; }
location ^~ /s/ { proxy_set_header X-Nav-Token ""; proxy_pass http://192.0.2.10:8080; }
The shim lets the cookie open only the endpoints those pages use, answers everything
under /s/ with Cache-Control: no-store, and keeps links and sessions in memory, so a
restart ends them and the app mints a new link next time. The token URL keeps working
for OsmAnd and the app.
9. Things that went wrong
A code review found the app and the shim each computing distances with two formulas, so
the spoken “450 metres” and the filter that decided what was ahead did not quite agree.
One Geo object each.
The provider that answered in 61 seconds on a cold cache held every alert request behind it until the fan-out got a deadline. The tile caches were allowed 136 MB in a 512 MB container because each one was sized alone; a week of nginx logs showed the busiest three minutes needed about 20 MB. The access log is the right place to size a cache from.
And a one-liner: a deploy script that tests the committed tree with the production
container’s Python caught a test that assumed git existed in the container. The test
was mine, written that morning.
Where to stop
Steps 1 to 3 are an afternoon, and routing alone is worth it. Live traffic is a weekend. The app with pins only is another; speech and the Android Auto workarounds took me a few drives each, because only a drive shows what they do. Each step’s logs tell you whether the next one is worth doing.