I Just Wanted Turn-by-Turn on My Wahoo (So Naturally I Built an Entire Route Planner)
I've been planning routes in one of the big commercial cycling apps for years - drag the line down the quiet lane instead of the A-road, sync to the Wahoo ELEMNT, ride. It's a good product. It is also somebody else's server holding every route I've ever drawn near my own house, and there's no path from "nice line on a map" to "turn-by-turn cues on the head unit" that doesn't go through it. So, as with most things that live in this house, the fix was "I'll just build it myself." The result is called Moovelo - moo plus velo, because every project in this house eventually gets absorbed into the sillymoo naming scheme whether it wants to or not.
The plan
The brief was small on paper: click the map to add waypoints, drag the route line itself to insert a new point exactly where you drop it, three route styles instead of an endless slider of options, an elevation chart, and a button that pushes the finished route straight to Wahoo's cloud so it's waiting on the ELEMNT after the next WiFi sync. Built it in phases with Claude Code over a few weekends - SvelteKit and MapLibre on top, FastAPI and Postgres/PostGIS underneath, Valhalla doing the actual routing.
The three presets - road, gravel, quiet - aren't three separate route-finding algorithms, they're three different bundles of Valhalla's bicycle costing options (bike type, surface tolerance, hill aversion, cycleway preference) sent with the same request. Turns out that's enough to make genuinely different routes: road happily takes the A-road, gravel actively hunts for the bridleway, quiet will add ten minutes rather than touch a main carriageway.
The map that would not load
The routing side worked first go, which should have been a warning sign. What didn't work was the map underneath it. CyclOSM's public tile servers are lovely and free and render tiles for you on demand - which is exactly the problem the moment you scroll somewhere that isn't a city centre. High zoom, low-traffic area, blank grey tiles for several seconds while some stranger's server draws them for the first time in months.
So I stood up the whole CyclOSM stack myself: PostGIS, Mapnik, renderd, on an LXC I'd already bumped to 200 GB because I could see this coming. Importing England into PostGIS took about 40 minutes and produced a roughly 50 GB database, then a batch job pre-rendered zoom levels 0 through 10 so the tiles that matter for planning a route are already sitting on disk before anyone asks for them.
The single most annoying hour of the entire project was renderd rendering nothing - silently, no errors, just tiles that never appeared:
render_list -a -z0 -Z10The map in renderd.conf isn't called "default". Somewhere back when I first wrote the config I'd named it "ajt", and render_list quietly defaults to the map named "default" if you don't tell it otherwise - so it happily rendered a map that doesn't exist and reported success the whole time.
render_list -a -z0 -Z10 -m ajtOne flag. Forty minutes of import, an afternoon of "why is this empty," one flag.
Wahoo's documentation is aspirational
Pushing a route to Wahoo turned out to be the project's best source of "the docs are lying to you, gently." The API is OAuth2, then a FIT course file - Valhalla's turn-by-turn maneuvers get embedded as FIT course points so the ELEMNT actually shows cues, not just a line. Two things the docs don't mention:
POST /v1/routes 422s if you don't send workout_type_family_id, start_lat, and start_lng - none of them flagged as required anywhere I could find:
422 Unprocessable Entity
{"errors": ["workout_type_family_id can't be blank", "start_lat can't be blank", "start_lng can't be blank"]}And the base64 data-URI upload format their own docs describe gets rejected outright with "not allowed to upload bin files." The actual requirement is a genuine multipart file attachment, and it has to be named route.fit literally - Wahoo validates the upload by filename extension, not content type. Send the identical bytes under a different name and it bounces.
There was a third bug that was entirely my own doing: an async SQLAlchemy commit expired a server-generated column, then the next access blew up with MissingGreenlet because nothing had told it to go fetch the fresh value. Every single push crashed on it. The fix was one explicit reload of the row after commit - the kind of bug that's obvious for the ten minutes after you've found it and opaque for the two hours before.
Never needs to be exposed to the internet
The bit I like best about how this ended up wired together: Moovelo never needs a public URL for the Wahoo half to work. The OAuth redirect happens in the user's own browser, every other call is outbound from the backend to Wahoo's API, and the ELEMNT picks the route up from Wahoo's cloud over its own WiFi sync. The whole round trip closes without anything needing to reach in.
It's MIT-licensed and about to go public at github.com/beaglemoo/moovelo, multi-arch Docker images and CI included, share links for sending a route to someone without an account, and a mobile pass still in progress - since most of this actually gets used from a phone at a kitchen table before a ride, not a desktop. Turns out wanting turn-by-turn cues on a bike computer is a very short hop from "well, I suppose I need a routing engine, a tile server, and an OAuth client now."