feat(sat): Doppler tracking on the radio

The hard part of satellite tuning is not the arithmetic, it is deciding
who owns the dial. A tracker that forces both frequencies fights the
operator every time they turn the knob to follow a station across a
linear transponder; one that never touches the receiver leaves them
chasing a signal that slides nine kilohertz across a 70 cm pass.

So the operator owns the receiver and the tracker follows them. Every
second it asks the radio where the receiver actually is. Where it put it,
nothing has changed. Further than a dial-turn's tolerance, and the
operator has chosen a station: what they landed on is converted back into
a nominal frequency, and the transmitter is derived from that. Which is
the division of labour on a linear bird — the operator listens, the radio
does the sums.

Three ways to reach the radio, because a satellite pair is a shape of
operating rather than a manufacturer's feature. An IC-9700 or IC-9100 is
asked for its OWN satellite mode: it pairs main and sub, gives full
duplex, and keeps the dials linked the way its designers meant, which is
always better than an imitation built out of split. A Flex gets two
slices, A the downlink and B the uplink, created when missing, because
"slice B does not exist" is not something to make an operator fix at the
start of a ten-minute pass. Everything else gets the downlink, and is
told so — half the job announced beats half the job hidden.

What goes in the log is the NOMINAL pair. Two stations working each other
through a transponder read different numbers off their dials at the same
instant; the only figure they can both agree on is the transponder's own.
FREQ is the uplink and FREQ_RX the downlink — the one place a satellite
QSO differs from every other kind, and the reason FREQ alone cannot
describe one.
This commit is contained in:
2026-09-07 11:27:06 +02:00
parent 680bf410fe
commit 465481f8f1
17 changed files with 1146 additions and 4 deletions
+15
View File
@@ -893,6 +893,10 @@ type App struct {
satStore *sat.Store // orbital elements, by satellite name
satBirds *sat.Birds // uplink/downlink plan
satFetch *sat.Fetcher // element feeds + the on-disk cache
// satTrack is the live tracker: the goroutine that walks the radio through a
// pass. nil when nothing is being tracked.
satTrackMu sync.Mutex
satTrack *satTracker
cwMu sync.Mutex // guards the CW decoder lifecycle
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
@@ -1960,6 +1964,10 @@ func (a *App) shutdown(ctx context.Context) {
applog.Printf("shutdown: closing autostart programs")
a.CloseAutostartPrograms()
a.stopPSKTarget() // one TLS socket to a public broker; nothing to flush
// Before CAT goes down: disarming satellite mode takes the radio out of full
// duplex and puts the transmitter back where the operator is listening, and
// that has to happen while the link is still up.
a.StopSatelliteTracking()
applog.Printf("shutdown: stopping UDP")
if a.udp != nil {
a.udp.StopAll()
@@ -3086,6 +3094,10 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
}
}()
a.applyStationDefaults(&q, true)
// Before fillRXDefaults, which copies the transmit frequency into the receive
// one: on a satellite the two are on different bands, and letting that copy
// happen first would bury the downlink under the uplink.
a.applySatellite(&q)
fillRXDefaults(&q)
fillDistance(&q)
a.applyDXCCNumber(&q)
@@ -16789,6 +16801,9 @@ func (a *App) reloadAfterProfileSwitch() {
// of the process.
a.disarmAutoCall("profile switch")
a.autoCallEngine().Reset()
// Same reasoning for the satellite tracker: it transmits, and the new profile
// may be a different station on a different antenna.
a.StopSatelliteTracking()
}
// DuplicateProfile clones an existing profile under newName. Useful when