Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 110 additions & 1 deletion gui_interface/fyne-ui/mercury_chat_window.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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()
Expand Down Expand Up @@ -123,17 +133,25 @@ 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},
&widget.FormItem{Text: "Target Callsign", Widget: cw.target},
&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,
Expand Down Expand Up @@ -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()
Expand All @@ -247,6 +266,7 @@ func (cw *chatWindow) setTCP(on bool) {
cw.arqAbort.Disable()
cw.sendARQ.Disable()
cw.sendBcastWrap.Disable()
cw.sendCQ.Disable()
}
})
}
Expand All @@ -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()
Expand All @@ -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()
}
}
}
})
Expand Down Expand Up @@ -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 {
Expand All @@ -316,6 +347,7 @@ func (cw *chatWindow) onConnect() {
return
}
cw.mc = mc
cw.cqSending = false
cw.setTCP(true)
cw.done = make(chan struct{})

Expand All @@ -337,6 +369,7 @@ func (cw *chatWindow) onDisconnect() {
}
cw.setTCP(false)
cw.setARQ(false)
cw.cqSending = false
cw.logMsg("Disconnected.")
}

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand All @@ -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
}
}
41 changes: 40 additions & 1 deletion gui_interface/mercury-client/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type Config struct {
IP string
ARQPort int
BroadcastPort int
BandwidthHz int
}

// ChatMessage is a single chat line emitted for display.
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions gui_interface/mercury-client/modem/modem.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
Loading