This commit is contained in:
2026-06-05 17:22:38 +02:00
parent cf9dbf26f3
commit 88623f55df
21 changed files with 2123 additions and 50 deletions
+144
View File
@@ -0,0 +1,144 @@
// 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
}