mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-16 22:30:43 +00:00
Record each attempt before sending so a request lost to a deadline still waits out MeshMapper's limit, and stop holding the directory lock across HTTP. Groups now qualify only through heard or configured IATAs, and one missing country list only skips pruning.
228 lines
6.9 KiB
Go
228 lines
6.9 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package meshmapper
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"maps"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
// RegionState is a stored region; imported ones are also what SaveImportedRegion writes.
|
|
type RegionState struct {
|
|
Slug, Name string
|
|
DisplayOrder int
|
|
Imported bool
|
|
IATAs []string
|
|
CenterLat, CenterLng *float64
|
|
}
|
|
|
|
// IATADetails is an IATA's display name and approximate location.
|
|
type IATADetails struct {
|
|
IATA string
|
|
Name string
|
|
Lat, Lng *float64
|
|
}
|
|
|
|
// SetConfiguredIATAs names the IATAs whose config entry sets a name or location; those win.
|
|
func (z *Zones) SetConfiguredIATAs(iatas []string) {
|
|
z.configured = map[string]bool{}
|
|
for _, iata := range iatas {
|
|
z.configured[iata] = true
|
|
}
|
|
}
|
|
|
|
// OnIATAsChange is told whenever imported IATA names or locations change.
|
|
func (z *Zones) OnIATAsChange(fn func(ctx context.Context)) { z.onIATAs = fn }
|
|
|
|
// OnRegionsChange is told whenever the imported regions change, for cache invalidation.
|
|
func (z *Zones) OnRegionsChange(fn func(ctx context.Context)) { z.onRegions = fn }
|
|
|
|
// pruneGroups drops every imported region when group import is off.
|
|
func (z *Zones) pruneGroups(ctx context.Context) error {
|
|
pruned, err := z.store.PruneImportedRegions(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("prune MeshMapper regions: %w", err)
|
|
}
|
|
z.regionsRemoved(pruned)
|
|
if len(pruned) > 0 {
|
|
z.regionsChanged(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// syncInputs is what syncDirectory last applied; it reruns when any of them changes.
|
|
type syncInputs struct {
|
|
version, known int
|
|
complete bool
|
|
heard string
|
|
}
|
|
|
|
// syncDirectory applies the loaded zone lists to IATA details and, when enabled,
|
|
// imported regions. Groups are only pruned once every tracked country has a list.
|
|
func (z *Zones) syncDirectory(ctx context.Context, iatas []string) error {
|
|
var countries []string
|
|
for _, r := range z.regions {
|
|
countries = append(countries, r.country)
|
|
}
|
|
slices.Sort(countries)
|
|
groups, zones, version, complete := z.dir.snapshot(slices.Compact(countries))
|
|
var heard []string
|
|
if z.importGroups {
|
|
var err error
|
|
if heard, err = z.store.ListHeardIATAs(ctx); err != nil {
|
|
return fmt.Errorf("list heard IATAs for MeshMapper groups: %w", err)
|
|
}
|
|
slices.Sort(heard)
|
|
}
|
|
inputs := syncInputs{version: version, known: len(iatas), complete: complete, heard: strings.Join(heard, ",")}
|
|
if z.groupsSynced && inputs == z.synced {
|
|
return nil
|
|
}
|
|
if err := z.syncIATADetails(ctx, zones); err != nil {
|
|
return err
|
|
}
|
|
if z.importGroups {
|
|
if err := z.syncGroups(ctx, heard, groups, zones, complete); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
z.groupsSynced, z.synced = true, inputs
|
|
return nil
|
|
}
|
|
|
|
// syncIATADetails names and locates each listed IATA unless its config entry does.
|
|
func (z *Zones) syncIATADetails(ctx context.Context, zones map[string]zoneEntry) error {
|
|
stored, err := z.store.ListIATADetails(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("list IATAs for MeshMapper details: %w", err)
|
|
}
|
|
changed := false
|
|
for _, cur := range stored {
|
|
zone, ok := zones[cur.IATA]
|
|
if !ok || z.configured[cur.IATA] || zone.name == "" && zone.lat == nil {
|
|
continue
|
|
}
|
|
name := cmp.Or(zone.name, cur.Name)
|
|
lat, lng := cur.Lat, cur.Lng
|
|
if zone.lat != nil {
|
|
lat, lng = zone.lat, zone.lon
|
|
}
|
|
if name == cur.Name && sameFloat(lat, cur.Lat) && sameFloat(lng, cur.Lng) {
|
|
continue
|
|
}
|
|
if err := z.store.UpsertIATADetails(ctx, cur.IATA, name, lat, lng); err != nil {
|
|
return fmt.Errorf("save MeshMapper details for %s: %w", cur.IATA, err)
|
|
}
|
|
changed = true
|
|
}
|
|
if changed && z.onIATAs != nil {
|
|
z.onIATAs(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// syncGroups imports groups with a member that is heard or configured, never one only
|
|
// its own import created. Without every country's list it adds and updates but never prunes.
|
|
func (z *Zones) syncGroups(ctx context.Context, heard []string, groups map[string]zoneGroup, zones map[string]zoneEntry, complete bool) error {
|
|
inUse := maps.Clone(z.configured)
|
|
if inUse == nil {
|
|
inUse = map[string]bool{}
|
|
}
|
|
for _, iata := range heard {
|
|
inUse[iata] = true
|
|
}
|
|
state, err := z.store.ListRegionState(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("list regions for MeshMapper groups: %w", err)
|
|
}
|
|
stored, handOrder := map[string]RegionState{}, 0
|
|
for _, r := range state {
|
|
stored[r.Slug] = r
|
|
if !r.Imported {
|
|
handOrder = max(handOrder, r.DisplayOrder)
|
|
for _, iata := range r.IATAs {
|
|
inUse[iata] = true
|
|
}
|
|
}
|
|
}
|
|
var want []RegionState
|
|
for code, g := range groups {
|
|
if slices.ContainsFunc(g.members, func(m string) bool { return inUse[m] }) {
|
|
lat, lng := center(g.members, zones)
|
|
want = append(want, RegionState{Slug: strings.ToLower(code), Name: g.name, Imported: true, IATAs: g.members, CenterLat: lat, CenterLng: lng})
|
|
}
|
|
}
|
|
slices.SortFunc(want, func(a, b RegionState) int { return cmp.Or(cmp.Compare(a.Name, b.Name), cmp.Compare(a.Slug, b.Slug)) })
|
|
keep, changed := []string{}, false
|
|
for n, r := range want {
|
|
r.DisplayOrder = handOrder + 1 + n
|
|
if cur, ok := stored[r.Slug]; ok && !cur.Imported {
|
|
slog.Info("MeshMapper group skipped: a configured region uses its slug", "component", "meshmapper.zones", "slug", r.Slug)
|
|
continue
|
|
} else if ok && cur.Name == r.Name && cur.DisplayOrder == r.DisplayOrder && slices.Equal(cur.IATAs, r.IATAs) &&
|
|
sameFloat(cur.CenterLat, r.CenterLat) && sameFloat(cur.CenterLng, r.CenterLng) {
|
|
keep = append(keep, r.Slug)
|
|
continue
|
|
}
|
|
saved, err := z.store.SaveImportedRegion(ctx, r)
|
|
if err != nil {
|
|
return fmt.Errorf("save MeshMapper region %s: %w", r.Slug, err)
|
|
}
|
|
if !saved {
|
|
slog.Info("MeshMapper group skipped: a configured region uses its slug", "component", "meshmapper.zones", "slug", r.Slug)
|
|
continue
|
|
}
|
|
keep, changed = append(keep, r.Slug), true
|
|
slog.Info("MeshMapper region imported", "component", "meshmapper.zones", "slug", r.Slug, "name", r.Name, "iatas", r.IATAs)
|
|
}
|
|
var pruned []string
|
|
if complete {
|
|
if pruned, err = z.store.PruneImportedRegions(ctx, keep); err != nil {
|
|
return fmt.Errorf("prune MeshMapper regions: %w", err)
|
|
}
|
|
z.regionsRemoved(pruned)
|
|
}
|
|
if changed || len(pruned) > 0 {
|
|
z.regionsChanged(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// center averages the members' zone locations; nil when none has one.
|
|
func center(members []string, zones map[string]zoneEntry) (*float64, *float64) {
|
|
var lat, lng float64
|
|
n := 0
|
|
for _, m := range members {
|
|
if z := zones[m]; z.lat != nil {
|
|
lat, lng, n = lat+*z.lat, lng+*z.lon, n+1
|
|
}
|
|
}
|
|
if n == 0 {
|
|
return nil, nil
|
|
}
|
|
lat, lng = lat/float64(n), lng/float64(n)
|
|
return &lat, &lng
|
|
}
|
|
|
|
func sameFloat(a, b *float64) bool {
|
|
return a == nil && b == nil || a != nil && b != nil && *a == *b
|
|
}
|
|
|
|
func (z *Zones) regionsRemoved(slugs []string) {
|
|
for _, slug := range slugs {
|
|
slog.Info("MeshMapper region removed", "component", "meshmapper.zones", "slug", slug)
|
|
}
|
|
}
|
|
|
|
func (z *Zones) regionsChanged(ctx context.Context) {
|
|
if z.onRegions != nil {
|
|
z.onRegions(ctx)
|
|
}
|
|
}
|