The same ASCII stream over a socket instead of a wire: ser2net, an
Ethernet-serial adapter, a Raspberry Pi at the radio. One TCP transport
presented as a serial port, so the backend keeps a single code path.
Explicitly NOT the radio's own RJ45. A TS-890 or TS-990 speaks Kenwood's
KNS/ARCP there — session, authentication, a different protocol — and that needs
one of those radios in hand to write honestly. The setting's help text says so,
because an operator who plugs in their TS-890's Ethernet port and types its
address deserves to learn that from the interface rather than from silence.
Two details that decide whether this is usable or maddening:
- A read deadline expiring is reported as "no data yet", not as an error. It
is exactly what a serial read timeout means to the caller; as an error it
would make ask() abandon a rig that is merely thinking.
- The log line names what was connected to. "connected on @ 0 baud" after a
network connect sends someone hunting a serial fault that does not exist.
The host wins over the COM port when both are filled: it is the more deliberate
setting, and silently preferring the wire leaves someone staring at an address
they typed and a radio that never answers.
563 lines
18 KiB
Go
563 lines
18 KiB
Go
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 (
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"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
|
|
// host is "address:port" for a serial link reached over the network — a
|
|
// ser2net daemon, an Ethernet-serial adapter, a Raspberry Pi in the shack.
|
|
//
|
|
// This is NOT Kenwood's own network protocol. A TS-890 or TS-990 speaks
|
|
// KNS/ARCP over its Ethernet socket, with a session and authentication, and
|
|
// that is a different piece of work needing one of those radios to confirm
|
|
// it. What this covers is the same CAT byte stream over a socket instead of
|
|
// a wire, which is how most operators actually put a rig on the network.
|
|
host string
|
|
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
|
|
}
|
|
|
|
// NewKenwoodTCP builds a backend that reaches the rig over a socket instead of
|
|
// a COM port (ser2net and friends).
|
|
func NewKenwoodTCP(hostPort, digital string) *Kenwood {
|
|
k := NewKenwood("", 0, digital)
|
|
k.host = strings.TrimSpace(hostPort)
|
|
return k
|
|
}
|
|
|
|
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 == "" && k.host == "" {
|
|
return fmt.Errorf("kenwood: no serial port or network address 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 {
|
|
if k.host != "" {
|
|
return fmt.Errorf("kenwood: connect %s: %w", k.host, err)
|
|
}
|
|
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 = ""
|
|
if k.host != "" {
|
|
return fmt.Errorf("kenwood: %s accepted the connection but the rig is not answering — check that the serial bridge points at the radio and that the radio is switched on", k.host)
|
|
}
|
|
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)
|
|
}
|
|
// Name what was actually connected to. A log reading "connected on @ 0 baud"
|
|
// after a network connect is the kind of line that sends someone hunting a
|
|
// serial fault that does not exist.
|
|
if k.host != "" {
|
|
debugLog.Printf("kenwood: connected to %s (network serial bridge), model=%q", k.host, k.model)
|
|
} else {
|
|
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's split bit is not filled in by every rig that speaks this dialect —
|
|
// reported on a Flex through its Kenwood CAT emulation, where the frequency
|
|
// reads perfectly and split never appears. So ask the question directly as
|
|
// well: split IS "the transmit VFO differs from the receive VFO", which is
|
|
// what FR/FT answer, and it is the same rule the Yaesu backend settled on.
|
|
//
|
|
// A rig that rejects FR/FT answers "?;" once and is never asked again, so
|
|
// this costs two short commands per poll only where it actually works.
|
|
split := f.Split
|
|
if !split {
|
|
rxv, rxOK := k.askVFO("FR;")
|
|
txv, txOK := k.askVFO("FT;")
|
|
if rxOK && txOK && rxv != txv {
|
|
split = true
|
|
// Trust FR over IF for which VFO is in use: they were asked in the
|
|
// same breath, and a rig that leaves the split bit empty may be just
|
|
// as vague about the VFO field.
|
|
// f.VFO too, not just the reported state: the block below picks the
|
|
// TRANSMIT VFO as "the other one" from f.VFO, and leaving the two
|
|
// disagreeing would read the transmit frequency off the wrong dial.
|
|
if rxv == "A" || rxv == "B" {
|
|
k.curVFO, s.Vfo, f.VFO = rxv, rxv, rxv
|
|
}
|
|
}
|
|
}
|
|
f.Split = split
|
|
|
|
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")
|
|
}
|
|
traceText("kenwood", "TX", cmd)
|
|
_, 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:]
|
|
traceText("kenwood", "RX", frame)
|
|
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()
|
|
}
|
|
if k.host != "" {
|
|
c, err := net.DialTimeout("tcp", k.host, 5*time.Second)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &tcpSerial{conn: c}, nil
|
|
}
|
|
return serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
|
|
}
|
|
|
|
// tcpSerial presents a TCP connection as a serial.Port, so the backend has one
|
|
// code path whether the rig is on a wire or on the network.
|
|
//
|
|
// The modem-control methods are no-ops rather than errors: a bridge has no DTR
|
|
// to raise, and failing them would break a caller that sets them defensively.
|
|
type tcpSerial struct{ conn net.Conn }
|
|
|
|
func (t *tcpSerial) Read(p []byte) (int, error) {
|
|
n, err := t.conn.Read(p)
|
|
// A read deadline expiring is this transport's "no data yet", exactly what a
|
|
// serial read timeout means to the caller — not a dead link. Reporting it as
|
|
// an error would make ask() abandon a rig that is merely thinking.
|
|
if err != nil {
|
|
var ne net.Error
|
|
if errors.As(err, &ne) && ne.Timeout() {
|
|
return n, nil
|
|
}
|
|
}
|
|
return n, err
|
|
}
|
|
func (t *tcpSerial) Write(p []byte) (int, error) { return t.conn.Write(p) }
|
|
func (t *tcpSerial) Close() error { return t.conn.Close() }
|
|
func (t *tcpSerial) SetReadTimeout(d time.Duration) error {
|
|
if d <= 0 {
|
|
return t.conn.SetReadDeadline(time.Time{})
|
|
}
|
|
return t.conn.SetReadDeadline(time.Now().Add(d))
|
|
}
|
|
func (t *tcpSerial) SetMode(*serial.Mode) error { return nil }
|
|
func (t *tcpSerial) Drain() error { return nil }
|
|
func (t *tcpSerial) ResetInputBuffer() error { return nil }
|
|
func (t *tcpSerial) ResetOutputBuffer() error { return nil }
|
|
func (t *tcpSerial) SetDTR(bool) error { return nil }
|
|
func (t *tcpSerial) SetRTS(bool) error { return nil }
|
|
func (t *tcpSerial) GetModemStatusBits() (*serial.ModemStatusBits, error) {
|
|
return &serial.ModemStatusBits{}, nil
|
|
}
|
|
func (t *tcpSerial) Break(time.Duration) error { return nil }
|
|
|
|
// askVFO asks FR; or FT; and returns "A" or "B".
|
|
//
|
|
// The reply is FR0; / FR1; — the digit right after the two-letter command.
|
|
// Anything else (a rig that answers with more fields, or not at all) returns
|
|
// false, and the caller keeps whatever IF said rather than inventing a split.
|
|
func (k *Kenwood) askVFO(cmd string) (string, bool) {
|
|
r, err := k.ask(cmd)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
want := cmdPrefix(cmd)
|
|
body := strings.TrimSuffix(strings.TrimPrefix(r, want), ";")
|
|
if body == "" {
|
|
return "", false
|
|
}
|
|
switch body[0] {
|
|
case '0':
|
|
return "A", true
|
|
case '1':
|
|
return "B", true
|
|
}
|
|
// 2 is "sub receiver" on a TS-2000 — real, but not a VFO we track. Saying
|
|
// nothing is better than mapping it onto A or B and reporting a split that
|
|
// does not exist.
|
|
return "", false
|
|
}
|