chore: release v0.19.3

This commit is contained in:
2026-07-09 17:32:13 +02:00
parent 19c2de5f61
commit 1f74e4d234
11 changed files with 482 additions and 9 deletions
+178
View File
@@ -0,0 +1,178 @@
// 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()
}
}
+48
View File
@@ -0,0 +1,48 @@
package solar
import "testing"
// A trimmed sample of the real N0NBH hamqsl.com solarxml.php document.
const sampleXML = `<?xml version="1.0"?>
<solar>
<solardata>
<source url="http://www.hamqsl.com/solar.html">N0NBH</source>
<updated>08 Jul 2025 2100 GMT</updated>
<solarflux>170</solarflux>
<aindex>5</aindex>
<kindex>2</kindex>
<xray>C1.5</xray>
<sunspots>150</sunspots>
<geomagfield>QUIET</geomagfield>
<muf>18.5</muf>
</solardata>
</solar>`
func TestParseSolar(t *testing.T) {
d, err := parseSolar([]byte(sampleXML))
if err != nil {
t.Fatalf("parse: %v", err)
}
if !d.OK {
t.Fatalf("expected OK")
}
checks := map[string]string{
"SFI": d.SFI, "SSN": d.SSN, "A": d.AIndex, "K": d.KIndex,
"XRay": d.XRay, "Geomag": d.GeomagField, "Source": d.Source,
}
want := map[string]string{
"SFI": "170", "SSN": "150", "A": "5", "K": "2",
"XRay": "C1.5", "Geomag": "QUIET", "Source": "N0NBH",
}
for k, got := range checks {
if got != want[k] {
t.Errorf("%s = %q, want %q", k, got, want[k])
}
}
}
func TestParseSolarBadXML(t *testing.T) {
if _, err := parseSolar([]byte("not xml")); err == nil {
t.Fatalf("expected an error for non-XML input")
}
}