Reported with two screenshots: both slices in USB on an inverting transponder,
slice B sitting at exactly 435.100000, and the red TX badge on the 2 m DOWNLINK.
The log named the cause in one line:
flex: satellite armed (rx slice 0, tx slice -1)
Creating a slice is asynchronous — "slice create" is answered later with the
index — and SetSatellite returned without waiting. Everything downstream then
ran against an uplink of -1 and silently did nothing: no antenna, no CTCSS tone,
no sideband, never tuned, and never sent "tx=1". So the radio went on
transmitting on the downlink, which is the one failure here that puts a signal
where it must not go, and the frequency and mode on screen were simply the ones
the slice had been created with.
Three fixes, because the ordering can fail in more than one way:
- Arming waits for both indices before reporting the pair armed, and says so
plainly when the radio does not produce them.
- The uplink is adopted from the SLICE STATUS as well as from the create
reply. The status needs no sequence-number correlation: if satellite mode is
armed, the uplink is unknown, and a slice is in use that is not the
downlink, that is it — the radio is saying so.
- What the uplink is owed is remembered — its mode, its antenna, its tone —
and given to it when it appears. Those three are sent ONCE; only the
frequency is re-sent every tick, so a slice that arrived late kept nothing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
337 lines
11 KiB
Go
337 lines
11 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, ant, tone := f.satUpMode, f.satTXAnt, f.satTone
|
|
f.mu.Unlock()
|
|
if strings.TrimSpace(ant) != "" {
|
|
f.send(fmt.Sprintf("slice s %d txant=%s", idx, ant))
|
|
f.send(fmt.Sprintf("slice s %d rxant=%s", idx, ant))
|
|
}
|
|
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
|
|
}
|
|
|
|
// SatAntennas selects the antenna each satellite slice uses.
|
|
//
|
|
// 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.
|
|
func (f *Flex) SatAntennas(rxAnt, txAnt 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 antenna.
|
|
f.satRXAnt, f.satTXAnt = rxAnt, txAnt
|
|
f.mu.Unlock()
|
|
if !connected {
|
|
return fmt.Errorf("flex: not connected")
|
|
}
|
|
// The downlink slice is the one being listened to, so it takes the receive
|
|
// antenna; the uplink slice is the one keyed, so it takes the transmit one.
|
|
if rx >= 0 && strings.TrimSpace(rxAnt) != "" {
|
|
f.send(fmt.Sprintf("slice s %d rxant=%s", rx, rxAnt))
|
|
applog.Printf("flex: satellite downlink slice %d on antenna %s", rx, rxAnt)
|
|
}
|
|
if tx >= 0 && strings.TrimSpace(txAnt) != "" {
|
|
f.send(fmt.Sprintf("slice s %d txant=%s", tx, txAnt))
|
|
// A transmit slice also has to HEAR its own band on some radios, and a
|
|
// transverter port is the only thing connected to it. Setting the
|
|
// receive antenna to match costs nothing when it is already right.
|
|
f.send(fmt.Sprintf("slice s %d rxant=%s", tx, txAnt))
|
|
applog.Printf("flex: satellite uplink slice %d on antenna %s", tx, txAnt)
|
|
}
|
|
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
|
|
}
|