diff --git a/gui_interface/fyne-ui/mercury_chat_window.go b/gui_interface/fyne-ui/mercury_chat_window.go index 1fcd0b77..fa7cb8d4 100644 --- a/gui_interface/fyne-ui/mercury_chat_window.go +++ b/gui_interface/fyne-ui/mercury_chat_window.go @@ -57,6 +57,7 @@ type chatWindow struct { ip *widget.Entry arqPort *widget.Entry bcastPort *widget.Entry + bandwidth *widget.Select connectBtn *widget.Button disconnectBtn *widget.Button @@ -66,12 +67,18 @@ type chatWindow struct { sendARQ *widget.Button sendBcast *widget.Button sendBcastWrap *hoverTooltipButton + sendCQ *widget.Button arqMsg *widget.Entry bcastMsg *widget.Entry // bcastDisabledReason is shown as a hover tooltip on the Broadcast // message button while it is disabled (e.g. during an ARQ session). bcastDisabledReason string + + // cqSending is set while a CQ frame is being transmitted; broadcast and + // ARQ connect are blocked until the engine reports the transmission done. + // Owned by setCQBusy and touched only from the UI thread. + cqSending bool } func (cw *chatWindow) build(app fyne.App, telemetry telemetryState, arqPort, broadcastPort int) { @@ -88,6 +95,9 @@ func (cw *chatWindow) build(app fyne.App, telemetry telemetryState, arqPort, bro cw.bcastPort = widget.NewEntry() cw.bcastPort.SetText(strconv.Itoa(broadcastPort)) + cw.bandwidth = widget.NewSelect([]string{"2300 Hz", "500 Hz"}, cw.onBandwidthChange) + cw.bandwidth.SetSelected("2300 Hz") + cw.arqMsg = widget.NewEntry() cw.arqMsg.SetPlaceHolder("Type message to be sent...") cw.bcastMsg = widget.NewEntry() @@ -123,6 +133,8 @@ func (cw *chatWindow) build(app fyne.App, telemetry telemetryState, arqPort, bro return cw.bcastDisabledReason }) cw.sendBcastWrap.Disable() + cw.sendCQ = widget.NewButton("Send CQ Frame", cw.onSendCQ) + cw.sendCQ.Disable() cfgForm := widget.NewForm( &widget.FormItem{Text: "My Callsign", Widget: cw.myCall}, @@ -130,10 +142,16 @@ func (cw *chatWindow) build(app fyne.App, telemetry telemetryState, arqPort, bro &widget.FormItem{Text: "IP/Host", Widget: cw.ip}, &widget.FormItem{Text: "ARQ Port", Widget: cw.arqPort}, &widget.FormItem{Text: "Broadcast Port", Widget: cw.bcastPort}, + &widget.FormItem{Text: "Bandwidth", Widget: cw.bandwidth}, ) modemRow := container.NewHBox(cw.connectBtn, cw.disconnectBtn) - sessionRow := container.NewHBox(cw.arqConnect, cw.arqDisconnect, cw.arqAbort) + sessionRow := container.NewGridWithColumns(4, + cw.arqConnect, + cw.arqDisconnect, + cw.arqAbort, + cw.sendCQ, + ) controls := container.NewVBox( cfgForm, @@ -239,6 +257,7 @@ func (cw *chatWindow) setTCP(on bool) { cw.disconnectBtn.Enable() cw.arqConnect.Enable() cw.sendBcastWrap.Enable() + cw.sendCQ.Enable() } else { cw.connectBtn.Enable() cw.disconnectBtn.Disable() @@ -247,6 +266,7 @@ func (cw *chatWindow) setTCP(on bool) { cw.arqAbort.Disable() cw.sendARQ.Disable() cw.sendBcastWrap.Disable() + cw.sendCQ.Disable() } }) } @@ -260,6 +280,11 @@ func (cw *chatWindow) setARQ(on bool) { cw.sendARQ.Enable() cw.sendBcastWrap.Disable() cw.bcastDisabledReason = "Broadcast is disabled while an ARQ session is active." + // A CQ is an unsolicited transmission; firing one mid-session puts + // it on the air on top of the session. The engine will not stop us + // -- ARQ_CMD_SEND_CQ (arq.c) builds and queues the frame with no + // conn_state guard -- so the block has to be here. + cw.sendCQ.Disable() } else { cw.arqConnect.Enable() cw.arqDisconnect.Disable() @@ -268,6 +293,11 @@ func (cw *chatWindow) setARQ(on bool) { cw.bcastDisabledReason = "" if cw.mc != nil && cw.mc.IsConnected() { cw.sendBcastWrap.Enable() + // Not while a CQ of our own is still on the air: setCQBusy + // owns that case and re-enables when PTT drops. + if !cw.cqSending { + cw.sendCQ.Enable() + } } } }) @@ -307,6 +337,7 @@ func (cw *chatWindow) onConnect() { IP: cw.ip.Text, ARQPort: arqPort, BroadcastPort: bcastPort, + BandwidthHz: bandwidthFromLabel(cw.bandwidth.Selected), } mc := client.New(cfg) if err := mc.Connect(); err != nil { @@ -316,6 +347,7 @@ func (cw *chatWindow) onConnect() { return } cw.mc = mc + cw.cqSending = false cw.setTCP(true) cw.done = make(chan struct{}) @@ -337,6 +369,7 @@ func (cw *chatWindow) onDisconnect() { } cw.setTCP(false) cw.setARQ(false) + cw.cqSending = false cw.logMsg("Disconnected.") } @@ -390,6 +423,64 @@ func (cw *chatWindow) onSendARQ() { cw.arqMsg.SetText("") } +func (cw *chatWindow) onBandwidthChange(sel string) { + hz := bandwidthFromLabel(sel) + if hz == 0 { + return + } + if mc := cw.mc; mc != nil && mc.IsConnected() { + if err := mc.SetBandwidth(hz); err != nil { + cw.logMsg("set bandwidth: %v", err) + } else { + cw.logMsg("Bandwidth set to %d Hz.", hz) + } + } +} + +func (cw *chatWindow) onSendCQ() { + mc := cw.mc + if mc == nil || !mc.IsConnected() || cw.cqSending { + return + } + if err := mc.SendCQFrame(); err != nil { + dialog.ShowError(err, cw.win) + return + } + cw.sendCQ.Disable() + cw.setCQBusy(true) + cw.logMsg("CQ frame sent.") +} + +// setCQBusy disables broadcast and ARQ connect while a CQ frame is on the air +// and restores them once the engine reports the transmission finished. It owns +// the cqSending flag and every widget it touches, all inside fyne.Do, so the +// flag is only ever accessed from the UI thread. +func (cw *chatWindow) setCQBusy(on bool) { + fyne.Do(func() { + if on { + cw.cqSending = true + cw.sendCQ.Disable() + cw.arqConnect.Disable() + cw.sendBcastWrap.Disable() + cw.bcastDisabledReason = "CQ frame transmission in progress." + return + } + if !cw.cqSending { + return + } + cw.cqSending = false + // sendCQ is re-enabled under the same session test as the rest: a + // session can come up while our CQ is still on the air, and enabling + // it unconditionally here would undo setARQ's block. + if cw.mc != nil && cw.mc.IsConnected() && !cw.mc.IsARQConnected() { + cw.sendCQ.Enable() + cw.arqConnect.Enable() + cw.sendBcastWrap.Enable() + cw.bcastDisabledReason = "" + } + }) +} + func (cw *chatWindow) onSendBroadcast() { mc := cw.mc if mc == nil || !mc.IsConnected() { @@ -482,6 +573,13 @@ func (cw *chatWindow) forwardStatus() { cw.setARQ(true) case "DISCONNECTED": cw.setARQ(false) + case "PTT OFF": + // The true end of a CQ transmission: an incoming connection's + // PENDING/CANCELPENDING never keys the radio, so PTT OFF is the + // unambiguous "CQ is off the air" signal. setCQBusy owns the + // cqSending flag and only clears it when one is actually in + // flight, marshalling the work onto the UI thread. + cw.setCQBusy(false) } case <-done: return @@ -495,3 +593,14 @@ func defaultCall(call, fallback string) string { } return fallback } + +func bandwidthFromLabel(label string) int { + switch strings.TrimSpace(label) { + case "500 Hz": + return 500 + case "2300 Hz": + return 2300 + default: + return 0 + } +} diff --git a/gui_interface/mercury-client/client/client.go b/gui_interface/mercury-client/client/client.go index 55ed882b..dbbace23 100644 --- a/gui_interface/mercury-client/client/client.go +++ b/gui_interface/mercury-client/client/client.go @@ -20,6 +20,7 @@ type Config struct { IP string ARQPort int BroadcastPort int + BandwidthHz int } // ChatMessage is a single chat line emitted for display. @@ -62,6 +63,9 @@ func New(cfg Config) *Client { if cfg.BroadcastPort == 0 { cfg.BroadcastPort = 8100 } + if cfg.BandwidthHz == 0 { + cfg.BandwidthHz = 2300 + } return &Client{ cfg: cfg, LogCh: make(chan string, 256), @@ -97,7 +101,7 @@ func (c *Client) Connect() error { mc.SendCommand("LISTEN ON") mc.SendCommand("PUBLIC OFF") mc.SendCommand("COMPRESSION OFF") - mc.SendCommand("BW2750") + mc.SendCommand(fmt.Sprintf("BW%d", c.cfg.BandwidthHz)) go c.updateLog() go c.handleIncomingARQ() @@ -224,6 +228,41 @@ func (c *Client) SendARQFile(filePath string) error { return mc.SendARQFile(filePath) } +// SetBandwidth updates the modem's bandwidth and stores it for the next +// connection. Supported values are 500 and 2300 (Hz). +func (c *Client) SetBandwidth(hz int) error { + if hz != 500 && hz != 2300 { + return fmt.Errorf("unsupported bandwidth: %d Hz", hz) + } + c.mu.Lock() + mc := c.modem + c.mu.Unlock() + if mc == nil || !mc.IsConnected() { + return fmt.Errorf("not connected to modem") + } + if err := mc.SendCommand(fmt.Sprintf("BW%d", hz)); err != nil { + return err + } + c.cfg.BandwidthHz = hz + return nil +} + +// SendCQFrame queues a one-shot CQ frame advertising the local callsign and +// the currently configured bandwidth. +func (c *Client) SendCQFrame() error { + c.mu.Lock() + mc := c.modem + c.mu.Unlock() + if mc == nil || !mc.IsConnected() { + return fmt.Errorf("not connected to modem") + } + if err := mc.SendCQFrame(c.cfg.MyCallsign, c.cfg.BandwidthHz); err != nil { + return err + } + c.LogCh <- fmt.Sprintf("CQ Frame TX: %s (%d Hz)", c.cfg.MyCallsign, c.cfg.BandwidthHz) + return nil +} + // SendBroadcast sends a broadcast message. The local callsign is prefixed to // the payload because the Mercury broadcast plane carries no callsign, so // receiving stations can attribute the message. diff --git a/gui_interface/mercury-client/modem/modem.go b/gui_interface/mercury-client/modem/modem.go index bb37760e..3ccd362d 100644 --- a/gui_interface/mercury-client/modem/modem.go +++ b/gui_interface/mercury-client/modem/modem.go @@ -219,6 +219,12 @@ func (mc *ModemClient) ListenOff() error { return mc.SendCommand("LISTEN OFF") } +// SendCQFrame queues a one-shot CQ frame advertising the source callsign and +// the given bandwidth (Hz). +func (mc *ModemClient) SendCQFrame(src string, bwHz int) error { + return mc.SendCommand(fmt.Sprintf("CQFRAME %s %d", src, bwHz)) +} + func (mc *ModemClient) ConnectARQ(src, dst string) error { mc.mu.Lock() if mc.ARQControlConn == nil { @@ -475,6 +481,12 @@ func (mc *ModemClient) dispatchControlLine(line string) { case strings.HasPrefix(upper, "CQFRAME"): mc.StatusCh <- "CQFRAME" + + case strings.HasPrefix(upper, "PTT ON"): + mc.StatusCh <- "PTT ON" + + case strings.HasPrefix(upper, "PTT OFF"): + mc.StatusCh <- "PTT OFF" } mc.mu.Lock()