diff --git a/internal/cat/yaesu.go b/internal/cat/yaesu.go index b1cb52e..062e07f 100644 --- a/internal/cat/yaesu.go +++ b/internal/cat/yaesu.go @@ -36,6 +36,7 @@ package cat // read as a hypothesis with a log line attached. import ( + "errors" "fmt" "strconv" "strings" @@ -101,6 +102,8 @@ type Yaesu struct { panel YaesuTXState panelCycle int panelLoaded bool + // Commands this rig answered "?;" to — asked once, then never again. + unsupported map[string]bool } func NewYaesu(portName string, baud int, digital string) *Yaesu { @@ -324,6 +327,13 @@ func (y *Yaesu) ask(cmd string) (string, error) { } frame := string(buf[:i+1]) buf = buf[i+1:] + // "?;" is the rig saying it does not know this command. Confirmed on an + // FTDX10, which answers it to KY; and MG;. Reporting it as such — rather + // than discarding it and waiting out the timeout — is what lets callers + // stop asking instead of paying 600 ms per poll for ever. + if strings.TrimSpace(frame) == "?;" { + return "", errYaesuUnsupported + } if want == "" || strings.HasPrefix(strings.ToUpper(frame), want) { return frame, nil } @@ -336,6 +346,11 @@ func (y *Yaesu) ask(cmd string) (string, error) { return "", fmt.Errorf("yaesu: timeout answering %q", cmd) } +// errYaesuUnsupported is returned when the rig answers "?;" — it does not know +// the command. Different models implement different subsets, and the only +// reliable way to learn which is to ask once and remember the refusal. +var errYaesuUnsupported = errors.New("yaesu: command not supported by this rig") + // cmdPrefix is the leading letters of a command — what its reply starts with. // "FA;" → "FA", "MD0;" → "MD", "KY;" → "KY". func cmdPrefix(cmd string) string { diff --git a/internal/cat/yaesu_cw.go b/internal/cat/yaesu_cw.go index c73c5ea..67175ea 100644 --- a/internal/cat/yaesu_cw.go +++ b/internal/cat/yaesu_cw.go @@ -18,6 +18,7 @@ package cat // Yaesu documents no way to clear the buffer, so see StopCW. import ( + "errors" "fmt" "strings" "time" @@ -62,10 +63,48 @@ func (y *Yaesu) SendCW(text string) error { if err != nil { return err } + // When the rig will not tell us how full its buffer is (an FTDX10 answers + // "?;" to KY;), pace the next piece by how long this one takes to key. + // Sending everything at once overruns the 24-character buffer and the rig + // silently drops the rest. + if len(msg) > 0 && y.cwStatusUnsupported() { + time.Sleep(yaesuCWDuration(chunk, y.keyerWPM())) + } } return nil } +// cwStatusUnsupported reports whether this rig refused the KY status query. +func (y *Yaesu) cwStatusUnsupported() bool { + y.mu.Lock() + defer y.mu.Unlock() + return y.unsupported["KY;"] +} + +// keyerWPM is the speed the rig is keying at, for pacing. Falls back to a +// middling 20 wpm when the rig does not report it: too slow only wastes a +// moment, too fast overruns the buffer. +func (y *Yaesu) keyerWPM() int { + y.mu.Lock() + defer y.mu.Unlock() + if y.panel.KeySpeed >= 4 { + return y.panel.KeySpeed + } + return 20 +} + +// yaesuCWDuration estimates how long a piece of text takes to key. +// +// PARIS timing: one word is 50 dit-lengths and a word is 5 characters, so a +// character averages 10 dits and a dit is 1.2/wpm seconds. +func yaesuCWDuration(text string, wpm int) time.Duration { + if wpm < 4 { + wpm = 20 + } + ditMs := 1200.0 / float64(wpm) + return time.Duration(float64(len(text))*10*ditMs) * time.Millisecond +} + // StopCW aborts the message being sent. // // Yaesu documents no buffer-clear, so this drops the transmitter instead: TX0 @@ -95,12 +134,25 @@ func (y *Yaesu) waitCWBuffer(within time.Duration) error { y.mu.Unlock() return fmt.Errorf("yaesu: not connected") } + if y.unsupported["KY;"] { + y.mu.Unlock() + return nil // this rig does not report buffer state — pacing covers it + } r, err := y.ask("KY;") - y.mu.Unlock() if err != nil { - debugLog.Printf("yaesu cw: KY status query failed (%v) — sending anyway", err) + if errors.Is(err, errYaesuUnsupported) { + if y.unsupported == nil { + y.unsupported = map[string]bool{} + } + y.unsupported["KY;"] = true + debugLog.Printf("yaesu cw: this rig does not answer KY; — pacing the send by keying time instead") + } else { + debugLog.Printf("yaesu cw: KY status query failed (%v) — sending anyway", err) + } + y.mu.Unlock() return nil } + y.mu.Unlock() // Only an explicit "full" holds us back. // // The first version required the reply to be exactly "KY0;" at byte 2, and diff --git a/internal/cat/yaesu_cw_test.go b/internal/cat/yaesu_cw_test.go index 50db9fb..6a9882e 100644 --- a/internal/cat/yaesu_cw_test.go +++ b/internal/cat/yaesu_cw_test.go @@ -3,6 +3,7 @@ package cat import ( "strings" "testing" + "time" ) // What reaches the rig's keyer. @@ -92,3 +93,30 @@ func TestYaesuCWBufferFull(t *testing.T) { } } } + +// Pacing when the rig will not report its buffer. +// +// An FTDX10 answers "?;" to KY;, so there is nothing to wait on and the send has +// to be spaced by how long the text takes to key — otherwise the second piece +// overruns the 24-character buffer and the rig drops it silently. +func TestYaesuCWDuration(t *testing.T) { + // PARIS: at 20 wpm a dit is 60 ms and a character averages 10 dits, so 24 + // characters take about 14.4 s. + got := yaesuCWDuration("ABCDEFGHIJKLMNOPQRSTUVWX", 20) + if got < 13*time.Second || got > 16*time.Second { + t.Errorf("24 chars at 20 wpm = %s, expected about 14.4s", got) + } + // Twice the speed, half the time. + fast := yaesuCWDuration("ABCDEFGHIJKLMNOPQRSTUVWX", 40) + if fast >= got || fast < got/3 { + t.Errorf("40 wpm = %s vs 20 wpm = %s — should be about half", fast, got) + } + // A nonsense speed must not produce a zero wait (send everything at once) nor + // an absurd one (the operator waiting minutes). + if d := yaesuCWDuration("TEST", 0); d <= 0 || d > 10*time.Second { + t.Errorf("4 chars at an unknown speed = %s, want a sane fallback", d) + } + if d := yaesuCWDuration("", 20); d != 0 { + t.Errorf("empty text = %s, want 0", d) + } +} diff --git a/internal/cat/yaesu_panel.go b/internal/cat/yaesu_panel.go index 71325a6..d11b8c0 100644 --- a/internal/cat/yaesu_panel.go +++ b/internal/cat/yaesu_panel.go @@ -17,6 +17,7 @@ package cat // rather than showing a zero that reads as "it reset itself". import ( + "errors" "fmt" "strconv" "strings" @@ -199,8 +200,21 @@ func (y *Yaesu) readPanelSettings() { // this model lacks then keeps its previous value instead of dropping to zero, // which would look like a setting that reset itself. func (y *Yaesu) askNum(cmd, prefix string, digits int) (int, bool) { + // A command this model refused once is never asked again. Models implement + // different subsets — an FTDX10 answers "?;" to MG; — and re-asking costs a + // 600 ms timeout on every slow beat, for a control that will never answer. + if y.unsupported[cmd] { + return 0, false + } r, err := y.ask(cmd) if err != nil { + if errors.Is(err, errYaesuUnsupported) { + if y.unsupported == nil { + y.unsupported = map[string]bool{} + } + y.unsupported[cmd] = true + debugLog.Printf("yaesu: this rig does not support %q — not asking again", cmd) + } return 0, false } if !strings.HasPrefix(r, prefix) || len(r) < len(prefix)+digits {