fix(sat): the Flex slices get their antenna, their sideband and their tone

Three things the tracker was leaving to chance on a FlexRadio, all reported from
a real pass.

ANTENNAS. Settings ▸ FlexRadio holds a per-band RX/TX antenna map, and it was
applied in exactly one place: the entry form, on a band change, to the active
slice. A pass never goes through that path — the tracker arms two slices itself.
So both were left on whatever the radio last used, and a station with
transverters (XVTA on 2 m, XVTB on 70 cm) heard nothing at all, having
configured precisely the thing being ignored. The two slices are on two
different bands, so they cannot share one setting: the downlink takes the
receive antenna for ITS band, the uplink the transmit antenna for its. Per
slice, not through sendSlice, which addresses whichever slice is active — during
a pass that is the downlink, so the uplink would never have been set.

SIDEBAND. satMode forced USB above 30 MHz on both sides. An inverting
transponder turns the passband over, so lower sideband up comes back as upper
sideband down: FO-29, RS-44 and AO-73 were being worked with the operator's own
audio going through upside down. The tracker now decides both sidebands from the
transponder's inverting flag and passes them separately; a bare "SSB" still
means USB, so nothing else changes.

CTCSS. Nothing set it, on any bird. The frequency plan has carried the tone all
along — 67.0 on SO-50 and AO-91, 141.3 on PO-101 — and an FM repeater does not
answer without it, which is indistinguishable from a satellite that is not
there. It goes on the uplink slice, value before mode so the radio cannot
transmit the previous tone in the gap between two commands.

Written against the SmartSDR slice API and UNTESTED on hardware.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-09-09 20:20:41 +02:00
co-authored by Claude Opus 5
parent 0add56fb2d
commit 3ac6f7e49c
6 changed files with 209 additions and 7 deletions
+74 -3
View File
@@ -158,6 +158,7 @@ func (a *App) StartSatelliteTracking(name string, transponder int) error {
t.status.Error = err.Error()
} else {
radio = "sat"
a.applySatRadio(b.Transponders[transponder])
}
}
t.status.Radio = radio
@@ -359,11 +360,12 @@ func (a *App) satTrackStep(t *satTracker) {
return
}
mode := tp.Mode
downMode, upMode := satSidebands(tp)
if lastDown != 0 {
mode = "" // set once, at the start of the pass — see satMode/satSetMode
// Set once, at the start of the pass — see satMode/satSetMode.
downMode, upMode = "", ""
}
err := a.satTune(down, up, mode, mode)
err := a.satTune(down, up, downMode, upMode)
t.mu.Lock()
if err == nil {
t.lastDown, t.lastUp, t.fails = down, up, 0
@@ -634,3 +636,72 @@ func satBandLetter(hz int64) string {
}
return "K" // 24 GHz and above
}
// applySatAntennas puts each satellite slice on the antenna configured for ITS
// band.
//
// Settings ▸ FlexRadio already holds a per-band RX/TX antenna map, and it was
// only ever applied by the entry form on a band change — to the active slice.
// A pass never goes through that path: the tracker arms two slices itself, on
// two different bands, and both were left on whatever the radio last used. A
// station with transverters (XVTA on 2 m, XVTB on 70 cm) therefore heard
// nothing at all, having configured exactly the thing that was being ignored.
//
// The bands come from the NOMINAL frequencies, not the Doppler-corrected ones:
// a correction of ten kilohertz cannot change the band, and the nominal pair is
// what the operator's configuration is written against.
func (a *App) applySatRadio(tp sat.Transponder) {
if a.cat == nil || !a.cat.SatCapable() {
return
}
// The CTCSS tone first: an FM bird will not answer without it, and it is the
// one setting an operator cannot make from the front panel once a pass has
// started. Zero turns it off, which is what a linear bird needs.
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
return fc.SatTone(tp.CTCSS)
}); err != nil {
applog.Printf("sat: could not set the uplink tone: %v", err)
}
m, err := a.GetFlexBandAntennas()
if err != nil || len(m) == 0 {
return
}
// The downlink is received, so it takes that band's RX antenna; the uplink
// is transmitted, so it takes that band's TX antenna.
rxAnt := m[bandForHz(tp.DownLo)].RX
txAnt := m[bandForHz(tp.UpLo)].TX
if strings.TrimSpace(rxAnt) == "" && strings.TrimSpace(txAnt) == "" {
return
}
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
return fc.SatAntennas(rxAnt, txAnt)
}); err != nil {
// Not fatal: a rig that is not a Flex has no such thing, and a pass with
// the wrong antenna is still a pass.
applog.Printf("sat: could not set the satellite antennas: %v", err)
}
}
// satSidebands is which sideband to set on each side of a linear transponder.
//
// The two are NOT the same when the transponder inverts, and FO-29, RS-44 and
// AO-73 all do: the passband is turned over, so a signal transmitted on lower
// sideband comes back on upper. Setting USB at both ends — which is what
// happened until now — put the operator's own audio through the transponder
// upside down, which is unreadable at the far end and sounds like nothing much
// at ours.
//
// Anything that is not SSB is the same on both sides: an FM repeater is FM up
// and FM down, and CW is CW whichever way round the passband runs.
func satSidebands(tp sat.Transponder) (downMode, upMode string) {
if !strings.EqualFold(strings.TrimSpace(tp.Mode), "SSB") {
return tp.Mode, tp.Mode
}
// Every satellite is above 30 MHz, so the downlink is upper sideband — even
// on the AO-7 10 m downlink, which would be lower sideband on HF.
if tp.Inverting {
return "USB", "LSB"
}
return "USB", "USB"
}
+29
View File
@@ -106,3 +106,32 @@ func (c *countingRotator) Point(az, el float64) error {
}
func (c *countingRotator) Heading() (float64, float64, bool, error) { return 0, 0, false, nil }
func (c *countingRotator) Close() {}
// Which sideband goes on each slice.
//
// An inverting transponder turns the passband over, so a signal transmitted on
// lower sideband comes back on upper. Setting USB at both ends put the
// operator's own audio through upside down — unreadable at the far end, and on
// FO-29, RS-44 and AO-73 that is every contact attempted.
func TestSatSidebands(t *testing.T) {
cases := []struct {
name string
tp sat.Transponder
wantDown, want string
}{
{"inverting linear: LSB up, USB down",
sat.Transponder{Mode: "SSB", Inverting: true}, "USB", "LSB"},
{"non-inverting linear: USB both ways",
sat.Transponder{Mode: "SSB"}, "USB", "USB"},
// A tone is transmitted and received in FM whichever way the passband
// runs, and CW is CW.
{"FM is FM both ways", sat.Transponder{Mode: "FM"}, "FM", "FM"},
{"CW ignores inversion", sat.Transponder{Mode: "CW", Inverting: true}, "CW", "CW"},
}
for _, c := range cases {
down, up := satSidebands(c.tp)
if down != c.wantDown || up != c.want {
t.Errorf("%s: got %s/%s, want %s/%s", c.name, down, up, c.wantDown, c.want)
}
}
}
+8 -2
View File
@@ -3,10 +3,16 @@
"version": "0.27.21",
"date": "",
"en": [
"The Doppler correction was wrong — by a factor of about 250, and in the wrong direction. The SGP4 library reports a range rate that is not one: the ISS closing at 5.5 km/s came back as +2036 km/s, which moved a 2 m downlink two megahertz instead of three kilohertz, and moved it the wrong way. OpsLog now measures the range rate from the range itself, which cannot disagree with physics. A 2 m downlink shifts about ±3.5 kHz across a pass and a 70 cm one about ±10 kHz, as they should."
"The Doppler correction was wrong — by a factor of about 250, and in the wrong direction. The SGP4 library reports a range rate that is not one: the ISS closing at 5.5 km/s came back as +2036 km/s, which moved a 2 m downlink two megahertz instead of three kilohertz, and moved it the wrong way. OpsLog now measures the range rate from the range itself, which cannot disagree with physics. A 2 m downlink shifts about ±3.5 kHz across a pass and a 70 cm one about ±10 kHz, as they should.",
"FlexRadio, satellite: the per-band antennas you configured are now applied to the satellite slices. They were not — the entry form applied them on a band change, to the active slice, and a pass never goes through that path. The two slices are on two different bands, so each gets its own: the downlink takes the receive antenna for its band, the uplink the transmit antenna for its. On a station with transverters (XVTA on 2 m, XVTB on 70 cm) the downlink was left on whatever the radio last used, and heard nothing.",
"FlexRadio, satellite: on an inverting transponder the uplink is set to LSB and the downlink to USB, instead of USB at both ends. The passband is turned over, so audio transmitted on the wrong sideband comes back through it upside down — which is every attempted contact on FO-29, RS-44 and AO-73.",
"FlexRadio, satellite: the CTCSS tone is set on the uplink slice from the satellite's frequency plan. An FM bird does not answer without it, and it is the one setting an operator cannot reach from the front panel once a pass has started."
],
"fr": [
"La correction Doppler était fausse — dun facteur denviron 250, et dans le mauvais sens. La bibliothèque SGP4 renvoie une vitesse radiale qui nen est pas une : lISS se rapprochant à 5,5 km/s était rapportée à +2036 km/s, ce qui déplaçait une descente 2 m de deux mégahertz au lieu de trois kilohertz, et dans la mauvaise direction. OpsLog mesure désormais cette vitesse à partir de la distance elle-même, ce qui ne peut pas contredire la physique. Une descente 2 m se décale denviron ±3,5 kHz sur un passage et une 70 cm denviron ±10 kHz, comme il se doit."
"La correction Doppler était fausse — dun facteur denviron 250, et dans le mauvais sens. La bibliothèque SGP4 renvoie une vitesse radiale qui nen est pas une : lISS se rapprochant à 5,5 km/s était rapportée à +2036 km/s, ce qui déplaçait une descente 2 m de deux mégahertz au lieu de trois kilohertz, et dans la mauvaise direction. OpsLog mesure désormais cette vitesse à partir de la distance elle-même, ce qui ne peut pas contredire la physique. Une descente 2 m se décale denviron ±3,5 kHz sur un passage et une 70 cm denviron ±10 kHz, comme il se doit.",
"FlexRadio, satellite : les antennes par bande que vous avez configurées sont désormais appliquées aux tranches satellite. Elles ne l’étaient pas — la fenêtre de saisie les appliquait au changement de bande, sur la tranche active, et un passage ne passe jamais par là. Les deux tranches sont sur deux bandes différentes, donc chacune reçoit la sienne : la descente prend lantenne de réception de sa bande, la montée lantenne d’émission de la sienne. Sur une station à transverters (XVTA en 2 m, XVTB en 70 cm), la descente restait sur ce que la radio utilisait en dernier, et nentendait rien.",
"FlexRadio, satellite : sur un transpondeur inverseur, la montée est mise en LSB et la descente en USB, au lieu dUSB des deux côtés. La bande passante est retournée : une audio émise sur la mauvaise bande latérale revient à lenvers — soit tous les QSO tentés sur FO-29, RS-44 et AO-73.",
"FlexRadio, satellite : la tonalité CTCSS est réglée sur la tranche de montée depuis le plan de fréquences du satellite. Un satellite FM ne répond pas sans elle, et cest le seul réglage quun OM ne peut pas atteindre en façade une fois le passage commencé."
]
},
{
+15
View File
@@ -65,6 +65,21 @@ func main() {
}
}
}
if !found {
// Last resort, exactly as satElement does: scan every element name and
// compare on letters and digits alone. This is how "JAS-2 (FO-29)" and
// "FO-29" meet, and leaving it out of the diagnostic made a satellite
// that resolves perfectly well in the app look unresolvable here.
for _, n := range store.Names() {
if b.Matches(n) {
if e, ok := store.Get(n); ok {
el, found = e, true
fmt.Printf("elements found by SCAN → %q (NORAD %d)\n", e.Name, e.NORAD)
break
}
}
}
}
if !found {
fmt.Println("NO ELEMENTS")
os.Exit(1)
+5
View File
@@ -584,6 +584,11 @@ type FlexController interface {
SetMute(bool) error
SetRXAntenna(string) error
SetTXAntenna(string) error
// SatAntennas sets the antenna on each SATELLITE slice — they are on two
// different bands and, with transverters, two different ports.
SatAntennas(rxAnt, txAnt string) error
// SatTone sets the CTCSS tone the satellite uplink transmits (0 = off).
SatTone(hz float64) error
SetActiveSlice(int) error // focus slice idx so commands target it
// ZoomPan sets the visible width (MHz) of the active slice's panadapter and
// keeps freqMHz inside it, re-centring when it must. See Flex.ZoomPan.
+78 -2
View File
@@ -161,8 +161,11 @@ func (f *Flex) satMode(idx int, mode string, freqHz int64) {
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.
// 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"
}
@@ -200,3 +203,76 @@ func (f *Flex) SatReceiveHz() (int64, error) {
}
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
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.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
}