awards
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user