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
+44
View File
@@ -832,6 +832,50 @@ func (m *Manager) IcomDo(fn func(IcomController) error) error {
})
}
// SatTuner is a backend that can be put on a satellite: a receiver on one band
// and a transmitter on another, both moving under Doppler, at the same time.
//
// It is a separate interface from the per-manufacturer ones because what a
// satellite needs is not a manufacturer's feature — it is a shape of operating
// that a FlexRadio and an IC-9700 both provide and reach in completely
// different ways. A backend that cannot do it simply does not implement this,
// and the caller falls back to tuning the downlink alone rather than pretending.
type SatTuner interface {
// SetSatellite arms or disarms satellite operation: the rig's own satellite
// mode where it has one, two slices where it has those. Disarming must leave
// the radio somewhere an operator can work from, not half-configured.
SetSatellite(on bool) error
// TuneSatellite points the receiver at downHz and the transmitter at upHz,
// both already Doppler-corrected. Modes are ADIF names ("SSB", "FM", "CW");
// an empty one leaves that side's mode alone.
TuneSatellite(downHz, upHz int64, downMode, upMode string) error
// SatReceiveHz is where the receiver actually is. The operator tunes it to
// follow a station across a linear transponder, and that dial movement is
// the input the whole tracker works from — without reading it back, a
// tracker fights the operator instead of helping them.
SatReceiveHz() (int64, error)
}
// SatCapable reports whether the active backend can hold a satellite pair.
func (m *Manager) SatCapable() bool {
m.mu.RLock()
b := m.backend
m.mu.RUnlock()
_, ok := b.(SatTuner)
return ok
}
// SatDo dispatches a satellite control onto the CAT goroutine.
func (m *Manager) SatDo(fn func(SatTuner) error) error {
return m.exec(func(b Backend) error {
st, ok := b.(SatTuner)
if !ok {
return fmt.Errorf("this radio cannot hold a satellite pair from OpsLog")
}
return fn(st)
})
}
// exec marshals a backend operation onto the CAT goroutine. Returns the
// operation's error or a "busy"/"not running" error if dispatch failed.
func (m *Manager) exec(fn func(Backend) error) error {