179 lines
5.0 KiB
Go
179 lines
5.0 KiB
Go
// Package solar fetches live space-weather / propagation numbers (solar flux,
|
|
// sunspot number, A/K indices, X-ray, geomagnetic field) from N0NBH's public
|
|
// hamqsl.com XML feed, caches them, and refreshes periodically. OpsLog shows the
|
|
// numbers in a header strip and stamps SFI / A_INDEX / K_INDEX onto each logged
|
|
// QSO (SSN has no standard ADIF field — the app stores it as an extra).
|
|
package solar
|
|
|
|
import (
|
|
"context"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// feedURL is N0NBH's solar data as XML. Free, no key.
|
|
const feedURL = "https://www.hamqsl.com/solarxml.php"
|
|
|
|
// Data is the parsed snapshot exposed to the UI (strings — some fields read
|
|
// "No Report" or carry units, so we keep them verbatim and parse to numbers only
|
|
// when stamping a QSO).
|
|
type Data struct {
|
|
SFI string `json:"sfi"` // solar flux index
|
|
SSN string `json:"ssn"` // sunspot number
|
|
AIndex string `json:"a_index"` // geomagnetic A index
|
|
KIndex string `json:"k_index"` // geomagnetic K index
|
|
XRay string `json:"xray"` // X-ray flux class (e.g. C1.5)
|
|
GeomagField string `json:"geomag_field"` // QUIET / UNSETTLED / STORM…
|
|
Aurora string `json:"aurora"`
|
|
MUF string `json:"muf"`
|
|
Updated string `json:"updated"` // source timestamp text
|
|
Source string `json:"source"` // e.g. N0NBH
|
|
OK bool `json:"ok"` // last fetch succeeded
|
|
Error string `json:"error,omitempty"`
|
|
FetchedAt time.Time `json:"fetched_at"`
|
|
}
|
|
|
|
// xmlSolar mirrors the hamqsl solarxml.php document.
|
|
type xmlSolar struct {
|
|
XMLName xml.Name `xml:"solar"`
|
|
SolarData struct {
|
|
Source string `xml:"source"`
|
|
Updated string `xml:"updated"`
|
|
SolarFlux string `xml:"solarflux"`
|
|
AIndex string `xml:"aindex"`
|
|
KIndex string `xml:"kindex"`
|
|
XRay string `xml:"xray"`
|
|
Sunspots string `xml:"sunspots"`
|
|
Aurora string `xml:"aurora"`
|
|
GeomagField string `xml:"geomagfield"`
|
|
MUF string `xml:"muf"`
|
|
} `xml:"solardata"`
|
|
}
|
|
|
|
// Fetch performs one GET + parse of the feed.
|
|
func Fetch(ctx context.Context, client *http.Client) (Data, error) {
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 20 * time.Second}
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
|
|
if err != nil {
|
|
return Data{}, err
|
|
}
|
|
req.Header.Set("User-Agent", "OpsLog")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return Data{}, fmt.Errorf("solar: request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return Data{}, fmt.Errorf("solar: read: %w", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return Data{}, fmt.Errorf("solar: http %d", resp.StatusCode)
|
|
}
|
|
return parseSolar(body)
|
|
}
|
|
|
|
// parseSolar turns the feed's XML bytes into a Data (split out so it can be
|
|
// unit-tested without a network call).
|
|
func parseSolar(body []byte) (Data, error) {
|
|
var x xmlSolar
|
|
if err := xml.Unmarshal(body, &x); err != nil {
|
|
return Data{}, fmt.Errorf("solar: parse: %w", err)
|
|
}
|
|
s := x.SolarData
|
|
return Data{
|
|
SFI: strings.TrimSpace(s.SolarFlux),
|
|
SSN: strings.TrimSpace(s.Sunspots),
|
|
AIndex: strings.TrimSpace(s.AIndex),
|
|
KIndex: strings.TrimSpace(s.KIndex),
|
|
XRay: strings.TrimSpace(s.XRay),
|
|
GeomagField: strings.TrimSpace(s.GeomagField),
|
|
Aurora: strings.TrimSpace(s.Aurora),
|
|
MUF: strings.TrimSpace(s.MUF),
|
|
Updated: strings.TrimSpace(s.Updated),
|
|
Source: strings.TrimSpace(s.Source),
|
|
OK: true,
|
|
FetchedAt: time.Now(),
|
|
}, nil
|
|
}
|
|
|
|
// Manager caches the latest Data and refreshes it on a timer.
|
|
type Manager struct {
|
|
mu sync.RWMutex
|
|
data Data
|
|
client *http.Client
|
|
onChange func()
|
|
stop chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
// NewManager builds a manager. onChange (optional) fires after each refresh so
|
|
// the host can push a UI event.
|
|
func NewManager(onChange func()) *Manager {
|
|
return &Manager{
|
|
client: &http.Client{Timeout: 20 * time.Second},
|
|
onChange: onChange,
|
|
stop: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Start fetches now and every hour until Stop (the feed updates roughly hourly).
|
|
func (m *Manager) Start() {
|
|
go func() {
|
|
m.refresh()
|
|
t := time.NewTicker(1 * time.Hour)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-m.stop:
|
|
return
|
|
case <-t.C:
|
|
m.refresh()
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Stop ends the refresh loop.
|
|
func (m *Manager) Stop() {
|
|
if m == nil {
|
|
return
|
|
}
|
|
m.once.Do(func() { close(m.stop) })
|
|
}
|
|
|
|
// Get returns the latest snapshot.
|
|
func (m *Manager) Get() Data {
|
|
if m == nil {
|
|
return Data{}
|
|
}
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return m.data
|
|
}
|
|
|
|
// Refresh forces an immediate fetch (used by a manual "refresh" binding).
|
|
func (m *Manager) Refresh() { m.refresh() }
|
|
|
|
func (m *Manager) refresh() {
|
|
d, err := Fetch(context.Background(), m.client)
|
|
m.mu.Lock()
|
|
if err != nil {
|
|
m.data.OK = false
|
|
m.data.Error = err.Error()
|
|
} else {
|
|
m.data = d
|
|
}
|
|
m.mu.Unlock()
|
|
if m.onChange != nil {
|
|
m.onChange()
|
|
}
|
|
}
|