177 lines
5.2 KiB
Go
177 lines
5.2 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|