Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/components/ConfigTab.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -126,6 +128,11 @@
{rotatorEnabled}
on:fieldchange={(e) => setProfileField(e.detail.key, e.detail.value)}
/>
<SatelliteSection
profile={activeProfile()}
{satEnabled}
on:fieldchange={(e) => setProfileField(e.detail.key, e.detail.value)}
/>
{/key}

<!-- Bottom action bar -->
Expand Down
76 changes: 76 additions & 0 deletions frontend/src/components/config/SatelliteSection.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<script>
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();

export let profile;
export let satEnabled = false;
</script>

<section class="bg-surface-section border border-stroke-section rounded-lg px-4 py-3">
<div class="flex items-center justify-between {satEnabled ? 'mb-3' : ''}">
<div class="text-2xs text-fg-bright font-semibold uppercase tracking-wider pl-2 border-l-2 border-cyan-400">
Satellite / Transverter
</div>
<label class="text-fg-label text-xs">
<input
type="checkbox"
checked={satEnabled}
on:change={(e) => dispatch("fieldchange", { key: "sat_enabled", value: e.currentTarget.checked })}
/>
Enable
</label>
</div>

{#if satEnabled}
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-2">
<label class="w-field-xs flex-shrink-0 text-fg-label text-2xs" for="sat-tx-offset">TX Offset</label>
<input
id="sat-tx-offset"
type="number"
class="flex-none w-field-sm"
value={profile.sat_tx_offset_mhz || 0}
on:change={(e) => dispatch("fieldchange", { key: "sat_tx_offset_mhz", value: Number(e.currentTarget.value) })}
min="0" step="0.001"
placeholder="0"
/>
<span class="text-fg-muted text-2xs">MHz</span>
</div>
<div class="flex items-center gap-2">
<label class="w-field-xs flex-shrink-0 text-fg-label text-2xs" for="sat-rx-offset">RX Offset</label>
<input
id="sat-rx-offset"
type="number"
class="flex-none w-field-sm"
value={profile.sat_rx_offset_mhz || 0}
on:change={(e) => dispatch("fieldchange", { key: "sat_rx_offset_mhz", value: Number(e.currentTarget.value) })}
min="0" step="0.001"
placeholder="0"
/>
<span class="text-fg-muted text-2xs">MHz</span>
</div>
<div class="flex items-center gap-2">
<label class="w-field-xs flex-shrink-0 text-fg-label text-2xs" for="sat-name">Satellite</label>
<input
id="sat-name"
type="text"
class="flex-none w-field-sm"
value={profile.sat_name || ""}
on:change={(e) => dispatch("fieldchange", { key: "sat_name", value: e.currentTarget.value })}
placeholder="QO-100"
/>
</div>
<div class="flex items-center gap-2">
<label class="w-field-xs flex-shrink-0 text-fg-label text-2xs" for="sat-mode">Sat Mode</label>
<input
id="sat-mode"
type="text"
class="flex-none w-field-sm"
value={profile.sat_mode || ""}
on:change={(e) => dispatch("fieldchange", { key: "sat_mode", value: e.currentTarget.value })}
placeholder="S/X"
/>
</div>
</div>
{/if}
</section>
59 changes: 59 additions & 0 deletions internal/adif/satellite.go
Original file line number Diff line number Diff line change
@@ -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)
}
15 changes: 13 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down
24 changes: 24 additions & 0 deletions internal/radio/poller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
}

Expand All @@ -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
Expand All @@ -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 {
Expand Down
34 changes: 34 additions & 0 deletions internal/radio/satellite.go
Original file line number Diff line number Diff line change
@@ -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)
}
23 changes: 22 additions & 1 deletion internal/udp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"fmt"
"net"
"strings"
"sync"

"waveloggate/internal/adif"
"waveloggate/internal/config"
"waveloggate/internal/debug"
"waveloggate/internal/wavelog"
)
Expand All @@ -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{
Expand Down Expand Up @@ -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)

Expand Down
Loading