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.
203 lines
6.0 KiB
Go
203 lines
6.0 KiB
Go
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
|
|
}
|