fix(flex): the satellite uplink slice was never really armed

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]>
This commit is contained in:
2026-09-10 00:08:43 +02:00
co-authored by Claude Opus 5
parent 3c93684b2b
commit e2fe406445
3 changed files with 98 additions and 6 deletions
+4 -2
View File
@@ -6,13 +6,15 @@
"The antenna readout no longer flickers in and out during a pass. The rotator is asked where it is every three seconds, but the tracking status was rebuilt from scratch every second and dropped the answer in between — so the antenna appeared for one second in three, which reads as a rotator that keeps disconnecting.",
"While tracking, the two frequencies and the antenna bearing sit beside the Tracking button. During a pass an operator watches the radio and the antenna, not a column on the far side of the window — and that column is the first thing hidden to get the map full width. The compass spins while the antenna is still slewing: a mast takes tens of seconds to cross a pass, and the difference between \"on its way\" and \"stuck\" is the whole reason to look at it.",
"Satellite frequencies are shown to a hundred hertz instead of one. The Doppler moves about sixty hertz a second on 70 cm, so the last two digits changed every tick and the display was a blur of numbers nobody could read and nobody needed. The radio still gets the whole figure — this is only how much of it is worth putting in front of you. The shift beside it now reads \"+9.7 kHz\" rather than \"+9741 Hz\".",
"Rotator Genius: OpsLog no longer clamps a target to 360°, and reads the limits the Genius reports so it can drive an overlap when the controller offers one. In practice a Rotator Genius is a 360° controller — its Limits fields say where the mechanical stop sits within one turn, not how far the mast travels — so an operator with a 450° rotator still gets 360° of it, and that limit is the controllers, not OpsLogs. The rotator range is therefore not offered for it: a setting that can only ever be refused by the box is worse than none."
"Rotator Genius: OpsLog no longer clamps a target to 360°, and reads the limits the Genius reports so it can drive an overlap when the controller offers one. In practice a Rotator Genius is a 360° controller — its Limits fields say where the mechanical stop sits within one turn, not how far the mast travels — so an operator with a 450° rotator still gets 360° of it, and that limit is the controllers, not OpsLogs. The rotator range is therefore not offered for it: a setting that can only ever be refused by the box is worse than none.",
"FlexRadio, satellite: the uplink slice is properly armed. Creating a slice is asynchronous — the radio reports its number afterwards — and OpsLog carried on without waiting, so everything meant for the uplink went nowhere: it was never tuned (it sat at the 435.100 it was created with), never got its sideband, its antenna or its CTCSS tone, and never became the transmitter, leaving the radio transmitting on the DOWNLINK slice. Arming now waits for both slices, adopts one that the radio announces without a reply of its own, and gives a late-arriving uplink everything it was owed."
],
"fr": [
"Laffichage de lantenne ne clignote plus pendant un passage. Le rotor est interrogé toutes les trois secondes, mais l’état du suivi était reconstruit de zéro chaque seconde et perdait la réponse entre-temps — lantenne apparaissait donc une seconde sur trois, ce qui se lit comme un rotor qui se déconnecte sans arrêt.",
"Pendant le suivi, les deux fréquences et le cap de lantenne sont affichés à côté du bouton Tracking. Pendant un passage, on regarde la radio et lantenne, pas une colonne à lautre bout de la fenêtre — et cest la première chose quon masque pour avoir la carte en pleine largeur. La boussole tourne tant que lantenne est en mouvement : un pylône met des dizaines de secondes à traverser un passage, et distinguer « en route » de « bloqué » est toute la raison de la regarder.",
"Les fréquences satellite sont affichées à la centaine de hertz au lieu du hertz. Le Doppler se déplace denviron soixante hertz par seconde en 70 cm : les deux derniers chiffres changeaient à chaque tick et laffichage était une bouillie de chiffres illisible et inutile. La radio reçoit toujours la valeur complète — il ne sagit que de ce qui vaut la peine d’être mis sous vos yeux. Le décalage à côté indique désormais « +9,7 kHz » plutôt que « +9741 Hz ».",
"Rotator Genius : OpsLog n’écrête plus une consigne à 360° et lit les limites que le Genius rapporte, de façon à exploiter un recouvrement quand le contrôleur en offre un. Dans les faits, le Rotator Genius est un contrôleur 360° — ses champs Limits indiquent où se trouve la butée mécanique dans un tour, pas la course du pylône — donc un rotor 450° nen donne que 360, et cette limite est celle du contrôleur, pas dOpsLog. Lamplitude du rotor nest donc pas proposée pour lui : un réglage que le boîtier ne pourra que refuser est pire que pas de réglage du tout."
"Rotator Genius : OpsLog n’écrête plus une consigne à 360° et lit les limites que le Genius rapporte, de façon à exploiter un recouvrement quand le contrôleur en offre un. Dans les faits, le Rotator Genius est un contrôleur 360° — ses champs Limits indiquent où se trouve la butée mécanique dans un tour, pas la course du pylône — donc un rotor 450° nen donne que 360, et cette limite est celle du contrôleur, pas dOpsLog. Lamplitude du rotor nest donc pas proposée pour lui : un réglage que le boîtier ne pourra que refuser est pire que pas de réglage du tout.",
"FlexRadio, satellite : la tranche de montée est correctement armée. Créer une tranche est asynchrone — la radio annonce son numéro ensuite — et OpsLog continuait sans attendre : tout ce qui était destiné à la montée partait dans le vide. Elle n’était jamais accordée (elle restait sur le 435,100 de sa création), ne recevait ni sa bande latérale, ni son antenne, ni sa tonalité CTCSS, et ne devenait jamais l’émettrice — la radio émettait donc sur la tranche de DESCENTE. Larmement attend maintenant les deux tranches, adopte celle que la radio annonce sans réponse propre, et donne à une montée arrivée en retard tout ce qui lui était dû."
]
},
{
+32
View File
@@ -13,6 +13,8 @@ import (
"strings"
"sync"
"time"
"hamlog/internal/applog"
)
// Flex is a native FlexRadio (SmartSDR) CAT backend. It speaks the radio's TCP
@@ -73,6 +75,14 @@ type Flex struct {
satRX int
satTX int
satCreatedTX bool
// What the uplink slice is owed, kept so it can be given to a slice that
// turns up LATE. Arming, the antennas, the tone and the mode all happen
// before the radio has necessarily reported the slice it was asked to
// create; without this they were applied to an index of -1 and never again.
satUpMode string
satRXAnt string
satTXAnt string
satTone float64
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
spotFreq map[int]int64 // spot index → Hz, so a click can report where it was (the trigger message carries only the index)
@@ -1034,7 +1044,29 @@ func (f *Flex) handleStatus(payload string) {
s.filterHi = atoiDefault(val, s.filterHi)
}
}
// A satellite uplink slice that arrived without our hearing about it.
//
// satCreate correlates the "slice create" reply by sequence number, and when
// that correlation misses, the slice exists on the radio and OpsLog does not
// know its index. Everything then silently does nothing: the uplink is never
// tuned, never gets its mode, never gets its antenna or its CTCSS tone, and
// — worst — never becomes the transmitter, so the radio goes on transmitting
// on the DOWNLINK slice. Seen on the air: slice B sitting at the 435.100000
// it was created with, both slices in USB, and the red TX badge on the 2 m
// downlink.
//
// The status message needs no correlation. If satellite mode is armed, the
// uplink is still unknown, and a slice is in use that is not the downlink,
// that is the slice — the radio is telling us plainly.
adopt := -1
if f.satOn && f.satTX < 0 && idx != f.satRX && s.inUse {
adopt = idx
}
f.mu.Unlock()
if adopt >= 0 {
applog.Printf("flex: adopting slice %d as the satellite uplink from its status — the create reply never came back", adopt)
f.adoptSatSlice("tx", adopt)
}
}
// defInt returns v, or def when v is zero (so sliders show sane defaults before
+58
View File
@@ -3,6 +3,7 @@ package cat
import (
"fmt"
"strings"
"time"
"hamlog/internal/applog"
)
@@ -65,10 +66,41 @@ func (f *Flex) SetSatellite(on bool) error {
} 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
@@ -118,6 +150,24 @@ func (f *Flex) adoptSatSlice(role string, idx int) {
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)
}
@@ -146,6 +196,11 @@ func (f *Flex) TuneSatellite(downHz, upHz int64, downMode, upMode string) error
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)
@@ -223,6 +278,8 @@ 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")
@@ -256,6 +313,7 @@ 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")