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 {
+16
View File
@@ -49,6 +49,11 @@ const (
CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off)
CmdRIT = 0x21 // RIT/ΔTX: sub 0x00 offset freq, 0x01 RIT on/off, 0x02 ΔTX(XIT) on/off
CmdSendCW = 0x17 // send a CW message (ASCII, ≤30 chars) via the rig's keyer; data 0xFF = stop
// CmdVFO selects which receiver subsequent commands address. On the two-band
// satellite rigs (IC-9700, IC-9100) the MAIN band is the downlink and the SUB
// band the uplink, so every satellite frequency set is "point at a band, then
// tune it".
CmdVFO = 0x07
SubLevelKeySpeed = 0x0C // CmdLevel: CW keying speed (0-255 → KeyMinWPM..KeyMaxWPM)
@@ -112,6 +117,17 @@ const (
SubSwBreakIn = 0x47 // CW break-in: 0=OFF, 1=SEMI, 2=FULL (needed so 0x17 CW keys TX)
SubSwMN = 0x48 // manual notch on/off
SubSwAPF = 0x32 // audio peak filter on/off (CW only)
// Satellite mode (IC-9700 / IC-9100). The rig's OWN satellite mode, not an
// imitation of one: it pairs main and sub, gives full duplex, and keeps the
// two dials linked the way the radio's designers meant. Asking it to do that
// is always better than building the same thing out of split.
SubSwSatellite = 0x5A
// CmdVFO sub-commands: which of a two-receiver rig's bands the next command
// addresses.
SubVFOMain = 0xD0 // MAIN band — the downlink in satellite mode
SubVFOSub = 0xD1 // SUB band — the uplink
SubVFOExchange = 0xB0 // swap main and sub
)
// CW break-in modes (CmdSwitch 0x47).
+20 -1
View File
@@ -67,6 +67,14 @@ type Flex struct {
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
pendingSpotMode map[int]string // seq → ADIF mode, paired with pendingSpot
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
pendingSat map[int]string // seq → "rx"/"tx", awaiting a satellite slice's index
// Satellite pair: slice A is the downlink, slice B the uplink. -1 when not
// armed. satCreatedTX marks an uplink slice OpsLog opened, and is the only
// one it will close again.
satOn bool
satRX int
satTX int
satCreatedTX bool
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
spotFreq map[int]int64 // spot index → Hz, so a click can report where it was (the trigger message carries only the index)
@@ -227,7 +235,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
return &Flex{
host: strings.TrimSpace(host), port: port,
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotFreq: map[int]int64{}, pendingSpotFreq: map[int]int64{}, panWindow: map[string]panView{}, spotSig: map[string]string{}, spotSent: map[string]time.Time{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotFreq: map[int]int64{}, pendingSpotFreq: map[int]int64{}, panWindow: map[string]panView{}, spotSig: map[string]string{}, spotSent: map[string]time.Time{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{}, pendingSat: map[int]string{}, satRX: -1, satTX: -1,
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
pinnedSlice: -1,
@@ -458,12 +466,23 @@ func (f *Flex) reader(conn net.Conn) {
if splitSeq {
delete(f.pendingSplit, seq)
}
// The same reply carries the index of a slice created for a satellite
// pair; which of the two it is was recorded when it was asked for.
satRole := f.pendingSat[seq]
if satRole != "" {
delete(f.pendingSat, seq)
}
f.mu.Unlock()
if splitSeq && ok && len(parts) >= 3 {
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
f.send(fmt.Sprintf("slice s %d tx=1", idx))
}
}
if satRole != "" && ok && len(parts) >= 3 {
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
f.adoptSatSlice(satRole, idx)
}
}
}
}
// Connection ended.
+202
View File
@@ -0,0 +1,202 @@
package cat
import (
"fmt"
"strings"
"hamlog/internal/applog"
)
// Satellite operation on a FlexRadio.
//
// A Flex has no satellite mode, and does not need one: it has slices. Slice A
// is the downlink and slice B the uplink — the arrangement every Flex satellite
// operator already uses by hand — with the transmitter on B and full duplex on,
// so the operator hears their own signal come back through the transponder.
// The transverters that put 145 and 435 MHz within the radio's reach are
// configured in SmartSDR, and their offsets are the radio's business: OpsLog
// sends the real satellite frequency and SmartSDR does the arithmetic.
//
// The two slices are CREATED when they are missing, because "slice B does not
// exist" is not a thing to make the operator fix at the start of a ten-minute
// pass. Only what OpsLog created is taken away again on disarming: a slice the
// operator opened is theirs.
// SetSatellite arranges (or unwinds) the two-slice satellite pair.
func (f *Flex) SetSatellite(on bool) error {
f.mu.Lock()
connected := f.conn != nil
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
if !on {
return f.satDisarm()
}
// The downlink slice is the one the operator is already on: taking the
// active slice rather than insisting on index 0 means arming the satellite
// does not move them off the receiver they were listening to.
f.mu.Lock()
rxIdx, _ := f.mainSliceLocked()
var txIdx = -1
for _, idx := range f.sortedSliceIdxLocked() {
if s := f.slices[idx]; s != nil && s.inUse && idx != rxIdx {
txIdx = idx
break
}
}
f.satRX, f.satTX = rxIdx, txIdx
f.satOn = true
f.mu.Unlock()
// Full duplex before anything else: without it the radio mutes the receiver
// on transmit, and an operator who cannot hear their own downlink has no way
// to know they are in the passband at all.
f.send("radio set full_duplex_enabled=1")
if rxIdx < 0 {
// A radio with no slice at all. One is created; the status that comes
// back adopts it as the downlink.
f.satCreate("rx", 145.900, "USB")
}
if txIdx < 0 {
f.satCreate("tx", 435.100, "USB")
} else {
f.send(fmt.Sprintf("slice s %d tx=1", txIdx))
}
applog.Printf("flex: satellite armed (rx slice %d, tx slice %d)", rxIdx, txIdx)
return nil
}
func (f *Flex) satDisarm() error {
f.mu.Lock()
rx, tx, created := f.satRX, f.satTX, f.satCreatedTX
f.satOn, f.satRX, f.satTX, f.satCreatedTX = false, -1, -1, false
f.mu.Unlock()
f.send("radio set full_duplex_enabled=0")
if created && tx >= 0 {
f.send(fmt.Sprintf("slice remove %d", tx))
}
// Transmit goes back where the operator is listening. A radio left
// transmitting on a slice that no longer exists — or on the uplink band with
// the satellite gone — is not somewhere anyone should be handed back.
if rx >= 0 {
f.send(fmt.Sprintf("slice s %d tx=1", rx))
}
applog.Printf("flex: satellite disarmed")
return nil
}
// satCreate asks for a slice and remembers what it is for; the index arrives in
// the reply (see the R-line handler), which is where the role is applied.
func (f *Flex) satCreate(role string, freqMHz float64, mode string) {
seq := f.send(fmt.Sprintf("slice create freq=%.6f mode=%s", freqMHz, mode))
if seq <= 0 {
return
}
f.mu.Lock()
if f.pendingSat == nil {
f.pendingSat = map[int]string{}
}
f.pendingSat[seq] = role
f.mu.Unlock()
}
// adoptSatSlice records a freshly created slice in its role. Called from the
// reply handler with the index the radio assigned.
func (f *Flex) adoptSatSlice(role string, idx int) {
f.mu.Lock()
switch role {
case "rx":
f.satRX = idx
case "tx":
f.satTX = idx
f.satCreatedTX = true
}
f.mu.Unlock()
if role == "tx" {
f.send(fmt.Sprintf("slice s %d tx=1", idx))
}
applog.Printf("flex: satellite %s slice is %d", role, idx)
}
// TuneSatellite moves the two slices.
func (f *Flex) TuneSatellite(downHz, upHz int64, downMode, upMode string) error {
f.mu.Lock()
rx, tx := f.satRX, f.satTX
connected := f.conn != nil
if rx >= 0 && f.slices[rx] != nil && downHz > 0 {
f.slices[rx].freqHz = downHz // optimistic, as SetFrequency is
}
if tx >= 0 && f.slices[tx] != nil && upHz > 0 {
f.slices[tx].freqHz = upHz
}
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
if rx < 0 {
// The slice was asked for and its index has not come back yet. Nothing is
// wrong — the next Doppler step, a second later, will find it.
return nil
}
if downHz > 0 {
f.send(fmt.Sprintf("slice t %d %.6f", rx, float64(downHz)/1e6))
f.satMode(rx, downMode, downHz)
}
if tx >= 0 && upHz > 0 {
f.send(fmt.Sprintf("slice t %d %.6f", tx, float64(upHz)/1e6))
f.satMode(tx, upMode, upHz)
}
return nil
}
// satMode sets a slice's mode only when it is not already there. A mode command
// on every Doppler step is a command a second per slice for a whole pass, and
// SmartSDR redraws the filter each time.
func (f *Flex) satMode(idx int, mode string, freqHz int64) {
mode = strings.TrimSpace(mode)
if mode == "" {
return
}
// USB on both sides above 30 MHz, which is every satellite worth the name —
// including the parts of a passband that would be an LSB band down on HF.
if strings.EqualFold(mode, "SSB") && freqHz > 30_000_000 {
mode = "USB"
}
fm := adifModeToFlex(mode, freqHz)
if fm == "" {
return
}
f.mu.Lock()
s := f.slices[idx]
same := s != nil && strings.EqualFold(s.mode, fm)
if s != nil {
s.mode = fm
}
f.mu.Unlock()
if same {
return
}
f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm))
}
// SatReceiveHz is where the downlink slice sits.
//
// From the cache, not from a read: SmartSDR pushes every slice change as it
// happens, so the cached value is what the radio said, and there is no round
// trip to pay for once a second.
func (f *Flex) SatReceiveHz() (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.satRX < 0 {
return 0, fmt.Errorf("flex: no downlink slice")
}
s := f.slices[f.satRX]
if s == nil || !s.inUse {
return 0, fmt.Errorf("flex: the downlink slice has gone")
}
return s.freqHz, nil
}
+181
View File
@@ -0,0 +1,181 @@
package cat
import (
"errors"
"fmt"
"strings"
"hamlog/internal/applog"
"hamlog/internal/cat/civ"
)
// Satellite operation on an Icom.
//
// Two rigs in the range have a satellite mode of their own — the IC-9700 and
// the IC-9100 — and on those the right thing to do is ask the radio for it
// rather than build an imitation out of split. Their satellite mode pairs the
// MAIN band (the downlink) with the SUB band (the uplink), gives full duplex,
// and keeps the two dials linked the way the designers meant. Every other Icom
// has one receiver on one band: it can be tuned to the downlink, and that is
// the whole truth about what it can do on a cross-band satellite.
//
// UNTESTED ON HARDWARE. Built from the IC-9700 CI-V reference: 0x16 0x5A arms
// satellite mode, 0x07 0xD0 / 0xD1 select MAIN and SUB, and once a band is
// selected the ordinary 0x05 / 0x06 tune it. If an IC-9700 owner reports it
// misbehaving, the log lines below name every frame sent.
// ErrSatUplinkUnreachable says the downlink was tuned and the uplink was not,
// because the radio has no second receiver and the two are on different bands.
//
// A distinct error rather than a silent half-success: a tracker that quietly
// stops transmitting where the operator expects it to is worse than one that
// says it cannot. The caller reports it once, not once per Doppler step.
var ErrSatUplinkUnreachable = errors.New("cat: this radio has one receiver — the uplink is on another band and cannot be set")
// SetSatellite arms the rig's own satellite mode.
func (b *IcomSerial) SetSatellite(on bool) error {
if !b.satNative {
// Nothing to arm and nothing to break: the tuning path below does what
// this radio can do without any mode change. Refusing here would deny an
// operator the downlink, which is most of the value on a receive-heavy
// pass.
b.satOn = on
return nil
}
if err := b.exec(civ.CmdSwitch, civ.SubSwSatellite, boolByte(on)); err != nil {
return fmt.Errorf("icom: satellite mode %v refused: %w", on, err)
}
b.satOn = on
applog.Printf("icom: satellite mode %v (%s)", on, b.model)
if on {
// Leave the radio pointing at MAIN. Everything else in OpsLog — the poll
// loop, the logged frequency, the operator's dial — reads the selected
// band, and on a satellite the band worth reading is the one carrying the
// downlink.
_ = b.exec(civ.CmdVFO, civ.SubVFOMain)
}
return nil
}
// TuneSatellite puts the receiver on downHz and the transmitter on upHz.
func (b *IcomSerial) TuneSatellite(downHz, upHz int64, downMode, upMode string) error {
if downHz <= 0 {
return fmt.Errorf("icom: no downlink frequency")
}
if !b.satNative {
return b.tuneSatSingleBand(downHz, upHz, downMode, upMode)
}
// MAIN — the downlink.
if err := b.exec(civ.CmdVFO, civ.SubVFOMain); err != nil {
return fmt.Errorf("icom: could not select the main band: %w", err)
}
if err := b.SetFrequency(downHz); err != nil {
return err
}
if err := b.satSetMode(downMode, downHz); err != nil {
return err
}
// SUB — the uplink.
if upHz > 0 {
if err := b.exec(civ.CmdVFO, civ.SubVFOSub); err != nil {
return fmt.Errorf("icom: could not select the sub band: %w", err)
}
uerr := b.execIdempotent(fmt.Sprintf("set uplink %d Hz", upHz),
append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(upHz)...)...)
merr := b.satSetMode(upMode, upHz)
// Back to MAIN whatever happened. A rig left pointing at SUB reports the
// uplink as its frequency, and every band-dependent thing in OpsLog —
// the log, the antenna, the amplifier — would follow the transmitter
// onto the wrong band.
if err := b.exec(civ.CmdVFO, civ.SubVFOMain); err != nil {
applog.Printf("icom: could not return to the main band: %v", err)
}
if uerr != nil {
return uerr
}
if merr != nil {
return merr
}
}
return nil
}
// satSetMode sets the mode of whichever band is currently selected. An empty
// mode leaves it alone — a linear transponder is worked in one mode for a whole
// pass, and re-sending it every second is traffic for nothing.
func (b *IcomSerial) satSetMode(mode string, freqHz int64) error {
mode = strings.TrimSpace(mode)
if mode == "" {
return nil
}
// modeCode resolves "SSB" against the CURRENT dial to pick a sideband, which
// is wrong here twice over: the dial may still be on the other band, and on
// satellites USB is the convention on both sides whatever the frequency.
code, data, err := b.modeCode(satSideband(mode))
if err != nil {
return err
}
return b.setModeBytes(mode, code, data)
}
// satSideband is the sideband convention above 30 MHz: USB, on both the uplink
// and the downlink, including the parts of a linear transponder that fall in
// what would be an LSB band on HF. The exceptions — AO-7's mode A downlink on
// 29 MHz among them — are still USB by convention, so there is no exception to
// make.
func satSideband(mode string) string {
if strings.EqualFold(strings.TrimSpace(mode), "SSB") {
return "USB"
}
return mode
}
// SatReceiveHz is where the receiver is now.
func (b *IcomSerial) SatReceiveHz() (int64, error) {
if b.satNative {
// The selected band is MAIN — see TuneSatellite, which always returns to
// it — so the ordinary frequency read is the downlink.
if err := b.exec(civ.CmdVFO, civ.SubVFOMain); err != nil {
applog.Printf("icom: sat readback could not select main: %v", err)
}
}
return b.readFreq()
}
// tuneSatSingleBand is every other Icom: one receiver, one band.
//
// The downlink is set, because that is what the operator is listening to. The
// uplink is set through split only when it is close enough to be on the same
// band — QO-100 behind transverters, AO-7's mode A — and otherwise reported as
// out of reach rather than quietly skipped.
func (b *IcomSerial) tuneSatSingleBand(downHz, upHz int64, downMode, _ string) error {
if err := b.SetFrequency(downHz); err != nil {
return err
}
if err := b.satSetMode(downMode, downHz); err != nil {
return err
}
if upHz <= 0 {
return nil
}
// One megahertz apart is the working definition of "the same band" here: it
// covers a transponder's own passband and any sensible transverter pairing,
// and excludes every real cross-band satellite (145 / 435 MHz).
if abs64(upHz-downHz) > 1_000_000 {
return ErrSatUplinkUnreachable
}
if err := b.exec(append([]byte{civ.CmdVfoFreq, civ.SubVfoUnselected}, civ.FreqToBCD(upHz)...)...); err != nil {
return err
}
if !b.satOn {
return nil
}
return b.exec(civ.CmdSplit, boolByte(true))
}
func abs64(v int64) int64 {
if v < 0 {
return -v
}
return v
}
+11
View File
@@ -94,6 +94,13 @@ type IcomSerial struct {
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read
// via ScopeData from the binding goroutine).
dualScope bool
// satNative marks the two-band satellite rigs — the IC-9700 and the IC-9100 —
// which have a real satellite mode of their own. Everything else gets the
// downlink and, where the uplink is reachable, split.
satNative bool
// satOn tracks what we last told the rig, so TuneSatellite can arm the mode
// once rather than on every Doppler step.
satOn bool
// Set when the rig rejects the waveform-output command in both shapes: it has
// no stream to give, and asking again on every enable is noise.
scopeUnsupported bool
@@ -284,6 +291,10 @@ func (b *IcomSerial) Connect() error {
// non-default address still RENDERS; this flag only drives the SET/read commands
// (mode, span, edges), which need the 0x00 selector to be accepted on the 7300.
b.dualScope = idAddr == 0x98 || idAddr == 0xA2 || idAddr == 0x94
// The satellite rigs: IC-9700 and IC-9100. Both carry two receivers on two
// bands and a satellite mode that pairs them; no other Icom in this table
// does, and asking one that does not is a rejected frame per Doppler step.
b.satNative = idAddr == 0xA2 || idAddr == 0x7C
// Silence any LEFTOVER waveform stream, BLIND, before anything else. The
// 0x27 output flag lives in the RADIO and survives sessions; its flood is
// what makes the IC-7760 stop answering CI-V — so waiting for CI-V to