diff --git a/app.go b/app.go index 1dab336..8c6a0d6 100644 --- a/app.go +++ b/app.go @@ -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 { diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 56e9c1f..aa47cd0 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -827,8 +827,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null); // Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings. - const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number }>({ - enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, + const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; tx_inhibit: boolean }>({ + enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, tx_inhibit: false, }); const [ubTesting, setUbTesting] = useState(false); const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null); @@ -2373,6 +2373,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan )} +
+ +

{t('hw.motorTxInhibitHint')}

+