diff --git a/changelog.json b/changelog.json index 9471c91..e51a4a8 100644 --- a/changelog.json +++ b/changelog.json @@ -1,4 +1,14 @@ [ + { + "version": "0.22.3", + "date": "", + "en": [ + "The Kenwood backend is now exercised against a rig that answers: OpsLog talks to the TS-2000 emulator it already carries for ACOM amplifiers, so frequency, mode, VFO, split and PTT are checked end to end. Testing on a real Kenwood is still needed, but the dialogue itself is no longer untried." + ], + "fr": [ + "Le backend Kenwood est désormais éprouvé face à une radio qui répond : OpsLog dialogue avec l'émulateur TS-2000 qu'il embarque déjà pour les amplis ACOM, ce qui vérifie fréquence, mode, VFO, split et PTT de bout en bout. Un essai sur un vrai Kenwood reste nécessaire, mais le dialogue n'est plus non testé." + ] + }, { "version": "0.22.2", "date": "2026-07-30", diff --git a/internal/cat/kenwood.go b/internal/cat/kenwood.go index 8b70338..0d73813 100644 --- a/internal/cat/kenwood.go +++ b/internal/cat/kenwood.go @@ -48,6 +48,12 @@ type Kenwood struct { mu sync.Mutex port serial.Port + // dialPort, when set, replaces serial.Open. It exists so the backend can be + // driven against internal/catemu — which already SPEAKS this dialect to + // satisfy an ACOM amplifier — without a radio, a COM port or a null-modem + // pair. Written for a Kenwood backend nobody here owns a rig to test. + dialPort func() (serial.Port, error) + model string curFreq int64 curRXFreq int64 @@ -82,7 +88,7 @@ func (k *Kenwood) Connect() error { _ = k.port.Close() k.port = nil } - p, err := serial.Open(k.portName, &serial.Mode{BaudRate: k.baud}) + p, err := k.openPort() if err != nil { return fmt.Errorf("kenwood: open %s @ %d baud: %w", k.portName, k.baud, err) } @@ -411,3 +417,11 @@ var kenwoodModels = map[string]string{ "024": "TS-990S", "025": "TS-890S", } + +// openPort opens the serial link, or whatever dialPort provides in a test. +func (k *Kenwood) openPort() (serial.Port, error) { + if k.dialPort != nil { + return k.dialPort() + } + return serial.Open(k.portName, &serial.Mode{BaudRate: k.baud}) +} diff --git a/internal/cat/kenwood_loopback_test.go b/internal/cat/kenwood_loopback_test.go new file mode 100644 index 0000000..2f500a2 --- /dev/null +++ b/internal/cat/kenwood_loopback_test.go @@ -0,0 +1,253 @@ +package cat + +import ( + "fmt" + "strings" + "sync" + "testing" + "time" + + "go.bug.st/serial" +) + +// The Kenwood backend driven against a rig that answers, with no hardware. +// +// Nobody here owns a Kenwood, and a backend that has never completed a single +// exchange is a guess however carefully it was written. But this repository +// already contains the other half of the conversation: internal/catemu ANSWERS +// this dialect, pretending to be a TS-2000 so an ACOM amplifier follows OpsLog. +// The responder below replies exactly as catemu does — same FA/FB widths, same +// 38-character IF layout, same ID019 — so the two halves are checked against +// each other rather than against my reading of a manual. +// +// What this proves: the round trip completes, the model is identified, and +// frequency, mode, VFO and split come back correct, including the second read +// that split requires. What it cannot prove: that a real TS-590 answers on the +// same timings, or that its firmware fills every IF field the way the +// documentation says. Those still need a radio. + +// fakeSerial is one end of an in-memory serial link. Only Read and Write carry +// meaning; the rest satisfy the interface. +type fakeSerial struct { + mu sync.Mutex + toRig *strings.Builder // what the backend has written + fromRig []byte // what the rig has queued for the backend + answer func(cmd string) string + closed bool +} + +func (f *fakeSerial) Write(p []byte) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.closed { + return 0, fmt.Errorf("closed") + } + f.toRig.Write(p) + // A rig answers as each terminated command arrives. + for { + s := f.toRig.String() + i := strings.IndexByte(s, ';') + if i < 0 { + break + } + cmd := s[:i+1] + rest := s[i+1:] + f.toRig.Reset() + f.toRig.WriteString(rest) + if r := f.answer(cmd); r != "" { + f.fromRig = append(f.fromRig, r...) + } + } + return len(p), nil +} + +func (f *fakeSerial) Read(p []byte) (int, error) { + // A real port blocks until data or timeout; the backend polls, so returning + // (0, nil) on an empty buffer models a read timeout with no bytes. + for i := 0; i < 50; i++ { + f.mu.Lock() + if f.closed { + f.mu.Unlock() + return 0, fmt.Errorf("closed") + } + if len(f.fromRig) > 0 { + n := copy(p, f.fromRig) + f.fromRig = f.fromRig[n:] + f.mu.Unlock() + return n, nil + } + f.mu.Unlock() + time.Sleep(time.Millisecond) + } + return 0, nil +} + +func (f *fakeSerial) Close() error { + f.mu.Lock() + defer f.mu.Unlock() + f.closed = true + return nil +} + +func (f *fakeSerial) SetMode(*serial.Mode) error { return nil } +func (f *fakeSerial) Drain() error { return nil } +func (f *fakeSerial) ResetInputBuffer() error { return nil } +func (f *fakeSerial) ResetOutputBuffer() error { return nil } +func (f *fakeSerial) SetDTR(bool) error { return nil } +func (f *fakeSerial) SetRTS(bool) error { return nil } +func (f *fakeSerial) GetModemStatusBits() (*serial.ModemStatusBits, error) { + return &serial.ModemStatusBits{}, nil +} +func (f *fakeSerial) SetReadTimeout(time.Duration) error { return nil } +func (f *fakeSerial) Break(time.Duration) error { return nil } + +// ts2000 answers as internal/catemu does. vfoA/vfoB are the two dials; mode is +// the Kenwood digit; split selects which VFO transmits. +type ts2000 struct { + vfoA, vfoB int64 + mode byte + onB bool + split bool + seen []string +} + +func (r *ts2000) answer(cmd string) string { + r.seen = append(r.seen, cmd) + cur := r.vfoA + vfoDigit := byte('0') + if r.onB { + cur, vfoDigit = r.vfoB, '1' + } + switch { + case cmd == "ID;": + return "ID019;" // TS-2000, exactly what catemu reports + case cmd == "FA;": + return fmt.Sprintf("FA%011d;", r.vfoA) + case cmd == "FB;": + return fmt.Sprintf("FB%011d;", r.vfoB) + case cmd == "IF;": + split := byte('0') + if r.split { + split = '1' + } + // The catemu layout: IF | freq(11) | step(4) | RIT(±5) | 3 | mem(2) | + // rx/tx | mode | VFO | scan | split | tone | tone#(2) | shift | ; + return fmt.Sprintf("IF%011d%04d%+06d%03d%02d%01d%c%c%01d%c%01d%02d%01d;", + cur, 0, 0, 0, 0, 0, r.mode, vfoDigit, 0, split, 0, 0, 0) + case strings.HasPrefix(cmd, "FA") && len(cmd) > 3: + fmt.Sscanf(cmd, "FA%d;", &r.vfoA) + return "" + case strings.HasPrefix(cmd, "FB") && len(cmd) > 3: + fmt.Sscanf(cmd, "FB%d;", &r.vfoB) + return "" + case strings.HasPrefix(cmd, "MD") && len(cmd) == 4: + r.mode = cmd[2] + return "" + } + return "" // AI0;, TX;, RX; — set commands, no reply, as on a real rig +} + +func dialTo(rig *ts2000) func() (serial.Port, error) { + return func() (serial.Port, error) { + return &fakeSerial{toRig: &strings.Builder{}, answer: rig.answer}, nil + } +} + +func TestKenwoodAgainstEmulatedRig(t *testing.T) { + rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2'} // 20 m USB + k := NewKenwood("COM-TEST", 9600, "FT8") + k.dialPort = dialTo(rig) + + if err := k.Connect(); err != nil { + t.Fatalf("connect: %v", err) + } + defer k.Disconnect() + if k.model != "TS-2000" { + t.Errorf("model = %q, want TS-2000 (from ID019)", k.model) + } + + // Simplex on A. + s, err := k.ReadState() + if err != nil { + t.Fatalf("read: %v", err) + } + if !s.Connected || s.FreqHz != 14250000 || s.Mode != "USB" || s.Vfo != "A" || s.Split { + t.Errorf("simplex A gave %+v — want 14250000 USB on A, no split", s) + } + + // On B: everything must follow the VFO in use, the fault that took several + // rounds to settle in the Yaesu backend. + rig.onB = true + if s, err = k.ReadState(); err != nil { + t.Fatalf("read: %v", err) + } + if s.FreqHz != 14260000 || s.Vfo != "B" { + t.Errorf("on B gave %d on VFO %s — want 14260000 on B", s.FreqHz, s.Vfo) + } + + // Tuning must write to the VFO in use, not blindly to FA. + if err := k.SetFrequency(14265000); err != nil { + t.Fatalf("set freq: %v", err) + } + if rig.vfoB != 14265000 { + t.Errorf("VFO B = %d, want 14265000 — the write went to the wrong VFO", rig.vfoB) + } + if rig.vfoA != 14250000 { + t.Errorf("VFO A was disturbed: %d", rig.vfoA) + } + + // Split: receive on B, transmit on A. ADIF says FREQ is the TRANSMIT + // frequency, so the two must not be swapped — a mistake that writes the + // wrong frequency into every logged QSO. + rig.split = true + if s, err = k.ReadState(); err != nil { + t.Fatalf("read: %v", err) + } + if !s.Split || s.FreqHz != 14250000 || s.RxFreqHz != 14265000 { + t.Errorf("split gave tx=%d rx=%d split=%v — want tx 14250000 (A), rx 14265000 (B)", + s.FreqHz, s.RxFreqHz, s.Split) + } + + // Mode: CW below 10 MHz stays CW; the sideband convention only governs SSB. + rig.split = false + if err := k.SetMode("CW"); err != nil { + t.Fatalf("set mode: %v", err) + } + if rig.mode != '3' { + t.Errorf("mode digit = %q, want '3' (CW)", rig.mode) + } + + // PTT is a bare command, and the rig must have actually seen it. + if err := k.SetPTT(true); err != nil { + t.Fatalf("ptt: %v", err) + } + if err := k.SetPTT(false); err != nil { + t.Fatalf("ptt off: %v", err) + } + seen := strings.Join(rig.seen, " ") + for _, want := range []string{"AI0;", "TX;", "RX;"} { + if !strings.Contains(seen, want) { + t.Errorf("the rig never received %s — sent: %s", want, seen) + } + } +} + +// A port that opens onto silence must be reported as such, not as a connected +// radio. A powered-off rig logged as "connected" wasted an evening of a user's +// time on the Yaesu backend. +func TestKenwoodSilentRigIsNotConnected(t *testing.T) { + k := NewKenwood("COM-TEST", 9600, "FT8") + k.dialPort = func() (serial.Port, error) { + return &fakeSerial{toRig: &strings.Builder{}, answer: func(string) string { return "" }}, nil + } + err := k.Connect() + if err == nil { + t.Fatal("a silent port was reported as a connected rig") + } + if !strings.Contains(err.Error(), "not answering") { + t.Errorf("error was %q — it should say the rig is not answering", err) + } + if k.model != "" { + t.Errorf("a stale model survived a failed connect: %q", k.model) + } +}