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) } }