feat: While using Flex & Steppir or Ultrabeam can prevent transmit when antenna is moving

This commit is contained in:
2026-07-16 19:51:25 +02:00
parent 28b6f04ea4
commit c9fd1379e1
8 changed files with 125 additions and 20 deletions
+86 -15
View File
@@ -162,12 +162,13 @@ const (
keyUltrabeamEnabled = "ultrabeam.enabled"
keyUltrabeamHost = "ultrabeam.host"
keyUltrabeamPort = "ultrabeam.port"
keyUltrabeamFollow = "ultrabeam.follow" // "1" → re-tune to the rig frequency
keyUltrabeamStep = "ultrabeam.step_khz" // re-tune hysteresis: 25 | 50 | 100 kHz
keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam)
keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp)
keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0)
keyMotorBaud = "ultrabeam.baud" // serial baud (SteppIR default 9600)
keyUltrabeamFollow = "ultrabeam.follow" // "1" → re-tune to the rig frequency
keyUltrabeamStep = "ultrabeam.step_khz" // re-tune hysteresis: 25 | 50 | 100 kHz
keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam)
keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp)
keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0)
keyMotorBaud = "ultrabeam.baud" // serial baud (SteppIR default 9600)
keyMotorTXInhibit = "ultrabeam.tx_inhibit" // "1" → block Flex TX while the antenna is moving
// Antenna Genius (4O3A) antenna switch — Hardware → Antenna Genius. TCP
// port is fixed at 9007, so only the IP is configurable.
@@ -441,6 +442,9 @@ type App struct {
clublog *clublog.Manager
motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
motorInhibStop chan struct{} // stops the "inhibit TX while moving" loop; nil when off
motorMoveCmdNs atomic.Int64 // unixnano of the last commanded antenna move (grace window)
motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
audioMgr *audio.Manager
@@ -9247,6 +9251,7 @@ func (a *App) ultrabeamFollowNow(freqHz int64) {
applog.Printf("ultrabeam: followNow within deadband (%d kHz vs ref %d, step %d) — no move", khz, ref, step)
return // within the deadband — don't chase a tiny QSY
}
a.noteMotorMoveCommanded()
if err := c.SetFrequency(khz, st.Direction); err != nil {
applog.Printf("ultrabeam: immediate re-tune to %d kHz failed: %v", khz, err)
} else {
@@ -10590,14 +10595,15 @@ func (a steppirAdapter) Status() motorStatus {
// is kept (bindings + frontend) though it now covers SteppIR too.
type UltrabeamSettings struct {
Enabled bool `json:"enabled"`
Type string `json:"type"` // "ultrabeam" | "steppir"
Transport string `json:"transport"` // "tcp" | "serial"
Host string `json:"host"` // tcp
Port int `json:"port"` // tcp
COM string `json:"com"` // serial device
Baud int `json:"baud"` // serial baud
Follow bool `json:"follow"` // re-tune the antenna to the rig's frequency
StepKHz int `json:"step_khz"` // re-tune only when the freq moved this far (25/50/100)
Type string `json:"type"` // "ultrabeam" | "steppir"
Transport string `json:"transport"` // "tcp" | "serial"
Host string `json:"host"` // tcp
Port int `json:"port"` // tcp
COM string `json:"com"` // serial device
Baud int `json:"baud"` // serial baud
Follow bool `json:"follow"` // re-tune the antenna to the rig's frequency
StepKHz int `json:"step_khz"` // re-tune only when the freq moved this far (25/50/100)
TXInhibit bool `json:"tx_inhibit"` // block Flex transmission while the elements are moving
}
// GetUltrabeamSettings returns the persisted motorized-antenna config, defaulting
@@ -10609,7 +10615,7 @@ func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
return out, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyUltrabeamEnabled, keyUltrabeamHost, keyUltrabeamPort, keyUltrabeamFollow, keyUltrabeamStep,
keyMotorType, keyMotorTransport, keyMotorCOM, keyMotorBaud)
keyMotorType, keyMotorTransport, keyMotorCOM, keyMotorBaud, keyMotorTXInhibit)
if err != nil {
return out, err
}
@@ -10629,6 +10635,7 @@ func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
out.Baud = b
}
out.Follow = m[keyUltrabeamFollow] == "1"
out.TXInhibit = m[keyMotorTXInhibit] == "1"
if st, _ := strconv.Atoi(m[keyUltrabeamStep]); st == 25 || st == 50 || st == 100 {
out.StepKHz = st
}
@@ -10666,6 +10673,7 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
keyMotorTransport: s.Transport,
keyMotorCOM: strings.TrimSpace(s.COM),
keyMotorBaud: strconv.Itoa(s.Baud),
keyMotorTXInhibit: boolStr(s.TXInhibit),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
@@ -10705,6 +10713,11 @@ func (a *App) startUltrabeam() {
close(a.ubFollowStop)
a.ubFollowStop = nil
}
// Stop the inhibit watcher (its deferred cleanup releases any active inhibit).
if a.motorInhibStop != nil {
close(a.motorInhibStop)
a.motorInhibStop = nil
}
if a.motorAnt != nil {
// Background teardown so saving Settings doesn't block on an in-progress
// connect (Stop waits for the dial timeout).
@@ -10726,12 +10739,68 @@ func (a *App) startUltrabeam() {
a.ubFollowStop = stop
go a.ultrabeamFollowLoop(a.motorAnt, s.StepKHz, stop)
}
if s.TXInhibit {
stop := make(chan struct{})
a.motorInhibStop = stop
go a.motorTXInhibitLoop(a.motorAnt, stop)
}
}
// ultrabeamFollowLoop re-tunes the antenna to the rig's current frequency
// whenever it drifts at least stepKHz from what the antenna is set to — so the
// elements track the band without the motors chasing every small QSY. Runs
// until stop is closed (a settings change or shutdown).
// noteMotorMoveCommanded marks that OpsLog just told the antenna to move. The
// inhibit watcher polls the antenna's Moving status, but that status is only
// refreshed every couple of seconds — so a fresh command opens a short grace
// window during which TX stays inhibited even before the poll confirms motion,
// closing the gap at the start of a move.
func (a *App) noteMotorMoveCommanded() { a.motorMoveCmdNs.Store(time.Now().UnixNano()) }
// applyMotorInhibit sets or clears the Flex transmit inhibit, but only when the
// active CAT backend IS a FlexRadio — the inhibit is a Flex-API feature; with any
// other rig the option simply does nothing (as documented in the UI). Idempotent.
func (a *App) applyMotorInhibit(on bool) {
if a.cat == nil {
return
}
if a.cat.State().Backend != "flex" {
return
}
if a.motorInhibited.Load() == on {
return
}
if err := a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXInhibit(on) }); err != nil {
applog.Printf("motor-antenna: TX inhibit=%v failed: %v", on, err)
return
}
a.motorInhibited.Store(on)
applog.Printf("motor-antenna: Flex TX inhibit %s (antenna moving)", map[bool]string{true: "ON", false: "OFF"}[on])
}
// motorTXInhibitLoop blocks Flex transmission while the motorized antenna's
// elements are moving. Runs only when the option is on and a motor antenna is
// active; it errs toward SAFE — TX is inhibited while the status reports motion
// OR within a grace window after a commanded move — and always releases the
// inhibit when it stops (so a settings change / shutdown never leaves TX blocked).
func (a *App) motorTXInhibitLoop(c motorAntenna, stop <-chan struct{}) {
const grace = 3 * time.Second // cover the poll latency at the very start of a move
ticker := time.NewTicker(300 * time.Millisecond)
defer ticker.Stop()
defer a.applyMotorInhibit(false) // never leave TX blocked when the loop ends
for {
select {
case <-stop:
return
case <-ticker.C:
st := c.Status()
recentCmd := time.Since(time.Unix(0, a.motorMoveCmdNs.Load())) < grace
moving := st.Connected && (st.Moving || recentCmd)
a.applyMotorInhibit(moving)
}
}
}
func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, stop <-chan struct{}) {
if stepKHz <= 0 {
stepKHz = 50
@@ -10786,6 +10855,7 @@ func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, stop <-chan struc
if ref > 0 && diff < stepKHz {
continue // within the deadband — leave the motors alone
}
a.noteMotorMoveCommanded()
if err := c.SetFrequency(rigKHz, st.Direction); err != nil {
applog.Printf("ultrabeam: follow re-tune to %d kHz failed: %v", rigKHz, err)
} else {
@@ -10837,6 +10907,7 @@ func (a *App) SetUltrabeamDirection(direction int) error {
// current frequency with the new direction byte. If the antenna hasn't reported
// a frequency yet (just connected / link settling), fall back to the rig's CAT
// frequency so the control still works.
a.noteMotorMoveCommanded()
st := a.motorAnt.Status()
if st.Frequency <= 0 && a.cat != nil {
if rs := a.cat.State(); rs.Connected && rs.FreqHz > 0 {