pr1v8.ca

Live-traffic routing and spoken road alerts for OsmAnd, from a home server

· 19 min read · osmandvalhallaandroid-autoself-hosting

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:

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:

  1. Valhalla: the router, one compose file
  2. The shim: 50 lines that make OsmAnd accept it
  3. The token path: the one secret, in the URL
  4. Live traffic: writing speeds and closures into Valhalla
  5. The alert feed: official closures, filtered to the car’s road
  6. The app: pins and speech through OsmAnd’s API
  7. Android Auto: three things that only show up in the car
  8. Browser pages: optional
  9. 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:

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:

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:

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

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:

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:

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.