A TS-480 connected, answered ID, then never reported a state: discarding " 000000000010000000;" while waiting for IF connected on COM3 @ 115200 baud cat:state → connected=false freq=0 A headless frame tail can only come from bytes that were thrown away. ask() buffered into a local slice, so everything left after the matched frame — including the head of a frame still arriving — went with it, and the link could sit one answer behind its questions. The buffer now lives on the backend, and Connect drains whatever the rig said before AI0 took effect rather than inheriting it. Also: the cat:state diagnostic prints RigState.Error. It read "connected=false freq=0" and said nothing about why, so every report of a CAT failure arrived without the one fact that explains it — the reason only ever reached a tooltip. New work moves to 0.22.8; 0.22.7 is released.
621 lines
20 KiB
Go
621 lines
20 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 (
|
|
"bytes"
|
|
"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
|
|
|
|
// rx holds bytes read but not yet consumed, ACROSS calls to ask.
|
|
//
|
|
// It has to survive: a rig answers faster than we ask, so one Read often
|
|
// returns a whole reply plus the start of the next frame. When this buffer
|
|
// was local to ask, everything after the matched frame was dropped — half a
|
|
// frame included — and the link desynchronised permanently: every ask then
|
|
// found the PREVIOUS command's answer and timed out waiting for its own.
|
|
// That is the "discarding \" 000000000010000000;\" while waiting for IF"
|
|
// a TS-480 reported, followed by connected=false for ever.
|
|
rx []byte
|
|
}
|
|
|
|
// 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;")
|
|
|
|
// Start from silence. A reconnect inherits whatever the rig said last —
|
|
// unsolicited frames sent before AI0 landed, the tail of an answer nobody
|
|
// read — and one stale frame is enough to leave every ask one reply behind
|
|
// its question for the rest of the session.
|
|
k.rx = nil
|
|
k.drain(250 * time.Millisecond)
|
|
|
|
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
|
|
}
|
|
|
|
// drain reads and throws away whatever the rig has already sent, until it stays
|
|
// quiet for one read timeout or the budget runs out.
|
|
func (k *Kenwood) drain(budget time.Duration) {
|
|
if k.port == nil {
|
|
return
|
|
}
|
|
tmp := make([]byte, 256)
|
|
deadline := time.Now().Add(budget)
|
|
for time.Now().Before(deadline) {
|
|
n, err := k.port.Read(tmp)
|
|
if err != nil || n == 0 {
|
|
return // an error here is not interesting: we are throwing this away
|
|
}
|
|
traceText("kenwood", "RX-drop", string(tmp[:n]))
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
tmp := make([]byte, 64)
|
|
deadline := time.Now().Add(600 * time.Millisecond)
|
|
for {
|
|
// Consume whatever is already buffered BEFORE reading more: the answer
|
|
// may have arrived attached to the previous one.
|
|
for {
|
|
i := bytes.IndexByte(k.rx, ';')
|
|
if i < 0 {
|
|
break
|
|
}
|
|
frame := string(k.rx[:i+1])
|
|
k.rx = k.rx[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)
|
|
}
|
|
if !time.Now().Before(deadline) {
|
|
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
|
|
}
|
|
n, err := k.port.Read(tmp)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if n > 0 {
|
|
k.rx = append(k.rx, tmp[:n]...)
|
|
}
|
|
// n == 0 is a read timeout, not silence for good: the rig may still be
|
|
// composing its answer.
|
|
}
|
|
}
|
|
|
|
// ── 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
|
|
}
|
|
p, err := serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Deassert DTR and RTS.
|
|
//
|
|
// Windows raises both when a serial port is opened, and a great many
|
|
// interfaces read them as PTT: a Xiegu G90 behind a DE-19 goes into
|
|
// transmit the moment OpsLog connects and STAYS there — Xiegu's own
|
|
// documentation asks for RTS and DTR low. The same applies to an Icom with
|
|
// USB SEND mapped to a line (which is why the CI-V backend has done this
|
|
// from the start) and to any rig keyed by a home-made cable.
|
|
//
|
|
// PTT on this backend is a CAT command, so neither line should ever be
|
|
// asserted here. A station keying by RTS/DTR configures that separately,
|
|
// on its own port.
|
|
_ = p.SetDTR(false)
|
|
_ = p.SetRTS(false)
|
|
return p, nil
|
|
}
|
|
|
|
// 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
|
|
}
|