145 lines
3.5 KiB
Go
145 lines
3.5 KiB
Go
// 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 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
|
|
}
|