// Package pota polls the Parks On The Air activator-spots API and exposes a // fast in-memory lookup so DX-cluster spots can be tagged "this station is // currently activating a park". No API key required. package pota import ( "context" "encoding/json" "net/http" "strings" "sync" "time" ) const apiURL = "https://api.pota.app/spot/activator" // Info is the park data we surface for a currently-active activator. type Info struct { Reference string `json:"reference"` // park id, e.g. "US-2072" ParkName string `json:"park_name"` // human name LocationDesc string `json:"location_desc"` // e.g. "US-NY" } // apiSpot is the subset of the POTA API record we read. type apiSpot struct { Activator string `json:"activator"` Reference string `json:"reference"` ParkName string `json:"parkName"` Name string `json:"name"` LocationDesc string `json:"locationDesc"` } // Cache holds the latest activator set, refreshed in the background. type Cache struct { mu sync.RWMutex byCall map[string]Info // base callsign (upper) → info client *http.Client logf func(string, ...any) } // New creates a cache. logf may be nil. func New(logf func(string, ...any)) *Cache { return &Cache{ byCall: map[string]Info{}, client: &http.Client{Timeout: 20 * time.Second}, logf: logf, } } // Run refreshes immediately, then every 60 s until ctx is cancelled. func (c *Cache) Run(ctx context.Context) { c.refresh(ctx) t := time.NewTicker(60 * time.Second) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: c.refresh(ctx) } } } func (c *Cache) log(format string, a ...any) { if c.logf != nil { c.logf(format, a...) } } func (c *Cache) refresh(ctx context.Context) { req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) if err != nil { c.log("pota: request: %v", err) return } resp, err := c.client.Do(req) if err != nil { c.log("pota: fetch: %v", err) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { c.log("pota: http %d", resp.StatusCode) return } var spots []apiSpot if err := json.NewDecoder(resp.Body).Decode(&spots); err != nil { c.log("pota: decode: %v", err) return } m := make(map[string]Info, len(spots)) for _, s := range spots { call := baseCall(s.Activator) if call == "" { continue } name := strings.TrimSpace(s.Name) if name == "" { name = strings.TrimSpace(s.ParkName) } // Keep the first reference seen for a call (most-recent-first ordering // from the API), but don't clobber with a blank. if _, exists := m[call]; exists { continue } m[call] = Info{Reference: s.Reference, ParkName: name, LocationDesc: s.LocationDesc} } c.mu.Lock() c.byCall = m c.mu.Unlock() c.log("pota: %d active activators", len(m)) } // Lookup returns park info for a callsign if it's currently activating. func (c *Cache) Lookup(call string) (Info, bool) { c.mu.RLock() defer c.mu.RUnlock() if len(c.byCall) == 0 { return Info{}, false } i, ok := c.byCall[baseCall(call)] return i, ok } // BaseCall is the exported callsign normaliser used for hunter-log matching. func BaseCall(s string) string { return baseCall(s) } // baseCall normalises a callsign for matching: upper-cased, and when it carries // "/" segments (F4BPO/P, HB9/F4BPO) we take the longest segment, which is // almost always the home call. func baseCall(s string) string { s = strings.ToUpper(strings.TrimSpace(s)) if s == "" { return "" } if !strings.Contains(s, "/") { return s } best := "" for _, part := range strings.Split(s, "/") { if len(part) > len(best) { best = part } } return best }