From 1627d60714e78e6c31773ae33736d2a0add7e179 Mon Sep 17 00:00:00 2001 From: beans Date: Wed, 28 Oct 2026 14:04:39 -0500 Subject: [PATCH] Address lookup --- server/addresses.go | 225 +++++++++++++++++++++++++++++++++++++++ server/addresses_test.go | 113 ++++++++++++++++++++ server/geocode.go | 176 ++++++++++++++++++++++++++++++ server/geocode_test.go | 129 ++++++++++++++++++++++ server/index.html | 18 +++- server/main.go | 62 ++++++++++- 6 files changed, 716 insertions(+), 7 deletions(-) create mode 100644 server/addresses.go create mode 100644 server/addresses_test.go create mode 100644 server/geocode.go create mode 100644 server/geocode_test.go diff --git a/server/addresses.go b/server/addresses.go new file mode 100644 index 0000000..e6bc0a1 --- /dev/null +++ b/server/addresses.go @@ -0,0 +1,225 @@ +package main + +import ( + "encoding/json" + "encoding/xml" + "fmt" + "io" + "math" + "os" + "path/filepath" + "strings" +) + +// Address points represent mapped address nodes or the mean of a way's vertices. +type addressPoint struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Label string `json:"label"` +} +type addressCell struct{ lat, lon int } +type addressIndex struct { + cells map[addressCell][]addressPoint + count int +} + +const localAddressRange = 5000.0 + +func cellFor(lat, lon float64) addressCell { + return addressCell{int(math.Floor(lat * 100)), int(math.Floor(lon * 100))} +} +func validCoordinate(lat, lon float64) bool { + return !math.IsNaN(lat) && !math.IsNaN(lon) && !math.IsInf(lat, 0) && !math.IsInf(lon, 0) && math.Abs(lat) <= 90 && math.Abs(lon) <= 180 +} +func loadAddresses(path string) (*addressIndex, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + var points []addressPoint + if err = json.NewDecoder(f).Decode(&points); err != nil { + return nil, fmt.Errorf("read address data: %w", err) + } + idx := &addressIndex{cells: make(map[addressCell][]addressPoint)} + for _, p := range points { + if !validCoordinate(p.Lat, p.Lon) || strings.TrimSpace(p.Label) == "" { + return nil, fmt.Errorf("invalid address record") + } + cell := cellFor(p.Lat, p.Lon) + idx.cells[cell] = append(idx.cells[cell], p) + idx.count++ + } + if idx.count == 0 { + return nil, fmt.Errorf("address data contains no addresses") + } + return idx, nil +} +func (idx *addressIndex) view(lat, lon float64) addressResult { + if !validCoordinate(lat, lon) { + return addressResult{Status: "unavailable"} + } + // An oversized geographic bounding box encloses the 5 km search circle. + // This index is intended for Mississippi, away from the poles/dateline. + dlat := localAddressRange / 110000 + dlon := dlat / math.Max(0.01, math.Cos((math.Abs(lat)+dlat)*math.Pi/180)) + lo, hi := cellFor(lat-dlat, lon-dlon), cellFor(lat+dlat, lon+dlon) + best := localAddressRange + var found *addressPoint + for y := lo.lat; y <= hi.lat; y++ { + for x := lo.lon; x <= hi.lon; x++ { + for _, p := range idx.cells[addressCell{y, x}] { + distance := distanceMeters(lat, lon, p.Lat, p.Lon) + if distance <= best { + copy := p + found = © + best = distance + } + } + } + } + if found == nil { + return addressResult{Status: "unavailable"} + } + return addressResult{Status: "ready", Address: &nearbyAddress{Label: found.Label, DistanceMeters: best, Attribution: "© OpenStreetMap contributors · local address data"}} +} + +type osmTag struct { + Key string `xml:"k,attr"` + Value string `xml:"v,attr"` +} +type osmNode struct { + ID int64 `xml:"id,attr"` + Lat float64 `xml:"lat,attr"` + Lon float64 `xml:"lon,attr"` + Tags []osmTag `xml:"tag"` +} +type osmWay struct { + Refs []struct { + ID int64 `xml:"ref,attr"` + } `xml:"nd"` + Tags []osmTag `xml:"tag"` +} + +func addressLabel(tags []osmTag) string { + values := make(map[string]string) + for _, tag := range tags { + values[tag.Key] = strings.TrimSpace(tag.Value) + } + street := values["addr:street"] + if street == "" { + street = values["addr:place"] + } + if values["addr:housenumber"] == "" || street == "" { + return "" + } + parts := []string{values["addr:housenumber"] + " " + street} + for _, k := range []string{"addr:unit", "addr:city", "addr:state", "addr:postcode"} { + if values[k] != "" { + parts = append(parts, values[k]) + } + } + return strings.Join(parts, ", ") +} + +// Import node/way addresses from node-before-way OSM XML. Osmium's default +// tags-filter output includes the referenced nodes needed for way coordinates. +func importAddresses(input io.Reader) ([]addressPoint, error) { + decoder := xml.NewDecoder(input) + nodes := make(map[int64]addressPoint) + var points []addressPoint + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + start, ok := token.(xml.StartElement) + if !ok { + continue + } + switch start.Name.Local { + case "node": + var n osmNode + if err = decoder.DecodeElement(&n, &start); err != nil { + return nil, err + } + if !validCoordinate(n.Lat, n.Lon) { + continue + } + p := addressPoint{Lat: n.Lat, Lon: n.Lon, Label: addressLabel(n.Tags)} + nodes[n.ID] = p + if p.Label != "" { + points = append(points, p) + } + case "way": + var w osmWay + if err = decoder.DecodeElement(&w, &start); err != nil { + return nil, err + } + label := addressLabel(w.Tags) + if label == "" { + continue + } + refs := w.Refs + if len(refs) > 1 && refs[0].ID == refs[len(refs)-1].ID { + refs = refs[:len(refs)-1] + } + if len(refs) == 0 { + continue + } + var lat, lon float64 + complete := true + for _, ref := range refs { + p, ok := nodes[ref.ID] + if !ok { + complete = false + break + } + lat += p.Lat + lon += p.Lon + } + if complete { + points = append(points, addressPoint{Lat: lat / float64(len(refs)), Lon: lon / float64(len(refs)), Label: label}) + } + } + } + if len(points) == 0 { + return nil, fmt.Errorf("no usable node/way addresses found in OSM XML") + } + return points, nil +} + +func buildAddresses(inputPath, outputPath string) (int, error) { + f, err := os.Open(inputPath) + if err != nil { + return 0, err + } + defer f.Close() + points, err := importAddresses(f) + if err != nil { + return 0, err + } + if err = os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { + return 0, err + } + tmp, err := os.CreateTemp(filepath.Dir(outputPath), ".addresses-*.json") + if err != nil { + return 0, err + } + defer os.Remove(tmp.Name()) + err = json.NewEncoder(tmp).Encode(points) + closeErr := tmp.Close() + if err != nil { + return 0, err + } + if closeErr != nil { + return 0, closeErr + } + if err = os.Rename(tmp.Name(), outputPath); err != nil { + return 0, err + } + return len(points), nil +} diff --git a/server/addresses_test.go b/server/addresses_test.go new file mode 100644 index 0000000..8e34724 --- /dev/null +++ b/server/addresses_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "encoding/json" + "math" + "math/rand" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "macker/datapoint" +) + +const addressFixture = ` + + + + + + + + +` + +func TestImportLocalAddresses(t *testing.T) { + points, err := importAddresses(strings.NewReader(addressFixture)) + if err != nil { + t.Fatal(err) + } + if len(points) != 2 || points[0].Label != "100 Main Street, Jackson" || points[1].Label != "200 Oak Street" { + t.Fatalf("bad addresses: %+v", points) + } + if math.Abs(points[1].Lat-32.315) > 1e-8 || math.Abs(points[1].Lon-(-90.195)) > 1e-8 { + t.Fatalf("incorrect closed-way position: %+v", points[1]) + } + for _, input := range []string{""} { + if _, err = importAddresses(strings.NewReader(input)); err == nil { + t.Fatal("accepted invalid/empty import") + } + } +} + +func TestLocalAddressBuildLoadAndAPI(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source.osm") + target := filepath.Join(dir, "data", "addresses.json") + if err := os.WriteFile(source, []byte(addressFixture), 0600); err != nil { + t.Fatal(err) + } + if count, err := buildAddresses(source, target); err != nil || count != 2 { + t.Fatalf("import: %d %v", count, err) + } + index, err := loadAddresses(target) + if err != nil { + t.Fatal(err) + } + tr := &tracker{addresses: index} + tr.accept([]datapoint.DataPoint{{Lat: 32.3, Long: -90.2}}, time.Now()) + rr := httptest.NewRecorder() + tr.handler().ServeHTTP(rr, httptest.NewRequest("GET", "/api/location", nil)) + var got location + if err = json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Nearby.Status != "ready" || got.Nearby.Address.Label != "100 Main Street, Jackson" || got.Nearby.Address.DistanceMeters != 0 { + t.Fatalf("bad response: %s", rr.Body.String()) + } + if index.view(30.4, -89.1).Status != "unavailable" { + t.Fatal("returned distant address outside search radius") + } + // A failed refresh must preserve the last usable database. + if err = os.WriteFile(source, []byte(""), 0600); err != nil { + t.Fatal(err) + } + if _, err = buildAddresses(source, target); err == nil { + t.Fatal("bad refresh accepted") + } + if _, err = loadAddresses(target); err != nil { + t.Fatalf("old index lost after failed refresh: %v", err) + } +} + +func TestLocalAddressIndexMatchesFullScan(t *testing.T) { + random := rand.New(rand.NewSource(42)) + idx := &addressIndex{cells: make(map[addressCell][]addressPoint)} + var all []addressPoint + for i := 0; i < 2000; i++ { + p := addressPoint{Lat: 30.2 + random.Float64()*2.3, Lon: -91.6 + random.Float64()*3.3, Label: "Test address"} + all = append(all, p) + c := cellFor(p.Lat, p.Lon) + idx.cells[c] = append(idx.cells[c], p) + } + for i := 0; i < 300; i++ { + lat, lon := 30.2+random.Float64()*2.3, -91.6+random.Float64()*3.3 + best := math.Inf(1) + for _, p := range all { + best = math.Min(best, distanceMeters(lat, lon, p.Lat, p.Lon)) + } + got := idx.view(lat, lon) + if best > localAddressRange { + if got.Status != "unavailable" { + t.Fatal("range limit ignored") + } + continue + } + if got.Address == nil || math.Abs(got.Address.DistanceMeters-best) > 0.001 { + t.Fatalf("index missed nearest address: %+v; want %f", got, best) + } + } +} diff --git a/server/geocode.go b/server/geocode.go new file mode 100644 index 0000000..7b96070 --- /dev/null +++ b/server/geocode.go @@ -0,0 +1,176 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +const addressCacheRadius = 25.0 // Metres from the coordinates used for the lookup. +const addressCacheTTL = 5 * time.Minute + +type nearbyAddress struct { + Label string `json:"label"` + DistanceMeters float64 `json:"distance_meters"` + Attribution string `json:"attribution"` +} +type addressResult struct { + Status string `json:"status"` + Address *nearbyAddress `json:"address,omitempty"` +} +type cachedAddress struct { + queryLat, queryLon, lat, lon float64 + label string + at time.Time +} +type geocoder struct { + endpoint, key string + client *http.Client + mu sync.RWMutex + cache []cachedAddress + lastAttempt time.Time + failed bool +} + +func newGeocoder(endpoint, key string) (*geocoder, error) { + if endpoint == "" { + return nil, nil + } + u, err := url.Parse(endpoint) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil { + return nil, fmt.Errorf("geocoder URL must be an HTTP(S) endpoint without embedded credentials") + } + if strings.EqualFold(u.Hostname(), "nominatim.openstreetmap.org") { + return nil, fmt.Errorf("use a tracking-compatible provider or your own Nominatim instance") + } + return &geocoder{endpoint: endpoint, key: key, client: &http.Client{Timeout: 8 * time.Second}}, nil +} + +// Straight-line distance between WGS84 coordinates, in metres. +func distanceMeters(lat1, lon1, lat2, lon2 float64) float64 { + const rad = math.Pi / 180 + a := math.Pow(math.Sin((lat2-lat1)*rad/2), 2) + math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Pow(math.Sin((lon2-lon1)*rad/2), 2) + return 6371000 * 2 * math.Asin(math.Sqrt(math.Max(0, math.Min(1, a)))) +} +func (g *geocoder) cached(lat, lon float64, now time.Time) *cachedAddress { + for i := len(g.cache) - 1; i >= 0; i-- { + c := &g.cache[i] + if now.Sub(c.at) < addressCacheTTL && distanceMeters(lat, lon, c.queryLat, c.queryLon) <= addressCacheRadius { + return c + } + } + return nil +} +func (g *geocoder) view(lat, lon float64, now time.Time) addressResult { + if g == nil { + return addressResult{Status: "disabled"} + } + g.mu.RLock() + defer g.mu.RUnlock() + if c := g.cached(lat, lon, now); c != nil { + return addressResult{Status: "ready", Address: &nearbyAddress{Label: c.label, DistanceMeters: distanceMeters(lat, lon, c.lat, c.lon), Attribution: "© OpenStreetMap contributors"}} + } + if g.failed { + return addressResult{Status: "unavailable"} + } + return addressResult{Status: "pending"} +} + +func (g *geocoder) update(ctx context.Context, lat, lon float64, now time.Time) { + g.mu.Lock() + if g.cached(lat, lon, now) != nil || (!g.lastAttempt.IsZero() && now.Sub(g.lastAttempt) < 30*time.Second) { + g.mu.Unlock() + return + } + g.lastAttempt = now + g.mu.Unlock() + c, err := g.lookup(ctx, lat, lon) + g.mu.Lock() + defer g.mu.Unlock() + g.failed = err != nil + if err != nil { + return + } // Keep provider errors and credentials out of API responses and logs. + c.at = now + if len(g.cache) >= 128 { + g.cache = g.cache[1:] + } + g.cache = append(g.cache, c) +} +func (g *geocoder) lookup(ctx context.Context, lat, lon float64) (cachedAddress, error) { + u, _ := url.Parse(g.endpoint) + q := u.Query() + q.Set("lat", strconv.FormatFloat(lat, 'f', 7, 64)) + q.Set("lon", strconv.FormatFloat(lon, 'f', 7, 64)) + q.Set("format", "jsonv2") + q.Set("zoom", "18") + q.Set("addressdetails", "1") + u.RawQuery = q.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return cachedAddress{}, err + } + req.Header.Set("User-Agent", "Macker/1.0") + if g.key != "" { + req.Header.Set("Authorization", "Bearer "+g.key) + } + res, err := g.client.Do(req) + if err != nil { + return cachedAddress{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return cachedAddress{}, fmt.Errorf("geocoder status %d", res.StatusCode) + } + raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20+1)) + if err != nil { + return cachedAddress{}, err + } + if len(raw) > 1<<20 { + return cachedAddress{}, fmt.Errorf("geocoder response too large") + } + var v struct { + Lat string `json:"lat"` + Lon string `json:"lon"` + DisplayName string `json:"display_name"` + } + if err = json.Unmarshal(raw, &v); err != nil { + return cachedAddress{}, err + } + alat, e1 := strconv.ParseFloat(v.Lat, 64) + alon, e2 := strconv.ParseFloat(v.Lon, 64) + if e1 != nil || e2 != nil || math.IsNaN(alat) || math.IsNaN(alon) || math.IsInf(alat, 0) || math.IsInf(alon, 0) || math.Abs(alat) > 90 || math.Abs(alon) > 180 || strings.TrimSpace(v.DisplayName) == "" { + return cachedAddress{}, fmt.Errorf("no usable address") + } + return cachedAddress{queryLat: lat, queryLon: lon, lat: alat, lon: alon, label: v.DisplayName}, nil +} + +func (t *tracker) runGeocoder(ctx context.Context) { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + t.mu.RLock() + var current *location + if t.latest != nil { + copy := *t.latest + current = © + } + t.mu.RUnlock() + if current != nil && now.Sub(current.UpdatedAt) <= 30*time.Second { + t.geocoder.update(ctx, current.Point.Lat, current.Point.Long, now) + } + } + } +} diff --git a/server/geocode_test.go b/server/geocode_test.go new file mode 100644 index 0000000..c456f65 --- /dev/null +++ b/server/geocode_test.go @@ -0,0 +1,129 @@ +package main + +import ( + "context" + "io" + "math" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "macker/datapoint" +) + +type transportFunc func(*http.Request) (*http.Response, error) + +func (f transportFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func testGeocoder(t *testing.T, response string, status int) (*geocoder, *int) { + t.Helper() + g, err := newGeocoder("https://example.com/reverse", "secret") + if err != nil { + t.Fatal(err) + } + calls := 0 + g.client = &http.Client{Transport: transportFunc(func(r *http.Request) (*http.Response, error) { + calls++ + if r.Header.Get("Authorization") != "Bearer secret" || r.URL.Query().Get("api_key") != "" { + t.Error("key must stay in server-side Authorization header") + } + if r.URL.Query().Get("format") != "jsonv2" || r.URL.Query().Get("lat") == "" || r.URL.Query().Get("lon") == "" { + t.Error("missing reverse geocoding parameters") + } + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(response)), Header: make(http.Header)}, nil + })} + return g, &calls +} + +func TestGeocodeCacheDistanceAndRateLimit(t *testing.T) { + g, calls := testGeocoder(t, `{"lat":"41.8001","lon":"-87.6","display_name":"123 Main Street"}`, 200) + now := time.Now().UTC() + g.update(context.Background(), 41.8, -87.6, now) + v := g.view(41.8, -87.6, now) + if v.Status != "ready" || v.Address == nil || v.Address.Label != "123 Main Street" || math.Abs(v.Address.DistanceMeters-11.12) > 0.1 { + t.Fatalf("bad address: %+v", v) + } + // A nearby fix reuses the cached address but computes its own distance. + g.update(context.Background(), 41.80005, -87.6, now.Add(time.Minute)) + moved := g.view(41.80005, -87.6, now.Add(time.Minute)) + if *calls != 1 || moved.Address.DistanceMeters >= v.Address.DistanceMeters { + t.Fatal("cache not reused or distance not updated") + } + // A distant fix must not display an address from the previous area. + if g.view(42, -87.6, now).Status != "pending" { + t.Fatal("old address shown for distant fix") + } + g.update(context.Background(), 42, -87.6, now.Add(time.Second)) + if *calls != 1 { + t.Fatal("rate limit bypassed") + } + g.update(context.Background(), 42, -87.6, now.Add(31*time.Second)) + if *calls != 2 { + t.Fatal("moved fix did not trigger new lookup") + } + if g.view(41.8, -87.6, now.Add(6*time.Minute)).Address != nil { + t.Fatal("expired cache used") + } +} + +func TestGeocodeFailureAndRetry(t *testing.T) { + for name, response := range map[string]string{"no match": `{"error":"Unable to geocode"}`, "invalid coords": `{"lat":"NaN","lon":"0","display_name":"bad"}`, "malformed": "not JSON", "oversized": strings.Repeat("x", (1<<20)+1)} { + t.Run(name, func(t *testing.T) { + g, calls := testGeocoder(t, response, 200) + now := time.Now() + g.update(context.Background(), 1, 2, now) + if g.view(1, 2, now).Status != "unavailable" { + t.Fatal("bad response accepted") + } + g.update(context.Background(), 1, 2, now.Add(time.Second)) + if *calls != 1 { + t.Fatal("failure retried too soon") + } + g.update(context.Background(), 1, 2, now.Add(31*time.Second)) + if *calls != 2 { + t.Fatal("failure not retried") + } + }) + } + g, _ := testGeocoder(t, `{}`, 429) + g.update(context.Background(), 1, 2, time.Now()) + if g.view(1, 2, time.Now()).Status != "unavailable" { + t.Fatal("HTTP error accepted") + } +} + +func TestGeocoderConfigurationAndAPI(t *testing.T) { + for _, endpoint := range []string{"file:///tmp/address", "https://nominatim.openstreetmap.org/reverse", "https://user:pass@example.com"} { + if _, err := newGeocoder(endpoint, ""); err == nil { + t.Fatalf("accepted endpoint %q", endpoint) + } + } + disabled, err := newGeocoder("", "") + if err != nil || disabled != nil || disabled.view(0, 0, time.Now()).Status != "disabled" { + t.Fatal("bad disabled behavior") + } + g, _ := testGeocoder(t, `{"lat":"41.8","lon":"-87.6","display_name":"123 Main Street"}`, 200) + now := time.Now() + g.update(context.Background(), 41.8, -87.6, now) + tr := &tracker{geocoder: g} + tr.accept([]datapoint.DataPoint{{Lat: 41.8, Long: -87.6}}, now) + rr := httptest.NewRecorder() + tr.handler().ServeHTTP(rr, httptest.NewRequest("GET", "/api/location", nil)) + if !strings.Contains(rr.Body.String(), `"label":"123 Main Street"`) || strings.Contains(rr.Body.String(), "secret") { + t.Fatalf("bad API response: %s", rr.Body.String()) + } +} + +func TestDistanceMeters(t *testing.T) { + if distanceMeters(0, 0, 0, 0) != 0 { + t.Fatal("identical coordinates have a distance") + } + if got := distanceMeters(0, 0, 0, 1); math.Abs(got-111195) > 1 { + t.Fatalf("unexpected one degree distance: %f", got) + } + if got := distanceMeters(0, 179.999, 0, -179.999); math.Abs(got-222.39) > 1 { + t.Fatalf("bad antimeridian distance: %f", got) + } +} diff --git a/server/index.html b/server/index.html index f1e5ef0..1b265e8 100644 --- a/server/index.html +++ b/server/index.html @@ -5,12 +5,12 @@ Macker · Work car

Macker

Work car · Live location

-
Current speed—Waiting for GPS
Last update—No location received yet

Waiting for the car’s first GPS fix…

GPS uncertainty unavailable

+
Current speed—Waiting for GPS
Last update—No location received yet
Nearby address—Waiting for GPS

Waiting for the car’s first GPS fix…

GPS uncertainty unavailable