A transponder does not translate by exactly the published difference — the oscillator on board is decades old on some birds and a kilohertz or two out. So an operator who sounds right to themselves comes back off frequency, corrects it on the transmit VFO, and the tracker put it back one second later, every second, for the rest of the pass. Reported on an IC-9700 against HRD, which keeps the shift the operator sets. The tracker already worked this way for the RECEIVER: it reads the dial back and treats a move as the operator choosing a new station. The transmitter had no equivalent — its comment even said so, "derived from the nominal and never argued with". Now it is read back too, and the difference becomes a standing trim on the nominal uplink. Applied to the nominal rather than the corrected frequency, because a translation error is a fixed offset in the uplink band and not something that scales with the Doppler. Read only while not transmitting: mid-over nobody is turning the knob, and on an Icom this read switches to the SUB band and back, which is the same path TuneSatellite already uses to write the uplink and not something to do under a carrier. Kept per satellite AND per transponder, because that is what it belongs to: the error is a property of the hardware in orbit, stable from one pass to the next. Capped at 20 kHz so a bad stored value cannot put the station outside the passband for ever, and shown in the tune panel with a reset — an offset taken silently from the VFO has to be visible, and the VFO alone cannot bring it back to zero once the operator has drifted somewhere wrong. SatTuner gains SatTransmitHz, implemented for the native Icom satellite mode and for the Flex uplink slice; anything else reports nothing and the uplink is left to the arithmetic, as before.
363 lines
12 KiB
Go
363 lines
12 KiB
Go
package cat
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"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))
|
|
}
|
|
// WAIT for the slices before saying the pair is armed.
|
|
//
|
|
// Creating a slice is asynchronous: the index comes back in a later reply.
|
|
// Returning before it arrives meant everything downstream ran against an
|
|
// uplink of -1 — no antenna, no CTCSS tone, no mode, never tuned, and never
|
|
// made the transmitter, so the radio went on transmitting on the DOWNLINK.
|
|
// Seen on the air, and it is the one failure here that can put a signal
|
|
// somewhere it must not go.
|
|
rxIdx, txIdx = f.awaitSatSlices(3 * time.Second)
|
|
if rxIdx < 0 || txIdx < 0 {
|
|
applog.Printf("flex: satellite armed but the radio did not report both slices (rx %d, tx %d) — "+
|
|
"the uplink will be picked up when it does", rxIdx, txIdx)
|
|
return nil
|
|
}
|
|
applog.Printf("flex: satellite armed (rx slice %d, tx slice %d)", rxIdx, txIdx)
|
|
return nil
|
|
}
|
|
|
|
// awaitSatSlices waits for both slice indices to be known, and returns whatever
|
|
// it has when the time is up. Polled rather than signalled: the indices arrive
|
|
// on the reader goroutine by two different routes — the create reply and the
|
|
// slice status — and a poll is indifferent to which of them got there first.
|
|
func (f *Flex) awaitSatSlices(d time.Duration) (rx, tx int) {
|
|
deadline := time.Now().Add(d)
|
|
for {
|
|
f.mu.Lock()
|
|
rx, tx = f.satRX, f.satTX
|
|
f.mu.Unlock()
|
|
if (rx >= 0 && tx >= 0) || time.Now().After(deadline) {
|
|
return rx, tx
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
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))
|
|
// Everything this slice was owed while nobody knew where it was. Set
|
|
// here rather than left to the next Doppler step, because the mode, the
|
|
// antenna and the tone are all sent ONCE — the step only re-sends
|
|
// frequencies.
|
|
f.mu.Lock()
|
|
mode, rxAnt, txAnt, tone := f.satUpMode, f.satUpRX, f.satUpTX, f.satTone
|
|
f.mu.Unlock()
|
|
if strings.TrimSpace(rxAnt) != "" {
|
|
f.send(fmt.Sprintf("slice s %d rxant=%s", idx, rxAnt))
|
|
}
|
|
if strings.TrimSpace(txAnt) != "" {
|
|
f.send(fmt.Sprintf("slice s %d txant=%s", idx, txAnt))
|
|
}
|
|
if strings.TrimSpace(mode) != "" {
|
|
f.satMode(idx, mode, 0)
|
|
}
|
|
if tone > 0 {
|
|
f.send(fmt.Sprintf("slice s %d fm_tone_value=%.1f", idx, tone))
|
|
f.send(fmt.Sprintf("slice s %d fm_tone_mode=CTCSS_TX", 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 strings.TrimSpace(upMode) != "" {
|
|
f.mu.Lock()
|
|
f.satUpMode = upMode
|
|
f.mu.Unlock()
|
|
}
|
|
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
|
|
}
|
|
// A bare "SSB" still means upper sideband 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. An explicit USB or LSB from the caller is left
|
|
// alone: on an INVERTING transponder the two sides are different sidebands,
|
|
// and only the caller knows which way round this bird runs.
|
|
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
|
|
}
|
|
|
|
// SatTransmitHz is where the uplink slice sits. From the cache, like the
|
|
// downlink: SmartSDR pushes every slice change as it happens.
|
|
func (f *Flex) SatTransmitHz() (int64, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.satTX < 0 {
|
|
return 0, nil // no uplink slice: nothing to report, not an error
|
|
}
|
|
s := f.slices[f.satTX]
|
|
if s == nil || !s.inUse {
|
|
return 0, nil
|
|
}
|
|
return s.freqHz, nil
|
|
}
|
|
|
|
// SatAntennas selects both antennas of each satellite slice.
|
|
//
|
|
// The two slices are on two different bands — a V/U bird receives on 70 cm and
|
|
// transmits on 2 m, a U/V one does the reverse — so they cannot share one
|
|
// antenna setting. On a station with transverters they are not even the same
|
|
// port: XVTA for 2 m, XVTB for 70 cm, and a downlink slice left on the HF
|
|
// antenna hears nothing at all.
|
|
//
|
|
// Per SLICE, not through sendSlice, which addresses whichever slice is active.
|
|
// During a pass the active slice is the downlink, so the uplink's antenna would
|
|
// never have been set.
|
|
//
|
|
// Empty strings are left alone: an operator who has configured 2 m and not
|
|
// 70 cm should keep whatever the radio already had on the other side rather
|
|
// than have it cleared.
|
|
// A slice has an rxant AND a txant, and both belong to the slice's own band.
|
|
// Only two of the four were being set — the downlink's receive antenna and
|
|
// the uplink's transmit one — which left the downlink slice with an empty
|
|
// txant. It never keys, so nothing was wrong on the air, but the slice was
|
|
// half-configured: move transmit focus to it and the radio uses whatever
|
|
// antenna it was last left on.
|
|
func (f *Flex) SatAntennas(downRX, downTX, upRX, upTX string) error {
|
|
f.mu.Lock()
|
|
rx, tx := f.satRX, f.satTX
|
|
connected := f.conn != nil
|
|
// Remembered so a slice that is reported late still gets its antennas.
|
|
f.satDownRX, f.satDownTX = downRX, downTX
|
|
f.satUpRX, f.satUpTX = upRX, upTX
|
|
f.mu.Unlock()
|
|
if !connected {
|
|
return fmt.Errorf("flex: not connected")
|
|
}
|
|
set := func(idx int, which, rxAnt, txAnt string) {
|
|
if idx < 0 {
|
|
return
|
|
}
|
|
if strings.TrimSpace(rxAnt) != "" {
|
|
f.send(fmt.Sprintf("slice s %d rxant=%s", idx, rxAnt))
|
|
}
|
|
if strings.TrimSpace(txAnt) != "" {
|
|
f.send(fmt.Sprintf("slice s %d txant=%s", idx, txAnt))
|
|
}
|
|
if strings.TrimSpace(rxAnt) != "" || strings.TrimSpace(txAnt) != "" {
|
|
applog.Printf("flex: satellite %s slice %d rx=%s tx=%s", which, idx, rxAnt, txAnt)
|
|
}
|
|
}
|
|
set(rx, "downlink", downRX, downTX)
|
|
set(tx, "uplink", upRX, upTX)
|
|
return nil
|
|
}
|
|
|
|
// SatTone sets the CTCSS tone the uplink slice transmits, in Hz. Zero turns it
|
|
// off.
|
|
//
|
|
// On the UPLINK slice, because that is the one that keys: a tone is something
|
|
// transmitted, and the repeater on the satellite will not open without it. This
|
|
// is the whole difference between an operator hearing a pass and hearing
|
|
// nothing on SO-50, AO-91, PO-101 and every other FM bird with a tone — and it
|
|
// is exactly the setting that cannot be made by hand mid-pass.
|
|
func (f *Flex) SatTone(hz float64) error {
|
|
f.mu.Lock()
|
|
tx := f.satTX
|
|
connected := f.conn != nil
|
|
f.satTone = hz
|
|
f.mu.Unlock()
|
|
if !connected {
|
|
return fmt.Errorf("flex: not connected")
|
|
}
|
|
if tx < 0 {
|
|
return nil // the slice has not come back yet; the next arming will set it
|
|
}
|
|
if hz <= 0 {
|
|
f.send(fmt.Sprintf("slice s %d fm_tone_mode=OFF", tx))
|
|
applog.Printf("flex: satellite uplink tone off")
|
|
return nil
|
|
}
|
|
// Value before mode: a radio that is told CTCSS_TX while still holding the
|
|
// previous tone transmits the previous tone for as long as it takes the
|
|
// second command to arrive.
|
|
f.send(fmt.Sprintf("slice s %d fm_tone_value=%.1f", tx, hz))
|
|
f.send(fmt.Sprintf("slice s %d fm_tone_mode=CTCSS_TX", tx))
|
|
applog.Printf("flex: satellite uplink tone %.1f Hz on slice %d", hz, tx)
|
|
return nil
|
|
}
|