// 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() } }