mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-20 12:11:58 +00:00
202 lines
6.2 KiB
Go
202 lines
6.2 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package meshmapper
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"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
|
|
}
|
|
|
|
// syncDirectory applies the loaded zone lists to IATA details and, when enabled,
|
|
// imported regions. It only runs once every tracked country has a list, and again
|
|
// when a list or the IATA set changes.
|
|
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))
|
|
if !complete || (z.groupsSynced && version == z.groupsVersion && len(iatas) == z.groupsKnown) {
|
|
return nil
|
|
}
|
|
if err := z.syncIATADetails(ctx, zones); err != nil {
|
|
return err
|
|
}
|
|
if z.importGroups {
|
|
if err := z.syncGroups(ctx, iatas, groups, zones); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
z.groupsSynced, z.groupsVersion, z.groupsKnown = true, version, len(iatas)
|
|
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
|
|
}
|
|
|
|
func (z *Zones) syncGroups(ctx context.Context, iatas []string, groups map[string]zoneGroup, zones map[string]zoneEntry) error {
|
|
known := make(map[string]bool, len(iatas))
|
|
for _, iata := range iatas {
|
|
known[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)
|
|
}
|
|
}
|
|
var want []RegionState
|
|
for code, g := range groups {
|
|
if slices.ContainsFunc(g.members, func(m string) bool { return known[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)
|
|
}
|
|
pruned, err := z.store.PruneImportedRegions(ctx, keep)
|
|
if 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)
|
|
}
|
|
}
|