From 864131b8630f88677a1d87bcfce910ac9702ae25 Mon Sep 17 00:00:00 2001 From: Jacob Zelek Date: Sun, 16 May 2021 01:47:15 -0700 Subject: [PATCH] Show conditions with Ctrl+C --- README.md | 1 + cmd/termlog/config.go | 11 +++ cmd/termlog/mainscreen.go | 63 ++++++++++++++- go.mod | 1 + go.sum | 8 ++ solar/hamqsl_client.go | 148 ++++++++++++++++++++++++++++++++++++ solar/hamqsl_client_test.go | 78 +++++++++++++++++++ 7 files changed, 306 insertions(+), 4 deletions(-) create mode 100644 solar/hamqsl_client.go create mode 100644 solar/hamqsl_client_test.go diff --git a/README.md b/README.md index 2cae0a1..0f4e278 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ Usage of ./termlog: | Ctrl+R | Force screen redraw | | Alt+Left | Tune down 500khz | | Alt+Right | Tune up 500khz | +| Ctrl+C | Show band conditions | ## adif diff --git a/cmd/termlog/config.go b/cmd/termlog/config.go index b131269..5f09e0b 100644 --- a/cmd/termlog/config.go +++ b/cmd/termlog/config.go @@ -11,6 +11,7 @@ import ( "github.com/tzneal/ham-go/adif" "github.com/tzneal/ham-go/callsigns" "github.com/tzneal/ham-go/cmd/termlog/ui" + "github.com/tzneal/ham-go/solar" "github.com/tzneal/ham-go/spotting" ) @@ -79,6 +80,12 @@ type SOTASpot struct { URL string } +// HamQSL allows solar condition monitoring +type HamQSL struct { + Enabled bool + URL string +} + // Label is a label that will be displayed when tuned to a particular frequency. // The start/end are the limits. type Label struct { @@ -99,6 +106,7 @@ type Config struct { DXCluster DXCluster POTASpot POTASpot SOTASpot SOTASpot + HamQSL HamQSL Theme ui.Theme Label []Label noNet bool // lowercase, so it shouldn't be serialized @@ -209,6 +217,9 @@ func NewConfig() *Config { cfg.SOTASpot.Enabled = true cfg.SOTASpot.URL = spotting.SOTAURL + cfg.HamQSL.Enabled = true + cfg.HamQSL.URL = solar.HAMQSL_URL + // 160 meters cfg.Label = append(cfg.Label, Label{ Name: "E/A/G/Data/Phone", diff --git a/cmd/termlog/mainscreen.go b/cmd/termlog/mainscreen.go index bd55f2b..ad59406 100644 --- a/cmd/termlog/mainscreen.go +++ b/cmd/termlog/mainscreen.go @@ -27,6 +27,7 @@ import ( "github.com/tzneal/ham-go/logingest" "github.com/tzneal/ham-go/logsync" "github.com/tzneal/ham-go/rig" + "github.com/tzneal/ham-go/solar" "github.com/tzneal/ham-go/spotting" ) @@ -49,6 +50,7 @@ type mainScreen struct { shutdown chan struct{} lookup callsigns.Lookup loggingReplaced bool + solar *solar.Solar } type logRequest struct { record adif.Record @@ -109,7 +111,8 @@ func newMainScreen(cfg *Config, alog *adif.Log, repo *git.Repository, bookmarks // is the spot monitoring enabled? shutdown := make(chan struct{}) - if !cfg.noNet && (cfg.DXCluster.Enabled || cfg.POTASpot.Enabled) { + if !cfg.noNet && (cfg.DXCluster.Enabled || cfg.POTASpot.Enabled || + cfg.SOTASpot.Enabled) { // create the UI dxHeight := remainingHeight - 1 - msgHeight // -1 due to status bar spotlist := ui.NewSpottingList(yPos, dxHeight, time.Duration(cfg.Operator.SpotExpiration)*time.Second, cfg.Theme) @@ -330,6 +333,7 @@ func newMainScreen(cfg *Config, alog *adif.Log, repo *git.Repository, bookmarks ms.js8log.Run() } } + if cfg.FLLog.Enabled { fldigiLog, err := logingest.NewFLDIGIServer(cfg.FLLog.Address) if err != nil { @@ -341,6 +345,25 @@ func newMainScreen(cfg *Config, alog *adif.Log, repo *git.Repository, bookmarks } } + if !cfg.noNet && cfg.HamQSL.Enabled { + hcfg := solar.HamQSLConfig{ + URL: cfg.HamQSL.URL, + } + hclient := solar.NewHamQSLClient(hcfg) + hclient.Run() + go func() { + for { + select { + case <-shutdown: + return + case s := <-hclient.Solar: + ms.solar = &s + _ = s + } + } + }() + } + c.AddCommand(input.KeyCtrlH, ms.showHelp) c.AddCommand(input.KeyCtrlL, ms.focusQSOList) c.AddCommand(input.KeyCtrlN, ms.newQSO) @@ -351,6 +374,7 @@ func newMainScreen(cfg *Config, alog *adif.Log, repo *git.Repository, bookmarks c.AddCommand(input.KeyCtrlG, ms.commitLog) c.AddCommand(input.KeyCtrlR, ms.redrawAll) c.AddCommand(input.KeyCtrlX, ms.exportCabrillo) + c.AddCommand(input.KeyCtrlC, ms.showConditions) c.AddCommand(input.KeyCtrlE, ms.executeCommands) c.AddCommand(input.KeyAltLeft, ms.tuneLeft) @@ -704,6 +728,7 @@ func (m *mainScreen) newQSO() { func (m *mainScreen) focusQSOList() { m.controller.Focus(m.qsoList) } + func (m *mainScreen) saveQSO() { if m.qso.IsValid() || ui.YesNoQuestion("Missing callsign or frequency, save anyway?") { rec := m.qso.GetRecord() @@ -720,6 +745,36 @@ func (m *mainScreen) saveQSO() { } } +func (m *mainScreen) showConditions() { + sb := strings.Builder{} + + formatDate := m.solar.SolarData.Updated.Format("01-02-2006 15:04:05") + sb.WriteString(fmt.Sprintf("%s UTC\n", formatDate)) + sb.WriteString("\n") + sb.WriteString(fmt.Sprintf("Sunspots: %d\n", m.solar.SolarData.Sunspots)) + sb.WriteString(fmt.Sprintf("Solar Flux: %d\n", m.solar.SolarData.SolarFlux)) + sb.WriteString(fmt.Sprintf("MUF: %0.2f MHz\n", m.solar.SolarData.MUF)) + sb.WriteString(fmt.Sprintf("K Index: %d\n", m.solar.SolarData.KIndex)) + sb.WriteString(fmt.Sprintf("A Index: %d\n", m.solar.SolarData.AIndex)) + sb.WriteString(fmt.Sprintf("Signal Noise: %s\n", m.solar.SolarData.SignalNoise)) + sb.WriteString("\n") + for _, band := range m.solar.SolarData.CalculatedConditions.Bands { + // Add spaces to day to make as long as night + timeStr := band.Time + spacesToAdd := 5 - len(timeStr) + for i := 0; i < spacesToAdd; i++ { + timeStr += " " + } + sb.WriteString(fmt.Sprintf("%s %s\t%s", band.Name, timeStr, band.Condition)) + sb.WriteString("\n") + } + + sb.WriteString("\n") + sb.WriteString("Press ESC to close") + ui.Splash("Conditions", sb.String()) + +} + func (m *mainScreen) showHelp() { sb := strings.Builder{} sb.WriteString("Ctrl+H - Show Help Ctrl+Q - Quit\n") @@ -739,12 +794,12 @@ func (m *mainScreen) showHelp() { sb.WriteString("Ctrl+E - Display Custom Commands\n") sb.WriteString("Ctrl+G - Commit log file to git\n") sb.WriteString("Ctrl+R - Force Screen Redraw\n") - sb.WriteString("ALt+Left - Tune Down\n") - sb.WriteString("ALt+Right - Tune Up\n") + sb.WriteString("Alt+Left - Tune Down\n") + sb.WriteString("Alt+Right - Tune Up\n") + sb.WriteString("Ctrl+C - Show Band Conditions\n") sb.WriteString("\n") sb.WriteString("Press ESC to close") ui.Splash("Commands", sb.String()) - } func (m *mainScreen) Tick() bool { diff --git a/go.mod b/go.mod index 6f7caf5..143b430 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/mattn/go-runewidth v0.0.9 // indirect github.com/nsf/termbox-go v0.0.0-20200418040025-38ba6e5628f1 github.com/pd0mz/go-maidenhead v0.0.0-20170221185439-faa09c24425e + github.com/stretchr/testify v1.7.0 // indirect go.etcd.io/bbolt v1.3.4 golang.org/x/crypto v0.0.0-20200429183012-4b2356b1ed79 // indirect golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5 // indirect diff --git a/go.sum b/go.sum index da24bf8..3abb9a2 100644 --- a/go.sum +++ b/go.sum @@ -54,13 +54,19 @@ github.com/pd0mz/go-maidenhead v0.0.0-20170221185439-faa09c24425e h1:Wi4WahFJ11u github.com/pd0mz/go-maidenhead v0.0.0-20170221185439-faa09c24425e/go.mod h1:4Q+QSDCqWqlabstLGUVm47rAcL06nEEty2d3KzsTNMk= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/term v0.0.0-20180423043932-cda20d4ac917/go.mod h1:eCbImbZ95eXtAUIbLAuAVnBnwf83mjf6QIVH8SHYwqQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/src-d/gcfg v1.3.0/go.mod h1:p/UMsR43ujA89BJY9duynAwIpvqEujIH/jFlfL7jWoI= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/wunderlist/ttlcache v0.0.0-20140611003616-fa8f18d5e019/go.mod h1:oWWm4B/FRe5AKcl+/5tz6YaA4HWpzzt5hSKM5+LSYgM= github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70= github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= go.etcd.io/bbolt v1.3.4 h1:hi1bXHMVrlQh6WwxAy+qZCV/SYIlqo+Ushwdpa4tAKg= @@ -95,3 +101,5 @@ gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRN gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/solar/hamqsl_client.go b/solar/hamqsl_client.go new file mode 100644 index 0000000..f13eb1d --- /dev/null +++ b/solar/hamqsl_client.go @@ -0,0 +1,148 @@ +package solar + +import ( + "encoding/xml" + "log" + "net/http" + "strings" + "time" +) + +const HAMQSL_URL = "http://www.hamqsl.com/solarxml.php" + +type HamQSLConfig struct { + URL string // HamQSL API url, defaults to http://www.hamqsl.com/solarxml.php + Delay time.Duration // Delay between SPOT checks, defaults to 60 minutes with a minimum of 60 minutes +} + +// HamQSL is a client for retrieving solar forecast's +// relevant to propagation +type HamQSLClient struct { + Solar chan Solar + + config HamQSLConfig + shutdown chan struct{} +} + +type Band struct { + Name string `xml:"name,attr"` // Band range (e.g. 80m-40m) + Time string `xml:"time,attr"` // Day or night + Condition string `xml:",chardata"` // Condition (e.g. Good, Fair) +} + +type Phenomenon struct { + Name string `xml:"name,attr"` // (e.g. E-Skip) + Location string `xml:"location,attr"` // (e.g. north_america) + Condition string `xml:",chardata"` // (e.g. Band Closed) +} + +type CalculatedConditions struct { + Bands []Band `xml:"band"` +} + +type CalculatedConditionsVHF struct { + Phenomenon []Phenomenon `xml:"phenomenon"` +} + +type SolarData struct { + UpdatedStr string `xml:"updated"` + Updated time.Time + SolarFlux int `xml:"solarflux"` + AIndex int `xml:"aindex"` + KIndex int `xml:"kindex"` + KIndexNt string `xml:"kindexnt"` + XRay string `xml:"xray"` + Sunspots int `xml:"sunspots"` + HeliumLine string `xml:"heliumline"` + ProtonFlux string `xml:"protonflux"` + ElectronFlux string `xml:"electronflux"` + Aurora int `xml:"aurora"` + Normalization float32 `xml:"normalization"` + LatDegree float32 `xml:"latdegree"` + SolarWind float32 `xml:"solarwind"` + MagneticField float32 `xml:"magneticfield"` + GeomagneticField string `xml:"geomagfield"` + SignalNoise string `xml:"signalnoise"` + Fof2 float32 `xml:"fof2"` + MUFFactor float32 `xml:"muffactor"` + MUF float32 `xml:"muf"` + CalculatedConditions CalculatedConditions `xml:"calculatedconditions"` + CalculatedConditionsVHF CalculatedConditionsVHF `xml:"calculatedvhfconditions"` +} + +type Solar struct { + SolarData SolarData `xml:"solardata"` +} + +// NewHamQSLClient constructs a new HamQSL client +func NewHamQSLClient(cfg HamQSLConfig) *HamQSLClient { + // enforce a minimum poll delay of once per hour + if cfg.Delay < 60*time.Minute { + cfg.Delay = 60 * time.Minute + } + if cfg.URL == "" { + cfg.URL = HAMQSL_URL + } + client := &HamQSLClient{ + config: cfg, + Solar: make(chan Solar), + shutdown: make(chan struct{}), + } + return client +} + +// Close gracefully shuts down the client +func (c *HamQSLClient) Close() error { + close(c.shutdown) + return nil +} + +// Run is a non-blocking call that starts the client +func (c *HamQSLClient) Run() { + go c.run() +} + +func (c *HamQSLClient) run() { + poll := func() bool { + req, err := http.NewRequest("GET", c.config.URL, nil) + if err != nil { + log.Printf("error forming HamQSL request: %s", err) + return false + } + req.Header.Set("User-Agent", "ham-go") + rsp, err := http.DefaultClient.Do(req) + if err != nil { + log.Printf("error fetching HamQSL solar data: %s", err) + return true + } + dec := xml.NewDecoder(rsp.Body) + defer rsp.Body.Close() + var result Solar + if err := dec.Decode(&result); err != nil { + log.Printf("error parsing HamQSL solar data: %s", err) + } + updatedStr := strings.Trim(result.SolarData.UpdatedStr, " ") + updatedTime, err := time.Parse("02 Jan 2006 1504 MST", updatedStr) + if err != nil { + log.Printf("error parsing updated time '%s': %s", updatedStr, err) + } else { + result.SolarData.Updated = updatedTime + c.Solar <- result + } + return true + } + + // do an initial poll + poll() + for { + select { + case <-time.After(c.config.Delay): + if !poll() { + return + } + case <-c.shutdown: + close(c.Solar) + return + } + } +} diff --git a/solar/hamqsl_client_test.go b/solar/hamqsl_client_test.go new file mode 100644 index 0000000..f9c5d39 --- /dev/null +++ b/solar/hamqsl_client_test.go @@ -0,0 +1,78 @@ +package solar + +import ( + "encoding/xml" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHamQSLClientParse(t *testing.T) { + var solar Solar + err := xml.Unmarshal([]byte(SAMPLE_RESPONSE), &solar) + assert.NoError(t, err) + + // Spot check various solar values + assert.Equal(t, 70, solar.SolarData.SolarFlux) + assert.Equal(t, 4, solar.SolarData.AIndex) + assert.Equal(t, 1, solar.SolarData.KIndex) + assert.Equal(t, "No Report", solar.SolarData.KIndexNt) + assert.Equal(t, "A3.6", solar.SolarData.XRay) + assert.Equal(t, 0, solar.SolarData.Sunspots) + assert.Equal(t, float32(9.86), solar.SolarData.MUF) + + // Assert HF band conditions parsed correctly + assert.Equal(t, "80m-40m", solar.SolarData.CalculatedConditions.Bands[0].Name) + assert.Equal(t, "day", solar.SolarData.CalculatedConditions.Bands[0].Time) + assert.Equal(t, "Fair", solar.SolarData.CalculatedConditions.Bands[0].Condition) + + // Assert VHF phenomenon parsed correctly + assert.Equal(t, "E-Skip", solar.SolarData.CalculatedConditionsVHF.Phenomenon[2].Name) + assert.Equal(t, "north_america", solar.SolarData.CalculatedConditionsVHF.Phenomenon[2].Location) + assert.Equal(t, "Band Closed", solar.SolarData.CalculatedConditionsVHF.Phenomenon[2].Condition) +} + +const SAMPLE_RESPONSE = ` + + +N0NBH + 07 May 2021 0818 GMT +70 + 4 + 1 +No Report +A3.6 +0 + 95.4 +19 +851 + 1 +1.99 +67.5 +292.5 + 1.2 + +Fair +Fair +Poor +Poor +Good +Fair +Poor +Poor + + +Band Closed +Band Closed +Band Closed +50MHz ES +Band Closed + +VR QUIET +S0-S1 +4.05 +2.43 + 9.86 + + +`