package cat // Native Kenwood CAT — TS-590, TS-890, TS-2000 and the many rigs that speak the // same dialect (Elecraft K3/K4, and the "Kenwood/Elecraft" setting on Flex and // SunSDR). Plain ASCII, every command terminated by ';', same shape as Yaesu but // a different vocabulary. // // The dialect is already proven in this repository from the other side: // internal/catemu ANSWERS these commands, pretending to be a TS-2000 so an ACOM // amplifier follows OpsLog. The frame layouts here and there are the same ones. // // Commands used: // // FA; → FA00014025000; VFO A frequency, ELEVEN digits, Hz // FB; → FB00014030000; VFO B frequency // IF; → 38-char status frame: frequency, RX/TX, mode, VFO, split — // the whole operating state in ONE round trip, which is why it // is the poll rather than asking four separate questions. // MD; → MD3; mode (1=LSB 2=USB 3=CW 4=FM 5=AM 6=FSK // 7=CW-R 9=FSK-R) // FR0;/FR1; receive VFO — 0 = A, 1 = B // FT0;/FT1; transmit VFO (split = the two differ) // TX;/RX; key / unkey // ID; → ID020; model number // AI0; silence unsolicited status reports // // Why not OmniRig: the same reason the Yaesu backend exists. Every Kenwood // fault reported through OmniRig came from its interpretation layer rather than // from the radio, and the rig file decides what a "Freq" property means. import ( "fmt" "strconv" "strings" "sync" "time" "go.bug.st/serial" ) // Kenwood is the native backend. One serial port, one mutex: a command and its // reply are never interleaved with another exchange. type Kenwood struct { portName string baud int digital string // mode name logged for data (FT8 by default) 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 curVFO string // "A" or "B" // Commands this rig answered "?;" to — asked once, then never again. unsupported map[string]bool } func NewKenwood(portName string, baud int, digital string) *Kenwood { if baud <= 0 { baud = 9600 // TS-590 factory default; TS-890 ships at 115200 } if strings.TrimSpace(digital) == "" { digital = "FT8" } return &Kenwood{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"} } func (k *Kenwood) Name() string { return "kenwood" } func (k *Kenwood) Connect() error { k.mu.Lock() defer k.mu.Unlock() if k.portName == "" { return fmt.Errorf("kenwood: no serial port configured") } // Close any handle still held before opening another: Connect runs again on // every reconnect, and Windows opens a serial port exclusively, so a leaked // handle makes the next open fail with "port busy" (the fault found in the // Yaesu backend — same shape here). if k.port != nil { _ = k.port.Close() k.port = nil } p, err := k.openPort() if err != nil { return fmt.Errorf("kenwood: open %s @ %d baud: %w", k.portName, k.baud, err) } p.SetReadTimeout(300 * time.Millisecond) k.port = p k.unsupported = map[string]bool{} // Silence unsolicited status reports: they interleave with our // request/response pairs and make a reply impossible to attribute. We poll. _ = k.write("AI0;") answered := false if id, err := k.ask("ID;"); err == nil && strings.HasPrefix(id, "ID") { answered = true code := strings.TrimSuffix(strings.TrimPrefix(id, "ID"), ";") if name, ok := kenwoodModels[code]; ok { k.model = name } else { k.model = "Kenwood (" + code + ")" debugLog.Printf("kenwood: unknown model id %q — add it to kenwoodModels", code) } } // IF is the command everything else depends on, so it is also the honest // test of whether a radio is really there. if r, err := k.ask("IF;"); err == nil && strings.HasPrefix(r, "IF") { answered = true } if !answered { k.model = "" return fmt.Errorf("kenwood: %s opened but the rig is not answering — check that it is switched on and set to %d baud", k.portName, k.baud) } debugLog.Printf("kenwood: connected on %s @ %d baud, model=%q", k.portName, k.baud, k.model) return nil } func (k *Kenwood) Disconnect() { k.mu.Lock() defer k.mu.Unlock() if k.port != nil { _ = k.port.Close() k.port = nil } } // ReadState polls the rig. IF carries frequency, mode, VFO, split and TX state // in one frame; the other VFO is only asked for when split is actually on, so // the common simplex case costs a single round trip. func (k *Kenwood) ReadState() (RigState, error) { k.mu.Lock() defer k.mu.Unlock() if k.port == nil { return RigState{}, fmt.Errorf("kenwood: not connected") } raw, err := k.ask("IF;") if err != nil { return RigState{}, err } f, ok := parseKenwoodIF(raw) if !ok { return RigState{}, fmt.Errorf("kenwood: unparsable IF frame %q", raw) } s := RigState{Connected: true, Backend: "kenwood"} k.curVFO = f.VFO s.Vfo = f.VFO s.Mode = kenwoodModeToADIF(f.Mode, k.digital) s.Rig = k.model // IF reports the frequency of the VFO in USE (what the operator hears). rx := f.FreqHz tx := rx if f.Split { // The transmit VFO is the other one. Read it rather than assume, and fall // back to simplex if it cannot be read: a wrong TX frequency is written // into the log, which is worse than showing no split at all. other := "FB;" if f.VFO == "B" { other = "FA;" } if r, err := k.ask(other); err == nil { if hz, ok := parseKenwoodFreq(r, strings.TrimSuffix(other, ";")); ok && hz > 0 && hz != rx { tx = hz } } } if tx != rx { s.FreqHz = tx // ADIF: FREQ is the TRANSMIT frequency s.RxFreqHz = rx s.Split = true } else { s.FreqHz = rx } k.curFreq = s.FreqHz if s.Split { k.curRXFreq = s.RxFreqHz } return s, nil } // SetFrequency tunes the VFO the operator is actually on — writing FA blind is // what makes a display disagree with the radio when they are on B. func (k *Kenwood) SetFrequency(hz int64) error { k.mu.Lock() defer k.mu.Unlock() if k.port == nil { return fmt.Errorf("kenwood: not connected") } if hz <= 0 || hz > 99_999_999_999 { return fmt.Errorf("kenwood: frequency %d out of the 11-digit CAT range", hz) } cmd := "FA" if k.curVFO == "B" { cmd = "FB" } return k.write(fmt.Sprintf("%s%011d;", cmd, hz)) } func (k *Kenwood) SetMode(mode string) error { k.mu.Lock() defer k.mu.Unlock() if k.port == nil { return fmt.Errorf("kenwood: not connected") } d := kenwoodModeDigit(mode, k.curFreq) if d == 0 { return fmt.Errorf("kenwood: no CAT mode for %q", mode) } return k.write(fmt.Sprintf("MD%c;", d)) } func (k *Kenwood) SetPTT(on bool) error { k.mu.Lock() defer k.mu.Unlock() if k.port == nil { return fmt.Errorf("kenwood: not connected") } if on { return k.write("TX;") } return k.write("RX;") } func (k *Kenwood) write(cmd string) error { if k.port == nil { return fmt.Errorf("kenwood: not connected") } _, err := k.port.Write([]byte(cmd)) return err } // ask sends a query and returns the reply belonging to THAT command. Anything // else on the wire is discarded: a stray frame parsed as a frequency reads as // "lost the rig" to the Manager, which then reconnects — the CAT link dropping // for no reason (found the hard way on the Yaesu backend). func (k *Kenwood) ask(cmd string) (string, error) { want := cmdPrefix(cmd) if k.unsupported[want] { return "", fmt.Errorf("kenwood: %s is not supported by this rig", want) } if err := k.write(cmd); err != nil { return "", err } buf := make([]byte, 0, 64) tmp := make([]byte, 64) deadline := time.Now().Add(600 * time.Millisecond) for time.Now().Before(deadline) { n, err := k.port.Read(tmp) if err != nil { return "", err } if n == 0 { continue // read timeout — the rig may still be composing its answer } buf = append(buf, tmp[:n]...) for { i := strings.IndexByte(string(buf), ';') if i < 0 { break } frame := string(buf[:i+1]) buf = buf[i+1:] if frame == "?;" { // The rig rejected the command. Remember it so the poll loop stops // paying a 600 ms timeout for it on every cycle. k.unsupported[want] = true debugLog.Printf("kenwood: this rig does not support %q — not asking again", cmd) return "", fmt.Errorf("kenwood: %s rejected", want) } if strings.HasPrefix(frame, want) { return frame, nil } debugLog.Printf("kenwood: discarding %q while waiting for %s", frame, want) } } return "", fmt.Errorf("kenwood: timeout answering %q", cmd) } // ── Frame parsing ────────────────────────────────────────────────────────── // kenwoodIF is what the 38-character IF status frame carries. type kenwoodIF struct { FreqHz int64 Mode byte VFO string // "A" or "B" Split bool // TX is parsed even though RigState has no PTT field: it is one character of // the same frame, and having it here means a future "transmitting" indicator // costs no extra round trip. TX bool } // parseKenwoodIF reads the TS-2000/TS-590 status frame. Its layout — the same // one internal/catemu emits — is: // // IF | freq(11) | step(4) | RIT(±5) | RIT/XIT/bank(3) | mem(2) | rx-tx(1) | // mode(1) | VFO(1) | scan(1) | split(1) | tone(1) | tone#(2) | shift(1) | ; // // Fields are read by POSITION, so the length is checked first: a short frame // means a truncated read, and indexing into it would panic or, worse, silently // yield a wrong frequency. func parseKenwoodIF(reply string) (kenwoodIF, bool) { r := strings.TrimSpace(reply) if !strings.HasPrefix(r, "IF") || len(r) < 38 { return kenwoodIF{}, false } hz, err := strconv.ParseInt(strings.TrimSpace(r[2:13]), 10, 64) if err != nil || hz <= 0 { return kenwoodIF{}, false } out := kenwoodIF{FreqHz: hz, Mode: r[29], VFO: "A", TX: r[28] == '1', Split: r[32] == '1'} if r[30] == '1' { out.VFO = "B" } return out, true } // parseKenwoodFreq reads an FA/FB reply — ELEVEN digits on Kenwood, where Yaesu // uses nine. The prefix is checked so an FB reply is never accepted as FA. func parseKenwoodFreq(reply, prefix string) (int64, bool) { r := strings.TrimSpace(reply) if !strings.HasPrefix(r, prefix) { return 0, false } digits := strings.TrimSuffix(strings.TrimPrefix(r, prefix), ";") if digits == "" { return 0, false } hz, err := strconv.ParseInt(digits, 10, 64) if err != nil || hz <= 0 { return 0, false } return hz, true } // ── Modes ────────────────────────────────────────────────────────────────── // kenwoodModeDigit maps an ADIF mode to the Kenwood digit. The sideband follows // the frequency by worldwide convention — a backend that puts USB on 40 m makes // every SSB QSO in the log wrong. func kenwoodModeDigit(mode string, hz int64) byte { m := strings.ToUpper(strings.TrimSpace(mode)) switch m { case "": return 0 case "LSB": return '1' case "USB": return '2' case "CW": return '3' case "CW-R", "CWR": return '7' case "FM": return '4' case "AM": return '5' case "RTTY", "FSK": return '6' case "RTTY-R", "FSK-R": return '9' case "SSB": if hz > 0 && hz < 10_000_000 { return '1' } return '2' } // Any other digital mode rides on the data sideband, which on Kenwood is // plain USB/LSB with the rig's DATA input selected. if hz > 0 && hz < 10_000_000 { return '1' } return '2' } // kenwoodModeToADIF turns the rig's mode digit into what the log records. // Digital modes are indistinguishable from SSB over CAT — the rig only knows // it is on USB — so the operator's configured digital mode is used, exactly as // the other backends do. func kenwoodModeToADIF(d byte, digital string) string { switch d { case '1': return "LSB" case '2': return "USB" case '3', '7': return "CW" case '4': return "FM" case '5': return "AM" case '6', '9': return "RTTY" } return "" } // kenwoodModels maps the ID reply to a name for the status bar. An unknown code // is shown as-is rather than refused: the model name is cosmetic, and a rig that // answers everything else must not be rejected over it. var kenwoodModels = map[string]string{ "017": "TS-570", "019": "TS-2000", "020": "TS-480", "021": "TS-590S", "023": "TS-590SG", "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}) }