Files
OpsLog/pskrtarget.go
T
2026-09-05 19:07:21 +02:00

151 lines
4.6 KiB
Go

package main
// PSK Reporter target analysis — the panel beside the FT decodes.
//
// The FT decodes list says who is on the air. It cannot say whether the station
// you are about to call can hear you, and on FT8 that is the only question that
// matters: a pileup is invisible from this end, and calling a DX who is not
// hearing your region at all is the commonest way to spend twenty minutes for
// nothing.
//
// internal/pskrtgt does the work. This file is the wiring: settings, lifecycle,
// and the bindings the panel polls.
import (
"fmt"
"strings"
"hamlog/internal/applog"
"hamlog/internal/pskrtgt"
)
const (
keyPSKTargetOn = "pskrtgt.enabled" // the operator's master switch
keyPSKTargetScope = "pskrtgt.scope" // "target" (narrow) | "band" (whole band)
)
// PSKTargetSettings is the panel's shape in Settings.
type PSKTargetSettings struct {
Enabled bool `json:"enabled"`
// Scope is how much of the PSK Reporter feed is subscribed to. The narrow
// one is three filters about two callsigns; the wide one is every FTx report
// on the band. The difference is a few messages a second against several
// hundred, which on an old PC is the difference between a panel and a
// problem — so the narrow one is the default, and the wide one is there for
// an operator who switches target constantly and has the machine for it.
Scope string `json:"scope"`
}
func (a *App) GetPSKTargetSettings() PSKTargetSettings {
scope := a.settingOr(keyPSKTargetScope, string(pskrtgt.ScopeTarget))
if scope != string(pskrtgt.ScopeBand) {
scope = string(pskrtgt.ScopeTarget)
}
return PSKTargetSettings{
Enabled: a.settingOr(keyPSKTargetOn, "0") == "1",
Scope: scope,
}
}
func (a *App) SavePSKTargetSettings(s PSKTargetSettings) error {
on := "0"
if s.Enabled {
on = "1"
}
scope := s.Scope
if scope != string(pskrtgt.ScopeBand) {
scope = string(pskrtgt.ScopeTarget)
}
a.setSetting(keyPSKTargetOn, on)
a.setSetting(keyPSKTargetScope, scope)
// Rebuilt rather than reconfigured: the scope decides the subscriptions, and
// a watcher that changed them under itself would have to unpick filters it
// no longer knows the shape of. It reconnects on the next Watch call, which
// the panel makes every second anyway.
a.stopPSKTarget()
applog.Printf("pskr target: %v (scope %s)", s.Enabled, scope)
return nil
}
// pskTargetWatcher returns the watcher, building it on first use. Nothing is
// connected until a target is set, so an operator who never opens the panel
// never opens a socket to the broker.
func (a *App) pskTargetWatcher() *pskrtgt.Watcher {
a.pskTgtMu.Lock()
defer a.pskTgtMu.Unlock()
if a.pskTgt != nil {
return a.pskTgt
}
s := a.GetPSKTargetSettings()
a.pskTgt = pskrtgt.New(pskrtgt.Config{
Scope: pskrtgt.Scope(s.Scope),
MyCall: a.opCall,
MyGrid: a.opGrid,
// Only a fallback, for a station with no grid set — see the package.
Continent: func(call string) string {
if a.dxcc == nil {
return ""
}
if m, ok := a.dxcc.Lookup(call); ok {
return m.Continent
}
return ""
},
Logf: applog.Printf,
})
return a.pskTgt
}
func (a *App) stopPSKTarget() {
a.pskTgtMu.Lock()
w := a.pskTgt
a.pskTgt = nil
a.pskTgtMu.Unlock()
if w != nil {
w.Stop()
}
}
// SetPSKTarget points the analysis at a callsign — normally the station the
// operator has just clicked or is calling. An empty callsign takes the feed
// down: no target, no subscription.
//
// mode is the target's own (FT8/FT4), and dialHz the operator's dial, which is
// what turns a report's frequency into an audio offset.
func (a *App) SetPSKTarget(call, mode string, dialHz int64) error {
if !a.GetPSKTargetSettings().Enabled {
a.stopPSKTarget()
return nil
}
call = strings.ToUpper(strings.TrimSpace(call))
if call == "" {
a.stopPSKTarget()
return nil
}
w := a.pskTargetWatcher()
// The operator's identity is re-asserted on every call rather than captured
// once: a profile switch changes both the callsign "he decoded you" looks
// for and the square "near me" is measured from.
w.SetOperator(a.opCall, a.opGrid)
if err := w.Watch(call, mode, dialHz); err != nil {
return fmt.Errorf("PSK Reporter: %w", err)
}
return nil
}
// GetPSKAnalysis is what the panel polls. Always answers, even with the feature
// off or no target set — the panel says WHY it is empty, and it can only do
// that if it is told.
func (a *App) GetPSKAnalysis() pskrtgt.Analysis {
s := a.GetPSKTargetSettings()
a.pskTgtMu.Lock()
w := a.pskTgt
a.pskTgtMu.Unlock()
if w == nil {
return pskrtgt.Analysis{Enabled: s.Enabled}
}
an := w.Snapshot()
an.Enabled = s.Enabled
return an
}