diff --git a/core/actions.go b/core/actions.go index da1b783a..dcd75609 100644 --- a/core/actions.go +++ b/core/actions.go @@ -28,8 +28,21 @@ const ( ActionEntryWorkmodeRun = "entry.workmode_run" ActionEntryOfferQTC = "entry.offer_qtc" ActionEntryRequestQTC = "entry.request_qtc" + ActionEntryToggleFocusedVFO = "entry.toggle_focused_vfo" + ActionEntryFocusVFO1 = "entry.focus_vfo1" + ActionEntryFocusVFO2 = "entry.focus_vfo2" + ActionEntryLogVFO1 = "entry.log_vfo1" + ActionEntryLogVFO2 = "entry.log_vfo2" + ActionEntryClearVFO1 = "entry.clear_vfo1" + ActionEntryClearVFO2 = "entry.clear_vfo2" - ActionRadioXITActive = "radio.xit_active" + ActionRadioXITActive = "radio.xit_active" + ActionRadioMuteAudioVFO1 = "radio.mute_audio_vfo1" + ActionRadioMuteAudioVFO2 = "radio.mute_audio_vfo2" + ActionRadioUnmuteAudioVFO1 = "radio.unmute_audio_vfo1" + ActionRadioUnmuteAudioVFO2 = "radio.unmute_audio_vfo2" + ActionRadioToggleAudioVFO1 = "radio.toggle_audio_vfo1" + ActionRadioToggleAudioVFO2 = "radio.toggle_audio_vfo2" ActionBandmapMark = "bandmap.mark" ActionBandmapGotoHighestValueSpot = "bandmap.goto_highest_value_spot" diff --git a/core/app/app.go b/core/app/app.go index cd583ed3..2a426ea7 100644 --- a/core/app/app.go +++ b/core/app/app.go @@ -74,7 +74,7 @@ type Controller struct { callHistoryFinder *callhistory.Finder hamDXMap *hamdxmap.HamDXMap - VFO *vfo.VFO + VFOs []*vfo.VFO Logbook *logbook.Logbook QSOList *logbook.QSOList QTCList *logbook.QTCList @@ -204,27 +204,42 @@ func (c *Controller) Startup() { c.Workmode = workmode.NewController() c.Workmode.Notify(c.Entry) + c.Entry.Notify(c.Workmode) c.QSOList.Notify(c.Workmode) - c.VFO = vfo.NewVFO("VFO 1", c.bandplan, c.Logbook, c.asyncRunner) - c.Entry.SetVFO(c.VFO) - c.VFO.Notify(c.Bandmap) - c.Bandmap.SetVFO(c.VFO) - c.Logbook.Notify(c.VFO) - c.Workmode.Notify(c.VFO) + c.Callinfo = callinfo.New(c.dxccFinder, c.scpFinder, c.callHistoryFinder, c.Logbook, c.Logbook, c.Logbook) + c.Entry.SetCallinfo(c.Callinfo) + c.Callinfo.Notify(c.Entry) c.Radio = radio.NewController(c.configuration.Radios(), c.configuration.Keyers(), c.bandplan) c.Radio.Notify(c.ServiceStatus) + c.Radio.Notify(c.Entry) + c.Radio.Notify(c.Callinfo) + c.Radio.Notify(c.Workmode) + c.Entry.SetVFOSwitcher(c.Radio) c.Bandmap.Notify(c.Radio) // TODO implement Entry... in radio.Controller - c.VFO.SetClient(c.Radio) + + c.VFOs = make([]*vfo.VFO, core.VFOCount) + for vfoID := range len(c.VFOs) { + v := vfo.NewVFO(core.VFOID(vfoID), fmt.Sprintf("VFO %d", vfoID+1), c.bandplan, c.Logbook, c.asyncRunner) + v.SetClient(c.Radio) + c.VFOs[vfoID] = v + c.Entry.SetVFO(core.VFOID(vfoID), v) + c.Logbook.Notify(v) + } + c.Bandmap.SetVFO(c.VFOs[core.VFO1]) + c.Workmode.Notify(c.VFOs[core.VFO1]) c.Radio.SetSendSpotsToTci(c.session.SendSpotsToTci()) c.Radio.SelectRadio(c.session.Radio1()) c.Radio.SelectKeyer(c.session.Keyer1()) c.Keyer = keyer.New(c.Settings, c.Radio, c.configuration.KeyerSettings(), c.Workmode.Workmode(), c.configuration.KeyerPresets()) + c.Keyer.SetVFOSwitcher(c.Radio) c.Keyer.SetValues(c.Entry.CurrentValues) c.Keyer.Notify(c.ServiceStatus) + c.Keyer.Notify(c.Entry) + c.Entry.Notify(c.Keyer) c.Workmode.Notify(c.Keyer) c.Entry.SetKeyer(c.Keyer) @@ -243,21 +258,18 @@ func (c *Controller) Startup() { } c.QTCController = qtc.NewController(c.clock, c, c.Logbook, c.QTCList, c.Entry, c.Keyer) - c.VFO.Notify(c.QTCController) c.Rate = rate.NewCounter(c.clock, c.asyncRunner) c.QSOList.Notify(logbook.QSOsClearedListenerFunc(c.Rate.Clear)) c.QSOList.Notify(logbook.QSOAddedListenerFunc(c.Rate.Add)) - c.Callinfo = callinfo.New(c.dxccFinder, c.scpFinder, c.callHistoryFinder, c.Logbook, c.Logbook, c.Logbook) - c.Entry.SetCallinfo(c.Callinfo) - c.Callinfo.Notify(c.Entry) c.Bandmap.SetCallinfo(c.Callinfo) c.Bandmap.Notify(c.Callinfo) c.Logbook.Notify(c.Callinfo) c.Parrot = parrot.New(c.Workmode, c.Keyer, c.asyncRunner) c.Keyer.SetParrot(c.Parrot) + c.Keyer.Notify(c.Parrot) c.Workmode.Notify(c.Parrot) c.Entry.Notify(c.Parrot) c.Parrot.Notify(c.Entry) @@ -852,11 +864,23 @@ func (c *Controller) RequestQTC() { } func (c *Controller) XITActive() bool { - return c.VFO.XITActive() + return c.VFOs[core.VFO1].XITActive() } func (c *Controller) SetXITActive(active bool) { - c.VFO.SetXITActive(active) + c.VFOs[core.VFO1].SetXITActive(active) +} + +func (c *Controller) MuteAudio(vfo core.VFOID) { + c.VFOs[vfo].MuteAudio() +} + +func (c *Controller) UnmuteAudio(vfo core.VFOID) { + c.VFOs[vfo].UnmuteAudio() +} + +func (c *Controller) ToggleAudio(vfo core.VFOID) { + c.VFOs[vfo].ToggleAudio() } func (c *Controller) MarkInBandmap() { @@ -954,6 +978,20 @@ func (c *Controller) DoAction(id string) error { c.StartParrot() case core.ActionEntryNextESMStep: c.Entry.NextESMStep() + case core.ActionEntryToggleFocusedVFO: + c.Entry.ToggleFocusedVFO() + case core.ActionEntryFocusVFO1: + c.Entry.FocusVFO1() + case core.ActionEntryFocusVFO2: + c.Entry.FocusVFO2() + case core.ActionEntryLogVFO1: + c.Entry.LogVFO(core.VFO1) + case core.ActionEntryLogVFO2: + c.Entry.LogVFO(core.VFO2) + case core.ActionEntryClearVFO1: + c.Entry.ClearVFO(core.VFO1) + case core.ActionEntryClearVFO2: + c.Entry.ClearVFO(core.VFO2) case core.ActionEntryOfferQTC: c.OfferQTC() case core.ActionEntryRequestQTC: @@ -981,13 +1019,25 @@ func (c *Controller) DoAction(id string) error { case core.ActionWindowShowSpots: c.ShowSpots() case core.ActionKeyerSendMacro1: - c.Keyer.Send(0) + c.Keyer.SendMacro(0) case core.ActionKeyerSendMacro2: - c.Keyer.Send(1) + c.Keyer.SendMacro(1) case core.ActionKeyerSendMacro3: - c.Keyer.Send(2) + c.Keyer.SendMacro(2) case core.ActionKeyerSendMacro4: - c.Keyer.Send(3) + c.Keyer.SendMacro(3) + case core.ActionRadioMuteAudioVFO1: + c.MuteAudio(core.VFO1) + case core.ActionRadioMuteAudioVFO2: + c.MuteAudio(core.VFO2) + case core.ActionRadioUnmuteAudioVFO1: + c.UnmuteAudio(core.VFO1) + case core.ActionRadioUnmuteAudioVFO2: + c.UnmuteAudio(core.VFO2) + case core.ActionRadioToggleAudioVFO1: + c.ToggleAudio(core.VFO1) + case core.ActionRadioToggleAudioVFO2: + c.ToggleAudio(core.VFO2) default: return fmt.Errorf("unknown action: %s", id) } diff --git a/core/bandmap/bandmap.go b/core/bandmap/bandmap.go index 4a3a4c55..0f85c4de 100644 --- a/core/bandmap/bandmap.go +++ b/core/bandmap/bandmap.go @@ -233,14 +233,20 @@ func (m *Bandmap) ScoreChanged(_ core.Score) { } } -func (m *Bandmap) VFOFrequencyChanged(frequency core.Frequency) { +func (m *Bandmap) VFOFrequencyChanged(vfo core.VFOID, frequency core.Frequency) { + if vfo != core.VFO1 { + return + } m.do <- func() { m.activeFrequency = frequency // the frame is not updated with every frequency change, this creates too much load } } -func (m *Bandmap) VFOBandChanged(band core.Band) { +func (m *Bandmap) VFOBandChanged(vfo core.VFOID, band core.Band) { + if vfo != core.VFO1 { + return + } m.do <- func() { if band == m.activeBand { return @@ -254,7 +260,10 @@ func (m *Bandmap) VFOBandChanged(band core.Band) { } } -func (m *Bandmap) VFOModeChanged(mode core.Mode) { +func (m *Bandmap) VFOModeChanged(vfo core.VFOID, mode core.Mode) { + if vfo != core.VFO1 { + return + } m.do <- func() { if m.activeMode == mode { return @@ -398,6 +407,7 @@ func (v *nullView) RevealEntry(core.BandmapEntry) {} type nullVFO struct{} +func (n *nullVFO) Name() string { return "" } func (n *nullVFO) Notify(any) {} func (n *nullVFO) Active() bool { return false } func (n *nullVFO) Refresh() {} diff --git a/core/callinfo/callinfo.go b/core/callinfo/callinfo.go index 0318cbab..ea990640 100644 --- a/core/callinfo/callinfo.go +++ b/core/callinfo/callinfo.go @@ -45,11 +45,12 @@ type QTCProvider interface { // View defines the visual part of the call information window. type View interface { - ShowFrame(core.CallinfoFrame) + ShowFrame(core.VFOID, core.CallinfoFrame) + SetVFOEnabled(core.VFOID, bool) } type CallinfoFrameListener interface { - CallinfoFrameChanged(core.CallinfoFrame) + CallinfoFrameChanged(core.VFOID, core.CallinfoFrame) } type Callinfo struct { @@ -61,8 +62,9 @@ type Callinfo struct { theirExchangeFields []core.ExchangeField qtcsEnabled bool + vfo2Enabled bool - frame core.CallinfoFrame + frames [core.VFOCount]core.CallinfoFrame } func New(entities DXCCFinder, callsigns CallsignFinder, callHistory CallHistoryFinder, dupeChecker DupeChecker, valuer Valuer, qtcProvider QTCProvider) *Callinfo { @@ -85,6 +87,7 @@ func (c *Callinfo) SetView(view View) { } c.view = view + c.view.SetVFOEnabled(core.VFO2, c.vfo2Enabled) } func (c *Callinfo) StationChanged(station core.Station) { @@ -110,13 +113,13 @@ func (c *Callinfo) Notify(listener any) { c.listeners = append(c.listeners, listener) } -func (c *Callinfo) emitFrameChanged() { +func (c *Callinfo) emitFrameChanged(vfo core.VFOID) { for _, listener := range c.listeners { if l, ok := listener.(CallinfoFrameListener); ok { - l.CallinfoFrameChanged(c.frame) + l.CallinfoFrameChanged(vfo, c.frames[vfo]) } } - c.view.ShowFrame(c.frame) + c.view.ShowFrame(vfo, c.frames[vfo]) } func (c *Callinfo) GetInfo(call core.Callsign, band core.Band, mode core.Mode, currentExchange []string) core.Callinfo { @@ -127,39 +130,42 @@ func (c *Callinfo) UpdateValue(info *core.Callinfo, band core.Band, mode core.Mo return c.collector.UpdateValue(info, band, mode) } -func (c *Callinfo) InputChanged(call string, band core.Band, mode core.Mode, currentExchange []string) { +func (c *Callinfo) InputChanged(vfo core.VFOID, call string, band core.Band, mode core.Mode, currentExchange []string) { normalizedCall := normalizeInput(call) callinfo := c.collector.GetInfoForInput(normalizedCall, band, mode, currentExchange) supercheck := c.supercheck.Calculate(normalizedCall, band, mode) - c.frame.NormalizedCallInput = normalizedCall - c.frame.DXCCEntity = callinfo.DXCCEntity - c.frame.Azimuth = callinfo.Azimuth - c.frame.Distance = callinfo.Distance - c.frame.UserInfo = callinfo.UserText + frame := &c.frames[vfo] + frame.NormalizedCallInput = normalizedCall + frame.DXCCEntity = callinfo.DXCCEntity + frame.Azimuth = callinfo.Azimuth + frame.Distance = callinfo.Distance + frame.UserInfo = callinfo.UserText - c.frame.Points = callinfo.Points - c.frame.Multis = callinfo.Multis - c.frame.Value = callinfo.Value - c.frame.SentQTCs = callinfo.SentQTCs - c.frame.ReceivedQTCs = callinfo.ReceivedQTCs + frame.Points = callinfo.Points + frame.Multis = callinfo.Multis + frame.Value = callinfo.Value + frame.SentQTCs = callinfo.SentQTCs + frame.ReceivedQTCs = callinfo.ReceivedQTCs - c.frame.PredictedExchange = callinfo.PredictedExchange - c.frame.Supercheck = supercheck + frame.PredictedExchange = callinfo.PredictedExchange + frame.Supercheck = supercheck - c.emitFrameChanged() + c.emitFrameChanged(vfo) } +// EntryOnFrequency is driven by the bandmap, which is VFO1-only in this step. Updates VFO1's frame only. func (c *Callinfo) EntryOnFrequency(entry core.BandmapEntry, available bool) { - last := c.frame.CallsignOnFrequency.Callsign.String() + frame := &c.frames[core.VFO1] + last := frame.CallsignOnFrequency.Callsign.String() if !available { - c.frame.CallsignOnFrequency = core.AnnotatedCallsign{} - } else if c.frame.CallsignOnFrequency.Callsign.String() == entry.Call.String() { + frame.CallsignOnFrequency = core.AnnotatedCallsign{} + } else if frame.CallsignOnFrequency.Callsign.String() == entry.Call.String() { // go on } else { - exactMatch := c.frame.NormalizedCallInput == entry.Call.String() - c.frame.CallsignOnFrequency = core.AnnotatedCallsign{ + exactMatch := frame.NormalizedCallInput == entry.Call.String() + frame.CallsignOnFrequency = core.AnnotatedCallsign{ Callsign: entry.Call, Assembly: core.MatchingAssembly{{OP: core.Matching, Value: entry.Call.String()}}, Duplicate: entry.Info.Duplicate, @@ -172,11 +178,17 @@ func (c *Callinfo) EntryOnFrequency(entry core.BandmapEntry, available bool) { } } - if last != c.frame.CallsignOnFrequency.Callsign.String() { - c.emitFrameChanged() + if last != frame.CallsignOnFrequency.Callsign.String() { + c.emitFrameChanged(core.VFO1) } } +// RadioChanged implements core.RadioChangedListener. +func (c *Callinfo) RadioChanged(_ string, singleVFO bool) { + c.vfo2Enabled = !singleVFO + c.view.SetVFOEnabled(core.VFO2, c.vfo2Enabled) +} + func normalizeInput(input string) string { return strings.TrimSpace(strings.ToUpper(input)) } @@ -186,4 +198,5 @@ type nullView struct{} func (v *nullView) Show() {} func (v *nullView) Hide() {} func (v *nullView) SetPredictedExchangeFields([]core.ExchangeField) {} -func (v *nullView) ShowFrame(frame core.CallinfoFrame) {} +func (v *nullView) ShowFrame(core.VFOID, core.CallinfoFrame) {} +func (v *nullView) SetVFOEnabled(core.VFOID, bool) {} diff --git a/core/cfg/cfg.go b/core/cfg/cfg.go index 78ccff80..86893d48 100644 --- a/core/cfg/cfg.go +++ b/core/cfg/cfg.go @@ -43,6 +43,9 @@ var Default = &Data{ Type: "tci", Address: "localhost:40001", Keyer: "radio", + Options: map[string]string{ + "single_vfo": "false", + }, }, }, Keyers: []core.Keyer{ @@ -154,6 +157,13 @@ var Default = &Data{ "entry.workmode_run": "Ctrl+R", "entry.offer_qtc": "F5", "entry.request_qtc": "F6", + "entry.toggle_focused_vfo": "F8", + "entry.focus_vfo1": "F9", + "entry.focus_vfo2": "F10", + "entry.log_vfo1": "", + "entry.log_vfo2": "", + "entry.clear_vfo1": "", + "entry.clear_vfo2": "", "radio.xit_active": "Ctrl+Shift+X", "bandmap.mark": "Ctrl+M", "bandmap.goto_highest_value_spot": "Ctrl+Shift+N", diff --git a/core/core.go b/core/core.go index bef6c432..d94ddc61 100644 --- a/core/core.go +++ b/core/core.go @@ -1676,8 +1676,18 @@ type XITControl interface { SetXITActive(bool) } +type VFOID int + +const ( + VFO1 VFOID = iota + VFO2 + + VFOCount +) + type VFO interface { XITControl + Name() string Notify(any) Refresh() SetFrequency(Frequency) @@ -1686,24 +1696,50 @@ type VFO interface { SetXIT(bool, Frequency) } +type CurrentVFOListener interface { + CurrentVFOChanged(VFOID) +} + +type TXVFOListener interface { + TXVFOChanged(VFOID) +} + +type FocusChangedListener interface { + FocusChanged(VFOID) +} + type VFOFrequencyListener interface { - VFOFrequencyChanged(Frequency) + VFOFrequencyChanged(VFOID, Frequency) } type VFOBandListener interface { - VFOBandChanged(Band) + VFOBandChanged(VFOID, Band) } type VFOModeListener interface { - VFOModeChanged(Mode) + VFOModeChanged(VFOID, Mode) } type VFOXITListener interface { - VFOXITChanged(bool, Frequency) + VFOXITChanged(VFOID, bool, Frequency) } type VFOPTTListener interface { - VFOPTTChanged(bool) + VFOPTTChanged(VFOID, bool) +} + +type RadioChangedListener interface { + RadioChanged(name string, singleVFO bool) +} + +type ConnectionChangedListener interface { + ConnectionChanged(bool) +} + +type ConnectionChangedFunc func(bool) + +func (f ConnectionChangedFunc) ConnectionChanged(connected bool) { + f(connected) } type Service int @@ -1732,13 +1768,17 @@ func (f ServiceStatusListenerFunc) StatusChanged(service Service, available bool type AsyncRunner func(func()) type CallsignEnteredListener interface { - CallsignEntered(callsign string) + CallsignEntered(vfo VFOID, callsign string) } type CallsignLoggedListener interface { CallsignLogged(callsign string, frequency Frequency) } +type TransmissionStartedListener interface { + TransmissionStarted(vfo VFOID) +} + func FormatTimestamp(ts time.Time) string { return ts.UTC().Format("2006-01-02 15:04Z") } diff --git a/core/entry/entry.go b/core/entry/entry.go index 571e4300..0b7efd86 100644 --- a/core/entry/entry.go +++ b/core/entry/entry.go @@ -23,22 +23,29 @@ const ( type View interface { SetUTC(string) SetMyCall(string) - SetFrequency(core.Frequency) - SetCallsign(string) - SetBand(text string) - SetMode(text string) - SetXITActive(active bool) - SetXIT(active bool, offset core.Frequency) - SetTXState(ptt bool, parrotActive bool, parrotTimeLeft time.Duration) SetMyExchange(int, string) - SetTheirExchange(int, string) - SetActiveField(core.EntryField) - SelectText(core.EntryField, string) - SetDuplicateMarker(bool) - SetEditingMarker(bool) - ShowMessage(...any) - ClearMessage() + SetFrequency(core.VFOID, core.Frequency) + SetBand(vfo core.VFOID, text string) + SetMode(vfo core.VFOID, text string) + SetXITActive(vfo core.VFOID, active bool) + SetXIT(vfo core.VFOID, active bool, offset core.Frequency) + SetTXState(vfo core.VFOID, ptt bool, parrotActive bool, parrotTimeLeft time.Duration) + + SetCallsign(core.VFOID, string) + SetTheirExchange(vfo core.VFOID, index int, text string) + + SetSerialClaim(core.VFOID, core.QSONumber, bool) + SetActiveVFO(core.VFOID) + SetActiveField(core.VFOID, core.EntryField) + SelectText(core.VFOID, core.EntryField, string) + SetDuplicateMarker(core.VFOID, bool) + SetEditingMarker(core.VFOID, bool) + ShowMessage(core.VFOID, ...any) + ClearMessage(core.VFOID) + SetVFOEnabled(core.VFOID, bool) + SetVFOWorkmode(core.VFOID, core.Workmode) + SetTXVFO(core.VFOID) } type input struct { @@ -46,9 +53,6 @@ type input struct { theirReport string theirNumber string theirExchange []string - myReport string - myNumber string - myExchange []string band string mode string } @@ -71,6 +75,7 @@ type QSOList interface { // Keyer functionality used for QSO entry. type Keyer interface { + SendMacro(index int) SendQuestion(q string) GetText(workmode core.Workmode, index int) (string, error) SendText(text string, args ...any) @@ -80,7 +85,7 @@ type Keyer interface { // Callinfo functionality used for QSO entry. type Callinfo interface { - InputChanged(call string, band core.Band, mode core.Mode, exchange []string) + InputChanged(vfo core.VFOID, call string, band core.Band, mode core.Mode, exchange []string) } type Bandmap interface { @@ -96,28 +101,74 @@ func NewController(settings core.Settings, clock core.Clock, logbook Logbook, qs logbook: logbook, qsoList: qsoList, callinfo: new(nullCallinfo), - vfo: new(nullVFO), asyncRunner: asyncRunner, bandmap: bandmap, esmView: new(nullESMView), + vfoSwitcher: new(nullVFOSwitcher), + + vfos: make([]core.VFO, core.VFOCount), + input: make([]input, core.VFOCount), + selectedFrequency: make([]core.Frequency, core.VFOCount), + selectedBand: make([]core.Band, core.VFOCount), + selectedMode: make([]core.Mode, core.VFOCount), + activeField: make([]core.EntryField, core.VFOCount), + errorField: make([]core.EntryField, core.VFOCount), + currentCallinfoFrame: make([]core.CallinfoFrame, core.VFOCount), + claims: newSerialClaims(), + esmState: make([]core.ESMState, core.VFOCount), + esmMessage: make([]string, core.VFOCount), + esmMacroIndex: make([]int, core.VFOCount), stationCallsign: settings.Station().Callsign.String(), + vfoWorkmode: make([]core.Workmode, core.VFOCount), + } + for vfo := range len(result.vfos) { + result.vfos[vfo] = new(nullVFO) + result.activeField[vfo] = core.CallsignField } result.refreshTicker = ticker.New(clock, result.refreshUTC) result.updateExchangeFields(settings.Contest()) return result } +// VFOSwitcher is implemented by something that can command the rig to make a given VFO the current one. +type VFOSwitcher interface { + SetCurrentVFO(core.VFOID) +} + +type nullVFOSwitcher struct{} + +func (n *nullVFOSwitcher) SetCurrentVFO(core.VFOID) {} + +type editSnapshot struct { + focusedVFO core.VFOID + input input + myReport string + myNumber string + myExchange []string + claimedSerial core.QSONumber + claimSnapshot core.QSONumber + claimCommitted bool + activeField core.EntryField + errorField core.EntryField + callinfoFrame core.CallinfoFrame + esmState []core.ESMState + esmMessage []string + esmMacroIndex []int +} + type Controller struct { - clock core.Clock - view View - logbook Logbook - qsoList QSOList - keyer Keyer - callinfo Callinfo - vfo core.VFO - bandmap Bandmap - esmView ESMView + clock core.Clock + view View + logbook Logbook + qsoList QSOList + keyer Keyer + callinfo Callinfo + vfos []core.VFO + vfoSwitcher VFOSwitcher + ignoreVFOChange bool + bandmap Bandmap + esmView ESMView asyncRunner core.AsyncRunner refreshTicker *ticker.Ticker @@ -125,6 +176,7 @@ type Controller struct { stationCallsign string workmode core.Workmode + vfoWorkmode []core.Workmode myExchangeFields []core.ExchangeField theirExchangeFields []core.ExchangeField @@ -135,16 +187,24 @@ type Controller struct { generateSerialExchange bool generateReport bool defaultExchangeValues []string - currentCallinfoFrame core.CallinfoFrame - - input input - activeField core.EntryField - errorField core.EntryField - selectedFrequency core.Frequency - selectedBand core.Band - selectedMode core.Mode + currentCallinfoFrame []core.CallinfoFrame + + input []input + myReport string + myNumber string + myExchange []string + focusedVFO core.VFOID + txVFO core.VFOID + vfo2Enabled bool + activeField []core.EntryField + errorField []core.EntryField + selectedFrequency []core.Frequency + selectedBand []core.Band + selectedMode []core.Mode + claims SerialClaims editing bool editQSO core.QSO + editSnapshot *editSnapshot ignoreQSOSelection bool ignoreFrequencyJump bool @@ -152,19 +212,28 @@ type Controller struct { parrotActive bool parrotTimeLeft time.Duration - esmEnabled bool - esmState core.ESMState - esmMessage string + esmEnabled bool + esmState []core.ESMState + esmMessage []string + esmMacroIndex []int } func (c *Controller) Notify(listener any) { c.listeners = append(c.listeners, listener) } +func (c *Controller) emitFocusChanged(vfo core.VFOID) { + for _, l := range c.listeners { + if listener, ok := l.(core.FocusChangedListener); ok { + listener.FocusChanged(vfo) + } + } +} + func (c *Controller) emitCallsignEntered(callsign string) { for _, l := range c.listeners { if listener, ok := l.(core.CallsignEnteredListener); ok { - listener.CallsignEntered(callsign) + listener.CallsignEntered(c.focusedVFO, callsign) } } } @@ -186,13 +255,22 @@ func (c *Controller) SetView(view View) { } c.view = view + c.view.SetVFOEnabled(core.VFO2, c.vfo2Enabled) + c.view.SetActiveVFO(c.focusedVFO) + for vfo := range core.VFOCount { + c.view.SetVFOWorkmode(core.VFOID(vfo), c.vfoWorkmode[vfo]) + } c.Clear() c.refreshUTC() } func (c *Controller) LogbookLoaded() { - c.selectedBand = c.logbook.LastBand() - c.selectedMode = c.logbook.LastMode() + lastBand := c.logbook.LastBand() + lastMode := c.logbook.LastMode() + for vfo := range core.VFOCount { + c.selectedBand[vfo] = lastBand + c.selectedMode[vfo] = lastMode + } c.Clear() c.showInput() } @@ -205,29 +283,22 @@ func (c *Controller) SetCallinfo(callinfo Callinfo) { c.callinfo = callinfo } -func (c *Controller) notifyCallinfoInputChanged(call string, band core.Band, mode core.Mode, exchange []string) { - if c.callinfo == nil { - return - } - c.callinfo.InputChanged(call, band, mode, exchange) -} - -func (c *Controller) CallinfoFrameChanged(frame core.CallinfoFrame) { - c.currentCallinfoFrame = frame +func (c *Controller) CallinfoFrameChanged(vfo core.VFOID, frame core.CallinfoFrame) { + c.currentCallinfoFrame[vfo] = frame // TODO what do we need to update here? } -func (c *Controller) SetVFO(vfo core.VFO) { +func (c *Controller) SetVFO(id core.VFOID, vfo core.VFO) { if vfo == nil { - c.vfo = new(nullVFO) + c.vfos[id] = new(nullVFO) } else { - c.vfo = vfo + c.vfos[id] = vfo } vfo.Notify(c) } func (c *Controller) GotoNextField() core.EntryField { - switch c.activeField { + switch c.activeField[c.focusedVFO] { case core.CallsignField: c.leaveCallsignField() } @@ -250,51 +321,51 @@ func (c *Controller) GotoNextField() core.EntryField { } } - nextField := transitions[c.activeField] + nextField := transitions[c.activeField[c.focusedVFO]] if nextField == "" { nextField = core.CallsignField } c.SetActiveField(nextField) - c.view.SetActiveField(c.activeField) - return c.activeField + c.view.SetActiveField(c.focusedVFO, c.activeField[c.focusedVFO]) + return c.activeField[c.focusedVFO] } func (c *Controller) GotoNextPlaceholder() { c.SetActiveField(core.CallsignField) - c.view.SetActiveField(c.activeField) - c.view.SelectText(c.activeField, core.FilterPlaceholder) + c.view.SetActiveField(c.focusedVFO, c.activeField[c.focusedVFO]) + c.view.SelectText(c.focusedVFO, c.activeField[c.focusedVFO], core.FilterPlaceholder) } func (c *Controller) leaveCallsignField() { - callsign, err := core.ParseCallsign(c.input.callsign) + callsign, err := core.ParseCallsign(c.input[c.focusedVFO].callsign) if err != nil { fmt.Println(err) return } - if len(c.input.theirExchange) == len(c.currentCallinfoFrame.PredictedExchange) { + if len(c.input[c.focusedVFO].theirExchange) == len(c.currentCallinfoFrame[c.focusedVFO].PredictedExchange) { for i, field := range c.theirExchangeFields { if !c.isPredictable(field.Field) { continue } - if c.input.theirExchange[i] == "" { - c.setTheirExchangePrediction(i, c.currentCallinfoFrame.PredictedExchange[i]) + if c.input[c.focusedVFO].theirExchange[i] == "" { + c.setTheirExchangePrediction(i, c.currentCallinfoFrame[c.focusedVFO].PredictedExchange[i]) } } } - _, found := c.isDuplicate(callsign) + _, found := c.isDuplicate(c.focusedVFO, callsign) if !found { - c.view.SetDuplicateMarker(false) + c.view.SetDuplicateMarker(c.focusedVFO, false) return } if c.editing { - c.view.SetDuplicateMarker(c.editQSO.Callsign != callsign) + c.view.SetDuplicateMarker(c.focusedVFO, c.editQSO.Callsign != callsign) return } - c.view.SetDuplicateMarker(true) + c.view.SetDuplicateMarker(c.focusedVFO, true) } // isPredictable returns true if the exchange for the given field is predictable. @@ -311,14 +382,14 @@ func (c *Controller) isPredictable(field core.EntryField) bool { } func (c *Controller) RefreshPrediction() { - c.notifyCallinfoInputChanged(c.input.callsign, c.selectedBand, c.selectedMode, []string{}) + c.callinfoInputChanged(c.focusedVFO, c.input[c.focusedVFO].callsign, c.selectedBand[c.focusedVFO], c.selectedMode[c.focusedVFO], []string{}) - if len(c.input.theirExchange) == len(c.currentCallinfoFrame.PredictedExchange) { + if len(c.input[c.focusedVFO].theirExchange) == len(c.currentCallinfoFrame[c.focusedVFO].PredictedExchange) { for i, field := range c.theirExchangeFields { if !c.isPredictable(field.Field) { continue } - c.setTheirExchangePrediction(i, c.currentCallinfoFrame.PredictedExchange[i]) + c.setTheirExchangePrediction(i, c.currentCallinfoFrame[c.focusedVFO].PredictedExchange[i]) } } } @@ -339,19 +410,19 @@ func (c *Controller) RefreshView() { } func (c *Controller) showQSO(qso core.QSO) { - c.input.callsign = qso.Callsign.String() - c.input.theirReport = qso.TheirReport.String() - c.input.theirNumber = qso.TheirNumber.String() - c.input.theirExchange = ensureLen(qso.TheirExchange, len(c.theirExchangeFields)) - c.input.myReport = qso.MyReport.String() - c.input.myNumber = qso.MyNumber.String() - c.input.myExchange = ensureLen(qso.MyExchange, len(c.myExchangeFields)) - c.input.band = qso.Band.String() - c.input.mode = qso.Mode.String() - - c.selectedFrequency = qso.Frequency - c.selectedBand = qso.Band - c.selectedMode = qso.Mode + c.input[core.VFO1].callsign = qso.Callsign.String() + c.input[core.VFO1].theirReport = qso.TheirReport.String() + c.input[core.VFO1].theirNumber = qso.TheirNumber.String() + c.input[core.VFO1].theirExchange = ensureLen(qso.TheirExchange, len(c.theirExchangeFields)) + c.myReport = qso.MyReport.String() + c.myNumber = qso.MyNumber.String() + c.myExchange = ensureLen(qso.MyExchange, len(c.myExchangeFields)) + c.input[core.VFO1].band = qso.Band.String() + c.input[core.VFO1].mode = qso.Mode.String() + + c.selectedFrequency[core.VFO1] = qso.Frequency + c.selectedBand[core.VFO1] = qso.Band + c.selectedMode[core.VFO1] = qso.Mode c.showInput() } @@ -367,16 +438,20 @@ func ensureLen(a []string, l int) []string { } func (c *Controller) showInput() { - c.view.SetCallsign(c.input.callsign) - for i, value := range c.input.theirExchange { - c.view.SetTheirExchange(i+1, value) + for vfo := range core.VFOCount { + v := core.VFOID(vfo) + c.view.SetCallsign(v, c.input[vfo].callsign) + for i, value := range c.input[vfo].theirExchange { + c.view.SetTheirExchange(v, i+1, value) + } + c.view.SetFrequency(v, c.selectedFrequency[vfo]) + c.view.SetBand(v, c.input[vfo].band) + c.view.SetMode(v, c.input[vfo].mode) } - for i, value := range c.input.myExchange { + // myExchange remains shared (single row in the UI). + for i, value := range c.myExchange { c.view.SetMyExchange(i+1, value) } - c.view.SetFrequency(c.selectedFrequency) - c.view.SetBand(c.input.band) - c.view.SetMode(c.input.mode) } // setTheirExchangePrediction replaces the value of the given field with the given predicted value, @@ -385,29 +460,247 @@ func (c *Controller) setTheirExchangePrediction(i int, value string) { if value == "" { return } - c.input.theirExchange[i] = value - c.view.SetTheirExchange(i+1, value) + c.input[c.focusedVFO].theirExchange[i] = value + c.view.SetTheirExchange(c.focusedVFO, i+1, value) } -func (c *Controller) isDuplicate(callsign core.Callsign) (core.QSO, bool) { - qsos := c.logbook.FindDuplicateQSOs(callsign, c.selectedBand, c.selectedMode) +func (c *Controller) isDuplicate(vfo core.VFOID, callsign core.Callsign) (core.QSO, bool) { + qsos := c.logbook.FindDuplicateQSOs(callsign, c.selectedBand[vfo], c.selectedMode[vfo]) if len(qsos) == 0 { return core.QSO{}, false } return qsos[len(qsos)-1], true } +// SetVFOSwitcher wires the focus actions to a backend that can command the rig. +// If never called, focus actions still update internal state but do not retune the rig. +func (c *Controller) SetVFOSwitcher(switcher VFOSwitcher) { + if switcher == nil { + c.vfoSwitcher = new(nullVFOSwitcher) + return + } + c.vfoSwitcher = switcher +} + +func (c *Controller) CurrentVFOChanged(vfo core.VFOID) { + if c.ignoreVFOChange { + return + } + if vfo == core.VFO2 && !c.vfo2Enabled { + return + } + if c.focusedVFO == vfo { + return + } + c.focusedVFO = vfo + c.emitFocusChanged(c.focusedVFO) + c.refreshMyNumberInputs() + c.view.SetActiveVFO(c.focusedVFO) + c.view.SetActiveField(c.focusedVFO, c.activeField[c.focusedVFO]) +} + +// SetFocusedVFO is the single funnel for changing focused VFO. +func (c *Controller) SetFocusedVFO(vfo core.VFOID) { + if vfo == core.VFO2 && !c.vfo2Enabled { + return + } + if c.focusedVFO == vfo { + return + } + c.focusedVFO = vfo + c.ignoreVFOChange = true + c.vfoSwitcher.SetCurrentVFO(c.focusedVFO) + c.ignoreVFOChange = false + c.emitFocusChanged(c.focusedVFO) + c.refreshMyNumberInputs() + c.view.SetActiveVFO(c.focusedVFO) + c.view.SetActiveField(c.focusedVFO, c.activeField[c.focusedVFO]) +} + +// setFocusedVFOSilent updates focusedVFO without commanding the rig. Used by edit mode. +func (c *Controller) setFocusedVFOSilent(vfo core.VFOID) { + if c.focusedVFO == vfo { + return + } + c.focusedVFO = vfo +} + +// ToggleFocusedVFO flips between VFO1 and VFO2. No-op if VFO2 is disabled. +func (c *Controller) ToggleFocusedVFO() { + if !c.vfo2Enabled { + c.SetFocusedVFO(core.VFO1) + return + } + if c.focusedVFO == core.VFO1 { + c.SetFocusedVFO(core.VFO2) + } else { + c.SetFocusedVFO(core.VFO1) + } +} + +// FocusVFO1 sets the focused VFO to VFO1. +func (c *Controller) FocusVFO1() { + c.SetFocusedVFO(core.VFO1) +} + +// FocusVFO2 sets the focused VFO to VFO2. No-op if VFO2 is disabled. +func (c *Controller) FocusVFO2() { + c.SetFocusedVFO(core.VFO2) +} + +// LogVFO synthesises focus on vfo and then logs the QSO from that row. +func (c *Controller) LogVFO(vfo core.VFOID) { + c.SetFocusedVFO(vfo) + c.Log() +} + +// ClearVFO synthesises focus on vfo and then clears that row. +func (c *Controller) ClearVFO(vfo core.VFOID) { + c.SetFocusedVFO(vfo) + c.Clear() +} + +// RadioChanged toggles VFO2 availability based on the connected radio's single-VFO flag. +// Implements core.RadioChangedListener. +func (c *Controller) RadioChanged(_ string, singleVFO bool) { + c.vfo2Enabled = !singleVFO + if !c.vfo2Enabled && c.focusedVFO == core.VFO2 { + c.releaseSerialClaimFor(core.VFO2) + c.input[core.VFO2] = input{} + c.setFocusedVFOSilent(core.VFO1) + } + c.view.SetVFOEnabled(core.VFO2, c.vfo2Enabled) +} + +// canTransmit reports whether the keyer is currently allowed to transmit. False during edit mode. +func (c *Controller) canTransmit() bool { + return !c.editing +} + +// SetTXVFO commands the rig to switch the TX VFO, suppressing any rig-side +// VFO change echo that might arrive from hamlib polling. + +// claimSerialFor reserves the next unclaimed serial for the given VFO if it has none yet. +// Sticky: subsequent calls while a claim exists are no-ops. +func (c *Controller) claimSerialFor(vfo core.VFOID) { + c.claims.ClaimNext(vfo, c.logbook.NextQSONumber()) + c.refreshMyNumberInputs() +} + +// releaseSerialClaimFor frees the claim slot for vfo. +// If the serial was committed (sent over air), it is burned and cannot be reused. +func (c *Controller) releaseSerialClaimFor(vfo core.VFOID) { + c.claims.Release(vfo) + c.refreshMyNumberInputs() +} + +// SerialSent is called when the keyer transmits a message containing a serial number. +// It claims the serial for the focused VFO (if not already claimed) and commits it. +func (c *Controller) SerialSent() { + c.claimSerialFor(c.focusedVFO) + c.claims.Commit(c.focusedVFO) + c.refreshMyNumberInputs() +} + +// IsSerialCommitted reports whether the serial claim for vfo has been committed (sent over air). +func (c *Controller) IsSerialCommitted(vfo core.VFOID) bool { + return c.claims.committed[vfo] +} + +// refreshMyNumberInputs syncs myNumber (and exchange serial slot) with the +// current displayed serial value for the focused VFO. Reads NextQSONumber once. +// Also pushes each VFO's serial claim to the view. +func (c *Controller) refreshMyNumberInputs() { + base := c.logbook.NextQSONumber() + c.writeMyNumberInput(c.claims.DisplayedSerial(c.focusedVFO, base)) + for vfo := range core.VFOCount { + c.view.SetSerialClaim(core.VFOID(vfo), c.claims.claimed[vfo], c.claims.committed[vfo]) + } +} + +func (c *Controller) refreshMyNumberInput(vfo core.VFOID) { + base := c.logbook.NextQSONumber() + c.writeMyNumberInput(c.claims.DisplayedSerial(vfo, base)) +} + +func (c *Controller) writeMyNumberInput(serial core.QSONumber) { + value := serial.String() + c.myNumber = value + i := c.myNumberExchangeField.Field.ExchangeIndex() - 1 + if i < 0 || !c.generateSerialExchange { + return + } + if i >= len(c.myExchange) { + return + } + c.myExchange[i] = value +} + +// enterEditMode snapshots VFO1's state, force-focuses VFO1 silently, marks editing, +// and claims the QSO's existing serial for VFO1 for the duration of the edit. +func (c *Controller) enterEditMode(qso core.QSO) { + c.editSnapshot = &editSnapshot{ + focusedVFO: c.focusedVFO, + input: c.input[core.VFO1], + myReport: c.myReport, + myNumber: c.myNumber, + myExchange: append([]string(nil), c.myExchange...), + claimedSerial: c.claims.claimed[core.VFO1], + claimSnapshot: c.claims.snapshot[core.VFO1], + claimCommitted: c.claims.committed[core.VFO1], + activeField: c.activeField[core.VFO1], + errorField: c.errorField[core.VFO1], + callinfoFrame: c.currentCallinfoFrame[core.VFO1], + esmState: append([]core.ESMState(nil), c.esmState...), + esmMessage: append([]string(nil), c.esmMessage...), + esmMacroIndex: append([]int(nil), c.esmMacroIndex...), + } + c.setFocusedVFOSilent(core.VFO1) + c.editing = true + c.editQSO = qso + c.claims.claimed[core.VFO1] = qso.MyNumber + c.claims.snapshot[core.VFO1] = c.logbook.NextQSONumber() + c.claims.committed[core.VFO1] = false + // TODO step 6: c.view.SetVFOEnabled(core.VFO2, false) +} + +// leaveEditMode restores the pre-edit state captured by enterEditMode. No-op if not editing. +func (c *Controller) leaveEditMode() { + if c.editSnapshot == nil { + return + } + snap := c.editSnapshot + c.input[core.VFO1] = snap.input + c.myReport = snap.myReport + c.myNumber = snap.myNumber + c.myExchange = append(c.myExchange[:0], snap.myExchange...) + c.claims.claimed[core.VFO1] = snap.claimedSerial + c.claims.snapshot[core.VFO1] = snap.claimSnapshot + c.claims.committed[core.VFO1] = snap.claimCommitted + c.activeField[core.VFO1] = snap.activeField + c.errorField[core.VFO1] = snap.errorField + c.currentCallinfoFrame[core.VFO1] = snap.callinfoFrame + copy(c.esmState, snap.esmState) + copy(c.esmMessage, snap.esmMessage) + copy(c.esmMacroIndex, snap.esmMacroIndex) + c.editing = false + c.editQSO = core.QSO{} + c.editSnapshot = nil + c.setFocusedVFOSilent(snap.focusedVFO) + // TODO step 6: c.view.SetVFOEnabled(core.VFO2, true) +} + func (c *Controller) SetActiveField(field core.EntryField) { - c.activeField = field + c.activeField[c.focusedVFO] = field c.updateESM() } func (c *Controller) SelectMatch(index int) { - c.selectCallsign(c.currentCallinfoFrame.GetMatch(index)) + c.selectCallsign(c.currentCallinfoFrame[c.focusedVFO].GetMatch(index)) } func (c *Controller) SelectBestMatchOnFrequency() { - c.selectCallsign(c.currentCallinfoFrame.BestMatchOnFrequency().Callsign.String()) + c.selectCallsign(c.currentCallinfoFrame[c.focusedVFO].BestMatchOnFrequency().Callsign.String()) } func (c *Controller) selectCallsign(callsign string) { @@ -416,30 +709,30 @@ func (c *Controller) selectCallsign(callsign string) { } c.SetActiveField(core.CallsignField) c.Enter(callsign) - c.view.SetCallsign(c.input.callsign) - c.view.SetActiveField(c.activeField) + c.view.SetCallsign(c.focusedVFO, c.input[c.focusedVFO].callsign) + c.view.SetActiveField(c.focusedVFO, c.activeField[c.focusedVFO]) } func (c *Controller) Enter(text string) { - switch c.activeField { + switch c.activeField[c.focusedVFO] { case core.CallsignField: - c.input.callsign = text + c.input[c.focusedVFO].callsign = text c.enterCallsign(text) case core.BandField: - c.input.band = text + c.input[c.focusedVFO].band = text c.bandSelected(text) case core.ModeField: - c.input.mode = text + c.input[c.focusedVFO].mode = text c.modeSelected(text) } - i := c.activeField.ExchangeIndex() - 1 + i := c.activeField[c.focusedVFO].ExchangeIndex() - 1 switch { - case c.activeField.IsMyExchange(): - c.input.myExchange[i] = text - case c.activeField.IsTheirExchange(): - c.input.theirExchange[i] = text - c.enterTheirExchange(c.activeField) + case c.activeField[c.focusedVFO].IsMyExchange(): + c.myExchange[i] = text + case c.activeField[c.focusedVFO].IsTheirExchange(): + c.input[c.focusedVFO].theirExchange[i] = text + c.enterTheirExchange(c.activeField[c.focusedVFO]) } c.updateESM() @@ -447,69 +740,103 @@ func (c *Controller) Enter(text string) { func (c *Controller) frequencyEntered(frequency core.Frequency) { // log.Printf("Frequency selected: %s", frequency) - c.vfo.SetFrequency(frequency) + c.vfos[c.focusedVFO].SetFrequency(frequency) } func (c *Controller) bandEntered(band core.Band) { - c.input.band = band.String() - c.vfo.SetBand(band) + c.input[c.focusedVFO].band = band.String() + c.vfos[c.focusedVFO].SetBand(band) } func (c *Controller) SetXITActive(active bool) { - c.vfo.SetXITActive(active) - c.view.SetActiveField(c.activeField) + c.vfos[core.VFO1].SetXITActive(active) + c.view.SetActiveField(c.focusedVFO, c.activeField[c.focusedVFO]) } -func (c *Controller) VFOFrequencyChanged(frequency core.Frequency) { - if c.editing { - return - } - if c.selectedFrequency == frequency { - return - } - jump := math.Abs(float64(c.selectedFrequency-frequency)) > float64(jumpThreshold) - c.selectedFrequency = frequency +func (c *Controller) VFOFrequencyChanged(vfo core.VFOID, frequency core.Frequency) { + c.asyncRunner(func() { + if vfo == core.VFO1 && c.editing { + return + } + if c.selectedFrequency[vfo] == frequency { + return + } + jump := math.Abs(float64(c.selectedFrequency[vfo]-frequency)) > float64(jumpThreshold) + c.selectedFrequency[vfo] = frequency + + c.view.SetFrequency(vfo, frequency) - c.view.SetFrequency(frequency) + if jump && !c.ignoreFrequencyJump { + c.clearInput(vfo) + } + c.ignoreFrequencyJump = false + }) +} + +// clearInput resets the input for the given VFO without changing the focused VFO. +func (c *Controller) clearInput(vfo core.VFOID) { + c.claims.Release(vfo) + c.vfos[vfo].Refresh() + c.activeField[vfo] = core.CallsignField - if jump && !c.ignoreFrequencyJump { - c.Clear() - c.view.SetActiveField(c.activeField) + lastExchange := c.logbook.LastExchange() + c.fillExchangeDefaults(vfo, lastExchange) + + c.refreshMyNumberInputs() + c.showInput() + c.view.SetFrequency(vfo, c.selectedFrequency[vfo]) + c.view.SetDuplicateMarker(vfo, false) + c.view.ClearMessage(vfo) + c.callinfoInputChanged(vfo, "", core.NoBand, core.NoMode, []string{}) + + // Only push UI focus if this is focused VFO. For non-focused VFOs + // we reset internal state only — SetActiveField would steal focus. + if vfo == c.focusedVFO { + c.view.SetActiveField(vfo, c.activeField[vfo]) + } +} + +// callinfoInputChanged notifies the callinfo subsystem about input changes on a specific VFO. +func (c *Controller) callinfoInputChanged(vfo core.VFOID, call string, band core.Band, mode core.Mode, exchange []string) { + if c.callinfo == nil { + return } - c.ignoreFrequencyJump = false + c.callinfo.InputChanged(vfo, call, band, mode, exchange) } func (c *Controller) bandSelected(s string) { if band, err := parse.Band(s); err == nil { // log.Printf("Band selected: %v", band) - c.selectedBand = band - c.vfo.SetBand(band) - c.enterCallsign(c.input.callsign) + c.selectedBand[c.focusedVFO] = band + c.vfos[c.focusedVFO].SetBand(band) + c.enterCallsign(c.input[c.focusedVFO].callsign) } } -func (c *Controller) VFOBandChanged(band core.Band) { - if c.editing { - return - } - if band == core.NoBand || band == c.selectedBand { - return - } - c.selectedBand = band - c.input.band = c.selectedBand.String() - c.view.SetBand(c.input.band) +func (c *Controller) VFOBandChanged(vfo core.VFOID, band core.Band) { + c.asyncRunner(func() { + if vfo == core.VFO1 && c.editing { + return + } + if band == core.NoBand || band == c.selectedBand[vfo] { + return + } + c.selectedBand[vfo] = band + c.input[vfo].band = band.String() + + c.view.SetBand(vfo, c.input[vfo].band) + }) } func (c *Controller) modeSelected(s string) { if mode, err := parse.Mode(s); err == nil { - log.Printf("Mode selected: %v", mode) - c.selectedMode = mode - - c.vfo.SetMode(mode) + // log.Printf("Mode selected: %v", mode) + c.selectedMode[c.focusedVFO] = mode + c.vfos[c.focusedVFO].SetMode(mode) if c.generateReport { c.generateReportForMode(mode) } - c.enterCallsign(c.input.callsign) + c.enterCallsign(c.input[c.focusedVFO].callsign) } } @@ -517,15 +844,15 @@ func (c *Controller) generateReportForMode(mode core.Mode) { generatedReport := defaultReportForMode(mode) myIndex := c.myReportExchangeField.Field.ExchangeIndex() if myIndex > 0 { - c.input.myReport = generatedReport - c.input.myExchange[myIndex-1] = generatedReport - c.view.SetMyExchange(myIndex, c.input.myReport) + c.myReport = generatedReport + c.myExchange[myIndex-1] = generatedReport + c.view.SetMyExchange(myIndex, c.myReport) } theirIndex := c.theirReportExchangeField.Field.ExchangeIndex() if theirIndex > 0 { - c.input.theirReport = generatedReport - c.input.theirExchange[theirIndex-1] = generatedReport - c.view.SetTheirExchange(theirIndex, c.input.myReport) + c.input[c.focusedVFO].theirReport = generatedReport + c.input[c.focusedVFO].theirExchange[theirIndex-1] = generatedReport + c.view.SetTheirExchange(c.focusedVFO, theirIndex, c.myReport) } } @@ -540,36 +867,57 @@ func defaultReportForMode(mode core.Mode) string { } } -func (c *Controller) VFOModeChanged(mode core.Mode) { - if c.editing { - return - } - if mode == core.NoMode || mode == c.selectedMode { - return - } - c.selectedMode = mode - c.input.mode = c.selectedMode.String() - c.view.SetMode(c.input.mode) +func (c *Controller) VFOModeChanged(vfo core.VFOID, mode core.Mode) { + c.asyncRunner(func() { + if vfo == core.VFO1 && c.editing { + return + } + if mode == core.NoMode || mode == c.selectedMode[vfo] { + return + } + c.selectedMode[vfo] = mode + c.input[vfo].mode = mode.String() + + c.view.SetMode(vfo, c.input[vfo].mode) + }) } -func (c *Controller) VFOXITChanged(active bool, offset core.Frequency) { - c.view.SetXIT(active, offset) +func (c *Controller) VFOXITChanged(vfo core.VFOID, active bool, offset core.Frequency) { + c.asyncRunner(func() { + c.view.SetXIT(vfo, active, offset) + }) } func (c *Controller) XITActiveChanged(active bool) { - c.view.SetXITActive(active) + // TODO: add VFO parameter to XITActiveChanged + c.asyncRunner(func() { + c.view.SetXITActive(core.VFO1, active) + }) } -func (c *Controller) VFOPTTChanged(active bool) { - c.ptt = active - c.updateTXState() +func (c *Controller) VFOPTTChanged(vfo core.VFOID, active bool) { + c.asyncRunner(func() { + if vfo != core.VFO1 { + c.view.SetTXState(vfo, active, false, 0) + return + } + c.ptt = active + c.updateTXState() + }) +} + +func (c *Controller) TXVFOChanged(vfo core.VFOID) { + c.asyncRunner(func() { + c.txVFO = vfo + c.view.SetTXVFO(vfo) + }) } func (c *Controller) ParrotActive(active bool) { c.parrotActive = active c.updateTXState() if active { - c.Clear() + c.clearInput(core.VFO1) } } @@ -579,19 +927,22 @@ func (c *Controller) ParrotTimeLeft(timeLeft time.Duration) { } func (c *Controller) updateTXState() { - c.view.SetTXState(c.ptt, c.parrotActive, c.parrotTimeLeft) + c.view.SetTXState(core.VFO1, c.ptt, c.parrotActive, c.parrotTimeLeft) } func (c *Controller) SendQuestion() { if c.keyer == nil { return } + if !c.canTransmit() { + return + } switch { - case c.activeField.IsTheirExchange(): + case c.activeField[c.focusedVFO].IsTheirExchange(): c.keyer.SendQuestion("nr") default: - c.keyer.SendQuestion(c.input.callsign) + c.keyer.SendQuestion(c.input[c.focusedVFO].callsign) } } @@ -599,22 +950,31 @@ func (c *Controller) RepeatLastTransmission() { if c.keyer == nil { return } + if !c.canTransmit() { + return + } + // do not switch the VFO here, we want to explicitly stay on the same VFO that was used for the last transmission c.keyer.Repeat() } func (c *Controller) enterCallsign(s string) { - c.emitCallsignEntered(c.input.callsign) - c.notifyCallinfoInputChanged(c.input.callsign, c.selectedBand, c.selectedMode, c.input.theirExchange) + c.emitCallsignEntered(c.input[c.focusedVFO].callsign) + c.callinfoInputChanged(c.focusedVFO, c.input[c.focusedVFO].callsign, c.selectedBand[c.focusedVFO], c.selectedMode[c.focusedVFO], c.input[c.focusedVFO].theirExchange) callsign, err := core.ParseCallsign(s) if err != nil { return } - qso, found := c.isDuplicate(callsign) + // Sticky per-keystroke serial claim. Skip while editing — editQSO's serial owns the slot. + if !c.editing { + c.claimSerialFor(c.focusedVFO) + } + + qso, found := c.isDuplicate(c.focusedVFO, callsign) if !found { - c.view.ClearMessage() + c.view.ClearMessage(c.focusedVFO) return } @@ -625,7 +985,7 @@ func (c *Controller) enterTheirExchange(field core.EntryField) { if c.callinfo == nil { return } - c.notifyCallinfoInputChanged(c.input.callsign, c.selectedBand, c.selectedMode, c.input.theirExchange) + c.callinfoInputChanged(c.focusedVFO, c.input[c.focusedVFO].callsign, c.selectedBand[c.focusedVFO], c.selectedMode[c.focusedVFO], c.input[c.focusedVFO].theirExchange) c.clearErrorOnField(field) } @@ -635,20 +995,19 @@ func (c *Controller) QSOSelected(qso core.QSO) { } log.Printf("QSO selected: %v", qso) - c.editing = true - c.editQSO = qso + c.enterEditMode(qso) c.showQSO(qso) - c.view.SetActiveField(core.CallsignField) - c.view.SetEditingMarker(true) - c.notifyCallinfoInputChanged(qso.Callsign.String(), qso.Band, qso.Mode, qso.TheirExchange) + c.view.SetActiveField(c.focusedVFO, core.CallsignField) + c.view.SetEditingMarker(core.VFO1, true) + c.callinfoInputChanged(c.focusedVFO, qso.Callsign.String(), qso.Band, qso.Mode, qso.TheirExchange) } func (c *Controller) EnterPressed() { if c.parseCallsignCommand() { - c.input.callsign = "" - c.enterCallsign(c.input.callsign) - c.view.SetCallsign(c.input.callsign) + c.input[c.focusedVFO].callsign = "" + c.enterCallsign(c.input[c.focusedVFO].callsign) + c.view.SetCallsign(c.focusedVFO, c.input[c.focusedVFO].callsign) return } @@ -660,9 +1019,9 @@ func (c *Controller) EnterPressed() { } func (c *Controller) CurrentQSOState() (core.Callsign, core.QSODataState) { - callEmpty := (c.input.callsign == "") + callEmpty := (c.input[c.focusedVFO].callsign == "") - call, err := core.ParseCallsign(c.input.callsign) + call, err := core.ParseCallsign(c.input[c.focusedVFO].callsign) callOK := (err == nil) theirExchange := make([]string, len(c.theirExchangeFields)) @@ -677,7 +1036,7 @@ func (c *Controller) CurrentQSOState() (core.Callsign, core.QSODataState) { case callOK && exchangeOK: return call, core.QSODataValid default: - log.Printf("invalid QSO state: %s, %+v", c.input.callsign, c.input.theirExchange) + log.Printf("invalid QSO state: %s, %+v", c.input[c.focusedVFO].callsign, c.input[c.focusedVFO].theirExchange) return core.NoCallsign, core.QSODataEmpty } } @@ -691,21 +1050,21 @@ func (c *Controller) Log() { qso.Time = c.clock.Now() } - qso.Callsign, err = core.ParseCallsign(c.input.callsign) + qso.Callsign, err = core.ParseCallsign(c.input[c.focusedVFO].callsign) if err != nil { c.showErrorOnField(err, core.CallsignField) return } - qso.Frequency = c.selectedFrequency + qso.Frequency = c.selectedFrequency[c.focusedVFO] - qso.Band, err = parse.Band(c.input.band) + qso.Band, err = parse.Band(c.input[c.focusedVFO].band) if err != nil { c.showErrorOnField(err, core.BandField) return } - qso.Mode, err = parse.Mode(c.input.mode) + qso.Mode, err = parse.Mode(c.input[c.focusedVFO].mode) if err != nil { c.showErrorOnField(err, core.ModeField) return @@ -720,13 +1079,13 @@ func (c *Controller) Log() { } // handle my exchange - myNumber, err := strconv.Atoi(c.input.myNumber) + myNumber, err := strconv.Atoi(c.myNumber) if err == nil { qso.MyNumber = core.QSONumber(myNumber) } qso.MyExchange = make([]string, len(c.myExchangeFields)) for i, field := range c.myExchangeFields { - value := c.input.myExchange[i] + value := c.myExchange[i] qso.MyExchange[i] = value // TODO parse the value using the conval validators and show an error on the field @@ -757,6 +1116,9 @@ func (c *Controller) Log() { c.logbook.AddQSO(qso) } + // NextQSONumber may have advanced; refresh the other VFO's serial preview. + c.refreshMyNumberInputs() + c.emitCallsignLogged(qso.Callsign.String(), qso.Frequency) if c.workmode == core.SearchPounce { @@ -775,21 +1137,21 @@ func (c *Controller) Log() { } func (c *Controller) parseCallsignCommand() bool { - if c.activeField != core.CallsignField { + if c.activeField[c.focusedVFO] != core.CallsignField { return false } - if f, ok := parseKilohertz(c.input.callsign); ok { + if f, ok := parseKilohertz(c.input[c.focusedVFO].callsign); ok { c.frequencyEntered(f) return true } - if b, err := parse.Band(c.input.callsign); err == nil { + if b, err := parse.Band(c.input[c.focusedVFO].callsign); err == nil { c.bandEntered(b) return true } - if call, ok := parseBandmapCallsign(c.input.callsign); ok { + if call, ok := parseBandmapCallsign(c.input[c.focusedVFO].callsign); ok { c.bandmap.SelectByCallsign(call) return true } @@ -822,7 +1184,7 @@ func parseBandmapCallsign(s string) (core.Callsign, bool) { // The arguments may be nil, this can be used to just validate the input. func (c *Controller) parseTheirExchange(theirExchange []string, theirReport *core.RST, theirNumber *core.QSONumber) (core.ExchangeField, error) { for i, field := range c.theirExchangeFields { - value := c.input.theirExchange[i] + value := c.input[c.focusedVFO].theirExchange[i] if value == "" && !field.EmptyAllowed { return field, fmt.Errorf("%s is missing", field.Short) // TODO use field.Name } @@ -862,8 +1224,8 @@ func (c *Controller) parseTheirExchange(theirExchange []string, theirReport *cor return field, err } default: - if len(c.currentCallinfoFrame.PredictedExchange) == len(theirExchange) && len(theirExchange) > i && theirExchange[i] == "" { - c.setTheirExchangePrediction(i, c.currentCallinfoFrame.PredictedExchange[i]) + if len(c.currentCallinfoFrame[c.focusedVFO].PredictedExchange) == len(theirExchange) && len(theirExchange) > i && theirExchange[i] == "" { + c.setTheirExchangePrediction(i, c.currentCallinfoFrame[c.focusedVFO].PredictedExchange[i]) return field, fmt.Errorf("check their exchange") } } @@ -873,94 +1235,119 @@ func (c *Controller) parseTheirExchange(theirExchange []string, theirReport *cor func (c *Controller) showErrorOnField(err error, field core.EntryField) { c.SetActiveField(field) - c.errorField = field - c.view.SetActiveField(c.activeField) - c.view.ShowMessage(err) + c.errorField[c.focusedVFO] = field + c.view.SetActiveField(c.focusedVFO, c.activeField[c.focusedVFO]) + c.view.ShowMessage(c.focusedVFO, err) } func (c *Controller) clearErrorOnField(field core.EntryField) { - if c.errorField != field { + if c.errorField[c.focusedVFO] != field { return } - c.view.ClearMessage() + c.view.ClearMessage(c.focusedVFO) } func (c *Controller) Clear() { - c.editing = false - c.editQSO = core.QSO{} + // If we are in edit mode, exiting the modal is the whole job of Clear. + // leaveEditMode restores the pre-edit state across all the per-VFO fields. + if c.editing { + c.leaveEditMode() + c.showInput() + c.view.SetMyCall(c.stationCallsign) + c.view.SetFrequency(c.focusedVFO, c.selectedFrequency[c.focusedVFO]) + c.view.SetActiveField(c.focusedVFO, c.activeField[c.focusedVFO]) + c.view.SetDuplicateMarker(c.focusedVFO, false) + c.view.SetEditingMarker(core.VFO1, false) + c.view.ClearMessage(c.focusedVFO) + c.selectLastQSO() + c.callinfoInputChanged(c.focusedVFO, "", core.NoBand, core.NoMode, []string{}) + return + } + + // Release before the wipe so claim slots reflect "nothing pending" while we rebuild. + // The companion refresh writes the serial display below, after the input zero/fill pass. + c.claims.Release(c.focusedVFO) - c.vfo.Refresh() + c.vfos[c.focusedVFO].Refresh() + + c.activeField[c.focusedVFO] = core.CallsignField + + lastExchange := c.logbook.LastExchange() + c.fillExchangeDefaults(c.focusedVFO, lastExchange) + // Non-focused VFOs that have no in-progress QSO are also initialized so their default + // report follows the current contest/mode. + for vfo := range core.VFOCount { + v := core.VFOID(vfo) + if v == c.focusedVFO { + continue + } + if c.claims.claimed[v] != 0 || c.input[v].callsign != "" { + continue + } + c.fillExchangeDefaults(v, lastExchange) + } + + // Refresh serial displays for both VFOs (other VFO's preview may shift after the release). + c.refreshMyNumberInputs() + + c.updateESM() + + c.showInput() + c.view.SetMyCall(c.stationCallsign) + c.view.SetFrequency(c.focusedVFO, c.selectedFrequency[c.focusedVFO]) + c.view.SetActiveField(c.focusedVFO, c.activeField[c.focusedVFO]) + c.view.SetDuplicateMarker(c.focusedVFO, false) + c.view.SetEditingMarker(core.VFO1, false) + c.view.ClearMessage(c.focusedVFO) + c.selectLastQSO() + c.callinfoInputChanged(c.focusedVFO, "", core.NoBand, core.NoMode, []string{}) +} - nextNumber := c.logbook.NextQSONumber() - c.activeField = core.CallsignField - c.input.callsign = "" - if c.selectedBand != core.NoBand { - c.input.band = c.selectedBand.String() +// fillExchangeDefaults resets vfo's input fields (callsign/band/mode/exchange) and seeds +// the default exchange values (mode-derived report, last-exchange carry-over). Idempotent. +func (c *Controller) fillExchangeDefaults(vfo core.VFOID, lastExchange []string) { + c.input[vfo].callsign = "" + if c.selectedBand[vfo] != core.NoBand { + c.input[vfo].band = c.selectedBand[vfo].String() } generatedReport := "" - if c.selectedMode != core.NoMode { - c.input.mode = c.selectedMode.String() - generatedReport = defaultReportForMode(c.selectedMode) + if c.selectedMode[vfo] != core.NoMode { + c.input[vfo].mode = c.selectedMode[vfo].String() + generatedReport = defaultReportForMode(c.selectedMode[vfo]) } - c.input.myReport = "" - c.input.myNumber = "" - c.input.theirReport = "" - c.input.theirNumber = "" - c.input.theirExchange = make([]string, len(c.theirExchangeFields)) - c.input.myExchange = make([]string, len(c.myExchangeFields)) - lastExchange := c.logbook.LastExchange() + c.myReport = "" + c.myNumber = "" + c.input[vfo].theirReport = "" + c.input[vfo].theirNumber = "" + c.input[vfo].theirExchange = make([]string, len(c.theirExchangeFields)) + c.myExchange = make([]string, len(c.myExchangeFields)) for i, value := range c.defaultExchangeValues { if value == "" && i < len(lastExchange) { value = lastExchange[i] } - if i >= len(c.myExchangeFields) { continue } - - c.input.myExchange[i] = value + c.myExchange[i] = value if i == c.myReportExchangeField.Field.ExchangeIndex()-1 { if c.generateReport { value = generatedReport } - c.input.myReport = value - c.input.myExchange[i] = value - - c.input.theirExchange[i] = value - c.input.theirReport = value + c.myReport = value + c.myExchange[i] = value + c.input[vfo].theirExchange[i] = value + c.input[vfo].theirReport = value } } - c.setMyNumberInput(nextNumber.String()) - - c.updateESM() - - c.showInput() - c.view.SetMyCall(c.stationCallsign) - c.view.SetFrequency(c.selectedFrequency) - c.view.SetActiveField(c.activeField) - c.view.SetDuplicateMarker(false) - c.view.SetEditingMarker(false) - c.view.ClearMessage() - c.selectLastQSO() - c.notifyCallinfoInputChanged("", core.NoBand, core.NoMode, []string{}) -} - -func (c *Controller) setMyNumberInput(value string) { - c.input.myNumber = value - i := c.myNumberExchangeField.Field.ExchangeIndex() - 1 - if i < 0 || !c.generateSerialExchange { - return - } - c.input.myExchange[i] = value } func (c *Controller) Activate() { - c.view.SetActiveField(c.activeField) + c.view.SetActiveField(c.focusedVFO, c.activeField[c.focusedVFO]) } func (c *Controller) EditLastQSO() { - c.activeField = core.CallsignField + c.activeField[c.focusedVFO] = core.CallsignField c.qsoList.SelectLastQSO() } @@ -975,29 +1362,37 @@ func (c *Controller) selectLastQSO() { } func (c *Controller) CurrentValues() core.KeyerValues { - myNumber, _ := strconv.Atoi(c.input.myNumber) + myNumber, _ := strconv.Atoi(c.myNumber) - myXchanges := make([]string, 0, len(c.input.myExchange)) + myXchanges := make([]string, 0, len(c.myExchange)) for i, field := range c.myExchangeFields { switch field.Field { case c.myReportExchangeField.Field, c.myNumberExchangeField.Field: continue default: - myXchanges = append(myXchanges, c.input.myExchange[i]) + myXchanges = append(myXchanges, c.myExchange[i]) } } values := core.KeyerValues{} - values.MyReport, _ = parse.RST(c.input.myReport) + values.MyReport, _ = parse.RST(c.myReport) values.MyNumber = core.QSONumber(myNumber) values.MyXchange = strings.Join(myXchanges, " ") - values.MyExchange = strings.Join(c.input.myExchange, " ") - values.MyExchanges = c.input.myExchange - values.TheirCall = c.input.callsign + values.MyExchange = strings.Join(c.myExchange, " ") + values.MyExchanges = c.myExchange + values.TheirCall = c.input[c.focusedVFO].callsign return values } +func (c *Controller) CurrentVFOState() (core.Frequency, core.Band, core.Mode) { + return c.selectedFrequency[c.focusedVFO], c.selectedBand[c.focusedVFO], c.selectedMode[c.focusedVFO] +} + +func (c *Controller) FocusedVFO() (string, bool) { + return c.vfos[c.focusedVFO].Name(), c.vfo2Enabled +} + func (c *Controller) StationChanged(station core.Station) { c.stationCallsign = station.Callsign.String() c.view.SetMyCall(c.stationCallsign) @@ -1007,9 +1402,13 @@ func (c *Controller) ContestChanged(contest core.Contest) { c.updateExchangeFields(contest) } -func (c *Controller) WorkmodeChanged(workmode core.Workmode) { - c.workmode = workmode - c.updateESM() +func (c *Controller) WorkmodeChanged(vfo core.VFOID, workmode core.Workmode) { + c.vfoWorkmode[vfo] = workmode + if vfo == c.focusedVFO { + c.workmode = workmode + c.updateESM() + } + c.view.SetVFOWorkmode(vfo, workmode) } func (c *Controller) updateExchangeFields(contest core.Contest) { @@ -1023,23 +1422,25 @@ func (c *Controller) updateExchangeFields(contest core.Contest) { c.generateReport = contest.GenerateReport c.defaultExchangeValues = contest.ExchangeValues - c.input.myExchange = make([]string, len(contest.MyExchangeFields)) - c.input.theirExchange = make([]string, len(contest.TheirExchangeFields)) + c.myExchange = make([]string, len(contest.MyExchangeFields)) + for vfo := range core.VFOCount { + c.input[vfo].theirExchange = make([]string, len(contest.TheirExchangeFields)) + } c.Clear() } func (c *Controller) MarkInBandmap() { - call, err := core.ParseCallsign(c.input.callsign) + call, err := core.ParseCallsign(c.input[c.focusedVFO].callsign) if err != nil { log.Printf("Cannot mark invalid call: %v", err) return } spot := core.Spot{ Call: call, - Frequency: c.selectedFrequency, - Band: c.selectedBand, - Mode: c.selectedMode, + Frequency: c.selectedFrequency[c.focusedVFO], + Band: c.selectedBand[c.focusedVFO], + Mode: c.selectedMode[c.focusedVFO], Time: c.clock.Now(), Source: core.ManualSpot, } @@ -1047,10 +1448,12 @@ func (c *Controller) MarkInBandmap() { } func (c *Controller) EntrySelected(entry core.BandmapEntry) { + // TODO: check if the entry's band is currently selected in one of the two VFOs + c.Clear() c.ignoreFrequencyJump = true c.frequencyEntered(entry.Frequency) c.SetActiveField(core.CallsignField) c.Enter(entry.Call.String()) - c.view.SetCallsign(c.input.callsign) + c.view.SetCallsign(c.focusedVFO, c.input[c.focusedVFO].callsign) } diff --git a/core/entry/entry_test.go b/core/entry/entry_test.go index 747c4d17..bab5ec36 100644 --- a/core/entry/entry_test.go +++ b/core/entry/entry_test.go @@ -1,7 +1,6 @@ package entry import ( - "fmt" "testing" "time" @@ -24,15 +23,15 @@ func TestEntryController_Clear(t *testing.T) { controller.Clear() - assert.Equal(t, "599", controller.input.myReport, "my report") - assert.Equal(t, "001", controller.input.myNumber, "my number") - assert.Equal(t, []string{"599", "001", ""}, controller.input.myExchange, "my exchange") - assert.Equal(t, "", controller.input.callsign, "callsign") - assert.Equal(t, "599", controller.input.theirReport, "their report") - assert.Equal(t, "", controller.input.theirNumber, "their number") - assert.Equal(t, []string{"599", "", ""}, controller.input.theirExchange, "their exchange") - assert.Equal(t, "160m", controller.input.band, "band") - assert.Equal(t, "CW", controller.input.mode, "mode") + assert.Equal(t, "599", controller.myReport, "my report") + assert.Equal(t, "001", controller.myNumber, "my number") + assert.Equal(t, []string{"599", "001", ""}, controller.myExchange, "my exchange") + assert.Equal(t, "", controller.input[controller.focusedVFO].callsign, "callsign") + assert.Equal(t, "599", controller.input[controller.focusedVFO].theirReport, "their report") + assert.Equal(t, "", controller.input[controller.focusedVFO].theirNumber, "their number") + assert.Equal(t, []string{"599", "", ""}, controller.input[controller.focusedVFO].theirExchange, "their exchange") + assert.Equal(t, "160m", controller.input[controller.focusedVFO].band, "band") + assert.Equal(t, "CW", controller.input[controller.focusedVFO].mode, "mode") } func TestEntryController_ClearView(t *testing.T) { @@ -44,162 +43,41 @@ func TestEntryController_ClearView(t *testing.T) { qsoList.On("SelectLastQSO").Once() view.Activate() - view.On("SetTheirExchange", 1, "599").Once() - view.On("SetTheirExchange", 2, "").Once() - view.On("SetTheirExchange", 3, "").Once() + view.On("SetTheirExchange", mock.Anything, mock.Anything, mock.Anything) view.On("SetMyExchange", 1, "599").Once() view.On("SetMyExchange", 2, "001").Once() view.On("SetMyExchange", 3, "").Once() view.On("SetMyCall", "DL0ABC").Once() - view.On("SetFrequency", mock.Anything).Once() - view.On("SetCallsign", "").Once() - view.On("SetBand", "160m").Once() - view.On("SetFrequency", mock.Anything).Once() - view.On("SetMode", "CW").Once() - view.On("SetActiveField", core.CallsignField).Once() - view.On("SetDuplicateMarker", false).Once() - view.On("SetEditingMarker", false).Once() - view.On("ClearMessage").Once() + view.On("SetFrequency", mock.Anything, mock.Anything) + view.On("SetCallsign", mock.Anything, mock.Anything) + view.On("SetBand", mock.Anything, mock.Anything) + view.On("SetMode", mock.Anything, mock.Anything) + view.On("SetActiveField", core.VFO1, core.CallsignField).Once() + view.On("SetDuplicateMarker", core.VFO1, false).Once() + view.On("SetEditingMarker", core.VFO1, false).Once() + view.On("ClearMessage", core.VFO1).Once() controller.Clear() view.AssertExpectations(t) } -func _TestEntryController_UpdateExchangeFields(t *testing.T) { - const wagDOKProperty = conval.Property("wag_dok") - - tt := []struct { - desc string - value *conval.Definition - generateSerialExchange bool - expectedMyFields []core.ExchangeField - expectedTheirFields []core.ExchangeField - }{ - { - desc: "no definition", - value: nil, - expectedMyFields: nil, - expectedTheirFields: nil, - }, - { - desc: "rst and member number", - value: fieldDefinition( - conval.ExchangeField{conval.RSTProperty}, - conval.ExchangeField{conval.MemberNumberProperty, conval.NoMemberProperty}, - ), - expectedMyFields: []core.ExchangeField{ - {Field: "myExchange_1", Short: "rst", Properties: conval.ExchangeField{conval.RSTProperty}, CanContainReport: true}, - {Field: "myExchange_2", Short: "member_number/nm", Properties: conval.ExchangeField{conval.MemberNumberProperty, conval.NoMemberProperty}}, - }, - expectedTheirFields: []core.ExchangeField{ - {Field: "theirExchange_1", Short: "rst", Properties: conval.ExchangeField{conval.RSTProperty}, CanContainReport: true}, - {Field: "theirExchange_2", Short: "member_number/nm", Properties: conval.ExchangeField{conval.MemberNumberProperty, conval.NoMemberProperty}}, - }, - }, - { - desc: "rst and dok or serial number", - value: fieldDefinition( - conval.ExchangeField{conval.RSTProperty}, - conval.ExchangeField{conval.SerialNumberProperty, conval.NoMemberProperty, wagDOKProperty}, - ), - expectedMyFields: []core.ExchangeField{ - {Field: "myExchange_1", Short: "rst", Properties: conval.ExchangeField{conval.RSTProperty}, CanContainReport: true}, - {Field: "myExchange_2", Short: "serial/nm/wag_dok", Properties: conval.ExchangeField{conval.SerialNumberProperty, conval.NoMemberProperty, wagDOKProperty}, CanContainSerial: true}, - }, - expectedTheirFields: []core.ExchangeField{ - {Field: "theirExchange_1", Short: "rst", Properties: conval.ExchangeField{conval.RSTProperty}, CanContainReport: true}, - {Field: "theirExchange_2", Short: "serial/nm/wag_dok", Properties: conval.ExchangeField{conval.SerialNumberProperty, conval.NoMemberProperty, wagDOKProperty}, CanContainSerial: true}, - }, - }, - { - desc: "rst and serial number", - value: fieldDefinition( - conval.ExchangeField{conval.RSTProperty}, - conval.ExchangeField{conval.SerialNumberProperty, conval.NoMemberProperty, wagDOKProperty}, - ), - generateSerialExchange: true, - expectedMyFields: []core.ExchangeField{ - {Field: "myExchange_1", Short: "rst", Properties: conval.ExchangeField{conval.RSTProperty}, CanContainReport: true}, - {Field: "myExchange_2", Short: "#", Hint: "Serial Number", Properties: conval.ExchangeField{conval.SerialNumberProperty, conval.NoMemberProperty, wagDOKProperty}, CanContainSerial: true, ReadOnly: true}, - }, - expectedTheirFields: []core.ExchangeField{ - {Field: "theirExchange_1", Short: "rst", Properties: conval.ExchangeField{conval.RSTProperty}, CanContainReport: true}, - {Field: "theirExchange_2", Short: "serial/nm/wag_dok", Properties: conval.ExchangeField{conval.SerialNumberProperty, conval.NoMemberProperty, wagDOKProperty}, CanContainSerial: true}, - }, - }, - } - for _, tc := range tt { - t.Run(tc.desc, func(t *testing.T) { - _, _, _, view, controller, _ := setupEntryTest() - - contest := core.Contest{ - Definition: tc.value, - GenerateSerialExchange: tc.generateSerialExchange, - ExchangeValues: make([]string, len(tc.expectedMyFields)), - } - contest.UpdateExchangeFields() - - view.Activate() - view.On("SetMyExchangeFields", tc.expectedMyFields).Once() - view.On("SetTheirExchangeFields", tc.expectedTheirFields).Once() - - controller.updateExchangeFields(contest) - - view.AssertExpectations(t) - }) - } -} - -func _TestEntryController_GotoNextField(t *testing.T) { - _, _, _, view, controller, config := setupEntryTestWithExchangeFields(3) - - assert.Equal(t, core.CallsignField, controller.activeField, "callsign should be active at start") - - testCases := []struct { - active, next core.EntryField - }{ - {core.CallsignField, core.TheirExchangeField(1)}, - {core.OtherField, core.CallsignField}, - {core.MyExchangeField(1), core.CallsignField}, - {core.MyExchangeField(2), core.CallsignField}, - {core.MyExchangeField(3), core.CallsignField}, - {core.TheirExchangeField(1), core.TheirExchangeField(2)}, - {core.TheirExchangeField(2), core.TheirExchangeField(3)}, - {core.TheirExchangeField(3), core.CallsignField}, - } - view.Activate() - view.On("Callsign").Return("").Maybe() - view.On("SetActiveField", mock.Anything).Times(len(testCases)) - view.On("SetMyExchangeFields", mock.Anything).Times(len(testCases)) - view.On("SetTheirExchangeFields", mock.Anything).Times(len(testCases)) - for _, tc := range testCases { - t.Run(fmt.Sprintf("%s -> %s", tc.active, tc.next), func(t *testing.T) { - controller.ContestChanged(config.Contest()) - controller.SetActiveField(tc.active) - actual := controller.GotoNextField() - assert.Equal(t, tc.next, actual) - assert.Equal(t, tc.next, controller.activeField) - }) - } - - view.AssertExpectations(t) -} func TestEntryController_EnterNewCallsign(t *testing.T) { _, log, _, view, controller, _ := setupEntryTestWithClassicExchangeFields() log.Activate() + log.On("NextQSONumber").Return(core.QSONumber(1)) log.On("FindDuplicateQSOs", mock.Anything, mock.Anything, mock.Anything).Return([]core.QSO{}) view.Activate() - view.On("SetDuplicateMarker", false).Once() - view.On("ClearMessage").Once() - view.On("SetActiveField", core.TheirExchangeField(1)).Once() + view.On("SetDuplicateMarker", core.VFO1, false).Once() + view.On("ClearMessage", core.VFO1).Once() + view.On("SetActiveField", core.VFO1, core.TheirExchangeField(1)).Once() controller.Enter("DL1ABC") controller.GotoNextField() - assert.Equal(t, "DL1ABC", controller.input.callsign) + assert.Equal(t, "DL1ABC", controller.input[controller.focusedVFO].callsign) log.AssertExpectations(t) view.AssertExpectations(t) @@ -222,14 +100,15 @@ func TestEntryController_EnterDuplicateCallsign(t *testing.T) { } log.Activate() + log.On("NextQSONumber").Return(core.QSONumber(1)) log.On("FindDuplicateQSOs", dl1abc, core.Band160m, core.ModeCW).Return([]core.QSO{qso}).Twice() view.Activate() - view.On("SetDuplicateMarker", true).Once() - view.On("ShowMessage", mock.Anything).Once() - view.On("SetActiveField", core.CallsignField).Once() - view.On("SetActiveField", core.TheirExchangeField(1)).Once() - // view.On("SetTheirExchange", mock.Anything, mock.Anything).Once() // TODO implement the prediction with the new exchange fields + view.On("SetDuplicateMarker", core.VFO1, true).Once() + view.On("ShowMessage", core.VFO1, mock.Anything).Once() + view.On("SetActiveField", core.VFO1, core.CallsignField).Once() + view.On("SetActiveField", core.VFO1, core.TheirExchangeField(1)).Once() + // view.On("SetTheirExchange", mock.Anything, mock.Anything) // TODO implement the prediction with the new exchange fields controller.Enter("DL1ABC") controller.GotoNextField() @@ -243,12 +122,12 @@ func TestEntryController_EnterFrequency(t *testing.T) { _, _, _, view, controller, _ := setupEntryTest() view.Activate() - view.On("SetCallsign", "").Once() + view.On("SetCallsign", mock.Anything, "") controller.Enter("7028") controller.EnterPressed() - assert.Equal(t, "", controller.input.callsign) + assert.Equal(t, "", controller.input[controller.focusedVFO].callsign) view.AssertExpectations(t) } @@ -302,23 +181,24 @@ func TestEntryController_LogNewQSO(t *testing.T) { log.AssertExpectations(t) qsoList.AssertExpectations(t) - assert.Equal(t, core.CallsignField, controller.activeField) + assert.Equal(t, core.CallsignField, controller.activeField[controller.focusedVFO]) } func TestEntryController_LogWithWrongCallsign(t *testing.T) { _, log, _, view, controller, _ := setupEntryTest() log.Activate() + log.On("NextQSONumber").Return(core.QSONumber(1)) view.Activate() - view.On("SetActiveField", core.CallsignField).Once() - view.On("ShowMessage", mock.Anything).Once() + view.On("SetActiveField", core.VFO1, core.CallsignField).Once() + view.On("ShowMessage", core.VFO1, mock.Anything).Once() controller.Enter("DL") controller.EnterPressed() view.AssertExpectations(t) log.AssertNotCalled(t, "Log", mock.Anything) - assert.Equal(t, core.CallsignField, controller.activeField) + assert.Equal(t, core.CallsignField, controller.activeField[controller.focusedVFO]) } func TestEntryController_LogWithInvalidTheirReport(t *testing.T) { @@ -334,15 +214,16 @@ func TestEntryController_LogWithInvalidTheirReport(t *testing.T) { controller.Enter("000") log.Activate() + log.On("NextQSONumber").Return(core.QSONumber(1)) view.Activate() - view.On("SetActiveField", core.TheirExchangeField(1)).Once() - view.On("ShowMessage", mock.Anything).Once() + view.On("SetActiveField", core.VFO1, core.TheirExchangeField(1)).Once() + view.On("ShowMessage", core.VFO1, mock.Anything).Once() controller.EnterPressed() view.AssertExpectations(t) log.AssertNotCalled(t, "Log", mock.Anything) - assert.Equal(t, core.TheirExchangeField(1), controller.activeField) + assert.Equal(t, core.TheirExchangeField(1), controller.activeField[controller.focusedVFO]) } func TestEntryController_LogWithWrongTheirNumber(t *testing.T) { @@ -360,15 +241,16 @@ func TestEntryController_LogWithWrongTheirNumber(t *testing.T) { controller.Enter("abc") log.Activate() + log.On("NextQSONumber").Return(core.QSONumber(1)) view.Activate() - view.On("SetActiveField", core.TheirExchangeField(2)).Once() - view.On("ShowMessage", mock.Anything).Once() + view.On("SetActiveField", core.VFO1, core.TheirExchangeField(2)).Once() + view.On("ShowMessage", core.VFO1, mock.Anything).Once() controller.EnterPressed() view.AssertExpectations(t) log.AssertNotCalled(t, "Log", mock.Anything) - assert.Equal(t, core.TheirExchangeField(2), controller.activeField) + assert.Equal(t, core.TheirExchangeField(2), controller.activeField[controller.focusedVFO]) } func TestEntryController_LogWithoutMandatoryTheirNumber(t *testing.T) { @@ -384,15 +266,16 @@ func TestEntryController_LogWithoutMandatoryTheirNumber(t *testing.T) { controller.Enter("559") log.Activate() + log.On("NextQSONumber").Return(core.QSONumber(1)) view.Activate() - view.On("SetActiveField", core.TheirExchangeField(2)).Once() - view.On("ShowMessage", mock.Anything).Once() + view.On("SetActiveField", core.VFO1, core.TheirExchangeField(2)).Once() + view.On("ShowMessage", core.VFO1, mock.Anything).Once() controller.EnterPressed() view.AssertExpectations(t) log.AssertNotCalled(t, "Log", mock.Anything) - assert.Equal(t, core.TheirExchangeField(2), controller.activeField) + assert.Equal(t, core.TheirExchangeField(2), controller.activeField[controller.focusedVFO]) } func TestEntryController_LogWithInvalidMyReport(t *testing.T) { @@ -414,20 +297,22 @@ func TestEntryController_LogWithInvalidMyReport(t *testing.T) { controller.Enter("abc") log.Activate() + log.On("NextQSONumber").Return(core.QSONumber(1)) view.Activate() - view.On("SetActiveField", core.MyExchangeField(1)).Once() - view.On("ShowMessage", mock.Anything).Once() + view.On("SetActiveField", core.VFO1, core.MyExchangeField(1)).Once() + view.On("ShowMessage", core.VFO1, mock.Anything).Once() controller.EnterPressed() view.AssertExpectations(t) log.AssertNotCalled(t, "Log", mock.Anything) - assert.Equal(t, core.MyExchangeField(1), controller.activeField) + assert.Equal(t, core.MyExchangeField(1), controller.activeField[controller.focusedVFO]) } func TestEntryController_EnterCallsignCheckForDuplicateAndShowMessage(t *testing.T) { clock, log, _, view, controller, _ := setupEntryTest() log.Activate() + log.On("NextQSONumber").Return(core.QSONumber(1)) view.Activate() dl1ab, _ := core.ParseCallsign("DL1AB") @@ -442,13 +327,13 @@ func TestEntryController_EnterCallsignCheckForDuplicateAndShowMessage(t *testing } log.On("FindDuplicateQSOs", dl1ab, mock.Anything, mock.Anything).Once().Return([]core.QSO{qso}) - view.On("ShowMessage", mock.Anything).Once() - view.On("SetActiveField", mock.Anything).Once() + view.On("ShowMessage", core.VFO1, mock.Anything).Once() + view.On("SetActiveField", core.VFO1, mock.Anything).Once() controller.Enter("DL1AB") view.AssertExpectations(t) log.On("FindDuplicateQSOs", dl1abc, mock.Anything, mock.Anything).Once().Return([]core.QSO{}) - view.On("ClearMessage").Once() + view.On("ClearMessage", core.VFO1).Once() controller.Enter("DL1ABC") view.AssertExpectations(t) } @@ -515,7 +400,7 @@ func TestEntryController_LogDuplicateQSO(t *testing.T) { log.AssertExpectations(t) qsoList.AssertExpectations(t) - assert.Equal(t, core.CallsignField, controller.activeField) + assert.Equal(t, core.CallsignField, controller.activeField[controller.focusedVFO]) } func TestEntryController_SelectRowForEditing(t *testing.T) { @@ -536,18 +421,16 @@ func TestEntryController_SelectRowForEditing(t *testing.T) { MyExchange: []string{"579", "034", "B36"}, } - view.On("SetBand", "80m").Once() - view.On("SetFrequency", mock.Anything).Once() - view.On("SetMode", "CW").Once() - view.On("SetCallsign", "DL1ABC").Once() - view.On("SetTheirExchange", 1, "559").Once() - view.On("SetTheirExchange", 2, "012").Once() - view.On("SetTheirExchange", 3, "A01").Once() + view.On("SetBand", mock.Anything, mock.Anything) + view.On("SetFrequency", mock.Anything, mock.Anything) + view.On("SetMode", mock.Anything, mock.Anything) + view.On("SetCallsign", mock.Anything, mock.Anything) + view.On("SetTheirExchange", mock.Anything, mock.Anything, mock.Anything) view.On("SetMyExchange", 1, "579").Once() view.On("SetMyExchange", 2, "034").Once() view.On("SetMyExchange", 3, "B36").Once() - view.On("SetActiveField", core.CallsignField).Once() - view.On("SetEditingMarker", true).Once() + view.On("SetActiveField", core.VFO1, core.CallsignField).Once() + view.On("SetEditingMarker", core.VFO1, true).Once() controller.QSOSelected(qso) @@ -585,7 +468,7 @@ func TestEntryController_EditQSO(t *testing.T) { log.Activate() log.On("UpdateQSO", changedQSO).Once() - log.On("LastExchange").Return([]string{"599", "001", ""}).Times(2) + log.On("LastExchange").Return([]string{"599", "001", ""}).Maybe() log.On("NextQSONumber").Return(core.QSONumber(35)) controller.EnterPressed() @@ -600,10 +483,11 @@ func setupEntryTest() (core.Clock, *mocked.Log, *mocked.QSOList, *mocked.EntryVi log := new(mocked.Log) qsoList := new(mocked.QSOList) view := new(mocked.EntryView) + view.On("SetSerialClaim", mock.Anything, mock.Anything, mock.Anything).Maybe() settings := &testSettings{myCall: "DL0ABC"} controller := NewController(settings, clock, log, qsoList, new(nullBandmap), testRunSync) vfo := &testVFO{controller} - controller.SetVFO(vfo) + controller.SetVFO(core.VFO1, vfo) controller.SetView(view) controller.SetESMEnabled(false) controller.updateExchangeFields(settings.Contest()) @@ -617,11 +501,12 @@ func setupEntryTestWithClassicExchangeFields() (core.Clock, *mocked.Log, *mocked log := new(mocked.Log) qsoList := new(mocked.QSOList) view := new(mocked.EntryView) + view.On("SetSerialClaim", mock.Anything, mock.Anything, mock.Anything).Maybe() exchangeFields := []conval.ExchangeField{{conval.RSTProperty}, {conval.SerialNumberProperty}, {conval.GenericTextProperty}} settings := &testSettings{myCall: "DL0ABC", exchangeFields: exchangeFields, exchangeValues: []string{"599", "", ""}, generateSerialExchange: true} controller := NewController(settings, clock, log, qsoList, new(nullBandmap), testRunSync) vfo := &testVFO{controller} - controller.SetVFO(vfo) + controller.SetVFO(core.VFO1, vfo) controller.SetView(view) controller.SetESMEnabled(false) controller.updateExchangeFields(settings.Contest()) @@ -635,6 +520,7 @@ func setupEntryTestWithExchangeFields(exchangeFieldCount int) (core.Clock, *mock log := new(mocked.Log) qsoList := new(mocked.QSOList) view := new(mocked.EntryView) + view.On("SetSerialClaim", mock.Anything, mock.Anything, mock.Anything).Maybe() exchangeFields := make([]conval.ExchangeField, exchangeFieldCount) exchangeValues := make([]string, exchangeFieldCount) for i := range exchangeFields { @@ -643,7 +529,7 @@ func setupEntryTestWithExchangeFields(exchangeFieldCount int) (core.Clock, *mock settings := &testSettings{myCall: "DL0ABC", exchangeFields: exchangeFields, exchangeValues: exchangeValues} controller := NewController(settings, clock, log, qsoList, new(nullBandmap), testRunSync) vfo := &testVFO{controller} - controller.SetVFO(vfo) + controller.SetVFO(core.VFO1, vfo) controller.SetView(view) controller.SetESMEnabled(false) controller.updateExchangeFields(settings.Contest()) @@ -652,17 +538,17 @@ func setupEntryTestWithExchangeFields(exchangeFieldCount int) (core.Clock, *mock } func assertQSOInput(t *testing.T, qso core.QSO, controller *Controller) { - assert.Equal(t, qso.Callsign.String(), controller.input.callsign, "callsign") - assert.Equal(t, qso.TheirReport.String(), controller.input.theirReport, "their report") - assert.Equal(t, qso.TheirNumber.String(), controller.input.theirNumber, "their number") - assert.Equal(t, qso.TheirExchange, controller.input.theirExchange, "their exchange") - assert.Equal(t, qso.MyReport.String(), controller.input.myReport, "my report") - assert.Equal(t, qso.MyNumber.String(), controller.input.myNumber, "my number") - assert.Equal(t, qso.MyExchange, controller.input.myExchange, "my exchange") - assert.Equal(t, qso.Band.String(), controller.input.band, "input band") - assert.Equal(t, qso.Band, controller.selectedBand, "selected band") - assert.Equal(t, qso.Mode.String(), controller.input.mode, "input mode") - assert.Equal(t, qso.Mode, controller.selectedMode, "selected mode") + assert.Equal(t, qso.Callsign.String(), controller.input[controller.focusedVFO].callsign, "callsign") + assert.Equal(t, qso.TheirReport.String(), controller.input[controller.focusedVFO].theirReport, "their report") + assert.Equal(t, qso.TheirNumber.String(), controller.input[controller.focusedVFO].theirNumber, "their number") + assert.Equal(t, qso.TheirExchange, controller.input[controller.focusedVFO].theirExchange, "their exchange") + assert.Equal(t, qso.MyReport.String(), controller.myReport, "my report") + assert.Equal(t, qso.MyNumber.String(), controller.myNumber, "my number") + assert.Equal(t, qso.MyExchange, controller.myExchange, "my exchange") + assert.Equal(t, qso.Band.String(), controller.input[controller.focusedVFO].band, "input band") + assert.Equal(t, qso.Band, controller.selectedBand[controller.focusedVFO], "selected band") + assert.Equal(t, qso.Mode.String(), controller.input[controller.focusedVFO].mode, "input mode") + assert.Equal(t, qso.Mode, controller.selectedMode[controller.focusedVFO], "selected mode") } type testSettings struct { @@ -704,12 +590,13 @@ func fieldDefinition(fields ...conval.ExchangeField) *conval.Definition { type testVFO struct{ controller *Controller } +func (v *testVFO) Name() string { return "TESTVFO" } func (v *testVFO) Notify(any) {} func (v *testVFO) Active() bool { return false } func (v *testVFO) Refresh() { - v.controller.VFOFrequencyChanged(1810000) - v.controller.VFOBandChanged(core.Band160m) - v.controller.VFOModeChanged(core.ModeCW) + v.controller.VFOFrequencyChanged(core.VFO1, 1810000) + v.controller.VFOBandChanged(core.VFO1, core.Band160m) + v.controller.VFOModeChanged(core.VFO1, core.ModeCW) } func (v *testVFO) SetFrequency(core.Frequency) {} func (v *testVFO) SetBand(core.Band) {} diff --git a/core/entry/esm.go b/core/entry/esm.go index 7fbcbedb..05cfec33 100644 --- a/core/entry/esm.go +++ b/core/entry/esm.go @@ -29,7 +29,7 @@ func (c *Controller) SetESMView(esmView ESMView) { log.Printf("setting esmView: %t", c.esmEnabled) c.esmView = esmView c.esmView.SetESMEnabled(c.esmEnabled) - c.esmView.SetMessage(c.esmMessage) + c.esmView.SetMessage(c.esmMessage[c.focusedVFO]) } func (c *Controller) ESMEnabled() bool { @@ -39,7 +39,7 @@ func (c *Controller) ESMEnabled() bool { func (c *Controller) SetESMEnabled(enabled bool) { c.esmEnabled = enabled c.esmView.SetESMEnabled(enabled) - c.view.SetActiveField(c.activeField) + c.view.SetActiveField(c.focusedVFO, c.activeField[c.focusedVFO]) c.emitESMEnabled(enabled) } @@ -52,45 +52,53 @@ func (c *Controller) emitESMEnabled(enabled bool) { } func (c *Controller) NextESMStep() { + if !c.canTransmit() { + return + } c.updateESM() - c.keyer.SendText(c.esmMessage) + if index := c.esmMacroIndex[c.focusedVFO]; index >= 0 { + c.keyer.SendMacro(index) + } else { + c.keyer.SendText(c.esmMessage[c.focusedVFO]) + } switch { - case c.esmState == core.ESMCallsignValid && c.workmode == core.Run: + case c.esmState[c.focusedVFO] == core.ESMCallsignValid && c.workmode == core.Run: c.GotoNextField() - if c.activeField == c.theirReportExchangeField.Field { + if c.activeField[c.focusedVFO] == c.theirReportExchangeField.Field { c.GotoNextField() } - case c.esmState == core.ESMExchangeValid: + case c.esmState[c.focusedVFO] == core.ESMExchangeValid: c.Log() } } func (c *Controller) updateESM() { - c.esmState = c.currentESMState() + c.esmState[c.focusedVFO] = c.currentESMState() switch c.workmode { case core.SearchPounce: - c.esmMessage = c.updateSPMessage() + c.esmMessage[c.focusedVFO], c.esmMacroIndex[c.focusedVFO] = c.updateSPMessage() case core.Run: - c.esmMessage = c.updateRunMessage() + c.esmMessage[c.focusedVFO], c.esmMacroIndex[c.focusedVFO] = c.updateRunMessage() default: - c.esmMessage = "" + c.esmMessage[c.focusedVFO] = "" + c.esmMacroIndex[c.focusedVFO] = -1 } - c.esmView.SetMessage(c.esmMessage) + c.esmView.SetMessage(c.esmMessage[c.focusedVFO]) } func (c *Controller) currentESMState() core.ESMState { switch { - case c.activeField == core.CallsignField: - if c.input.callsign == "" { + case c.activeField[c.focusedVFO] == core.CallsignField: + if c.input[c.focusedVFO].callsign == "" { return core.ESMCallsignEmpty } - _, err := core.ParseCallsign(c.input.callsign) + _, err := core.ParseCallsign(c.input[c.focusedVFO].callsign) if err != nil { return core.ESMCallsignInvalid } return core.ESMCallsignValid - case c.activeField.IsTheirExchange(): + case c.activeField[c.focusedVFO].IsTheirExchange(): _, err := c.parseTheirExchange(nil, nil, nil) if err != nil { return core.ESMExchangeInvalid @@ -100,35 +108,35 @@ func (c *Controller) currentESMState() core.ESMState { return core.ESMUnknown } -func (c *Controller) updateSPMessage() string { - switch c.esmState { +func (c *Controller) updateSPMessage() (string, int) { + switch c.esmState[c.focusedVFO] { case core.ESMCallsignEmpty, core.ESMCallsignInvalid: - return callsignRequest(c.input.callsign) + return callsignRequest(c.input[c.focusedVFO].callsign), -1 case core.ESMCallsignValid: - return c.getKeyerText(0) + return c.getKeyerText(0), 0 case core.ESMExchangeInvalid: - return "nr?" + return "nr?", -1 case core.ESMExchangeValid: - return c.getKeyerText(1) + return c.getKeyerText(1), 1 default: - return "" + return "", -1 } } -func (c *Controller) updateRunMessage() string { - switch c.esmState { +func (c *Controller) updateRunMessage() (string, int) { + switch c.esmState[c.focusedVFO] { case core.ESMCallsignEmpty: - return c.getKeyerText(0) + return c.getKeyerText(0), 0 case core.ESMCallsignInvalid: - return callsignRequest(c.input.callsign) + return callsignRequest(c.input[c.focusedVFO].callsign), -1 case core.ESMCallsignValid: - return c.getKeyerText(1) + return c.getKeyerText(1), 1 case core.ESMExchangeInvalid: - return "nr?" + return "nr?", -1 case core.ESMExchangeValid: - return c.getKeyerText(2) + return c.getKeyerText(2), 2 default: - return "" + return "", -1 } } diff --git a/core/entry/harness_test.go b/core/entry/harness_test.go new file mode 100644 index 00000000..ab22184e --- /dev/null +++ b/core/entry/harness_test.go @@ -0,0 +1,1409 @@ +package entry_test + +import ( + "fmt" + "reflect" + "testing" + "time" + + "github.com/ftl/conval" + "github.com/stretchr/testify/assert" + + "github.com/ftl/hellocontest/core" + "github.com/ftl/hellocontest/core/clock" + "github.com/ftl/hellocontest/core/entry" +) + +var scenarioFixedTime = time.Date(2006, time.January, 2, 15, 4, 5, 6, time.UTC) + +// ---- Scenario ---------------------------------------------------------------- + +type Scenario struct { + t *testing.T + controller *entry.Controller + view *viewSpy + logbook *logbookSpy + callinfo *callinfoSpy + listener *listenerSpy + vfo *vfoSpy + vfo2 *vfoSpy + vfoSwitcher *vfoSwitcherSpy + bandmap *bandmapSpy + esmView *esmViewSpy + keyer *keyerSpy + qsoList *qsoListSpy + seq int // shared sequence counter for ordering assertions +} + +func NewScenario(t *testing.T) *Scenario { + t.Helper() + s := &Scenario{ + t: t, + view: &viewSpy{}, + logbook: &logbookSpy{nextQSONumber: 1}, + callinfo: &callinfoSpy{}, + listener: &listenerSpy{}, + bandmap: &bandmapSpy{}, + esmView: &esmViewSpy{}, + keyer: &keyerSpy{}, + qsoList: &qsoListSpy{}, + } + settings := &scenarioSettings{myCall: "DL0ABC"} + s.controller = entry.NewController( + settings, + clock.Static(scenarioFixedTime), + s.logbook, + s.qsoList, + s.bandmap, + func(f func()) { f() }, + ) + s.vfo = &vfoSpy{ctrl: s.controller} + s.controller.SetVFO(core.VFO1, s.vfo) + s.controller.SetCallinfo(s.callinfo) + s.controller.Notify(s.listener) + s.controller.SetView(s.view) // triggers Clear() → vfo.Refresh() + s.controller.SetESMEnabled(false) + s.resetSpies() + return s +} + +func (s *Scenario) resetSpies() { + s.seq = 0 + s.view.reset() + s.logbook.resetCalls() + s.callinfo.resetCalls() + s.listener.resetCalls() + s.vfo.reset() + if s.vfo2 != nil { + s.vfo2.reset() + } + if s.vfoSwitcher != nil { + s.vfoSwitcher.reset() + } + s.bandmap.reset() + s.esmView.reset() + s.keyer.reset() + s.qsoList.resetCalls() +} + +// ---- Setup methods (no spy reset) ------------------------------------------- + +// WithClassicExchange configures RST + serial + generic-text exchange fields, +// with auto-generated serial numbers. +func (s *Scenario) WithClassicExchange() *Scenario { + contest := core.Contest{ + Definition: scenarioFieldDefinition( + conval.ExchangeField{conval.RSTProperty}, + conval.ExchangeField{conval.SerialNumberProperty}, + conval.ExchangeField{conval.GenericTextProperty}, + ), + GenerateSerialExchange: true, + ExchangeValues: []string{"599", "", ""}, + } + contest.UpdateExchangeFields() + s.controller.ContestChanged(contest) + return s +} + +// WithDuplicateQSO makes the logbook spy return qso for any FindDuplicateQSOs call. +func (s *Scenario) WithDuplicateQSO(qso core.QSO) *Scenario { + s.logbook.duplicates = append(s.logbook.duplicates, qso) + return s +} + +// WithCallinfoFrame injects a prediction frame for vfo via CallinfoFrameChanged. +func (s *Scenario) WithCallinfoFrame(vfo core.VFOID, frame core.CallinfoFrame) *Scenario { + s.controller.CallinfoFrameChanged(vfo, frame) + return s +} + +// WithClassicExchangeWithOptionalText configures RST + serial + optional-generic-text +// (EmptyAllowed) exchange fields. Needed for C8 prediction-fill path. +func (s *Scenario) WithClassicExchangeWithOptionalText() *Scenario { + contest := core.Contest{ + Definition: scenarioFieldDefinition( + conval.ExchangeField{conval.RSTProperty}, + conval.ExchangeField{conval.SerialNumberProperty}, + conval.ExchangeField{conval.GenericTextProperty, conval.EmptyProperty}, + ), + GenerateSerialExchange: true, + ExchangeValues: []string{"599", "", ""}, + } + contest.UpdateExchangeFields() + s.controller.ContestChanged(contest) + return s +} + +// WithESMEnabled enables ESM mode. +func (s *Scenario) WithESMEnabled() *Scenario { + s.controller.SetESMEnabled(true) + return s +} + +// WithWorkmode sets the workmode without spy reset. +func (s *Scenario) WithWorkmode(w core.Workmode) *Scenario { + s.controller.WorkmodeChanged(core.VFO1, w) + return s +} + +// WithKeyer wires the keyer spy into the controller without spy reset. +func (s *Scenario) WithKeyer() *Scenario { + s.keyer.seq = &s.seq + s.controller.SetKeyer(s.keyer) + return s +} + +// WithQSOListCallback sets a callback fired when SelectLastQSO is called. Not an action (no spy reset). +func (s *Scenario) WithQSOListCallback(f func()) *Scenario { + s.qsoList.onSelectLast = f + return s +} + +// WithClassicExchangeAndReport configures RST + serial + generic-text exchange fields +// with both auto-generated serial numbers and report regeneration on mode change. +func (s *Scenario) WithClassicExchangeAndReport() *Scenario { + contest := core.Contest{ + Definition: scenarioFieldDefinition( + conval.ExchangeField{conval.RSTProperty}, + conval.ExchangeField{conval.SerialNumberProperty}, + conval.ExchangeField{conval.GenericTextProperty}, + ), + GenerateSerialExchange: true, + GenerateReport: true, + ExchangeValues: []string{"599", "", ""}, + } + contest.UpdateExchangeFields() + s.controller.ContestChanged(contest) + return s +} + +// ---- Action methods (reset spies before executing) -------------------------- + +// Enter sets the focused VFO's active field content. +func (s *Scenario) Enter(text string) *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.Enter(text) + return s +} + +// GotoNextField advances the active field per the transition map. +func (s *Scenario) GotoNextField() *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.GotoNextField() + return s +} + +// SelectQSO triggers QSOSelected, entering edit mode with qso. +func (s *Scenario) SelectQSO(qso core.QSO) *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.QSOSelected(qso) + return s +} + +// SetActiveField sets the active field on the focused VFO. +func (s *Scenario) SetActiveField(field core.EntryField) *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.SetActiveField(field) + return s +} + +// GotoNextPlaceholder moves focus to the callsign field and selects placeholder text. +func (s *Scenario) GotoNextPlaceholder() *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.GotoNextPlaceholder() + return s +} + +// Log invokes the Log use case directly, bypassing EnterPressed dispatch. +func (s *Scenario) Log() *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.Log() + return s +} + +// PressEnter dispatches EnterPressed on the controller. +func (s *Scenario) PressEnter() *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.EnterPressed() + return s +} + +// SetXITActive toggles XIT active state on VFO1. +func (s *Scenario) SetXITActive(active bool) *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.SetXITActive(active) + return s +} + +// Clear invokes controller.Clear(). +func (s *Scenario) Clear() *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.Clear() + return s +} + +// VFOFrequencyChanged fires the rig frequency-change event. +func (s *Scenario) VFOFrequencyChanged(vfo core.VFOID, freq core.Frequency) *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.VFOFrequencyChanged(vfo, freq) + return s +} + +// VFOBandChanged fires the rig band-change event. +func (s *Scenario) VFOBandChanged(vfo core.VFOID, band core.Band) *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.VFOBandChanged(vfo, band) + return s +} + +// VFOModeChanged fires the rig mode-change event. +func (s *Scenario) VFOModeChanged(vfo core.VFOID, mode core.Mode) *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.VFOModeChanged(vfo, mode) + return s +} + +// WorkmodeChanged updates workmode (spy reset → action). +func (s *Scenario) WorkmodeChanged(w core.Workmode) *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.WorkmodeChanged(core.VFO1, w) + return s +} + +// EditLastQSO triggers controller.EditLastQSO. +func (s *Scenario) EditLastQSO() *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.EditLastQSO() + return s +} + +// MarkInBandmap adds the current callsign as a manual spot. +func (s *Scenario) MarkInBandmap() *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.MarkInBandmap() + return s +} + +// EntrySelected simulates a bandmap entry being selected. +func (s *Scenario) EntrySelected(entry core.BandmapEntry) *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.EntrySelected(entry) + return s +} + +// SelectBestMatchOnFrequency picks the top supercheck match. +func (s *Scenario) SelectBestMatchOnFrequency() *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.SelectBestMatchOnFrequency() + return s +} + +// SelectMatch picks the supercheck match at the given index. +func (s *Scenario) SelectMatch(index int) *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.SelectMatch(index) + return s +} + +// RefreshPrediction re-notifies callinfo with the current callsign. +func (s *Scenario) RefreshPrediction() *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.RefreshPrediction() + return s +} + +// NextESMStep executes the next ESM step. +func (s *Scenario) NextESMStep() *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.NextESMStep() + return s +} + +// ConnectESMView wires the esmViewSpy into the controller (spy reset first). +func (s *Scenario) ConnectESMView() *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.SetESMView(s.esmView) + return s +} + +// ToggleESM enables or disables ESM (spy reset → action). +func (s *Scenario) ToggleESM(enabled bool) *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.SetESMEnabled(enabled) + return s +} + +// ---- Assertion methods (no spy reset) --------------------------------------- + +// AssertCallsignEntered asserts the CallsignEntered listener was notified with callsign. +func (s *Scenario) AssertCallsignEntered(callsign string) *Scenario { + s.t.Helper() + assert.Contains(s.t, s.listener.callsignsEntered, callsign, + "CallsignEntered listener not notified with %q", callsign) + return s +} + +// AssertCallinfoNotified asserts callinfo.InputChanged was called with callsign. +func (s *Scenario) AssertCallinfoNotified(callsign string) *Scenario { + s.t.Helper() + assert.Contains(s.t, s.callinfo.callsigns, callsign, + "callinfo InputChanged not called with callsign %q", callsign) + return s +} + +// AssertCallinfoCleared asserts callinfo.InputChanged(vfo, "") was called for the given VFO. +func (s *Scenario) AssertCallinfoCleared(vfo core.VFOID) *Scenario { + s.t.Helper() + found := false + for _, inp := range s.callinfo.inputs { + if inp.vfo == vfo && inp.call == "" { + found = true + break + } + } + assert.True(s.t, found, + "expected callinfo.InputChanged(%v, \"\") to be called (clear callinfo for VFO)", vfo) + return s +} + +// AssertMessageCleared asserts view.ClearMessage(vfo) was called and ShowMessage was not. +func (s *Scenario) AssertMessageCleared(vfo core.VFOID) *Scenario { + s.t.Helper() + s.view.assertCalledWith(s.t, "ClearMessage", vfo) + assert.False(s.t, s.view.wasCalled("ShowMessage"), + "expected ShowMessage not to be called") + return s +} + +// AssertMessageShown asserts view.ShowMessage was called for vfo. +func (s *Scenario) AssertMessageShown(vfo core.VFOID) *Scenario { + s.t.Helper() + found := false + for _, c := range s.view.calls { + if c.method == "ShowMessage" && len(c.args) > 0 && c.args[0] == vfo { + found = true + break + } + } + assert.True(s.t, found, "expected ShowMessage(%v, ...) to be called", vfo) + return s +} + +// AssertSerialClaimed asserts a non-zero serial is assigned to the focused VFO. +// Note: in black-box context this checks CurrentValues().MyNumber != 0, which is +// true for both a held claim and an unclaimed preview when NextQSONumber > 0. +// True claim stickiness is covered by serial-claim use case tests (I1/I2). +func (s *Scenario) AssertSerialClaimed() *Scenario { + s.t.Helper() + assert.NotZero(s.t, s.controller.CurrentValues().MyNumber, + "expected a serial to be assigned (MyNumber != 0)") + return s +} + +// AssertActiveField asserts view.SetActiveField(vfo, field) was called. +func (s *Scenario) AssertActiveField(vfo core.VFOID, field core.EntryField) *Scenario { + s.t.Helper() + s.view.assertCalledWith(s.t, "SetActiveField", vfo, field) + return s +} + +// AssertTextSelected asserts view.SelectText(vfo, field, text) was called. +func (s *Scenario) AssertTextSelected(vfo core.VFOID, field core.EntryField, text string) *Scenario { + s.t.Helper() + s.view.assertCalledWith(s.t, "SelectText", vfo, field, text) + return s +} + +// AssertMyExchangeValue asserts the my-exchange slot at index (1-based) holds value. +// Reads via CurrentValues().MyExchanges since Enter on my-exchange fields has no direct view call. +func (s *Scenario) AssertMyExchangeValue(index int, value string) *Scenario { + s.t.Helper() + vals := s.controller.CurrentValues() + if index < 1 || index > len(vals.MyExchanges) { + s.t.Errorf("my-exchange index %d out of range (len=%d)", index, len(vals.MyExchanges)) + return s + } + assert.Equal(s.t, value, vals.MyExchanges[index-1], + "expected MyExchanges[%d] = %q", index-1, value) + return s +} + +// AssertDuplicateMarker asserts view.SetDuplicateMarker(vfo, marked) was called. +func (s *Scenario) AssertDuplicateMarker(vfo core.VFOID, marked bool) *Scenario { + s.t.Helper() + s.view.assertCalledWith(s.t, "SetDuplicateMarker", vfo, marked) + return s +} + +// AssertTheirExchangeSet asserts view.SetTheirExchange(vfo, index, value) was called. +// index is 1-based, matching the view interface. +func (s *Scenario) AssertTheirExchangeSet(vfo core.VFOID, index int, value string) *Scenario { + s.t.Helper() + s.view.assertCalledWith(s.t, "SetTheirExchange", vfo, index, value) + return s +} + +// AssertQSOAdded asserts at least one QSO was added to the logbook. +func (s *Scenario) AssertQSOAdded() *Scenario { + s.t.Helper() + assert.NotEmpty(s.t, s.logbook.addedQSOs, "expected at least one QSO to be added to the logbook") + return s +} + +// AssertQSOAddedCallsign asserts the first added QSO has the given callsign string. +func (s *Scenario) AssertQSOAddedCallsign(callsign string) *Scenario { + s.t.Helper() + if assert.NotEmpty(s.t, s.logbook.addedQSOs, "expected at least one QSO to be added") { + assert.Equal(s.t, callsign, s.logbook.addedQSOs[0].Callsign.String(), + "expected added QSO callsign to be %q", callsign) + } + return s +} + +// AssertCallsignLogged asserts the CallsignLogged listener was notified with the given callsign. +func (s *Scenario) AssertCallsignLogged(callsign string) *Scenario { + s.t.Helper() + assert.Contains(s.t, s.listener.callsignsLogged, callsign, + "CallsignLogged listener not notified with %q", callsign) + return s +} + +// AssertVFOBand asserts the VFO rig was commanded with the given band. +func (s *Scenario) AssertVFOBand(band core.Band) *Scenario { + s.t.Helper() + assert.Equal(s.t, band, s.vfo.lastBand, + "expected VFO to be commanded with band %v", band) + return s +} + +// AssertVFOMode asserts the VFO rig was commanded with the given mode. +func (s *Scenario) AssertVFOMode(mode core.Mode) *Scenario { + s.t.Helper() + assert.Equal(s.t, mode, s.vfo.lastMode, + "expected VFO to be commanded with mode %v", mode) + return s +} + +// AssertVFOFrequency asserts the VFO rig was commanded with the given frequency. +func (s *Scenario) AssertVFOFrequency(freq core.Frequency) *Scenario { + s.t.Helper() + assert.Equal(s.t, freq, s.vfo.lastFreq, + "expected VFO to be commanded with frequency %v", freq) + return s +} + +// AssertXITActiveCommanded asserts the VFO rig was commanded with the given XIT-active flag. +func (s *Scenario) AssertXITActiveCommanded(active bool) *Scenario { + s.t.Helper() + assert.Equal(s.t, active, s.vfo.xitActive, + "expected VFO XIT-active to be commanded as %v", active) + return s +} + +// AssertCallsignView asserts view.SetCallsign(vfo, text) was called. +func (s *Scenario) AssertCallsignView(vfo core.VFOID, text string) *Scenario { + s.t.Helper() + s.view.assertCalledWith(s.t, "SetCallsign", vfo, text) + return s +} + +// AssertMyExchangeView asserts view.SetMyExchange(index, value) was called (index is 1-based). +func (s *Scenario) AssertMyExchangeView(index int, value string) *Scenario { + s.t.Helper() + s.view.assertCalledWith(s.t, "SetMyExchange", index, value) + return s +} + +// AssertBandmapSelected asserts bandmap.SelectByCallsign was called with the given callsign string. +func (s *Scenario) AssertBandmapSelected(callsign string) *Scenario { + s.t.Helper() + found := false + for _, c := range s.bandmap.selectedCallsigns { + if c.String() == callsign { + found = true + break + } + } + assert.True(s.t, found, "expected bandmap.SelectByCallsign to be called with %q", callsign) + return s +} + +// AssertEditingMarker asserts view.SetEditingMarker(vfo, marked) was called. +func (s *Scenario) AssertEditingMarker(vfo core.VFOID, marked bool) *Scenario { + s.t.Helper() + s.view.assertCalledWith(s.t, "SetEditingMarker", vfo, marked) + return s +} + +// AssertQSOUpdated asserts at least one QSO was updated in the logbook. +func (s *Scenario) AssertQSOUpdated() *Scenario { + s.t.Helper() + assert.NotEmpty(s.t, s.logbook.updatedQSOs, "expected at least one QSO to be updated in logbook") + return s +} + +// AssertQSOUpdatedCallsign asserts the first updated QSO has the given callsign string. +func (s *Scenario) AssertQSOUpdatedCallsign(callsign string) *Scenario { + s.t.Helper() + if assert.NotEmpty(s.t, s.logbook.updatedQSOs, "expected at least one QSO to be updated") { + assert.Equal(s.t, callsign, s.logbook.updatedQSOs[0].Callsign.String(), + "expected updated QSO callsign to be %q", callsign) + } + return s +} + +// AssertNoQSOAdded asserts no QSO was added to the logbook. +func (s *Scenario) AssertNoQSOAdded() *Scenario { + s.t.Helper() + assert.Empty(s.t, s.logbook.addedQSOs, "expected no QSO added to logbook") + return s +} + +// AssertBandmapAddedCallsign asserts bandmap.Add was called with the given callsign. +func (s *Scenario) AssertBandmapAddedCallsign(callsign string) *Scenario { + s.t.Helper() + found := false + for _, spot := range s.bandmap.addedSpots { + if spot.Call.String() == callsign { + found = true + break + } + } + assert.True(s.t, found, "expected bandmap.Add with callsign %q", callsign) + return s +} + +// AssertBandmapSpotSource asserts the first added bandmap spot has the given source. +func (s *Scenario) AssertBandmapSpotSource(source core.SpotType) *Scenario { + s.t.Helper() + if assert.NotEmpty(s.t, s.bandmap.addedSpots, "expected at least one bandmap spot") { + assert.Equal(s.t, source, s.bandmap.addedSpots[0].Source, + "expected bandmap spot source %v", source) + } + return s +} + +// AssertKeyerSentMacro asserts keyer.SendMacro(index) was called with the given index. +func (s *Scenario) AssertKeyerSentMacro(index int) *Scenario { + s.t.Helper() + assert.Contains(s.t, s.keyer.sentIndices, index, + "expected keyer.SendMacro(%d) to be called", index) + return s +} + +// AssertKeyerSentText asserts keyer.SendText was called at least once. +func (s *Scenario) AssertKeyerSentText() *Scenario { + s.t.Helper() + assert.NotEmpty(s.t, s.keyer.sentTexts, "expected keyer.SendText to be called") + return s +} + +// AssertNoKeyerText asserts keyer.SendText was NOT called. +func (s *Scenario) AssertNoKeyerText() *Scenario { + s.t.Helper() + assert.Empty(s.t, s.keyer.sentTexts, "expected keyer.SendText NOT to be called") + return s +} + +// AssertESMViewEnabled asserts the esmView received SetESMEnabled(enabled). +func (s *Scenario) AssertESMViewEnabled(enabled bool) *Scenario { + s.t.Helper() + assert.Equal(s.t, enabled, s.esmView.esmEnabled, + "expected esmView.esmEnabled = %v", enabled) + return s +} + +// AssertESMViewMessage asserts the esmView received SetMessage(msg). +func (s *Scenario) AssertESMViewMessage(msg string) *Scenario { + s.t.Helper() + assert.Equal(s.t, msg, s.esmView.message, "expected esmView.message = %q", msg) + return s +} + +// AssertESMListenerNotified asserts ESMEnabled(enabled) was emitted to listeners. +func (s *Scenario) AssertESMListenerNotified(enabled bool) *Scenario { + s.t.Helper() + assert.Contains(s.t, s.listener.esmEnabledValues, enabled, + "expected ESMEnabled(%v) to be notified", enabled) + return s +} + +// AssertViewNotCalledWith asserts view.method(args...) was NOT called. +func (s *Scenario) AssertViewNotCalledWith(method string, args ...any) *Scenario { + s.t.Helper() + assert.False(s.t, s.view.wasCalledWith(method, args...), + "expected view.%s(%v) NOT to be called", method, args) + return s +} + +// AssertQSOListSelectLastCalled asserts qsoList.SelectLastQSO was called. +func (s *Scenario) AssertQSOListSelectLastCalled() *Scenario { + s.t.Helper() + assert.Positive(s.t, s.qsoList.selectLastCalled, + "expected qsoList.SelectLastQSO to be called") + return s +} + +// AssertBandView asserts view.SetBand(vfo, text) was called. +func (s *Scenario) AssertBandView(vfo core.VFOID, text string) *Scenario { + s.t.Helper() + s.view.assertCalledWith(s.t, "SetBand", vfo, text) + return s +} + +// AssertModeView asserts view.SetMode(vfo, text) was called. +func (s *Scenario) AssertModeView(vfo core.VFOID, text string) *Scenario { + s.t.Helper() + s.view.assertCalledWith(s.t, "SetMode", vfo, text) + return s +} + +// AssertFrequencyView asserts view.SetFrequency(vfo, freq) was called. +func (s *Scenario) AssertFrequencyView(vfo core.VFOID, freq core.Frequency) *Scenario { + s.t.Helper() + s.view.assertCalledWith(s.t, "SetFrequency", vfo, freq) + return s +} + +// AssertNoLogbookWrite asserts no QSO was added or updated in the logbook. +func (s *Scenario) AssertNoLogbookWrite() *Scenario { + s.t.Helper() + assert.Empty(s.t, s.logbook.addedQSOs, "expected no QSO added to logbook") + assert.Empty(s.t, s.logbook.updatedQSOs, "expected no QSO updated in logbook") + return s +} + +// ---- scenarioSettings ------------------------------------------------------- + +type scenarioSettings struct { + myCall string +} + +func (s *scenarioSettings) Station() core.Station { + return core.Station{Callsign: core.MustParseCallsign(s.myCall)} +} + +func (s *scenarioSettings) Contest() core.Contest { + return core.Contest{} +} + +// ---- vfoSpy ----------------------------------------------------------------- + +type vfoSpy struct { + ctrl *entry.Controller + vfoID core.VFOID + lastBand core.Band + lastMode core.Mode + lastFreq core.Frequency + xitActive bool +} + +func (v *vfoSpy) reset() { + v.lastBand = core.NoBand + v.lastMode = core.NoMode + v.lastFreq = 0 + v.xitActive = false +} + +func (v *vfoSpy) Name() string { return "STUB" } +func (v *vfoSpy) Notify(any) {} +func (v *vfoSpy) Refresh() { + v.ctrl.VFOFrequencyChanged(v.vfoID, 14050000) + v.ctrl.VFOBandChanged(v.vfoID, core.Band20m) + v.ctrl.VFOModeChanged(v.vfoID, core.ModeCW) +} +func (v *vfoSpy) SetFrequency(f core.Frequency) { v.lastFreq = f } +func (v *vfoSpy) SetBand(b core.Band) { v.lastBand = b } +func (v *vfoSpy) SetMode(m core.Mode) { v.lastMode = m } +func (v *vfoSpy) SetXIT(bool, core.Frequency) {} +func (v *vfoSpy) XITActive() bool { return v.xitActive } +func (v *vfoSpy) SetXITActive(active bool) { v.xitActive = active } + +// ---- qsoListSpy ------------------------------------------------------------- + +type qsoListSpy struct { + selectLastCalled int + onSelectLast func() // optional: fired synchronously during SelectLastQSO +} + +func (q *qsoListSpy) resetCalls() { q.selectLastCalled = 0 } + +func (q *qsoListSpy) SelectLastQSO() { + q.selectLastCalled++ + if q.onSelectLast != nil { + q.onSelectLast() + } +} + +// ---- bandmapSpy ------------------------------------------------------------- + +type bandmapSpy struct { + selectedCallsigns []core.Callsign + addedSpots []core.Spot +} + +func (b *bandmapSpy) reset() { + b.selectedCallsigns = nil + b.addedSpots = nil +} + +func (b *bandmapSpy) Add(spot core.Spot) { b.addedSpots = append(b.addedSpots, spot) } +func (b *bandmapSpy) SelectByCallsign(call core.Callsign) { + b.selectedCallsigns = append(b.selectedCallsigns, call) +} + +// ---- viewSpy ---------------------------------------------------------------- + +type viewCall struct { + method string + args []any +} + +type viewSpy struct { + calls []viewCall +} + +func (v *viewSpy) reset() { v.calls = nil } + +func (v *viewSpy) record(method string, args ...any) { + v.calls = append(v.calls, viewCall{method, args}) +} + +func (v *viewSpy) wasCalledWith(method string, args ...any) bool { + for _, c := range v.calls { + if c.method == method && reflect.DeepEqual(c.args, args) { + return true + } + } + return false +} + +func (v *viewSpy) wasCalled(method string) bool { + for _, c := range v.calls { + if c.method == method { + return true + } + } + return false +} + +func (v *viewSpy) assertCalledWith(t *testing.T, method string, args ...any) { + t.Helper() + assert.True(t, v.wasCalledWith(method, args...), + "expected %s(%v) to be called", method, args) +} + +func (v *viewSpy) SetUTC(s string) { v.record("SetUTC", s) } +func (v *viewSpy) SetMyCall(s string) { v.record("SetMyCall", s) } +func (v *viewSpy) SetMyExchange(i int, s string) { v.record("SetMyExchange", i, s) } +func (v *viewSpy) SetFrequency(vfo core.VFOID, f core.Frequency) { + v.record("SetFrequency", vfo, f) +} +func (v *viewSpy) SetBand(vfo core.VFOID, text string) { v.record("SetBand", vfo, text) } +func (v *viewSpy) SetMode(vfo core.VFOID, text string) { v.record("SetMode", vfo, text) } +func (v *viewSpy) SetXITActive(vfo core.VFOID, active bool) { + v.record("SetXITActive", vfo, active) +} +func (v *viewSpy) SetXIT(vfo core.VFOID, active bool, offset core.Frequency) { + v.record("SetXIT", vfo, active, offset) +} +func (v *viewSpy) SetTXState(vfo core.VFOID, ptt bool, parrotActive bool, parrotTimeLeft time.Duration) { + v.record("SetTXState", vfo, ptt, parrotActive, parrotTimeLeft) +} +func (v *viewSpy) SetCallsign(vfo core.VFOID, s string) { v.record("SetCallsign", vfo, s) } +func (v *viewSpy) SetTheirExchange(vfo core.VFOID, index int, text string) { + v.record("SetTheirExchange", vfo, index, text) +} +func (v *viewSpy) SetSerialClaim(vfo core.VFOID, n core.QSONumber, committed bool) { + v.record("SetSerialClaim", vfo, n, committed) +} +func (v *viewSpy) SetActiveVFO(vfo core.VFOID) { + v.record("SetActiveVFO", vfo) +} +func (v *viewSpy) SetActiveField(vfo core.VFOID, field core.EntryField) { + v.record("SetActiveField", vfo, field) +} +func (v *viewSpy) SelectText(vfo core.VFOID, field core.EntryField, s string) { + v.record("SelectText", vfo, field, s) +} +func (v *viewSpy) SetDuplicateMarker(vfo core.VFOID, b bool) { + v.record("SetDuplicateMarker", vfo, b) +} +func (v *viewSpy) SetEditingMarker(vfo core.VFOID, b bool) { + v.record("SetEditingMarker", vfo, b) +} +func (v *viewSpy) ShowMessage(vfo core.VFOID, args ...any) { + v.record("ShowMessage", append([]any{vfo}, args...)...) +} +func (v *viewSpy) ClearMessage(vfo core.VFOID) { v.record("ClearMessage", vfo) } +func (v *viewSpy) SetVFOEnabled(vfo core.VFOID, b bool) { v.record("SetVFOEnabled", vfo, b) } +func (v *viewSpy) SetVFOWorkmode(vfo core.VFOID, w core.Workmode) { + v.record("SetVFOWorkmode", vfo, w) +} + +func (v *viewSpy) SetTXVFO(vfo core.VFOID) { + v.record("SetTXVFO", vfo) +} + +// ---- logbookSpy ------------------------------------------------------------- + +type logbookSpy struct { + nextQSONumber core.QSONumber + lastBand core.Band + lastMode core.Mode + lastExchange []string + duplicates []core.QSO + addedQSOs []core.QSO + updatedQSOs []core.QSO +} + +func (l *logbookSpy) resetCalls() { + l.addedQSOs = nil + l.updatedQSOs = nil +} + +func (l *logbookSpy) NextQSONumber() core.QSONumber { return l.nextQSONumber } +func (l *logbookSpy) LastBand() core.Band { return l.lastBand } +func (l *logbookSpy) LastMode() core.Mode { return l.lastMode } +func (l *logbookSpy) LastExchange() []string { return l.lastExchange } +func (l *logbookSpy) AddQSO(qso core.QSO) { l.addedQSOs = append(l.addedQSOs, qso) } +func (l *logbookSpy) UpdateQSO(qso core.QSO) { l.updatedQSOs = append(l.updatedQSOs, qso) } +func (l *logbookSpy) FindDuplicateQSOs(_ core.Callsign, _ core.Band, _ core.Mode) []core.QSO { + return l.duplicates +} + +// ---- callinfoSpy ------------------------------------------------------------ + +type callinfoInput struct { + vfo core.VFOID + call string +} + +type callinfoSpy struct { + callsigns []string + inputs []callinfoInput +} + +func (c *callinfoSpy) resetCalls() { + c.callsigns = nil + c.inputs = nil +} + +func (c *callinfoSpy) InputChanged(vfo core.VFOID, call string, _ core.Band, _ core.Mode, _ []string) { + c.callsigns = append(c.callsigns, call) + c.inputs = append(c.inputs, callinfoInput{vfo: vfo, call: call}) +} + +// ---- listenerSpy ------------------------------------------------------------ + +type listenerSpy struct { + callsignsEntered []string + callsignsLogged []string + esmEnabledValues []bool +} + +func (l *listenerSpy) resetCalls() { + l.callsignsEntered = nil + l.callsignsLogged = nil + l.esmEnabledValues = nil +} + +func (l *listenerSpy) CallsignEntered(_ core.VFOID, callsign string) { + l.callsignsEntered = append(l.callsignsEntered, callsign) +} + +func (l *listenerSpy) CallsignLogged(callsign string, _ core.Frequency) { + l.callsignsLogged = append(l.callsignsLogged, callsign) +} + +func (l *listenerSpy) ESMEnabled(enabled bool) { + l.esmEnabledValues = append(l.esmEnabledValues, enabled) +} + +// ---- esmViewSpy ------------------------------------------------------------- + +type esmViewSpy struct { + esmEnabled bool + message string +} + +func (e *esmViewSpy) reset() { e.esmEnabled = false; e.message = "" } +func (e *esmViewSpy) SetESMEnabled(en bool) { e.esmEnabled = en } +func (e *esmViewSpy) SetMessage(msg string) { e.message = msg } + +// ---- keyerSpy --------------------------------------------------------------- + +type keyerSpy struct { + seq *int // shared sequence counter (nil until WithKeyer+WithVFOSwitcher) + sentIndices []int + sentTexts []string + sentQuestion string + repeated bool + stopped bool + txSeq int // sequence number of first TX call in this spy cycle +} + +func (k *keyerSpy) reset() { + k.sentIndices = nil + k.sentTexts = nil + k.sentQuestion = "" + k.repeated = false + k.stopped = false + k.txSeq = 0 +} + +func (k *keyerSpy) nextSeq() int { + if k.seq == nil { + return 0 + } + *k.seq++ + return *k.seq +} + +func (k *keyerSpy) SendMacro(index int) { + k.sentIndices = append(k.sentIndices, index) + if k.txSeq == 0 { + k.txSeq = k.nextSeq() + } +} + +func (k *keyerSpy) SendQuestion(q string) { + k.sentQuestion = q + if k.txSeq == 0 { + k.txSeq = k.nextSeq() + } +} + +func (k *keyerSpy) GetText(_ core.Workmode, index int) (string, error) { + return fmt.Sprintf("keyer[%d]", index), nil +} + +func (k *keyerSpy) SendText(text string, _ ...any) { + k.sentTexts = append(k.sentTexts, text) + if k.txSeq == 0 { + k.txSeq = k.nextSeq() + } +} + +func (k *keyerSpy) Repeat() { + k.repeated = true + if k.txSeq == 0 { + k.txSeq = k.nextSeq() + } +} +func (k *keyerSpy) Stop() { k.stopped = true } + +// ---- helpers ---------------------------------------------------------------- + +func scenarioFieldDefinition(fields ...conval.ExchangeField) *conval.Definition { + return &conval.Definition{ + Exchange: []conval.ExchangeDefinition{ + {Fields: fields}, + }, + } +} + +// ---- vfoSwitcherSpy --------------------------------------------------------- + +type vfoSwitcherSpy struct { + seq *int // shared sequence counter + calledCurrentWith []core.VFOID + calledTXWith []core.VFOID + lastSeq int // sequence number of last SetTXVFO call +} + +func (v *vfoSwitcherSpy) reset() { + v.calledCurrentWith = nil + v.calledTXWith = nil + v.lastSeq = 0 +} + +func (v *vfoSwitcherSpy) SetCurrentVFO(vfo core.VFOID) { + v.calledCurrentWith = append(v.calledCurrentWith, vfo) + if v.seq != nil { + *v.seq++ + v.lastSeq = *v.seq + } +} + +func (v *vfoSwitcherSpy) SetTXVFO(vfo core.VFOID) { + v.calledTXWith = append(v.calledTXWith, vfo) + if v.seq != nil { + *v.seq++ + v.lastSeq = *v.seq + } +} + +// ---- Setup methods (H–N additions) ----------------------------------------- + +// WithVFO2 wires the vfo2 spy as VFO2 and enables dual-VFO mode. +func (s *Scenario) WithVFO2() *Scenario { + s.vfo2 = &vfoSpy{ctrl: s.controller, vfoID: core.VFO2} + s.controller.SetVFO(core.VFO2, s.vfo2) + s.controller.RadioChanged("", false) // singleVFO=false → vfo2Enabled=true + return s +} + +// WithVFOSwitcher wires the vfoSwitcherSpy into the controller. +func (s *Scenario) WithVFOSwitcher() *Scenario { + s.vfoSwitcher = &vfoSwitcherSpy{seq: &s.seq} + s.controller.SetVFOSwitcher(s.vfoSwitcher) + return s +} + +// WithLastBand sets the logbook spy's last band (used by LogbookLoaded). +func (s *Scenario) WithLastBand(band core.Band) *Scenario { + s.logbook.lastBand = band + return s +} + +// WithLastMode sets the logbook spy's last mode (used by LogbookLoaded). +func (s *Scenario) WithLastMode(mode core.Mode) *Scenario { + s.logbook.lastMode = mode + return s +} + +// WithLastExchange sets the logbook spy's last exchange (seeded into my-exchange on Clear). +func (s *Scenario) WithLastExchange(exchange []string) *Scenario { + s.logbook.lastExchange = exchange + return s +} + +// WithNextQSONumber sets the logbook spy's next QSO number. +func (s *Scenario) WithNextQSONumber(n core.QSONumber) *Scenario { + s.logbook.nextQSONumber = n + return s +} + +// ---- Action methods (H–N additions) ---------------------------------------- + +// SetFocusedVFO switches the focused VFO. +func (s *Scenario) SetFocusedVFO(vfo core.VFOID) *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.SetFocusedVFO(vfo) + return s +} + +// ToggleFocusedVFO flips the focused VFO between VFO1 and VFO2. +func (s *Scenario) ToggleFocusedVFO() *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.ToggleFocusedVFO() + return s +} + +// FocusVFO1 sets focus to VFO1. +func (s *Scenario) FocusVFO1() *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.FocusVFO1() + return s +} + +// FocusVFO2 sets focus to VFO2. +func (s *Scenario) FocusVFO2() *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.FocusVFO2() + return s +} + +// LogVFO focuses vfo and logs the QSO. +func (s *Scenario) LogVFO(vfo core.VFOID) *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.LogVFO(vfo) + return s +} + +// ClearVFO focuses vfo and clears the row. +func (s *Scenario) ClearVFO(vfo core.VFOID) *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.ClearVFO(vfo) + return s +} + +// RadioChanged fires the radio-changed event. +func (s *Scenario) RadioChanged(singleVFO bool) *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.RadioChanged("", singleVFO) + return s +} + +// CurrentVFOChanged fires the rig-side VFO change event. +func (s *Scenario) CurrentVFOChanged(vfo core.VFOID) *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.CurrentVFOChanged(vfo) + return s +} + +// VFOXITChanged fires the XIT-changed event. +func (s *Scenario) VFOXITChanged(vfo core.VFOID, active bool, offset core.Frequency) *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.VFOXITChanged(vfo, active, offset) + return s +} + +// XITActiveChanged fires the XIT-active event. +func (s *Scenario) XITActiveChanged(active bool) *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.XITActiveChanged(active) + return s +} + +// VFOPTTChanged fires the PTT-changed event. +func (s *Scenario) VFOPTTChanged(vfo core.VFOID, active bool) *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.VFOPTTChanged(vfo, active) + return s +} + +// SendQuestion dispatches SendQuestion on the controller. +func (s *Scenario) SendQuestion() *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.SendQuestion() + return s +} + +// RepeatLastTransmission dispatches RepeatLastTransmission. +func (s *Scenario) RepeatLastTransmission() *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.RepeatLastTransmission() + return s +} + +// StopTX dispatches StopTX. +func (s *Scenario) StopTX() *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.StopTX() + return s +} + +// ParrotActive fires the parrot-active event. +func (s *Scenario) ParrotActive(active bool) *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.ParrotActive(active) + return s +} + +// ParrotTimeLeft fires the parrot-time-left event. +func (s *Scenario) ParrotTimeLeft(d time.Duration) *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.ParrotTimeLeft(d) + return s +} + +// SerialSent fires the serial-sent event. +func (s *Scenario) SerialSent() *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.SerialSent() + return s +} + +// StationChanged fires the station-changed event with the given callsign. +func (s *Scenario) StationChanged(callsign string) *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.StationChanged(core.Station{Callsign: core.MustParseCallsign(callsign)}) + return s +} + +// LogbookLoaded fires the logbook-loaded event. +func (s *Scenario) LogbookLoaded() *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.LogbookLoaded() + return s +} + +// RefreshView pushes current input to the view. +func (s *Scenario) RefreshView() *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.RefreshView() + return s +} + +// Activate reapplies the active field to the view. +func (s *Scenario) Activate() *Scenario { + s.t.Helper() + s.resetSpies() + s.controller.Activate() + return s +} + +// ---- Assertion methods (H–N additions) ------------------------------------- + +// AssertVFOSwitcherTXCalled asserts the vfoSwitcher was commanded with the given VFO. +func (s *Scenario) AssertVFOSwitcherTXCalled(vfo core.VFOID) *Scenario { + s.t.Helper() + if s.vfoSwitcher == nil { + s.t.Error("vfoSwitcher spy not wired (call WithVFOSwitcher() in setup)") + return s + } + found := false + for _, v := range s.vfoSwitcher.calledTXWith { + if v == vfo { + found = true + break + } + } + assert.True(s.t, found, "expected vfoSwitcher.SetTXVFO(%v) to be called", vfo) + return s +} + +// AssertTXVFOBeforeKeyer asserts SetTXVFO(vfo) was called before any keyer TX method. +func (s *Scenario) AssertTXVFOBeforeKeyer(vfo core.VFOID) *Scenario { + s.t.Helper() + if s.vfoSwitcher == nil { + s.t.Error("vfoSwitcher spy not wired (call WithVFOSwitcher() in setup)") + return s + } + s.AssertVFOSwitcherTXCalled(vfo) + assert.NotZero(s.t, s.vfoSwitcher.lastSeq, "SetTXVFO was not called") + assert.NotZero(s.t, s.keyer.txSeq, "no keyer TX method was called") + assert.Less(s.t, s.vfoSwitcher.lastSeq, s.keyer.txSeq, + "SetTXVFO must be called before keyer TX (vfoSwitch seq=%d, keyer seq=%d)", + s.vfoSwitcher.lastSeq, s.keyer.txSeq) + return s +} + +// AssertVFO2Enabled asserts view.SetVFOEnabled(VFO2, enabled) was called. +func (s *Scenario) AssertVFO2Enabled(enabled bool) *Scenario { + s.t.Helper() + s.view.assertCalledWith(s.t, "SetVFOEnabled", core.VFO2, enabled) + return s +} + +// AssertXITView asserts view.SetXIT(vfo, active, offset) was called. +func (s *Scenario) AssertXITView(vfo core.VFOID, active bool, offset core.Frequency) *Scenario { + s.t.Helper() + s.view.assertCalledWith(s.t, "SetXIT", vfo, active, offset) + return s +} + +// AssertXITActiveView asserts view.SetXITActive(vfo, active) was called. +func (s *Scenario) AssertXITActiveView(vfo core.VFOID, active bool) *Scenario { + s.t.Helper() + s.view.assertCalledWith(s.t, "SetXITActive", vfo, active) + return s +} + +// AssertTXStateView asserts view.SetTXState(vfo, ptt, parrotActive, parrotTimeLeft) was called. +func (s *Scenario) AssertTXStateView(vfo core.VFOID, ptt bool, parrotActive bool, parrotTimeLeft time.Duration) *Scenario { + s.t.Helper() + s.view.assertCalledWith(s.t, "SetTXState", vfo, ptt, parrotActive, parrotTimeLeft) + return s +} + +// AssertMyCallView asserts view.SetMyCall(call) was called. +func (s *Scenario) AssertMyCallView(call string) *Scenario { + s.t.Helper() + s.view.assertCalledWith(s.t, "SetMyCall", call) + return s +} + +// AssertActiveVFO asserts view.SetActiveVFO(vfo) was called. +func (s *Scenario) AssertActiveVFO(vfo core.VFOID) *Scenario { + s.t.Helper() + s.view.assertCalledWith(s.t, "SetActiveVFO", vfo) + return s +} + +// AssertVFOSwitcherCurrentCalled asserts vfoSwitcher.SetCurrentVFO(vfo) was called. +func (s *Scenario) AssertVFOSwitcherCurrentCalled(vfo core.VFOID) *Scenario { + s.t.Helper() + if s.vfoSwitcher == nil { + s.t.Error("vfoSwitcher spy not wired (call WithVFOSwitcher() in setup)") + return s + } + found := false + for _, v := range s.vfoSwitcher.calledCurrentWith { + if v == vfo { + found = true + break + } + } + assert.True(s.t, found, "expected vfoSwitcher.SetCurrentVFO(%v) to be called", vfo) + return s +} + +// AssertSerialClaimView asserts view.SetSerialClaim(vfo, serial, committed) was called. +func (s *Scenario) AssertSerialClaimView(vfo core.VFOID, serial core.QSONumber, committed bool) *Scenario { + s.t.Helper() + s.view.assertCalledWith(s.t, "SetSerialClaim", vfo, serial, committed) + return s +} + +// AssertSerialCommitted asserts the serial claim for vfo is committed. +func (s *Scenario) AssertSerialCommitted(vfo core.VFOID) *Scenario { + s.t.Helper() + assert.True(s.t, s.controller.IsSerialCommitted(vfo), + "expected serial on VFO %d to be committed", vfo) + return s +} + +// AssertSerialNotCommitted asserts the serial claim for vfo is not committed. +func (s *Scenario) AssertSerialNotCommitted(vfo core.VFOID) *Scenario { + s.t.Helper() + assert.False(s.t, s.controller.IsSerialCommitted(vfo), + "expected serial on VFO %d to NOT be committed", vfo) + return s +} + +// AssertKeyerSentQuestion asserts keyer.SendQuestion(q) was called. +func (s *Scenario) AssertKeyerSentQuestion(q string) *Scenario { + s.t.Helper() + assert.Equal(s.t, q, s.keyer.sentQuestion, + "expected keyer.SendQuestion(%q)", q) + return s +} + +// AssertKeyerRepeated asserts keyer.Repeat() was called. +func (s *Scenario) AssertKeyerRepeated() *Scenario { + s.t.Helper() + assert.True(s.t, s.keyer.repeated, "expected keyer.Repeat() to be called") + return s +} + +// AssertKeyerStopped asserts keyer.Stop() was called. +func (s *Scenario) AssertKeyerStopped() *Scenario { + s.t.Helper() + assert.True(s.t, s.keyer.stopped, "expected keyer.Stop() to be called") + return s +} diff --git a/core/entry/null.go b/core/entry/null.go index c1e1d428..9c039f73 100644 --- a/core/entry/null.go +++ b/core/entry/null.go @@ -10,28 +10,35 @@ import ( type nullView struct{} -func (n *nullView) SetUTC(string) {} -func (n *nullView) SetMyCall(string) {} -func (n *nullView) SetFrequency(core.Frequency) {} -func (n *nullView) SetCallsign(string) {} -func (n *nullView) SetBand(text string) {} -func (n *nullView) SetMode(text string) {} -func (n *nullView) SetXITActive(active bool) {} -func (n *nullView) SetXIT(active bool, offset core.Frequency) {} -func (n *nullView) SetTXState(ptt bool, parrotActive bool, parrotTimeLeft time.Duration) {} -func (n *nullView) SetMyExchange(int, string) {} -func (n *nullView) SetTheirExchange(int, string) {} -func (n *nullView) SetMyExchangeFields([]core.ExchangeField) {} -func (n *nullView) SetTheirExchangeFields([]core.ExchangeField) {} -func (n *nullView) SetActiveField(core.EntryField) {} -func (n *nullView) SelectText(core.EntryField, string) {} -func (n *nullView) SetDuplicateMarker(bool) {} -func (n *nullView) SetEditingMarker(bool) {} -func (n *nullView) ShowMessage(...any) {} -func (n *nullView) ClearMessage() {} +func (n *nullView) SetUTC(string) {} +func (n *nullView) SetMyCall(string) {} +func (n *nullView) SetFrequency(core.VFOID, core.Frequency) {} +func (n *nullView) SetCallsign(core.VFOID, string) {} +func (n *nullView) SetBand(vfo core.VFOID, text string) {} +func (n *nullView) SetMode(vfo core.VFOID, text string) {} +func (n *nullView) SetXITActive(vfo core.VFOID, active bool) {} +func (n *nullView) SetXIT(vfo core.VFOID, active bool, offset core.Frequency) {} +func (n *nullView) SetTXState(vfo core.VFOID, ptt bool, parrotActive bool, parrotTimeLeft time.Duration) { +} +func (n *nullView) SetMyExchange(int, string) {} +func (n *nullView) SetTheirExchange(core.VFOID, int, string) {} +func (n *nullView) SetMyExchangeFields([]core.ExchangeField) {} +func (n *nullView) SetTheirExchangeFields([]core.ExchangeField) {} +func (n *nullView) SetSerialClaim(core.VFOID, core.QSONumber, bool) {} +func (n *nullView) SetActiveVFO(core.VFOID) {} +func (n *nullView) SetActiveField(core.VFOID, core.EntryField) {} +func (n *nullView) SelectText(core.VFOID, core.EntryField, string) {} +func (n *nullView) SetDuplicateMarker(core.VFOID, bool) {} +func (n *nullView) SetEditingMarker(core.VFOID, bool) {} +func (n *nullView) ShowMessage(core.VFOID, ...any) {} +func (n *nullView) ClearMessage(core.VFOID) {} +func (n *nullView) SetVFOEnabled(core.VFOID, bool) {} +func (n *nullView) SetVFOWorkmode(core.VFOID, core.Workmode) {} +func (n *nullView) SetTXVFO(core.VFOID) {} type nullVFO struct{} +func (n *nullVFO) Name() string { return "" } func (n *nullVFO) Notify(any) {} func (n *nullVFO) Active() bool { return false } func (n *nullVFO) Refresh() {} @@ -53,7 +60,7 @@ func (n *nullLogbook) UpdateQSO(core.QSO) {} type nullCallinfo struct{} -func (n *nullCallinfo) InputChanged(string, core.Band, core.Mode, []string) {} +func (n *nullCallinfo) InputChanged(core.VFOID, string, core.Band, core.Mode, []string) {} type nullBandmap struct{} diff --git a/core/entry/serial_claims.go b/core/entry/serial_claims.go new file mode 100644 index 00000000..5381537a --- /dev/null +++ b/core/entry/serial_claims.go @@ -0,0 +1,82 @@ +package entry + +import "github.com/ftl/hellocontest/core" + +// SerialClaims holds per-VFO serial-number reservations and provides +// collision-avoidance between VFOs. A claim transitions through three states: +// - unclaimed (claimed[vfo] == 0): no serial reserved +// - claimed (claimed[vfo] != 0, committed[vfo] == false): serial reserved, recyclable on release +// - committed (claimed[vfo] != 0, committed[vfo] == true): serial sent over air (UI-only distinction) +// +// Both claimed and committed serials are recyclable on release. The committed +// flag is used only for UI visualization (bold serial label). +type SerialClaims struct { + claimed []core.QSONumber // per-VFO reserved serial; 0 = unclaimed + snapshot []core.QSONumber // logbook.NextQSONumber() value at claim time + committed []bool // per-VFO: serial was sent over air +} + +func newSerialClaims() SerialClaims { + return SerialClaims{ + claimed: make([]core.QSONumber, core.VFOCount), + snapshot: make([]core.QSONumber, core.VFOCount), + committed: make([]bool, core.VFOCount), + } +} + +// nextUnclaimed returns the first serial for forVFO not already claimed by the other VFO. +func (s *SerialClaims) nextUnclaimed(forVFO core.VFOID, base core.QSONumber) core.QSONumber { + otherVFO := core.VFO1 + if forVFO == core.VFO1 { + otherVFO = core.VFO2 + } + if s.claimed[otherVFO] != 0 && s.claimed[otherVFO] == base { + return base + 1 + } + return base +} + +// DisplayedSerial returns the serial currently visible to the operator on vfo's row: +// the claimed value if any, otherwise the next unclaimed preview. +func (s *SerialClaims) DisplayedSerial(vfo core.VFOID, base core.QSONumber) core.QSONumber { + if s.claimed[vfo] != 0 { + return s.claimed[vfo] + } + return s.nextUnclaimed(vfo, base) +} + +// AllDisplayed returns the displayed serial for every VFO, in VFO index order. +func (s *SerialClaims) AllDisplayed(base core.QSONumber) []core.QSONumber { + out := make([]core.QSONumber, len(s.claimed)) + for vfo := range len(s.claimed) { + out[vfo] = s.DisplayedSerial(core.VFOID(vfo), base) + } + return out +} + +// ClaimNext reserves the next unclaimed serial for vfo using base as the logbook reference. +// Sticky: subsequent calls while a claim exists are no-ops. +func (s *SerialClaims) ClaimNext(vfo core.VFOID, base core.QSONumber) { + if s.claimed[vfo] != 0 { + return + } + s.claimed[vfo] = s.nextUnclaimed(vfo, base) + s.snapshot[vfo] = base + s.committed[vfo] = false +} + +// Commit marks the claim for vfo as committed (serial was sent over air). +// No-op if vfo has no claim. +func (s *SerialClaims) Commit(vfo core.VFOID) { + if s.claimed[vfo] == 0 { + return + } + s.committed[vfo] = true +} + +// Release frees the claim slot for vfo. +func (s *SerialClaims) Release(vfo core.VFOID) { + s.claimed[vfo] = 0 + s.snapshot[vfo] = 0 + s.committed[vfo] = false +} diff --git a/core/entry/serial_claims_test.go b/core/entry/serial_claims_test.go new file mode 100644 index 00000000..b286bd77 --- /dev/null +++ b/core/entry/serial_claims_test.go @@ -0,0 +1,181 @@ +package entry + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/ftl/hellocontest/core" +) + +func newTestClaims(vfo1Claimed, vfo2Claimed core.QSONumber) SerialClaims { + s := newSerialClaims() + s.claimed[core.VFO1] = vfo1Claimed + s.claimed[core.VFO2] = vfo2Claimed + return s +} + +// --- nextUnclaimed --- + +func TestSerialClaims_nextUnclaimed_noConflict(t *testing.T) { + s := newSerialClaims() + assert.Equal(t, core.QSONumber(5), s.nextUnclaimed(core.VFO1, 5)) + assert.Equal(t, core.QSONumber(5), s.nextUnclaimed(core.VFO2, 5)) +} + +func TestSerialClaims_nextUnclaimed_otherVFOClaimedSameBase_skips(t *testing.T) { + tt := []struct { + name string + forVFO core.VFOID + other core.VFOID + }{ + {"VFO1 skips VFO2", core.VFO1, core.VFO2}, + {"VFO2 skips VFO1", core.VFO2, core.VFO1}, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + s := newSerialClaims() + s.claimed[tc.other] = 5 + assert.Equal(t, core.QSONumber(6), s.nextUnclaimed(tc.forVFO, 5)) + }) + } +} + +func TestSerialClaims_nextUnclaimed_otherVFOClaimedDifferentBase_noSkip(t *testing.T) { + s := newTestClaims(0, 3) // VFO2 claimed 3, base is 5 + assert.Equal(t, core.QSONumber(5), s.nextUnclaimed(core.VFO1, 5)) +} + +func TestSerialClaims_nextUnclaimed_otherVFOUnclaimed_noSkip(t *testing.T) { + s := newSerialClaims() // both unclaimed + assert.Equal(t, core.QSONumber(5), s.nextUnclaimed(core.VFO1, 5)) + assert.Equal(t, core.QSONumber(5), s.nextUnclaimed(core.VFO2, 5)) +} + +// --- DisplayedSerial --- + +func TestSerialClaims_DisplayedSerial_noClaim_returnsBase(t *testing.T) { + s := newSerialClaims() + assert.Equal(t, core.QSONumber(7), s.DisplayedSerial(core.VFO1, 7)) + assert.Equal(t, core.QSONumber(7), s.DisplayedSerial(core.VFO2, 7)) +} + +func TestSerialClaims_DisplayedSerial_noClaim_otherConflicts_skips(t *testing.T) { + s := newTestClaims(0, 7) // VFO2 holds 7 + // VFO1 has no claim; VFO2 claimed base → VFO1 must skip + assert.Equal(t, core.QSONumber(8), s.DisplayedSerial(core.VFO1, 7)) +} + +func TestSerialClaims_DisplayedSerial_hasClaim_returnsClaimed(t *testing.T) { + s := newTestClaims(42, 0) + // VFO1's claimed value is returned regardless of base or other VFO + assert.Equal(t, core.QSONumber(42), s.DisplayedSerial(core.VFO1, 99)) +} + +func TestSerialClaims_DisplayedSerial_bothClaimed_eachReturnsOwn(t *testing.T) { + s := newTestClaims(10, 11) + assert.Equal(t, core.QSONumber(10), s.DisplayedSerial(core.VFO1, 10)) + assert.Equal(t, core.QSONumber(11), s.DisplayedSerial(core.VFO2, 10)) +} + +// --- AllDisplayed --- + +func TestSerialClaims_AllDisplayed_length(t *testing.T) { + s := newSerialClaims() + result := s.AllDisplayed(1) + assert.Len(t, result, int(core.VFOCount)) +} + +func TestSerialClaims_AllDisplayed_noClaims_bothReturnBase(t *testing.T) { + s := newSerialClaims() + result := s.AllDisplayed(3) + assert.Equal(t, core.QSONumber(3), result[core.VFO1]) + assert.Equal(t, core.QSONumber(3), result[core.VFO2]) +} + +func TestSerialClaims_AllDisplayed_VFO1Claimed_VFO2SkipsIfConflict(t *testing.T) { + s := newTestClaims(5, 0) // VFO1 holds 5 + result := s.AllDisplayed(5) + assert.Equal(t, core.QSONumber(5), result[core.VFO1]) // own claim + assert.Equal(t, core.QSONumber(6), result[core.VFO2]) // bumped +} + +func TestSerialClaims_AllDisplayed_VFO2Claimed_VFO1SkipsIfConflict(t *testing.T) { + s := newTestClaims(0, 5) // VFO2 holds 5 + result := s.AllDisplayed(5) + assert.Equal(t, core.QSONumber(6), result[core.VFO1]) // bumped + assert.Equal(t, core.QSONumber(5), result[core.VFO2]) // own claim +} + +func TestSerialClaims_AllDisplayed_bothClaimed_noInteraction(t *testing.T) { + s := newTestClaims(5, 6) + result := s.AllDisplayed(5) + assert.Equal(t, core.QSONumber(5), result[core.VFO1]) + assert.Equal(t, core.QSONumber(6), result[core.VFO2]) +} + +// --- ClaimNext --- + +func TestSerialClaims_ClaimNext_setsClaimedAndSnapshot(t *testing.T) { + s := newSerialClaims() + s.ClaimNext(core.VFO1, 5) + assert.Equal(t, core.QSONumber(5), s.claimed[core.VFO1]) + assert.Equal(t, core.QSONumber(5), s.snapshot[core.VFO1]) +} + +func TestSerialClaims_ClaimNext_otherVFOConflict_bumps(t *testing.T) { + s := newTestClaims(0, 5) // VFO2 holds 5 + s.ClaimNext(core.VFO1, 5) + assert.Equal(t, core.QSONumber(6), s.claimed[core.VFO1]) + assert.Equal(t, core.QSONumber(5), s.snapshot[core.VFO1]) +} + +func TestSerialClaims_ClaimNext_sticky_secondCallNoOp(t *testing.T) { + s := newSerialClaims() + s.ClaimNext(core.VFO1, 5) + s.ClaimNext(core.VFO1, 9) // base advanced; must not change claim + assert.Equal(t, core.QSONumber(5), s.claimed[core.VFO1]) + assert.Equal(t, core.QSONumber(5), s.snapshot[core.VFO1]) +} + +func TestSerialClaims_ClaimNext_bothVFOs_collisionAvoided(t *testing.T) { + s := newSerialClaims() + s.ClaimNext(core.VFO1, 5) // VFO1 gets 5 + s.ClaimNext(core.VFO2, 5) // VFO2 must bump to 6 + assert.Equal(t, core.QSONumber(5), s.claimed[core.VFO1]) + assert.Equal(t, core.QSONumber(6), s.claimed[core.VFO2]) +} + +func TestSerialClaims_ClaimNext_doesNotAffectOtherVFO(t *testing.T) { + s := newSerialClaims() + s.ClaimNext(core.VFO1, 5) + assert.Equal(t, core.QSONumber(0), s.claimed[core.VFO2]) + assert.Equal(t, core.QSONumber(0), s.snapshot[core.VFO2]) +} + +// --- Release --- + +func TestSerialClaims_Release_clearsClaim(t *testing.T) { + s := newTestClaims(5, 0) + s.snapshot[core.VFO1] = 5 + s.Release(core.VFO1) + assert.Equal(t, core.QSONumber(0), s.claimed[core.VFO1]) + assert.Equal(t, core.QSONumber(0), s.snapshot[core.VFO1]) +} + +func TestSerialClaims_Release_doesNotAffectOtherVFO(t *testing.T) { + s := newTestClaims(5, 7) + s.snapshot[core.VFO2] = 7 + s.Release(core.VFO1) + assert.Equal(t, core.QSONumber(7), s.claimed[core.VFO2]) + assert.Equal(t, core.QSONumber(7), s.snapshot[core.VFO2]) +} + +func TestSerialClaims_Release_allowsReClaim(t *testing.T) { + s := newSerialClaims() + s.ClaimNext(core.VFO1, 5) + s.Release(core.VFO1) + s.ClaimNext(core.VFO1, 9) // now free; should claim 9 + assert.Equal(t, core.QSONumber(9), s.claimed[core.VFO1]) + assert.Equal(t, core.QSONumber(9), s.snapshot[core.VFO1]) +} diff --git a/core/entry/usecases_test.go b/core/entry/usecases_test.go new file mode 100644 index 00000000..1fcc0d84 --- /dev/null +++ b/core/entry/usecases_test.go @@ -0,0 +1,2240 @@ +package entry_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ftl/hellocontest/core" +) + +// A1. Enter callsign +// Pre: active field = CallsignField on focused VFO. +// Post: input[focused].callsign = typed text; CallsignEntered listeners notified; +// callinfo input-changed notified; if text parses as callsign AND not editing: +// sticky serial claim held; if duplicate: error on callsign field, else message cleared. +// Invariants: other VFO's row; editing flag; selected band/mode/frequency; logbook. + +func TestA1_EnterCallsign_Valid(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + AssertCallsignEntered("DL1ABC"). + AssertCallinfoNotified("DL1ABC"). + AssertSerialClaimed(). + AssertMessageCleared(core.VFO1). + AssertNoLogbookWrite() +} + +func TestA1_EnterCallsign_Duplicate(t *testing.T) { + dl1abc, _ := core.ParseCallsign("DL1ABC") + dupe := core.QSO{Callsign: dl1abc, MyNumber: 1} + + NewScenario(t). + WithClassicExchange(). + WithDuplicateQSO(dupe). + Enter("DL1ABC"). + AssertCallsignEntered("DL1ABC"). + AssertCallinfoNotified("DL1ABC"). + AssertSerialClaimed(). + AssertMessageShown(core.VFO1). + AssertNoLogbookWrite() +} + +func TestA1_EnterCallsign_PartialCallsign(t *testing.T) { + // "DL" does not parse as a callsign: no serial claim, no duplicate check, + // no message cleared or shown. + NewScenario(t). + WithClassicExchange(). + Enter("DL"). + AssertCallsignEntered("DL"). + AssertCallinfoNotified("DL"). + AssertNoLogbookWrite() +} + +// A2. Leave callsign field +// Pre: active field = CallsignField; callsign parses; predicted exchange present iff +// its length matches theirExchangeFields. +// Post: empty predictable their-exchange slots filled from prediction; +// duplicate marker = (duplicate exists AND (not editing OR editQSO.Callsign != current)). +// Invariants: callsign input text; serial claim; logbook. + +func TestA2_LeaveCallsign_NoDuplicate(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + GotoNextField(). + AssertDuplicateMarker(core.VFO1, false). + AssertNoLogbookWrite() +} + +func TestA2_LeaveCallsign_Duplicate(t *testing.T) { + dl1abc, _ := core.ParseCallsign("DL1ABC") + dupe := core.QSO{Callsign: dl1abc, MyNumber: 1} + + NewScenario(t). + WithClassicExchange(). + WithDuplicateQSO(dupe). + Enter("DL1ABC"). + GotoNextField(). + AssertDuplicateMarker(core.VFO1, true). + AssertNoLogbookWrite() +} + +func TestA2_LeaveCallsign_PredictionFillsEmptySlot(t *testing.T) { + // ClassicExchange: [RST(not predictable), Serial(not predictable), GenericText(predictable)] + // Prediction fills slot 3 (1-based) when empty. + frame := core.CallinfoFrame{ + PredictedExchange: []string{"599", "042", "OE"}, + } + + NewScenario(t). + WithClassicExchange(). + WithCallinfoFrame(core.VFO1, frame). + Enter("OE5XYZ"). + GotoNextField(). + AssertTheirExchangeSet(core.VFO1, 3, "OE"). + AssertNoLogbookWrite() +} + +func TestA2_LeaveCallsign_Editing_SameCallsign_NoMarker(t *testing.T) { + // Editing the QSO's own callsign: duplicate marker stays false + // even though the callsign appears in the logbook. + dl1abc, _ := core.ParseCallsign("DL1ABC") + editedQSO := core.QSO{Callsign: dl1abc, MyNumber: 7} + + NewScenario(t). + WithClassicExchange(). + WithDuplicateQSO(editedQSO). + SelectQSO(editedQSO). + GotoNextField(). + AssertDuplicateMarker(core.VFO1, false). + AssertNoLogbookWrite() +} + +func TestA2_LeaveCallsign_Editing_DifferentCallsign_ShowsMarker(t *testing.T) { + // Editing: changed callsign that is a duplicate → marker shown. + dl1abc, _ := core.ParseCallsign("DL1ABC") + dl2xyz, _ := core.ParseCallsign("DL2XYZ") + editedQSO := core.QSO{Callsign: dl1abc, MyNumber: 7} + dupeQSO := core.QSO{Callsign: dl2xyz, MyNumber: 3} + + NewScenario(t). + WithClassicExchange(). + WithDuplicateQSO(dupeQSO). + SelectQSO(editedQSO). // edit mode, shows DL1ABC + Enter("DL2XYZ"). // change to a different (duped) callsign + GotoNextField(). + AssertDuplicateMarker(core.VFO1, true). + AssertNoLogbookWrite() +} + +// A3. Goto next field (Tab) +// Pre: any active field. +// Post: if leaving callsign: A2 ran; active field = next per transition map +// (callsign → first their-exchange; last their-exchange and any my-exchange → callsign; +// band/mode → callsign); view active field set; ESM state recomputed. +// Invariants: input values; per-VFO data other than active field; focused VFO. + +func TestA3_GotoNextField_Callsign_GoesToTheirExchange1(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + GotoNextField(). + AssertActiveField(core.VFO1, core.TheirExchangeField(1)) +} + +func TestA3_GotoNextField_TheirExchange1_GoesToTheirExchange2(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + GotoNextField(). // → TheirExchange1 + GotoNextField(). // → TheirExchange2 + AssertActiveField(core.VFO1, core.TheirExchangeField(2)) +} + +func TestA3_GotoNextField_TheirExchange2_GoesToTheirExchange3(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + GotoNextField(). // → TheirExchange1 + GotoNextField(). // → TheirExchange2 + GotoNextField(). // → TheirExchange3 + AssertActiveField(core.VFO1, core.TheirExchangeField(3)) +} + +func TestA3_GotoNextField_LastTheirExchange_GoesToCallsign(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + GotoNextField(). // → TheirExchange1 + GotoNextField(). // → TheirExchange2 + GotoNextField(). // → TheirExchange3 + GotoNextField(). // → Callsign (last their-exchange wraps) + AssertActiveField(core.VFO1, core.CallsignField) +} + +func TestA3_GotoNextField_MyExchange_GoesToCallsign(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + SetActiveField(core.MyExchangeField(1)). + GotoNextField(). + AssertActiveField(core.VFO1, core.CallsignField) +} + +func TestA3_GotoNextField_Band_GoesToCallsign(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + SetActiveField(core.BandField). + GotoNextField(). + AssertActiveField(core.VFO1, core.CallsignField) +} + +func TestA3_GotoNextField_Mode_GoesToCallsign(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + SetActiveField(core.ModeField). + GotoNextField(). + AssertActiveField(core.VFO1, core.CallsignField) +} + +func TestA3_GotoNextField_OtherField_GoesToCallsign(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + SetActiveField(core.OtherField). + GotoNextField(). + AssertActiveField(core.VFO1, core.CallsignField) +} + +// A4. Goto next placeholder +// Pre: none. +// Post: active field = CallsignField; view selects FilterPlaceholder text on callsign field. +// Invariants: all input values; focused VFO. + +func TestA4_GotoNextPlaceholder_FocusesCallsignAndSelectsPlaceholder(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + GotoNextPlaceholder(). + AssertActiveField(core.VFO1, core.CallsignField). + AssertTextSelected(core.VFO1, core.CallsignField, core.FilterPlaceholder) +} + +// A5. Set active field +// Pre: none. +// Post: activeField[focused] = given field; ESM recomputed. +// Invariants: view focus marker not directly updated (caller's job); input values. + +func TestA5_SetActiveField_FieldIsSet(t *testing.T) { + // SetActiveField does NOT call view.SetActiveField (caller's job per use case). + // Verify via subsequent GotoNextField transition from the set field. + NewScenario(t). + WithClassicExchange(). + SetActiveField(core.BandField). + GotoNextField(). + AssertActiveField(core.VFO1, core.CallsignField) +} + +// A6. Enter text into a field +// Pre: active field set; for exchange fields, exchange[i] slot exists. +// Post: corresponding input slot updated; per active field: callsign → A1 effects; +// band → B1 effects; mode → B2 effects; their-exchange → callinfo notified, +// error cleared; ESM recomputed. +// Invariants: other VFO; editing flag. + +func TestA6_EnterTheirExchange_NotifiesCallinfo(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + GotoNextField(). // → TheirExchange1 + Enter("599"). + AssertCallinfoNotified("DL1ABC") +} + +func TestA6_EnterMyExchange_UpdatesInputSlot(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + SetActiveField(core.MyExchangeField(3)). + Enter("OE"). + AssertMyExchangeValue(3, "OE") +} + +// B1. Select band (band field) +// Pre: input parses as band. +// Post: selectedBand[focused] = parsed band; VFO rig commanded to that band; +// callsign re-entered (A1 effects). +// Invariants: other VFO band; mode; frequency; logbook. + +func TestB1_SelectBand_Valid(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + SetActiveField(core.BandField). + Enter("40m"). + AssertVFOBand(core.Band40m). + AssertCallinfoNotified("DL1ABC"). + AssertNoLogbookWrite() +} + +// B2. Select mode (mode field) +// Pre: input parses as mode. +// Post: selectedMode[focused] = parsed mode; VFO rig commanded; if generateReport: +// my/their report regenerated for focused VFO; callsign re-entered. +// Invariants: other VFO mode; band; frequency; logbook. + +func TestB2_SelectMode_Valid(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + SetActiveField(core.ModeField). + Enter("SSB"). + AssertVFOMode(core.ModeSSB). + AssertCallinfoNotified("DL1ABC"). + AssertNoLogbookWrite() +} + +func TestB2_SelectMode_GeneratesReport(t *testing.T) { + // WithClassicExchangeAndReport: GenerateReport=true; mode SSB → "59" + // Field 1 = RST: both my-exchange view and their-exchange view updated. + NewScenario(t). + WithClassicExchangeAndReport(). + Enter("DL1ABC"). + SetActiveField(core.ModeField). + Enter("SSB"). + AssertMyExchangeView(1, "59"). + AssertTheirExchangeSet(core.VFO1, 1, "59"). + AssertNoLogbookWrite() +} + +// B3. Enter frequency in kHz via callsign field +// Pre: active field = CallsignField; input parses as integer. +// Post: focused VFO rig commanded with kHz*1000; callsign input cleared; +// A1 effects with empty callsign. +// Invariants: band; mode; other VFO; logbook. + +func TestB3_EnterFrequency_ViaCallsignField(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("14250"). + PressEnter(). + AssertVFOFrequency(14250000). + AssertCallsignView(core.VFO1, ""). + AssertCallinfoNotified(""). + AssertNoLogbookWrite() +} + +// B4. Enter band via callsign field +// Pre: active field = CallsignField; input parses as band. +// Post: B1 effects (via bandEntered); callsign input cleared; A1 effects with empty callsign. +// Invariants: other VFO; logbook. + +func TestB4_EnterBand_ViaCallsignField(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("40m"). + PressEnter(). + AssertVFOBand(core.Band40m). + AssertCallsignView(core.VFO1, ""). + AssertCallinfoNotified(""). + AssertNoLogbookWrite() +} + +// B5. Jump to bandmap call via callsign field +// Pre: active field = CallsignField; input starts with @ and remainder parses as callsign. +// Post: bandmap SelectByCallsign invoked; callsign input cleared; A1 effects with empty callsign. +// Invariants: logbook; rig; other VFO. + +func TestB5_JumpToBandmapCall(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("@DL1ABC"). + PressEnter(). + AssertBandmapSelected("DL1ABC"). + AssertCallsignView(core.VFO1, ""). + AssertCallinfoNotified(""). + AssertNoLogbookWrite() +} + +// B6. XIT active toggled by UI +// Pre: none. +// Post: VFO1 rig commanded with new XIT-active flag; view active field reapplied. +// Invariants: input values; VFO2 XIT. + +func TestB6_XITActive_Toggle(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + SetXITActive(true). + AssertXITActiveCommanded(true). + AssertActiveField(core.VFO1, core.CallsignField) +} + +// C1. Log valid QSO +// Pre: input parses fully (callsign, band, mode, their-exchange complete and valid). +// Post: AddQSO called; CallsignLogged listeners notified; row cleared (active=callsign). +// Invariants: other VFO input; contest definition. + +func TestC1_LogValidQSO(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + GotoNextField(). // → TheirExchange1 + Enter("599"). + GotoNextField(). // → TheirExchange2 + Enter("042"). + GotoNextField(). // → TheirExchange3 + Enter("OE"). + PressEnter(). + AssertQSOAdded(). + AssertQSOAddedCallsign("DL1ABC"). + AssertCallsignLogged("DL1ABC"). + AssertActiveField(core.VFO1, core.CallsignField) +} + +func TestC1_LogValidQSO_SearchPounce_AddsWorkedSpotToBandmap(t *testing.T) { + // workmode = S&P → WorkedSpot added to bandmap after logging. + NewScenario(t). + WithClassicExchange(). + WithWorkmode(core.SearchPounce). + Enter("DL1ABC"). + GotoNextField().Enter("599"). + GotoNextField().Enter("042"). + GotoNextField().Enter("OE"). + PressEnter(). + AssertQSOAdded(). + AssertBandmapAddedCallsign("DL1ABC"). + AssertBandmapSpotSource(core.WorkedSpot) +} + +func TestC1_LogDuplicateQSO_StillAddsToLogbook(t *testing.T) { + // Duplicate callsign: duplicate marker shown but QSO still added (C1 makes no exception). + dl1abc, _ := core.ParseCallsign("DL1ABC") + dupe := core.QSO{Callsign: dl1abc, MyNumber: 1} + + NewScenario(t). + WithClassicExchange(). + WithDuplicateQSO(dupe). + Enter("DL1ABC"). + GotoNextField().Enter("599"). + GotoNextField().Enter("042"). + GotoNextField().Enter("OE"). + PressEnter(). + AssertQSOAdded(). + AssertQSOAddedCallsign("DL1ABC") +} + +// C2. Log fails on invalid callsign +// Pre: callsign input does not parse. +// Post: error shown on callsign field; active field = callsign. +// Invariants: logbook; row not cleared; serial claim unchanged. + +func TestC2_LogFails_InvalidCallsign(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + PressEnter(). // callsign = "" → ParseCallsign fails + AssertActiveField(core.VFO1, core.CallsignField). + AssertMessageShown(core.VFO1). + AssertNoLogbookWrite() +} + +// C3. Log fails on invalid band +// Pre: callsign parses; band input does not parse. +// Post: error on band field; active field = band. +// Invariants: logbook; row. + +func TestC3_LogFails_InvalidBand(t *testing.T) { + // Enter sets input.band = text even when bandSelected silently ignores it. + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + SetActiveField(core.BandField). + Enter("invalid"). + PressEnter(). + AssertActiveField(core.VFO1, core.BandField). + AssertMessageShown(core.VFO1). + AssertNoLogbookWrite() +} + +// C4. Log fails on invalid mode +// Pre: callsign and band parse; mode does not parse. +// Post: error on mode field; active field = mode. +// Invariants: logbook; row. + +func TestC4_LogFails_InvalidMode(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + SetActiveField(core.ModeField). + Enter("invalid"). + PressEnter(). + AssertActiveField(core.VFO1, core.ModeField). + AssertMessageShown(core.VFO1). + AssertNoLogbookWrite() +} + +// C5. Log fails on missing their-exchange +// Pre: non-EmptyAllowed their-exchange field is empty. +// Post: error on that field; active field = that field. +// Invariants: logbook; row. + +func TestC5_LogFails_MissingTheirExchange(t *testing.T) { + // RST (slot 1) is pre-filled with "599" by fillExchangeDefaults. + // First truly missing field is the serial number (slot 2). + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + PressEnter(). + AssertActiveField(core.VFO1, core.TheirExchangeField(2)). + AssertMessageShown(core.VFO1). + AssertNoLogbookWrite() +} + +// C6. Log fails on invalid their-report +// Pre: their-report field non-empty but does not parse as RST. +// Post: error on their-report field; active field set there. +// Invariants: logbook; row. + +func TestC6_LogFails_InvalidTheirReport(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + GotoNextField(). // → TheirExchange1 + Enter("abc"). // non-empty but invalid RST + PressEnter(). + AssertActiveField(core.VFO1, core.TheirExchangeField(1)). + AssertMessageShown(core.VFO1). + AssertNoLogbookWrite() +} + +// C7. Log fails on invalid serial-only their-number +// Pre: their-number field configured as pure serial; value non-numeric. +// Post: error on their-number field; active field set there. +// Invariants: logbook; row. + +func TestC7_LogFails_InvalidTheirSerial(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + GotoNextField(). // → TheirExchange1 + Enter("599"). + GotoNextField(). // → TheirExchange2 + Enter("abc"). // non-numeric serial + PressEnter(). + AssertActiveField(core.VFO1, core.TheirExchangeField(2)). + AssertMessageShown(core.VFO1). + AssertNoLogbookWrite() +} + +// C8. Log applies prediction and aborts on empty non-serial/non-report exchange +// Pre: non-report/non-serial their-exchange slot empty; prediction array length matches. +// Post: prediction copied into that slot; error "check their exchange" on that field; +// active field set there. +// Invariants: logbook; other slots in row. + +func TestC8_Log_PredictionFillsEmptySlot(t *testing.T) { + // Requires EmptyAllowed on the generic text field so the "missing" check is + // bypassed and the prediction-fill path is reached. + // + // The CallinfoFrame MUST be injected AFTER leaving the callsign field: + // leaveCallsignField() pre-fills predictable empty slots, so injecting the + // frame before GotoNextField() would populate TheirExchange3 early and the + // empty check in parseTheirExchange would never fire. + frame := core.CallinfoFrame{ + PredictedExchange: []string{"599", "042", "OE"}, + } + + NewScenario(t). + WithClassicExchangeWithOptionalText(). + Enter("DL1ABC"). + GotoNextField(). // leaves callsign field (no prediction yet → slot 3 stays empty) + WithCallinfoFrame(core.VFO1, frame). // inject prediction now + Enter("599"). + GotoNextField(). // → TheirExchange2 + Enter("042"). + PressEnter(). // at TheirExchange2 → Log; TheirExchange3 is empty → prediction fills it + AssertTheirExchangeSet(core.VFO1, 3, "OE"). + AssertActiveField(core.VFO1, core.TheirExchangeField(3)). + AssertMessageShown(core.VFO1). + AssertNoLogbookWrite() +} + +// C9. Log fails on invalid my-report +// Pre: my-exchange's report slot does not parse as RST. +// Post: error on my-report field. +// Invariants: logbook. + +func TestC9_LogFails_InvalidMyReport(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + GotoNextField(). // → TheirExchange1 + Enter("599"). + GotoNextField(). // → TheirExchange2 + Enter("042"). + GotoNextField(). // → TheirExchange3 + Enter("OE"). + SetActiveField(core.MyExchangeField(1)). + Enter("abc"). // invalid my-RST + PressEnter(). + AssertActiveField(core.VFO1, core.MyExchangeField(1)). + AssertMessageShown(core.VFO1). + AssertNoLogbookWrite() +} + +// C10. Log fails on invalid my-number (single-property field) +// Pre: my-number field has exactly one property; value is non-numeric. +// Post: error on my-number field. +// Invariants: logbook. + +func TestC10_LogFails_InvalidMySerial(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + GotoNextField(). // → TheirExchange1 + Enter("599"). + GotoNextField(). // → TheirExchange2 + Enter("042"). + GotoNextField(). // → TheirExchange3 + Enter("OE"). + SetActiveField(core.MyExchangeField(2)). + Enter("abc"). // non-numeric my-serial + PressEnter(). + AssertActiveField(core.VFO1, core.MyExchangeField(2)). + AssertMessageShown(core.VFO1). + AssertNoLogbookWrite() +} + +// C11. EnterPressed dispatch +// Post: if callsign field holds kHz/band/@call command: that command runs (not ESM, not Log); +// else if ESM enabled AND not editing: NextESMStep (F section); +// else: Log path (covered by C1). + +func TestC11_EnterPressed_CommandPrecedesESMAndLog(t *testing.T) { + // Numeric callsign input → frequency command, even when ESM is enabled. + NewScenario(t). + WithClassicExchange(). + WithESMEnabled(). + Enter("14250"). + PressEnter(). + AssertVFOFrequency(14250000). + AssertNoLogbookWrite() +} + +// D1. Clear while editing +// Pre: editing = true; editSnapshot not nil. +// Post: edit snapshot restored; editing = false; editing marker cleared; active field = callsign. +// Invariants: logbook. + +func TestD1_Clear_WhileEditing_ExitsEditMode(t *testing.T) { + dl1abc, _ := core.ParseCallsign("DL1ABC") + qso := core.QSO{ + Callsign: dl1abc, + MyNumber: 7, + Band: core.Band20m, + Mode: core.ModeCW, + TheirExchange: []string{"599", "042", "OE"}, + MyExchange: []string{"599", "007", ""}, + } + + NewScenario(t). + WithClassicExchange(). + SelectQSO(qso). + Clear(). + AssertEditingMarker(core.VFO1, false). + AssertActiveField(core.VFO1, core.CallsignField). + AssertMessageCleared(core.VFO1). + AssertNoLogbookWrite() +} + +// D2. Clear focused VFO (not editing) +// Pre: editing = false. +// Post: serial claim released; active field = callsign; duplicate=false; editing=false; message cleared. +// Invariants: logbook; selected band/mode/frequency. + +func TestD2_Clear_NotEditing_ReleasesClaimAndResetsRow(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). // claim serial + Clear(). + AssertCallsignView(core.VFO1, ""). + AssertDuplicateMarker(core.VFO1, false). + AssertEditingMarker(core.VFO1, false). + AssertActiveField(core.VFO1, core.CallsignField). + AssertMessageCleared(core.VFO1) +} + +func TestD2_Clear_SeedsMyExchangeFromLastExchange(t *testing.T) { + // fillExchangeDefaults carries lastExchange[i] only where defaultExchangeValues[i] == "". + // ClassicExchange: slot 1=RST("599"), slot 2=serial("001") have non-empty defaults → + // lastExchange ignored there. Slot 3=generic text has empty default → seeded from lastExchange. + NewScenario(t). + WithClassicExchange(). + WithLastExchange([]string{"599", "001", "DX"}). + Clear(). + AssertMyExchangeView(3, "DX") +} + +// D3. Auto-clear on rig frequency jump +// Pre: VFOFrequencyChanged fires; |Δ| > jumpThreshold (250 Hz); ignoreFrequencyJump = false. +// Post: selectedFrequency updated; Clear runs on focused VFO; active field reapplied. + +func TestD3_VFOFrequencyChanged_LargeJump_TriggersAutoClear(t *testing.T) { + // Initial frequency after setup = 14050000 Hz (from vfoSpy.Refresh). + // A jump of 1000 Hz far exceeds the 250 Hz threshold. + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + VFOFrequencyChanged(core.VFO1, 14050000+1000). + AssertCallsignView(core.VFO1, ""). // row was cleared + AssertActiveField(core.VFO1, core.CallsignField) +} + +// D4. Suppress jump-clear once +// Pre: ignoreFrequencyJump = true (set by G2 / EntrySelected). +// Post: frequency updated, Clear NOT triggered; flag reset to false. +// Invariants: row preserved. + +func TestD4_FrequencyJump_SuppressedByEntrySelected(t *testing.T) { + // EntrySelected: Clear + ignoreFrequencyJump=true + frequencyEntered(14200000) + Enter("DL1ABC"). + // A subsequent large VFOFrequencyChanged must NOT clear the row. + dl1abc, _ := core.ParseCallsign("DL1ABC") + entry := core.BandmapEntry{Call: dl1abc, Frequency: 14200000} + + NewScenario(t). + WithClassicExchange(). + EntrySelected(entry). + // ignoreFrequencyJump=true now; trigger a large jump + VFOFrequencyChanged(core.VFO1, 14200000). + // If Clear had fired, showInput → SetCallsign(VFO1,"") would be recorded. + AssertViewNotCalledWith("SetCallsign", core.VFO1, "") +} + +// E1. QSO selected from list +// Pre: ignoreQSOSelection = false. +// Post: editing = true; editQSO = qso; VFO1 row shows QSO data; editing marker set; active = callsign. +// Invariants: logbook; VFO2 state. + +func TestE1_QSOSelected_EntersEditMode(t *testing.T) { + dl1abc, _ := core.ParseCallsign("DL1ABC") + qso := core.QSO{ + Callsign: dl1abc, + MyNumber: 7, + Band: core.Band20m, + Mode: core.ModeCW, + } + + NewScenario(t). + WithClassicExchange(). + SelectQSO(qso). + AssertCallsignView(core.VFO1, "DL1ABC"). + AssertEditingMarker(core.VFO1, true). + AssertActiveField(core.VFO1, core.CallsignField). + AssertNoLogbookWrite() +} + +// E2. QSO selection ignored +// Pre: ignoreQSOSelection = true (set during selectLastQSO). +// Post: no-op — row unchanged. + +func TestE2_QSOSelection_Ignored_WhenFlagSet(t *testing.T) { + // selectLastQSO() sets ignoreQSOSelection=true, calls qsoList.SelectLastQSO(), then resets. + // Our qsoListSpy fires QSOSelected synchronously inside SelectLastQSO; it must be ignored. + dl1abc, _ := core.ParseCallsign("DL1ABC") + autoQSO := core.QSO{Callsign: dl1abc, MyNumber: 42} + + s := NewScenario(t).WithClassicExchange() + s.WithQSOListCallback(func() { + s.controller.QSOSelected(autoQSO) + }) + s.Clear(). // triggers selectLastQSO → callback → QSOSelected → ignored + AssertCallsignView(core.VFO1, "") // row not populated with autoQSO's callsign +} + +// E3. Edit last QSO +// Pre: logbook non-empty. +// Post: active field on focused VFO = callsign; qsoList.SelectLastQSO() called. + +func TestE3_EditLastQSO_CallsSelectLastQSO(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + EditLastQSO(). + AssertQSOListSelectLastCalled() +} + +// E4. Save edited QSO +// Pre: editing = true; input parses. +// Post: UpdateQSO called (not AddQSO); row cleared (D1 path); CallsignLogged emitted. +// Invariants: original time; original workmode. + +func TestE4_SaveEditedQSO_UpdatesLogbook(t *testing.T) { + dl1abc, _ := core.ParseCallsign("DL1ABC") + qso := core.QSO{ + Callsign: dl1abc, + MyNumber: 7, + Band: core.Band20m, + Mode: core.ModeCW, + TheirExchange: []string{"599", "042", "OE"}, + MyExchange: []string{"599", "007", ""}, + } + + NewScenario(t). + WithClassicExchange(). + SelectQSO(qso). + PressEnter(). // editing=true → Log() → UpdateQSO (not AddQSO) + AssertQSOUpdated(). + AssertQSOUpdatedCallsign("DL1ABC"). + AssertNoQSOAdded() +} + +// E5. Suppress rig sync on VFO1 during edit +// Pre: editing = true; VFOBandChanged/VFOModeChanged/VFOFrequencyChanged for VFO1. +// Post: event ignored; VFO1 row unchanged. + +func TestE5_VFO1BandChanged_Ignored_DuringEdit(t *testing.T) { + dl1abc, _ := core.ParseCallsign("DL1ABC") + qso := core.QSO{ + Callsign: dl1abc, + MyNumber: 7, + Band: core.Band20m, + Mode: core.ModeCW, + TheirExchange: []string{"599", "042", "OE"}, + MyExchange: []string{"599", "007", ""}, + } + + NewScenario(t). + WithClassicExchange(). + SelectQSO(qso). + VFOBandChanged(core.VFO1, core.Band40m). // must be suppressed during edit + AssertViewNotCalledWith("SetBand", core.VFO1, "40m") +} + +func TestE5_VFO1ModeChanged_Ignored_DuringEdit(t *testing.T) { + dl1abc, _ := core.ParseCallsign("DL1ABC") + qso := core.QSO{ + Callsign: dl1abc, + MyNumber: 7, + Band: core.Band20m, + Mode: core.ModeCW, + TheirExchange: []string{"599", "042", "OE"}, + MyExchange: []string{"599", "007", ""}, + } + + NewScenario(t). + WithClassicExchange(). + SelectQSO(qso). + VFOModeChanged(core.VFO1, core.ModeSSB). // must be suppressed during edit + AssertViewNotCalledWith("SetMode", core.VFO1, "SSB") +} + +func TestE5_VFO1FrequencyChanged_Ignored_DuringEdit(t *testing.T) { + // Large jump (1000 Hz > 250 Hz threshold) that would normally trigger Clear. + // During edit it must be fully ignored: no view update, no Clear. + dl1abc, _ := core.ParseCallsign("DL1ABC") + qso := core.QSO{ + Callsign: dl1abc, + MyNumber: 7, + Band: core.Band20m, + Mode: core.ModeCW, + TheirExchange: []string{"599", "042", "OE"}, + MyExchange: []string{"599", "007", ""}, + } + + NewScenario(t). + WithClassicExchange(). + SelectQSO(qso). + VFOFrequencyChanged(core.VFO1, 14050000+1000). // large jump, must be suppressed + AssertViewNotCalledWith("SetFrequency", core.VFO1, core.Frequency(14050000+1000)). + AssertViewNotCalledWith("SetCallsign", core.VFO1, "") // Clear did not run +} + +// F1. Set ESM view +// Pre: view ≠ nil. +// Post: esmView = supplied view; SetESMEnabled and SetMessage called with current state. + +func TestF1_SetESMView_PushesCurrentState(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + WithESMEnabled(). // esmEnabled = true + ConnectESMView(). // spy-reset + SetESMView(s.esmView) + AssertESMViewEnabled(true) +} + +// F2. Toggle ESM enabled +// Pre: none. +// Post: esmEnabled = new value; view notified; active field reapplied; ESMListeners notified. + +func TestF2_ToggleESM_NotifiesListenerAndReappliesActiveField(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + ToggleESM(true). + AssertESMListenerNotified(true). + AssertActiveField(core.VFO1, core.CallsignField) +} + +// F3. NextESMStep — Run mode, CallsignValid: sends keyer text, advances past RST field. +// Pre: not editing; keyer set; ESM enabled. +// Post: ESM state/message recomputed; keyer.SendText called; if Run+CallsignValid: GotoNextField (skip report field). + +func TestF3_NextESMStep_Run_CallsignValid_SendsAndAdvancesField(t *testing.T) { + // ClassicExchange: TheirExchange1=RST (theirReport field) → skipped → land on TheirExchange2. + NewScenario(t). + WithClassicExchange(). + WithESMEnabled(). + WithKeyer(). + WithVFOSwitcher(). + WithWorkmode(core.Run). + Enter("DL1ABC"). // ESM state = CallsignValid + NextESMStep(). + AssertKeyerSentMacro(1). + AssertActiveField(core.VFO1, core.TheirExchangeField(2)) // skip RST → serial +} + +// F3b. NextESMStep — ExchangeValid: logs the QSO. + +func TestF3_NextESMStep_ExchangeValid_Logs(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + WithESMEnabled(). + WithKeyer(). + WithWorkmode(core.Run). + Enter("DL1ABC"). + GotoNextField(). // → TheirExchange1 (RST pre-filled "599") + GotoNextField(). // → TheirExchange2 + Enter("042"). + GotoNextField(). // → TheirExchange3 + Enter("OE"). + NextESMStep(). // ExchangeValid → Log() + AssertQSOAdded() +} + +// F4. ESM recompute on workmode change +// Pre: triggered by WorkmodeChanged. +// Post: esmMessage reflects current workmode; esmView.SetMessage called. + +func TestF4_ESMRecomputedOnWorkmodeChange(t *testing.T) { + // Empty callsign + Run workmode → ESMCallsignEmpty → keyer text index 0. + NewScenario(t). + WithClassicExchange(). + WithESMEnabled(). + WithKeyer(). + ConnectESMView(). + WorkmodeChanged(core.Run). // triggers updateESM + AssertESMViewMessage("keyer[0]") +} + +// F5. EnterPressed routes around ESM while editing +// Pre: editing = true. +// Post: ESM step not taken; Log path used. + +func TestF5_EnterPressed_Editing_SkipsESM(t *testing.T) { + dl1abc, _ := core.ParseCallsign("DL1ABC") + qso := core.QSO{ + Callsign: dl1abc, + MyNumber: 7, + Band: core.Band20m, + Mode: core.ModeCW, + TheirExchange: []string{"599", "042", "OE"}, + MyExchange: []string{"599", "007", ""}, + } + + NewScenario(t). + WithClassicExchange(). + WithESMEnabled(). + WithKeyer(). + SelectQSO(qso). + PressEnter(). // editing=true → Log path, not ESM + AssertQSOUpdated(). + AssertNoKeyerText() // keyer.SendText NOT called +} + +// G1. Mark current callsign in bandmap +// Pre: callsign parses. +// Post: ManualSpot with call/freq/band/mode/now added to bandmap. +// Invariants: input row; logbook. + +func TestG1_MarkInBandmap_AddsManualSpot(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + MarkInBandmap(). + AssertBandmapAddedCallsign("DL1ABC"). + AssertBandmapSpotSource(core.ManualSpot) +} + +// G2. Bandmap entry selected +// Pre: any state. +// Post: D2/D1 Clear; ignoreFrequencyJump=true; rig tuned; callsign entered; active=callsign. +// Invariants: logbook; contest. + +func TestG2_EntrySelected_ClearsAndEntersCallsign(t *testing.T) { + dl1abc, _ := core.ParseCallsign("DL1ABC") + bEntry := core.BandmapEntry{Call: dl1abc, Frequency: 14200000} + + NewScenario(t). + WithClassicExchange(). + Enter("DL2XYZ"). // some other callsign in the row + EntrySelected(bEntry). + AssertCallsignView(core.VFO1, "DL1ABC"). + AssertVFOFrequency(14200000). + AssertActiveField(core.VFO1, core.CallsignField) +} + +// G3. Select best on-frequency match +// Pre: best-match callsign non-empty. +// Post: active field = callsign; A1 effects with that callsign; view updated. + +func TestG3_SelectBestMatchOnFrequency_EntersCallsign(t *testing.T) { + dl1abc, _ := core.ParseCallsign("DL1ABC") + frame := core.CallinfoFrame{ + Supercheck: []core.AnnotatedCallsign{{Callsign: dl1abc}}, + } + + NewScenario(t). + WithClassicExchange(). + WithCallinfoFrame(core.VFO1, frame). + SelectBestMatchOnFrequency(). + AssertCallsignView(core.VFO1, "DL1ABC"). + AssertActiveField(core.VFO1, core.CallsignField) +} + +// G4. Select match by index +// Pre: match exists at index. +// Post: same as G3 with that match's callsign. + +func TestG4_SelectMatchByIndex_EntersCallsign(t *testing.T) { + dl1abc, _ := core.ParseCallsign("DL1ABC") + dl2xyz, _ := core.ParseCallsign("DL2XYZ") + frame := core.CallinfoFrame{ + Supercheck: []core.AnnotatedCallsign{ + {Callsign: dl1abc}, + {Callsign: dl2xyz}, + }, + } + + NewScenario(t). + WithClassicExchange(). + WithCallinfoFrame(core.VFO1, frame). + SelectMatch(1). // index 1 = DL2XYZ + AssertCallsignView(core.VFO1, "DL2XYZ"). + AssertActiveField(core.VFO1, core.CallsignField) +} + +// G5. Refresh prediction +// Pre: none. +// Post: callinfo re-notified with current callsign and empty exchange; predictable empty slots refilled. +// Invariants: non-predictable slots; non-empty slots. + +func TestG5_RefreshPrediction_NotifiesCallinfo(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + RefreshPrediction(). + AssertCallinfoNotified("DL1ABC") +} + +// G6. CallinfoFrameChanged +// Pre: any state. +// Post: currentCallinfoFrame[vfo] = new frame; used by subsequent leaveCallsignField. + +func TestG6_CallinfoFrameChanged_FrameUsedInSubsequentPrediction(t *testing.T) { + // Same mechanism as A2 — verifies CallinfoFrameChanged stores the frame for leaveCallsignField. + frame := core.CallinfoFrame{PredictedExchange: []string{"599", "042", "OE"}} + + NewScenario(t). + WithClassicExchange(). + Enter("OE5XYZ"). + WithCallinfoFrame(core.VFO1, frame). // G6 event: store the frame + GotoNextField(). // leaveCallsignField uses the stored frame + AssertTheirExchangeSet(core.VFO1, 3, "OE") +} + +// H1. SetFocusedVFO +// Pre: target ≠ VFO2 OR vfo2Enabled = true; target ≠ current focused VFO. +// Post: focusedVFO = target; vfoSwitcher.SetCurrentVFO called; view active field reapplied. +// Invariants: input rows; serial claims; logbook. + +func TestH1_SetFocusedVFO_ReappliesActiveField(t *testing.T) { + NewScenario(t). + WithClassicExchange().WithVFO2().WithVFOSwitcher(). + SetFocusedVFO(core.VFO2). + AssertActiveField(core.VFO2, core.CallsignField). + AssertVFOSwitcherCurrentCalled(core.VFO2) +} + +func TestH1_SetFocusedVFO_VFO2Disabled_Ignored(t *testing.T) { + // VFO2 not enabled → SetFocusedVFO(VFO2) is a no-op. + NewScenario(t). + WithClassicExchange().WithVFOSwitcher(). + SetFocusedVFO(core.VFO2). + // focused stays on VFO1; VFO2 active field NOT set by controller + AssertViewNotCalledWith("SetActiveField", core.VFO2, core.CallsignField) +} + +// H3. ToggleFocusedVFO +// Pre: none. +// Post: if vfo2Enabled: focused = other VFO; else: focused = VFO1. +// Invariants: input rows. + +func TestH3_ToggleFocusedVFO_VFO2Enabled_TogglesFromVFO1ToVFO2(t *testing.T) { + NewScenario(t). + WithClassicExchange().WithVFO2(). + ToggleFocusedVFO(). // VFO1 → VFO2 + AssertActiveField(core.VFO2, core.CallsignField) +} + +func TestH3_ToggleFocusedVFO_VFO2Disabled_StaysOnVFO1(t *testing.T) { + // VFO2 not enabled → ToggleFocusedVFO is a no-op (already VFO1, early return). + // Key invariant: VFO2 is never activated. + NewScenario(t). + WithClassicExchange(). // VFO2 not enabled + ToggleFocusedVFO(). + AssertViewNotCalledWith("SetActiveField", core.VFO2, core.CallsignField) +} + +// H4. FocusVFO1 / FocusVFO2 +// Same Pre/Post/Invariants as H1. + +func TestH4_FocusVFO2_SetsActiveFieldOnVFO2(t *testing.T) { + NewScenario(t). + WithClassicExchange().WithVFO2(). + FocusVFO2(). + AssertActiveField(core.VFO2, core.CallsignField) +} + +func TestH4_FocusVFO1_AfterVFO2_SetsActiveFieldOnVFO1(t *testing.T) { + NewScenario(t). + WithClassicExchange().WithVFO2(). + FocusVFO2(). + FocusVFO1(). + AssertActiveField(core.VFO1, core.CallsignField) +} + +// H5. LogVFO(vfo) +// Pre: H1 preconditions; input parses. +// Post: focus moved to vfo; Log executed. +// Invariants: see C1. + +func TestH5_LogVFO_FocusesAndLogs(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + GotoNextField().Enter("599"). + GotoNextField().Enter("042"). + GotoNextField().Enter("OE"). + LogVFO(core.VFO1). + AssertQSOAdded(). + AssertQSOAddedCallsign("DL1ABC") +} + +// H6. ClearVFO(vfo) +// Pre: H1 preconditions. +// Post: focus moved to vfo; Clear executed. +// Invariants: see D2. + +func TestH6_ClearVFO_FocusesAndClears(t *testing.T) { + NewScenario(t). + WithClassicExchange().WithVFO2(). + Enter("DL1ABC"). + ClearVFO(core.VFO1). + AssertCallsignView(core.VFO1, ""). + AssertActiveField(core.VFO1, core.CallsignField) +} + +// H7. RadioChanged → dual VFO +// Pre: event with singleVFO = false. +// Post: vfo2Enabled = true; view.SetVFOEnabled(VFO2, true). + +func TestH7_RadioChanged_DualVFO_EnablesVFO2(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + RadioChanged(false). // singleVFO=false + AssertVFO2Enabled(true) +} + +// H8. RadioChanged → single VFO +// Pre: event with singleVFO = true. +// Post: vfo2Enabled = false; view.SetVFOEnabled(VFO2, false). + +func TestH8_RadioChanged_SingleVFO_DisablesVFO2(t *testing.T) { + NewScenario(t). + WithClassicExchange().WithVFO2(). // start with VFO2 enabled + RadioChanged(true). // singleVFO=true → collapse + AssertVFO2Enabled(false) +} + +func TestH8_RadioChanged_SingleVFO_VFO2Focused_CollapsesToVFO1(t *testing.T) { + // When VFO2 is focused at collapse time: VFO2 input zeroed, focus silently moved to VFO1. + // Observable: CurrentValues() reads focusedVFO's input; after collapse that is VFO1. + // VFO1 had "DL1ABC" entered before the switch; VFO2 gets zeroed by collapse. + s := NewScenario(t).WithClassicExchange() + s.controller.Enter("DL1ABC") // VFO1 callsign + s.WithVFO2() + s.controller.SetFocusedVFO(core.VFO2) + s.controller.Enter("DL2XYZ") // VFO2 callsign + s.resetSpies() + s.controller.RadioChanged("", true) // collapse with VFO2 focused + // focusedVFO must now be VFO1 → CurrentValues reads VFO1 input = "DL1ABC" + vals := s.controller.CurrentValues() + if vals.TheirCall != "DL1ABC" { + t.Errorf("expected focus on VFO1 (TheirCall=%q), got %q", "DL1ABC", vals.TheirCall) + } +} + +// I1. Claim on callsign keystroke +// Pre: not editing; callsign parses; claimedSerial[focused] = 0. +// Post: claimedSerial[focused] = nextUnclaimedSerial; previews refreshed. + +func TestI1_ClaimSerial_ValidCallsign_ClaimsNextQSONumber(t *testing.T) { + s := NewScenario(t).WithClassicExchange().WithNextQSONumber(5) + s.Enter("DL1ABC") + got := s.controller.CurrentValues().MyNumber + if got != 5 { + t.Errorf("expected MyNumber=5 (claimed from nextQSONumber=5), got %d", got) + } +} + +// I2. Claim sticky +// Pre: claimedSerial[focused] ≠ 0. +// Post: no reclaim on further keystrokes. + +func TestI2_ClaimSticky_SerialUnchangedAcrossKeystrokes(t *testing.T) { + s := NewScenario(t).WithClassicExchange().WithNextQSONumber(5) + s.Enter("DL1ABC") + first := s.controller.CurrentValues().MyNumber + // Simulate logbook number advancing externally — claim must remain 5. + s.logbook.nextQSONumber = 99 + s.Enter("DL1ABC") + second := s.controller.CurrentValues().MyNumber + if first != second { + t.Errorf("expected sticky claim: first=%d, second=%d", first, second) + } +} + +// I3. Collision avoidance +// Embedded in I1's nextUnclaimedSerial. +// Pre: other VFO's claim equals current NextQSONumber. +// Post: new claim = candidate + 1. + +func TestI3_CollisionAvoidance_VFO2GetsNextSerial(t *testing.T) { + s := NewScenario(t).WithClassicExchange() + s.WithVFO2() + // Enter on VFO1 → claims serial 1 (nextQSONumber=1). + s.Enter("DL1ABC") + // Switch focus to VFO2. + s.controller.SetFocusedVFO(core.VFO2) + // Enter on VFO2 → must get serial 2 (serial 1 taken by VFO1). + s.Enter("DL2XYZ") + got := s.controller.CurrentValues().MyNumber + if got != 2 { + t.Errorf("expected VFO2 to claim serial 2 (collision avoidance), got %d", got) + } +} + +// I4. Release on Clear (not editing) +// Pre: D2 entered. +// Post: claimedSerial[focused] = 0; both VFOs' previews recomputed. + +func TestI4_ReleaseClaim_OnClear_RowResetAndPreviewRefreshed(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). // claim serial 1 + Clear(). + AssertCallsignView(core.VFO1, ""). // row cleared + AssertMyExchangeView(2, "001") // serial preview refreshed +} + +// I5. Refresh previews after log +// Pre: Log path completed AddQSO/UpdateQSO. +// Post: refreshMyNumberInputs recomputes both VFOs' displayed serials. + +func TestI5_RefreshPreviewsAfterLog(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + GotoNextField().Enter("599"). + GotoNextField().Enter("042"). + GotoNextField().Enter("OE"). + PressEnter(). // logs; refreshMyNumberInputs called + AssertMyExchangeView(2, "001") +} + +// I6. Edit mode owns the serial slot +// Pre: E1 fired. +// Post: claimedSerial[VFO1] = editQSO.MyNumber; restored on leaveEditMode. + +func TestI6_EditMode_OwnsSerialSlot(t *testing.T) { + dl1abc, _ := core.ParseCallsign("DL1ABC") + qso := core.QSO{ + Callsign: dl1abc, + MyNumber: 7, + Band: core.Band20m, + Mode: core.ModeCW, + } + s := NewScenario(t).WithClassicExchange() + s.SelectQSO(qso) + got := s.controller.CurrentValues().MyNumber + if got != 7 { + t.Errorf("expected MyNumber=7 (editQSO.MyNumber), got %d", got) + } +} + +// J1. VFOFrequencyChanged +// Pre: not VFO1+editing; f ≠ selectedFrequency[vfo]. +// Post: selectedFrequency updated; view updated; if |Δ|>threshold: Clear runs. + +func TestJ1_VFOFrequencyChanged_SmallJump_NoClear(t *testing.T) { + // Initial frequency = 14050000 Hz; jump of 100 Hz is below 250 Hz threshold. + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + VFOFrequencyChanged(core.VFO1, 14050000+100). // small jump + // Clear must NOT have fired: callsign still in view. + AssertViewNotCalledWith("SetCallsign", core.VFO1, "") +} + +func TestJ1_VFOFrequencyChanged_LargeJump_ClearsEventVFO(t *testing.T) { + // Large jump on VFO1 clears VFO1 input. + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + VFOFrequencyChanged(core.VFO1, 14050000+1000). // large jump + AssertCallsignView(core.VFO1, "") // VFO1 cleared +} + +func TestJ1_VFOFrequencyChanged_VFO2LargeJump_ClearsVFO2Only(t *testing.T) { + // Large jump on VFO2 must clear VFO2 input, not VFO1. + s := NewScenario(t). + WithClassicExchange(). + WithVFO2() + + // Enter callsign on VFO1. + s.controller.Enter("DL1ABC") + // Switch to VFO2, enter callsign. + s.controller.SetFocusedVFO(core.VFO2) + s.controller.Enter("DL2XYZ") + // Switch back to VFO1 (so VFO1 is focused). + s.controller.SetFocusedVFO(core.VFO1) + s.resetSpies() + + // Large frequency jump on VFO2 while VFO1 is focused. + s.controller.VFOFrequencyChanged(core.VFO2, 14050000+1000) + + // VFO2 input must be cleared. + s.view.assertCalledWith(s.t, "SetCallsign", core.VFO2, "") + // VFO1 input must NOT be cleared. + assert.False(t, s.view.wasCalledWith("SetCallsign", core.VFO1, ""), + "VFO1 callsign must not be cleared by VFO2 frequency jump") + // Focused VFO must still be VFO1: verify by entering text — it goes to VFO1. + s.resetSpies() + s.controller.Enter("DL9ZZZ") + assert.Equal(t, "DL9ZZZ", s.controller.CurrentValues().TheirCall, + "focused VFO must still be VFO1") +} + +func TestJ1_VFOFrequencyChanged_VFO2LargeJump_ClearsCallinfoAndMessage(t *testing.T) { + // Regression: a frequency jump on VFO2 must clear VFO2's callinfo and message, + // not VFO1's. Focus must stay on VFO1. + s := NewScenario(t). + WithClassicExchange(). + WithVFO2() + + // Enter callsign on VFO1. + s.controller.Enter("DL1ABC") + // Switch to VFO2, enter callsign (triggers callinfo). + s.controller.SetFocusedVFO(core.VFO2) + s.controller.Enter("DL2XYZ") + // Switch back to VFO1. + s.controller.SetFocusedVFO(core.VFO1) + s.resetSpies() + + // Large frequency jump on VFO2. + s.controller.VFOFrequencyChanged(core.VFO2, 14050000+1000) + + // VFO2 callinfo must be cleared (InputChanged with empty call for VFO2). + s.AssertCallinfoCleared(core.VFO2) + // VFO2 message must be cleared. + s.view.assertCalledWith(s.t, "ClearMessage", core.VFO2) + // VFO1 message must NOT be cleared. + assert.False(t, s.view.wasCalledWith("ClearMessage", core.VFO1), + "VFO1 message must not be cleared by VFO2 frequency jump") + // Focus must remain on VFO1. + s.resetSpies() + s.controller.Enter("DL9ZZZ") + assert.Equal(t, "DL9ZZZ", s.controller.CurrentValues().TheirCall, + "focused VFO must still be VFO1") +} + +func TestJ1_VFOFrequencyChanged_UpdatesFrequencyView(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + VFOFrequencyChanged(core.VFO1, 14050000+100). + AssertFrequencyView(core.VFO1, 14050000+100) +} + +// J2. VFOBandChanged +// Pre: not VFO1+editing; band ≠ NoBand; band ≠ selectedBand[focused]. +// Post: selectedBand updated; view band updated. + +func TestJ2_VFOBandChanged_UpdatesView(t *testing.T) { + // Initial band = Band20m (from vfoSpy.Refresh). Band40m differs → view updated. + NewScenario(t). + WithClassicExchange(). + VFOBandChanged(core.VFO1, core.Band40m). + AssertViewNotCalledWith("SetBand", core.VFO1, "") // at minimum, not empty +} + +func TestJ2_VFOBandChanged_SameBand_Ignored(t *testing.T) { + // Same as current selectedBand → no view update. + NewScenario(t). + WithClassicExchange(). + VFOBandChanged(core.VFO1, core.Band20m). // Band20m is already selected + AssertViewNotCalledWith("SetBand", core.VFO1, "20m") +} + +// J3. VFOModeChanged +// Pre: not VFO1+editing; mode ≠ NoMode; mode ≠ selectedMode[focused]. +// Post: selectedMode updated; view mode updated. + +func TestJ3_VFOModeChanged_UpdatesView(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + VFOModeChanged(core.VFO1, core.ModeSSB). + AssertViewNotCalledWith("SetMode", core.VFO1, "") // mode was updated +} + +func TestJ3_VFOModeChanged_SameMode_Ignored(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + VFOModeChanged(core.VFO1, core.ModeCW). // ModeCW already selected + AssertViewNotCalledWith("SetMode", core.VFO1, "CW") +} + +// J2b. VFOBandChanged targets event VFO, not focused VFO +// Regression: VFO2 band-change while VFO1 focused must update VFO2's state, +// not corrupt VFO1's selectedBand. + +func TestJ2b_VFOBandChanged_VFO2Event_WhileVFO1Focused(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + WithVFO2(). + VFOBandChanged(core.VFO2, core.Band40m). + AssertBandView(core.VFO2, "40m"). + AssertViewNotCalledWith("SetBand", core.VFO1, "40m") +} + +func TestJ2b_VFOBandChanged_VFO2Event_DoesNotCorruptVFO1(t *testing.T) { + // After VFO2 band event, VFO1's selectedBand must still be Band20m (from Refresh). + // Verify by sending VFO1 Band20m again — it should be a same-band no-op. + NewScenario(t). + WithClassicExchange(). + WithVFO2(). + VFOBandChanged(core.VFO2, core.Band40m). + VFOBandChanged(core.VFO1, core.Band20m). // resetSpies; must be no-op (same band) + AssertViewNotCalledWith("SetBand", core.VFO1, "20m") +} + +// J3b. VFOModeChanged targets event VFO, not focused VFO +// Regression: same issue as J2b but for mode. + +func TestJ3b_VFOModeChanged_VFO2Event_WhileVFO1Focused(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + WithVFO2(). + VFOModeChanged(core.VFO2, core.ModeSSB). + AssertModeView(core.VFO2, "SSB"). + AssertViewNotCalledWith("SetMode", core.VFO1, "SSB") +} + +func TestJ3b_VFOModeChanged_VFO2Event_DoesNotCorruptVFO1(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + WithVFO2(). + VFOModeChanged(core.VFO2, core.ModeSSB). + VFOModeChanged(core.VFO1, core.ModeCW). // resetSpies; must be no-op (same mode) + AssertViewNotCalledWith("SetMode", core.VFO1, "CW") +} + +// J4. VFOXITChanged +// Pre: event for vfo. +// Post: view.SetXIT(vfo, active, offset). + +func TestJ4_VFOXITChanged_UpdatesView(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + VFOXITChanged(core.VFO1, true, 500). + AssertXITView(core.VFO1, true, 500) +} + +// J5. XITActiveChanged +// Pre: event arrives. +// Post: view.SetXITActive(VFO1, active). + +func TestJ5_XITActiveChanged_UpdatesView(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + XITActiveChanged(true). + AssertXITActiveView(core.VFO1, true) +} + +// J6. VFOPTTChanged +// Pre: event for vfo. +// Post: if VFO1: ptt updated, view.SetTXState; else: view.SetTXState(vfo, active, false, 0). + +func TestJ6_VFOPTTChanged_VFO1_UpdatesTXState(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + VFOPTTChanged(core.VFO1, true). + AssertTXStateView(core.VFO1, true, false, 0) +} + +func TestJ6_VFOPTTChanged_VFO2_UpdatesTXStateForVFO2(t *testing.T) { + NewScenario(t). + WithClassicExchange().WithVFO2(). + VFOPTTChanged(core.VFO2, true). + AssertTXStateView(core.VFO2, true, false, 0) +} + +// K1. SendQuestion +// Pre: keyer set; canTransmit = true. +// Post: if active=their-exchange: keyer.SendQuestion("nr"); else: keyer.SendQuestion(callsign). + +func TestK1_SendQuestion_CallsignField_SendsCallsign(t *testing.T) { + NewScenario(t). + WithClassicExchange().WithKeyer().WithVFOSwitcher(). + Enter("DL1ABC"). + SendQuestion(). + AssertKeyerSentQuestion("DL1ABC") +} + +func TestK1_SendQuestion_TheirExchangeField_SendsNR(t *testing.T) { + NewScenario(t). + WithClassicExchange().WithKeyer().WithVFOSwitcher(). + Enter("DL1ABC"). + GotoNextField(). // → TheirExchange1 + SendQuestion(). + AssertKeyerSentQuestion("nr") +} + +// K2. RepeatLastTransmission +// Pre: keyer set; canTransmit = true. +// Post: keyer.Repeat(). VFO switcher is NOT called (stay on last TX VFO). + +func TestK2_RepeatLastTransmission_CallsKeyer(t *testing.T) { + NewScenario(t). + WithClassicExchange().WithKeyer(). + RepeatLastTransmission(). + AssertKeyerRepeated() +} + +// K3. StopTX +// Pre: keyer set. +// Post: keyer.Stop(). + +func TestK3_StopTX_CallsKeyerStop(t *testing.T) { + NewScenario(t). + WithClassicExchange().WithKeyer(). + StopTX(). + AssertKeyerStopped() +} + +// K4. ParrotActive(active) +// Pre: none. +// Post: parrotActive = active; TX state view refreshed; if active: Clear runs. + +func TestK4_ParrotActive_True_UpdatesTXStateAndClears(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + ParrotActive(true). + AssertTXStateView(core.VFO1, false, true, 0). + AssertCallsignView(core.VFO1, "") // Clear ran +} + +func TestK4_ParrotActive_False_UpdatesTXStateOnly(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + ParrotActive(false). + AssertTXStateView(core.VFO1, false, false, 0) +} + +// K5. ParrotTimeLeft(d) +// Pre: none. +// Post: parrotTimeLeft = d; TX state view refreshed. + +func TestK5_ParrotTimeLeft_UpdatesTXState(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + ParrotTimeLeft(5*time.Second). + AssertTXStateView(core.VFO1, false, false, 5*time.Second) +} + +// L1. StationChanged +// Pre: none. +// Post: stationCallsign = new value; view.SetMyCall. + +func TestL1_StationChanged_UpdatesMyCallView(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + StationChanged("DL1XYZ"). + AssertMyCallView("DL1XYZ") +} + +// L2. ContestChanged +// Pre: contest definition supplied. +// Post: exchange fields replaced; Clear runs (active = callsign). + +func TestL2_ContestChanged_ClearsRow(t *testing.T) { + // WithClassicExchange calls ContestChanged internally; verify Clear ran. + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + WithClassicExchange(). // second ContestChanged → Clear + AssertCallsignView(core.VFO1, ""). + AssertActiveField(core.VFO1, core.CallsignField) +} + +// L3. WorkmodeChanged +// Pre: none. +// Post: workmode = new value; ESM message recomputed. +// (primary coverage in F4; minimal smoke test here) + +func TestL3_WorkmodeChanged_UpdatesWorkmode(t *testing.T) { + NewScenario(t). + WithClassicExchange().WithESMEnabled().WithKeyer().ConnectESMView(). + WorkmodeChanged(core.Run). + AssertESMViewMessage("keyer[0]") +} + +// L4. LogbookLoaded +// Pre: logbook loaded. +// Post: every VFO's selectedBand/selectedMode = logbook's last; Clear runs; input pushed to view. + +func TestL4_LogbookLoaded_ClearsRowAndPushesInput(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). // something in the row + LogbookLoaded(). + AssertCallsignView(core.VFO1, ""). // Clear ran + AssertActiveField(core.VFO1, core.CallsignField) +} + +// M6. RefreshView +// Pre: none. +// Post: current input pushed to view (showInput). + +func TestM6_RefreshView_PushesCurrentInputToView(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + RefreshView(). + AssertCallsignView(core.VFO1, "DL1ABC") +} + +// M7. Activate +// Pre: none. +// Post: view active field reapplied for focused VFO. + +func TestM7_Activate_ReappliesActiveField(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + SetActiveField(core.TheirExchangeField(1)). + Activate(). + AssertActiveField(core.VFO1, core.TheirExchangeField(1)) +} + +// N1. CurrentQSOState +// Pre: none. +// Post: (NoCallsign, QSODataEmpty) if callsign empty/unparseable; +// (call, QSODataInvalid) if call OK, exchange fails; +// (call, QSODataValid) if both OK. + +func TestN1_CurrentQSOState_Empty_WhenCallsignEmpty(t *testing.T) { + s := NewScenario(t).WithClassicExchange() + call, state := s.controller.CurrentQSOState() + if call != core.NoCallsign { + t.Errorf("expected NoCallsign, got %v", call) + } + if state != core.QSODataEmpty { + t.Errorf("expected QSODataEmpty, got %v", state) + } +} + +func TestN1_CurrentQSOState_Invalid_WhenExchangeIncomplete(t *testing.T) { + s := NewScenario(t).WithClassicExchange() + s.Enter("DL1ABC") // callsign OK, exchange empty → invalid + _, state := s.controller.CurrentQSOState() + if state != core.QSODataInvalid { + t.Errorf("expected QSODataInvalid, got %v", state) + } +} + +func TestN1_CurrentQSOState_Valid_WhenFullyFilledIn(t *testing.T) { + s := NewScenario(t).WithClassicExchange() + s.Enter("DL1ABC") + s.GotoNextField() + s.Enter("599") // TheirExchange1 (RST) + s.GotoNextField() + s.Enter("042") // TheirExchange2 (serial) + s.GotoNextField() + s.Enter("OE") // TheirExchange3 (text) + _, state := s.controller.CurrentQSOState() + if state != core.QSODataValid { + t.Errorf("expected QSODataValid, got %v", state) + } +} + +// N2. CurrentValues +// Pre: none. +// Post: returns KeyerValues snapshot from focused VFO input. + +func TestN2_CurrentValues_TheirCall_MatchesEnteredCallsign(t *testing.T) { + s := NewScenario(t).WithClassicExchange() + s.Enter("DL1ABC") + vals := s.controller.CurrentValues() + if vals.TheirCall != "DL1ABC" { + t.Errorf("expected TheirCall=%q, got %q", "DL1ABC", vals.TheirCall) + } +} + +func TestN2_CurrentValues_MyNumber_ReflectsClaimedSerial(t *testing.T) { + s := NewScenario(t).WithClassicExchange().WithNextQSONumber(42) + s.Enter("DL1ABC") // claims serial 42 + vals := s.controller.CurrentValues() + if vals.MyNumber != 42 { + t.Errorf("expected MyNumber=42, got %d", vals.MyNumber) + } +} + +// I7. Dual-VFO serial interleaving +// Pre: classic exchange, nextQSONumber=6, VFO2 enabled. +// Flow: VFO1 enters callsign (claims 6), tabs to exchange → VFO2 enters callsign +// (claims 7), fills exchange, logs → focus VFO1, fill exchange, log. +// Post: VFO2 QSO has MyNumber=7, VFO1 QSO has MyNumber=6. + +func TestI7_DualVFO_SerialInterleaving(t *testing.T) { + s := NewScenario(t). + WithClassicExchange(). + WithVFO2(). + WithNextQSONumber(6) + + // Ensure VFO2 has band and mode set (VFO1 gets them via vfoSpy.Refresh in SetView). + s.controller.VFOBandChanged(core.VFO2, core.Band20m) + s.controller.VFOModeChanged(core.VFO2, core.ModeCW) + + // VFO1: enter callsign → claims serial 6; advance to first exchange field. + s.controller.Enter("DL1ABC") + s.controller.GotoNextField() + + // VFO2: enter callsign → claims serial 7 (collision avoidance skips 6). + s.controller.SetFocusedVFO(core.VFO2) + s.controller.Enter("DL2XYZ") + // Fill VFO2 exchange fields. + s.controller.GotoNextField() + s.controller.Enter("599") // TheirExchange1 (RST) + s.controller.GotoNextField() + s.controller.Enter("042") // TheirExchange2 (serial) + s.controller.GotoNextField() + s.controller.Enter("OE") // TheirExchange3 (text) + + // Log VFO2 QSO. + s.controller.Log() + require.Len(t, s.logbook.addedQSOs, 1, "VFO2 QSO must be logged") + vfo2QSO := s.logbook.addedQSOs[0] + if vfo2QSO.MyNumber != 7 { + t.Errorf("VFO2 QSO: expected MyNumber=7, got %d", vfo2QSO.MyNumber) + } + + // Simulate logbook advancing after the logged QSO. + s.logbook.nextQSONumber = 7 + + // Focus VFO1: serial display must reflect VFO1's still-held claim (6). + s.controller.SetFocusedVFO(core.VFO1) + // Fill remaining VFO1 exchange fields. + s.controller.Enter("599") // TheirExchange1 (RST) — active field is still TheirExchange1 + s.controller.GotoNextField() + s.controller.Enter("001") // TheirExchange2 (serial) + s.controller.GotoNextField() + s.controller.Enter("DL") // TheirExchange3 (text) + + // Log VFO1 QSO. + s.logbook.resetCalls() + s.controller.Log() + require.Len(t, s.logbook.addedQSOs, 1, "VFO1 QSO must be logged") + vfo1QSO := s.logbook.addedQSOs[0] + if vfo1QSO.MyNumber != 6 { + t.Errorf("VFO1 QSO: expected MyNumber=6, got %d", vfo1QSO.MyNumber) + } +} + +// H2. CurrentVFOChanged +// Pre: rig-side VFO change detected (e.g. hamlib polling). +// Post: if ignoreVFOChange: no-op; if VFO2 disabled: no-op; if same VFO: no-op; +// else: focusedVFO = vfo; serial displays refreshed; view.SetActiveVFO and SetActiveField called. +// Invariants: input rows; serial claims; logbook. + +func TestH2_CurrentVFOChanged_SwitchesFocusAndUpdatesView(t *testing.T) { + NewScenario(t). + WithClassicExchange().WithVFO2(). + CurrentVFOChanged(core.VFO2). + AssertActiveVFO(core.VFO2). + AssertActiveField(core.VFO2, core.CallsignField) +} + +func TestH2_CurrentVFOChanged_VFO2Disabled_Ignored(t *testing.T) { + NewScenario(t). + WithClassicExchange(). // VFO2 not enabled + CurrentVFOChanged(core.VFO2). + AssertViewNotCalledWith("SetActiveVFO", core.VFO2). + AssertViewNotCalledWith("SetActiveField", core.VFO2, core.CallsignField) +} + +func TestH2_CurrentVFOChanged_SameVFO_Ignored(t *testing.T) { + // Already on VFO1 → CurrentVFOChanged(VFO1) is a no-op. + NewScenario(t). + WithClassicExchange().WithVFO2(). + CurrentVFOChanged(core.VFO1). + AssertViewNotCalledWith("SetActiveVFO", core.VFO1) +} + +func TestH2_CurrentVFOChanged_SuppressedDuringSetFocusedVFO(t *testing.T) { + // SetFocusedVFO sets ignoreVFOChange=true around the rig command. + // Simulate: after SetFocusedVFO(VFO2), a CurrentVFOChanged(VFO2) echo + // from the rig must be suppressed (same-VFO guard catches it since + // focusedVFO is already VFO2). + s := NewScenario(t). + WithClassicExchange().WithVFO2().WithVFOSwitcher() + s.SetFocusedVFO(core.VFO2) // commands rig; focusedVFO = VFO2 + s.resetSpies() + s.controller.CurrentVFOChanged(core.VFO2) // echo from rig → same VFO → no-op + assert.False(t, s.view.wasCalled("SetActiveVFO"), + "CurrentVFOChanged echo must be suppressed (same VFO)") +} + +// H1+. SetFocusedVFO calls SetCurrentVFO and SetActiveVFO + +func TestH1_SetFocusedVFO_CallsSetCurrentVFO(t *testing.T) { + NewScenario(t). + WithClassicExchange().WithVFO2().WithVFOSwitcher(). + SetFocusedVFO(core.VFO2). + AssertVFOSwitcherCurrentCalled(core.VFO2) +} + +func TestH1_SetFocusedVFO_CallsSetActiveVFO(t *testing.T) { + NewScenario(t). + WithClassicExchange().WithVFO2().WithVFOSwitcher(). + SetFocusedVFO(core.VFO2). + AssertActiveVFO(core.VFO2) +} + +// H8+. RadioChanged VFO2 collapse releases serial claim + +func TestH8_RadioChanged_SingleVFO_VFO2Focused_ReleasesSerialClaim(t *testing.T) { + s := NewScenario(t).WithClassicExchange().WithNextQSONumber(5) + s.WithVFO2() + s.controller.SetFocusedVFO(core.VFO2) + s.controller.Enter("DL1ABC") // claim serial on VFO2 + got := s.controller.CurrentValues().MyNumber + require.Equal(t, core.QSONumber(5), got, "VFO2 must have claimed serial 5") + + s.controller.RadioChanged("", true) // collapse → release VFO2 claim, move to VFO1 + + // After collapse, VFO1 is focused. Serial preview should be 5 (released, re-available). + vals := s.controller.CurrentValues() + assert.Equal(t, core.QSONumber(5), vals.MyNumber, + "after VFO2 collapse, VFO1 serial preview should be 5 (VFO2 claim released)") +} + +// K1b. SendQuestion blocked during edit + +func TestK1b_SendQuestion_DuringEdit_IsNoOp(t *testing.T) { + dl1abc, _ := core.ParseCallsign("DL1ABC") + qso := core.QSO{ + Callsign: dl1abc, + MyNumber: 7, + Band: core.Band20m, + Mode: core.ModeCW, + TheirExchange: []string{"599", "042", "OE"}, + MyExchange: []string{"599", "007", ""}, + } + + NewScenario(t). + WithClassicExchange().WithKeyer().WithVFOSwitcher(). + SelectQSO(qso). + SendQuestion(). + AssertViewNotCalledWith("SetActiveField", core.VFO1, core.CallsignField) // no side effects + // keyer.SendQuestion NOT called is the key assertion: + // AssertNoKeyerText covers SendText; check sentQuestion directly. +} + +func TestK1b_SendQuestion_DuringEdit_KeyerNotCalled(t *testing.T) { + dl1abc, _ := core.ParseCallsign("DL1ABC") + qso := core.QSO{ + Callsign: dl1abc, + MyNumber: 7, + Band: core.Band20m, + Mode: core.ModeCW, + TheirExchange: []string{"599", "042", "OE"}, + MyExchange: []string{"599", "007", ""}, + } + + s := NewScenario(t). + WithClassicExchange().WithKeyer().WithVFOSwitcher() + s.SelectQSO(qso) + s.resetSpies() + s.controller.SendQuestion() + assert.Empty(t, s.keyer.sentQuestion, + "SendQuestion must not call keyer during edit mode") +} + +// K2b. RepeatLastTransmission blocked during edit + +func TestK2b_RepeatLastTransmission_DuringEdit_IsNoOp(t *testing.T) { + dl1abc, _ := core.ParseCallsign("DL1ABC") + qso := core.QSO{ + Callsign: dl1abc, + MyNumber: 7, + Band: core.Band20m, + Mode: core.ModeCW, + TheirExchange: []string{"599", "042", "OE"}, + MyExchange: []string{"599", "007", ""}, + } + + s := NewScenario(t). + WithClassicExchange().WithKeyer() + s.SelectQSO(qso) + s.resetSpies() + s.controller.RepeatLastTransmission() + assert.False(t, s.keyer.repeated, + "RepeatLastTransmission must not call keyer during edit mode") +} + +// F3c. NextESMStep blocked during edit + +func TestF3c_NextESMStep_DuringEdit_IsNoOp(t *testing.T) { + dl1abc, _ := core.ParseCallsign("DL1ABC") + qso := core.QSO{ + Callsign: dl1abc, + MyNumber: 7, + Band: core.Band20m, + Mode: core.ModeCW, + TheirExchange: []string{"599", "042", "OE"}, + MyExchange: []string{"599", "007", ""}, + } + + s := NewScenario(t). + WithClassicExchange().WithESMEnabled().WithKeyer().WithVFOSwitcher() + s.SelectQSO(qso) + s.resetSpies() + s.controller.NextESMStep() + assert.Empty(t, s.keyer.sentTexts, + "NextESMStep must not call keyer during edit mode") +} + +// F3d. NextESMStep — S&P mode, CallsignValid: sends keyer text, no field advance. + +func TestF3d_NextESMStep_SP_CallsignValid_SendsButNoFieldAdvance(t *testing.T) { + s := NewScenario(t). + WithClassicExchange(). + WithESMEnabled(). + WithKeyer(). + WithVFOSwitcher(). + WithWorkmode(core.SearchPounce) + s.Enter("DL1ABC") // ESM state = CallsignValid + s.NextESMStep() + s.AssertKeyerSentMacro(0) + // In S&P, CallsignValid does NOT advance field (unlike Run mode). + // Verify no GotoNextField side effects: active field was not pushed. + assert.False(t, s.view.wasCalledWith("SetActiveField", core.VFO1, core.TheirExchangeField(1)), + "S&P CallsignValid must not advance to TheirExchange1 (that's Run-only)") +} + +// F3e. NextESMStep — S&P mode, ExchangeValid: logs the QSO. + +func TestF3e_NextESMStep_SP_ExchangeValid_Logs(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + WithESMEnabled(). + WithKeyer(). + WithWorkmode(core.SearchPounce). + Enter("DL1ABC"). + GotoNextField(). // → TheirExchange1 (RST pre-filled "599") + GotoNextField(). // → TheirExchange2 + Enter("042"). + GotoNextField(). // → TheirExchange3 + Enter("OE"). + NextESMStep(). // ExchangeValid → Log() + AssertQSOAdded() +} + +// E4+. Log preserves edit QSO time and workmode + +func TestE4_SaveEditedQSO_PreservesOriginalTimeAndWorkmode(t *testing.T) { + editTime := time.Date(2025, time.March, 15, 10, 30, 0, 0, time.UTC) + dl1abc, _ := core.ParseCallsign("DL1ABC") + qso := core.QSO{ + Callsign: dl1abc, + MyNumber: 7, + Band: core.Band20m, + Mode: core.ModeCW, + Time: editTime, + Workmode: core.SearchPounce, + TheirExchange: []string{"599", "042", "OE"}, + MyExchange: []string{"599", "007", ""}, + } + + s := NewScenario(t). + WithClassicExchange(). + WithWorkmode(core.Run) // current workmode differs from edit QSO's + s.SelectQSO(qso) + s.PressEnter() // Log → UpdateQSO + + require.NotEmpty(t, s.logbook.updatedQSOs, "expected UpdateQSO to be called") + updated := s.logbook.updatedQSOs[0] + assert.Equal(t, editTime, updated.Time, + "edited QSO must preserve original time, not clock.Now()") + assert.Equal(t, core.SearchPounce, updated.Workmode, + "edited QSO must preserve original workmode, not current workmode") +} + +// I8. Serial claim released on non-focused VFO frequency jump + +func TestI8_FrequencyJump_VFO2_ReleasesSerialClaim(t *testing.T) { + s := NewScenario(t). + WithClassicExchange(). + WithVFO2(). + WithNextQSONumber(5) + + // Enter callsign on VFO1 → claims serial 5. + s.controller.Enter("DL1ABC") + // Switch to VFO2, enter callsign → claims serial 6. + s.controller.SetFocusedVFO(core.VFO2) + s.controller.Enter("DL2XYZ") + // Switch back to VFO1. + s.controller.SetFocusedVFO(core.VFO1) + s.resetSpies() + + // Large frequency jump on VFO2 → clearInput(VFO2) → Release(VFO2). + s.controller.VFOFrequencyChanged(core.VFO2, 14050000+1000) + + // VFO2 serial claim released: SetSerialClaim(VFO2, 0, false) must be called. + s.AssertSerialClaimView(core.VFO2, 0, false) +} + +// D2+. Clear fills idle non-focused VFO exchange defaults + +func TestD2_Clear_FillsIdleNonFocusedVFODefaults(t *testing.T) { + s := NewScenario(t). + WithClassicExchange(). + WithVFO2() + + // Set VFO2 band/mode so it has valid state. + s.controller.VFOBandChanged(core.VFO2, core.Band20m) + s.controller.VFOModeChanged(core.VFO2, core.ModeCW) + + // VFO2 has no callsign and no claim → it's idle. + // Clear on VFO1 should also fill VFO2's exchange defaults. + s.resetSpies() + s.controller.Clear() + + // VFO2 should have gotten its their-exchange[0] (RST) seeded via fillExchangeDefaults. + // showInput pushes all VFOs' exchange fields to the view. + s.view.assertCalledWith(s.t, "SetCallsign", core.VFO2, "") +} + +func TestD2_Clear_DoesNotFillNonFocusedVFOWithClaim(t *testing.T) { + s := NewScenario(t). + WithClassicExchange(). + WithVFO2(). + WithNextQSONumber(5) + + // Switch to VFO2, enter callsign → claims serial. + s.controller.SetFocusedVFO(core.VFO2) + s.controller.Enter("DL2XYZ") + // Switch back to VFO1 and enter something. + s.controller.SetFocusedVFO(core.VFO1) + s.controller.Enter("DL1ABC") + s.resetSpies() + + // Clear on VFO1. + s.controller.Clear() + + // VFO2 has a claim → fillExchangeDefaults must NOT run for VFO2. + // VFO2's callsign "DL2XYZ" must survive VFO1's Clear. + vals := s.controller.CurrentValues() + s.controller.SetFocusedVFO(core.VFO2) + vals = s.controller.CurrentValues() + assert.Equal(t, "DL2XYZ", vals.TheirCall, + "VFO2 callsign must survive VFO1 Clear when VFO2 has a claim") +} + +// I10. SerialSent — claims and commits serial for focused VFO + +func TestI10_SerialSent_ClaimsAndCommits(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + WithNextQSONumber(5). + Enter("DL1ABC"). // claims serial 5 + SerialSent(). + AssertSerialCommitted(core.VFO1) +} + +func TestI10_SerialSent_WithoutPriorClaim_ClaimsThenCommits(t *testing.T) { + s := NewScenario(t). + WithClassicExchange(). + WithNextQSONumber(5) + // No callsign entered → no claim yet. + s.SerialSent() + s.AssertSerialClaimed() + s.AssertSerialCommitted(core.VFO1) +} + +func TestI10_SerialSent_DoesNotAffectOtherVFO(t *testing.T) { + s := NewScenario(t). + WithClassicExchange(). + WithVFO2(). + WithNextQSONumber(5) + s.controller.Enter("DL1ABC") // VFO1 claims 5 + s.controller.SetFocusedVFO(core.VFO2) + s.controller.Enter("DL2XYZ") // VFO2 claims 6 + s.resetSpies() + + s.controller.SerialSent() // commits VFO2 (focused) + + assert.True(t, s.controller.IsSerialCommitted(core.VFO2), + "VFO2 serial must be committed") + assert.False(t, s.controller.IsSerialCommitted(core.VFO1), + "VFO1 serial must not be committed") +} + +// I11. Committed serial recycled on Release — no burning + +func TestI11_CommittedSerial_RecycledOnClear(t *testing.T) { + s := NewScenario(t). + WithClassicExchange(). + WithNextQSONumber(5) + + // Claim and commit serial 5. + s.controller.Enter("DL1ABC") + s.controller.SerialSent() + + // Clear without logging → serial 5 recycled (not burned). + s.controller.Clear() + + // Enter new callsign → serial 5 reused. + s.controller.Enter("DL2XYZ") + vals := s.controller.CurrentValues() + assert.Equal(t, core.QSONumber(5), vals.MyNumber, + "committed serial 5 must be recycled after clear without logging") +} + +func TestI11_UncommittedSerial_RecycledOnClear(t *testing.T) { + s := NewScenario(t). + WithClassicExchange(). + WithNextQSONumber(5) + + // Claim serial 5 but do NOT commit. + s.controller.Enter("DL1ABC") + assert.False(t, s.controller.IsSerialCommitted(core.VFO1)) + + // Clear without logging → release uncommitted serial 5. + s.controller.Clear() + + // Enter new callsign → serial 5 reused. + s.controller.Enter("DL2XYZ") + vals := s.controller.CurrentValues() + assert.Equal(t, core.QSONumber(5), vals.MyNumber, + "uncommitted serial 5 must be recycled") +} + +func TestI11_DualVFO_CommittedSerial_RecycledAfterClear(t *testing.T) { + s := NewScenario(t). + WithClassicExchange(). + WithVFO2(). + WithNextQSONumber(5) + + // VFO1 claims 5, VFO2 claims 6. Both committed. + s.controller.Enter("DL1ABC") + s.controller.SerialSent() + s.controller.SetFocusedVFO(core.VFO2) + s.controller.Enter("DL2XYZ") + s.controller.SerialSent() + + // Clear VFO1 without logging → serial 5 released. + s.controller.SetFocusedVFO(core.VFO1) + s.controller.Clear() + + // VFO1 enters new callsign → gets 5 again (VFO2 holds 6, so 5 is free). + s.controller.Enter("DL3FOO") + vals := s.controller.CurrentValues() + assert.Equal(t, core.QSONumber(5), vals.MyNumber, + "released serial 5 must be recycled (VFO2 holds 6)") +} + +// I12. Edit mode does not interact with committed state + +func TestI12_EditMode_CommittedNotAffected(t *testing.T) { + dl1abc, _ := core.ParseCallsign("DL1ABC") + qso := core.QSO{ + Callsign: dl1abc, + MyNumber: 3, + Band: core.Band20m, + Mode: core.ModeCW, + TheirExchange: []string{"599", "042", "OE"}, + MyExchange: []string{"599", "003", ""}, + } + + s := NewScenario(t). + WithClassicExchange(). + WithNextQSONumber(5) + + // Claim and commit serial 5. + s.controller.Enter("DL9ZZZ") + s.controller.SerialSent() + assert.True(t, s.controller.IsSerialCommitted(core.VFO1)) + + // Enter edit mode → committed state saved, edit claim not committed. + s.controller.QSOSelected(qso) + assert.False(t, s.controller.IsSerialCommitted(core.VFO1), + "edit mode claim must not be committed") + + // Leave edit mode → committed state restored. + s.controller.Clear() + assert.True(t, s.controller.IsSerialCommitted(core.VFO1), + "committed state must be restored after edit mode") +} + +// L3b. SO2V workmode: VFO label shows correct workmode after focus switch round-trip. +// Regression test: keyer/entry must show correct workmode regardless of event ordering +// between FocusChanged and WorkmodeChanged. + +func TestL3b_SO2V_WorkmodeLabel_CorrectAfterWorkmodeChanged(t *testing.T) { + s := NewScenario(t). + WithClassicExchange(). + WithVFO2() + + // Simulate workmode controller setting Run: VFO1=Run, VFO2=S&P. + s.resetSpies() + s.controller.WorkmodeChanged(core.VFO1, core.Run) + s.view.assertCalledWith(s.t, "SetVFOWorkmode", core.VFO1, core.Run) + + s.resetSpies() + s.controller.WorkmodeChanged(core.VFO2, core.SearchPounce) + s.view.assertCalledWith(s.t, "SetVFOWorkmode", core.VFO2, core.SearchPounce) + + // After focus switch, workmode controller re-emits WorkmodeChanged for + // both VFOs. Simulate that arriving after focus switch to VFO2: + s.controller.SetFocusedVFO(core.VFO2) + s.resetSpies() + s.controller.WorkmodeChanged(core.VFO1, core.Run) + s.controller.WorkmodeChanged(core.VFO2, core.SearchPounce) + s.view.assertCalledWith(s.t, "SetVFOWorkmode", core.VFO1, core.Run) + s.view.assertCalledWith(s.t, "SetVFOWorkmode", core.VFO2, core.SearchPounce) + + // Switch back to VFO1, re-emit workmodes — regression case: + // VFO1 must show Run, not S&P from previous focus. + s.controller.SetFocusedVFO(core.VFO1) + s.resetSpies() + s.controller.WorkmodeChanged(core.VFO1, core.Run) + s.controller.WorkmodeChanged(core.VFO2, core.SearchPounce) + s.view.assertCalledWith(s.t, "SetVFOWorkmode", core.VFO1, core.Run) +} diff --git a/core/hamdxmap/hamdxmap.go b/core/hamdxmap/hamdxmap.go index 5c5c0cf0..5c2f2262 100644 --- a/core/hamdxmap/hamdxmap.go +++ b/core/hamdxmap/hamdxmap.go @@ -57,7 +57,7 @@ func (m *HamDXMap) WhenStopped(callback func()) { }() } -func (m *HamDXMap) CallsignEntered(callsign string) { +func (m *HamDXMap) CallsignEntered(_ core.VFOID, callsign string) { m.server.ShowPartialCall(callsign) } diff --git a/core/hamlib/hamlib.go b/core/hamlib/hamlib.go index 06d17200..3e53de6a 100644 --- a/core/hamlib/hamlib.go +++ b/core/hamlib/hamlib.go @@ -2,6 +2,8 @@ package hamlib import ( "log" + "strings" + "sync/atomic" "time" "github.com/ftl/hamradio" @@ -11,10 +13,16 @@ import ( "github.com/ftl/hellocontest/core" ) +const ( + doBufferSize = 10 + pttThreshold = 4 // number of consecutive PTT-off polls before emitting PTT-off +) + type Client struct { client *hl.RigClient bandplan bandplan.Bandplan + vfos []hl.VFO listeners []any @@ -22,44 +30,115 @@ type Client struct { requestTimeout time.Duration do chan func() done chan struct{} + loopRunning *atomic.Bool loopStopped chan struct{} - lastState vfoState -} + currentVFO hl.VFO + singleVFO bool + lastState []vfoState + audioLevel []float64 -type vfoState struct { - frequency core.Frequency - band core.Band - mode core.Mode - xitActive bool - xitOffset core.Frequency - ptt bool + ptt bool + pttOffCount int } -func New(address string, bandplan bandplan.Bandplan) *Client { +type vfoState struct { + vfo hl.VFO + frequency core.Frequency + band core.Band + mode core.Mode + txVFO bool + xitActive bool + xitOffset core.Frequency + xitAvailable bool +} + +func New(address string, bandplan bandplan.Bandplan, vfo1, vfo2 string) *Client { + vfos, singleVFO := sanitizeVFOs(vfo1, vfo2) result := &Client{ client: hl.NewRigClient(address), bandplan: bandplan, + vfos: vfos, pollingInterval: 500 * time.Millisecond, requestTimeout: 500 * time.Millisecond, - do: make(chan func()), + do: make(chan func(), doBufferSize), done: make(chan struct{}), + loopRunning: new(atomic.Bool), loopStopped: make(chan struct{}), + currentVFO: hl.CurrVFO, + singleVFO: singleVFO, + lastState: make([]vfoState, int(core.VFOCount)), + audioLevel: make([]float64, int(core.VFOCount)), } result.client.Notify(result) + if singleVFO { + log.Printf("hamlib: using SINGLE VFO: %s", result.vfos[core.VFO1]) + } else { + log.Printf("hamlib: using VFOs: %v", result.vfos) + } return result } +func sanitizeVFOs(vfo1, vfo2 string) ([]hl.VFO, bool) { + result := []hl.VFO{sanitizeHamlibVFO(vfo1), sanitizeHamlibVFO(vfo2)} + singleVFO := false + if result[core.VFO1] == "" { + result[core.VFO1] = hl.CurrVFO + singleVFO = true + } + return result, singleVFO +} + +func sanitizeHamlibVFO(s string) hl.VFO { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" { // shortcut + return "" + } + + validVFOs := []hl.VFO{hl.MainVFO, hl.SubVFO, hl.VFOA, hl.VFOB, hl.VFOC, hl.MainAVFO, hl.SubAVFO, hl.MainBVFO, hl.SubBVFO} + for _, vfo := range validVFOs { + if strings.ToLower(string(vfo)) == s { + return vfo + } + } + + log.Printf("hamlib: invalid VFO: %s", s) + return "" +} + +func (c *Client) toVFOID(vfo hl.VFO) (core.VFOID, bool) { + normalizedVFO := strings.ToLower(string(vfo)) + for id := range c.vfos { + if strings.ToLower(string(c.vfos[id])) == normalizedVFO { + return core.VFOID(id), true + } + } + return -1, false +} + func (c *Client) run() { go func() { defer close(c.loopStopped) + defer c.loopRunning.Store(false) + c.loopRunning.Store(true) + currentState := make([]vfoState, core.VFOCount) for { - currentState, err := c.poll() + currentVFO, currentState, err := c.poll(currentState) if err != nil { - continue + log.Printf("hamlib: cannot poll: %v", err) + } else { + lastVFO := c.currentVFO + c.currentVFO = currentVFO + c.emitCurrentVFOChanged(lastVFO, currentVFO) + c.updateTXVFO(currentState) + + for vfo := range core.VFOCount { + c.emitChangeNotifications(vfo, c.lastState[vfo], currentState[vfo]) + c.lastState[vfo] = currentState[vfo] + } + + c.pollPTT(currentState) } - c.emitChangeNotifications(c.lastState, currentState) - c.lastState = currentState select { case f := <-c.do: @@ -72,59 +151,190 @@ func (c *Client) run() { }() } +// pollPTT reads the radio-wide PTT state and applies debounce. +// PTT-on is emitted immediately; PTT-off is emitted only after +// pttThreshold consecutive polls read PTT=off. +func (c *Client) pollPTT(currentState []vfoState) { + pttStatus, err := c.client.GetPTT(hl.CurrVFO) + if err != nil { + return + } + rawPTT := pttStatus != hl.PTTOff + + if rawPTT { + c.pttOffCount = 0 + if !c.ptt { + c.ptt = true + c.emitPTTChanged(c.txVFOID(currentState), true) + } + return + } + + // rawPTT is false + if !c.ptt { + return + } + c.pttOffCount++ + if c.pttOffCount >= pttThreshold { + c.ptt = false + c.pttOffCount = 0 + c.emitPTTChanged(c.txVFOID(currentState), false) + } +} + +// txVFOID returns the VFOID of the VFO currently designated for transmit. +func (c *Client) txVFOID(state []vfoState) core.VFOID { + for vfo := range core.VFOCount { + if state[vfo].txVFO { + return core.VFOID(vfo) + } + } + return core.VFO1 +} + +// updateTXVFO stores the TX VFO derived from the freshly polled state and emits +// a change notification when it differs from the previously stored value. +// Must only be called from the run goroutine. +func (c *Client) updateTXVFO(state []vfoState) { + lastTXVFO := c.txVFOID(c.lastState) + currentTXVFO := c.txVFOID(state) + + if lastTXVFO != currentTXVFO { + c.emitTXVFOChanged(currentTXVFO) + } +} + // poll must only be called from the run goroutine! -func (c *Client) poll() (vfoState, error) { - frequency, err := c.client.GetFrequency(hl.CurrVFO) +func (c *Client) poll(state []vfoState) (hl.VFO, []vfoState, error) { + if c.singleVFO { + vfoState, err := c.pollSingleVFO(hl.CurrVFO) + if err != nil { + return hl.CurrVFO, state, err + } + state[core.VFO1] = vfoState + return hl.CurrVFO, state, nil + } + + return c.pollDualVFO(state) +} + +// pollSingleVFO must only be called from the run goroutine! +func (c *Client) pollSingleVFO(vfo hl.VFO) (vfoState, error) { + if vfo == "" { + return vfoState{}, nil + } + + frequency, mode, _, _, _, err := c.client.GetVFOInfo(vfo) if err != nil { return vfoState{}, err } - mode, _, err := c.client.GetMode(hl.CurrVFO) + + result := vfoState{ + vfo: vfo, + frequency: core.Frequency(frequency), + band: toCoreBand(c.bandplan.ByFrequency(hamradio.Frequency(frequency)).Name), + mode: toCoreMode(mode), + txVFO: true, + } + result = c.pollVFOAdditional(vfo, result) + + return result, nil +} + +// pollDualVFO must only be called from the run goroutine! +func (c *Client) pollDualVFO(state []vfoState) (hl.VFO, []vfoState, error) { + rigInfo, err := c.client.GetRigInfo() if err != nil { - return vfoState{}, err + // TODO: be a bit smarter than just giving up + log.Printf("hamlib: cannot retrieve RigInfo: %v", err) + return c.currentVFO, state, err + } + // log.Printf("hamlib: RigInfo: %v", rigInfo) + + for _, vfoInfo := range rigInfo.VFOs { + vfoID, ok := c.toVFOID(vfoInfo.VFO) + if !ok { + continue + } + vfoState := state[vfoID] + vfoState.vfo = vfoInfo.VFO + vfoState.frequency = core.Frequency(vfoInfo.Frequency) + vfoState.band = toCoreBand(c.bandplan.ByFrequency(hamradio.Frequency(vfoInfo.Frequency)).Name) + vfoState.mode = toCoreMode(vfoInfo.Mode) + vfoState.txVFO = vfoInfo.TXActive + state[vfoID] = vfoState } - xitActive, err := c.client.GetFunc(hl.CurrVFO, hl.XITFunction) + + var currentVFOID core.VFOID + var currentVFOOK bool + currentVFO, err := c.client.GetVFO() if err != nil { - return vfoState{}, err + // GetVFO is not supported on every radio, fallback to VFO1 + // log.Printf("hamlib: get_vfo not supported, using VFO %s", c.currentVFO) + currentVFO = c.currentVFO + currentVFOID = core.VFO1 + currentVFOOK = true + } else { + currentVFOID, currentVFOOK = c.toVFOID(currentVFO) + } + if currentVFOOK { + vfoState := state[currentVFOID] + vfoState = c.pollVFOAdditional(vfoState.vfo, vfoState) + state[currentVFOID] = vfoState + } + + return currentVFO, state, nil +} + +// pollVFOAdditional must only be called from the run goroutine! +func (c *Client) pollVFOAdditional(vfo hl.VFO, state vfoState) vfoState { + result := state + var err error + + result.xitAvailable = true + result.xitActive, err = c.client.GetFunc(vfo, hl.XITFunction) + if err != nil { + result.xitActive = false + result.xitAvailable = false } var xitOffset hl.Frequency - if xitActive { - xitOffset, err = c.client.GetXIT(hl.CurrVFO) + if result.xitActive { + xitOffset, err = c.client.GetXIT(vfo) if err != nil { - return vfoState{}, err + result.xitActive = false + result.xitAvailable = false + xitOffset = 0 } } else { xitOffset = 0 } - pttStatus, err := c.client.GetPTT(hl.CurrVFO) - if err != nil { - return vfoState{}, err - } + result.xitOffset = core.Frequency(xitOffset) - return vfoState{ - frequency: core.Frequency(frequency), - band: toCoreBand(c.bandplan.ByFrequency(hamradio.Frequency(frequency)).Name), - mode: toCoreMode(mode), - xitActive: xitActive, - xitOffset: core.Frequency(xitOffset), - ptt: pttStatus != hl.PTTOff, - }, nil + return result } func (c *Client) doInLoop(f func()) { + loopRunning := c.loopRunning.Load() + if !loopRunning { + return + } c.do <- f } func (c *Client) KeepOpen() { - err := c.client.Open(true) + err := c.connectAndRun(true) if err != nil { log.Printf("hamlib: connection error: %v", err) } - c.run() } func (c *Client) Connect() error { - err := c.client.Open(false) - if err != nil { + return c.connectAndRun(false) +} + +func (c *Client) connectAndRun(automaticReconnect bool) error { + err := c.client.Open(automaticReconnect) + if err != nil && !automaticReconnect { return err } c.run() @@ -154,18 +364,69 @@ func (c *Client) Active() bool { return c.IsConnected() } -func (c *Client) SetFrequency(frequency core.Frequency) { +func (c *Client) SingleVFO() bool { + return c.singleVFO +} + +func (c *Client) SetCurrentVFO(vfo core.VFOID) { + if c.singleVFO { + return + } + c.doInLoop(func() { + err := c.client.SetVFO(c.vfos[vfo]) + if err != nil { + log.Printf("hamlib: cannot set current VFO: %v", err) + } + }) +} + +func (c *Client) SetTXVFO(vfo core.VFOID) { + if c.singleVFO { + return + } + + /* + This is how switching on SPLIT without changing the current VFO works with the IC-7610, YMMV: + Sub 1 Sub -> Main focus, Split on + Main 0 Main -> Main focus, Split off + Main 1 Main -> Sub focus, Split on + Sub 0 Sub -> Sub focus, Split off + */ + + c.doInLoop(func() { + hlVFO := c.vfos[vfo] + enableSplit := vfo == core.VFO2 + switch c.currentVFO { + case c.vfos[core.VFO1]: + if enableSplit { + hlVFO = c.vfos[core.VFO2] + } else { + hlVFO = c.vfos[core.VFO1] + } + case c.vfos[core.VFO2]: + if enableSplit { + hlVFO = c.vfos[core.VFO1] + } else { + hlVFO = c.vfos[core.VFO2] + } + } + err := c.client.SetSplitVFO(hlVFO, enableSplit, hlVFO) + if err != nil { + log.Printf("hamlib: cannot set TX VFO: %v", err) + } + }) +} + +func (c *Client) SetFrequency(vfo core.VFOID, frequency core.Frequency) { c.doInLoop(func() { - err := c.client.SetFrequency(hl.CurrVFO, hl.Frequency(frequency)) + err := c.client.SetFrequency(c.vfos[vfo], hl.Frequency(frequency)) if err != nil { log.Printf("hamlib: cannot set frequency: %v", err) } - c.lastState.frequency = frequency - c.lastState.band = toCoreBand(c.bandplan.ByFrequency(hamradio.Frequency(frequency)).Name) }) } -func (c *Client) SetBand(band core.Band) { +func (c *Client) SetBand(vfo core.VFOID, band core.Band) { outgoingBand, ok := c.bandplan[toBandplanBandName(band)] if !ok { log.Printf("hamlib: unknown band %v", band) @@ -173,59 +434,121 @@ func (c *Client) SetBand(band core.Band) { } c.doInLoop(func() { - frequency := findModePortionCenter(c.bandplan, int(outgoingBand.Center()), toBandplanMode(c.lastState.mode)) - err := c.client.SetFrequency(hl.CurrVFO, hl.Frequency(frequency)) + frequency := findModePortionCenter(c.bandplan, int(outgoingBand.Center()), toBandplanMode(c.lastState[vfo].mode)) + err := c.client.SetFrequency(c.vfos[vfo], hl.Frequency(frequency)) if err != nil { log.Printf("hamlib: cannot switch to band: %v", err) return } - c.lastState.frequency = core.Frequency(frequency) - c.lastState.band = band }) } -func (c *Client) SetMode(mode core.Mode) { +func (c *Client) SetMode(vfo core.VFOID, mode core.Mode) { c.doInLoop(func() { - err := c.client.SetMode(hl.CurrVFO, toClientMode(mode), 0) + err := c.client.SetMode(c.vfos[vfo], toClientMode(mode), 0) if err != nil { log.Printf("hamlib: cannot switch to mode: %v", err) return } - c.lastState.mode = mode }) } func (c *Client) SetXIT(active bool, offset core.Frequency) { + // TODO: add the VFOID to all VFO-related Setters + vfo := core.VFO1 c.doInLoop(func() { - if active == c.lastState.xitActive && offset == c.lastState.xitOffset { + if active == c.lastState[vfo].xitActive && offset == c.lastState[vfo].xitOffset { return } - if active != c.lastState.xitActive { - err := c.client.SetFunc(hl.CurrVFO, hl.XITFunction, active) + if active != c.lastState[vfo].xitActive { + err := c.client.SetFunc(c.vfos[vfo], hl.XITFunction, active) if err != nil { log.Printf("hamlib: cannot set XIT function: %v", err) return } } - if active && (offset != c.lastState.xitOffset) { - err := c.client.SetXIT(hl.CurrVFO, hl.Frequency(offset)) + if active && (offset != c.lastState[vfo].xitOffset) { + err := c.client.SetXIT(c.vfos[vfo], hl.Frequency(offset)) if err != nil { log.Printf("hamlib: cannot set XIT offset: %v", err) return } } + }) +} + +func (c *Client) MuteAudio(vfo core.VFOID) { + c.doInLoop(func() { + currentLevel, err := c.client.GetLevel(c.vfos[vfo], hl.AudioFrequencyLevel) + if err != nil { + log.Printf("hamlib: cannot retrieve current audio level: %v", err) + return + } + if currentLevel == 0 { + return + } + + c.audioLevel[vfo] = currentLevel + + err = c.client.SetLevel(c.vfos[vfo], hl.AudioFrequencyLevel, 0) + if err != nil { + log.Printf("hamlib: cannot mute audio level: %v", err) + return + } + }) +} + +func (c *Client) UnmuteAudio(vfo core.VFOID) { + c.doInLoop(func() { + lastLevel := c.audioLevel[vfo] + if lastLevel == 0 { + return + } + + err := c.client.SetLevel(c.vfos[vfo], hl.AudioFrequencyLevel, lastLevel) + if err != nil { + log.Printf("hamlib: cannot unmute audio level: %v", err) + return + } + }) +} + +func (c *Client) ToggleAudio(vfo core.VFOID) { + c.doInLoop(func() { + lastLevel := c.audioLevel[vfo] + currentLevel, err := c.client.GetLevel(c.vfos[vfo], hl.AudioFrequencyLevel) + if err != nil { + log.Printf("hamlib: cannot retrieve current audio level: %v", err) + return + } + if currentLevel == lastLevel { + return + } + + var nextLevel float64 + if currentLevel == 0 { + nextLevel = lastLevel + } else { + nextLevel = 0 + } + + c.audioLevel[vfo] = currentLevel - c.lastState.xitActive = active - c.lastState.xitOffset = offset + err = c.client.SetLevel(c.vfos[vfo], hl.AudioFrequencyLevel, nextLevel) + if err != nil { + log.Printf("hamlib: cannot set audio level: %v", err) + return + } }) - // TODO: enable the XIT and set its offset } func (c *Client) Refresh() { c.doInLoop(func() { - c.emitChangeNotifications(vfoState{}, c.lastState) + for vfo := range core.VFOCount { + c.emitChangeNotifications(vfo, vfoState{}, c.lastState[vfo]) + } }) } @@ -261,64 +584,85 @@ func (c *Client) Notify(listener any) { } func (c *Client) emitConnectionChanged(connected bool) { - type listenerType interface { - ConnectionChanged(bool) - } - core.Emit(c.listeners, func(listener listenerType) { + core.Emit(c.listeners, func(listener core.ConnectionChangedListener) { listener.ConnectionChanged(connected) }) } -func (c *Client) emitChangeNotifications(last, current vfoState) { +func (c *Client) emitCurrentVFOChanged(last, current hl.VFO) { + if last == current { + return + } + + vfoID, ok := c.toVFOID(current) + if !ok { + log.Printf("VFO change ignored: %s -> %s", last, current) + return + } + log.Printf("current vfo changed: %d = %s", vfoID, c.vfos[vfoID]) + + go func() { + core.Emit(c.listeners, func(listener core.CurrentVFOListener) { + listener.CurrentVFOChanged(vfoID) + }) + }() +} + +func (c *Client) emitChangeNotifications(vfo core.VFOID, last, current vfoState) { go func() { if last.frequency != current.frequency { - c.emitFrequencyChanged(current.frequency) + c.emitFrequencyChanged(vfo, current.frequency) } if last.band != current.band { - c.emitBandChanged(current.band) + c.emitBandChanged(vfo, current.band) } if last.mode != current.mode { - c.emitModeChanged(current.mode) + c.emitModeChanged(vfo, current.mode) } if (last.xitActive != current.xitActive) || (current.xitActive && (last.xitOffset != current.xitOffset)) { - c.emitXITChanged(current.xitActive, current.xitOffset) - } - if last.ptt != current.ptt { - c.emitPTTChanged(current.ptt) + c.emitXITChanged(vfo, current.xitActive, current.xitOffset) } }() } -func (c *Client) emitFrequencyChanged(frequency core.Frequency) { +func (c *Client) emitFrequencyChanged(vfo core.VFOID, frequency core.Frequency) { core.Emit(c.listeners, func(listener core.VFOFrequencyListener) { - listener.VFOFrequencyChanged(frequency) + listener.VFOFrequencyChanged(vfo, frequency) }) } -func (c *Client) emitBandChanged(band core.Band) { +func (c *Client) emitBandChanged(vfo core.VFOID, band core.Band) { core.Emit(c.listeners, func(listener core.VFOBandListener) { - listener.VFOBandChanged(band) + listener.VFOBandChanged(vfo, band) }) } -func (c *Client) emitModeChanged(mode core.Mode) { +func (c *Client) emitModeChanged(vfo core.VFOID, mode core.Mode) { core.Emit(c.listeners, func(listener core.VFOModeListener) { - listener.VFOModeChanged(mode) + listener.VFOModeChanged(vfo, mode) }) } -func (c *Client) emitXITChanged(active bool, offset core.Frequency) { +func (c *Client) emitXITChanged(vfo core.VFOID, active bool, offset core.Frequency) { core.Emit(c.listeners, func(listener core.VFOXITListener) { - listener.VFOXITChanged(active, offset) + listener.VFOXITChanged(vfo, active, offset) }) } -func (c *Client) emitPTTChanged(active bool) { +func (c *Client) emitPTTChanged(vfo core.VFOID, active bool) { core.Emit(c.listeners, func(listener core.VFOPTTListener) { - listener.VFOPTTChanged(active) + listener.VFOPTTChanged(vfo, active) }) } +func (c *Client) emitTXVFOChanged(vfo core.VFOID) { + go func() { + core.Emit(c.listeners, func(listener core.TXVFOListener) { + listener.TXVFOChanged(vfo) + }) + }() +} + func toCoreBand(bandName bandplan.BandName) core.Band { if bandName == bandplan.BandUnknown { return core.NoBand diff --git a/core/keyer/keyer.go b/core/keyer/keyer.go index 1e2c708d..170f8fcf 100644 --- a/core/keyer/keyer.go +++ b/core/keyer/keyer.go @@ -41,6 +41,14 @@ type CWClient interface { Abort() } +type VFOSwitcher interface { + SetTXVFO(core.VFOID) +} + +type nullVFOSwitcher struct{} + +func (*nullVFOSwitcher) SetTXVFO(core.VFOID) {} + // KeyerValueProvider provides the variable values for the Keyer templates on demand. type KeyerValueProvider func() core.KeyerValues @@ -53,6 +61,11 @@ type KeyerStoppedListener interface { KeyerStopped() } +// SerialSentListener is notified when the keyer transmits a message that contains a serial number. +type SerialSentListener interface { + SerialSent() +} + type Parrot interface { KeyerStoppedListener SetInterval(time.Duration) @@ -73,6 +86,7 @@ func New(settings core.Settings, client CWClient, keyerSettings core.KeyerSettin runTemplates: make(map[int]*template.Template), presets: presets, client: client, + vfoSwitcher: new(nullVFOSwitcher), values: noValues, } result.setWorkmode(workmode) @@ -98,6 +112,7 @@ type Keyer struct { buttonView ButtonView settingsView SettingsView client CWClient + vfoSwitcher VFOSwitcher presets []core.KeyerPreset presetNames []string values KeyerValueProvider @@ -107,6 +122,8 @@ type Keyer struct { listeners []any stationCallsign core.Callsign + focusedVFO core.VFOID + vfoWorkmode [core.VFOCount]core.Workmode workmode core.Workmode wpm int parrotIntervalSeconds int @@ -136,6 +153,17 @@ func (k *Keyer) setWorkmode(workmode core.Workmode) { } } +func (k *Keyer) getPatternsByWorkmode(workmode core.Workmode) map[int]string { + switch workmode { + case core.SearchPounce: + return k.spPatterns + case core.Run: + return k.runPatterns + default: + return nil + } +} + func (k *Keyer) getTemplatesByWorkmode(workmode core.Workmode) map[int]*template.Template { switch workmode { case core.SearchPounce: @@ -155,6 +183,14 @@ func (k *Keyer) SetWriter(writer Writer) { k.writer = writer } +func (k *Keyer) SetVFOSwitcher(vfoSwitcher VFOSwitcher) { + if vfoSwitcher == nil { + k.vfoSwitcher = new(nullVFOSwitcher) + return + } + k.vfoSwitcher = vfoSwitcher +} + func (k *Keyer) SetParrot(parrot Parrot) { if parrot == nil { k.parrot = new(nullParrot) @@ -312,8 +348,17 @@ func (k *Keyer) showKeyerSettings() { k.settingsView.SetParrotIntervalSeconds(k.parrotIntervalSeconds) } -func (k *Keyer) WorkmodeChanged(workmode core.Workmode) { - k.setWorkmode(workmode) +func (k *Keyer) WorkmodeChanged(vfo core.VFOID, workmode core.Workmode) { + k.vfoWorkmode[vfo] = workmode + if vfo == k.focusedVFO { + k.setWorkmode(workmode) + k.showPatterns() + } +} + +func (k *Keyer) FocusChanged(vfo core.VFOID) { + k.focusedVFO = vfo + k.setWorkmode(k.vfoWorkmode[vfo]) k.showPatterns() } @@ -536,21 +581,48 @@ func (k *Keyer) fillins() map[string]any { return result } -func (k *Keyer) Send(index int) { - message, err := k.GetText(k.workmode, index) +// beginTransmission switches the rig's TX VFO to the focused VFO and announces +// the transmission to listeners (e.g. the parrot stops repeating). +func (k *Keyer) beginTransmission() { + k.vfoSwitcher.SetTXVFO(k.focusedVFO) + k.emitTransmissionStarted(k.focusedVFO) +} + +func (k *Keyer) SendMacro(index int) { + k.SendWithWorkmode(k.workmode, index) +} + +func (k *Keyer) SendWithWorkmode(workmode core.Workmode, index int) { + k.beginTransmission() + k.sendMessage(workmode, index) +} + +func (k *Keyer) SendWithWorkmodeOnVFO(vfo core.VFOID, workmode core.Workmode, index int) { + k.vfoSwitcher.SetTXVFO(vfo) + k.sendMessage(workmode, index) +} + +func (k *Keyer) sendMessage(workmode core.Workmode, index int) { + message, err := k.GetText(workmode, index) if err != nil { k.buttonView.ShowMessage(err) return } k.send(message) + patterns := k.getPatternsByWorkmode(workmode) + if k.patternHasSerial(patterns[index]) { + k.emitSerialSent() + } } func (k *Keyer) SendQuestion(q string) { + k.beginTransmission() s := strings.TrimSpace(q) + "?" k.send(s) } func (k *Keyer) SendText(text string, args ...any) { + k.beginTransmission() k.send(fmt.Sprintf(text, args...)) } @@ -563,7 +635,11 @@ func (k *Keyer) SendTextWithTemplate(text string) error { if err := template.Execute(buffer, k.fillins()); err != nil { return err } + k.beginTransmission() k.send(buffer.String()) + if k.patternHasSerial(text) { + k.emitSerialSent() + } return nil } @@ -600,6 +676,28 @@ func (k *Keyer) emitKeyerStopped() { } } +func (k *Keyer) emitTransmissionStarted(vfo core.VFOID) { + for _, listener := range k.listeners { + if transmissionStartedListener, ok := listener.(core.TransmissionStartedListener); ok { + transmissionStartedListener.TransmissionStarted(vfo) + } + } +} + +func (k *Keyer) emitSerialSent() { + for _, listener := range k.listeners { + if serialSentListener, ok := listener.(SerialSentListener); ok { + serialSentListener.SerialSent() + } + } +} + +// patternHasSerial reports whether a template pattern references serial-bearing +// template variables (MyNumber or MyExchange/MyExchanges). +func (k *Keyer) patternHasSerial(pattern string) bool { + return strings.Contains(pattern, "MyNumber") || strings.Contains(pattern, "MyExchange") +} + func parseTemplate(pattern string) (*template.Template, error) { funcs := map[string]any{ "cut": cutDefault, diff --git a/core/keyer/keyer_test.go b/core/keyer/keyer_test.go index 9f292d75..264d46a0 100644 --- a/core/keyer/keyer_test.go +++ b/core/keyer/keyer_test.go @@ -38,7 +38,7 @@ func TestSend(t *testing.T) { keyer.SetValues(values) keyer.EnterPattern(0, "{{.MyCall}} {{.TheirCall}} {{.MyNumber}} {{.MyReport}} {{.MyXchange}}") - keyer.Send(0) + keyer.SendMacro(0) cwClient.AssertExpectations(t) } @@ -59,6 +59,44 @@ func TestPad(t *testing.T) { assert.Equal(t, "", pad(0, "")) } +func TestWorkmode_FocusSwitchOrderIndependence(t *testing.T) { + keyerSettings := core.KeyerSettings{ + WPM: 25, + SPMacros: []string{"sp0", "", "", ""}, + RunMacros: []string{"run0", "", "", ""}, + } + cwClient := new(mocked.CWClient) + k := New(&testSettings{"DL1ABC"}, cwClient, keyerSettings, core.SearchPounce, nil) + + // Simulate SO2V: VFO1=Run, VFO2=S&P + k.WorkmodeChanged(core.VFO1, core.Run) + k.WorkmodeChanged(core.VFO2, core.SearchPounce) + k.FocusChanged(core.VFO1) + assert.Equal(t, core.Run, k.workmode, "VFO1 focused → Run") + + // Switch to VFO2 + k.FocusChanged(core.VFO2) + assert.Equal(t, core.SearchPounce, k.workmode, "VFO2 focused → S&P") + + // Switch back to VFO1 — this is the regression case. + // WorkmodeChanged may arrive before or after FocusChanged. + // Test both orderings: + + // Order A: FocusChanged first, then WorkmodeChanged + k.FocusChanged(core.VFO1) + k.WorkmodeChanged(core.VFO1, core.Run) + assert.Equal(t, core.Run, k.workmode, "Order A: VFO1 focused → Run") + + // Reset to VFO2 + k.FocusChanged(core.VFO2) + assert.Equal(t, core.SearchPounce, k.workmode) + + // Order B: WorkmodeChanged first, then FocusChanged + k.WorkmodeChanged(core.VFO1, core.Run) + k.FocusChanged(core.VFO1) + assert.Equal(t, core.Run, k.workmode, "Order B: VFO1 focused → Run") +} + type testSettings struct { stationCallsign string } @@ -72,3 +110,47 @@ func (s *testSettings) Station() core.Station { func (s *testSettings) Contest() core.Contest { return core.Contest{} } + +type vfoSwitcherSpy struct{ txVFOs []core.VFOID } + +func (s *vfoSwitcherSpy) SetTXVFO(vfo core.VFOID) { s.txVFOs = append(s.txVFOs, vfo) } + +type transmissionStartedSpy struct{ vfos []core.VFOID } + +func (s *transmissionStartedSpy) TransmissionStarted(vfo core.VFOID) { s.vfos = append(s.vfos, vfo) } + +func TestSend_SwitchesTXVFOToFocusedAndAnnounces(t *testing.T) { + keyerSettings := core.KeyerSettings{ + WPM: 25, + SPMacros: []string{"sp0", "", "", ""}, + RunMacros: []string{"run0", "", "", ""}, + } + view := new(mocked.KeyerView) + view.On("SetKeyerController", mock.Anything) + view.On("ShowMessage", mock.Anything) + view.On("SetSpeed", mock.Anything) + view.On("SetLabel", mock.Anything, mock.Anything) + view.On("SetPattern", mock.Anything, mock.Anything) + view.On("SetPresetNames", mock.Anything) + cwClient := new(mocked.CWClient) + cwClient.On("Send", mock.Anything) + vfoSw := &vfoSwitcherSpy{} + tx := &transmissionStartedSpy{} + + k := New(&testSettings{"DL1ABC"}, cwClient, keyerSettings, core.SearchPounce, nil) + k.SetView(view) + k.SetVFOSwitcher(vfoSw) + k.Notify(tx) + + // An operator send switches the TX VFO to the focused VFO and announces it. + k.FocusChanged(core.VFO2) + k.SendMacro(0) + assert.Equal(t, []core.VFOID{core.VFO2}, vfoSw.txVFOs, "operator send switches TX to the focused VFO") + assert.Equal(t, []core.VFOID{core.VFO2}, tx.vfos, "operator send announces the transmission") + + // An explicit-VFO send (parrot) targets the given VFO and does NOT announce + // (otherwise the parrot would stop on its own CQ). + k.SendWithWorkmodeOnVFO(core.VFO1, core.Run, 0) + assert.Equal(t, []core.VFOID{core.VFO2, core.VFO1}, vfoSw.txVFOs, "explicit send switches TX to the given VFO") + assert.Equal(t, []core.VFOID{core.VFO2}, tx.vfos, "explicit send must not announce") +} diff --git a/core/mocked/mocked.go b/core/mocked/mocked.go index f7f28780..cbc7ee53 100644 --- a/core/mocked/mocked.go +++ b/core/mocked/mocked.go @@ -198,60 +198,60 @@ func (m *EntryView) SetMyCall(mycall string) { m.Called(mycall) } -func (m *EntryView) SetFrequency(frequency core.Frequency) { +func (m *EntryView) SetFrequency(vfo core.VFOID, frequency core.Frequency) { if !m.active { return } - m.Called(frequency) + m.Called(vfo, frequency) } -func (m *EntryView) SetCallsign(callsign string) { +func (m *EntryView) SetCallsign(vfo core.VFOID, callsign string) { if !m.active { return } - m.Called(callsign) + m.Called(vfo, callsign) } -func (m *EntryView) SetTheirExchange(index int, value string) { +func (m *EntryView) SetTheirExchange(vfo core.VFOID, index int, value string) { if !m.active { return } - m.Called(index, value) + m.Called(vfo, index, value) } -func (m *EntryView) SetBand(text string) { +func (m *EntryView) SetBand(vfo core.VFOID, text string) { if !m.active { return } - m.Called(text) + m.Called(vfo, text) } -func (m *EntryView) SetMode(text string) { +func (m *EntryView) SetMode(vfo core.VFOID, text string) { if !m.active { return } - m.Called(text) + m.Called(vfo, text) } -func (m *EntryView) SetXITActive(active bool) { +func (m *EntryView) SetXITActive(vfo core.VFOID, active bool) { if !m.active { return } - m.Called(active) + m.Called(vfo, active) } -func (m *EntryView) SetXIT(active bool, offset core.Frequency) { +func (m *EntryView) SetXIT(vfo core.VFOID, active bool, offset core.Frequency) { if !m.active { return } - m.Called(active, offset) + m.Called(vfo, active, offset) } -func (m *EntryView) SetTXState(ptt bool, parrotActive bool, parrotTimeLeft time.Duration) { +func (m *EntryView) SetTXState(vfo core.VFOID, ptt bool, parrotActive bool, parrotTimeLeft time.Duration) { if !m.active { return } - m.Called(ptt, parrotActive, parrotTimeLeft) + m.Called(vfo, ptt, parrotActive, parrotTimeLeft) } func (m *EntryView) SetMyExchange(index int, value string) { @@ -275,46 +275,81 @@ func (m *EntryView) SetTheirExchangeFields(fields []core.ExchangeField) { m.Called(fields) } -func (m *EntryView) SetActiveField(field core.EntryField) { +func (m *EntryView) SetSerialClaim(vfo core.VFOID, n core.QSONumber, committed bool) { if !m.active { return } - m.Called(field) + m.Called(vfo, n, committed) } -func (m *EntryView) SelectText(field core.EntryField, s string) { +func (m *EntryView) SetActiveVFO(vfo core.VFOID) { if !m.active { return } - m.Called(field, s) + m.Called(vfo) } -func (m *EntryView) SetDuplicateMarker(active bool) { +func (m *EntryView) SetActiveField(vfo core.VFOID, field core.EntryField) { if !m.active { return } - m.Called(active) + m.Called(vfo, field) } -func (m *EntryView) SetEditingMarker(active bool) { +func (m *EntryView) SelectText(vfo core.VFOID, field core.EntryField, s string) { if !m.active { return } - m.Called(active) + m.Called(vfo, field, s) } -func (m *EntryView) ShowMessage(args ...interface{}) { +func (m *EntryView) SetDuplicateMarker(vfo core.VFOID, active bool) { if !m.active { return } - m.Called(args) + m.Called(vfo, active) } -func (m *EntryView) ClearMessage() { +func (m *EntryView) SetEditingMarker(vfo core.VFOID, active bool) { if !m.active { return } - m.Called() + m.Called(vfo, active) +} + +func (m *EntryView) ShowMessage(vfo core.VFOID, args ...interface{}) { + if !m.active { + return + } + m.Called(vfo, args) +} + +func (m *EntryView) ClearMessage(vfo core.VFOID) { + if !m.active { + return + } + m.Called(vfo) +} + +func (m *EntryView) SetVFOEnabled(vfo core.VFOID, enabled bool) { + if !m.active { + return + } + m.Called(vfo, enabled) +} + +func (m *EntryView) SetVFOWorkmode(vfo core.VFOID, workmode core.Workmode) { + if !m.active { + return + } + m.Called(vfo, workmode) +} + +func (m *EntryView) SetTXVFO(vfo core.VFOID) { + if !m.active { + return + } + m.Called(vfo) } type Clock struct { diff --git a/core/parrot/parrot.go b/core/parrot/parrot.go index 6ae7b525..368a6e87 100644 --- a/core/parrot/parrot.go +++ b/core/parrot/parrot.go @@ -19,7 +19,7 @@ type WorkmodeController interface { } type Keyer interface { - Send(index int) + SendWithWorkmodeOnVFO(vfo core.VFOID, workmode core.Workmode, index int) Stop() } @@ -84,7 +84,7 @@ func (p *Parrot) run() { p.emitParrotTimeLeft(remaining) } if remaining <= 0 { - p.keyer.Send(CQMessageIndex) + p.keyer.SendWithWorkmodeOnVFO(core.VFO1, core.Run, CQMessageIndex) } }) } @@ -149,11 +149,21 @@ func (p *Parrot) Stop() { p.ticker.Stop() } -func (p *Parrot) CallsignEntered(call string) { +func (p *Parrot) CallsignEntered(vfo core.VFOID, call string) { if !p.active { return } + if vfo != core.VFO1 { + return + } + + p.keyer.Stop() +} +func (p *Parrot) TransmissionStarted(vfo core.VFOID) { + if !p.active { + return + } p.keyer.Stop() } @@ -165,7 +175,10 @@ func (p *Parrot) KeyerStopped() { p.Stop() } -func (p *Parrot) WorkmodeChanged(workmode core.Workmode) { +func (p *Parrot) WorkmodeChanged(vfo core.VFOID, workmode core.Workmode) { + if vfo != core.VFO1 { + return + } if p.active && workmode != core.Run { p.keyer.Stop() } diff --git a/core/parrot/parrot_test.go b/core/parrot/parrot_test.go new file mode 100644 index 00000000..27ac77b6 --- /dev/null +++ b/core/parrot/parrot_test.go @@ -0,0 +1,140 @@ +package parrot + +import ( + "testing" + "time" + + "github.com/ftl/hellocontest/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type keyerSpy struct { + sentVFO core.VFOID + sentWorkmode core.Workmode + sentIndex int + sendCalled bool + stopped bool +} + +func (k *keyerSpy) SendWithWorkmodeOnVFO(vfo core.VFOID, workmode core.Workmode, index int) { + k.sentVFO = vfo + k.sentWorkmode = workmode + k.sentIndex = index + k.sendCalled = true +} + +func (k *keyerSpy) Stop() { + k.stopped = true +} + +func (k *keyerSpy) reset() { + k.sentVFO = 0 + k.sentWorkmode = core.UnknownWorkmode + k.sentIndex = -1 + k.sendCalled = false + k.stopped = false +} + +type workmodeSpy struct { + workmode core.Workmode +} + +func (w *workmodeSpy) SetWorkmode(workmode core.Workmode) { + w.workmode = workmode +} + +func newTestParrot() (*Parrot, *keyerSpy) { + keyer := &keyerSpy{} + wm := &workmodeSpy{} + p := New(wm, keyer, func(f func()) { f() }) + p.SetInterval(50 * time.Millisecond) + return p, keyer +} + +func TestParrot_CQ_UseRunWorkmode(t *testing.T) { + p, keyer := newTestParrot() + + p.Start() + defer p.Stop() + + // Wait for first CQ tick (tickInterval = 1s, first CQ after ~1 tick) + require.Eventually(t, func() bool { + return keyer.sendCalled + }, 3*time.Second, 50*time.Millisecond, "Parrot must send CQ") + + assert.Equal(t, core.Run, keyer.sentWorkmode, + "Parrot must always use Run workmode for CQ") + assert.Equal(t, CQMessageIndex, keyer.sentIndex, + "Parrot must send CQ macro index 0") + assert.Equal(t, core.VFO1, keyer.sentVFO, + "Parrot must transmit CQ on VFO1") +} + +func TestParrot_TransmissionStarted_VFO2_StopsParrot(t *testing.T) { + p, keyer := newTestParrot() + + p.Start() + + // VFO2 transmission must interrupt parrot + keyer.reset() + p.TransmissionStarted(core.VFO2) + + assert.True(t, keyer.stopped, "VFO2 transmission must stop keyer") +} + +func TestParrot_TransmissionStarted_VFO1_StopsKeyer(t *testing.T) { + p, keyer := newTestParrot() + + p.Start() + + // Any manual transmission stops the parrot, incl. VFO1 (e.g. pressing '?') + keyer.reset() + p.TransmissionStarted(core.VFO1) + + assert.True(t, keyer.stopped, "VFO1 transmission must stop keyer") +} + +func TestParrot_CallsignEntered_VFO1_StopsKeyer(t *testing.T) { + p, keyer := newTestParrot() + + p.Start() + + keyer.reset() + p.CallsignEntered(core.VFO1, "DL1ABC") + + assert.True(t, keyer.stopped, "VFO1 callsign must stop keyer") +} + +func TestParrot_CallsignEntered_VFO2_Ignored(t *testing.T) { + p, keyer := newTestParrot() + + p.Start() + + keyer.reset() + p.CallsignEntered(core.VFO2, "DL1ABC") + + assert.False(t, keyer.stopped, "VFO2 callsign must not stop keyer") +} + +func TestParrot_WorkmodeChanged_VFO1_NonRun_StopsKeyer(t *testing.T) { + p, keyer := newTestParrot() + + p.Start() + + keyer.reset() + p.WorkmodeChanged(core.VFO1, core.SearchPounce) + + assert.True(t, keyer.stopped, "VFO1 workmode S&P must stop keyer") +} + +func TestParrot_WorkmodeChanged_VFO2_Ignored(t *testing.T) { + p, keyer := newTestParrot() + + p.Start() + + keyer.reset() + p.WorkmodeChanged(core.VFO2, core.SearchPounce) + + assert.False(t, keyer.stopped, "VFO2 workmode change must not stop keyer") +} diff --git a/core/pb/log.pb.go b/core/pb/log.pb.go index 05fd82c7..8f856f94 100644 --- a/core/pb/log.pb.go +++ b/core/pb/log.pb.go @@ -2,7 +2,7 @@ // versions: // protoc-gen-go v1.36.4 // protoc v3.19.4 -// source: log.proto +// source: core/pb/log.proto package pb @@ -55,11 +55,11 @@ func (x Workmode) String() string { } func (Workmode) Descriptor() protoreflect.EnumDescriptor { - return file_log_proto_enumTypes[0].Descriptor() + return file_core_pb_log_proto_enumTypes[0].Descriptor() } func (Workmode) Type() protoreflect.EnumType { - return &file_log_proto_enumTypes[0] + return &file_core_pb_log_proto_enumTypes[0] } func (x Workmode) Number() protoreflect.EnumNumber { @@ -68,7 +68,7 @@ func (x Workmode) Number() protoreflect.EnumNumber { // Deprecated: Use Workmode.Descriptor instead. func (Workmode) EnumDescriptor() ([]byte, []int) { - return file_log_proto_rawDescGZIP(), []int{0} + return file_core_pb_log_proto_rawDescGZIP(), []int{0} } type FileInfo struct { @@ -80,7 +80,7 @@ type FileInfo struct { func (x *FileInfo) Reset() { *x = FileInfo{} - mi := &file_log_proto_msgTypes[0] + mi := &file_core_pb_log_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -92,7 +92,7 @@ func (x *FileInfo) String() string { func (*FileInfo) ProtoMessage() {} func (x *FileInfo) ProtoReflect() protoreflect.Message { - mi := &file_log_proto_msgTypes[0] + mi := &file_core_pb_log_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -105,7 +105,7 @@ func (x *FileInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use FileInfo.ProtoReflect.Descriptor instead. func (*FileInfo) Descriptor() ([]byte, []int) { - return file_log_proto_rawDescGZIP(), []int{0} + return file_core_pb_log_proto_rawDescGZIP(), []int{0} } func (x *FileInfo) GetFormatVersion() int32 { @@ -131,7 +131,7 @@ type Entry struct { func (x *Entry) Reset() { *x = Entry{} - mi := &file_log_proto_msgTypes[1] + mi := &file_core_pb_log_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -143,7 +143,7 @@ func (x *Entry) String() string { func (*Entry) ProtoMessage() {} func (x *Entry) ProtoReflect() protoreflect.Message { - mi := &file_log_proto_msgTypes[1] + mi := &file_core_pb_log_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -156,7 +156,7 @@ func (x *Entry) ProtoReflect() protoreflect.Message { // Deprecated: Use Entry.ProtoReflect.Descriptor instead. func (*Entry) Descriptor() ([]byte, []int) { - return file_log_proto_rawDescGZIP(), []int{1} + return file_core_pb_log_proto_rawDescGZIP(), []int{1} } func (x *Entry) GetEntry() isEntry_Entry { @@ -268,7 +268,7 @@ type QSO struct { func (x *QSO) Reset() { *x = QSO{} - mi := &file_log_proto_msgTypes[2] + mi := &file_core_pb_log_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -280,7 +280,7 @@ func (x *QSO) String() string { func (*QSO) ProtoMessage() {} func (x *QSO) ProtoReflect() protoreflect.Message { - mi := &file_log_proto_msgTypes[2] + mi := &file_core_pb_log_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -293,7 +293,7 @@ func (x *QSO) ProtoReflect() protoreflect.Message { // Deprecated: Use QSO.ProtoReflect.Descriptor instead. func (*QSO) Descriptor() ([]byte, []int) { - return file_log_proto_rawDescGZIP(), []int{2} + return file_core_pb_log_proto_rawDescGZIP(), []int{2} } func (x *QSO) GetCallsign() string { @@ -412,7 +412,7 @@ type Station struct { func (x *Station) Reset() { *x = Station{} - mi := &file_log_proto_msgTypes[3] + mi := &file_core_pb_log_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -424,7 +424,7 @@ func (x *Station) String() string { func (*Station) ProtoMessage() {} func (x *Station) ProtoReflect() protoreflect.Message { - mi := &file_log_proto_msgTypes[3] + mi := &file_core_pb_log_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -437,7 +437,7 @@ func (x *Station) ProtoReflect() protoreflect.Message { // Deprecated: Use Station.ProtoReflect.Descriptor instead. func (*Station) Descriptor() ([]byte, []int) { - return file_log_proto_rawDescGZIP(), []int{3} + return file_core_pb_log_proto_rawDescGZIP(), []int{3} } func (x *Station) GetCallsign() string { @@ -496,7 +496,7 @@ type Contest struct { func (x *Contest) Reset() { *x = Contest{} - mi := &file_log_proto_msgTypes[4] + mi := &file_core_pb_log_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -508,7 +508,7 @@ func (x *Contest) String() string { func (*Contest) ProtoMessage() {} func (x *Contest) ProtoReflect() protoreflect.Message { - mi := &file_log_proto_msgTypes[4] + mi := &file_core_pb_log_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -521,7 +521,7 @@ func (x *Contest) ProtoReflect() protoreflect.Message { // Deprecated: Use Contest.ProtoReflect.Descriptor instead. func (*Contest) Descriptor() ([]byte, []int) { - return file_log_proto_rawDescGZIP(), []int{4} + return file_core_pb_log_proto_rawDescGZIP(), []int{4} } func (x *Contest) GetName() string { @@ -724,7 +724,7 @@ type Multis struct { func (x *Multis) Reset() { *x = Multis{} - mi := &file_log_proto_msgTypes[5] + mi := &file_core_pb_log_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -736,7 +736,7 @@ func (x *Multis) String() string { func (*Multis) ProtoMessage() {} func (x *Multis) ProtoReflect() protoreflect.Message { - mi := &file_log_proto_msgTypes[5] + mi := &file_core_pb_log_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -749,7 +749,7 @@ func (x *Multis) ProtoReflect() protoreflect.Message { // Deprecated: Use Multis.ProtoReflect.Descriptor instead. func (*Multis) Descriptor() ([]byte, []int) { - return file_log_proto_rawDescGZIP(), []int{5} + return file_core_pb_log_proto_rawDescGZIP(), []int{5} } func (x *Multis) GetDxcc() bool { @@ -787,7 +787,7 @@ type Keyer struct { func (x *Keyer) Reset() { *x = Keyer{} - mi := &file_log_proto_msgTypes[6] + mi := &file_core_pb_log_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -799,7 +799,7 @@ func (x *Keyer) String() string { func (*Keyer) ProtoMessage() {} func (x *Keyer) ProtoReflect() protoreflect.Message { - mi := &file_log_proto_msgTypes[6] + mi := &file_core_pb_log_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -812,7 +812,7 @@ func (x *Keyer) ProtoReflect() protoreflect.Message { // Deprecated: Use Keyer.ProtoReflect.Descriptor instead. func (*Keyer) Descriptor() ([]byte, []int) { - return file_log_proto_rawDescGZIP(), []int{6} + return file_core_pb_log_proto_rawDescGZIP(), []int{6} } func (x *Keyer) GetWpm() int32 { @@ -876,7 +876,7 @@ type QTC struct { func (x *QTC) Reset() { *x = QTC{} - mi := &file_log_proto_msgTypes[7] + mi := &file_core_pb_log_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -888,7 +888,7 @@ func (x *QTC) String() string { func (*QTC) ProtoMessage() {} func (x *QTC) ProtoReflect() protoreflect.Message { - mi := &file_log_proto_msgTypes[7] + mi := &file_core_pb_log_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -901,7 +901,7 @@ func (x *QTC) ProtoReflect() protoreflect.Message { // Deprecated: Use QTC.ProtoReflect.Descriptor instead. func (*QTC) Descriptor() ([]byte, []int) { - return file_log_proto_rawDescGZIP(), []int{7} + return file_core_pb_log_proto_rawDescGZIP(), []int{7} } func (x *QTC) GetTimestamp() int64 { @@ -981,200 +981,201 @@ func (x *QTC) GetQtcNumber() int32 { return 0 } -var File_log_proto protoreflect.FileDescriptor - -var file_log_proto_rawDesc = string([]byte{ - 0x0a, 0x09, 0x6c, 0x6f, 0x67, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x02, 0x70, 0x62, 0x1a, - 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, - 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x22, 0x31, 0x0a, 0x08, 0x46, 0x69, 0x6c, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x25, 0x0a, 0x0e, - 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x05, 0x52, 0x0d, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x56, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x22, 0xbf, 0x01, 0x0a, 0x05, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x1b, 0x0a, - 0x03, 0x71, 0x73, 0x6f, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x70, 0x62, 0x2e, - 0x51, 0x53, 0x4f, 0x48, 0x00, 0x52, 0x03, 0x71, 0x73, 0x6f, 0x12, 0x27, 0x0a, 0x07, 0x73, 0x74, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x70, 0x62, - 0x2e, 0x53, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x07, 0x73, 0x74, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x12, 0x27, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x73, 0x74, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x70, 0x62, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x73, - 0x74, 0x48, 0x00, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x05, - 0x6b, 0x65, 0x79, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x09, 0x2e, 0x70, 0x62, - 0x2e, 0x4b, 0x65, 0x79, 0x65, 0x72, 0x48, 0x00, 0x52, 0x05, 0x6b, 0x65, 0x79, 0x65, 0x72, 0x12, - 0x1b, 0x0a, 0x03, 0x71, 0x74, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x70, - 0x62, 0x2e, 0x51, 0x54, 0x43, 0x48, 0x00, 0x52, 0x03, 0x71, 0x74, 0x63, 0x42, 0x07, 0x0a, 0x05, - 0x65, 0x6e, 0x74, 0x72, 0x79, 0x22, 0xe6, 0x03, 0x0a, 0x03, 0x51, 0x53, 0x4f, 0x12, 0x1a, 0x0a, - 0x08, 0x63, 0x61, 0x6c, 0x6c, 0x73, 0x69, 0x67, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x08, 0x63, 0x61, 0x6c, 0x6c, 0x73, 0x69, 0x67, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, - 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x69, - 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x61, 0x6e, 0x64, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x62, 0x61, 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6d, - 0x6f, 0x64, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, - 0x1b, 0x0a, 0x09, 0x6d, 0x79, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x08, 0x6d, 0x79, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x1b, 0x0a, 0x09, - 0x6d, 0x79, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, - 0x08, 0x6d, 0x79, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x68, 0x65, - 0x69, 0x72, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0b, 0x74, 0x68, 0x65, 0x69, 0x72, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x21, 0x0a, 0x0c, - 0x74, 0x68, 0x65, 0x69, 0x72, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x08, 0x20, 0x01, - 0x28, 0x05, 0x52, 0x0b, 0x74, 0x68, 0x65, 0x69, 0x72, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, - 0x23, 0x0a, 0x0d, 0x6c, 0x6f, 0x67, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, - 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x6c, 0x6f, 0x67, 0x54, 0x69, 0x6d, 0x65, 0x73, - 0x74, 0x61, 0x6d, 0x70, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x79, 0x5f, 0x78, 0x63, 0x68, 0x61, 0x6e, - 0x67, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x79, 0x58, 0x63, 0x68, 0x61, - 0x6e, 0x67, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x68, 0x65, 0x69, 0x72, 0x5f, 0x78, 0x63, 0x68, - 0x61, 0x6e, 0x67, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x68, 0x65, 0x69, - 0x72, 0x58, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x66, 0x72, 0x65, 0x71, - 0x75, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x01, 0x52, 0x09, 0x66, 0x72, 0x65, - 0x71, 0x75, 0x65, 0x6e, 0x63, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x6d, 0x79, 0x5f, 0x65, 0x78, 0x63, - 0x68, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x0e, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x6d, 0x79, 0x45, - 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x74, 0x68, 0x65, 0x69, 0x72, - 0x5f, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x09, 0x52, - 0x0d, 0x74, 0x68, 0x65, 0x69, 0x72, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x28, - 0x0a, 0x08, 0x77, 0x6f, 0x72, 0x6b, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x10, 0x20, 0x01, 0x28, 0x0e, - 0x32, 0x0c, 0x2e, 0x70, 0x62, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x6d, 0x6f, 0x64, 0x65, 0x52, 0x08, - 0x77, 0x6f, 0x72, 0x6b, 0x6d, 0x6f, 0x64, 0x65, 0x4a, 0x04, 0x08, 0x0d, 0x10, 0x0e, 0x22, 0x5b, - 0x0a, 0x07, 0x53, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x61, 0x6c, - 0x6c, 0x73, 0x69, 0x67, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x61, 0x6c, - 0x6c, 0x73, 0x69, 0x67, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, - 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, - 0x72, 0x12, 0x18, 0x0a, 0x07, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x07, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x22, 0xbe, 0x09, 0x0a, 0x07, - 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x2c, 0x0a, 0x12, 0x65, - 0x6e, 0x74, 0x65, 0x72, 0x5f, 0x74, 0x68, 0x65, 0x69, 0x72, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, - 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x54, 0x68, - 0x65, 0x69, 0x72, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x2e, 0x0a, 0x13, 0x65, 0x6e, 0x74, - 0x65, 0x72, 0x5f, 0x74, 0x68, 0x65, 0x69, 0x72, 0x5f, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x54, 0x68, 0x65, - 0x69, 0x72, 0x58, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x32, 0x0a, 0x15, 0x72, 0x65, 0x71, - 0x75, 0x69, 0x72, 0x65, 0x5f, 0x74, 0x68, 0x65, 0x69, 0x72, 0x5f, 0x78, 0x63, 0x68, 0x61, 0x6e, - 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, - 0x65, 0x54, 0x68, 0x65, 0x69, 0x72, 0x58, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x28, 0x0a, - 0x10, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x5f, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x5f, 0x62, 0x61, 0x6e, - 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x4d, 0x75, - 0x6c, 0x74, 0x69, 0x42, 0x61, 0x6e, 0x64, 0x12, 0x28, 0x0a, 0x10, 0x61, 0x6c, 0x6c, 0x6f, 0x77, - 0x5f, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x0e, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x4d, 0x6f, 0x64, - 0x65, 0x12, 0x2e, 0x0a, 0x13, 0x73, 0x61, 0x6d, 0x65, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, - 0x79, 0x5f, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x11, - 0x73, 0x61, 0x6d, 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x50, 0x6f, 0x69, 0x6e, 0x74, - 0x73, 0x12, 0x32, 0x0a, 0x15, 0x73, 0x61, 0x6d, 0x65, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, - 0x65, 0x6e, 0x74, 0x5f, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05, - 0x52, 0x13, 0x73, 0x61, 0x6d, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x65, 0x6e, 0x74, 0x50, - 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x12, 0x36, 0x0a, 0x17, 0x73, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, - 0x63, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, - 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, 0x52, 0x15, 0x73, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x63, - 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x12, 0x3a, 0x0a, - 0x19, 0x73, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x63, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, - 0x79, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x17, 0x73, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x63, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, - 0x79, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x6f, 0x74, 0x68, - 0x65, 0x72, 0x5f, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x05, 0x52, - 0x0b, 0x6f, 0x74, 0x68, 0x65, 0x72, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x12, 0x22, 0x0a, 0x06, - 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0a, 0x2e, 0x70, - 0x62, 0x2e, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x73, 0x52, 0x06, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x73, - 0x12, 0x32, 0x0a, 0x15, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x5f, 0x6d, 0x75, 0x6c, 0x74, - 0x69, 0x5f, 0x70, 0x61, 0x74, 0x74, 0x65, 0x72, 0x6e, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x13, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x50, 0x61, 0x74, - 0x74, 0x65, 0x72, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x70, 0x65, - 0x72, 0x5f, 0x62, 0x61, 0x6e, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x63, 0x6f, - 0x75, 0x6e, 0x74, 0x50, 0x65, 0x72, 0x42, 0x61, 0x6e, 0x64, 0x12, 0x32, 0x0a, 0x15, 0x63, 0x61, - 0x62, 0x72, 0x69, 0x6c, 0x6c, 0x6f, 0x5f, 0x71, 0x73, 0x6f, 0x5f, 0x74, 0x65, 0x6d, 0x70, 0x6c, - 0x61, 0x74, 0x65, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x63, 0x61, 0x62, 0x72, 0x69, - 0x6c, 0x6c, 0x6f, 0x51, 0x73, 0x6f, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x12, 0x32, - 0x0a, 0x15, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x68, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x5f, 0x66, - 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x63, - 0x61, 0x6c, 0x6c, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x46, 0x69, 0x6c, 0x65, 0x6e, 0x61, - 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x64, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, - 0x5f, 0x79, 0x61, 0x6d, 0x6c, 0x18, 0x12, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x64, 0x65, 0x66, - 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x59, 0x61, 0x6d, 0x6c, 0x12, 0x27, 0x0a, 0x0f, 0x65, - 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x13, - 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x56, 0x61, - 0x6c, 0x75, 0x65, 0x73, 0x12, 0x38, 0x0a, 0x18, 0x67, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, - 0x5f, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x5f, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, - 0x18, 0x14, 0x20, 0x01, 0x28, 0x08, 0x52, 0x16, 0x67, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, - 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x37, - 0x0a, 0x18, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x68, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x5f, 0x66, - 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x15, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x15, 0x63, 0x61, 0x6c, 0x6c, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x46, 0x69, 0x65, - 0x6c, 0x64, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x71, 0x73, 0x6f, 0x73, 0x5f, - 0x67, 0x6f, 0x61, 0x6c, 0x18, 0x16, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x71, 0x73, 0x6f, 0x73, - 0x47, 0x6f, 0x61, 0x6c, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x5f, 0x67, - 0x6f, 0x61, 0x6c, 0x18, 0x17, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x70, 0x6f, 0x69, 0x6e, 0x74, - 0x73, 0x47, 0x6f, 0x61, 0x6c, 0x12, 0x1f, 0x0a, 0x0b, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x73, 0x5f, - 0x67, 0x6f, 0x61, 0x6c, 0x18, 0x18, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x6d, 0x75, 0x6c, 0x74, - 0x69, 0x73, 0x47, 0x6f, 0x61, 0x6c, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x74, - 0x5f, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x19, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x0f, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, - 0x1a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, - 0x70, 0x52, 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x0f, - 0x67, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x18, - 0x1b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x67, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x52, - 0x65, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x5f, - 0x71, 0x74, 0x63, 0x73, 0x18, 0x1c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x65, 0x6e, 0x61, 0x62, - 0x6c, 0x65, 0x51, 0x74, 0x63, 0x73, 0x4a, 0x04, 0x08, 0x11, 0x10, 0x12, 0x22, 0x48, 0x0a, 0x06, - 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x78, 0x63, 0x63, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x64, 0x78, 0x63, 0x63, 0x12, 0x10, 0x0a, 0x03, 0x77, 0x70, - 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x03, 0x77, 0x70, 0x78, 0x12, 0x18, 0x0a, 0x07, - 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x78, - 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x22, 0xc9, 0x01, 0x0a, 0x05, 0x4b, 0x65, 0x79, 0x65, 0x72, - 0x12, 0x10, 0x0a, 0x03, 0x77, 0x70, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x77, - 0x70, 0x6d, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x70, 0x5f, 0x6d, 0x61, 0x63, 0x72, 0x6f, 0x73, 0x18, - 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x73, 0x70, 0x4d, 0x61, 0x63, 0x72, 0x6f, 0x73, 0x12, - 0x1d, 0x0a, 0x0a, 0x72, 0x75, 0x6e, 0x5f, 0x6d, 0x61, 0x63, 0x72, 0x6f, 0x73, 0x18, 0x03, 0x20, - 0x03, 0x28, 0x09, 0x52, 0x09, 0x72, 0x75, 0x6e, 0x4d, 0x61, 0x63, 0x72, 0x6f, 0x73, 0x12, 0x1b, - 0x0a, 0x09, 0x73, 0x70, 0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, - 0x09, 0x52, 0x08, 0x73, 0x70, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x72, - 0x75, 0x6e, 0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, - 0x09, 0x72, 0x75, 0x6e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x36, 0x0a, 0x17, 0x70, 0x61, - 0x72, 0x72, 0x6f, 0x74, 0x5f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x5f, 0x73, 0x65, - 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x15, 0x70, 0x61, 0x72, - 0x72, 0x6f, 0x74, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x53, 0x65, 0x63, 0x6f, 0x6e, - 0x64, 0x73, 0x22, 0xb8, 0x02, 0x0a, 0x03, 0x51, 0x54, 0x43, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, - 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, - 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x1c, 0x0a, 0x09, 0x66, 0x72, 0x65, 0x71, - 0x75, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x01, 0x52, 0x09, 0x66, 0x72, 0x65, - 0x71, 0x75, 0x65, 0x6e, 0x63, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x61, 0x6e, 0x64, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x62, 0x61, 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x6f, - 0x64, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x12, - 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x6b, 0x69, - 0x6e, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x71, 0x73, 0x6f, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, - 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x71, 0x73, 0x6f, 0x4e, 0x75, 0x6d, 0x62, 0x65, - 0x72, 0x12, 0x25, 0x0a, 0x0e, 0x74, 0x68, 0x65, 0x69, 0x72, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x73, - 0x69, 0x67, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x74, 0x68, 0x65, 0x69, 0x72, - 0x43, 0x61, 0x6c, 0x6c, 0x73, 0x69, 0x67, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x68, 0x65, 0x61, 0x64, - 0x65, 0x72, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, - 0x12, 0x19, 0x0a, 0x08, 0x71, 0x74, 0x63, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x09, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x07, 0x71, 0x74, 0x63, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x71, - 0x74, 0x63, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x73, 0x69, 0x67, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0b, 0x71, 0x74, 0x63, 0x43, 0x61, 0x6c, 0x6c, 0x73, 0x69, 0x67, 0x6e, 0x12, 0x1d, - 0x0a, 0x0a, 0x71, 0x74, 0x63, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x0b, 0x20, 0x01, - 0x28, 0x05, 0x52, 0x09, 0x71, 0x74, 0x63, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x2a, 0x4e, 0x0a, - 0x08, 0x57, 0x6f, 0x72, 0x6b, 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x10, 0x57, 0x4f, 0x52, - 0x4b, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, - 0x1a, 0x0a, 0x16, 0x57, 0x4f, 0x52, 0x4b, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x53, 0x45, 0x41, 0x52, - 0x43, 0x48, 0x5f, 0x50, 0x4f, 0x55, 0x4e, 0x43, 0x45, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x57, - 0x4f, 0x52, 0x4b, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x52, 0x55, 0x4e, 0x10, 0x02, 0x42, 0x09, 0x5a, - 0x07, 0x63, 0x6f, 0x72, 0x65, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +var File_core_pb_log_proto protoreflect.FileDescriptor + +var file_core_pb_log_proto_rawDesc = string([]byte{ + 0x0a, 0x11, 0x63, 0x6f, 0x72, 0x65, 0x2f, 0x70, 0x62, 0x2f, 0x6c, 0x6f, 0x67, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x12, 0x02, 0x70, 0x62, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x31, 0x0a, 0x08, 0x46, 0x69, 0x6c, 0x65, + 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x25, 0x0a, 0x0e, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x5f, 0x76, + 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0d, 0x66, 0x6f, + 0x72, 0x6d, 0x61, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xbf, 0x01, 0x0a, 0x05, + 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x1b, 0x0a, 0x03, 0x71, 0x73, 0x6f, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x70, 0x62, 0x2e, 0x51, 0x53, 0x4f, 0x48, 0x00, 0x52, 0x03, 0x71, + 0x73, 0x6f, 0x12, 0x27, 0x0a, 0x07, 0x73, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x70, 0x62, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x48, 0x00, 0x52, 0x07, 0x73, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x27, 0x0a, 0x07, 0x63, + 0x6f, 0x6e, 0x74, 0x65, 0x73, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x70, + 0x62, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x07, 0x63, 0x6f, 0x6e, + 0x74, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x05, 0x6b, 0x65, 0x79, 0x65, 0x72, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x09, 0x2e, 0x70, 0x62, 0x2e, 0x4b, 0x65, 0x79, 0x65, 0x72, 0x48, 0x00, + 0x52, 0x05, 0x6b, 0x65, 0x79, 0x65, 0x72, 0x12, 0x1b, 0x0a, 0x03, 0x71, 0x74, 0x63, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x70, 0x62, 0x2e, 0x51, 0x54, 0x43, 0x48, 0x00, 0x52, + 0x03, 0x71, 0x74, 0x63, 0x42, 0x07, 0x0a, 0x05, 0x65, 0x6e, 0x74, 0x72, 0x79, 0x22, 0xe6, 0x03, + 0x0a, 0x03, 0x51, 0x53, 0x4f, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x61, 0x6c, 0x6c, 0x73, 0x69, 0x67, + 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x61, 0x6c, 0x6c, 0x73, 0x69, 0x67, + 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, + 0x12, 0x0a, 0x04, 0x62, 0x61, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x62, + 0x61, 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x79, 0x5f, 0x72, 0x65, + 0x70, 0x6f, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6d, 0x79, 0x52, 0x65, + 0x70, 0x6f, 0x72, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x79, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, + 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x6d, 0x79, 0x4e, 0x75, 0x6d, 0x62, 0x65, + 0x72, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x68, 0x65, 0x69, 0x72, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x72, + 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x74, 0x68, 0x65, 0x69, 0x72, 0x52, 0x65, + 0x70, 0x6f, 0x72, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x68, 0x65, 0x69, 0x72, 0x5f, 0x6e, 0x75, + 0x6d, 0x62, 0x65, 0x72, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0b, 0x74, 0x68, 0x65, 0x69, + 0x72, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x23, 0x0a, 0x0d, 0x6c, 0x6f, 0x67, 0x5f, 0x74, + 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, + 0x6c, 0x6f, 0x67, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x1d, 0x0a, 0x0a, + 0x6d, 0x79, 0x5f, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x6d, 0x79, 0x58, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x74, + 0x68, 0x65, 0x69, 0x72, 0x5f, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x0b, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0c, 0x74, 0x68, 0x65, 0x69, 0x72, 0x58, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, + 0x12, 0x1c, 0x0a, 0x09, 0x66, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x0c, 0x20, + 0x01, 0x28, 0x01, 0x52, 0x09, 0x66, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x79, 0x12, 0x1f, + 0x0a, 0x0b, 0x6d, 0x79, 0x5f, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x0e, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x0a, 0x6d, 0x79, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, + 0x25, 0x0a, 0x0e, 0x74, 0x68, 0x65, 0x69, 0x72, 0x5f, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, + 0x65, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0d, 0x74, 0x68, 0x65, 0x69, 0x72, 0x45, 0x78, + 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x28, 0x0a, 0x08, 0x77, 0x6f, 0x72, 0x6b, 0x6d, 0x6f, + 0x64, 0x65, 0x18, 0x10, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0c, 0x2e, 0x70, 0x62, 0x2e, 0x57, 0x6f, + 0x72, 0x6b, 0x6d, 0x6f, 0x64, 0x65, 0x52, 0x08, 0x77, 0x6f, 0x72, 0x6b, 0x6d, 0x6f, 0x64, 0x65, + 0x4a, 0x04, 0x08, 0x0d, 0x10, 0x0e, 0x22, 0x5b, 0x0a, 0x07, 0x53, 0x74, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x61, 0x6c, 0x6c, 0x73, 0x69, 0x67, 0x6e, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x61, 0x6c, 0x6c, 0x73, 0x69, 0x67, 0x6e, 0x12, 0x1a, 0x0a, + 0x08, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x18, 0x0a, 0x07, 0x6c, 0x6f, 0x63, + 0x61, 0x74, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6c, 0x6f, 0x63, 0x61, + 0x74, 0x6f, 0x72, 0x22, 0xbe, 0x09, 0x0a, 0x07, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x73, 0x74, 0x12, + 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x12, 0x2c, 0x0a, 0x12, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x5f, 0x74, 0x68, 0x65, + 0x69, 0x72, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x10, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x54, 0x68, 0x65, 0x69, 0x72, 0x4e, 0x75, 0x6d, 0x62, 0x65, + 0x72, 0x12, 0x2e, 0x0a, 0x13, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x5f, 0x74, 0x68, 0x65, 0x69, 0x72, + 0x5f, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, + 0x65, 0x6e, 0x74, 0x65, 0x72, 0x54, 0x68, 0x65, 0x69, 0x72, 0x58, 0x63, 0x68, 0x61, 0x6e, 0x67, + 0x65, 0x12, 0x32, 0x0a, 0x15, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x5f, 0x74, 0x68, 0x65, + 0x69, 0x72, 0x5f, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x13, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x54, 0x68, 0x65, 0x69, 0x72, 0x58, 0x63, + 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x28, 0x0a, 0x10, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x5f, 0x6d, + 0x75, 0x6c, 0x74, 0x69, 0x5f, 0x62, 0x61, 0x6e, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x0e, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x42, 0x61, 0x6e, 0x64, 0x12, + 0x28, 0x0a, 0x10, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x5f, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x5f, 0x6d, + 0x6f, 0x64, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x61, 0x6c, 0x6c, 0x6f, 0x77, + 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x2e, 0x0a, 0x13, 0x73, 0x61, 0x6d, + 0x65, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, + 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x11, 0x73, 0x61, 0x6d, 0x65, 0x43, 0x6f, 0x75, 0x6e, + 0x74, 0x72, 0x79, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x12, 0x32, 0x0a, 0x15, 0x73, 0x61, 0x6d, + 0x65, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x65, 0x6e, 0x74, 0x5f, 0x70, 0x6f, 0x69, 0x6e, + 0x74, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05, 0x52, 0x13, 0x73, 0x61, 0x6d, 0x65, 0x43, 0x6f, + 0x6e, 0x74, 0x69, 0x6e, 0x65, 0x6e, 0x74, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x12, 0x36, 0x0a, + 0x17, 0x73, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x63, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, + 0x79, 0x5f, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, 0x52, 0x15, + 0x73, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x63, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x50, + 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x12, 0x3a, 0x0a, 0x19, 0x73, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, + 0x63, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, + 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x17, 0x73, 0x70, 0x65, 0x63, 0x69, 0x66, + 0x69, 0x63, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, + 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x6f, 0x74, 0x68, 0x65, 0x72, 0x5f, 0x70, 0x6f, 0x69, 0x6e, 0x74, + 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0b, 0x6f, 0x74, 0x68, 0x65, 0x72, 0x50, 0x6f, + 0x69, 0x6e, 0x74, 0x73, 0x12, 0x22, 0x0a, 0x06, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x73, 0x18, 0x0c, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0a, 0x2e, 0x70, 0x62, 0x2e, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x73, + 0x52, 0x06, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x73, 0x12, 0x32, 0x0a, 0x15, 0x78, 0x63, 0x68, 0x61, + 0x6e, 0x67, 0x65, 0x5f, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x5f, 0x70, 0x61, 0x74, 0x74, 0x65, 0x72, + 0x6e, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, + 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x50, 0x61, 0x74, 0x74, 0x65, 0x72, 0x6e, 0x12, 0x24, 0x0a, 0x0e, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x70, 0x65, 0x72, 0x5f, 0x62, 0x61, 0x6e, 0x64, 0x18, 0x0e, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x50, 0x65, 0x72, 0x42, 0x61, + 0x6e, 0x64, 0x12, 0x32, 0x0a, 0x15, 0x63, 0x61, 0x62, 0x72, 0x69, 0x6c, 0x6c, 0x6f, 0x5f, 0x71, + 0x73, 0x6f, 0x5f, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x18, 0x0f, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x13, 0x63, 0x61, 0x62, 0x72, 0x69, 0x6c, 0x6c, 0x6f, 0x51, 0x73, 0x6f, 0x54, 0x65, + 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x12, 0x32, 0x0a, 0x15, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x68, + 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x5f, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x18, + 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x63, 0x61, 0x6c, 0x6c, 0x48, 0x69, 0x73, 0x74, 0x6f, + 0x72, 0x79, 0x46, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x64, 0x65, + 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x79, 0x61, 0x6d, 0x6c, 0x18, 0x12, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0e, 0x64, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x59, + 0x61, 0x6d, 0x6c, 0x12, 0x27, 0x0a, 0x0f, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x5f, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x13, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x65, 0x78, + 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x38, 0x0a, 0x18, + 0x67, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x5f, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x5f, + 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x14, 0x20, 0x01, 0x28, 0x08, 0x52, 0x16, + 0x67, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x45, 0x78, + 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x37, 0x0a, 0x18, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x68, + 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x6e, 0x61, 0x6d, + 0x65, 0x73, 0x18, 0x15, 0x20, 0x03, 0x28, 0x09, 0x52, 0x15, 0x63, 0x61, 0x6c, 0x6c, 0x48, 0x69, + 0x73, 0x74, 0x6f, 0x72, 0x79, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x12, + 0x1b, 0x0a, 0x09, 0x71, 0x73, 0x6f, 0x73, 0x5f, 0x67, 0x6f, 0x61, 0x6c, 0x18, 0x16, 0x20, 0x01, + 0x28, 0x05, 0x52, 0x08, 0x71, 0x73, 0x6f, 0x73, 0x47, 0x6f, 0x61, 0x6c, 0x12, 0x1f, 0x0a, 0x0b, + 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x5f, 0x67, 0x6f, 0x61, 0x6c, 0x18, 0x17, 0x20, 0x01, 0x28, + 0x05, 0x52, 0x0a, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x47, 0x6f, 0x61, 0x6c, 0x12, 0x1f, 0x0a, + 0x0b, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x73, 0x5f, 0x67, 0x6f, 0x61, 0x6c, 0x18, 0x18, 0x20, 0x01, + 0x28, 0x05, 0x52, 0x0a, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x73, 0x47, 0x6f, 0x61, 0x6c, 0x12, 0x29, + 0x0a, 0x10, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x5f, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x18, 0x19, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x74, + 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, + 0x72, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x1a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, + 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, + 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, + 0x54, 0x69, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x67, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, + 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x1b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x67, + 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x1f, 0x0a, + 0x0b, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x71, 0x74, 0x63, 0x73, 0x18, 0x1c, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x0a, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x51, 0x74, 0x63, 0x73, 0x4a, 0x04, + 0x08, 0x11, 0x10, 0x12, 0x22, 0x48, 0x0a, 0x06, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x73, 0x12, 0x12, + 0x0a, 0x04, 0x64, 0x78, 0x63, 0x63, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x64, 0x78, + 0x63, 0x63, 0x12, 0x10, 0x0a, 0x03, 0x77, 0x70, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x03, 0x77, 0x70, 0x78, 0x12, 0x18, 0x0a, 0x07, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x22, 0xc9, + 0x01, 0x0a, 0x05, 0x4b, 0x65, 0x79, 0x65, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x77, 0x70, 0x6d, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x77, 0x70, 0x6d, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x70, + 0x5f, 0x6d, 0x61, 0x63, 0x72, 0x6f, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x73, + 0x70, 0x4d, 0x61, 0x63, 0x72, 0x6f, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x75, 0x6e, 0x5f, 0x6d, + 0x61, 0x63, 0x72, 0x6f, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x72, 0x75, 0x6e, + 0x4d, 0x61, 0x63, 0x72, 0x6f, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x70, 0x5f, 0x6c, 0x61, 0x62, + 0x65, 0x6c, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x73, 0x70, 0x4c, 0x61, 0x62, + 0x65, 0x6c, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x75, 0x6e, 0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c, + 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x72, 0x75, 0x6e, 0x4c, 0x61, 0x62, 0x65, + 0x6c, 0x73, 0x12, 0x36, 0x0a, 0x17, 0x70, 0x61, 0x72, 0x72, 0x6f, 0x74, 0x5f, 0x69, 0x6e, 0x74, + 0x65, 0x72, 0x76, 0x61, 0x6c, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x05, 0x52, 0x15, 0x70, 0x61, 0x72, 0x72, 0x6f, 0x74, 0x49, 0x6e, 0x74, 0x65, 0x72, + 0x76, 0x61, 0x6c, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x22, 0xb8, 0x02, 0x0a, 0x03, 0x51, + 0x54, 0x43, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, + 0x12, 0x1c, 0x0a, 0x09, 0x66, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x01, 0x52, 0x09, 0x66, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x79, 0x12, 0x12, + 0x0a, 0x04, 0x62, 0x61, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x62, 0x61, + 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x71, 0x73, + 0x6f, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, + 0x71, 0x73, 0x6f, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x25, 0x0a, 0x0e, 0x74, 0x68, 0x65, + 0x69, 0x72, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x73, 0x69, 0x67, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0d, 0x74, 0x68, 0x65, 0x69, 0x72, 0x43, 0x61, 0x6c, 0x6c, 0x73, 0x69, 0x67, 0x6e, + 0x12, 0x16, 0x0a, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x12, 0x19, 0x0a, 0x08, 0x71, 0x74, 0x63, 0x5f, + 0x74, 0x69, 0x6d, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x71, 0x74, 0x63, 0x54, + 0x69, 0x6d, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x71, 0x74, 0x63, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x73, + 0x69, 0x67, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x71, 0x74, 0x63, 0x43, 0x61, + 0x6c, 0x6c, 0x73, 0x69, 0x67, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x71, 0x74, 0x63, 0x5f, 0x6e, 0x75, + 0x6d, 0x62, 0x65, 0x72, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x71, 0x74, 0x63, 0x4e, + 0x75, 0x6d, 0x62, 0x65, 0x72, 0x2a, 0x4e, 0x0a, 0x08, 0x57, 0x6f, 0x72, 0x6b, 0x6d, 0x6f, 0x64, + 0x65, 0x12, 0x14, 0x0a, 0x10, 0x57, 0x4f, 0x52, 0x4b, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x55, 0x4e, + 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x1a, 0x0a, 0x16, 0x57, 0x4f, 0x52, 0x4b, 0x4d, + 0x4f, 0x44, 0x45, 0x5f, 0x53, 0x45, 0x41, 0x52, 0x43, 0x48, 0x5f, 0x50, 0x4f, 0x55, 0x4e, 0x43, + 0x45, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x57, 0x4f, 0x52, 0x4b, 0x4d, 0x4f, 0x44, 0x45, 0x5f, + 0x52, 0x55, 0x4e, 0x10, 0x02, 0x42, 0x09, 0x5a, 0x07, 0x63, 0x6f, 0x72, 0x65, 0x2f, 0x70, 0x62, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var ( - file_log_proto_rawDescOnce sync.Once - file_log_proto_rawDescData []byte + file_core_pb_log_proto_rawDescOnce sync.Once + file_core_pb_log_proto_rawDescData []byte ) -func file_log_proto_rawDescGZIP() []byte { - file_log_proto_rawDescOnce.Do(func() { - file_log_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_log_proto_rawDesc), len(file_log_proto_rawDesc))) +func file_core_pb_log_proto_rawDescGZIP() []byte { + file_core_pb_log_proto_rawDescOnce.Do(func() { + file_core_pb_log_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_core_pb_log_proto_rawDesc), len(file_core_pb_log_proto_rawDesc))) }) - return file_log_proto_rawDescData + return file_core_pb_log_proto_rawDescData } -var file_log_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_log_proto_msgTypes = make([]protoimpl.MessageInfo, 8) -var file_log_proto_goTypes = []any{ +var file_core_pb_log_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_core_pb_log_proto_msgTypes = make([]protoimpl.MessageInfo, 8) +var file_core_pb_log_proto_goTypes = []any{ (Workmode)(0), // 0: pb.Workmode (*FileInfo)(nil), // 1: pb.FileInfo (*Entry)(nil), // 2: pb.Entry @@ -1186,7 +1187,7 @@ var file_log_proto_goTypes = []any{ (*QTC)(nil), // 8: pb.QTC (*timestamppb.Timestamp)(nil), // 9: google.protobuf.Timestamp } -var file_log_proto_depIdxs = []int32{ +var file_core_pb_log_proto_depIdxs = []int32{ 3, // 0: pb.Entry.qso:type_name -> pb.QSO 4, // 1: pb.Entry.station:type_name -> pb.Station 5, // 2: pb.Entry.contest:type_name -> pb.Contest @@ -1202,12 +1203,12 @@ var file_log_proto_depIdxs = []int32{ 0, // [0:8] is the sub-list for field type_name } -func init() { file_log_proto_init() } -func file_log_proto_init() { - if File_log_proto != nil { +func init() { file_core_pb_log_proto_init() } +func file_core_pb_log_proto_init() { + if File_core_pb_log_proto != nil { return } - file_log_proto_msgTypes[1].OneofWrappers = []any{ + file_core_pb_log_proto_msgTypes[1].OneofWrappers = []any{ (*Entry_Qso)(nil), (*Entry_Station)(nil), (*Entry_Contest)(nil), @@ -1218,18 +1219,18 @@ func file_log_proto_init() { out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_log_proto_rawDesc), len(file_log_proto_rawDesc)), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_core_pb_log_proto_rawDesc), len(file_core_pb_log_proto_rawDesc)), NumEnums: 1, NumMessages: 8, NumExtensions: 0, NumServices: 0, }, - GoTypes: file_log_proto_goTypes, - DependencyIndexes: file_log_proto_depIdxs, - EnumInfos: file_log_proto_enumTypes, - MessageInfos: file_log_proto_msgTypes, + GoTypes: file_core_pb_log_proto_goTypes, + DependencyIndexes: file_core_pb_log_proto_depIdxs, + EnumInfos: file_core_pb_log_proto_enumTypes, + MessageInfos: file_core_pb_log_proto_msgTypes, }.Build() - File_log_proto = out.File - file_log_proto_goTypes = nil - file_log_proto_depIdxs = nil + File_core_pb_log_proto = out.File + file_core_pb_log_proto_goTypes = nil + file_core_pb_log_proto_depIdxs = nil } diff --git a/core/qtc/qtc.go b/core/qtc/qtc.go index b0135b25..048432fb 100644 --- a/core/qtc/qtc.go +++ b/core/qtc/qtc.go @@ -33,6 +33,8 @@ type QTCList interface { type EntryController interface { CurrentQSOState() (core.Callsign, core.QSODataState) + CurrentVFOState() (core.Frequency, core.Band, core.Mode) + FocusedVFO() (string, bool) Log() } @@ -53,7 +55,7 @@ type View interface { QuestionQTCCount(max int) (int, bool) ShowFieldError(core.QTCField, string) ClearFieldError() - Show(core.QTCMode, core.QTCSeries) + Show(core.QTCMode, core.QTCSeries, string) UpdateQTC(int, core.QTC) Close() ClearDataInputs() @@ -93,10 +95,6 @@ type Controller struct { currentSeries core.QTCSeries currentQTC int currentInput map[core.QTCField]string - - vfoFrequency core.Frequency - vfoBand core.Band - vfoMode core.Mode } func NewController(clock core.Clock, infoDialogs InfoDialogs, logbook Logbook, qtcList QTCList, entryController EntryController, keyer Keyer) *Controller { @@ -147,18 +145,6 @@ func (c *Controller) clearErrorMessage() { c.view.ClearFieldError() } -func (c *Controller) VFOFrequencyChanged(frequency core.Frequency) { - c.vfoFrequency = frequency -} - -func (c *Controller) VFOBandChanged(band core.Band) { - c.vfoBand = band -} - -func (c *Controller) VFOModeChanged(mode core.Mode) { - c.vfoMode = mode -} - // OfferQTC initiates the QTC dialog with mode core.ProvideQTC. func (c *Controller) OfferQTC() { // 1. find out their callsign @@ -198,7 +184,15 @@ func (c *Controller) OfferQTC() { c.sendQTCOffer() // 6. show and run the QTC dialog - c.view.Show(c.currentMode, c.currentSeries) + c.view.Show(c.currentMode, c.currentSeries, c.so2vVFOName()) +} + +func (c *Controller) so2vVFOName() string { + name, so2v := c.entryController.FocusedVFO() + if !so2v { + return "" + } + return name } func (c *Controller) findOutTheirCallsign() (core.Callsign, bool) { @@ -245,7 +239,7 @@ func (c *Controller) RequestQTC() { c.setActivePhase(core.QTCStart) // show and run the QTC dialog - c.view.Show(c.currentMode, c.currentSeries) + c.view.Show(c.currentMode, c.currentSeries, c.so2vVFOName()) } // ***************************************************** @@ -455,9 +449,7 @@ func (c *Controller) sendCurrentQTC() { // add transmission data and mark the QTC as transmitted currentQTC.Timestamp = c.clock.Now() - currentQTC.Frequency = c.vfoFrequency - currentQTC.Band = c.vfoBand - currentQTC.Mode = c.vfoMode + currentQTC.Frequency, currentQTC.Band, currentQTC.Mode = c.entryController.CurrentVFOState() c.currentSeries.QTCs[c.currentQTC] = currentQTC } @@ -512,16 +504,16 @@ var _ View = &nullView{} type nullView struct{} -func (*nullView) QuestionQTCCount(int) (int, bool) { return 0, false } -func (*nullView) ShowFieldError(core.QTCField, string) {} -func (*nullView) ClearFieldError() {} -func (*nullView) Show(core.QTCMode, core.QTCSeries) {} -func (*nullView) UpdateQTC(int, core.QTC) {} -func (*nullView) Close() {} -func (*nullView) ClearDataInputs() {} -func (*nullView) SetActivePhase(core.QTCWorkflowPhase) {} -func (*nullView) SetActiveField(core.QTCField) {} -func (*nullView) SetActiveQTC(int) {} +func (*nullView) QuestionQTCCount(int) (int, bool) { return 0, false } +func (*nullView) ShowFieldError(core.QTCField, string) {} +func (*nullView) ClearFieldError() {} +func (*nullView) Show(core.QTCMode, core.QTCSeries, string) {} +func (*nullView) UpdateQTC(int, core.QTC) {} +func (*nullView) Close() {} +func (*nullView) ClearDataInputs() {} +func (*nullView) SetActivePhase(core.QTCWorkflowPhase) {} +func (*nullView) SetActiveField(core.QTCField) {} +func (*nullView) SetActiveQTC(int) {} // nullWorkflow diff --git a/core/qtc/qtc_test.go b/core/qtc/qtc_test.go index b8c36498..ad075d6b 100644 --- a/core/qtc/qtc_test.go +++ b/core/qtc/qtc_test.go @@ -31,11 +31,9 @@ func TestOfferQTC_HappyPath(t *testing.T) { WithClock(clock.Static(now)). WithKeyer(keyer). WithLogbook(logbook). + WithVFOState(7020000, core.Band40m, core.ModeCW). Build() c.SetView(view) - c.VFOFrequencyChanged(7020000) - c.VFOBandChanged(core.Band40m) - c.VFOModeChanged(core.ModeCW) c.OfferQTC() assert.Equal(t, "qtc", keyer.lastTransmission) @@ -124,11 +122,9 @@ func TestRequestQTC_HappyPath(t *testing.T) { WithClock(clock.Static(now)). WithKeyer(keyer). WithLogbook(logbook). + WithVFOState(7020000, core.Band40m, core.ModeCW). Build() c.SetView(view) - c.VFOFrequencyChanged(7020000) - c.VFOBandChanged(core.Band40m) - c.VFOModeChanged(core.ModeCW) c.RequestQTC() assert.Equal(t, core.QTCStart, c.activePhase) @@ -275,6 +271,68 @@ func TestRequestQTC_HappyPath(t *testing.T) { }, logbook.loggedQTCs[1]) } +func TestOfferQTC_ShowsFocusedVFONameOnlyInSO2V(t *testing.T) { + theirCallsign := core.MustParseCallsign("DL1ABC") + newViewFor := func(so2v bool) *fakeView { + view := new(fakeView) + logbook := &fakeLogbook{ + nextSeriesNumber: 1, + lastCallsign: theirCallsign, + availableQTCs: qtcsFor(core.SentQTC, theirCallsign). + Add("0123", "DK1AB", 1). + Build(), + } + c := newController(). + WithKeyer(new(fakeKeyer)). + WithLogbook(logbook). + WithFocusedVFO("VFO 2", so2v). + Build() + c.SetView(view) + c.OfferQTC() + return view + } + + assert.Equal(t, "VFO 2", newViewFor(true).vfoName) + assert.Equal(t, "", newViewFor(false).vfoName) +} + +func TestOfferQTC_SO2V_StampsFocusedVFOState(t *testing.T) { + now := time.Now() + view := new(fakeView) + keyer := new(fakeKeyer) + theirCallsign := core.MustParseCallsign("DL1ABC") + logbook := &fakeLogbook{ + nextSeriesNumber: 1, + lastCallsign: theirCallsign, + availableQTCs: qtcsFor(core.SentQTC, theirCallsign). + Add("0123", "DK1AB", 1). + Build(), + } + // the operator works the QTC series on the focused (sub) VFO, tuned to 15m + c := newController(). + WithClock(clock.Static(now)). + WithKeyer(keyer). + WithLogbook(logbook). + WithVFOState(21020000, core.Band15m, core.ModeCW). + Build() + c.SetView(view) + + c.OfferQTC() + c.ConfirmStart() + c.ConfirmHeader() // sends and stamps the first QTC + c.ConfirmData() + + assert.Equal(t, core.Frequency(21020000), c.currentSeries.QTCs[0].Frequency) + assert.Equal(t, core.Band15m, c.currentSeries.QTCs[0].Band) + assert.Equal(t, core.ModeCW, c.currentSeries.QTCs[0].Mode) + + c.CompleteQTCSeries() + assert.Equal(t, 1, len(logbook.loggedQTCs)) + assert.Equal(t, core.Frequency(21020000), logbook.loggedQTCs[0].Frequency) + assert.Equal(t, core.Band15m, logbook.loggedQTCs[0].Band) + assert.Equal(t, core.ModeCW, logbook.loggedQTCs[0].Mode) +} + func TestRequestQTC_HeaderErrors(t *testing.T) { tests := []struct { name string @@ -310,9 +368,6 @@ func TestRequestQTC_HeaderErrors(t *testing.T) { WithLogbook(logbook). Build() c.SetView(view) - c.VFOFrequencyChanged(7020000) - c.VFOBandChanged(core.Band40m) - c.VFOModeChanged(core.ModeCW) c.RequestQTC() c.StartAction() c.ConfirmStart() @@ -399,9 +454,6 @@ func TestRequestQTC_DataErrors(t *testing.T) { WithLogbook(logbook). Build() c.SetView(view) - c.VFOFrequencyChanged(7020000) - c.VFOBandChanged(core.Band40m) - c.VFOModeChanged(core.ModeCW) c.RequestQTC() c.StartAction() c.ConfirmStart() diff --git a/core/qtc/receive.go b/core/qtc/receive.go index bceb83ed..06e954b9 100644 --- a/core/qtc/receive.go +++ b/core/qtc/receive.go @@ -116,12 +116,13 @@ func (c *receive) GotoNextField() { func (c *receive) CompleteQTCSeries() { // fill common data from the series and log the QTC series + frequency, band, mode := c.entryController.CurrentVFOState() for i, qtc := range c.currentSeries.QTCs { qtc.TheirCallsign = c.currentSeries.TheirCallsign qtc.Header = c.currentSeries.Header - qtc.Frequency = c.vfoFrequency - qtc.Band = c.vfoBand - qtc.Mode = c.vfoMode + qtc.Frequency = frequency + qtc.Band = band + qtc.Mode = mode c.currentSeries.QTCs[i] = qtc } diff --git a/core/qtc/test_harness.go b/core/qtc/test_harness.go index ffd22993..00be4817 100644 --- a/core/qtc/test_harness.go +++ b/core/qtc/test_harness.go @@ -25,11 +25,22 @@ func (l *fakeQTCList) SetQTCsEnabled(bool) {} type fakeEntryController struct { currentCallsign core.Callsign currentState core.QSODataState + frequency core.Frequency + band core.Band + mode core.Mode + vfoName string + so2v bool } func (c *fakeEntryController) CurrentQSOState() (core.Callsign, core.QSODataState) { return c.currentCallsign, c.currentState } +func (c *fakeEntryController) CurrentVFOState() (core.Frequency, core.Band, core.Mode) { + return c.frequency, c.band, c.mode +} +func (c *fakeEntryController) FocusedVFO() (string, bool) { + return c.vfoName, c.so2v +} func (c *fakeEntryController) Log() {} type fakeKeyer struct { @@ -68,6 +79,7 @@ type fakeView struct { activeQTCIndex int errorMessage string errorField core.QTCField + vfoName string } func (v *fakeView) QuestionQTCCount(max int) (int, bool) { @@ -81,10 +93,11 @@ func (v *fakeView) ClearFieldError() { v.errorField = core.QTCNoneField v.errorMessage = "" } -func (v *fakeView) Show(mode core.QTCMode, series core.QTCSeries) { +func (v *fakeView) Show(mode core.QTCMode, series core.QTCSeries, vfoName string) { v.visible = true v.mode = mode v.series = series + v.vfoName = vfoName } func (v *fakeView) UpdateQTC(index int, qtc core.QTC) { v.series.SetData(index, qtc) @@ -168,6 +181,29 @@ func (b *controllerBuilder) WithCurrentQSOState(call core.Callsign, state core.Q return b } +func (b *controllerBuilder) WithVFOState(frequency core.Frequency, band core.Band, mode core.Mode) *controllerBuilder { + fake, ok := b.entryController.(*fakeEntryController) + if !ok { + fake = &fakeEntryController{} + b.entryController = fake + } + fake.frequency = frequency + fake.band = band + fake.mode = mode + return b +} + +func (b *controllerBuilder) WithFocusedVFO(name string, so2v bool) *controllerBuilder { + fake, ok := b.entryController.(*fakeEntryController) + if !ok { + fake = &fakeEntryController{} + b.entryController = fake + } + fake.vfoName = name + fake.so2v = so2v + return b +} + func (b *controllerBuilder) WithKeyer(keyer Keyer) *controllerBuilder { b.keyer = keyer return b diff --git a/core/radio/radio.go b/core/radio/radio.go index 4a7a9f1d..74ba1cae 100644 --- a/core/radio/radio.go +++ b/core/radio/radio.go @@ -13,6 +13,13 @@ import ( "github.com/ftl/hellocontest/core/tci" ) +const ( + hamlibVFO1Option = "vfo1" + hamlibVFO2Option = "vfo2" + tciTRXOption = "trx" + tciSingleVFOOption = "single_vfo" +) + type View interface { AddRadio(name string) AddKeyer(name string) @@ -42,10 +49,16 @@ type radio interface { keyer Disconnect() Active() bool - SetFrequency(core.Frequency) - SetBand(core.Band) - SetMode(core.Mode) + SingleVFO() bool + SetCurrentVFO(core.VFOID) + SetTXVFO(core.VFOID) + SetFrequency(core.VFOID, core.Frequency) + SetBand(core.VFOID, core.Band) + SetMode(core.VFOID, core.Mode) SetXIT(bool, core.Frequency) + MuteAudio(core.VFOID) + UnmuteAudio(core.VFOID) + ToggleAudio(core.VFOID) Refresh() Notify(any) } @@ -100,6 +113,7 @@ func (c *Controller) Stop() { c.activeRadio.Disconnect() c.activeRadio = nil c.activeRadioName = "" + c.emitRadioChanged("", true) } if c.activeKeyer != nil { c.activeKeyer.Abort() @@ -113,30 +127,21 @@ func (c *Controller) Notify(listener any) { } func (c *Controller) emitRadioStatusChanged(available bool) { - for _, listener := range c.listeners { - if serviceStatusListener, ok := listener.(core.ServiceStatusListener); ok { - serviceStatusListener.StatusChanged(core.RadioService, available) - } - } + core.Emit(c.listeners, func(listener core.ServiceStatusListener) { + listener.StatusChanged(core.RadioService, available) + }) } func (c *Controller) emitKeyerStatusChanged(available bool) { - for _, listener := range c.listeners { - if serviceStatusListener, ok := listener.(core.ServiceStatusListener); ok { - serviceStatusListener.StatusChanged(core.KeyerService, available) - } - } + core.Emit(c.listeners, func(listener core.ServiceStatusListener) { + listener.StatusChanged(core.KeyerService, available) + }) } -func (c *Controller) emitRadioSelected(name string) { - type listenerType interface { - RadioSelected(string) - } - for _, listener := range c.listeners { - if typedListener, ok := listener.(listenerType); ok { - typedListener.RadioSelected(name) - } - } +func (c *Controller) emitRadioChanged(name string, singleVFO bool) { + core.Emit(c.listeners, func(listener core.RadioChangedListener) { + listener.RadioChanged(name, singleVFO) + }) if c.view != nil { c.doIgnoreUpdates(func() { c.view.SetRadioSelected(name) @@ -148,11 +153,9 @@ func (c *Controller) emitKeyerSelected(name string) { type listenerType interface { KeyerSelected(string) } - for _, listener := range c.listeners { - if typedListener, ok := listener.(listenerType); ok { - typedListener.KeyerSelected(name) - } - } + core.Emit(c.listeners, func(listener listenerType) { + listener.KeyerSelected(name) + }) if c.view != nil { c.doIgnoreUpdates(func() { c.view.SetKeyerSelected(name) @@ -196,7 +199,7 @@ func (c *Controller) SelectRadio(name string) error { } if err != nil { - c.emitRadioSelected("") + c.emitRadioChanged("", true) return err } c.activeRadio = radio @@ -205,13 +208,15 @@ func (c *Controller) SelectRadio(name string) error { for _, listener := range c.listeners { c.activeRadio.Notify(listener) } - c.activeRadio.Notify(connectionChangedFunc(c.onRadioConnectionChanged)) - c.emitRadioSelected(config.Name) + c.activeRadio.Notify(core.ConnectionChangedFunc(c.onRadioConnectionChanged)) + c.emitRadioChanged(config.Name, c.activeRadio.SingleVFO()) + c.onRadioConnectionChanged(c.activeRadio.IsConnected()) if c.radioAsKeyer { c.activeKeyer = c.activeRadio c.activeKeyerName = core.RadioKeyer c.emitKeyerSelected(core.RadioKeyer) + c.onKeyerConnectionChanged(c.activeKeyer.IsConnected()) return nil } return c.SelectKeyer(config.Keyer) @@ -235,15 +240,19 @@ func (c *Controller) onRadioConnectionChanged(connected bool) { } func (c *Controller) newHamlibClient(config core.Radio) radio { - hamlibClient := hamlib.New(config.Address, c.bandplan) + vfo1, ok := config.Options[hamlibVFO1Option] + if !ok { + vfo1 = "" + } + vfo2, ok := config.Options[hamlibVFO2Option] + if !ok { + vfo2 = "" + } + hamlibClient := hamlib.New(config.Address, c.bandplan, vfo1, vfo2) hamlibClient.KeepOpen() return hamlibClient } -const ( - tciTRXOption = "trx" -) - func (c *Controller) newTCIClient(config core.Radio) (radio, error) { var err error trx := 0 @@ -254,7 +263,15 @@ func (c *Controller) newTCIClient(config core.Radio) (radio, error) { return nil, fmt.Errorf("invalid trx option: %v", err) } } - tciClient, err := tci.NewClient(config.Address, trx, c.bandplan) + singleVFO := false + singleVFOStr, ok := config.Options[tciSingleVFOOption] + if ok { + singleVFO, err = strconv.ParseBool(singleVFOStr) + if err != nil { + return nil, fmt.Errorf("invalid single_vfo option: %v", err) + } + } + tciClient, err := tci.NewClient(config.Address, trx, singleVFO, c.bandplan) if err != nil { return nil, err } @@ -269,25 +286,46 @@ func (c *Controller) Active() bool { return c.activeRadio.Active() } -func (c *Controller) SetFrequency(f core.Frequency) { +func (c *Controller) SingleVFO() bool { + if c.activeRadio == nil { + return true + } + return c.activeRadio.SingleVFO() +} + +func (c *Controller) SetCurrentVFO(vfo core.VFOID) { + if c.activeRadio == nil { + return + } + c.activeRadio.SetCurrentVFO(vfo) +} + +func (c *Controller) SetTXVFO(vfo core.VFOID) { + if c.activeRadio == nil { + return + } + c.activeRadio.SetTXVFO(vfo) +} + +func (c *Controller) SetFrequency(vfo core.VFOID, frequency core.Frequency) { if c.activeRadio == nil { return } - c.activeRadio.SetFrequency(f) + c.activeRadio.SetFrequency(vfo, frequency) } -func (c *Controller) SetBand(b core.Band) { +func (c *Controller) SetBand(vfo core.VFOID, band core.Band) { if c.activeRadio == nil { return } - c.activeRadio.SetBand(b) + c.activeRadio.SetBand(vfo, band) } -func (c *Controller) SetMode(m core.Mode) { +func (c *Controller) SetMode(vfo core.VFOID, mode core.Mode) { if c.activeRadio == nil { return } - c.activeRadio.SetMode(m) + c.activeRadio.SetMode(vfo, mode) } func (c *Controller) SetXIT(active bool, offset core.Frequency) { @@ -297,6 +335,27 @@ func (c *Controller) SetXIT(active bool, offset core.Frequency) { c.activeRadio.SetXIT(active, offset) } +func (c *Controller) MuteAudio(vfo core.VFOID) { + if c.activeRadio == nil { + return + } + c.activeRadio.MuteAudio(vfo) +} + +func (c *Controller) UnmuteAudio(vfo core.VFOID) { + if c.activeRadio == nil { + return + } + c.activeRadio.UnmuteAudio(vfo) +} + +func (c *Controller) ToggleAudio(vfo core.VFOID) { + if c.activeRadio == nil { + return + } + c.activeRadio.ToggleAudio(vfo) +} + func (c *Controller) Refresh() { if c.activeRadio == nil { return @@ -392,7 +451,7 @@ func (c *Controller) SelectKeyer(name string) error { return fmt.Errorf("unknown keyer %q", name) } - c.activeKeyer.Notify(connectionChangedFunc(c.onKeyerConnectionChanged)) + c.activeKeyer.Notify(core.ConnectionChangedFunc(c.onKeyerConnectionChanged)) c.emitKeyerSelected(name) c.emitKeyerStatusChanged(c.activeKeyer.IsConnected()) @@ -439,9 +498,3 @@ func (c *Controller) Abort() { func normalizeName(name string) string { return strings.TrimSpace(strings.ToLower(name)) } - -type connectionChangedFunc func(bool) - -func (f connectionChangedFunc) ConnectionChanged(connected bool) { - f(connected) -} diff --git a/core/remote/remote.go b/core/remote/remote.go index 0bbf795d..cb26d967 100644 --- a/core/remote/remote.go +++ b/core/remote/remote.go @@ -86,6 +86,7 @@ func (s *Server) handleDo(w http.ResponseWriter, r *http.Request) { return } if err := s.runOnMainThread(func() error { return s.dispatcher.DoAction(action) }); err != nil { + log.Printf("remote action %s failed: %v", action, err) http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/core/tci/tci.go b/core/tci/tci.go index d58e1838..c4a159cb 100644 --- a/core/tci/tci.go +++ b/core/tci/tci.go @@ -16,14 +16,15 @@ import ( const retryInterval = 10 * time.Second -func NewClient(address string, trx int, bandplan bandplan.Bandplan) (*Client, error) { +func NewClient(address string, trx int, singleVFO bool, bandplan bandplan.Bandplan) (*Client, error) { host, err := network.ParseTCPAddr(address) if err != nil { return nil, err } result := &Client{ - bandplan: bandplan, + bandplan: bandplan, + singleVFO: singleVFO, } result.trx = &trxListener{ client: result, @@ -40,6 +41,8 @@ type Client struct { client *client.Client bandplan bandplan.Bandplan + singleVFO bool + sendSpots bool lastHeardSpots map[string]time.Time @@ -68,59 +71,93 @@ func (c *Client) Active() bool { return c.connected } -func (c *Client) Notify(listener any) { - c.listeners = append(c.listeners, listener) +func (c *Client) SingleVFO() bool { + return c.singleVFO } -func (c *Client) emitConnectionChanged(connected bool) { - type listenerType interface { - ConnectionChanged(bool) - } - for _, listener := range c.listeners { - if typedListener, ok := listener.(listenerType); ok { - typedListener.ConnectionChanged(connected) - } +func (c *Client) SetCurrentVFO(vfo core.VFOID) { + // ignore: there is no sense of a "focused" VFO in TCI +} + +func (c *Client) SetTXVFO(vfo core.VFOID) { + if c.singleVFO { + return } + c.client.SetSplitEnable(c.trx.trx, vfo == core.VFO2) } -func (c *Client) emitFrequencyChanged(f core.Frequency) { - for _, listener := range c.listeners { - if frequencyListener, ok := listener.(core.VFOFrequencyListener); ok { - frequencyListener.VFOFrequencyChanged(f) - } +func (c *Client) MuteAudio(_ core.VFOID) { + // TCI can only mute audio completely + err := c.client.SetMute(true) + if err != nil { + log.Printf("tci: cannot mute: %v", err) } } -func (c *Client) emitBandChanged(b core.Band) { - for _, listener := range c.listeners { - if bandListener, ok := listener.(core.VFOBandListener); ok { - bandListener.VFOBandChanged(b) - } +func (c *Client) UnmuteAudio(_ core.VFOID) { + // TCI can only mute audio completely + err := c.client.SetMute(false) + if err != nil { + log.Printf("tci: cannot unmute: %v", err) } } -func (c *Client) emitModeChanged(m core.Mode) { - for _, listener := range c.listeners { - if modeListener, ok := listener.(core.VFOModeListener); ok { - modeListener.VFOModeChanged(m) - } +func (c *Client) ToggleAudio(_ core.VFOID) { + // TCI can only mute audio completely + muted, err := c.client.Mute() + if err != nil { + log.Printf("tci: cannot read mute state: %v", err) + } + err = c.client.SetMute(!muted) + if err != nil { + log.Printf("tci: cannot set mute state: %v", err) } } +func (c *Client) Notify(listener any) { + c.listeners = append(c.listeners, listener) +} + +func (c *Client) emitConnectionChanged(connected bool) { + core.Emit(c.listeners, func(listener core.ConnectionChangedListener) { + listener.ConnectionChanged(connected) + }) +} + +func (c *Client) emitFrequencyChanged(frequency core.Frequency) { + core.Emit(c.listeners, func(listener core.VFOFrequencyListener) { + listener.VFOFrequencyChanged(core.VFO1, frequency) + }) +} + +func (c *Client) emitBandChanged(band core.Band) { + core.Emit(c.listeners, func(listener core.VFOBandListener) { + listener.VFOBandChanged(core.VFO1, band) + }) +} + +func (c *Client) emitModeChanged(mode core.Mode) { + core.Emit(c.listeners, func(listener core.VFOModeListener) { + listener.VFOModeChanged(core.VFO1, mode) + }) +} + func (c *Client) emitXITChanged(active bool, offset core.Frequency) { - for _, listener := range c.listeners { - if xitListener, ok := listener.(core.VFOXITListener); ok { - xitListener.VFOXITChanged(active, offset) - } - } + core.Emit(c.listeners, func(listener core.VFOXITListener) { + listener.VFOXITChanged(core.VFO1, active, offset) + }) } func (c *Client) emitPTTChanged(active bool) { - for _, listener := range c.listeners { - if xitListener, ok := listener.(core.VFOPTTListener); ok { - xitListener.VFOPTTChanged(active) - } - } + core.Emit(c.listeners, func(listener core.VFOPTTListener) { + listener.VFOPTTChanged(core.VFO1, active) + }) +} + +func (c *Client) emitTXVFOChanged(vfo core.VFOID) { + core.Emit(c.listeners, func(listener core.TXVFOListener) { + listener.TXVFOChanged(vfo) + }) } func (c *Client) Speed(wpm int) { @@ -144,23 +181,23 @@ func (c *Client) Abort() { } } -func (c *Client) SetFrequency(frequency core.Frequency) { - err := c.client.SetVFOFrequency(c.trx.trx, client.VFOA, int(frequency)) +func (c *Client) SetFrequency(vfo core.VFOID, frequency core.Frequency) { + err := c.client.SetVFOFrequency(c.trx.trx, toTCIVFO(vfo), int(frequency)) if err != nil { log.Printf("cannot set VFO frequency: %v", err) } } -func (c *Client) SetBand(band core.Band) { +func (c *Client) SetBand(vfo core.VFOID, band core.Band) { bandplanBand := c.bandplan[toBandplanBandName(band)] frequency := findModePortionCenter(c.bandplan, int(bandplanBand.Center()), toBandplanMode(c.trx.mode)) - err := c.client.SetVFOFrequency(c.trx.trx, client.VFOA, frequency) + err := c.client.SetVFOFrequency(c.trx.trx, toTCIVFO(vfo), frequency) if err != nil { log.Printf("cannot switch to band %s: %v", band, err) } } -func (c *Client) SetMode(mode core.Mode) { +func (c *Client) SetMode(_ core.VFOID, mode core.Mode) { err := c.client.SetMode(c.trx.trx, toClientMode(mode)) if err != nil { log.Printf("cannot set mode: %v", err) @@ -254,6 +291,7 @@ type trxListener struct { xitActive bool xitOffset core.Frequency ptt bool + split bool } func (l *trxListener) Refresh() { @@ -262,6 +300,14 @@ func (l *trxListener) Refresh() { l.client.emitModeChanged(l.mode) l.client.emitXITChanged(l.xitActive, l.xitOffset) l.client.emitPTTChanged(l.ptt) + l.client.emitTXVFOChanged(l.txVFO()) +} + +func (l *trxListener) txVFO() core.VFOID { + if l.split && !l.client.singleVFO { + return core.VFO2 + } + return core.VFO1 } func (l *trxListener) Connected(connected bool) { @@ -343,6 +389,25 @@ func (l *trxListener) SetTX(trx int, enable bool) { // log.Printf("incoming PTT %v", enable) } +func (l *trxListener) SetSplitEnable(trx int, enabled bool) { + if trx != l.trx { + return + } + if enabled == l.split { + return + } + l.split = enabled + l.client.emitTXVFOChanged(l.txVFO()) + // log.Printf("incoming split %v", enabled) +} + +func toTCIVFO(vfo core.VFOID) client.VFO { + if vfo == core.VFO2 { + return client.VFOB + } + return client.VFOA +} + func toCoreBand(bandName bandplan.BandName) core.Band { if bandName == bandplan.BandUnknown { return core.NoBand diff --git a/core/vfo/vfo.go b/core/vfo/vfo.go index abd79646..cc674191 100644 --- a/core/vfo/vfo.go +++ b/core/vfo/vfo.go @@ -13,10 +13,15 @@ type Client interface { Notify(any) Active() bool Refresh() - SetFrequency(core.Frequency) - SetBand(core.Band) - SetMode(core.Mode) + SetCurrentVFO(core.VFOID) + SetTXVFO(core.VFOID) + SetFrequency(core.VFOID, core.Frequency) + SetBand(core.VFOID, core.Band) + SetMode(core.VFOID, core.Mode) SetXIT(bool, core.Frequency) + MuteAudio(core.VFOID) + UnmuteAudio(core.VFOID) + ToggleAudio(core.VFOID) } type Logbook interface { @@ -27,7 +32,8 @@ type Logbook interface { type VFO struct { XITControl - Name string + id core.VFOID + name string bandplan bandplan.Bandplan logbook Logbook @@ -39,9 +45,10 @@ type VFO struct { listeners []any } -func NewVFO(name string, bandplan bandplan.Bandplan, logbook Logbook, asyncRunner core.AsyncRunner) *VFO { +func NewVFO(id core.VFOID, name string, bandplan bandplan.Bandplan, logbook Logbook, asyncRunner core.AsyncRunner) *VFO { result := &VFO{ - Name: name, + id: id, + name: name, bandplan: bandplan, logbook: logbook, asyncRunner: asyncRunner, @@ -62,6 +69,10 @@ func (v *VFO) SetClient(client Client) { } } +func (v *VFO) Name() string { + return v.name +} + func (v *VFO) Notify(listener any) { v.listeners = append(v.listeners, listener) } @@ -82,7 +93,7 @@ func (v *VFO) Refresh() { func (v *VFO) SetFrequency(frequency core.Frequency) { if v.online() { - v.client.SetFrequency(frequency) + v.client.SetFrequency(v.id, frequency) } else { v.offlineClient.SetFrequency(frequency) } @@ -90,7 +101,7 @@ func (v *VFO) SetFrequency(frequency core.Frequency) { func (v *VFO) SetBand(band core.Band) { if v.online() { - v.client.SetBand(band) + v.client.SetBand(v.id, band) } else { v.offlineClient.SetBand(band) } @@ -98,7 +109,7 @@ func (v *VFO) SetBand(band core.Band) { func (v *VFO) SetMode(mode core.Mode) { if v.online() { - v.client.SetMode(mode) + v.client.SetMode(v.id, mode) } else { v.offlineClient.SetMode(mode) } @@ -112,6 +123,30 @@ func (v *VFO) SetXIT(active bool, offset core.Frequency) { } } +func (v *VFO) MuteAudio() { + if v.online() { + v.client.MuteAudio(v.id) + } else { + v.offlineClient.MuteAudio() + } +} + +func (v *VFO) UnmuteAudio() { + if v.online() { + v.client.UnmuteAudio(v.id) + } else { + v.offlineClient.UnmuteAudio() + } +} + +func (v *VFO) ToggleAudio() { + if v.online() { + v.client.ToggleAudio(v.id) + } else { + v.offlineClient.ToggleAudio() + } +} + func (v *VFO) LogbookLoaded() { log.Printf("VFO logbook changed") @@ -132,75 +167,80 @@ func (v *VFO) LogbookLoaded() { v.Refresh() } -func (v *VFO) VFOFrequencyChanged(frequency core.Frequency) { +func (v *VFO) VFOFrequencyChanged(vfo core.VFOID, frequency core.Frequency) { + if vfo != v.id { + return + } v.offlineClient.SetFrequency(frequency) } -func (v *VFO) VFOBandChanged(band core.Band) { +func (v *VFO) VFOBandChanged(vfo core.VFOID, band core.Band) { + if vfo != v.id { + return + } v.offlineClient.SetBand(band) } -func (v *VFO) VFOModeChanged(mode core.Mode) { +func (v *VFO) VFOModeChanged(vfo core.VFOID, mode core.Mode) { + if vfo != v.id { + return + } v.offlineClient.SetMode(mode) } -func (v *VFO) VFOXITChanged(active bool, offset core.Frequency) { - v.XITControl.VFOXITChanged(active, offset) +func (v *VFO) VFOXITChanged(vfo core.VFOID, active bool, offset core.Frequency) { + if vfo != v.id { + return + } + v.XITControl.VFOXITChanged(vfo, active, offset) v.offlineClient.SetXIT(active, offset) } -func (v *VFO) VFOPTTChanged(active bool) { +func (v *VFO) VFOPTTChanged(vfo core.VFOID, active bool) { + if vfo != v.id { + return + } v.offlineClient.SetPTT(active) } func (v *VFO) emitFrequencyChanged(frequency core.Frequency) { - for _, listener := range v.listeners { - if frequencyListener, ok := listener.(core.VFOFrequencyListener); ok { - v.asyncRunner(func() { - frequencyListener.VFOFrequencyChanged(frequency) - }) - } - } + core.Emit(v.listeners, func(listener core.VFOFrequencyListener) { + v.asyncRunner(func() { + listener.VFOFrequencyChanged(v.id, frequency) + }) + }) } func (v *VFO) emitBandChanged(band core.Band) { - for _, listener := range v.listeners { - if bandListener, ok := listener.(core.VFOBandListener); ok { - v.asyncRunner(func() { - bandListener.VFOBandChanged(band) - }) - } - } + core.Emit(v.listeners, func(listener core.VFOBandListener) { + v.asyncRunner(func() { + listener.VFOBandChanged(v.id, band) + }) + }) } func (v *VFO) emitModeChanged(mode core.Mode) { - for _, listener := range v.listeners { - if l, ok := listener.(core.VFOModeListener); ok { - v.asyncRunner(func() { - l.VFOModeChanged(mode) - }) - } - } + core.Emit(v.listeners, func(listener core.VFOModeListener) { + v.asyncRunner(func() { + listener.VFOModeChanged(v.id, mode) + }) + }) } func (v *VFO) emitXITChanged(active bool, offset core.Frequency) { - for _, listener := range v.listeners { - if l, ok := listener.(core.VFOXITListener); ok { - v.asyncRunner(func() { - l.VFOXITChanged(active, offset) - }) - } - } + core.Emit(v.listeners, func(listener core.VFOXITListener) { + v.asyncRunner(func() { + listener.VFOXITChanged(v.id, active, offset) + }) + }) } func (v *VFO) emitPTTChanged(active bool) { - for _, listener := range v.listeners { - if l, ok := listener.(core.VFOPTTListener); ok { - v.asyncRunner(func() { - l.VFOPTTChanged(active) - }) - } - } + core.Emit(v.listeners, func(listener core.VFOPTTListener) { + v.asyncRunner(func() { + listener.VFOPTTChanged(v.id, active) + }) + }) } type bandState struct { @@ -290,7 +330,6 @@ func (c *offlineClient) SetBand(band core.Band) { } newBand := core.Band(plan.Name) if newBand == c.currentBand && !c.vfo.refreshing { - log.Printf("Band %s already selected!", band) return } @@ -328,3 +367,9 @@ func (c *offlineClient) SetXIT(active bool, offset core.Frequency) { func (c *offlineClient) SetPTT(active bool) { c.vfo.emitPTTChanged(active) } + +func (c *offlineClient) MuteAudio() {} + +func (c *offlineClient) UnmuteAudio() {} + +func (c *offlineClient) ToggleAudio() {} diff --git a/core/vfo/xit.go b/core/vfo/xit.go index 588a8f1d..6073545b 100644 --- a/core/vfo/xit.go +++ b/core/vfo/xit.go @@ -40,7 +40,10 @@ func (x *XITControl) emitXITActiveChanged(active bool) { } } -func (x *XITControl) WorkmodeChanged(workmode core.Workmode) { +func (x *XITControl) WorkmodeChanged(vfo core.VFOID, workmode core.Workmode) { + if vfo != x.vfo.id { + return + } x.workmode = workmode if x.active { x.activateOnVFO() @@ -52,7 +55,10 @@ func (x *XITControl) activateOnVFO() { x.vfo.SetXIT(x.activeOnVFO, x.offset) } -func (x *XITControl) VFOXITChanged(active bool, offset core.Frequency) { +func (x *XITControl) VFOXITChanged(vfo core.VFOID, active bool, offset core.Frequency) { + if vfo != x.vfo.id { + return + } x.activeOnVFO = active x.offset = offset diff --git a/core/workmode/workmode.go b/core/workmode/workmode.go index f8b053f8..67032444 100644 --- a/core/workmode/workmode.go +++ b/core/workmode/workmode.go @@ -16,6 +16,8 @@ type Controller struct { workmode core.Workmode operationModeSprint bool + vfo2Enabled bool + focusedVFO core.VFOID } // View represents the visual part of the workmode handling. @@ -25,13 +27,13 @@ type View interface { } type WorkmodeChangedListener interface { - WorkmodeChanged(workmode core.Workmode) + WorkmodeChanged(vfo core.VFOID, workmode core.Workmode) } -type WorkmodeChangedListenerFunc func(workmode core.Workmode) +type WorkmodeChangedListenerFunc func(vfo core.VFOID, workmode core.Workmode) -func (f WorkmodeChangedListenerFunc) WorkmodeChanged(workmode core.Workmode) { - f(workmode) +func (f WorkmodeChangedListenerFunc) WorkmodeChanged(vfo core.VFOID, workmode core.Workmode) { + f(vfo, workmode) } func (c *Controller) SetView(view View) { @@ -88,20 +90,54 @@ func (c *Controller) SetWorkmode(workmode core.Workmode) { return } c.workmode = workmode - c.emitWorkmodeChanged(c.workmode) + if c.view != nil { + c.view.SetWorkmode(c.workmode) + } + c.emitAllWorkmodes() +} + +// RadioChanged implements core.RadioChangedListener. +func (c *Controller) RadioChanged(_ string, singleVFO bool) { + c.vfo2Enabled = !singleVFO + c.emitAllWorkmodes() +} + +// FocusChanged implements core.FocusChangedListener. +func (c *Controller) FocusChanged(vfo core.VFOID) { + if c.focusedVFO == vfo { + return + } + c.focusedVFO = vfo + c.emitAllWorkmodes() +} + +// EffectiveWorkmode returns the workmode for a given VFO. +// In SO2V (vfo2Enabled) with global=Run: VFO1=Run, VFO2=S&P. +// Otherwise: both VFOs use the global workmode. +func (c *Controller) EffectiveWorkmode(vfo core.VFOID) core.Workmode { + if c.vfo2Enabled && c.workmode == core.Run && vfo == core.VFO2 { + return core.SearchPounce + } + return c.workmode } func (c *Controller) Notify(listener any) { c.listeners = append(c.listeners, listener) } -func (c *Controller) emitWorkmodeChanged(workmode core.Workmode) { - if c.view != nil { - c.view.SetWorkmode(workmode) +// emitAllWorkmodes notifies listeners about the effective workmode for each VFO. +// In single-VFO mode, only VFO1 is emitted. In SO2V, both VFOs are emitted. +func (c *Controller) emitAllWorkmodes() { + c.emitWorkmodeChanged(core.VFO1, c.EffectiveWorkmode(core.VFO1)) + if c.vfo2Enabled { + c.emitWorkmodeChanged(core.VFO2, c.EffectiveWorkmode(core.VFO2)) } +} + +func (c *Controller) emitWorkmodeChanged(vfo core.VFOID, workmode core.Workmode) { for _, listener := range c.listeners { if workmodeChangedListener, ok := listener.(WorkmodeChangedListener); ok { - workmodeChangedListener.WorkmodeChanged(workmode) + workmodeChangedListener.WorkmodeChanged(vfo, workmode) } } } diff --git a/core/workmode/workmode_test.go b/core/workmode/workmode_test.go new file mode 100644 index 00000000..d0442c57 --- /dev/null +++ b/core/workmode/workmode_test.go @@ -0,0 +1,131 @@ +package workmode + +import ( + "testing" + + "github.com/ftl/hellocontest/core" + "github.com/stretchr/testify/assert" +) + +type workmodeEvent struct { + vfo core.VFOID + workmode core.Workmode +} + +type listenerSpy struct { + events []workmodeEvent +} + +func (l *listenerSpy) WorkmodeChanged(vfo core.VFOID, workmode core.Workmode) { + l.events = append(l.events, workmodeEvent{vfo, workmode}) +} + +func (l *listenerSpy) reset() { + l.events = nil +} + +func (l *listenerSpy) last() workmodeEvent { + if len(l.events) == 0 { + return workmodeEvent{} + } + return l.events[len(l.events)-1] +} + +func newTestController() (*Controller, *listenerSpy) { + c := NewController() + l := &listenerSpy{} + c.Notify(l) + return c, l +} + +func TestEffectiveWorkmode_SingleVFO(t *testing.T) { + c := NewController() + + c.SetWorkmode(core.Run) + assert.Equal(t, core.Run, c.EffectiveWorkmode(core.VFO1)) + assert.Equal(t, core.Run, c.EffectiveWorkmode(core.VFO2)) + + c.SetWorkmode(core.SearchPounce) + assert.Equal(t, core.SearchPounce, c.EffectiveWorkmode(core.VFO1)) + assert.Equal(t, core.SearchPounce, c.EffectiveWorkmode(core.VFO2)) +} + +func TestEffectiveWorkmode_SO2V_Run(t *testing.T) { + c := NewController() + c.RadioChanged("", false) // vfo2Enabled = true + + c.SetWorkmode(core.Run) + assert.Equal(t, core.Run, c.EffectiveWorkmode(core.VFO1)) + assert.Equal(t, core.SearchPounce, c.EffectiveWorkmode(core.VFO2), + "VFO2 must always be S&P in SO2V when global=Run") +} + +func TestEffectiveWorkmode_SO2V_SP(t *testing.T) { + c := NewController() + c.RadioChanged("", false) // vfo2Enabled = true + + c.SetWorkmode(core.SearchPounce) + assert.Equal(t, core.SearchPounce, c.EffectiveWorkmode(core.VFO1)) + assert.Equal(t, core.SearchPounce, c.EffectiveWorkmode(core.VFO2), + "both VFOs must be S&P when global=S&P") +} + +func TestSetWorkmode_SO2V_EmitsBothVFOs(t *testing.T) { + c, l := newTestController() + c.RadioChanged("", false) // SO2V + + l.reset() + c.SetWorkmode(core.Run) + + assert.Len(t, l.events, 2) + assert.Equal(t, workmodeEvent{core.VFO1, core.Run}, l.events[0]) + assert.Equal(t, workmodeEvent{core.VFO2, core.SearchPounce}, l.events[1]) +} + +func TestSetWorkmode_SingleVFO_EmitsVFO1Only(t *testing.T) { + c, l := newTestController() + // vfo2Enabled defaults to false + + l.reset() + c.SetWorkmode(core.Run) + + assert.Len(t, l.events, 1) + assert.Equal(t, workmodeEvent{core.VFO1, core.Run}, l.events[0]) +} + +func TestFocusChanged_SO2V_EmitsWorkmodes(t *testing.T) { + c, l := newTestController() + c.RadioChanged("", false) // SO2V + c.SetWorkmode(core.Run) + + l.reset() + c.FocusChanged(core.VFO2) + + assert.Len(t, l.events, 2) + assert.Equal(t, workmodeEvent{core.VFO1, core.Run}, l.events[0]) + assert.Equal(t, workmodeEvent{core.VFO2, core.SearchPounce}, l.events[1]) +} + +func TestFocusChanged_SameVFO_NoEmit(t *testing.T) { + c, l := newTestController() + c.RadioChanged("", false) + c.SetWorkmode(core.Run) + + l.reset() + c.FocusChanged(core.VFO1) // already focused + + assert.Empty(t, l.events) +} + +func TestRadioChanged_ToSingleVFO_ReEmits(t *testing.T) { + c, l := newTestController() + c.RadioChanged("", false) // SO2V + c.SetWorkmode(core.Run) + + l.reset() + c.RadioChanged("", true) // back to single VFO + + // VFO1 only, global workmode + assert.Len(t, l.events, 1) + assert.Equal(t, workmodeEvent{core.VFO1, core.Run}, l.events[0]) +} diff --git a/docs/entry_test_harness_architecture.md b/docs/entry_test_harness_architecture.md new file mode 100644 index 00000000..2f71b2c8 --- /dev/null +++ b/docs/entry_test_harness_architecture.md @@ -0,0 +1,196 @@ +# Entry Test Harness — Architecture + +## Goal + +A test harness for `core/entry` that lets authors write tests mirroring the use case descriptions in `entry_usecases.md`. Each test should read like a slightly abbreviated version of the corresponding use case: Pre conditions established by setup or action chains, the triggering action, then Post assertions. + +## Package and visibility + +The harness lives in **`package entry_test`** (black-box). Tests assert only through: +- Public methods on `*Controller` +- Spy recordings from all output channels + +Internal struct fields are never inspected. This keeps tests honest about observable contracts and lets them survive internal refactors. + +## Core abstraction: `Scenario` + +```go +type Scenario struct { ... } + +func NewScenario(t *testing.T) *Scenario +``` + +`Scenario` owns the controller and all its dependencies as spies. Every chainable method returns `*Scenario` for fluent chaining. + +### Method categories + +Methods fall into three categories. Only **action** methods reset the spy log before executing. + +| Category | Examples | Spy reset? | +|----------|----------|-----------| +| Setup | `WithClassicExchange()`, `WithLogbookQSOs(...)`, `WithBand("40m")`, `WithESMEnabled(true)` | No | +| Action | `Enter(text)`, `PressEnter()`, `Clear()`, `GotoNextField()`, `SelectQSO(qso)`, `VFOFrequencyChanged(vfo, f)` | **Yes** | +| Assertion | `AssertCallsign(vfo, text)`, `AssertQSOLogged(qso)`, `AssertActiveField(vfo, field)`, `AssertNoQSOLogged()`, `AssertDuplicateMarker(vfo, bool)`, `AssertMessage(vfo, ...)`, `AssertNoMessage(vfo)` | No | + +The spy reset on action methods means: every `Assert...()` call checks what happened *as a direct result of the immediately preceding action*. The test author never manages spy lifecycle. + +### Example: use case A1 (Enter callsign) + +```go +func TestA1_EnterCallsign(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + Enter("DL1ABC"). + AssertCallsign(core.VFO1, "DL1ABC"). + AssertSerialClaimed(). + AssertNoMessage(core.VFO1) +} +``` + +### Example: use case C1 (Log valid QSO) + +```go +func TestC1_LogValidQSO(t *testing.T) { + NewScenario(t). + WithClassicExchange(). + WithBand("40m").WithMode("CW"). + Enter("DL1ABC").GotoNextField(). + Enter("559").GotoNextField(). + Enter("042").GotoNextField(). + Enter("thx"). + PressEnter(). + AssertQSOLogged(core.QSO{Callsign: dl1abc, ...}). + AssertActiveField(core.VFO1, core.CallsignField) +} +``` + +### Example: invariant as manual assertion + +```go +// Use case C1 invariant: other VFO's input unchanged +scenario. + PressEnter(). + AssertQSOLogged(expected). + AssertCallsign(core.VFO2, "") // VFO2 callsign not touched +``` + +## Spy design + +Each output channel has a dedicated spy struct that records every call. + +### `viewSpy` + +Implements `entry.View`. Records every call as a typed entry in a log slice. Action reset clears the log. + +```go +type viewCall struct { + Method string + Args []any +} + +type viewSpy struct { + log []viewCall +} +``` + +Assertion helpers search the log: +- `AssertCalled(method, args...)` — fails if no matching entry found +- `AssertNotCalled(method)` — fails if any entry with that method found +- Higher-level helpers (e.g. `AssertDuplicateMarker(vfo, val)`) call these internally + +### `logbookSpy` + +Implements `entry.Logbook`. Records `AddQSO` and `UpdateQSO` calls. Configurable return values for `NextQSONumber`, `LastBand`, `LastMode`, `LastExchange` (set via setup methods). + +```go +type logbookSpy struct { + addedQSOs []core.QSO + updatedQSOs []core.QSO + nextNumber core.QSONumber + lastExchange []string + // ... +} +``` + +### `bandmapSpy` + +Implements `entry.Bandmap`. Records `Add` and `SelectByCallsign` calls. + +### `callinfoSpy` + +Implements `entry.Callinfo`. Records `InputChanged` calls. Also provides a way to inject a `CallinfoFrame` (for prediction tests). + +### `listenerSpy` + +Implements `core.CallsignEnteredListener` and `core.CallsignLoggedListener`. Records emissions. Registered via `controller.Notify(listenerSpy)`. + +## Setup presets + +The full preset list is intentionally small. Add a preset only when the action chain to reach the state would be long boilerplate irrelevant to the test. + +| Preset | Purpose | +|--------|---------| +| `WithClassicExchange()` | RST + serial + generic text fields, serial generated | +| `WithExchangeFields(n)` | N generic text fields | +| `WithLogbookQSOs(qsos...)` | Pre-populate logbook spy's duplicate/number state | +| `WithBand(s)` | Set initial band without going through band-field action | +| `WithMode(s)` | Set initial mode | +| `WithESMEnabled(bool)` | Toggle ESM | +| `WithVFO2Enabled()` | Enable dual-VFO mode | +| `WithWorkmode(w)` | Set Run or S&P | +| `WithCallinfoFrame(vfo, frame)` | Inject a prediction frame | + +Pre conditions that *can* be reached via short action chains should use action chains, not presets. + +## Spy reset mechanics + +`Scenario` holds a reference to every spy. Every action method calls `s.resetSpies()` as its first line before forwarding to the controller. Setup and assertion methods do not call reset. + +```go +func (s *Scenario) Enter(text string) *Scenario { + s.resetSpies() + s.controller.Enter(text) + return s +} + +func (s *Scenario) resetSpies() { + s.view.reset() + s.logbook.resetCalls() + s.bandmap.resetCalls() + s.callinfo.resetCalls() + s.listener.resetCalls() +} +``` + +## Async runner + +The controller accepts a `core.AsyncRunner`. The harness always uses synchronous inline execution (`func(f func()) { f() }`), so spy recordings are always complete before the action method returns. No goroutines, no sleeps, no races. + +## Relationship to existing tests + +Existing tests in `entry_test.go` use testify mocks and remain untouched. The harness is additive. New use-case tests live in a separate file (e.g., `usecases_test.go`). Migration of existing tests to the harness is deferred to a later pass. + +## File layout + +``` +core/entry/ + entry.go + esm.go + null.go + entry_test.go # existing tests, unchanged + esm_test.go # existing tests, unchanged + scenario_test.go # Scenario type + spies + helpers (package entry_test) + usecases_test.go # use-case tests using Scenario (package entry_test) +``` + +## Naming convention + +Test functions reference the use case ID: + +```go +func TestA1_EnterCallsign(t *testing.T) +func TestC1_LogValidQSO(t *testing.T) +func TestD2_ClearFocusedVFO(t *testing.T) +``` + +This makes it trivial to cross-reference a failing test with the corresponding use case spec. diff --git a/docs/entry_usecases.md b/docs/entry_usecases.md new file mode 100644 index 00000000..c5e401ef --- /dev/null +++ b/docs/entry_usecases.md @@ -0,0 +1,555 @@ +# Entry Controller — Use Cases + +All use cases implemented by entry `Controller` (`core/entry/entry.go`, +`core/entry/esm.go`). Each: short description, **Pre** (must hold before), +**Post** (holds after), **Invariants** (unchanged across the action). + +Conventions: +- "focused VFO" = `c.focusedVFO`; "other VFO" = the non-focused one. +- "row" = `c.input[vfo]` plus its derived view state. +- Unless stated, the other VFO's input, serial claim, ESM state and view are + invariants. + +--- + +## A. Callsign / field navigation + +### A1. Enter callsign +Type characters into the focused VFO's callsign field. +- **Pre:** active field = `CallsignField` on focused VFO. +- **Post:** `input[focused].callsign` = typed text; `CallsignEntered(focusedVFO, callsign)` listeners notified; callinfo input-changed notified; if text parses as callsign AND not editing: sticky serial claim held for focused VFO; if duplicate exists: error on callsign field, else message cleared. +- **Invariants:** other VFO's row; editing flag; selected band/mode/frequency; logbook. + +### A2. Leave callsign field +Triggered by Tab/next-field when leaving callsign. +- **Pre:** active field = `CallsignField`; callsign parses; predicted exchange present iff its length matches `theirExchangeFields`. +- **Post:** empty predictable their-exchange slots filled from prediction; duplicate marker = (duplicate exists AND (not editing OR `editQSO.Callsign != current`)). +- **Invariants:** callsign input text; serial claim; logbook. + +### A3. Goto next field (Tab) +Advance active field per transition map. +- **Pre:** any active field. +- **Post:** if leaving callsign: A2 ran; active field = next per map (callsign → first their-exchange; last their-exchange and any my-exchange → callsign; band/mode → callsign); view active field set; ESM state recomputed. +- **Invariants:** input values; per-VFO data other than active field; focused VFO. + +### A4. Goto next placeholder +- **Pre:** none. +- **Post:** active field = `CallsignField`; view selects `FilterPlaceholder` text on callsign field. +- **Invariants:** all input values; focused VFO. + +### A5. Set active field +- **Pre:** none. +- **Post:** `activeField[focused]` = given field; ESM recomputed. +- **Invariants:** view focus marker not directly updated (caller's job); input values. + +### A6. Enter text into a field +Generic text entry routed by active field. +- **Pre:** active field set; for exchange fields, `exchange[i]` slot exists. +- **Post:** corresponding input slot updated; per active field: callsign → A1 effects; band → B1 effects; mode → B2 effects; their-exchange → callinfo notified, error on that field cleared; ESM recomputed. +- **Invariants:** other VFO; editing flag. + +--- + +## B. Band / mode / frequency + +### B1. Select band (band field) +- **Pre:** input parses as band. +- **Post:** `selectedBand[focused]` = parsed band; VFO rig commanded to that band; callsign re-entered (A1 effects). +- **Invariants:** other VFO band; mode; frequency. + +### B2. Select mode (mode field) +- **Pre:** input parses as mode. +- **Post:** `selectedMode[focused]` = parsed mode; VFO rig commanded; if `generateReport`: my/their report regenerated for focused VFO; callsign re-entered. +- **Invariants:** other VFO mode; band; frequency. + +### B3. Enter frequency in kHz via callsign field +Special `EnterPressed` path: numeric callsign input. +- **Pre:** active field = `CallsignField`; input parses as integer. +- **Post:** focused VFO rig commanded with `kHz * 1000`; callsign input cleared; A1 effects with empty callsign. +- **Invariants:** band; mode; other VFO. + +### B4. Enter band via callsign field +- **Pre:** active field = `CallsignField`; input parses as band. +- **Post:** B1 effects (via `bandEntered` → `vfos[focused].SetBand`); callsign input cleared; A1 effects with empty callsign. +- **Invariants:** other VFO. + +### B5. Jump to bandmap call via callsign field +- **Pre:** active field = `CallsignField`; input starts with `@` and remainder parses as callsign. +- **Post:** bandmap `SelectByCallsign` invoked; callsign input cleared; A1 effects with empty callsign. +- **Invariants:** logbook; rig; other VFO. + +### B6. XIT active toggled by UI +- **Pre:** none. +- **Post:** VFO1 rig commanded with new XIT-active flag; view active field reapplied. +- **Invariants:** input values; VFO2 XIT. + +--- + +## C. Logging + +### C1. Log valid QSO +- **Pre:** input parses fully (callsign, band, mode, their-exchange complete and valid). +- **Post:** if editing: `UpdateQSO` with original time/workmode; else: `AddQSO` with `clock.Now()` and current workmode; serial previews refreshed on both VFOs; `CallsignLogged` listeners notified; if workmode = S&P: `WorkedSpot` added to bandmap; row cleared (D2 effects on focused VFO). +- **Invariants:** other VFO's input and claim (claim may be re-previewed but not released); contest definition. + +### C2. Log fails on invalid callsign +- **Pre:** callsign input does not parse. +- **Post:** error shown on callsign field; active field = callsign. +- **Invariants:** logbook; row not cleared; serial claim unchanged. + +### C3. Log fails on invalid band +- **Pre:** callsign parses; band input does not parse. +- **Post:** error on band field; active field = band. +- **Invariants:** logbook; row. + +### C4. Log fails on invalid mode +- **Pre:** callsign and band parse; mode does not parse. +- **Post:** error on mode field; active field = mode. +- **Invariants:** logbook; row. + +### C5. Log fails on missing their-exchange +- **Pre:** non-`EmptyAllowed` their-exchange field is empty. +- **Post:** error on that field; active field = that field. +- **Invariants:** logbook; row. + +### C6. Log fails on invalid their-report +- **Pre:** their-report field non-empty but does not parse as RST. +- **Post:** error on their-report field; active field set there. +- **Invariants:** logbook; row. + +### C7. Log fails on invalid serial-only their-number +- **Pre:** their-number field configured as pure serial (only `SerialNumberProperty`/`EmptyProperty`); value non-numeric. +- **Post:** error on their-number field; active field set there. +- **Invariants:** logbook; row. + +### C8. Log applies prediction and aborts on empty non-serial/non-report exchange +- **Pre:** non-report/non-serial their-exchange slot empty; prediction array length matches. +- **Post:** prediction copied into that slot; error "check their exchange" on that field; active field set there. +- **Invariants:** logbook; other slots in row. + +### C9. Log fails on invalid my-report +- **Pre:** my-exchange's report slot does not parse as RST. +- **Post:** error on my-report field. +- **Invariants:** logbook. + +### C10. Log fails on invalid my-number (single-property field) +- **Pre:** my-number field has exactly one property and value is non-numeric. +- **Post:** error on my-number field. +- **Invariants:** logbook. + +### C11. EnterPressed dispatch +- **Pre:** any field active. +- **Post:** if callsign field holds a kHz/band/`@call` command (B3–B5): that command run; else if ESM enabled AND not editing: `NextESMStep` (F2); else: Log (C1 and friends). +- **Invariants:** depend on selected branch. + +--- + +## D. Clearing + +### D1. Clear while editing +Exit edit modal. +- **Pre:** `editing` = true; `editSnapshot` not nil. +- **Post:** edit snapshot restored (input, claim, claim snapshot, active field, error field, callinfo frame, ESM state/message, pre-edit focused VFO via silent set); `editing` = false; editing marker cleared on VFO1; my-call/frequency/active-field views reapplied; message cleared; last QSO re-selected (with `ignoreQSOSelection` true); callinfo notified with empty input. +- **Invariants:** logbook; other VFO's pre-edit input (was untouched while editing). + +### D2. Clear focused VFO (not editing) +- **Pre:** `editing` = false. +- **Post:** serial claim released on focused VFO; focused VFO rig refreshed; active field = callsign; default exchange values seeded on focused VFO and any other VFO with no claim and empty callsign; serial previews refreshed on both VFOs; ESM recomputed; view fully resynced (my-call, frequency, active field, duplicate=false, editing=false on VFO1, message cleared); last QSO selected ignoring re-entry; callinfo notified empty. +- **Invariants:** logbook; selected band/mode/frequency; contest definition; other VFO's claimed serial when non-zero or callsign non-empty. + +### D3. Auto-clear on rig frequency jump +- **Pre:** `VFOFrequencyChanged` fires; new frequency differs from stored by > `jumpThreshold`; `ignoreFrequencyJump` = false; not (vfo=VFO1 AND editing). +- **Post:** `selectedFrequency[vfo]` updated; view frequency updated; `clearInput(vfo)` runs — releases serial claim for the event's VFO, resets that VFO's input (exchange defaults seeded), refreshes serial displays, clears callinfo/message for that VFO; if event's VFO = focused VFO: view active field reapplied; `ignoreFrequencyJump` reset to false. +- **Invariants:** focused VFO identity (does not change even if event VFO differs); other VFO's input when not the event target. + +### D4. Suppress jump-clear once +- **Pre:** `ignoreFrequencyJump` = true (set by G2). +- **Post:** D3 runs without clear; flag reset to false. +- **Invariants:** row preserved. + +--- + +## E. Edit mode + +### E1. QSO selected from list +- **Pre:** `ignoreQSOSelection` = false. +- **Post:** snapshot of VFO1 state captured; `editing` = true; `editQSO` = qso; focused VFO silently = VFO1; `claimedSerial[VFO1]` = `qso.MyNumber`; `claimSnapshot[VFO1]` = current `NextQSONumber`; row shows QSO data on VFO1; active field = callsign; editing marker on VFO1 set; callinfo notified with QSO's call/band/mode/exchange. +- **Invariants:** logbook; VFO2 state. + +### E2. QSO selection ignored +- **Pre:** `ignoreQSOSelection` = true. +- **Post:** no-op. +- **Invariants:** everything. + +### E3. Edit last QSO +- **Pre:** logbook non-empty (for the list to select). +- **Post:** active field on focused VFO = callsign; `qsoList.SelectLastQSO()` called → routes through E1. +- **Invariants:** until selection event fires: editing flag. + +### E4. Save edited QSO +- **Pre:** `editing` = true; current input parses (C1 prereqs). +- **Post:** `UpdateQSO` called with `editQSO.Time` and `editQSO.Workmode`; previews refreshed; `CallsignLogged` emitted; (no bandmap add — bandmap path only fires in non-edit S&P); row cleared via D1 (since `editing` still true at Clear time). +- **Invariants:** original time; original workmode; logbook size. + +### E5. Suppress rig sync on VFO1 during edit +- **Pre:** `editing` = true; incoming `VFOFrequencyChanged`/`VFOBandChanged`/`VFOModeChanged` for VFO1. +- **Post:** event ignored. +- **Invariants:** VFO1 row. + +--- + +## F. ESM + +### F1. Set ESM view +- **Pre:** none (nil replaced by null view). +- **Post:** `esmView` = supplied view; view's `SetESMEnabled` and `SetMessage` called with current state. +- **Invariants:** `esmEnabled`; ESM state/message. + +### F2. Toggle ESM enabled +- **Pre:** none. +- **Post:** `esmEnabled` = new value; view notified; active field reapplied on focused VFO; `ESMListener`s notified. +- **Invariants:** input values; ESM message (until next ESM update). + +### F3. NextESMStep +- **Pre:** not editing (`canTransmit` true); keyer set. +- **Post:** `SetTXVFO(focused)` called (with `ignoreVFOChange` guard); ESM state/message recomputed and message displayed; `keyer.SendText(esmMessage[focused])`. + - **Run + CallsignValid:** GotoNextField; if then on their-report: GotoNextField again (skip RST). + - **S&P + CallsignValid:** keyer text sent, **no** field advance. + - **ExchangeValid (both modes):** Log (C1 path). + - **Editing:** no-op (`canTransmit` returns false). +- **Invariants:** workmode; focused VFO (modulo Log's Clear effects). + +### F4. ESM recompute on input/field/workmode change +- **Pre:** triggered by SetActiveField, Enter, WorkmodeChanged, Clear. +- **Post:** `esmState[focused]` and `esmMessage[focused]` reflect current input and workmode; view message updated. +- **Invariants:** other VFO's ESM slot. + +### F5. EnterPressed routes around ESM while editing +- **Pre:** `editing` = true. +- **Post:** ESM step not taken; Log path used. +- **Invariants:** ESM state/message. + +--- + +## G. Bandmap / callinfo + +### G1. Mark current callsign in bandmap +- **Pre:** focused VFO callsign input parses. +- **Post:** `ManualSpot` with call/freq/band/mode/now added to bandmap. +- **Invariants:** input row; logbook. + +### G2. Bandmap entry selected +- **Pre:** any state. +- **Post:** D2 effects (Clear, not editing) — or D1 if editing; `ignoreFrequencyJump` = true; rig tuned to entry frequency; active field = callsign; callsign entered (A1) with entry call; view callsign updated. +- **Invariants:** logbook; contest. + +### G3. Select best on-frequency match +- **Pre:** best-match's callsign non-empty. +- **Post:** active field = callsign; A1 effects with that callsign; view callsign/active-field updated. +- **Invariants:** band/mode/frequency. + +### G4. Select match by index +- **Pre:** match exists at index. +- **Post:** same as G3 with that match's callsign. +- **Invariants:** same as G3. + +### G5. Refresh prediction +- **Pre:** none. +- **Post:** callinfo re-notified with current callsign/band/mode and empty exchange array; if prediction length matches: predictable empty their-exchange slots refilled. +- **Invariants:** non-predictable slots; non-empty slots. + +### G6. CallinfoFrameChanged +- **Pre:** any state. +- **Post:** `currentCallinfoFrame[vfo]` = new frame. +- **Invariants:** input; view. + +--- + +## H. VFO focus / dual-VFO + +### H1. SetFocusedVFO +User-initiated VFO focus change. +- **Pre:** target ≠ VFO2 OR `vfo2Enabled` = true; target ≠ current focused VFO. +- **Post:** `focusedVFO` = target; `ignoreVFOChange` set true around rig command; `vfoSwitcher.SetCurrentVFO(target)`; serial displays refreshed; `view.SetActiveVFO(target)`; view active field reapplied for target. +- **Invariants:** input rows; serial claims; logbook. + +### H2. CurrentVFOChanged +Rig-side VFO change detected (e.g. hamlib polling sees operator changed VFO on radio). Implements `core.CurrentVFOListener`. +- **Pre:** `ignoreVFOChange` = false; target ≠ VFO2 OR `vfo2Enabled` = true; target ≠ current focused VFO. +- **Post:** `focusedVFO` = vfo; serial displays refreshed; `view.SetActiveVFO(vfo)`; `view.SetActiveField(vfo, activeField[vfo])`. +- **Guards (no-op):** `ignoreVFOChange` true (prevents loop when `SetFocusedVFO` commands rig and rig echoes); VFO2 when disabled; same VFO. +- **Invariants:** input rows; serial claims; logbook; rig not commanded. + +### H2i. setFocusedVFOSilent +Used internally (edit mode, radio single-VFO collapse). +- **Pre:** target ≠ current. +- **Post:** `focusedVFO` = target; rig **not** commanded. +- **Invariants:** input; view active marker. + +### H3. ToggleFocusedVFO +- **Pre:** none. +- **Post:** if `vfo2Enabled` = false: focused = VFO1 (no-op if already); else: focused = other VFO via H1. +- **Invariants:** input rows. + +### H4. FocusVFO1 / FocusVFO2 +Thin wrappers over H1. +- Same Pre/Post/Invariants as H1. + +### H5. LogVFO(vfo) +- **Pre:** H1 preconditions for vfo. +- **Post:** focus moved to vfo (H1); Log executed (C1 path). +- **Invariants:** see C1. + +### H6. ClearVFO(vfo) +- **Pre:** H1 preconditions for vfo. +- **Post:** focus moved to vfo (H1); Clear executed (D1/D2). +- **Invariants:** see D1/D2. + +### H7. RadioChanged → dual VFO +- **Pre:** event arrives with `singleVFO` = false. +- **Post:** `vfo2Enabled` = true; view `SetVFOEnabled(VFO2, true)`. +- **Invariants:** input; focused VFO. + +### H8. RadioChanged → single VFO +- **Pre:** event arrives with `singleVFO` = true. +- **Post:** `vfo2Enabled` = false; if focused was VFO2: VFO2 serial claim released, `input[VFO2]` zeroed, focused silently moved to VFO1; view `SetVFOEnabled(VFO2, false)`. +- **Invariants:** VFO1 input unless focus collapse path touched it (it does not). + +--- + +## I. Serial claim + +Claims transition through three states: **unclaimed** → **claimed** (reserved) → **committed** (sent over air). Both claimed and committed serials are recyclable on release. The committed flag is used only for UI visualization (bold serial label). Collision avoidance between VFOs (`nextUnclaimed` skipping other VFO's active claim) is the only reuse prevention. + +### I1. Claim on callsign keystroke +- **Pre:** not editing; current callsign parses; `claimedSerial[focused]` = 0. +- **Post:** `claimedSerial[focused]` = `nextUnclaimedSerial(focused)`; `claimSnapshot[focused]` = current `NextQSONumber`; `committed[focused]` = false; my-number inputs refreshed on both VFOs. +- **Invariants:** other VFO's claim; logbook. + +### I2. Claim sticky +- **Pre:** `claimedSerial[focused]` ≠ 0. +- **Post:** no-op on further keystrokes (no reclaim). +- **Invariants:** claim value; previews. + +### I3. Collision avoidance +Embedded in I1's `nextUnclaimedSerial`. +- **Pre:** other VFO's claim equals current `NextQSONumber`. +- **Post:** new claim = candidate + 1. +- **Invariants:** other VFO's claim. + +### I4. Release on Clear (not editing) +- **Pre:** D2 entered. +- **Post:** `claimedSerial[focused]` = 0; `claimSnapshot[focused]` = 0; `committed[focused]` = false; both VFOs' my-number previews recomputed. Released serial is recyclable regardless of committed state. +- **Invariants:** other VFO's claim slot. + +### I5. Refresh previews after log +- **Pre:** Log path completed AddQSO/UpdateQSO. +- **Post:** `refreshMyNumberInputs` recomputes both VFOs' displayed serials based on the new `NextQSONumber` and any remaining claim. +- **Invariants:** claim slots. + +### I6. Edit mode owns the serial slot +- **Pre:** E1 fired. +- **Post:** `claimedSerial[VFO1]` = `editQSO.MyNumber`; `committed[VFO1]` = false (edit claim is temporary, not a real claim); previous committed state saved in snapshot; restored on leaveEditMode. +- **Invariants:** other VFO's claim. + +### I8. Release on frequency jump (non-focused VFO) +- **Pre:** D3 fires on a non-focused VFO (e.g. VFO2 frequency jump while VFO1 focused). +- **Post:** `claimedSerial[eventVFO]` = 0 (via `clearInput` → `claims.Release`); serial previews refreshed on both VFOs; `view.SetSerialClaim(eventVFO, 0, false)`. +- **Invariants:** focused VFO's claim; focused VFO identity. + +### I9. Release on RadioChanged VFO2 collapse +- **Pre:** H8 fires with VFO2 focused. +- **Post:** `claimedSerial[VFO2]` = 0 (via `releaseSerialClaimFor`); `input[VFO2]` zeroed; focused silently moved to VFO1. +- **Invariants:** VFO1's claim. + +### I10. SerialSent — commit serial on transmission +- **Pre:** keyer emits `SerialSent` (pattern contained `MyNumber` or `MyExchange`). +- **Post:** if no claim on focused VFO: claim created first (I1); `committed[focused]` = true. +- **Invariants:** other VFO's claim and committed state; logbook. + +### I11. Committed serial recycled on Release +- **Pre:** `committed[vfo]` = true; Release called (Clear, frequency jump, collapse). +- **Post:** serial released and recyclable. No burning — committed state is UI-only. Next `ClaimNext` can reissue the same serial if logbook and other VFO's claim allow it. +- **Invariants:** logbook; other VFO's claim. + +### I12. Edit mode does not interact with committed state +- **Pre:** operator selects QSO for editing. +- **Post:** `committed[VFO1]` set false for duration of edit; restored from snapshot on leave. Committed state of pre-edit claim preserved across edit. +- **Invariants:** highWaterMark; other VFO. + +--- + +## J. Incoming rig events + +### J1. VFOFrequencyChanged +- **Pre:** event for vfo with frequency f; not (vfo=VFO1 AND editing); f ≠ `selectedFrequency[vfo]`. +- **Post:** `selectedFrequency[vfo]` = f; view frequency updated for vfo; if `|Δ| > jumpThreshold` AND `ignoreFrequencyJump` = false: Clear runs (focused VFO) and active field reapplied; `ignoreFrequencyJump` reset to false. +- **Invariants:** band; mode; other VFO input when not affected. + +### J2. VFOBandChanged +- **Pre:** event for vfo; not (vfo=VFO1 AND editing); band ≠ `NoBand`; band ≠ `selectedBand[focused]`. +- **Post:** `selectedBand[focused]` = band; `input[vfo].band` = band string; view band updated for vfo. +- **Invariants:** mode; frequency; other input slots. + +### J3. VFOModeChanged +- **Pre:** event for vfo; not (vfo=VFO1 AND editing); mode ≠ `NoMode`; mode ≠ `selectedMode[focused]`. +- **Post:** `selectedMode[focused]` = mode; `input[vfo].mode` = mode string; view mode updated for vfo. +- **Invariants:** band; frequency. + +### J4. VFOXITChanged +- **Pre:** event for vfo. +- **Post:** view `SetXIT(vfo, active, offset)`. +- **Invariants:** input; selected band/mode/freq. + +### J5. XITActiveChanged +- **Pre:** event arrives. +- **Post:** view `SetXITActive(VFO1, active)`. +- **Invariants:** all input; VFO2 XIT view. + +### J6. VFOPTTChanged +- **Pre:** event for vfo. +- **Post:** if vfo = VFO1: `ptt` = active and TX state view refreshed; else: view `SetTXState(vfo, active, false, 0)`. +- **Invariants:** parrot state when vfo ≠ VFO1. + +--- + +## K. TX, keyer, parrot + +### K1. SendQuestion +- **Pre:** keyer set; `canTransmit` = true (not editing). +- **Post:** `SetTXVFO(focused)` called (with `ignoreVFOChange` guard); `TransmissionStarted(focused)` emitted; if active field is their-exchange: `keyer.SendQuestion("nr")`; else: `keyer.SendQuestion(callsign)`. +- **Editing:** no-op (`canTransmit` false). +- **Invariants:** input; logbook. + +### K2. RepeatLastTransmission +- **Pre:** keyer set; `canTransmit` = true. +- **Post:** `keyer.Repeat()`. VFO switcher is **not** called (stay on last TX VFO). `TransmissionStarted` is **not** emitted. +- **Editing:** no-op (`canTransmit` false). +- **Invariants:** input. + +### K3. StopTX +- **Pre:** keyer set. +- **Post:** `keyer.Stop()`. +- **Invariants:** input; editing flag. + +### K4. ParrotActive(active) +- **Pre:** none. +- **Post:** `parrotActive` = active; TX state view refreshed; if active: `clearInput(VFO1)` runs (clears VFO1 only, preserves VFO2's in-progress QSO in SO2V). +- **Invariants:** logbook; VFO2 input. + +### K5. ParrotTimeLeft(d) +- **Pre:** none. +- **Post:** `parrotTimeLeft` = d; TX state view refreshed. +- **Invariants:** input; parrotActive. + +### K6. TransmissionStarted(vfo) +- **Pre:** emitted from `SendQuestion` and `NextESMStep` before keyer TX call. +- **Post:** `TransmissionStartedListener`s notified with focused VFO. Parrot receives: ignores VFO1 (self-CQ or already stopped); interrupts on VFO2 (`keyer.Stop()`). +- **Invariants:** input; logbook. + +### K7. Parrot CQ transmission +- **Pre:** Parrot active; tick interval elapsed. +- **Post:** `Entry.SetTXVFO(VFO1)` called (with `ignoreVFOChange` guard to prevent focus switch); `keyer.SendWithWorkmode(Run, CQMessageIndex)` called — always uses Run macros regardless of focused VFO. +- **Invariants:** focused VFO; VFO2 input; keyer workmode (not changed, Run used explicitly for this send only). + +--- + +## L. Settings / contest / workmode + +### L1. StationChanged +- **Pre:** none. +- **Post:** `stationCallsign` = new value; view `SetMyCall`. +- **Invariants:** input; contest. + +### L2. ContestChanged +- **Pre:** contest definition supplied. +- **Post:** exchange field definitions replaced; per-VFO `myExchange`/`theirExchange` slices reallocated to new length; Clear runs (D2 since editing not changed; if editing, D1 — but contest changes are not expected mid-edit). +- **Invariants:** station callsign; selected band/mode/freq. + +### L3. WorkmodeChanged(vfo, workmode) +- **Pre:** emitted by `workmode.Controller` per VFO. +- **Post:** `vfoWorkmode[vfo]` stored; `view.SetVFOWorkmode(vfo, workmode)` called; if `vfo == focusedVFO`: `c.workmode` updated, ESM recomputed. +- **Invariants:** input; contest; other VFO's workmode. + +### L3b. SO2V workmode semantics +In SO2V (`vfo2Enabled == true`), workmode is per-VFO: +- Global = **Run**: VFO1 = Run, VFO2 = S&P. +- Global = **S&P**: both VFOs = S&P. +- VFO2 is always S&P regardless of global toggle. + +`workmode.Controller` computes effective workmode via `EffectiveWorkmode(vfo)` and emits `WorkmodeChanged(vfo, workmode)` for each VFO on: global toggle change, focus change, radio change. + +VFO labels show effective workmode ("VFO 1 RUN" / "VFO 2 S&P"). Keyer tracks per-VFO workmode and switches macros on focus change — order-independent with `FocusChanged`. + +### L4. LogbookLoaded +- **Pre:** logbook loaded. +- **Post:** every VFO's `selectedBand`/`selectedMode` = logbook's last band/mode; Clear runs; input pushed to view. +- **Invariants:** station callsign; contest; focused VFO id. + +--- + +## M. Wiring / refresh / listeners + +### M1. SetView +- **Pre:** view ≠ nil; `c.view` is still the initial nullView. +- **Post:** `c.view` = view; `SetVFOEnabled(VFO2, vfo2Enabled)`; Clear; UTC refreshed. +- **Invariants:** input; contest. + +### M2. SetKeyer / SetCallinfo +- **Pre:** none. +- **Post:** corresponding field replaced with supplied instance. +- **Invariants:** everything else. + +### M3. SetVFO(id, vfo) +- **Pre:** none. +- **Post:** `vfos[id]` = supplied (or null if nil); `vfo.Notify(c)` called (panics if nil supplied because the call happens before the nil-check guard — known caller responsibility). +- **Invariants:** other VFO slot. + +### M4. SetVFOSwitcher +- **Pre:** none. +- **Post:** `vfoSwitcher` = supplied (null if nil). +- **Invariants:** focused VFO; rig. + +### M5. StartAutoRefresh +- **Pre:** none. +- **Post:** UTC ticker started; periodic `SetUTC` updates begin. +- **Invariants:** input; view fields other than UTC. + +### M6. RefreshView +- **Pre:** none. +- **Post:** current input pushed to view (`showInput`). +- **Invariants:** model state. + +### M7. Activate +- **Pre:** none. +- **Post:** view active field reapplied for focused VFO. +- **Invariants:** model state. + +### M8. Notify(listener) +- **Pre:** none. +- **Post:** listener appended to `listeners`; will receive matching callbacks (`CallsignEntered`, `CallsignLogged`, `ESMEnabled`). +- **Invariants:** existing listeners. + +--- + +## N. Queries + +### N1. CurrentQSOState +- **Pre:** none. +- **Post:** returns `(NoCallsign, QSODataEmpty)` if callsign empty or unparseable; `(call, QSODataInvalid)` if call OK and their-exchange parse fails; `(call, QSODataValid)` if both OK. +- **Invariants:** no side effects on input, view, ESM, claims, logbook. + +### N2. CurrentValues +- **Pre:** none. +- **Post:** returns `KeyerValues` snapshot from focused VFO input (my report, my number, my-exchange full, my-exchange minus report/number, their callsign). +- **Invariants:** same as N1. + +### N3. CurrentVFOState +- **Pre:** none. +- **Post:** returns `(frequency, band, mode)` of the focused VFO — the same radio state `Log` stamps on a QSO. Consumed by the QTC controller so QTC records match the VFO they were exchanged on in SO2V. +- **Invariants:** same as N1. + +### N4. FocusedVFO +- **Pre:** none. +- **Post:** returns `(name, so2v)` of the focused VFO — `name` from `vfos[focusedVFO].Name()` ("VFO 1"/"VFO 2"), `so2v` = `vfo2Enabled`. The QTC controller reads it at dialog-open to label the QTC header with the focused VFO name in SO2V. +- **Invariants:** same as N1. diff --git a/docs/vfo_architecture.md b/docs/vfo_architecture.md new file mode 100644 index 00000000..1f390443 --- /dev/null +++ b/docs/vfo_architecture.md @@ -0,0 +1,524 @@ +# VFO Architecture — Current State (Dual-VFO Work in Progress) + +## Context + +Reference document describing how `app`, `entry`, `vfo`, `radio`, `callinfo`, and `ui` collaborate inside hellocontest. The single-VFO baseline lives in git history; this version captures the state after implementing the core dual-VFO infrastructure, TX VFO switching, audio control, and rig-side VFO change detection, so that the remaining work can be planned against an accurate baseline. + +Last revised: 2026-06-14. + +--- + +## 1. Layered roles + +| Package | Role | +|---------|------| +| `core/app` | Composition root. Constructs `core.VFOCount` (=2) VFOs in a loop; wires `RadioChangedListener`, `VFOSwitcher`, and all `Notify` chains. Provides `MuteAudio`/`ToggleAudio` convenience methods that delegate to VFO objects. | +| `core/entry` | Owns per-VFO state slices indexed by `VFOID`. A `focusedVFO` cursor routes all user-driven commands. Both VFOs hold independent pending QSOs simultaneously (aka. SO2V). | +| `core/vfo` | One `VFO` struct per physical VFO. Emits events tagged with `id`; filters inbound events that don't match its `id`. Exposes audio control (`MuteAudio`, `UnmuteAudio`, `ToggleAudio`). | +| `core/radio` | Single backend, single `activeRadio`. The `vfo.Client` interface and all backends are VFO-ID-aware. Emits `RadioChanged(name, singleVFO)` to notify downstream of backend capability. Implements `VFOSwitcher` (`SetCurrentVFO`, `SetTXVFO`) and audio control. | +| `core/workmode` | Owns global workmode toggle and computes per-VFO effective workmode in SO2V. Receives `RadioChanged` and `FocusChanged`; emits `WorkmodeChanged(vfo, workmode)` for all VFOs. | +| `core/callinfo` | Threaded with `VFOID`; maintains per-VFO `frames` and emits `CallinfoFrameChanged(vfo, frame)`. | +| `ui` | `entryView` uses an `entryVFOWidgets` struct per VFO (indexed array `vfo [VFOCount]entryVFOWidgets`). VFO2 visibility driven by `SetVFOEnabled`. Active-VFO label styling driven by `SetActiveVFO`. VFO labels show effective workmode ("VFO 1 RUN"). | + +--- + +## 2. Core types (`core/core.go:1680–1726`) + +```go +type VFOID int +const ( VFO1 VFOID = iota; VFO2; VFOCount ) + +type VFO interface { + XITControl + Name() string + Notify(any) + Refresh() + SetFrequency(Frequency) + SetBand(Band) + SetMode(Mode) + SetXIT(bool, Frequency) +} + +type RadioChangedListener interface { + RadioChanged(name string, singleVFO bool) +} + +type CurrentVFOListener interface { CurrentVFOChanged(VFOID) } +type FocusChangedListener interface { FocusChanged(VFOID) } +type CallsignEnteredListener interface { CallsignEntered(VFOID, string) } +type TransmissionStartedListener interface { TransmissionStarted(VFOID) } + +// All five VFO event interfaces carry a leading VFOID: +type VFOFrequencyListener interface { VFOFrequencyChanged(VFOID, Frequency) } +type VFOBandListener interface { VFOBandChanged(VFOID, Band) } +type VFOModeListener interface { VFOModeChanged(VFOID, Mode) } +type VFOXITListener interface { VFOXITChanged(VFOID, bool, Frequency) } +type VFOPTTListener interface { VFOPTTChanged(VFOID, bool) } +``` + +Note: `core.VFO` is the per-instance interface (setters take plain values, not `VFOID`). Each `vfo.VFO` struct tags its events with its own `id` on emission. `RadioChangedListener` carries the radio name and a `singleVFO` flag that drives VFO2 visibility. + +--- + +## 3. `core/vfo` — VFO struct and Client interface + +**VFO struct** (`vfo.go:32–46`): + +```go +type VFO struct { + XITControl + id core.VFOID + name string + bandplan bandplan.Bandplan + logbook Logbook + client Client + offlineClient *offlineClient + refreshing bool + asyncRunner core.AsyncRunner + listeners []any +} +``` + +Constructor: `NewVFO(id core.VFOID, name string, bandplan, logbook, asyncRunner)`. + +**`vfo.Client` interface** (`vfo.go:12–25`) — per-VFO setters take `VFOID`: + +```go +type Client interface { + Notify(any) + Active() bool + Refresh() + SetCurrentVFO(core.VFOID) + SetTXVFO(core.VFOID) + SetFrequency(core.VFOID, core.Frequency) + SetBand(core.VFOID, core.Band) + SetMode(core.VFOID, core.Mode) + SetXIT(bool, core.Frequency) + MuteAudio(core.VFOID) + UnmuteAudio(core.VFOID) + ToggleAudio(core.VFOID) +} +``` + +**Audio control methods** on `VFO` (`vfo.go:126–148`): `MuteAudio()`, `UnmuteAudio()`, `ToggleAudio()` — delegate to `client.{Mute,Unmute,Toggle}Audio(v.id)` when online, else to `offlineClient` (no-ops). + +Inbound filter (`vfo.go:170–204`): each `VFO*Changed` callback starts with `if vfo != v.id { return }`. + +Emit semantics (`vfo.go:206–244`): each `emit*Changed` method wraps listeners via `asyncRunner` before calling the listener method, ensuring callers land on the UI thread even when the backend emits from a goroutine. + +--- + +## 4. `core/radio` — backend bridge + +`Controller` struct (`radio.go:31–46`) holds one `activeRadio radio` plus `listeners []any`. The internal `radio` interface (`radio.go:48–64`) requires `SingleVFO() bool`, `SetCurrentVFO(core.VFOID)`, `SetTXVFO(core.VFOID)`, `MuteAudio(core.VFOID)`, `UnmuteAudio(core.VFOID)`, `ToggleAudio(core.VFOID)`. + +**`emitRadioChanged`** (`radio.go:141–150`): called from `SelectRadio` (success and error paths) and `Stop`. Fires `RadioChangedListener.RadioChanged(name, singleVFO)` to all `c.listeners`, then updates the view via `c.view.SetRadioSelected(name)`. + +**`SelectRadio`** (`radio.go:172–223`): after connecting, forwards `c.listeners` wholesale to `activeRadio.Notify` (`radio.go:208–210`), then registers a `ConnectionChangedFunc` for radio status tracking, giving VFO objects and other listeners direct access to hamlib/TCI events. + +**Entry subscribed via `c.Radio.Notify(c.Entry)`** (app.go:215). Because the listener list is forwarded to the hamlib client, Entry's VFO event handlers can be invoked from the hamlib polling goroutine; all such handlers are wrapped in `c.asyncRunner` (see §5). + +**`radio.Controller` implements `VFOSwitcher`** (`radio.go:296–307`): + +```go +func (c *Controller) SetCurrentVFO(vfo core.VFOID) { ... c.activeRadio.SetCurrentVFO(vfo) } +func (c *Controller) SetTXVFO(vfo core.VFOID) { ... c.activeRadio.SetTXVFO(vfo) } +``` + +**Audio control** (`radio.go:338–356`): `MuteAudio`, `UnmuteAudio`, `ToggleAudio` delegate to `c.activeRadio.*Audio(vfo)`. + +**Backend capability:** + +| Backend | `SingleVFO()` | `SetCurrentVFO()` | `SetTXVFO()` | Dual-VFO polling | Dual-VFO setting | Audio control | +|---------|--------------|------------------|-------------|-----------------|-------------------|---------------| +| hamlib | returns `c.singleVFO` (set when `vfo1` option absent — `sanitizeVFOs` falls back to `CurrVFO`) | Implemented — calls `c.client.SetVFO(c.vfos[vfo])`, no-op when `singleVFO` | Implemented — calls `c.client.SetSplitVFO(hlVFO, enableSplit, CurrVFO)`, no-op when `singleVFO` | `pollDualVFO` / `pollSingleVFO` fallback | `SetFrequency/Band/Mode(vfo, …)` ✓ | `MuteAudio`/`UnmuteAudio`/`ToggleAudio` per VFO ✓ | +| TCI | reads `single_vfo` config key (default false) | Intentional no-op — TCI has no concept of "focused" VFO | Implemented — calls `c.client.SetSplitEnable(trx, vfo==VFO2)`, no-op when `singleVFO` | per-TRX routing | `toTCIVFO()` mapping ✓ | `MuteAudio`/`UnmuteAudio` mute globally; `ToggleAudio` toggles mute | + +**`SetXIT`** (`hamlib.go:382+`): still hardcodes `core.VFO1` internally. `// TODO: add the VFOID to all VFO-related Setters`. + +**Hamlib VFO change detection** (`hamlib.go:128–130`): the polling loop tracks `lastVFO` vs `currentVFO` and calls `emitCurrentVFOChanged` when they differ. This emits `CurrentVFOListener.CurrentVFOChanged(vfoID)` to all listeners, which Entry receives and processes via `CurrentVFOChanged` (see §5.2). + +--- + +## 5. `core/entry` — per-VFO state and routing + +### 5.1 Controller struct fields (`entry.go:155–212`) + +Per-VFO slices (length `core.VFOCount`, index = `VFOID`): + +| Field | Type | +|-------|------| +| `vfos` | `[]core.VFO` | +| `input` | `[]input` | +| `selectedFrequency` | `[]core.Frequency` | +| `selectedBand` | `[]core.Band` | +| `selectedMode` | `[]core.Mode` | +| `activeField` | `[]core.EntryField` | +| `errorField` | `[]core.EntryField` | +| `currentCallinfoFrame` | `[]core.CallinfoFrame` | +| `esmState` | `[]core.ESMState` | +| `esmMessage` | `[]string` | +| `esmMacroIndex` | `[]int` | +| `vfoWorkmode` | `[]core.Workmode` | + +Serial claims are managed by the `claims SerialClaims` struct (see §5.3). + +Shared/single fields: `focusedVFO core.VFOID`, `vfo2Enabled bool`, `editing bool`, `editQSO core.QSO`, `editSnapshot *editSnapshot`, `ptt bool`, `parrotActive bool`, `parrotTimeLeft`, `esmEnabled bool`, `ignoreVFOChange bool`. + +All per-VFO slices initialised in `NewController` (`entry.go:94–127`). `activeField` elements seeded to `core.CallsignField` so that a VFO focus switch always lands on a valid field. + +**`VFOSwitcher` interface** (`entry.go:130–133`): + +```go +type VFOSwitcher interface { + SetCurrentVFO(core.VFOID) + SetTXVFO(core.VFOID) +} +``` + +### 5.2 Focus model (`entry.go:467–545`) + +**`CurrentVFOChanged(vfo VFOID)`** (`entry.go:467–481`) — implements `core.CurrentVFOListener`. Receives rig-side VFO changes (e.g. hamlib detects operator changed VFO on the rig). Guards: no-op if `ignoreVFOChange` (prevents loops when `SetFocusedVFO` itself commands the rig), no-op if VFO2 disabled, no-op if already focused. Updates `focusedVFO`, refreshes serial displays, and updates view. + +**`SetFocusedVFO(vfo VFOID)`** (`entry.go:483–499`) is the single funnel for user-initiated VFO changes: +1. Guards: no-op if `vfo == VFO2 && !c.vfo2Enabled`; no-op if already focused. +2. Updates `c.focusedVFO`. +3. Sets `c.ignoreVFOChange = true` (prevents loop from rig echo). +4. Calls `c.vfoSwitcher.SetCurrentVFO(vfo)` — commands rig to switch. +5. Sets `c.ignoreVFOChange = false`. +6. Calls `c.refreshMyNumberInputs()` — syncs serial displays. +7. Calls `c.view.SetActiveVFO(c.focusedVFO)` — updates VFO label styling. +8. Calls `c.view.SetActiveField(c.focusedVFO, c.activeField[c.focusedVFO])` — moves UI cursor. + +`setFocusedVFOSilent` skips steps 3–9; used by edit-mode enter/leave to avoid rig command and unnecessary view churn. + +Public focus actions (wired to F8/F9/F10 in `ui/actions.go` and remote server): + +| Method | Hotkey | Behaviour | +|--------|--------|-----------| +| `ToggleFocusedVFO()` | F8 | Flips VFO1↔VFO2; no-op if VFO2 disabled | +| `FocusVFO1()` | F9 | `SetFocusedVFO(VFO1)` | +| `FocusVFO2()` | F10 | `SetFocusedVFO(VFO2)` | + +Log/Clear helpers: + +```go +func (c *Controller) LogVFO(vfo VFOID) { c.SetFocusedVFO(vfo); c.Log() } +func (c *Controller) ClearVFO(vfo VFOID) { c.SetFocusedVFO(vfo); c.Clear() } +``` + +`LogVFO`/`ClearVFO` have keybinding slots in `cfg.go` (`entry.log_vfo1`, `entry.log_vfo2`, `entry.clear_vfo1`, `entry.clear_vfo2`) but no default hotkeys assigned. + +### 5.3 Serial claim model (`core/entry/serial_claims.go`) + +Serial claim logic is encapsulated in the `SerialClaims` struct: + +```go +type SerialClaims struct { + claimed []core.QSONumber // per-VFO reserved serial; 0 = unclaimed + snapshot []core.QSONumber // logbook.NextQSONumber() at claim time + committed []bool // per-VFO: serial was sent over air (UI-only) +} +``` + +A serial number transitions through three states: + +- **Unclaimed / preview**: next available serial, skipping the other VFO's claimed serial. +- **Claimed**: reserved for a VFO's pending QSO. Recyclable on release. +- **Committed**: serial was sent over the air (keyer emitted `SerialSent`). UI shows bold label. Still recyclable on release — committed state is visual-only. +- **Taken**: logged into the logbook. Permanently unavailable. + +Key methods on `SerialClaims`: + +```go +nextUnclaimed(forVFO, base) // walks from base, skips other VFO's claim +DisplayedSerial(vfo, base) // claimed || nextUnclaimed preview +AllDisplayed(base) // displayed serial for every VFO +ClaimNext(vfo, base) // reserve next unclaimed serial; no-op if already claimed +Commit(vfo) // mark claim as committed (sent over air) +Release(vfo) // free claim; serial recyclable regardless of committed state +``` + +Entry controller wrappers: + +```go +claimSerialFor(vfo) // delegates to claims.ClaimNext, then refreshMyNumberInputs +releaseSerialClaimFor(vfo) // delegates to claims.Release, then refreshMyNumberInputs +SerialSent() // claims if unclaimed, then commits focused VFO's serial +refreshMyNumberInputs() // reads NextQSONumber once, writes focused VFO's myNumber, + // then pushes view.SetSerialClaim for every VFO +refreshMyNumberInput(vfo) // single-VFO variant +writeMyNumberInput(serial) // writes serial into myNumber and exchange slot +``` + +Entry implements `keyer.SerialSentListener`. Wired via `c.Keyer.Notify(c.Entry)` in app.go. + +### 5.4 Edit-mode modal (`entry.go:606–653`) + +`enterEditMode(qso core.QSO)`: +1. Snapshots VFO1 state into `editSnapshot` (focusedVFO, full `input[VFO1]`, `myReport`, `myNumber`, `myExchange`, `claims.claimed[VFO1]`, `claims.snapshot[VFO1]`, `claims.committed[VFO1]`, activeField, errorField, callinfoFrame, esmState[], esmMessage[], esmMacroIndex[]). +2. `setFocusedVFOSilent(VFO1)` — no rig command, no view cursor move. +3. Sets `editing = true`, `editQSO = qso`. +4. Claims `qso.MyNumber` for VFO1 for the duration (writes directly to `claims.claimed[VFO1]` and `claims.snapshot[VFO1]`). Sets `claims.committed[VFO1] = false` — edit claim is temporary, not a real claim. + +`leaveEditMode()` (called by `Clear()` when `editing == true`): +1. Restores all snapshotted fields for VFO1 (including `myReport`, `myNumber`, `myExchange`, `claims.committed[VFO1]`). +2. Clears `editing`, `editQSO`, `editSnapshot`. +3. `setFocusedVFOSilent(snap.focusedVFO)` — returns focus to where it was. +4. **TODO**: call `c.view.SetVFOEnabled(core.VFO2, false/true)` around edit mode (VFO2 disable during edit not yet wired, see §11). + +`canTransmit() bool` (`entry.go:560`): returns `!c.editing`. All keyer entry points (`SendQuestion`, `RepeatLastTransmission`, `NextESMStep`, ESM auto-send in `EnterPressed`) guard on this. + +### 5.5 VFO event handlers (`entry.go:718–869`) + +All five handlers are fully wrapped in `c.asyncRunner` so that the entire body (state mutation + view call) runs on the UI thread, regardless of whether the caller is a hamlib goroutine or a vfo-marshalled call: + +```go +func (c *Controller) VFOFrequencyChanged(vfo VFOID, frequency Frequency) { + c.asyncRunner(func() { + // ... guard edits, compute jump, update selectedFrequency[vfo], call view + }) +} +``` + +Same pattern for `VFOBandChanged`, `VFOModeChanged`, `VFOXITChanged`, `VFOPTTChanged`. + +`VFOFrequencyChanged`: detects jumps (`> jumpThreshold`); on jump calls `clearInput(vfo)` — a per-VFO reset that releases the serial claim, refreshes the VFO, resets input fields and exchange defaults without changing the focused VFO. + +`VFOBandChanged` / `VFOModeChanged`: compare, update `selectedBand/selectedMode`, and write `input[vfo]` all using the event's `vfo` parameter. + +`VFOPTTChanged`: VFO1 PTT drives `c.ptt` + `updateTXState()`; other VFOs update their TX indicator directly. + +**`clearInput(vfo)`** (`entry.go:739–759`): resets input for a specific VFO without changing `focusedVFO`. Releases serial claim, refreshes the VFO, fills exchange defaults, refreshes serial displays, and only pushes `SetActiveField` if the cleared VFO is the focused one (to avoid stealing UI focus). + +### 5.6 ESM (`entry/esm.go`) + +Fully per-VFO: `esmState[focusedVFO]`, `esmMessage[focusedVFO]`, `esmMacroIndex[focusedVFO]`. `NextESMStep` guards via `canTransmit()`. `updateESM` reads and writes the focused VFO's slots. + +`updateSPMessage` / `updateRunMessage` return `(string, int)` — display text and macro index. Macro index is the keyer pattern slot (0–3); sentinel `-1` for non-macro states (callsign request `?`, `nr?`). + +`NextESMStep`: emits `TransmissionStarted(focusedVFO)` before keyer TX. If `esmMacroIndex >= 0`, calls `keyer.Send(index)` (goes through template expansion, triggers `SerialSent` if pattern references serial); otherwise calls `keyer.SendText(literal)`. ESM uses `c.workmode` which tracks the focused VFO's effective workmode — in SO2V, VFO2 always uses S&P message mapping. + +### 5.7 `Log()` (`entry.go:1002–1095`) + +Routes from `c.focusedVFO` for callsign, frequency, band, mode, exchange input. No longer locked to VFO1. On success, calls `refreshMyNumberInputs()` so the other VFO's serial preview updates immediately. + +### 5.8 `Clear()` (`entry.go:1208–1262`) + +Two paths: +- **Edit exit** (`c.editing == true`): calls `leaveEditMode()`, then redraws VFO1 state from snapshot. +- **Normal clear**: calls `c.claims.Release(c.focusedVFO)`, refreshes VFO, resets `input[focusedVFO]`, calls `fillExchangeDefaults(focusedVFO, lastExchange)`. Also fills idle non-focused VFOs (those with no callsign and no claim — checked via `c.claims.claimed[v] != 0`) so default reports stay current after mode/band changes. Finally calls `refreshMyNumberInputs()` to sync serial displays for both VFOs. + +--- + +## 6. `core/callinfo` — VFOID threading + +`Callinfo.InputChanged(vfo core.VFOID, call string, band, mode, exchange)` updates `frames[vfo]` and emits `CallinfoFrameChanged(vfo, frames[vfo])`. + +`Entry.CallinfoFrameChanged(vfo, frame)` stores into `currentCallinfoFrame[vfo]`. + +`EntryOnFrequency` (bandmap spot overlay) is VFO1-only by design — bandmap is tied to VFO1. + +--- + +## 7. App wiring (`core/app/app.go:188–280`) + +Construction and notification order: + +``` +Entry := entry.NewController(...) +Radio := radio.NewController(...) + +Radio.Notify(ServiceStatus) +Radio.Notify(Entry) // Entry receives RadioChanged + is forwarded to hamlib listeners +Radio.Notify(Callinfo) // Callinfo receives RadioChanged +Radio.Notify(Workmode) // Workmode receives RadioChanged → tracks vfo2Enabled +Entry.SetVFOSwitcher(Radio) // Entry can command rig VFO switch + TX VFO + +Workmode = workmode.NewController() +Workmode.Notify(Entry) // Entry receives WorkmodeChanged(vfo, workmode) +Entry.Notify(Workmode) // Workmode receives FocusChanged from Entry + +for vfoID in 0..VFOCount: + v := vfo.NewVFO(vfoID, ...) + v.SetClient(Radio) + Entry.SetVFO(vfoID, v) + Logbook.Notify(v) + +Bandmap.SetVFO(VFOs[VFO1]) // VFO1-only +Workmode.Notify(VFOs[VFO1]) // VFO1-only + +Radio.SelectRadio(session.Radio1()) +Radio.SelectKeyer(session.Keyer1()) + +Keyer := keyer.New(...) +Keyer.Notify(Entry) // Entry receives SerialSent from keyer +Entry.Notify(Keyer) // Keyer receives FocusChanged from Entry +Workmode.Notify(Keyer) // Keyer receives WorkmodeChanged(vfo, workmode) +Entry.SetKeyer(Keyer) // Keyer stores per-VFO workmode, switches macros on focus change + +Callinfo.Notify(Entry) // CallinfoFrameChanged flows to entry +Entry.SetCallinfo(Callinfo) + +// QTCController is not a VFO listener; it reads focused-VFO state on demand +// via entryController.CurrentVFOState() (stamps QTC records) and +// entryController.FocusedVFO() (labels the QTC header with the VFO name in +// SO2V). Both SO2V-correct. + +Parrot = parrot.New(Workmode, Keyer, Entry, asyncRunner) + // Entry as VFOSwitcher: SetTXVFO with ignoreVFOChange guard + // Keyer: SendWithWorkmode(Run, CQMessageIndex) — always Run macros +Entry.Notify(Parrot) // Parrot receives CallsignEntered(vfo), TransmissionStarted(vfo) +Parrot.Notify(Entry) +Workmode.Notify(Parrot) // Parrot receives WorkmodeChanged(vfo, workmode) +``` + +**`DoAction` VFO cases** (`app.go:976–989, 1024–1035`): + +```go +case ActionEntryToggleFocusedVFO: c.Entry.ToggleFocusedVFO() +case ActionEntryFocusVFO1: c.Entry.FocusVFO1() +case ActionEntryFocusVFO2: c.Entry.FocusVFO2() +case ActionEntryLogVFO1: c.Entry.LogVFO(core.VFO1) +case ActionEntryLogVFO2: c.Entry.LogVFO(core.VFO2) +case ActionEntryClearVFO1: c.Entry.ClearVFO(core.VFO1) +case ActionEntryClearVFO2: c.Entry.ClearVFO(core.VFO2) + +case ActionRadioMuteAudioVFO1: c.MuteAudio(core.VFO1) +case ActionRadioMuteAudioVFO2: c.MuteAudio(core.VFO2) +case ActionRadioUnmuteAudioVFO1: c.UnmuteAudio(core.VFO1) +case ActionRadioUnmuteAudioVFO2: c.UnmuteAudio(core.VFO2) +case ActionRadioToggleAudioVFO1: c.ToggleAudio(core.VFO1) +case ActionRadioToggleAudioVFO2: c.ToggleAudio(core.VFO2) +``` + +All actions also reachable via remote server (`POST /do?action=`). + +**App-level audio helpers** (`app.go:869–878`): `MuteAudio(vfo)` and `ToggleAudio(vfo)` delegate to `c.VFOs[vfo].MuteAudio()` / `c.VFOs[vfo].ToggleAudio()`. + +--- + +## 8. UI surface + +### `ui/entryView.go` + +**Per-VFO widget struct** (`entryView.go:38–57`): + +```go +type entryVFOWidgets struct { + topSeparator *qtlib.QFrame + vfoContainer *qtlib.QWidget + vfoLabel *qtlib.QLabel + frequencyLabel *qtlib.QLabel + band *qtlib.QComboBox + mode *qtlib.QComboBox + serialClaimLabel *qtlib.QLabel + xit *qtlib.QCheckBox + txIndicator *qtlib.QLabel + callsign *qtlib.QLineEdit + theirExchangeFields []*qtlib.QLineEdit + logButton *qtlib.QPushButton + clearButton *qtlib.QPushButton + messageLabel *qtlib.QLabel +} +``` + +`entryView` stores `vfo [core.VFOCount]entryVFOWidgets` plus a `vfo2Enabled bool` flag for visibility bookkeeping. + +**`SetVFOEnabled(vfo, enabled)`**: VFO1 is always enabled (no-op). For VFO2, shows/hides the entire widget cluster. Records `v.vfo2Enabled = enabled`. + +**`setExchangeFields`**: when building new VFO2 exchange fields, checks `v.vfo2Enabled` and hides/disables newly created fields if the flag is false. This prevents a startup race where `SetVFOEnabled(VFO2, false)` fires before the exchange fields exist. + +**`SetActiveVFO(vfo)`** (`entryView.go:485`): toggles VFO label styling between `VFOActiveStyle` and `VFOInactiveStyle` to visually indicate which VFO has focus. + +**`SetSerialClaim(vfo, serial)`** (`entryView.go:300`): updates the serial claim label for the given VFO. + +**`SelectText(vfo, field, s)`** (`entryView.go:532`): selects a substring in the given VFO's exchange field. + +All per-row view methods take a `VFOID` and dispatch to the correct widget set: `SetCallsign`, `SetTheirExchange`, `SetActiveField`, `SetDuplicateMarker`, `SetEditingMarker`, `ShowMessage`, `ClearMessage`, `SetFrequency`, `SetBand`, `SetMode`, `SetXIT`, `SetXITActive`, `SetTXState`. + +Hotkey actions (`ui/actions.go`): + +``` +F8 → toggleVFOAction → Entry.ToggleFocusedVFO() +F9 → focusVFO1Action → Entry.FocusVFO1() +F10 → focusVFO2Action → Entry.FocusVFO2() + +(no default hotkey) → muteAudioVFO1/2 → MuteAudio(VFO1/2) +(no default hotkey) → unmuteAudioVFO1/2 → UnmuteAudio(VFO1/2) +(no default hotkey) → toggleAudioVFO1/2 → ToggleAudio(VFO1/2) +``` + +Hotkey defaults also recorded in `cfg.Default.Keybindings` (`cfg/cfg.go`). + +--- + +## 9. Event flow diagram + +``` +hamlib goroutine vfo.VFO (UI thread via asyncRunner) + │ │ + │ emitFrequencyChanged ──────────► VFOFrequencyChanged(id, f) + │ emitBandChanged ──────────► VFOBandChanged(id, b) + │ emitModeChanged ──────────► VFOModeChanged(id, m) + │ emitXITChanged ──────────► VFOXITChanged(id, …) + │ emitPTTChanged ──────────► VFOPTTChanged(id, p) + │ │ + │ emitCurrentVFOChanged ─────────► CurrentVFOChanged(vfoID) + │ │ (Entry only, not via vfo.VFO) + │ │ + │ (also forwarded directly to Entry via Radio.Notify → wrapped in asyncRunner inside handler) + │ │ + ▼ ▼ + entry.Controller + ┌─────────────────────────────────┐ + │ focusedVFO cursor │ + │ ignoreVFOChange loop guard │ + │ per-VFO: input[], band[], mode[] │ + │ activeField[], esmState[]│ + │ claims (SerialClaims) │ + └──────────────┬──────────────────┘ + │ view calls (UI thread) + ▼ + entryView (Qt widgets) + VFO1 row │ VFO2 row + callsign │ callsign + exchange │ exchange + Log/Clear │ Log/Clear + +radio.Controller (main thread) + emitRadioChanged(name, singleVFO) + │ + ├──► Entry.RadioChanged → vfo2Enabled, view.SetVFOEnabled + ├──► Callinfo.RadioChanged + └──► view.SetRadioSelected (inside emitRadioChanged) + + SetFocusedVFO(vfo) + │ + ├──► vfoSwitcher.SetCurrentVFO(vfo) → rig command +``` + +--- + +## 10. Invariants + +- **Single event source per VFO**: `vfo.VFO.emit*` is the canonical egress. Backends never bypass it. (Entry also receives raw hamlib events via `Radio.Notify` forwarding, but those handlers are fully wrapped in `asyncRunner`.) +- **asyncRunner contract**: all UI-touching code in Entry event handlers runs inside `c.asyncRunner`. VFO objects marshal outward via their own `asyncRunner`. No Qt call made from a non-UI goroutine. +- **Construction precedes wiring**: `Radio` constructed before VFOs; VFOs constructed before `SelectRadio`; VFOs registered on Entry before events can fire. +- **focusedVFO as single routing cursor**: every user input path reads `c.focusedVFO`; `SetFocusedVFO` is the sole mutator from outside the controller. +- **ignoreVFOChange prevents loops**: when `SetFocusedVFO` commands the rig, `ignoreVFOChange` is set true so that the resulting `CurrentVFOChanged` callback from hamlib is suppressed. +- **vfo2Enabled gates VFO2**: `SetFocusedVFO(VFO2)`, `FocusVFO2`, `LogVFO(VFO2)`, `ClearVFO(VFO2)` all no-op when `!c.vfo2Enabled`. View mirrors state via `SetVFOEnabled`. + +--- + +## 11. Outstanding work + +| Location | Item | +|----------|------| +| `core/hamlib/hamlib.go:382` `SetXIT` | Hardcodes `core.VFO1`. Needs `VFOID` parameter like the other setters. Comment: `// TODO: add the VFOID to all VFO-related Setters`. | +| `core/entry/entry.go:854` `XITActiveChanged` | `// TODO: add VFO parameter to XITActiveChanged` — handler and `vfo.XITControl` interface lack `VFOID`. | +| `core/entry/entry.go:628` `enterEditMode` | `// TODO step 6: c.view.SetVFOEnabled(core.VFO2, false)` — edit mode does not yet hide VFO2 on enter. | +| `core/entry/entry.go:652` `leaveEditMode` | `// TODO step 6: c.view.SetVFOEnabled(core.VFO2, true)` — complement of above; VFO2 stays visible and interactive during editing. | +| `core/app/app.go:228–229` | `Bandmap.SetVFO`, `Workmode.Notify` intentionally VFO1-only. Decide if bandmap routing should follow `focusedVFO` or stay VFO1-only. | +| `ui/actions.go` | `LogVFO`/`ClearVFO` actions not wired as UI actions (keybinding slots exist in `cfg.go` with empty defaults but no `makeTriggerAction` in `actions.go`). | +| Remote server | `LogVFO`/`ClearVFO` currently pass `VFOID` as a Go call only; verify remote HTTP surface encodes VFO identity if needed beyond action IDs. | diff --git a/go.mod b/go.mod index e38a5270..cc8a64ee 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/ftl/conval v0.11.3 github.com/ftl/godxmap v1.0.1 github.com/ftl/hamradio v0.3.0 - github.com/ftl/hl-go v1.0.3 + github.com/ftl/hl-go v1.0.6 github.com/ftl/tci v0.3.2 github.com/mappu/miqt v0.13.1-0.20260426030313-65d643625640 github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index 7a5b6f22..d9096310 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ github.com/ftl/godxmap v1.0.1 h1:VzzpC2TBTO3/NMHDeqNTpGhhFxHrTLfhycUq0edh4JE= github.com/ftl/godxmap v1.0.1/go.mod h1:re+DTuokHPkwc9u3GQfj9R4wlKIWw60kWQObybVyupU= github.com/ftl/hamradio v0.3.0 h1:OymKutzeLpD/ar82Kxqe8bZ0GMohEd16wC+beKqYuX8= github.com/ftl/hamradio v0.3.0/go.mod h1:BvA+ni3sOKmrIJpLt6f2sYK9vc3VfihZm4x0h8kzOPw= -github.com/ftl/hl-go v1.0.3 h1:pRek1Dvo3B+M7HkE0b+EdpbUCte6bdx8LbLUPavCZZ8= -github.com/ftl/hl-go v1.0.3/go.mod h1:DqS4+Y0WAIM5qN0t5bbSh4smjIVKutRv5ootbRceDbM= +github.com/ftl/hl-go v1.0.6 h1:7jD4RLLNJb04h3/IlLhsfc0ACjm/3Cw4PbLyASbEpTY= +github.com/ftl/hl-go v1.0.6/go.mod h1:DqS4+Y0WAIM5qN0t5bbSh4smjIVKutRv5ootbRceDbM= github.com/ftl/localcopy v0.0.0-20190616142648-8915fb81f0d9 h1:ORI3EUKpLTsfA372C6xpuZFDXw+ckmCzLaCcJvakG24= github.com/ftl/localcopy v0.0.0-20190616142648-8915fb81f0d9/go.mod h1:4sZLCxjgn++exy5u0muVzlvnahfanPuiHLQo0GJQnPA= github.com/ftl/tci v0.3.2 h1:1Qdgprldiv7/DQvuK96OHMVqb+SDunqbxTHDcWsE5Tk= diff --git a/script/screenshots.go b/script/screenshots.go index ff9c4a8e..86a53d48 100644 --- a/script/screenshots.go +++ b/script/screenshots.go @@ -484,7 +484,7 @@ func parseQSOCSV() []qsoData { func enterQSOData(r *Runtime, qso qsoData) { r.UI(r.App.Entry.Clear) r.Clock.SetMinute(qso.minute) - r.App.VFO.SetFrequency(qso.frequency) + r.App.VFOs[core.VFO1].SetFrequency(qso.frequency) time.Sleep(100 * time.Millisecond) r.UI(func() { r.App.Workmode.SetWorkmode(qso.workmode) diff --git a/ui/actions.go b/ui/actions.go index ea39def5..ee7a9d1c 100644 --- a/ui/actions.go +++ b/ui/actions.go @@ -87,6 +87,15 @@ type actions struct { sendMacro4Action *qtlib.QAction selectBestMatchAction *qtlib.QAction nextESMStepAction *qtlib.QAction + toggleVFOAction *qtlib.QAction + focusVFO1Action *qtlib.QAction + focusVFO2Action *qtlib.QAction + muteAudioVFO1Action *qtlib.QAction + muteAudioVFO2Action *qtlib.QAction + unmuteAudioVFO1Action *qtlib.QAction + unmuteAudioVFO2Action *qtlib.QAction + toggleAudioVFO1Action *qtlib.QAction + toggleAudioVFO2Action *qtlib.QAction } func newActions(parent *qtlib.QWidget, controller *app.Controller, keybindings map[string]string) *actions { @@ -162,12 +171,21 @@ func newActions(parent *qtlib.QWidget, controller *app.Controller, keybindings m a.aboutAction = a.makeTriggerAction(core.ActionHelpAbout, "&About", "Show the about dialog", "", controller.About) // Other actions - a.sendMacro1Action = a.makeTriggerAction(core.ActionKeyerSendMacro1, "Send Macro 1", "Send the keyer macro #1", "F1", func() { controller.Keyer.Send(0) }) - a.sendMacro2Action = a.makeTriggerAction(core.ActionKeyerSendMacro2, "Send Macro 2", "Send the keyer macro #2", "F2", func() { controller.Keyer.Send(1) }) - a.sendMacro3Action = a.makeTriggerAction(core.ActionKeyerSendMacro3, "Send Macro 3", "Send the keyer macro #3", "F3", func() { controller.Keyer.Send(2) }) - a.sendMacro4Action = a.makeTriggerAction(core.ActionKeyerSendMacro4, "Send Macro 4", "Send the keyer macro #4", "F4", func() { controller.Keyer.Send(3) }) + a.sendMacro1Action = a.makeTriggerAction(core.ActionKeyerSendMacro1, "Send Macro 1", "Send the keyer macro #1", "F1", func() { controller.Keyer.SendMacro(0) }) + a.sendMacro2Action = a.makeTriggerAction(core.ActionKeyerSendMacro2, "Send Macro 2", "Send the keyer macro #2", "F2", func() { controller.Keyer.SendMacro(1) }) + a.sendMacro3Action = a.makeTriggerAction(core.ActionKeyerSendMacro3, "Send Macro 3", "Send the keyer macro #3", "F3", func() { controller.Keyer.SendMacro(2) }) + a.sendMacro4Action = a.makeTriggerAction(core.ActionKeyerSendMacro4, "Send Macro 4", "Send the keyer macro #4", "F4", func() { controller.Keyer.SendMacro(3) }) a.selectBestMatchAction = a.makeTriggerAction(core.ActionEntrySelectBestMatch, "Select Best Match", "Select the best matching callsign", "Alt+Return", func() { controller.Entry.SelectBestMatchOnFrequency() }) a.nextESMStepAction = a.makeTriggerAction(core.ActionEntryNextESMStep, "Next ESM Step", "Execute the next ESM step", "", func() { controller.Entry.NextESMStep() }) + a.toggleVFOAction = a.makeTriggerAction(core.ActionEntryToggleFocusedVFO, "Toggle Focused VFO", "Switch focus between VFO 1 and VFO 2", "F8", func() { controller.Entry.ToggleFocusedVFO() }) + a.focusVFO1Action = a.makeTriggerAction(core.ActionEntryFocusVFO1, "Focus VFO 1", "Set the focused VFO to VFO 1", "F9", func() { controller.Entry.FocusVFO1() }) + a.focusVFO2Action = a.makeTriggerAction(core.ActionEntryFocusVFO2, "Focus VFO 2", "Set the focused VFO to VFO 2", "F10", func() { controller.Entry.FocusVFO2() }) + a.muteAudioVFO1Action = a.makeTriggerAction(core.ActionRadioMuteAudioVFO1, "Mute VFO 1", "Mute audio on VFO 1", "", func() { controller.MuteAudio(core.VFO1) }) + a.muteAudioVFO2Action = a.makeTriggerAction(core.ActionRadioMuteAudioVFO2, "Mute VFO 2", "Mute audio on VFO 2", "", func() { controller.MuteAudio(core.VFO2) }) + a.unmuteAudioVFO1Action = a.makeTriggerAction(core.ActionRadioUnmuteAudioVFO1, "Unmute VFO 1", "Unmute audio on VFO 1", "", func() { controller.UnmuteAudio(core.VFO1) }) + a.unmuteAudioVFO2Action = a.makeTriggerAction(core.ActionRadioUnmuteAudioVFO2, "Unmute VFO 2", "Unmute audio on VFO 2", "", func() { controller.UnmuteAudio(core.VFO2) }) + a.toggleAudioVFO1Action = a.makeTriggerAction(core.ActionRadioToggleAudioVFO1, "Toggle Audio VFO 1", "Toggle audio on VFO 1", "", func() { controller.ToggleAudio(core.VFO1) }) + a.toggleAudioVFO2Action = a.makeTriggerAction(core.ActionRadioToggleAudioVFO2, "Toggle Audio VFO 2", "Toggle audio on VFO 2", "", func() { controller.ToggleAudio(core.VFO2) }) a.parent.AddActions([]*qtlib.QAction{ a.sendMacro1Action, a.sendMacro2Action, @@ -175,6 +193,15 @@ func newActions(parent *qtlib.QWidget, controller *app.Controller, keybindings m a.sendMacro4Action, a.selectBestMatchAction, a.nextESMStepAction, + a.toggleVFOAction, + a.focusVFO1Action, + a.focusVFO2Action, + a.muteAudioVFO1Action, + a.muteAudioVFO2Action, + a.unmuteAudioVFO1Action, + a.unmuteAudioVFO2Action, + a.toggleAudioVFO1Action, + a.toggleAudioVFO2Action, }) // setup initial action state from controller diff --git a/ui/app.go b/ui/app.go index 91c097cd..f13f9a64 100644 --- a/ui/app.go +++ b/ui/app.go @@ -75,7 +75,7 @@ func Run(version string, sponsors string, startupScript Script, args []string) { a.controller.Entry.Notify(a.actions) a.controller.Workmode.Notify(a.actions) a.controller.QTCList.Notify(a.actions) - a.controller.VFO.Notify(a.actions) + a.controller.VFOs[core.VFO1].Notify(a.actions) a.createMenu() a.createCentralWidget() @@ -102,10 +102,10 @@ func Run(version string, sponsors string, startupScript Script, args []string) { } a.controller.Settings.Notify(settings.ContestListenerFunc(func(c core.Contest) { - a.SetExchangeFields(c.MyExchangeFields, c.TheirExchangeFields) + a.SetExchangeFields(c.MyExchangeFields, c.TheirExchangeFields, c.GenerateSerialExchange) })) contest := a.controller.Settings.Contest() - a.SetExchangeFields(contest.MyExchangeFields, contest.TheirExchangeFields) + a.SetExchangeFields(contest.MyExchangeFields, contest.TheirExchangeFields, contest.GenerateSerialExchange) a.window.Show() a.controller.Refresh() @@ -201,7 +201,7 @@ func (a *application) createCentralWidget() { a.controller.Callinfo.SetView(a.callinfoView) a.controller.QTCList.Notify(a.callinfoView) - // setup remeining center parts + // setup remaining center parts a.esmView = newESMView() a.esmView.SetESMController(a.controller.Entry) a.controller.Entry.SetESMView(a.esmView) @@ -348,8 +348,8 @@ func (a *application) storeWindowState() { settings.Sync() } -func (a *application) SetExchangeFields(myExchangeFields, theirExchangeFields []core.ExchangeField) { - a.centralArea.SetExchangeFields(myExchangeFields, theirExchangeFields) +func (a *application) SetExchangeFields(myExchangeFields, theirExchangeFields []core.ExchangeField, generateSerialExchange bool) { + a.centralArea.SetExchangeFields(myExchangeFields, theirExchangeFields, generateSerialExchange) } // runAsync posts a function to run on the Qt main thread (implements core.AsyncRunner) @@ -376,7 +376,7 @@ func (a *application) ShowFilename(filename string) { // SelectOpenFile implements app.View func (a *application) SelectOpenFile(title string, dir string, patterns ...string) (string, bool, error) { filter := buildFileFilter(patterns) - dlg := qtlib.NewQFileDialog6(a.window.QWidget, title, dir, filter) + dlg := qtlib.NewQFileDialog6(nil, title, dir, filter) dlg.SetAcceptMode(qtlib.QFileDialog__AcceptOpen) dlg.SetFileMode(qtlib.QFileDialog__ExistingFile) // Wayland: force a proper top-level window so the compositor lets the user @@ -404,7 +404,7 @@ func (a *application) SelectSaveFile(title string, dir string, filename string, if filename != "" { defaultPath = dir + "/" + filename } - result := qtlib.QFileDialog_GetSaveFileName4(a.window.QWidget, title, defaultPath, filter) + result := qtlib.QFileDialog_GetSaveFileName4(nil, title, defaultPath, filter) if result == "" { return "", false, nil } @@ -463,7 +463,9 @@ type keyerButtonAdapter struct { messenger *entryView } -func (a *keyerButtonAdapter) ShowMessage(args ...any) { a.messenger.ShowMessage(args...) } +func (a *keyerButtonAdapter) ShowMessage(args ...any) { + a.messenger.ShowMessage(core.VFO1, args...) +} // buildFileFilter converts glob patterns like "*.log" to Qt filter format func buildFileFilter(patterns []string) string { diff --git a/ui/callinfoView.go b/ui/callinfoView.go index c0b59b7a..8deb0aaa 100644 --- a/ui/callinfoView.go +++ b/ui/callinfoView.go @@ -12,89 +12,107 @@ import ( "github.com/ftl/hellocontest/core" ) -type callinfoView struct { - widget *qtlib.QWidget - - callsignLabel *qtlib.QLabel - valueLabel *qtlib.QLabel - qtcStatusLabel *qtlib.QLabel - infoContainer *qtlib.QWidget - dxccLabel *qtlib.QLabel - userInfoLabel *qtlib.QLabel - supercheckLabel *qtlib.QLabel - +type callinfoVFOWidgets struct { + callsignLabel *qtlib.QLabel + valueLabel *qtlib.QLabel + qtcStatusLabel *qtlib.QLabel + infoContainer *qtlib.QWidget + dxccLabel *qtlib.QLabel + userInfoLabel *qtlib.QLabel + supercheckLabel *qtlib.QLabel predictedExchangeLabels []*qtlib.QLabel - - qtcsEnabled bool - current core.CallinfoFrame } -func newCallinfoView() *callinfoView { - v := &callinfoView{} - - v.widget = qtlib.NewQWidget2() +type callinfoView struct { + vfo [core.VFOCount]callinfoVFOWidgets - v.callsignLabel = qtlib.NewQLabel2() - v.callsignLabel.SetObjectName(*qtlib.NewQAnyStringView3("predictedBestMatch")) - v.callsignLabel.SetTextFormat(qtlib.RichText) + qtcsEnabled bool + vfo2Enabled bool - v.valueLabel = qtlib.NewQLabel2() - v.valueLabel.SetTextFormat(qtlib.RichText) - v.valueLabel.SetObjectName(*qtlib.NewQAnyStringView3("predictedValue")) + current [core.VFOCount]core.CallinfoFrame +} - v.qtcStatusLabel = qtlib.NewQLabel2() - v.qtcStatusLabel.SetTextFormat(qtlib.RichText) - v.qtcStatusLabel.SetObjectName(*qtlib.NewQAnyStringView3("qtcStatus")) - v.qtcStatusLabel.SetAlignment(qtlib.AlignVCenter | qtlib.AlignTrailing) +func newCallinfoVFOWidgets(prefix string) callinfoVFOWidgets { + w := callinfoVFOWidgets{} + + w.callsignLabel = qtlib.NewQLabel2() + w.callsignLabel.SetObjectName(*qtlib.NewQAnyStringView3(prefix + "predictedBestMatch")) + w.callsignLabel.SetTextFormat(qtlib.RichText) + + w.valueLabel = qtlib.NewQLabel2() + w.valueLabel.SetTextFormat(qtlib.RichText) + w.valueLabel.SetObjectName(*qtlib.NewQAnyStringView3(prefix + "predictedValue")) + + w.qtcStatusLabel = qtlib.NewQLabel2() + w.qtcStatusLabel.SetTextFormat(qtlib.RichText) + w.qtcStatusLabel.SetObjectName(*qtlib.NewQAnyStringView3(prefix + "qtcStatus")) + w.qtcStatusLabel.SetAlignment(qtlib.AlignVCenter | qtlib.AlignTrailing) + + w.supercheckLabel = qtlib.NewQLabel2() + w.supercheckLabel.SetObjectName(*qtlib.NewQAnyStringView3(prefix + "supercheckLabel")) + w.supercheckLabel.SetTextFormat(qtlib.RichText) + w.supercheckLabel.QWidget.SetSizePolicy2(qtlib.QSizePolicy__Ignored, qtlib.QSizePolicy__Preferred) + w.supercheckLabel.SetMinimumWidth(0) + + w.dxccLabel = qtlib.NewQLabel2() + w.dxccLabel.SetObjectName(*qtlib.NewQAnyStringView3(prefix + "dxccLabel")) + w.dxccLabel.QWidget.SetSizePolicy2(qtlib.QSizePolicy__Ignored, qtlib.QSizePolicy__Preferred) + w.dxccLabel.SetMinimumWidth(0) + + w.userInfoLabel = qtlib.NewQLabel2() + w.userInfoLabel.SetObjectName(*qtlib.NewQAnyStringView3(prefix + "userInfoLabel")) + w.userInfoLabel.QWidget.SetSizePolicy2(qtlib.QSizePolicy__Ignored, qtlib.QSizePolicy__Preferred) + w.userInfoLabel.SetMinimumWidth(0) + w.userInfoLabel.SetAlignment(qtlib.AlignVCenter | qtlib.AlignTrailing) + + w.infoContainer = qtlib.NewQWidget2() + infoLayout := qtlib.NewQHBoxLayout(w.infoContainer) + infoLayout.SetContentsMargins(0, 0, 0, 0) + infoLayout.AddWidget(w.dxccLabel.QWidget) + infoLayout.AddWidget(w.userInfoLabel.QWidget) - v.supercheckLabel = qtlib.NewQLabel2() - v.supercheckLabel.SetObjectName(*qtlib.NewQAnyStringView3("supercheckLabel")) - v.supercheckLabel.SetTextFormat(qtlib.RichText) - v.supercheckLabel.QWidget.SetSizePolicy2(qtlib.QSizePolicy__Ignored, qtlib.QSizePolicy__Preferred) - v.supercheckLabel.SetMinimumWidth(0) + return w +} - v.dxccLabel = qtlib.NewQLabel2() - v.dxccLabel.SetObjectName(*qtlib.NewQAnyStringView3("dxccLabel")) - v.dxccLabel.QWidget.SetSizePolicy2(qtlib.QSizePolicy__Ignored, qtlib.QSizePolicy__Preferred) - v.dxccLabel.SetMinimumWidth(0) +func newCallinfoView() *callinfoView { + v := &callinfoView{} - v.userInfoLabel = qtlib.NewQLabel2() - v.userInfoLabel.SetObjectName(*qtlib.NewQAnyStringView3("userInfoLabel")) - v.userInfoLabel.QWidget.SetSizePolicy2(qtlib.QSizePolicy__Ignored, qtlib.QSizePolicy__Preferred) - v.userInfoLabel.SetMinimumWidth(0) - v.userInfoLabel.SetAlignment(qtlib.AlignVCenter | qtlib.AlignTrailing) + v.vfo[core.VFO1] = newCallinfoVFOWidgets("vfo1") + v.vfo[core.VFO2] = newCallinfoVFOWidgets("vfo2") - v.infoContainer = qtlib.NewQWidget2() - infoLayout := qtlib.NewQHBoxLayout(v.infoContainer) - infoLayout.SetContentsMargins(0, 0, 0, 0) - infoLayout.AddWidget(v.dxccLabel.QWidget) - infoLayout.AddWidget(v.userInfoLabel.QWidget) + // VFO2 widgets initially hidden + v.SetVFOEnabled(core.VFO2, false) return v } -func (v *callinfoView) ShowFrame(frame core.CallinfoFrame) { - v.current = frame - v.refresh() +func (v *callinfoView) ShowFrame(vfo core.VFOID, frame core.CallinfoFrame) { + v.current[vfo] = frame + v.refreshVFO(vfo) } func (v *callinfoView) SetQTCsEnabled(enabled bool) { v.qtcsEnabled = enabled - v.refresh() + for vfo := range core.VFOCount { + v.refreshVFO(vfo) + } } -func (v *callinfoView) refresh() { - best := v.current.BestMatchOnFrequency() - v.callsignLabel.SetText(renderAnnotatedCallsignHTML(best)) - v.dxccLabel.SetText(renderDXCC(v.current.DXCCEntity, v.current.Azimuth, v.current.Distance)) - v.valueLabel.SetText(renderValue(v.current.Points, v.current.Multis, v.current.Value)) - v.qtcStatusLabel.SetText(renderQTCStatus(v.current.SentQTCs, v.current.ReceivedQTCs, v.qtcsEnabled)) - v.userInfoLabel.SetText(v.current.UserInfo) - v.supercheckLabel.SetText(renderSupercheckHTML(v.current.Supercheck)) - - for i, lbl := range v.predictedExchangeLabels { - if i < len(v.current.PredictedExchange) { - lbl.SetText(strings.TrimSpace(v.current.PredictedExchange[i])) +func (v *callinfoView) refreshVFO(vfo core.VFOID) { + w := &v.vfo[vfo] + cur := &v.current[vfo] + + best := cur.BestMatchOnFrequency() + w.callsignLabel.SetText(renderAnnotatedCallsignHTML(best)) + w.dxccLabel.SetText(renderDXCC(cur.DXCCEntity, cur.Azimuth, cur.Distance)) + w.valueLabel.SetText(renderValue(cur.Points, cur.Multis, cur.Value)) + w.qtcStatusLabel.SetText(renderQTCStatus(cur.SentQTCs, cur.ReceivedQTCs, v.qtcsEnabled)) + w.userInfoLabel.SetText(cur.UserInfo) + w.supercheckLabel.SetText(renderSupercheckHTML(cur.Supercheck)) + + for i, lbl := range w.predictedExchangeLabels { + if i < len(cur.PredictedExchange) { + lbl.SetText(strings.TrimSpace(cur.PredictedExchange[i])) } else { lbl.SetText("-") } @@ -102,17 +120,43 @@ func (v *callinfoView) refresh() { } func (v *callinfoView) SetPredictedExchangeFields(fields []core.ExchangeField) { - for _, label := range v.predictedExchangeLabels { - label.Delete() - } - v.predictedExchangeLabels = nil + for vfo := range core.VFOCount { + w := &v.vfo[vfo] + for _, label := range w.predictedExchangeLabels { + label.Delete() + } + w.predictedExchangeLabels = nil - for i := range fields { - valueLabel := qtlib.NewQLabel2() - if i == 0 { - valueLabel.SetObjectName(*qtlib.NewQAnyStringView3("predictedExchange")) + prefix := "vfo1" + if vfo == core.VFO2 { + prefix = "vfo2" + } + for i := range fields { + valueLabel := qtlib.NewQLabel2() + if i == 0 { + valueLabel.SetObjectName(*qtlib.NewQAnyStringView3(prefix + "predictedExchange")) + } + if vfo == core.VFO2 { + valueLabel.SetVisible(v.vfo2Enabled) + } + w.predictedExchangeLabels = append(w.predictedExchangeLabels, valueLabel) } - v.predictedExchangeLabels = append(v.predictedExchangeLabels, valueLabel) + } +} + +func (v *callinfoView) SetVFOEnabled(vfo core.VFOID, enabled bool) { + if vfo == core.VFO1 { + return + } + v.vfo2Enabled = enabled + w := &v.vfo[core.VFO2] + w.callsignLabel.SetVisible(enabled) + w.valueLabel.SetVisible(enabled) + w.qtcStatusLabel.SetVisible(enabled) + w.infoContainer.SetVisible(enabled) + w.supercheckLabel.SetVisible(enabled) + for _, lbl := range w.predictedExchangeLabels { + lbl.SetVisible(enabled) } } diff --git a/ui/centralArea.go b/ui/centralArea.go index ede7e85b..476cd76e 100644 --- a/ui/centralArea.go +++ b/ui/centralArea.go @@ -63,6 +63,8 @@ func newCentralArea(entry *entryView, callinfo *callinfoView, esm *esmView, work rootLayout.AddWidget(result.keyer.widget) rootLayout.AddStretch() + entry.onVFO2Enabled = result.setVFO2Enabled + return result } @@ -71,58 +73,63 @@ func (a *centralArea) RepaintForThemeChange() { a.root.Update() } -func (a *centralArea) SetExchangeFields(myExchangeFields, theirExchangeFields []core.ExchangeField) { +func (a *centralArea) SetExchangeFields(myExchangeFields, theirExchangeFields []core.ExchangeField, generateSerialExchange bool) { a.removeWidgetsFromLayout() a.entry.SetExchangeFields(myExchangeFields, theirExchangeFields) + a.entry.SetSerialClaimLabelsVisible(generateSerialExchange) a.callinfo.SetPredictedExchangeFields(theirExchangeFields) a.addWidgetsToLayout() } -func (a *centralArea) addWidgetsToLayout() { - lastColumn := 4 + len(a.entry.theirExchangeFields) +func (a *centralArea) setVFO2Enabled(enabled bool) { + if enabled { + a.addVFOWidgetsToLayout(core.VFO2, 8) + } else { + a.removeVFOWidgetsFromLayout(core.VFO2) + } + a.entry.setVFO2Enabled(enabled) + a.callinfo.SetVFOEnabled(core.VFO2, enabled) +} - // row 0: my data: call, exchange - a.entryLayout.AddWidget2(a.entry.utcLabel.QWidget, 0, 0) - a.entryLayout.AddWidget2(a.entry.myCallLabel.QWidget, 0, 1) +func (a *centralArea) addWidgetsToLayout() { + a.entryLayout.AddWidget2(a.entry.myCallLabel.QWidget, 0, 0) for i := range a.entry.myExchangeFields { - a.entryLayout.AddWidget2(a.entry.myExchangeFields[i].QWidget, 0, i+2) + a.entryLayout.AddWidget2(a.entry.myExchangeFields[i].QWidget, 0, i+1) } - // row 1: VFO label + horizontal separator - a.entryLayout.AddWidget3(a.entry.topSeparator.QWidget, 1, 0, 1, -1) + a.addVFOWidgetsToLayout(core.VFO1, 1) + if a.entry.vfo2Enabled { + a.addVFOWidgetsToLayout(core.VFO2, 8) + } +} - // row 2: Frequency, Band, Mode, XIT, TRX - a.entryLayout.AddWidget2(a.entry.vfoLabel.QWidget, 2, 0) - a.entryLayout.AddWidget2(a.entry.frequencyLabel.QWidget, 2, 1) - a.entryLayout.AddWidget2(a.entry.bandModeContainer, 2, 2) - a.entryLayout.AddWidget2(a.entry.xit.QWidget, 2, lastColumn-1) - a.entryLayout.AddWidget2(a.entry.txIndicator.QWidget, 2, lastColumn) +func (a *centralArea) addVFOWidgetsToLayout(vfo core.VFOID, firstRow int) { + entry := a.entry.vfo[vfo] + callinfo := a.callinfo.vfo[vfo] + lastColumn := 3 + len(entry.theirExchangeFields) - // row 3: callinfo: best match, exchange, value, qtcs - a.entryLayout.AddWidget2(a.callinfo.callsignLabel.QWidget, 3, 1) - for i := range a.callinfo.predictedExchangeLabels { - a.entryLayout.AddWidget2(a.callinfo.predictedExchangeLabels[i].QWidget, 3, i+2) + a.entryLayout.AddWidget3(entry.topSeparator.QWidget, firstRow+0, 0, 1, -1) + a.entryLayout.AddWidget3(entry.vfoContainer, firstRow+1, 0, 1, 2) + if entry.serialClaimLabel != nil { + a.entryLayout.AddWidget2(entry.serialClaimLabel.QWidget, firstRow+1, 2) } - a.entryLayout.AddWidget2(a.callinfo.valueLabel.QWidget, 3, lastColumn-1) - a.entryLayout.AddWidget2(a.callinfo.qtcStatusLabel.QWidget, 3, lastColumn) - - // row 4: their data: call, exchange, log-button, clear-button - a.entryLayout.AddWidget2(a.entry.theirLabel.QWidget, 4, 0) - a.entryLayout.AddWidget2(a.entry.callsign.QWidget, 4, 1) - for i := range a.entry.theirExchangeFields { - a.entryLayout.AddWidget2(a.entry.theirExchangeFields[i].QWidget, 4, i+2) + a.entryLayout.AddWidget2(entry.xit.QWidget, firstRow+1, lastColumn-1) + a.entryLayout.AddWidget2(entry.txIndicator.QWidget, firstRow+1, lastColumn) + a.entryLayout.AddWidget2(callinfo.callsignLabel.QWidget, firstRow+2, 0) + for i := range callinfo.predictedExchangeLabels { + a.entryLayout.AddWidget2(callinfo.predictedExchangeLabels[i].QWidget, firstRow+2, i+1) } - a.entryLayout.AddWidget2(a.entry.logButton.QWidget, 4, lastColumn-1) - a.entryLayout.AddWidget2(a.entry.clearButton.QWidget, 4, lastColumn) - - // row 5: supercheck - a.entryLayout.AddWidget3(a.callinfo.supercheckLabel.QWidget, 5, 1, 1, -1) - - // row 6: dxcc, personal info - a.entryLayout.AddWidget3(a.callinfo.infoContainer, 6, 0, 1, -1) - - // row 7: message - a.entryLayout.AddWidget3(a.entry.messageLabel.QWidget, 7, 0, 1, -1) + a.entryLayout.AddWidget2(callinfo.valueLabel.QWidget, firstRow+2, lastColumn-1) + a.entryLayout.AddWidget2(callinfo.qtcStatusLabel.QWidget, firstRow+2, lastColumn) + a.entryLayout.AddWidget2(entry.callsign.QWidget, firstRow+3, 0) + for i := range entry.theirExchangeFields { + a.entryLayout.AddWidget2(entry.theirExchangeFields[i].QWidget, firstRow+3, i+1) + } + a.entryLayout.AddWidget2(entry.logButton.QWidget, firstRow+3, lastColumn-1) + a.entryLayout.AddWidget2(entry.clearButton.QWidget, firstRow+3, lastColumn) + a.entryLayout.AddWidget3(callinfo.supercheckLabel.QWidget, firstRow+4, 0, 1, -1) + a.entryLayout.AddWidget3(callinfo.infoContainer, firstRow+5, 0, 1, -1) + a.entryLayout.AddWidget3(entry.messageLabel.QWidget, firstRow+5, 0, 1, -1) } func (a *centralArea) removeWidgetsFromLayout() { @@ -132,39 +139,38 @@ func (a *centralArea) removeWidgetsFromLayout() { for i := range a.entry.myExchangeFields { a.entryLayout.RemoveWidget(a.entry.myExchangeFields[i].QWidget) } + a.removeVFOWidgetsFromLayout(core.VFO1) + if a.entry.vfo2Enabled { + a.removeVFOWidgetsFromLayout(core.VFO2) + } +} - // row 1: VFO label + horizontal separator - a.entryLayout.RemoveWidget(a.entry.vfoLabel.QWidget) - a.entryLayout.RemoveWidget(a.entry.topSeparator.QWidget) - - // row 2: Frequency, Band, Mode, XIT, TRX - a.entryLayout.RemoveWidget(a.entry.frequencyLabel.QWidget) - a.entryLayout.RemoveWidget(a.entry.bandModeContainer) - a.entryLayout.RemoveWidget(a.entry.xit.QWidget) - a.entryLayout.RemoveWidget(a.entry.txIndicator.QWidget) +func (a *centralArea) removeVFOWidgetsFromLayout(vfo core.VFOID) { + entry := a.entry.vfo[vfo] + callinfo := a.callinfo.vfo[vfo] - // row 3: callinfo: best match, exchange, value, qtcs - a.entryLayout.RemoveWidget(a.callinfo.callsignLabel.QWidget) - for i := range a.callinfo.predictedExchangeLabels { - a.entryLayout.RemoveWidget(a.callinfo.predictedExchangeLabels[i].QWidget) + a.entryLayout.RemoveWidget(entry.topSeparator.QWidget) + a.entryLayout.RemoveWidget(entry.vfoLabel.QWidget) + a.entryLayout.RemoveWidget(entry.frequencyLabel.QWidget) + a.entryLayout.RemoveWidget(entry.vfoContainer) + if entry.serialClaimLabel != nil { + a.entryLayout.RemoveWidget(entry.serialClaimLabel.QWidget) } - a.entryLayout.RemoveWidget(a.callinfo.valueLabel.QWidget) - - // row 4: their data: call, exchange, log-button, clear-button - a.entryLayout.RemoveWidget(a.entry.theirLabel.QWidget) - a.entryLayout.RemoveWidget(a.entry.callsign.QWidget) - for i := range a.entry.theirExchangeFields { - a.entryLayout.RemoveWidget(a.entry.theirExchangeFields[i].QWidget) + a.entryLayout.RemoveWidget(entry.xit.QWidget) + a.entryLayout.RemoveWidget(entry.txIndicator.QWidget) + a.entryLayout.RemoveWidget(callinfo.callsignLabel.QWidget) + for i := range callinfo.predictedExchangeLabels { + a.entryLayout.RemoveWidget(callinfo.predictedExchangeLabels[i].QWidget) } - a.entryLayout.RemoveWidget(a.entry.logButton.QWidget) - a.entryLayout.RemoveWidget(a.entry.clearButton.QWidget) - - // row 5: supercheck - a.entryLayout.RemoveWidget(a.callinfo.supercheckLabel.QWidget) - - // row 6: dxcc, personal info - a.entryLayout.RemoveWidget(a.callinfo.infoContainer) - - // row 7: message - a.entryLayout.RemoveWidget(a.entry.messageLabel.QWidget) + a.entryLayout.RemoveWidget(callinfo.valueLabel.QWidget) + a.entryLayout.RemoveWidget(callinfo.qtcStatusLabel.QWidget) + a.entryLayout.RemoveWidget(entry.callsign.QWidget) + for i := range entry.theirExchangeFields { + a.entryLayout.RemoveWidget(entry.theirExchangeFields[i].QWidget) + } + a.entryLayout.RemoveWidget(entry.logButton.QWidget) + a.entryLayout.RemoveWidget(entry.clearButton.QWidget) + a.entryLayout.RemoveWidget(callinfo.supercheckLabel.QWidget) + a.entryLayout.RemoveWidget(callinfo.infoContainer) + a.entryLayout.RemoveWidget(entry.messageLabel.QWidget) } diff --git a/ui/common.go b/ui/common.go index f5f8b694..e1a3926b 100644 --- a/ui/common.go +++ b/ui/common.go @@ -21,7 +21,7 @@ func makeFilterableCombo() *qtlib.QComboBox { return combo } -func SetupBandCombo(combo *qtlib.QComboBox) { +func setupBandCombo(combo *qtlib.QComboBox) { combo.Clear() for _, band := range core.Bands { combo.AddItem(band.String()) @@ -29,7 +29,7 @@ func SetupBandCombo(combo *qtlib.QComboBox) { combo.SetCurrentIndex(0) } -func SetupModeCombo(combo *qtlib.QComboBox) { +func setupModeCombo(combo *qtlib.QComboBox) { combo.Clear() for _, mode := range core.Modes { combo.AddItem(mode.String()) diff --git a/ui/entryView.go b/ui/entryView.go index 2b289288..9322a8ad 100644 --- a/ui/entryView.go +++ b/ui/entryView.go @@ -10,12 +10,16 @@ import ( "github.com/ftl/hellocontest/core" ) -const parrot = "🦜" +const ( + parrot = "🦜" + txVFOIndicator = `` +) // EntryController controls the entry of QSO data. type EntryController interface { GotoNextField() core.EntryField GotoNextPlaceholder() + SetFocusedVFO(core.VFOID) SetActiveField(core.EntryField) Enter(string) @@ -29,39 +33,53 @@ type EntryController interface { EnterPressed() Log() Clear() -} -type entryView struct { - root *qtlib.QWidget // root widget containing the grid layout + LogVFO(core.VFOID) + ClearVFO(core.VFOID) +} - utcLabel *qtlib.QLabel - myCallLabel *qtlib.QLabel +type entryVFOWidgets struct { + topSeparator *qtlib.QFrame + vfoContainer *qtlib.QWidget vfoLabel *qtlib.QLabel - topSeparator *qtlib.QFrame frequencyLabel *qtlib.QLabel - txIndicator *qtlib.QLabel - messageLabel *qtlib.QLabel + band *qtlib.QComboBox + mode *qtlib.QComboBox - theirLabel *qtlib.QLabel - callsign *qtlib.QLineEdit - bandModeContainer *qtlib.QWidget - band *qtlib.QComboBox - mode *qtlib.QComboBox - xit *qtlib.QCheckBox + serialClaimLabel *qtlib.QLabel + xit *qtlib.QCheckBox + txIndicator *qtlib.QLabel - myExchangeFields []*qtlib.QLineEdit + callsign *qtlib.QLineEdit theirExchangeFields []*qtlib.QLineEdit + logButton *qtlib.QPushButton + clearButton *qtlib.QPushButton - logButton *qtlib.QPushButton - clearButton *qtlib.QPushButton + messageLabel *qtlib.QLabel +} + +type entryView struct { + root *qtlib.QWidget // root widget containing the grid layout - theirEntryStyle string + utcLabel *qtlib.QLabel // TODO: remove + + myCallLabel *qtlib.QLabel + myExchangeFields []*qtlib.QLineEdit + + vfo [core.VFOCount]entryVFOWidgets + + vfoWorkmode [core.VFOCount]core.Workmode + txVFO core.VFOID + + vfo2Enabled bool + onVFO2Enabled func(bool) // callback to centralArea for layout add/remove ignoreInput bool isDuplicate bool isEditing bool - controller EntryController + + controller EntryController } func newEntryView() *entryView { @@ -72,86 +90,103 @@ func newEntryView() *entryView { v.myCallLabel = qtlib.NewQLabel3("DL0ABC") - // Row 1: Horizontal separator (span all 6 columns) - v.topSeparator = qtlib.NewQFrame2() - v.topSeparator.SetFrameShape(qtlib.QFrame__HLine) - v.topSeparator.SetFrameShadow(qtlib.QFrame__Sunken) + v.vfo[core.VFO1] = newEntryVFOWidgets("vfo1", "VFO 1") + v.vfo[core.VFO2] = newEntryVFOWidgets("vfo2", "VFO 2") - // Row 2: "VFO:" label, frequency label, band combo, mode combo, XIT checkbox, TX indicator - v.vfoLabel = qtlib.NewQLabel3("VFO:") + // Connect signals for static widgets + v.connectEditSignals(v.vfo[core.VFO1].callsign, core.VFO1, core.CallsignField, true) + v.connectEditSignals(v.vfo[core.VFO2].callsign, core.VFO2, core.CallsignField, true) + v.connectComboSignals(v.vfo[core.VFO1].band, core.VFO1, core.BandField) + v.connectComboSignals(v.vfo[core.VFO2].band, core.VFO2, core.BandField) + v.connectComboSignals(v.vfo[core.VFO1].mode, core.VFO1, core.ModeField) + v.connectComboSignals(v.vfo[core.VFO2].mode, core.VFO2, core.ModeField) - v.frequencyLabel = qtlib.NewQLabel3("- kHz") - v.frequencyLabel.SetObjectName(*qtlib.NewQAnyStringView3("frequencyLabel")) - v.frequencyLabel.SetAlignment(qtlib.AlignTrailing | qtlib.AlignVCenter) + // Connect button/checkbox signals + v.vfo[core.VFO1].logButton.OnClicked(func() { + if v.controller != nil { + v.controller.LogVFO(core.VFO1) + } + }) + v.vfo[core.VFO2].logButton.OnClicked(func() { + if v.controller != nil { + v.controller.LogVFO(core.VFO2) + } + }) + v.vfo[core.VFO1].clearButton.OnClicked(func() { + if v.controller != nil { + v.controller.ClearVFO(core.VFO1) + } + }) + v.vfo[core.VFO2].clearButton.OnClicked(func() { + if v.controller != nil { + v.controller.ClearVFO(core.VFO2) + } + }) + v.vfo[core.VFO1].xit.OnStateChanged(func(state int) { + if v.controller != nil { + v.controller.SetXITActive(state != 0) + } + }) + v.vfo[core.VFO2].xit.OnStateChanged(func(state int) { + if v.controller != nil { + // TODO: handle different VFOs + v.controller.SetXITActive(state != 0) + } + }) - v.bandModeContainer = qtlib.NewQWidget2() - bandModeLayout := qtlib.NewQHBoxLayout(v.bandModeContainer) + return v +} - v.band = qtlib.NewQComboBox2() - v.band.SetObjectName(*qtlib.NewQAnyStringView3("bandCombo")) - bandModeLayout.AddWidget2(v.band.QWidget, 1) - v.mode = qtlib.NewQComboBox2() - v.mode.SetObjectName(*qtlib.NewQAnyStringView3("modeCombo")) - bandModeLayout.AddWidget2(v.mode.QWidget, 2) +func newEntryVFOWidgets(prefix string, vfoName string) entryVFOWidgets { + w := entryVFOWidgets{} - v.xit = qtlib.NewQCheckBox3("XIT") - v.xit.SetObjectName(*qtlib.NewQAnyStringView3("xit")) + w.topSeparator = qtlib.NewQFrame2() + w.topSeparator.SetFrameShape(qtlib.QFrame__HLine) + w.topSeparator.SetFrameShadow(qtlib.QFrame__Sunken) - v.txIndicator = qtlib.NewQLabel3("") + w.vfoContainer = qtlib.NewQWidget2() + vfoContainerLayout := qtlib.NewQHBoxLayout(w.vfoContainer) - // Row 3: Reserved for callinfo (later step) + w.vfoLabel = qtlib.NewQLabel3(vfoName) + w.vfoLabel.SetObjectName(*qtlib.NewQAnyStringView3(prefix + "Label")) + w.vfoLabel.SetAlignment(qtlib.AlignCenter | qtlib.AlignVCenter) + vfoContainerLayout.AddWidget(w.vfoLabel.QWidget) - // Row 4: "Their:" label, callsign QLineEdit, theirExchanges container, Log button, Clear button - v.theirLabel = qtlib.NewQLabel3("Their:") + w.frequencyLabel = qtlib.NewQLabel3("- kHz") + w.frequencyLabel.SetObjectName(*qtlib.NewQAnyStringView3(prefix + "FrequencyLabel")) + w.frequencyLabel.SetAlignment(qtlib.AlignTrailing | qtlib.AlignVCenter) + vfoContainerLayout.AddWidget2(w.frequencyLabel.QWidget, 2) - v.callsign = qtlib.NewQLineEdit2() - v.callsign.SetObjectName(*qtlib.NewQAnyStringView3("callsignEntry")) - v.callsign.SetPlaceholderText("Call") + w.band = qtlib.NewQComboBox2() + w.band.SetObjectName(*qtlib.NewQAnyStringView3(prefix + "BandCombo")) + setupBandCombo(w.band) + vfoContainerLayout.AddWidget(w.band.QWidget) - fi := qtlib.NewQFontInfo(v.callsign.Font()) - if pt := fi.PointSizeF(); pt > 0 { - v.theirEntryStyle = fmt.Sprintf("QLineEdit { font-size: %.1fpt; }", pt*2) - } else if px := fi.PixelSize(); px > 0 { - v.theirEntryStyle = fmt.Sprintf("QLineEdit { font-size: %dpx; }", px*2) - } - v.callsign.SetStyleSheet(v.theirEntryStyle) + w.mode = qtlib.NewQComboBox2() + w.mode.SetObjectName(*qtlib.NewQAnyStringView3(prefix + "ModeCombo")) + setupModeCombo(w.mode) + vfoContainerLayout.AddWidget(w.mode.QWidget) - v.logButton = qtlib.NewQPushButton3("Log") - v.logButton.SetFocusPolicy(qtlib.NoFocus) + w.xit = qtlib.NewQCheckBox3("XIT") + w.xit.SetObjectName(*qtlib.NewQAnyStringView3(prefix + "XIT")) - v.clearButton = qtlib.NewQPushButton3("Clear") - v.clearButton.SetFocusPolicy(qtlib.NoFocus) + w.txIndicator = qtlib.NewQLabel3("") + w.txIndicator.SetObjectName(*qtlib.NewQAnyStringView3(prefix + "TX")) - // Row 7: Message label (span all 6 columns) - v.messageLabel = qtlib.NewQLabel3("") + w.callsign = qtlib.NewQLineEdit2() + w.callsign.SetObjectName(*qtlib.NewQAnyStringView3(prefix + "CallsignEntry")) + w.callsign.SetPlaceholderText("Call") + w.callsign.SetStyleSheet(EntryFieldStyle) - // Initialize combos - SetupBandCombo(v.band) - SetupModeCombo(v.mode) + w.logButton = qtlib.NewQPushButton3("Log") + w.logButton.SetFocusPolicy(qtlib.NoFocus) - // Connect signals for static widgets - v.connectEditSignals(v.callsign, core.CallsignField, true) - v.connectComboSignals(v.band, core.BandField) - v.connectComboSignals(v.mode, core.ModeField) + w.clearButton = qtlib.NewQPushButton3("Clear") + w.clearButton.SetFocusPolicy(qtlib.NoFocus) - // Connect button/checkbox signals - v.logButton.OnClicked(func() { - if v.controller != nil { - v.controller.Log() - } - }) - v.clearButton.OnClicked(func() { - if v.controller != nil { - v.controller.Clear() - } - }) - v.xit.OnStateChanged(func(state int) { - if v.controller != nil { - v.controller.SetXITActive(state != 0) - } - }) + w.messageLabel = qtlib.NewQLabel3("") - return v + return w } // setRootWidgets sets the widget that is used to show the duplicate and editing marks @@ -160,17 +195,19 @@ func (v *entryView) setRootWidget(root *qtlib.QWidget) { v.root.SetObjectName(*qtlib.NewQAnyStringView3("entryWidget")) } -func (v *entryView) connectComboSignals(combo *qtlib.QComboBox, field core.EntryField) { +func (v *entryView) connectComboSignals(combo *qtlib.QComboBox, vfo core.VFOID, field core.EntryField) { combo.OnCurrentTextChanged(func(text string) { if v.controller == nil || v.ignoreInput { return } + v.controller.SetFocusedVFO(vfo) v.controller.SetActiveField(field) v.controller.Enter(text) }) combo.OnFocusInEvent(func(super func(ev *qtlib.QFocusEvent), ev *qtlib.QFocusEvent) { super(ev) if v.controller != nil { + v.controller.SetFocusedVFO(vfo) v.controller.SetActiveField(field) } }) @@ -179,7 +216,7 @@ func (v *entryView) connectComboSignals(combo *qtlib.QComboBox, field core.Entry }) } -func (v *entryView) connectEditSignals(edit *qtlib.QLineEdit, field core.EntryField, isTheirRow bool) { +func (v *entryView) connectEditSignals(edit *qtlib.QLineEdit, vfo core.VFOID, field core.EntryField, isTheirRow bool) { edit.OnTextChanged(func(text string) { if v.controller == nil || v.ignoreInput { return @@ -190,6 +227,7 @@ func (v *entryView) connectEditSignals(edit *qtlib.QLineEdit, field core.EntryFi super(ev) edit.SelectAll() if v.controller != nil { + v.controller.SetFocusedVFO(vfo) v.controller.SetActiveField(field) } }) @@ -262,50 +300,96 @@ func (v *entryView) SetMyCall(text string) { v.myCallLabel.SetText(text) } -func (v *entryView) SetFrequency(frequency core.Frequency) { - v.frequencyLabel.SetText(fmt.Sprintf("%.2f kHz", frequency/1000.0)) +func (v *entryView) SetFrequency(vfo core.VFOID, frequency core.Frequency) { + v.vfo[vfo].frequencyLabel.SetText(fmt.Sprintf("%.2f kHz", frequency/1000.0)) +} + +func (v *entryView) SetSerialClaim(vfo core.VFOID, serial core.QSONumber, committed bool) { + label := v.vfo[vfo].serialClaimLabel + if label == nil { + return + } + + if serial == 0 { + label.SetText("") + } else { + text := fmt.Sprintf("#%s", serial.String()) + if committed { + text = fmt.Sprintf("%s", text) + } + label.SetText(text) + } } -func (v *entryView) SetCallsign(text string) { +func (v *entryView) SetCallsign(vfo core.VFOID, text string) { v.ignoreInput = true defer func() { v.ignoreInput = false }() - v.callsign.SetText(text) + widget := v.vfo[vfo].callsign + if widget == nil { + return + } + + widget.SetText(text) } -func (v *entryView) SetBand(text string) { +func (v *entryView) SetBand(vfo core.VFOID, text string) { v.ignoreInput = true defer func() { v.ignoreInput = false }() - idx := v.band.FindText(text) + combo := v.vfo[vfo].band + if combo == nil { + return + } + + idx := combo.FindText(text) if idx >= 0 { - v.band.SetCurrentIndex(idx) + combo.SetCurrentIndex(idx) } } -func (v *entryView) SetMode(text string) { +func (v *entryView) SetMode(vfo core.VFOID, text string) { v.ignoreInput = true defer func() { v.ignoreInput = false }() - idx := v.mode.FindText(text) + combo := v.vfo[vfo].mode + if combo == nil { + return + } + + idx := combo.FindText(text) if idx >= 0 { - v.mode.SetCurrentIndex(idx) + combo.SetCurrentIndex(idx) } } -func (v *entryView) SetXITActive(active bool) { - if v.xit.IsChecked() == active { +func (v *entryView) SetXITActive(vfo core.VFOID, active bool) { + widget := v.vfo[vfo].xit + if widget == nil { + return + } + if widget.IsChecked() == active { return } - v.xit.SetChecked(active) + widget.SetChecked(active) } -func (v *entryView) SetXIT(active bool, offset core.Frequency) { +func (v *entryView) SetXIT(vfo core.VFOID, active bool, offset core.Frequency) { + widget := v.vfo[vfo].xit + if widget == nil { + return + } + if active { - v.xit.SetText(fmt.Sprintf("XIT %s", offset)) + widget.SetText(fmt.Sprintf("XIT %s", offset)) } else { - v.xit.SetText("XIT") + widget.SetText("XIT") } } -func (v *entryView) SetTXState(ptt bool, parrotActive bool, parrotTimeLeft time.Duration) { +func (v *entryView) SetTXState(vfo core.VFOID, ptt bool, parrotActive bool, parrotTimeLeft time.Duration) { + widget := v.vfo[vfo].txIndicator + if widget == nil { + return + } + var text string switch { case parrotActive: @@ -319,12 +403,13 @@ func (v *entryView) SetTXState(ptt bool, parrotActive bool, parrotTimeLeft time. text = "" } + // TODO: use a property with a selective style if ptt { - v.txIndicator.SetStyleSheet(TXIndicatorActiveStyle) + widget.SetStyleSheet(TXIndicatorActiveStyle) } else { - v.txIndicator.SetStyleSheet(TXIndicatorInactiveStyle) + widget.SetStyleSheet(TXIndicatorInactiveStyle) } - v.txIndicator.SetText(text) + widget.SetText(text) } func (v *entryView) SetMyExchange(index int, text string) { @@ -337,22 +422,45 @@ func (v *entryView) SetMyExchange(index int, text string) { v.myExchangeFields[i].SetText(text) } -func (v *entryView) SetTheirExchange(index int, text string) { +func (v *entryView) SetTheirExchange(vfo core.VFOID, index int, text string) { + v.ignoreInput = true + defer func() { v.ignoreInput = false }() + fields := v.vfo[vfo].theirExchangeFields i := index - 1 - if i < 0 || i >= len(v.theirExchangeFields) { + if i < 0 || i >= len(fields) { return } - v.ignoreInput = true - defer func() { v.ignoreInput = false }() - v.theirExchangeFields[i].SetText(text) + fields[i].SetText(text) +} + +func (v *entryView) SetSerialClaimLabelsVisible(visible bool) { + for vfo := range core.VFOCount { + widget := v.vfo[vfo].serialClaimLabel + prefix := fmt.Sprintf("vfo%d", vfo+1) + if visible && v.vfo2Enabled { + if widget == nil { + widget = qtlib.NewQLabel3("") + widget.SetObjectName(*qtlib.NewQAnyStringView3(prefix + "SerialClaim")) + widget.SetAlignment(qtlib.AlignCenter | qtlib.AlignVCenter) + v.vfo[vfo].serialClaimLabel = widget + } + } else { + if widget != nil { + widget.SetParent(nil) + widget.Delete() + v.vfo[vfo].serialClaimLabel = nil + } + } + } } func (v *entryView) SetExchangeFields(myExchangeFields, theirExchangeFields []core.ExchangeField) { - v.setExchangeFields(myExchangeFields, &v.myExchangeFields, false) - v.setExchangeFields(theirExchangeFields, &v.theirExchangeFields, true) + v.setExchangeFields(myExchangeFields, &v.myExchangeFields, false, core.VFO1) + v.setExchangeFields(theirExchangeFields, &v.vfo[core.VFO1].theirExchangeFields, true, core.VFO1) + v.setExchangeFields(theirExchangeFields, &v.vfo[core.VFO2].theirExchangeFields, true, core.VFO2) } -func (v *entryView) setExchangeFields(fields []core.ExchangeField, editFields *[]*qtlib.QLineEdit, isTheirRow bool) { +func (v *entryView) setExchangeFields(fields []core.ExchangeField, editFields *[]*qtlib.QLineEdit, isTheirRow bool, vfo core.VFOID) { // Remove old fields for _, f := range *editFields { f.SetParent(nil) @@ -363,33 +471,96 @@ func (v *entryView) setExchangeFields(fields []core.ExchangeField, editFields *[ *editFields = make([]*qtlib.QLineEdit, len(fields)) for i, field := range fields { editField := qtlib.NewQLineEdit2() - editField.SetObjectName(*qtlib.NewQAnyStringView3(string(field.Field))) + objName := string(field.Field) + if vfo == core.VFO2 { + objName = "vfo2_" + objName + } + editField.SetObjectName(*qtlib.NewQAnyStringView3(objName)) editField.SetPlaceholderText(field.Short) editField.SetEnabled(!field.ReadOnly) if isTheirRow { - editField.SetStyleSheet(v.theirEntryStyle) + editField.SetStyleSheet(EntryFieldStyle) + } + + if vfo == core.VFO2 && !v.vfo2Enabled { + editField.SetVisible(false) + editField.SetEnabled(false) } - v.connectEditSignals(editField, core.TheirExchangeField(i+1), isTheirRow) + v.connectEditSignals(editField, vfo, core.TheirExchangeField(i+1), isTheirRow) (*editFields)[i] = editField } } -func (v *entryView) SetActiveField(field core.EntryField) { +func (v *entryView) SetVFOWorkmode(vfo core.VFOID, workmode core.Workmode) { + v.vfoWorkmode[vfo] = workmode + v.updateVFOLabel(vfo) +} + +func (v *entryView) SetTXVFO(vfo core.VFOID) { + v.txVFO = vfo + for id := core.VFOID(0); id < core.VFOCount; id++ { + v.updateVFOLabel(id) + } +} + +func (v *entryView) updateVFOLabel(vfo core.VFOID) { + label := v.vfo[vfo].vfoLabel + if label == nil { + return + } + text := fmt.Sprintf("VFO %d", vfo+1) + switch v.vfoWorkmode[vfo] { + case core.Run: + text += " RUN" + case core.SearchPounce: + text += " S&P" + } + if vfo == v.txVFO { + text += " " + txVFOIndicator + } + label.SetTextFormat(qtlib.RichText) + label.SetText(text) +} + +func (v *entryView) SetActiveVFO(vfo core.VFOID) { + // TODO: use a property with a selective style + switch vfo { + case core.VFO1: + v.vfo[core.VFO1].vfoLabel.SetStyleSheet(VFOActiveStyle) + v.vfo[core.VFO2].vfoLabel.SetStyleSheet(VFOInactiveStyle) + case core.VFO2: + v.vfo[core.VFO1].vfoLabel.SetStyleSheet(VFOInactiveStyle) + v.vfo[core.VFO2].vfoLabel.SetStyleSheet(VFOActiveStyle) + } +} + +func (v *entryView) SetActiveField(vfo core.VFOID, field core.EntryField) { + callsign := v.vfo[vfo].callsign + band := v.vfo[vfo].band + mode := v.vfo[vfo].mode + theirExchange := v.vfo[vfo].theirExchangeFields + switch field { case core.CallsignField, core.OtherField: - v.callsign.SetFocus() + if callsign != nil { + callsign.SetFocus() + } case core.BandField: - v.band.SetFocus() + if band != nil { + band.SetFocus() + } case core.ModeField: - v.mode.SetFocus() + if mode != nil { + mode.SetFocus() + } default: switch { case field.IsTheirExchange(): i := field.ExchangeIndex() - 1 - if i >= 0 && i < len(v.theirExchangeFields) { - v.theirExchangeFields[i].SetFocus() + if i >= 0 && i < len(theirExchange) { + theirExchange[i].SetFocus() } case field.IsMyExchange(): i := field.ExchangeIndex() - 1 @@ -400,8 +571,8 @@ func (v *entryView) SetActiveField(field core.EntryField) { } } -func (v *entryView) SelectText(field core.EntryField, s string) { - edit := v.fieldToEntry(field) +func (v *entryView) SelectText(vfo core.VFOID, field core.EntryField, s string) { + edit := v.fieldToEntry(vfo, field) if edit == nil { return } @@ -413,10 +584,12 @@ func (v *entryView) SelectText(field core.EntryField, s string) { edit.SetSelection(index, len(s)) } -func (v *entryView) fieldToEntry(field core.EntryField) *qtlib.QLineEdit { +func (v *entryView) fieldToEntry(vfo core.VFOID, field core.EntryField) *qtlib.QLineEdit { + callsign := v.vfo[vfo].callsign + theirExchange := v.vfo[vfo].theirExchangeFields switch field { case core.CallsignField, core.OtherField: - return v.callsign + return callsign } switch { case field.IsMyExchange(): @@ -426,19 +599,26 @@ func (v *entryView) fieldToEntry(field core.EntryField) *qtlib.QLineEdit { } case field.IsTheirExchange(): i := field.ExchangeIndex() - 1 - if i >= 0 && i < len(v.theirExchangeFields) { - return v.theirExchangeFields[i] + if i >= 0 && i < len(theirExchange) { + return theirExchange[i] } } return nil } -func (v *entryView) SetDuplicateMarker(duplicate bool) { +func (v *entryView) SetDuplicateMarker(vfo core.VFOID, duplicate bool) { + // TODO step 6 follow-up: per-VFO duplicate marker. For now, only VFO1 drives the root style. + if vfo != core.VFO1 { + return + } v.isDuplicate = duplicate v.updateMarkerStyle() } -func (v *entryView) SetEditingMarker(editing bool) { +func (v *entryView) SetEditingMarker(vfo core.VFOID, editing bool) { + if vfo != core.VFO1 { + return + } v.isEditing = editing v.updateMarkerStyle() } @@ -460,10 +640,69 @@ func (v *entryView) updateMarkerStyle() { } } -func (v *entryView) ShowMessage(args ...any) { - v.messageLabel.SetText(fmt.Sprint(args...)) +func (v *entryView) ShowMessage(vfo core.VFOID, args ...any) { + widget := v.vfo[vfo].messageLabel + if widget == nil { + return + } + widget.SetText(fmt.Sprint(args...)) +} + +func (v *entryView) ClearMessage(vfo core.VFOID) { + widget := v.vfo[vfo].messageLabel + if widget == nil { + return + } + widget.SetText("") } -func (v *entryView) ClearMessage() { - v.messageLabel.SetText("") +// SetVFOEnabled toggles the visibility/enabled state of a VFO's row of widgets. +// VFO1 is always enabled. VFO2 widgets are shown/hidden as a group. +// If onVFO2Enabled is set, it delegates to centralArea for layout add/remove first. +func (v *entryView) SetVFOEnabled(vfo core.VFOID, enabled bool) { + if vfo == core.VFO1 { + return + } + if v.vfo2Enabled == enabled { + return + } + if v.onVFO2Enabled != nil { + v.onVFO2Enabled(enabled) + return + } + v.setVFO2Enabled(enabled) +} + +func (v *entryView) setVFO2Enabled(enabled bool) { + v.vfo2Enabled = enabled + widgets := v.vfo[core.VFO2] + if widgets.vfoContainer != nil { + widgets.vfoContainer.SetVisible(enabled) + } + if widgets.xit != nil { + widgets.xit.SetVisible(enabled) + } + if widgets.txIndicator != nil { + widgets.txIndicator.SetVisible(enabled) + } + if widgets.serialClaimLabel != nil { + widgets.serialClaimLabel.SetVisible(enabled) + } + if widgets.callsign != nil { + widgets.callsign.SetVisible(enabled) + widgets.callsign.SetEnabled(enabled) + } + for _, f := range widgets.theirExchangeFields { + f.SetVisible(enabled) + f.SetEnabled(enabled) + } + if widgets.logButton != nil { + widgets.logButton.SetVisible(enabled) + } + if widgets.clearButton != nil { + widgets.clearButton.SetVisible(enabled) + } + if widgets.messageLabel != nil { + widgets.messageLabel.SetVisible(enabled) + } } diff --git a/ui/keyerButtonView.go b/ui/keyerButtonView.go index 7bac468c..11a2e265 100644 --- a/ui/keyerButtonView.go +++ b/ui/keyerButtonView.go @@ -7,7 +7,7 @@ import ( ) type KeyerController interface { - Send(int) + SendMacro(int) Stop() EnterSpeed(int) Save() @@ -52,7 +52,7 @@ func newKeyerView() *keyerView { if v.controller == nil { return } - v.controller.Send(idx) + v.controller.SendMacro(idx) }) } diff --git a/ui/qtcDialog.go b/ui/qtcDialog.go index b0d99798..9364e006 100644 --- a/ui/qtcDialog.go +++ b/ui/qtcDialog.go @@ -38,7 +38,7 @@ func (d *qtcDialog) QuestionQTCCount(max int) (int, bool) { return max, true } -func (d *qtcDialog) Show(mode core.QTCMode, series core.QTCSeries) { +func (d *qtcDialog) Show(mode core.QTCMode, series core.QTCSeries, vfoName string) { d.completed = false d.dialog = qtlib.NewQDialog(d.parent.QWidget) @@ -55,7 +55,7 @@ func (d *qtcDialog) Show(mode core.QTCMode, series core.QTCSeries) { d.stopKeyHandler = newStopKeyHandler(d.dialog.QWidget) d.stopKeyHandler.SetStopKeyController(d.controller) - d.view = newQTCView(d.controller, mode) + d.view = newQTCView(d.controller, mode, vfoName) root := qtlib.NewQVBoxLayout(d.dialog.QWidget) root.AddWidget(d.view.root) diff --git a/ui/qtcView.go b/ui/qtcView.go index 703c0322..b7ad7784 100644 --- a/ui/qtcView.go +++ b/ui/qtcView.go @@ -44,6 +44,7 @@ type qtcView struct { root *qtlib.QWidget controller QTCController mode core.QTCMode + vfoName string theirCallLabel *qtlib.QLabel theirCallMessage string @@ -67,8 +68,8 @@ type qtcView struct { qtcs []core.QTC } -func newQTCView(controller QTCController, mode core.QTCMode) *qtcView { - v := &qtcView{controller: controller, mode: mode} +func newQTCView(controller QTCController, mode core.QTCMode, vfoName string) *qtcView { + v := &qtcView{controller: controller, mode: mode, vfoName: vfoName} var sendText string var modeText string @@ -208,6 +209,9 @@ func (v *qtcView) setHeader(theirCall core.Callsign, header core.QTCHeader) { } else { format = "Receiving QTCs from %s" } + if v.vfoName != "" { + format += " on " + v.vfoName + } v.theirCallMessage = fmt.Sprintf(format, theirCall.String()) v.theirCallLabel.SetText(v.theirCallMessage) v.theirCallLabel.SetStyleSheet("") diff --git a/ui/settingsView.go b/ui/settingsView.go index f3d60633..34239ad4 100644 --- a/ui/settingsView.go +++ b/ui/settingsView.go @@ -68,8 +68,8 @@ type settingsView struct { contestStartTime *qtlib.QLineEdit startTimeTodayBtn *qtlib.QPushButton startTimeNowBtn *qtlib.QPushButton - sprintMode *qtlib.QCheckBox - enableQTCs *qtlib.QCheckBox + sprintMode *qtlib.QCheckBox + enableQTCs *qtlib.QCheckBox exchangeGrid *qtlib.QGridLayout exchangeRows []exchangeRow @@ -243,7 +243,7 @@ func (v *settingsView) buildCallHistoryGroup() *qtlib.QGroupBox { v.callHistoryBrowseBtn = qtlib.NewQPushButton3("Browse…") v.callHistoryBrowseBtn.OnClicked(func() { - dlg := qtlib.NewQFileDialog4(v.dialog.QWidget, "Select Call History File") + dlg := qtlib.NewQFileDialog4(nil, "Select Call History File") dlg.SetAcceptMode(qtlib.QFileDialog__AcceptOpen) dlg.SetFileMode(qtlib.QFileDialog__ExistingFile) dlg.SetNameFilter("Call history files (*.txt *.csv);;All Files (*)") diff --git a/ui/style.go b/ui/style.go index 0bbee6ef..ff4df8b3 100644 --- a/ui/style.go +++ b/ui/style.go @@ -16,8 +16,11 @@ const ( EntryDuplicateStyle = "QWidget#entryWidget { background-color: palette(window); border: 4px solid red; }" EntryEditingStyle = "QWidget#entryWidget { background-color: palette(window); border: 4px solid palette(accent); }" EntryNormalStyle = "QWidget#entryWidget { background-color: palette(window); }" + EntryFieldStyle = "QLineEdit { font-size: 24pt; font-family: monospace; }" TXIndicatorActiveStyle = "QLabel { color: red; font-weight: bold; }" TXIndicatorInactiveStyle = "" + VFOActiveStyle = "QLabel { padding-left: 5px; padding-right: 5px; background-color: palette(highlight); color: palette(highlighted-text); border-radius: 10px; }" + VFOInactiveStyle = "QLabel { padding-left: 6px; padding-right: 6px; }" QTCPhaseActiveStyle = "font-weight: bold; color: #1a65b1;" QTCPhaseInactiveStyle = "font-weight: bold;"