App 2.39.0, server 2.7.0: Grid with map

A new Overview style: the Grid over the roads round the car from
OpenStreetMap, drawn in the floor's perspective, heading up, the bigger
roads glowing brighter, the car as a chevron.

Server: POST /v1/map/roads {z:14,x,y} fetches a tile's drivable roads
from Overpass (OVERPASS_URL; one request at a time, at least a second
apart), simplifies them to tile-local points in four classes, caches
them for 30 days and never logs the tile.

App: MapRoads keeps the 3x3 tiles round the car from its own cache or
the server; GPS only while the style is shown; bearing from the GPS or
from successive positions. Without GPS, server or roads the plain floor
says why. OSM attribution on screen and in LICENSE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
This commit is contained in:
2026-10-26 23:34:21 +01:00
co-authored by Claude Opus 5.5
parent 776a11b182
commit 65dc1ba847
24 changed files with 1312 additions and 18 deletions
+16
View File
@@ -23,6 +23,7 @@ Environment only; the server refuses to start when a required value is missing.
| `APPROVAL_PASSWORD` | optional, at least 12 characters; switches pairing on (see below) |
| `PUBLIC_URL` | the address a phone uses, for the pairing QR code; derived from the request if empty |
| `FORGE_URL`, `FORGE_REPO`, `FORGE_TOKEN` | optional; app updates (below). The token only needs to read the repository |
| `OVERPASS_URL` | the Overpass API the map's roads come from; default `https://overpass-api.de/api/interpreter`, `off` switches maps off, otherwise https only |
| `LISTEN_ADDR` | default `:8080` |
## Endpoints
@@ -57,6 +58,21 @@ shared ingest token): `update check` (how many releases, the newest pre-release)
download started` (file, size), then `update handed out` (bytes sent, time taken) or `update
download cut short`. The forge's address and token never appear in the log.
## Map roads
`POST /v1/map/roads` `{z, x, y}`, with the car's token: the drivable roads (motorway to
residential) of one web-mercator tile at zoom 14, for the app's "Grid with map" style. The car
names the tile, not its position, and in the body, so it stays out of access logs. The answer is
`{z, x, y, extent: 4096, ways: [{c, p: [x, y, …]}]}`: per road its class (0 motorway and trunk,
1 primary and secondary, 2 tertiary and unclassified, 3 residential and living streets) and its
points in tile units, simplified to about 1.5 m; gzipped when the car accepts it (20–30 KB a tile
in a city before that). The roads come from OpenStreetMap through `OVERPASS_URL`, one request at
a time and at most one a second, with a User-Agent naming this service, and are kept in memory
for 30 days (up to 3000 tiles); the car keeps its own copy too. 400 for anything but zoom 14 or
a tile outside it, 503 when Overpass is busy or unreachable (not cached, the car tries again a
minute later), 404 when maps are off. The log says `map tile fetched` with the car's installation
and the size, never the tile. Map data © OpenStreetMap contributors, ODbL.
## Settings from a phone
The ABRP credentials are long to type on the car. With pairing on, the car (with its own token)
+4
View File
@@ -20,6 +20,7 @@ import (
// GET /v1/health unauthenticated liveness probe
// /v1/settings/…, /settings settings typed on a phone (handoff_http.go)
// GET /v1/app/… app updates from the forge (releases.go)
// POST /v1/map/roads the roads of one map tile (maps.go)
//
// A car authenticates with the shared INGEST_TOKEN or with its own token from pairing
// (pairing_http.go).
@@ -31,6 +32,8 @@ type API struct {
Handoff *Handoff
// Releases relays the app's releases from the private forge to the car (releases.go).
Releases *Releases
// Maps serves the roads for the Overview's map from OpenStreetMap (maps.go); nil is off.
Maps *Maps
// PublicURL is the server's address as a phone reaches it, for the pairing QR code;
// derived from the request when empty.
PublicURL string
@@ -50,6 +53,7 @@ func (a *API) Routes() http.Handler {
a.pairingRoutes(mux)
a.handoffRoutes(mux)
a.releaseRoutes(mux)
a.mapRoutes(mux)
return mux
}
+19 -1
View File
@@ -34,6 +34,7 @@ type config struct {
forgeURL string
forgeRepo string
forgeToken string
overpassURL string
}
// loadConfig reads the environment and fails closed: a missing InfluxDB setting or a
@@ -51,6 +52,7 @@ func loadConfig(getenv func(string) string) (config, error) {
forgeURL: getenv("FORGE_URL"),
forgeRepo: getenv("FORGE_REPO"),
forgeToken: getenv("FORGE_TOKEN"),
overpassURL: getenv("OVERPASS_URL"),
}
if c.listenAddr == "" {
c.listenAddr = ":8080"
@@ -73,6 +75,18 @@ func loadConfig(getenv func(string) string) (config, error) {
if c.forgeURL != "" && !strings.HasPrefix(c.forgeURL, "https://") {
return config{}, errors.New("FORGE_URL must be an https URL")
}
// The map's roads come from an Overpass API: the public one unless another is named, none
// with "off". Only https, like the forge.
switch c.overpassURL {
case "":
c.overpassURL = DefaultOverpassURL
case "off":
c.overpassURL = ""
default:
if !strings.HasPrefix(c.overpassURL, "https://") {
return config{}, errors.New("OVERPASS_URL must be an https URL or \"off\"")
}
}
if c.approvalPass != "" && len(c.approvalPass) < minApprovalPasswordLength {
return config{}, fmt.Errorf("APPROVAL_PASSWORD must be at least %d characters", minApprovalPasswordLength)
}
@@ -102,6 +116,9 @@ func main() {
releases := &Releases{ForgeURL: cfg.forgeURL, Repo: cfg.forgeRepo, Token: cfg.forgeToken}
api := &API{Store: store, IngestToken: cfg.ingestToken, Pairing: pairing, Handoff: NewHandoff(),
Releases: releases, PublicURL: cfg.publicURL}
if cfg.overpassURL != "" {
api.Maps = NewMaps(cfg.overpassURL, mapUserAgent)
}
srv := &http.Server{
Addr: cfg.listenAddr,
@@ -116,7 +133,8 @@ func main() {
defer stop()
go func() {
slog.Info("listening", "addr", cfg.listenAddr, "bucket", cfg.influxBucket,
"pairing", pairing.Enabled(), "pairedDevices", len(devices), "appUpdates", releases.enabled())
"pairing", pairing.Enabled(), "pairedDevices", len(devices), "appUpdates", releases.enabled(),
"maps", api.Maps != nil)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server failed", "error", err)
os.Exit(1)
+358
View File
@@ -0,0 +1,358 @@
package main
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// Maps serves the roads around the car for the Overview's map (the "Grid with map" style),
// taken from OpenStreetMap through an Overpass API and cached here, so the car only ever
// talks to this server and asks Overpass nothing itself.
//
// POST /v1/map/roads {"z":14,"x":…,"y":…}
// → {"z","x","y","extent":4096,"ways":[{"c":class,"p":[x,y,x,y,…]}]}
//
// The car names a web-mercator tile at zoom 14 (about 1.5 km across in central Europe), not
// its position, and the request goes in the body so the tile stays out of access logs. Points
// are tile-local in 0..4096 (they may lie a little outside: a road is kept up to the first
// point beyond the edge), simplified to about 1.5 m. Classes: 0 motorway and trunk, 1 primary
// and secondary, 2 tertiary and unclassified, 3 residential and living streets.
//
// Overpass is asked one tile at a time, at most once a second; a tile is kept for 30 days,
// up to mapCacheTiles of them. The tile's coordinates never appear in the log.
type Maps struct {
OverpassURL string
UserAgent string
Client *http.Client
mu sync.Mutex
tiles map[mapTile]mapEntry
fetchMu sync.Mutex // one Overpass request at a time
lastFetch time.Time
gap time.Duration
}
const (
mapZoom = 14
mapExtent = 4096
mapCacheTiles = 3000
mapCacheAge = 30 * 24 * time.Hour
maxOverpassBytes = 32 << 20
// The simplification tolerance in tile units (about 0.37 m each at 52° N).
mapTolerance = 4.0
// DefaultOverpassURL is the public instance of the Overpass API.
DefaultOverpassURL = "https://overpass-api.de/api/interpreter"
// mapUserAgent names this service to Overpass, as its usage policy asks.
mapUserAgent = "nils-mg4-server/2.7 (private car dashboard; roads for an in-car map)"
)
type mapTile struct{ Z, X, Y int }
type mapEntry struct {
body []byte
at time.Time
}
// roadClasses maps OSM's highway values to the four classes the car draws.
var roadClasses = map[string]int{
"motorway": 0, "motorway_link": 0, "trunk": 0, "trunk_link": 0,
"primary": 1, "primary_link": 1, "secondary": 1, "secondary_link": 1,
"tertiary": 2, "tertiary_link": 2, "unclassified": 2,
"residential": 3, "living_street": 3,
}
type roadTile struct {
Z int `json:"z"`
X int `json:"x"`
Y int `json:"y"`
Extent int `json:"extent"`
Ways []road `json:"ways"`
}
type road struct {
C int `json:"c"`
P []int `json:"p"`
}
func NewMaps(overpassURL, userAgent string) *Maps {
return &Maps{OverpassURL: overpassURL, UserAgent: userAgent, gap: time.Second}
}
func (m *Maps) client() *http.Client {
if m.Client != nil {
return m.Client
}
return &http.Client{Timeout: 60 * time.Second}
}
// tileBounds is the tile's south, west, north and east edge in degrees.
func tileBounds(t mapTile) (s, w, n, e float64) {
scale := math.Exp2(float64(t.Z))
lon := func(x float64) float64 { return x/scale*360 - 180 }
lat := func(y float64) float64 { return math.Atan(math.Sinh(math.Pi*(1-2*y/scale))) * 180 / math.Pi }
return lat(float64(t.Y + 1)), lon(float64(t.X)), lat(float64(t.Y)), lon(float64(t.X + 1))
}
// tilePoint is a position in the tile's own units: 0..extent across, y downwards.
func tilePoint(t mapTile, lat, lon float64) (int, int) {
scale := math.Exp2(float64(t.Z)) * mapExtent
x := (lon + 180) / 360 * scale
r := lat * math.Pi / 180
y := (1 - math.Log(math.Tan(r)+1/math.Cos(r))/math.Pi) / 2 * scale
return int(math.Round(x - float64(t.X)*mapExtent)), int(math.Round(y - float64(t.Y)*mapExtent))
}
// overpassQuery asks for the drivable roads in the tile, with a small margin so that roads
// crossing its edge reach a point beyond it.
func overpassQuery(t mapTile) string {
s, w, n, e := tileBounds(t)
dy, dx := (n-s)*0.03, (e-w)*0.03
box := fmt.Sprintf("%.6f,%.6f,%.6f,%.6f", s-dy, w-dx, n+dy, e+dx)
return "[out:json][timeout:50];way[\"highway\"~\"^(motorway|motorway_link|trunk|trunk_link|primary|primary_link|" +
"secondary|secondary_link|tertiary|tertiary_link|unclassified|residential|living_street)$\"](" + box +
");out geom(" + box + ");"
}
type overpassResult struct {
Elements []struct {
Type string `json:"type"`
Tags map[string]string `json:"tags"`
Geometry []*struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
} `json:"geometry"`
} `json:"elements"`
}
// buildTile turns Overpass's answer into the tile the car reads. A missing point (outside
// the clipped geometry) splits a road in two.
func buildTile(t mapTile, raw []byte) ([]byte, error) {
var res overpassResult
if err := json.Unmarshal(raw, &res); err != nil {
return nil, fmt.Errorf("overpass answer: %w", err)
}
out := roadTile{Z: t.Z, X: t.X, Y: t.Y, Extent: mapExtent, Ways: []road{}}
for _, el := range res.Elements {
class, ok := roadClasses[el.Tags["highway"]]
if el.Type != "way" || !ok {
continue
}
var run [][2]int
flush := func() {
if pts := simplify(run, mapTolerance); len(pts) >= 2 {
flat := make([]int, 0, len(pts)*2)
for _, p := range pts {
flat = append(flat, p[0], p[1])
}
out.Ways = append(out.Ways, road{C: class, P: flat})
}
run = nil
}
for _, g := range el.Geometry {
if g == nil {
flush()
continue
}
x, y := tilePoint(t, g.Lat, g.Lon)
if n := len(run); n > 0 && run[n-1] == [2]int{x, y} {
continue
}
run = append(run, [2]int{x, y})
}
flush()
}
return json.Marshal(out)
}
// simplify is Douglas–Peucker: the points that keep the line within tol of the original.
func simplify(pts [][2]int, tol float64) [][2]int {
if len(pts) < 3 {
return pts
}
keep := make([]bool, len(pts))
keep[0], keep[len(pts)-1] = true, true
type span struct{ a, b int }
stack := []span{{0, len(pts) - 1}}
for len(stack) > 0 {
s := stack[len(stack)-1]
stack = stack[:len(stack)-1]
best, at := 0.0, -1
for i := s.a + 1; i < s.b; i++ {
if d := segmentDistance(pts[i], pts[s.a], pts[s.b]); d > best {
best, at = d, i
}
}
if at >= 0 && best > tol {
keep[at] = true
stack = append(stack, span{s.a, at}, span{at, s.b})
}
}
var out [][2]int
for i, k := range keep {
if k {
out = append(out, pts[i])
}
}
return out
}
func segmentDistance(p, a, b [2]int) float64 {
px, py := float64(p[0]), float64(p[1])
ax, ay, bx, by := float64(a[0]), float64(a[1]), float64(b[0]), float64(b[1])
dx, dy := bx-ax, by-ay
if dx == 0 && dy == 0 {
return math.Hypot(px-ax, py-ay)
}
u := math.Max(0, math.Min(1, ((px-ax)*dx+(py-ay)*dy)/(dx*dx+dy*dy)))
return math.Hypot(px-ax-u*dx, py-ay-u*dy)
}
// Tile returns the tile's roads as JSON, from the cache or from Overpass, and whether it
// came from the cache.
func (m *Maps) Tile(ctx context.Context, t mapTile) ([]byte, bool, error) {
if body, ok := m.cached(t); ok {
return body, true, nil
}
m.fetchMu.Lock()
defer m.fetchMu.Unlock()
// Another request may have fetched it while this one waited.
if body, ok := m.cached(t); ok {
return body, true, nil
}
if wait := m.gap - time.Since(m.lastFetch); wait > 0 {
select {
case <-time.After(wait):
case <-ctx.Done():
return nil, false, ctx.Err()
}
}
m.lastFetch = time.Now()
raw, err := m.fetch(ctx, t)
if err != nil {
return nil, false, err
}
body, err := buildTile(t, raw)
if err != nil {
return nil, false, err
}
m.store(t, body)
return body, false, nil
}
func (m *Maps) cached(t mapTile) ([]byte, bool) {
m.mu.Lock()
defer m.mu.Unlock()
e, ok := m.tiles[t]
if !ok || time.Since(e.at) > mapCacheAge {
return nil, false
}
return e.body, true
}
// store keeps the tile, dropping the oldest when the cache is full.
func (m *Maps) store(t mapTile, body []byte) {
m.mu.Lock()
defer m.mu.Unlock()
if m.tiles == nil {
m.tiles = map[mapTile]mapEntry{}
}
if len(m.tiles) >= mapCacheTiles {
var oldest mapTile
var at time.Time
for k, e := range m.tiles {
if at.IsZero() || e.at.Before(at) {
oldest, at = k, e.at
}
}
delete(m.tiles, oldest)
}
m.tiles[t] = mapEntry{body: body, at: time.Now()}
}
// fetch asks Overpass. The query goes in the body, so its errors never carry the tile.
func (m *Maps) fetch(ctx context.Context, t mapTile) ([]byte, error) {
form := url.Values{"data": {overpassQuery(t)}}.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, m.OverpassURL, strings.NewReader(form))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", m.UserAgent)
resp, err := m.client().Do(req)
if err != nil {
return nil, errors.New("overpass could not be reached")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
return nil, fmt.Errorf("overpass answered %d", resp.StatusCode)
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxOverpassBytes+1))
if err != nil {
return nil, errors.New("overpass answer could not be read")
}
if len(raw) > maxOverpassBytes {
return nil, errors.New("overpass answer is too large")
}
return raw, nil
}
func (a *API) mapRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /v1/map/roads", a.ingest(a.mapRoads))
}
func (a *API) mapRoads(w http.ResponseWriter, r *http.Request, c caller, body []byte) error {
if a.Maps == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "maps are not enabled"})
return nil
}
var req struct {
Z, X, Y *int
}
if err := json.Unmarshal(body, &req); err != nil || req.Z == nil || req.X == nil || req.Y == nil {
return invalid("z, x and y are required")
}
if *req.Z != mapZoom {
return invalid("z must be %d", mapZoom)
}
limit := 1 << mapZoom
if *req.X < 0 || *req.X >= limit || *req.Y < 0 || *req.Y >= limit {
return invalid("x and y must be within the zoom level")
}
t := mapTile{Z: *req.Z, X: *req.X, Y: *req.Y}
data, cached, err := a.Maps.Tile(r.Context(), t)
if err != nil {
slog.Warn("map tile not available", "installation", who(c), "error", err)
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "map data not available, try again later"})
return nil
}
if !cached {
slog.Info("map tile fetched", "installation", who(c), "ways", strings.Count(string(data), `"c":`),
"bytes", len(data))
}
w.Header().Set("Content-Type", "application/json")
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
_, _ = zw.Write(data)
_ = zw.Close()
w.Header().Set("Content-Encoding", "gzip")
data = buf.Bytes()
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
return nil
}
+212
View File
@@ -0,0 +1,212 @@
package main
import (
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
// A tile in central Berlin.
var testTile = mapTile{Z: 14, X: 8802, Y: 5373}
// fakeOverpass answers every query with a primary road (with a gap in its geometry), a
// residential street with a needless middle point, and a footway the car does not draw.
func fakeOverpass(t *testing.T, calls *atomic.Int32, status int) *httptest.Server {
t.Helper()
s, w, n, e := tileBounds(testTile)
lat := func(f float64) float64 { return s + (n-s)*f }
lon := func(f float64) float64 { return w + (e-w)*f }
answer := fmt.Sprintf(`{"elements":[
{"type":"way","tags":{"highway":"primary"},"geometry":[
{"lat":%f,"lon":%f},{"lat":%f,"lon":%f},null,{"lat":%f,"lon":%f},{"lat":%f,"lon":%f}]},
{"type":"way","tags":{"highway":"residential"},"geometry":[
{"lat":%f,"lon":%f},{"lat":%f,"lon":%f},{"lat":%f,"lon":%f}]},
{"type":"way","tags":{"highway":"footway"},"geometry":[
{"lat":%f,"lon":%f},{"lat":%f,"lon":%f}]}]}`,
lat(0.1), lon(0.1), lat(0.2), lon(0.2), lat(0.6), lon(0.6), lat(0.7), lon(0.7),
lat(0.5), lon(0.1), lat(0.5), lon(0.5), lat(0.5), lon(0.9),
lat(0.3), lon(0.3), lat(0.4), lon(0.4))
return httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.Method != http.MethodPost || !strings.Contains(r.UserAgent(), "nils-mg4-server") {
t.Errorf("request %s %q", r.Method, r.UserAgent())
}
_ = r.ParseForm()
if q := r.PostForm.Get("data"); !strings.Contains(q, "out geom(") || !strings.Contains(q, "residential") {
t.Errorf("query %q", q)
}
if status != http.StatusOK {
http.Error(rw, "busy", status)
return
}
_, _ = rw.Write([]byte(answer))
}))
}
func mapsAPI(overpass *httptest.Server) http.Handler {
api, _ := newTestAPI()
api.Maps = NewMaps(overpass.URL, mapUserAgent)
api.Maps.Client = overpass.Client()
api.Maps.gap = 0
return api.Routes()
}
func tileBody(t mapTile) string {
return fmt.Sprintf(`{"z":%d,"x":%d,"y":%d}`, t.Z, t.X, t.Y)
}
func TestMapTileRoadsAreClassifiedSplitAndSimplified(t *testing.T) {
var calls atomic.Int32
overpass := fakeOverpass(t, &calls, http.StatusOK)
defer overpass.Close()
h := mapsAPI(overpass)
if rec := do(t, h, "POST", "/v1/map/roads", "", tileBody(testTile)); rec.Code != 401 {
t.Fatalf("without the car's token: %d", rec.Code)
}
rec := do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile))
if rec.Code != 200 {
t.Fatalf("%d %s", rec.Code, rec.Body)
}
var got roadTile
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Extent != 4096 || got.X != testTile.X || got.Y != testTile.Y {
t.Fatalf("tile %+v", got)
}
// The primary road in two pieces, the street straightened to its ends, no footway.
if len(got.Ways) != 3 || got.Ways[0].C != 1 || got.Ways[1].C != 1 || got.Ways[2].C != 3 {
t.Fatalf("ways %+v", got.Ways)
}
if len(got.Ways[2].P) != 4 {
t.Fatalf("the straight street keeps only its ends: %v", got.Ways[2].P)
}
// Tile-local: 0.1 of the way in is about 410 units from the west edge.
if x := got.Ways[0].P[0]; x < 400 || x > 420 {
t.Fatalf("x %d", x)
}
// Cached: the second car asking costs Overpass nothing.
if rec := do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile)); rec.Code != 200 {
t.Fatalf("%d", rec.Code)
}
if calls.Load() != 1 {
t.Fatalf("overpass asked %d times", calls.Load())
}
}
func TestMapTilesAreGzippedWhenTheCarAcceptsIt(t *testing.T) {
var calls atomic.Int32
overpass := fakeOverpass(t, &calls, http.StatusOK)
defer overpass.Close()
req := httptest.NewRequest("POST", "/v1/map/roads", strings.NewReader(tileBody(testTile)))
req.Header.Set("Authorization", "Bearer "+testToken)
req.Header.Set("Accept-Encoding", "gzip")
rec := httptest.NewRecorder()
mapsAPI(overpass).ServeHTTP(rec, req)
if rec.Code != 200 || rec.Header().Get("Content-Encoding") != "gzip" {
t.Fatalf("%d %v", rec.Code, rec.Header())
}
zr, err := gzip.NewReader(rec.Body)
if err != nil {
t.Fatal(err)
}
plain, _ := io.ReadAll(zr)
if !bytes.Contains(plain, []byte(`"extent":4096`)) {
t.Fatalf("%s", plain)
}
}
func TestMapRequestsAreChecked(t *testing.T) {
var calls atomic.Int32
overpass := fakeOverpass(t, &calls, http.StatusOK)
defer overpass.Close()
h := mapsAPI(overpass)
for _, body := range []string{`{}`, `{"z":15,"x":1,"y":1}`, `{"z":14,"x":-1,"y":1}`,
`{"z":14,"x":1,"y":16384}`, `not json`} {
if rec := do(t, h, "POST", "/v1/map/roads", testToken, body); rec.Code != 400 {
t.Errorf("%s: %d", body, rec.Code)
}
}
if calls.Load() != 0 {
t.Fatal("a bad request reached Overpass")
}
}
func TestABusyOverpassIsATemporaryFailureAndNotCached(t *testing.T) {
var logged bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logged, nil)))
defer slog.SetDefault(previous)
var calls atomic.Int32
overpass := fakeOverpass(t, &calls, http.StatusTooManyRequests)
defer overpass.Close()
h := mapsAPI(overpass)
for i := 0; i < 2; i++ {
if rec := do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile)); rec.Code != 503 {
t.Fatalf("%d", rec.Code)
}
}
if calls.Load() != 2 {
t.Fatalf("a failure was cached: %d calls", calls.Load())
}
// Where the car is stays out of the log.
out := logged.String()
if !strings.Contains(out, "overpass answered 429") || strings.Contains(out, "8802") ||
strings.Contains(out, "5373") {
t.Fatalf("log:\n%s", out)
}
}
func TestMapsAreOffWithoutOverpass(t *testing.T) {
api, _ := newTestAPI()
if rec := do(t, api.Routes(), "POST", "/v1/map/roads", testToken, tileBody(testTile)); rec.Code != 404 {
t.Fatalf("%d", rec.Code)
}
}
func TestOverpassURLConfiguration(t *testing.T) {
base := map[string]string{"INFLUX_URL": "http://influx", "INFLUX_TOKEN": "t", "INFLUX_ORG": "o",
"INGEST_TOKEN": "0123456789abcdef01234567"}
load := func(overpass string) (config, error) {
return loadConfig(func(k string) string {
if k == "OVERPASS_URL" {
return overpass
}
return base[k]
})
}
if c, err := load(""); err != nil || c.overpassURL != DefaultOverpassURL {
t.Fatalf("default: %q %v", c.overpassURL, err)
}
if c, err := load("off"); err != nil || c.overpassURL != "" {
t.Fatalf("off: %q %v", c.overpassURL, err)
}
if _, err := load("http://overpass.example.com/api/interpreter"); err == nil {
t.Fatal("plain http accepted")
}
}
func TestTilePointsAndSimplification(t *testing.T) {
s, w, n, e := tileBounds(testTile)
if x, y := tilePoint(testTile, n, w); x != 0 || y != 0 {
t.Fatalf("north-west corner %d,%d", x, y)
}
if x, y := tilePoint(testTile, s, e); x != 4096 || y != 4096 {
t.Fatalf("south-east corner %d,%d", x, y)
}
line := [][2]int{{0, 0}, {10, 1}, {20, 0}, {30, 40}}
got := simplify(line, 4)
if len(got) != 3 || got[1] != [2]int{20, 0} {
t.Fatalf("%v", got)
}
}