diff --git a/app.go b/app.go index 168ddac..d3bee5a 100644 --- a/app.go +++ b/app.go @@ -286,13 +286,18 @@ func (a *App) applyProfile(profile config.Profile) { a.poller.UpdateConfig(&profile) a.rotator.UpdateProfile(profile) a.startManagedHamlib(profile) + if a.udpSrv != nil { + a.udpSrv.UpdateConfig(&profile) + } } // startUDPServer creates and starts the UDP server, storing it in a.udpSrv. func (a *App) startUDPServer(port int) error { + profile := a.cfg.ActiveProfile() a.udpSrv = udp.New( port, a.wlClient, + &profile, func(result *wavelog.QSOResult) { wailsruntime.EventsEmit(a.ctx, "qso:result", result) }, diff --git a/frontend/src/components/ConfigTab.svelte b/frontend/src/components/ConfigTab.svelte index 40c0aa5..e237297 100644 --- a/frontend/src/components/ConfigTab.svelte +++ b/frontend/src/components/ConfigTab.svelte @@ -9,6 +9,7 @@ import WavelogSection from "./config/WavelogSection.svelte"; import RadioSection from "./config/RadioSection.svelte"; import RotatorSection from "./config/RotatorSection.svelte"; + import SatelliteSection from "./config/SatelliteSection.svelte"; import ProfileModal from "./config/ProfileModal.svelte"; import AdvancedModal from "./config/AdvancedModal.svelte"; @@ -80,6 +81,7 @@ : "none"; $: rotatorEnabled = cfg?.profiles?.[cfg.profile]?.rotator_enabled ?? false; + $: satEnabled = cfg?.profiles?.[cfg.profile]?.sat_enabled ?? false; function setRadioType(type) { setProfileField("flrig_ena", type === "flrig"); @@ -126,6 +128,11 @@ {rotatorEnabled} on:fieldchange={(e) => setProfileField(e.detail.key, e.detail.value)} /> + setProfileField(e.detail.key, e.detail.value)} + /> {/key} diff --git a/frontend/src/components/config/SatelliteSection.svelte b/frontend/src/components/config/SatelliteSection.svelte new file mode 100644 index 0000000..004be3a --- /dev/null +++ b/frontend/src/components/config/SatelliteSection.svelte @@ -0,0 +1,76 @@ + + +
+
+
+ Satellite / Transverter +
+ +
+ + {#if satEnabled} +
+
+ + dispatch("fieldchange", { key: "sat_tx_offset_mhz", value: Number(e.currentTarget.value) })} + min="0" step="0.001" + placeholder="0" + /> + MHz +
+
+ + dispatch("fieldchange", { key: "sat_rx_offset_mhz", value: Number(e.currentTarget.value) })} + min="0" step="0.001" + placeholder="0" + /> + MHz +
+
+ + dispatch("fieldchange", { key: "sat_name", value: e.currentTarget.value })} + placeholder="QO-100" + /> +
+
+ + dispatch("fieldchange", { key: "sat_mode", value: e.currentTarget.value })} + placeholder="S/X" + /> +
+
+ {/if} +
diff --git a/internal/adif/satellite.go b/internal/adif/satellite.go new file mode 100644 index 0000000..853d9b4 --- /dev/null +++ b/internal/adif/satellite.go @@ -0,0 +1,59 @@ +package adif + +import ( + "fmt" + "strconv" + + "waveloggate/internal/config" + "waveloggate/internal/debug" +) + +// ApplySatellite applies transverter frequency offsets and injects satellite +// ADIF fields (PROP_MODE, SAT_NAME, SAT_MODE) into the parsed ADIF field map. +// This is called after parsing and band enrichment, before serialisation. +func ApplySatellite(fields map[string]string, cfg *config.Profile) { + if cfg == nil || !cfg.SatEnabled { + return + } + + origFreq := fields["FREQ"] + + // --- TX frequency offset --- + if origFreq != "" && cfg.SatTxOffsetMHz != 0 { + if freqMHz, err := strconv.ParseFloat(origFreq, 64); err == nil { + corrected := freqMHz + cfg.SatTxOffsetMHz + fields["FREQ"] = fmt.Sprintf("%.6f", corrected) + if band := FreqToBand(corrected); band != "" { + fields["BAND"] = band + } + debug.Log("[SAT] FREQ: %.6f → %.6f MHz (band=%s)", freqMHz, corrected, fields["BAND"]) + } + } + + // --- RX frequency offset --- + if cfg.SatRxOffsetMHz != 0 { + // Use existing FREQ_RX if present, otherwise fall back to the + // original IF frequency (before TX offset was applied). + rxSource := fields["FREQ_RX"] + if rxSource == "" && origFreq != "" && cfg.SatRxOffsetMHz != cfg.SatTxOffsetMHz { + rxSource = origFreq + } + if rxSource != "" { + if rxMHz, err := strconv.ParseFloat(rxSource, 64); err == nil { + correctedRX := rxMHz + cfg.SatRxOffsetMHz + fields["FREQ_RX"] = fmt.Sprintf("%.6f", correctedRX) + debug.Log("[SAT] FREQ_RX: %.6f → %.6f MHz", rxMHz, correctedRX) + } + } + } + + // --- Inject satellite ADIF fields --- + fields["PROP_MODE"] = "SAT" + if cfg.SatName != "" { + fields["SAT_NAME"] = cfg.SatName + } + if cfg.SatMode != "" { + fields["SAT_MODE"] = cfg.SatMode + } + debug.Log("[SAT] injected PROP_MODE=SAT SAT_NAME=%s SAT_MODE=%s", cfg.SatName, cfg.SatMode) +} diff --git a/internal/config/config.go b/internal/config/config.go index 1771892..d889b5c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,6 +32,13 @@ type Profile struct { RotatorParkAz float64 `json:"rotator_park_az"` RotatorParkEl float64 `json:"rotator_park_el"` + // Satellite / Transverter settings. + SatEnabled bool `json:"sat_enabled"` + SatTxOffsetMHz float64 `json:"sat_tx_offset_mhz"` // MHz added to TX frequency + SatRxOffsetMHz float64 `json:"sat_rx_offset_mhz"` // MHz added to RX frequency + SatName string `json:"sat_name"` // ADIF SAT_NAME (e.g., "QO-100") + SatMode string `json:"sat_mode"` // ADIF SAT_MODE (e.g., "S/X") + // Managed rigctld settings (WaveLogGate launches/manages rigctld). HamlibManaged bool `json:"hamlib_managed"` HamlibModel int `json:"hamlib_model"` @@ -81,7 +88,7 @@ func defaultProfile() Profile { func Default() Config { return Config{ - Version: 6, + Version: 7, Profile: 0, ProfileNames: []string{"Profile 1", "Profile 2"}, UDPEnabled: true, @@ -142,7 +149,7 @@ func Save(cfg Config) error { return os.WriteFile(path, data, 0644) } -// migrate ensures the config matches version 4 schema. +// migrate ensures the config matches version 7 schema. func migrate(cfg Config) Config { // Ensure at least 2 profiles exist. for len(cfg.Profiles) < 2 { @@ -177,6 +184,10 @@ func migrate(cfg Config) Config { cfg.UDPEmitHost = "127.0.0.1" } } + if cfg.Version < 7 { + cfg.Version = 7 + // Satellite/transverter fields default to zero values (disabled). + } return cfg } diff --git a/internal/radio/poller.go b/internal/radio/poller.go index 99aadca..8498e16 100644 --- a/internal/radio/poller.go +++ b/internal/radio/poller.go @@ -85,6 +85,14 @@ func (p *Poller) SetFreqMode(hz int64, mode string) error { return fmt.Errorf("no radio client configured") } + // Reverse satellite offset: the incoming frequency is the displayed + // (offset-corrected) value. Subtract the offset so the radio receives + // the IF frequency. + if cfg.SatEnabled && cfg.SatRxOffsetMHz != 0 { + hz = ReverseSatOffset(hz, cfg.SatRxOffsetMHz) + debug.Log("[QSY] sat reverse RX offset → %d Hz", hz) + } + modes, _ := client.GetModes() debug.Log("[QSY] requested mode=%q freq=%d available=%v", mode, hz, modes) @@ -104,11 +112,19 @@ func (p *Poller) SetFreqMode(hz int64, mode string) error { func (p *Poller) SetTxFreq(hz int64) error { p.mu.Lock() client := p.client + cfg := p.cfg p.mu.Unlock() if client == nil { return fmt.Errorf("no radio client configured") } + + // Reverse satellite offset for TX frequency. + if cfg.SatEnabled && cfg.SatTxOffsetMHz != 0 { + hz = ReverseSatOffset(hz, cfg.SatTxOffsetMHz) + debug.Log("[QSY] sat reverse TX offset → %d Hz", hz) + } + return client.SetTxFreq(hz) } @@ -132,6 +148,9 @@ func (p *Poller) poll() { status.Power = 0 } + // Apply satellite/transverter frequency offsets. + ApplySatOffsets(&status, cfg) + p.mu.Lock() changed := !statusEqual(status, p.lastStatus) forceUpdate := time.Since(p.lastUpdated) > 30*time.Minute @@ -156,6 +175,11 @@ func (p *Poller) poll() { data.FrequencyRx = int64(math.Round(status.FreqB)) data.ModeRx = status.ModeB } + if cfg.SatEnabled { + data.PropMode = "SAT" + data.SatName = cfg.SatName + data.SatMode = cfg.SatMode + } _ = p.wlClient.UpdateRadioStatus(data) } } else { diff --git a/internal/radio/satellite.go b/internal/radio/satellite.go new file mode 100644 index 0000000..fad6113 --- /dev/null +++ b/internal/radio/satellite.go @@ -0,0 +1,34 @@ +package radio + +import ( + "waveloggate/internal/config" + "waveloggate/internal/debug" +) + +// ApplySatOffsets applies transverter frequency offsets to a RigStatus. +// FreqA is the main/RX VFO, FreqB is the TX VFO in split mode. +// All frequencies are in Hz. +func ApplySatOffsets(status *RigStatus, cfg *config.Profile) { + if cfg == nil || !cfg.SatEnabled { + return + } + + txOffsetHz := cfg.SatTxOffsetMHz * 1_000_000 + rxOffsetHz := cfg.SatRxOffsetMHz * 1_000_000 + + if !status.Split { + // Simplex: single VFO, use TX offset. + status.FreqA += txOffsetHz + } else { + // Split: FreqA=RX, FreqB=TX. + status.FreqA += rxOffsetHz + status.FreqB += txOffsetHz + } + debug.Log("[SAT] radio offsets applied: FreqA=%.0f FreqB=%.0f split=%v", status.FreqA, status.FreqB, status.Split) +} + +// ReverseSatOffset subtracts the satellite offset from a displayed frequency +// to recover the IF frequency for sending to the radio hardware. +func ReverseSatOffset(hz int64, offsetMHz float64) int64 { + return hz - int64(offsetMHz*1_000_000) +} diff --git a/internal/udp/server.go b/internal/udp/server.go index 39ff9d8..72dc568 100644 --- a/internal/udp/server.go +++ b/internal/udp/server.go @@ -4,8 +4,10 @@ import ( "fmt" "net" "strings" + "sync" "waveloggate/internal/adif" + "waveloggate/internal/config" "waveloggate/internal/debug" "waveloggate/internal/wavelog" ) @@ -21,19 +23,35 @@ type Server struct { onStatus func(msg string) conn *net.UDPConn sem chan struct{} + cfgMu sync.RWMutex + cfg *config.Profile } // New creates a new UDP server. -func New(port int, wlClient *wavelog.Client, onResult func(result *wavelog.QSOResult), onStatus func(msg string)) *Server { +func New(port int, wlClient *wavelog.Client, cfg *config.Profile, onResult func(result *wavelog.QSOResult), onStatus func(msg string)) *Server { return &Server{ port: port, wlClient: wlClient, + cfg: cfg, onResult: onResult, onStatus: onStatus, sem: make(chan struct{}, maxConcurrentHandlers), } } +// UpdateConfig updates the profile used for satellite/transverter processing. +func (s *Server) UpdateConfig(cfg *config.Profile) { + s.cfgMu.Lock() + defer s.cfgMu.Unlock() + s.cfg = cfg +} + +func (s *Server) getConfig() *config.Profile { + s.cfgMu.RLock() + defer s.cfgMu.RUnlock() + return s.cfg +} + // Start binds the UDP socket and begins receiving datagrams. func (s *Server) Start() error { addr := &net.UDPAddr{ @@ -125,6 +143,9 @@ func (s *Server) handleDatagram(data string) { } } + // Apply satellite/transverter frequency offsets and inject SAT ADIF fields. + adif.ApplySatellite(fields, s.getConfig()) + adifStr := adif.MapToADIF(fields) debug.Log("[UDP] final ADIF: %s", adifStr) diff --git a/internal/wavelog/client.go b/internal/wavelog/client.go index 491ca50..39fa067 100644 --- a/internal/wavelog/client.go +++ b/internal/wavelog/client.go @@ -35,6 +35,9 @@ type RadioData struct { FrequencyRx int64 ModeRx string Split bool + PropMode string + SatName string + SatMode string } // Station represents a Wavelog station profile. @@ -185,6 +188,9 @@ type radioPayload struct { Power float64 `json:"power,omitempty"` FrequencyRx int64 `json:"frequency_rx,omitempty"` ModeRx string `json:"mode_rx,omitempty"` + PropMode string `json:"prop_mode,omitempty"` + SatName string `json:"sat_name,omitempty"` + SatMode string `json:"sat_mode,omitempty"` } // UpdateRadioStatus posts radio status to Wavelog's /api/radio. @@ -209,6 +215,9 @@ func (c *Client) UpdateRadioStatus(data RadioData) error { Mode: mode, FrequencyRx: freqRx, ModeRx: modeRx, + PropMode: data.PropMode, + SatName: data.SatName, + SatMode: data.SatMode, } if data.Power > 0 { p.Power = data.Power