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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user