The audio session opens its TX side alongside RX (txenable in the conninfo, 16 kHz both ways), the stream gains a paced sender — 320 samples every 20 ms, the frame mirroring what the rig itself sends on this socket, IDs swapped — and the network Icom takes the same NetworkPlayer slot a TCI radio does: pick 'Radio (network audio)' as the To-radio device and the voice keyer needs no cable and no virtual sound card. PTT brackets the message through the same code as every other audio path.
2067 lines
73 KiB
Go
2067 lines
73 KiB
Go
package cat
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"hamlog/internal/applog"
|
|
"hamlog/internal/cat/civ"
|
|
|
|
"go.bug.st/serial"
|
|
)
|
|
|
|
// civTransport is everything IcomSerial needs from its underlying link. It's
|
|
// satisfied directly by a go.bug.st serial.Port (USB, local control) and, for
|
|
// remote control, by the icomnet UDP stream which presents the tunnelled CI-V
|
|
// byte stream as plain Read/Write (there SetDTR/SetRTS/SetReadTimeout are
|
|
// no-ops). Abstracting it here means the whole IcomController surface — DSP,
|
|
// scope, RIT, CW — is reused unchanged over the network; only `open` differs.
|
|
type civTransport interface {
|
|
Read(p []byte) (int, error)
|
|
Write(p []byte) (int, error)
|
|
Close() error
|
|
SetReadTimeout(time.Duration) error
|
|
SetDTR(bool) error
|
|
SetRTS(bool) error
|
|
}
|
|
|
|
// aliveTransport is an OPTIONAL transport capability: report whether the link is
|
|
// still up independently of whether the rig answers CI-V. The network transport
|
|
// implements it (the rig's server pings even in standby), letting ReadState keep
|
|
// the session "connected but rig off" instead of tearing it down and flapping.
|
|
// USB doesn't implement it (no such out-of-band signal), so it keeps the bounded
|
|
// read-failure tolerance instead.
|
|
type aliveTransport interface {
|
|
Alive() bool
|
|
}
|
|
|
|
const (
|
|
// How long the network backend accepts "the control link answers but the rig
|
|
// sends no CI-V" before declaring the session lost. Long enough to cover a
|
|
// band change or a rig booting from standby; short enough that an operator
|
|
// whose CI-V session was stolen (WSJT-X via OmniRig, the Remote Utility) gets
|
|
// a live frequency back on his own rather than restarting OpsLog.
|
|
icomSilentGrace = 30 * time.Second
|
|
// Ceiling for the backoff applied when the silence persists — a rig switched
|
|
// OFF is silent for hours, and re-tearing its session every 30 s would blink
|
|
// the panel (and its ON button) away continuously.
|
|
icomSilentGraceMax = 4 * time.Minute
|
|
)
|
|
|
|
// scopeTransport is an OPTIONAL transport capability: deliver spectrum-scope
|
|
// (0x27) frames on a SEPARATE channel from control replies. The network transport
|
|
// implements it so the continuous panadapter stream can't crowd control replies
|
|
// out of the main Read path (which made every command time out with the scope
|
|
// on). USB doesn't implement it — there the scope frames ride the normal Read
|
|
// path and the reader splits them off to specCh.
|
|
type scopeTransport interface {
|
|
ScopeChan() <-chan []byte
|
|
}
|
|
|
|
// IcomSerial controls an Icom transceiver over the shared civ protocol. The
|
|
// transport is pluggable via `open`: NewIcomSerial opens a USB/serial port;
|
|
// NewIcomNet (later) returns one configured with a network transport. Implements
|
|
// Backend; all methods run on the Manager's CAT goroutine, so the port is
|
|
// accessed single-threaded (no locking needed).
|
|
type IcomSerial struct {
|
|
portName string
|
|
baud int
|
|
rigAddr byte // rig's CI-V address (IC-7610 default 0x98)
|
|
digital string // mode to command for DATA (FT8/RTTY/…)
|
|
|
|
// open dials the underlying link (serial port or network stream). Set by the
|
|
// constructor; called by Connect. Blocks until connected (the network opener
|
|
// performs the full UDP handshake + login before returning).
|
|
open func() (civTransport, error)
|
|
|
|
port civTransport
|
|
model string
|
|
|
|
// I/O routing. A single reader goroutine owns port.Read and dispatches every
|
|
// decoded rig frame: control replies go to respCh (drained by recv), while
|
|
// spectrum-scope frames (0x27) go to specCh for the panadapter. This decouples
|
|
// the continuous scope stream from the request/response control path — without
|
|
// it, scope frames would flood recv() and stall polling.
|
|
respCh chan civ.Decoded
|
|
specCh chan civ.Decoded
|
|
readerDone chan struct{}
|
|
|
|
// Spectrum scope (0x27). dualScope marks rigs whose waveform frames carry a
|
|
// leading main/sub selector byte (IC-7610/9700). scopeAmp is the latest
|
|
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read
|
|
// via ScopeData from the binding goroutine).
|
|
dualScope bool
|
|
// Set when the rig rejects the waveform-output command in both shapes: it has
|
|
// no stream to give, and asking again on every enable is noise.
|
|
scopeUnsupported bool
|
|
scopeMu sync.Mutex
|
|
scopeAmp []byte
|
|
scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame)
|
|
scopeHigh int64 // spectrum right-edge frequency
|
|
scopeSeq int
|
|
scopeOn bool
|
|
scopeFixed bool // true = fixed-span mode (tracked optimistically)
|
|
scopeSeen bool // logged the first sweep's structure once (on-rig verification)
|
|
|
|
curFreq int64 // last frequency read (for sideband choice)
|
|
curModeByte byte // last raw Icom mode byte (for filter re-send)
|
|
pollN int // ReadState cycle counter (staggers slow reads)
|
|
splitOn bool // last read split state (refreshed every few cycles)
|
|
splitTXFreq int64 // last read unselected/TX VFO freq while in split
|
|
readFails int // consecutive ReadState freq-read failures (transient tolerance)
|
|
lastGoodAt time.Time // last SUCCESSFUL frequency read — bounds the network
|
|
// "alive but silent" tolerance below, which used to be
|
|
// unbounded and left the display frozen for ever
|
|
silentGrace time.Duration // current width of that tolerance (backs off, see ReadState)
|
|
dspLoaded bool // readDSP has run since the rig became responsive (loads all
|
|
// the panel's set-once controls once the rig actually answers)
|
|
// When the console last asked for the DSP snapshot. The meters and the
|
|
// front-panel rotation are polled ONLY while something is displaying them:
|
|
// they are half of OpsLog's CI-V traffic, and on a shared bus — a microHAM
|
|
// Router splitting one CI-V link into two virtual ports, OpsLog on one and
|
|
// WSJT-X/MSHV on the other — every frame we don't need is a frame that can
|
|
// collide with the other program's.
|
|
dspWantMu sync.Mutex
|
|
dspWantAt time.Time
|
|
|
|
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
|
|
lastSetFreqAt time.Time
|
|
|
|
// dsp caches the receive-DSP state for the Icom control tab. Read off the
|
|
// CAT goroutine via IcomState(), written on the CAT goroutine (RefreshIcom
|
|
// / setters) — hence the mutex.
|
|
dspMu sync.Mutex
|
|
dsp IcomTXState
|
|
|
|
// dialCancel is closed by Interrupt() to abort an in-progress network dial
|
|
// (icomnet's handshake/login/boot-wait can block ~tens of seconds). A fresh
|
|
// channel is made by each Connect. Guarded by dialMu: written on the CAT
|
|
// goroutine, closed from the goroutine calling Stop.
|
|
dialMu sync.Mutex
|
|
dialCancel chan struct{}
|
|
}
|
|
|
|
// Interrupt aborts an in-progress network Connect so Stop()/Start() don't block
|
|
// on a slow UDP handshake (or the 25 s boot-from-standby wait). Safe to call at
|
|
// any time and from another goroutine; harmless when no dial is in progress and
|
|
// a no-op for the USB transport (which dials instantly).
|
|
func (b *IcomSerial) Interrupt() {
|
|
b.dialMu.Lock()
|
|
if b.dialCancel != nil {
|
|
select {
|
|
case <-b.dialCancel: // already closed
|
|
default:
|
|
close(b.dialCancel)
|
|
}
|
|
}
|
|
b.dialMu.Unlock()
|
|
}
|
|
|
|
const (
|
|
icomReadTimeout = 350 * time.Millisecond // wait for a poll response
|
|
icomCmdTimeout = 400 * time.Millisecond // wait for a set ack (FB/FA)
|
|
)
|
|
|
|
// NewIcomSerial builds an (unconnected) Icom serial backend. baud defaults to
|
|
// 115200, rig address to the IC-7610's 0x98 when out of range.
|
|
func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *IcomSerial {
|
|
if baud <= 0 {
|
|
baud = 115200
|
|
}
|
|
if civAddr <= 0 || civAddr > 0xFF {
|
|
civAddr = 0x98 // IC-7610
|
|
}
|
|
if digitalDefault == "" {
|
|
digitalDefault = "FT8"
|
|
}
|
|
b := &IcomSerial{
|
|
portName: portName,
|
|
baud: baud,
|
|
rigAddr: byte(civAddr),
|
|
digital: strings.ToUpper(digitalDefault),
|
|
model: "Icom",
|
|
scopeFixed: true, // rigs default to a fixed-span scope
|
|
}
|
|
// USB/serial transport opener.
|
|
b.open = func() (civTransport, error) {
|
|
if b.portName == "" {
|
|
return nil, fmt.Errorf("no serial port configured")
|
|
}
|
|
p, err := serial.Open(b.portName, &serial.Mode{BaudRate: b.baud})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open %s @ %d baud: %w%s", b.portName, b.baud, err, busyHint(b.portName, err))
|
|
}
|
|
return p, nil
|
|
}
|
|
return b
|
|
}
|
|
|
|
func (b *IcomSerial) Name() string { return "icom" }
|
|
|
|
func (b *IcomSerial) Connect() error {
|
|
if b.open == nil {
|
|
return fmt.Errorf("no transport configured")
|
|
}
|
|
// Fresh cancel channel for this dial so Interrupt() (called by Stop) can abort
|
|
// a slow network handshake instead of freezing the UI.
|
|
b.dialMu.Lock()
|
|
b.dialCancel = make(chan struct{})
|
|
b.dialMu.Unlock()
|
|
port, err := b.open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Short read timeout so recv() polls in a tight loop without blocking the
|
|
// CAT goroutine when the rig is silent. (No-op on the network transport.)
|
|
_ = port.SetReadTimeout(60 * time.Millisecond)
|
|
// Deassert DTR/RTS. Icom USB rigs (IC-7610, IC-7300…) let "USB SEND" and
|
|
// "USB Keying (CW)" be mapped to the RTS or DTR line: if the port opens with
|
|
// those asserted, the rig keys into TRANSMIT. PTT here is CI-V only, so both
|
|
// hardware lines must stay low.
|
|
_ = port.SetDTR(false)
|
|
_ = port.SetRTS(false)
|
|
b.port = port
|
|
b.model = civ.ModelName(b.rigAddr)
|
|
|
|
// Start the reader before any request: recv() now waits on respCh, which only
|
|
// the reader feeds. respCh is buffered so a burst (or the scope stream) never
|
|
// blocks the reader; specCh holds the latest scope frames for the panadapter.
|
|
b.respCh = make(chan civ.Decoded, 64)
|
|
b.specCh = make(chan civ.Decoded, 32)
|
|
b.readerDone = make(chan struct{})
|
|
go b.reader(port, b.readerDone)
|
|
go b.scopeLoop(b.specCh, b.readerDone)
|
|
// On the network the scope frames come on their own channel (kept off the
|
|
// control Read path); feed them into the same scope pipeline.
|
|
if sc, ok := port.(scopeTransport); ok {
|
|
go b.netScopeFeeder(sc.ScopeChan(), b.readerDone)
|
|
}
|
|
|
|
// Best-effort model identification: ask the rig for its own CI-V address. The
|
|
// 0x19 ID read returns the rig's FACTORY default address (e.g. 0x94 for an
|
|
// IC-7300) regardless of the address it's currently OPERATING on — so it's the
|
|
// reliable model signal even when the user runs the rig at a non-default CI-V
|
|
// address (a common trick to make CAT "just work" at 0x98).
|
|
idAddr := b.rigAddr // fallback: the configured address if the ID read fails
|
|
if err := b.write(civ.CmdReadID, civ.SubPTT); err == nil {
|
|
if f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
|
return d.Cmd == civ.CmdReadID && len(d.Data) >= 2 && d.Data[0] == 0x00
|
|
}); err == nil {
|
|
b.model = civ.ModelName(f.Data[1])
|
|
idAddr = f.Data[1]
|
|
}
|
|
}
|
|
// Whether the rig's scope protocol carries a leading main/sub SELECTOR byte on
|
|
// every 0x27 frame and command. The IC-7610/9700 (dual scope) do — and the logs
|
|
// prove the IC-7300 does too (frames arrive as `27 00 00 <seq> <total> …` and a
|
|
// mode read answers `27 14 00 <mode>`), even though it has a single scope. The
|
|
// waveform parser auto-detects this per-frame regardless, so a 7300 at a
|
|
// non-default address still RENDERS; this flag only drives the SET/read commands
|
|
// (mode, span, edges), which need the 0x00 selector to be accepted on the 7300.
|
|
b.dualScope = idAddr == 0x98 || idAddr == 0xA2 || idAddr == 0x94
|
|
// Defer the DSP snapshot until the rig actually answers CI-V. Over the network
|
|
// the rig may still be booting (or off) at Connect, so an immediate readDSP
|
|
// would time out and leave every control at 0 / off with no retry. ReadState
|
|
// loads it once on the first successful freq read instead (see dspLoaded).
|
|
b.dspLoaded = false
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) Disconnect() {
|
|
if b.port != nil {
|
|
_ = b.port.Close() // unblocks the reader's pending Read
|
|
b.port = nil
|
|
}
|
|
if b.readerDone != nil {
|
|
<-b.readerDone // wait for the reader goroutine to exit cleanly
|
|
b.readerDone = nil
|
|
}
|
|
}
|
|
|
|
// ReadState polls the rig for frequency and mode. A failed frequency read is
|
|
// treated as "lost the rig" so the Manager reconnects.
|
|
func (b *IcomSerial) ReadState() (RigState, error) {
|
|
if b.port == nil {
|
|
return RigState{}, fmt.Errorf("not connected")
|
|
}
|
|
s := RigState{Backend: b.Name(), Connected: true, Rig: b.model}
|
|
|
|
hz, err := b.readFreq()
|
|
if err != nil {
|
|
// Network transport: if the control link is still alive, the rig is simply
|
|
// silent — either in standby / powered OFF (the ON button is manual now), or
|
|
// mid band-change. Stay CONNECTED and show last-known state (empty until the
|
|
// rig is switched on) rather than tearing the whole UDP session down and
|
|
// flapping every few seconds. The panel stays up so the ON button works.
|
|
if at, ok := b.port.(aliveTransport); ok {
|
|
// The reader goroutine is what feeds every CI-V reply. If it has
|
|
// exited, no read can ever succeed again, however healthy the control
|
|
// link looks — that is not a silent rig, it is a dead connection.
|
|
readerGone := false
|
|
if b.readerDone != nil {
|
|
select {
|
|
case <-b.readerDone:
|
|
readerGone = true
|
|
default:
|
|
}
|
|
}
|
|
// "Alive but silent" is the rig in standby, or mid band-change. It was
|
|
// tolerated for ever, and that is the freeze: when ANOTHER program takes
|
|
// the CI-V session (WSJT-X through OmniRig, say) the rig keeps answering
|
|
// pings on the control stream while sending us no CI-V at all. Alive()
|
|
// stayed true, the cached frequency was re-published with a fresh
|
|
// timestamp on every poll, and the operator saw a confident, frozen
|
|
// number until OpsLog was restarted. Bounded now: past the grace period
|
|
// the error is reported so the Manager tears the session down and
|
|
// reconnects, which re-takes the CI-V stream.
|
|
silentFor := time.Duration(0)
|
|
if !b.lastGoodAt.IsZero() {
|
|
silentFor = time.Since(b.lastGoodAt)
|
|
}
|
|
if b.silentGrace <= 0 {
|
|
b.silentGrace = icomSilentGrace
|
|
}
|
|
if at.Alive() && !readerGone && (b.lastGoodAt.IsZero() || silentFor < b.silentGrace) {
|
|
b.readFails = 0
|
|
s.FreqHz = b.curFreq // 0 until the rig is powered on and first read
|
|
if b.curModeByte != 0 {
|
|
s.Mode = civ.ModeToADIF(b.curModeByte, false)
|
|
if s.Mode == "DATA" {
|
|
s.Mode = b.digital
|
|
}
|
|
}
|
|
// Keep the Icom panel visible (so ON/OFF are reachable) but show no
|
|
// live meters while the rig is silent.
|
|
b.dspMu.Lock()
|
|
b.dsp.Available = true
|
|
b.dsp.Model = b.model
|
|
b.dsp.Transmitting = false
|
|
b.dsp.SMeter, b.dsp.PowerMeter, b.dsp.SWRMeter = 0, 0, 0
|
|
b.dspMu.Unlock()
|
|
return s, nil
|
|
}
|
|
switch {
|
|
case readerGone:
|
|
debugLog.Printf("icom net: the CI-V reader has exited — the connection is dead however alive the control link looks → reconnecting")
|
|
case at.Alive():
|
|
// No verdict on WHY: this fires for a session taken by another
|
|
// program (WSJT-X, the Remote Utility) AND for a rig that simply
|
|
// stopped answering — a real IC-7760 did exactly that once with
|
|
// nothing else on the network. Blaming a hijacker sent that
|
|
// operator hunting software that was not installed.
|
|
debugLog.Printf("icom net: control link answers but no CI-V reply for %s → reconnecting. Either another program took the CI-V session (WSJT-X/OmniRig/Remote Utility), or the rig stopped answering on its own.", silentFor.Round(time.Second))
|
|
default:
|
|
debugLog.Printf("icom net: control link went quiet (no rig packets for >6 s) → reconnecting. If this recurs every ~2-3 min, the rig is invalidating the session (token renewal rejected).")
|
|
}
|
|
// Restart the clock, and widen the window each time it fires without a
|
|
// good read in between. A hijacked session recovers on the first shot;
|
|
// a rig simply switched OFF never will, and re-tearing its session every
|
|
// 30 s would make the panel — and its ON button — blink away
|
|
// continuously. Backing off to minutes keeps the standby case quiet
|
|
// while still recovering on its own.
|
|
b.lastGoodAt = time.Now()
|
|
if b.silentGrace < icomSilentGraceMax {
|
|
b.silentGrace *= 2
|
|
}
|
|
return RigState{}, err // let the Manager reconnect
|
|
}
|
|
// USB (no liveness signal): the rig briefly stops answering CI-V while it
|
|
// switches band/VFO. Tolerate a few consecutive misses as transient — keep
|
|
// the connection and report last-known state — so a band change doesn't
|
|
// trigger a full disconnect + 5 s reconnect. Only after several failures do
|
|
// we declare the rig lost so the Manager reconnects.
|
|
b.readFails++
|
|
if b.readFails <= 6 && b.curFreq > 0 {
|
|
s.FreqHz = b.curFreq
|
|
if b.curModeByte != 0 {
|
|
s.Mode = civ.ModeToADIF(b.curModeByte, false)
|
|
if s.Mode == "DATA" {
|
|
s.Mode = b.digital
|
|
}
|
|
}
|
|
return s, nil
|
|
}
|
|
return RigState{}, err
|
|
}
|
|
b.readFails = 0
|
|
b.lastGoodAt = time.Now()
|
|
b.silentGrace = icomSilentGrace // the rig answers: back to the short window
|
|
s.FreqHz = hz
|
|
b.curFreq = hz
|
|
|
|
if m, ok := b.readMode(); ok {
|
|
b.curModeByte = m
|
|
data := b.readDataMode() // best-effort; ignored on failure
|
|
s.Mode = civ.ModeToADIF(m, data)
|
|
if s.Mode == "DATA" {
|
|
s.Mode = b.digital
|
|
}
|
|
b.dspMu.Lock()
|
|
b.dsp.Mode = s.Mode
|
|
b.dspMu.Unlock()
|
|
}
|
|
|
|
b.pollN++
|
|
|
|
// Split: the selected VFO (read above) is RX; the unselected VFO is TX. ADIF
|
|
// convention → FreqHz = TX, RxFreqHz = RX. Split changes rarely and its read
|
|
// (0x0F + 0x25, each with a 350 ms timeout) is the costliest part of a poll,
|
|
// so refresh it only every 4th cycle and reuse the cached value between —
|
|
// this keeps the CAT thread free for the freq/mode/meter reads and, above
|
|
// all, for the user's Set* commands.
|
|
if b.pollN%4 == 1 {
|
|
b.splitOn, b.splitTXFreq = false, 0
|
|
if on, ok := b.readSplit(); ok && on {
|
|
if txHz, ok2 := b.readTXFreq(); ok2 && txHz > 0 {
|
|
b.splitOn, b.splitTXFreq = true, txHz
|
|
}
|
|
}
|
|
}
|
|
if b.splitOn && b.splitTXFreq > 0 && b.splitTXFreq != s.FreqHz {
|
|
s.Split = true
|
|
s.RxFreqHz = s.FreqHz // selected VFO = RX
|
|
s.FreqHz = b.splitTXFreq // unselected VFO = TX
|
|
}
|
|
|
|
// Live meters + TX state for the Icom panel (the rig doesn't push these).
|
|
// The meters are polled only while the console is actually on screen: they
|
|
// were three CI-V round trips per cycle, every cycle, whether or not anyone
|
|
// could see them. The previous readings are kept so the panel opens on the
|
|
// last known values rather than on zeros.
|
|
tx := b.readTX()
|
|
watched := b.panelWatched()
|
|
b.dspMu.Lock()
|
|
sm, po, swr := b.dsp.SMeter, b.dsp.PowerMeter, b.dsp.SWRMeter
|
|
b.dspMu.Unlock()
|
|
if watched {
|
|
sm, _ = b.readMeter(civ.SubMeterS)
|
|
po, swr = 0, 0
|
|
if tx {
|
|
if v, ok := b.readMeter(civ.SubMeterPo); ok {
|
|
po = v
|
|
}
|
|
if v, ok := b.readMeter(civ.SubMeterSWR); ok {
|
|
swr = v
|
|
}
|
|
}
|
|
}
|
|
b.dspMu.Lock()
|
|
b.dsp.Available = true
|
|
b.dsp.Model = b.model
|
|
b.dsp.Transmitting = tx
|
|
b.dsp.Split = s.Split
|
|
b.dsp.SMeter = sm
|
|
b.dsp.PowerMeter = po
|
|
b.dsp.SWRMeter = swr
|
|
b.dspMu.Unlock()
|
|
|
|
// First time the rig answers (it's booted/responsive): load the full DSP
|
|
// snapshot so the panel's antenna, sliders, RIT, notch, etc. reflect the rig
|
|
// instead of sitting at their zero defaults. Runs once; ↻ Refresh re-reads on
|
|
// demand, and a reconnect re-arms it (Connect clears dspLoaded).
|
|
// Both of these only make sense for the console — don't spend the bus on them
|
|
// while it is closed. The snapshot then loads the first time it is opened.
|
|
if watched {
|
|
if !b.dspLoaded {
|
|
b.readDSP()
|
|
b.dspLoaded = true
|
|
} else {
|
|
b.refreshFrontPanel()
|
|
}
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// refreshFrontPanel re-reads the few controls an operator actually reaches for on
|
|
// the rig itself, so the console follows the radio instead of only driving it.
|
|
//
|
|
// readDSP loads everything but runs ONCE per connection (dspLoaded), which left
|
|
// the panel showing whatever was set at connect: switch AGC from FAST to MID on
|
|
// the front panel and OpsLog still said FAST, indefinitely. Commands worked, so
|
|
// the link was plainly fine — only this direction was missing.
|
|
//
|
|
// ONE read per poll cycle, in rotation. The full snapshot is ~30 CI-V round trips
|
|
// and refreshing it wholesale would hog the CAT thread for seconds at a time —
|
|
// including the operator's own Set* commands, which is far worse than a stale
|
|
// label. The rotation completes in 8 cycles; anything not covered here is still a
|
|
// connect-time or ↻ Refresh read.
|
|
func (b *IcomSerial) refreshFrontPanel() {
|
|
switch b.pollN % 8 {
|
|
case 1:
|
|
if v, ok := b.readSwitch(civ.SubSwAGC); ok {
|
|
b.dspMu.Lock()
|
|
b.dsp.AGC = agcName(v)
|
|
b.dspMu.Unlock()
|
|
}
|
|
case 3:
|
|
if v, ok := b.readAtt(); ok {
|
|
b.dspMu.Lock()
|
|
b.dsp.Att = v
|
|
b.dspMu.Unlock()
|
|
}
|
|
case 5:
|
|
if v, ok := b.readSwitch(civ.SubSwPreamp); ok {
|
|
b.dspMu.Lock()
|
|
b.dsp.Preamp = int(v)
|
|
b.dspMu.Unlock()
|
|
}
|
|
case 7:
|
|
if _, f, ok := b.readModeFilter(); ok {
|
|
b.dspMu.Lock()
|
|
b.dsp.Filter = int(f)
|
|
b.dspMu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *IcomSerial) SetFrequency(hz int64) error {
|
|
if hz <= 0 {
|
|
return fmt.Errorf("invalid frequency")
|
|
}
|
|
b.lastSetFreq, b.lastSetFreqAt = hz, time.Now()
|
|
err := b.execIdempotent(fmt.Sprintf("set frequency %d Hz", hz),
|
|
append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)...)
|
|
// Same reasoning as SetPTT: a lost acknowledgement is not a failed command,
|
|
// and WSJT-X or JTDX answer a failed set_freq by dropping the link — an
|
|
// IC-7850 log shows exactly that at 15:16:15, the disconnect four seconds
|
|
// later. The rig is asked what frequency it is on rather than being called a
|
|
// failure on the strength of a missing "FB".
|
|
if err == nil || !errors.Is(err, errIcomAckLost) {
|
|
return err
|
|
}
|
|
if got, rerr := b.readFreq(); rerr == nil && got == hz {
|
|
applog.Printf("icom: frequency %d Hz not acknowledged, but the rig is on it — treating as done", hz)
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (b *IcomSerial) SetMode(mode string) error {
|
|
code, data, err := b.modeCode(mode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Set the base mode (keeping the rig's current filter by sending only the
|
|
// mode byte), then set the data-mode flag for digital modes.
|
|
if err := b.execIdempotent("set mode "+mode, civ.CmdSetMode, code); err != nil {
|
|
// A lost acknowledgement is not a refused mode — see SetPTT. The mode is
|
|
// readable, so ask rather than report a failure that would cost the
|
|
// digital application its connection.
|
|
if !errors.Is(err, errIcomAckLost) {
|
|
return err
|
|
}
|
|
if got, ok := b.readMode(); !ok || got != code {
|
|
return err
|
|
}
|
|
applog.Printf("icom: mode %s not acknowledged, but the rig reports it — treating as done", mode)
|
|
}
|
|
dataByte := byte(0)
|
|
if data {
|
|
dataByte = 1
|
|
}
|
|
// Filter 0x01 (FIL1) is the conventional default for the data-mode set.
|
|
_ = b.execIdempotent("set data mode", civ.CmdExtra, civ.SubDataMode, dataByte, 0x01)
|
|
// Trust, then verify: the IC-7760 acknowledges 1A 06 and stays in USB-D
|
|
// anyway, which left an operator unable to get back to plain USB at all.
|
|
// When the readback disagrees, say it again with 0x26 — mode, data flag and
|
|
// filter in one frame, the command the newer rigs actually honour. Only on
|
|
// a mismatch, so rigs that predate 0x26 never see it.
|
|
if got := b.readDataMode(); got != data {
|
|
if err := b.execIdempotent("set mode+data (0x26)", civ.CmdModeDataFil, 0x00, code, dataByte, 0x01); err != nil {
|
|
applog.Printf("icom: data flag stuck at %v after mode %s, and 0x26 failed too: %v", got, mode, err)
|
|
} else {
|
|
applog.Printf("icom: data flag stuck after 1A06, corrected via 0x26 (mode %s)", mode)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// errIcomAckLost is "the reply never came" — as opposed to a reply that said no
|
|
// (NG) or a dead port. Only this one is worth repeating: the command may well
|
|
// have been carried out and only its acknowledgement lost. Sentinel rather than
|
|
// a formatted string so callers can tell the two apart.
|
|
var errIcomAckLost = errors.New("icom: timeout waiting for response")
|
|
|
|
// execIdempotent runs a SET command and sends it ONCE MORE if the
|
|
// acknowledgement is lost.
|
|
//
|
|
// A missing FB is not a missing command — the rig acts on the frame as soon as it
|
|
// decodes it, and what expires is our wait for the answer on a bus shared with
|
|
// the rig's own transceive updates. JTDX in "Split Operating: Fake It" moves the
|
|
// dial and the mode immediately before every key-down, so those acks queue behind
|
|
// each other, and losing one was fatal: rigctld answers RPRT -9, JTDX reads that
|
|
// as losing rig control and tears the connection down mid-over. An operator's log
|
|
// shows it happening on set_ptt, on set_freq and on set_mode alike, each failure
|
|
// followed within 300 ms by a fresh rigctld client — and shows this resend
|
|
// rescuing a PTT that would otherwise have ended the over.
|
|
//
|
|
// Only for commands that say "be in this state": re-sending one changes nothing
|
|
// if the first arrived. A relative or incremental command must not come through
|
|
// here, which is why this is opt-in per caller rather than folded into exec.
|
|
func (b *IcomSerial) execIdempotent(what string, payload ...byte) error {
|
|
err := b.exec(payload...)
|
|
if err == nil || !errors.Is(err, errIcomAckLost) {
|
|
return err
|
|
}
|
|
applog.Printf("icom: %s — no acknowledgement in %s, sending it once more", what, icomCmdTimeout)
|
|
return b.exec(payload...)
|
|
}
|
|
|
|
// SetPTT keys or unkeys the transmitter (CI-V 0x1C 0x00).
|
|
// A lost acknowledgement is NOT proof the rig ignored us, and treating it as one
|
|
// is expensive: OpsLog fronts a Hamlib rigctl port, so the failure is handed
|
|
// straight to WSJT-X or JTDX, which answer a failed set_ptt with a Rig Control
|
|
// Error and drop the link mid-over. An IC-7850 log showed exactly that, three
|
|
// times in a session, on a CI-V bus with a second controller corrupting frames.
|
|
//
|
|
// So when the acknowledgement does not arrive, ASK the rig what it is doing. PTT
|
|
// is readable (0x1C 0x00), the answer is unambiguous, and a rig that is keyed
|
|
// received the command whatever became of its reply.
|
|
func (b *IcomSerial) SetPTT(on bool) error {
|
|
state := byte(0)
|
|
if on {
|
|
state = 1
|
|
}
|
|
err := b.execIdempotent(fmt.Sprintf("PTT %v", on), civ.CmdPTT, civ.SubPTT, state)
|
|
if err == nil || !errors.Is(err, errIcomAckLost) {
|
|
return err
|
|
}
|
|
// A GENEROUS window for the verification, not the poll's 150 ms.
|
|
//
|
|
// The rig has just failed to answer twice in 800 ms, and the reason seen on a
|
|
// real IC-7850 is that it was retuning: JTDX moves the transmit frequency and
|
|
// keys in the same breath. Asking it again with the short probe timeout would
|
|
// fail for exactly the same reason and prove nothing.
|
|
keyed, ok := b.readTXWithin(icomPTTVerifyTimeout)
|
|
if ok && keyed == on {
|
|
applog.Printf("icom: PTT %v not acknowledged, but the rig reports it — treating as done", on)
|
|
return nil
|
|
}
|
|
// Logged either way, because "the fix did not work" and "the rig really did
|
|
// not key" look identical from outside and need telling apart.
|
|
if ok {
|
|
applog.Printf("icom: PTT %v not acknowledged, and the rig reports %v — reporting the failure", on, keyed)
|
|
} else {
|
|
applog.Printf("icom: PTT %v not acknowledged, and the rig did not answer the check either", on)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// SetPower turns the transceiver on or off (CI-V 0x18). Power-ON is prefixed with
|
|
// a run of 0xFE — the wake preamble Icom rigs need to notice a command while
|
|
// asleep (harmless when already awake); after it the rig boots for ~10-15 s.
|
|
// Sent raw with no ack wait, since a rig waking up or shutting down won't
|
|
// reliably answer. On the network transport the whole buffer becomes one data
|
|
// packet, exactly as the Remote Utility sends it. Power is manual (the app never
|
|
// wakes the rig on connect), so this is driven by the panel's ON/OFF button.
|
|
func (b *IcomSerial) SetPower(on bool) error {
|
|
if b.port == nil {
|
|
return fmt.Errorf("icom: not connected")
|
|
}
|
|
if on {
|
|
// The preamble has to last long enough IN TIME for the sleeping rig to
|
|
// notice it, so its length scales with the baud rate — Icom's own table
|
|
// asks for 7 bytes at 4800 baud and 150 at 115200, which is a fixed
|
|
// ~25 ms of carrier. A flat 25 bytes (what this sent before) is right at
|
|
// 19200 and far too short above it, so a rig configured for 115200 simply
|
|
// ignored the command.
|
|
n := b.baud / 768
|
|
if n < 7 {
|
|
n = 7
|
|
} else if n > 150 {
|
|
n = 150
|
|
}
|
|
buf := make([]byte, 0, n+8)
|
|
for i := 0; i < n; i++ {
|
|
buf = append(buf, 0xFE)
|
|
}
|
|
buf = append(buf, 0xFE, 0xFE, b.rigAddr, civ.AddrController, civ.CmdPower, 0x01, 0xFD)
|
|
_, err := b.port.Write(buf)
|
|
applog.Printf("icom: power ON — %d-byte wake preamble at %d baud, addr 0x%02X (err=%v)", n, b.baud, b.rigAddr, err)
|
|
return err
|
|
}
|
|
_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, civ.CmdPower, 0x00))
|
|
return err
|
|
}
|
|
|
|
// ── helpers ───────────────────────────────────────────────────────────────
|
|
|
|
func (b *IcomSerial) write(payload ...byte) error {
|
|
// Not connected (rig off / port dropped): fail cleanly instead of
|
|
// dereferencing a nil port — a Set* dispatched while disconnected (e.g.
|
|
// clicking Scope ON with the radio off) would otherwise panic the app.
|
|
if b.port == nil {
|
|
return fmt.Errorf("icom: not connected")
|
|
}
|
|
// Drop any stale/unsolicited frames buffered from before this command so
|
|
// recv() only sees the reply to THIS request (avoids a previous command's ack
|
|
// or an unsolicited dial-turn update being mistaken for our response).
|
|
b.drainResp()
|
|
frame := civ.Frame(b.rigAddr, civ.AddrController, payload...)
|
|
traceCIV("TX", frame)
|
|
_, err := b.port.Write(frame)
|
|
return err
|
|
}
|
|
|
|
// recv waits for a frame the reader routed to respCh that satisfies match, or
|
|
// times out. The reader has already discarded echoes and split off scope frames,
|
|
// so recv only ever sees candidate control replies. It also bails out at once if
|
|
// Interrupt() fires (Stop) so an in-flight ReadState — which can be a dozen reads,
|
|
// each up to icomReadTimeout when the rig is slow — doesn't make Stop/Save-&-Close
|
|
// wait several seconds for the poll goroutine to finish.
|
|
func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (civ.Decoded, error) {
|
|
b.dialMu.Lock()
|
|
cancel := b.dialCancel
|
|
b.dialMu.Unlock()
|
|
deadline := time.After(timeout)
|
|
for {
|
|
select {
|
|
case f := <-b.respCh:
|
|
if match(f) {
|
|
return f, nil
|
|
}
|
|
case <-cancel:
|
|
return civ.Decoded{}, fmt.Errorf("icom: interrupted")
|
|
case <-deadline:
|
|
return civ.Decoded{}, errIcomAckLost
|
|
}
|
|
}
|
|
}
|
|
|
|
// reader is the sole owner of port.Read. It decodes the CI-V byte stream into
|
|
// frames and routes each: our own echoes are dropped, spectrum-scope frames
|
|
// (0x27) go to specCh, everything else (control replies, acks, unsolicited
|
|
// transceive updates) goes to respCh. It exits when the port is closed.
|
|
func (b *IcomSerial) reader(port civTransport, done chan struct{}) {
|
|
defer close(done)
|
|
tmp := make([]byte, 512)
|
|
var rx []byte
|
|
for {
|
|
n, err := port.Read(tmp)
|
|
if err != nil {
|
|
return // port closed or failed — Disconnect/reconnect handles it
|
|
}
|
|
if n == 0 {
|
|
continue // read timeout with no data
|
|
}
|
|
rx = append(rx, tmp[:n]...)
|
|
// Traced BEFORE framing: the bytes as they arrived are what shows a lost
|
|
// frame boundary, which a decoded view would hide.
|
|
traceCIV("RX", tmp[:n])
|
|
frames, consumed := civ.Scan(rx)
|
|
if consumed > 0 {
|
|
rx = append(rx[:0], rx[consumed:]...)
|
|
}
|
|
for _, f := range frames {
|
|
if f.From != b.rigAddr {
|
|
continue // echo of our own command
|
|
}
|
|
// Addressed to US, or broadcast.
|
|
//
|
|
// A CI-V bus can carry a second controller — RS-BA1, wfview, another
|
|
// logger — and the rig answers each of them in turn on the same wire.
|
|
// Those replies come FROM the rig, so the check above lets them through,
|
|
// and one can be taken for the answer to our own command: an IC-7850 log
|
|
// showed "FE FE 01 8E …" (to controller 01) arriving between our PTT
|
|
// command and its acknowledgement, with bus collisions around it.
|
|
// Transceive broadcasts (to 0x00) stay welcome — that is how the rig
|
|
// announces a knob turn.
|
|
if f.To != civ.AddrController && f.To != 0x00 {
|
|
continue
|
|
}
|
|
if f.Cmd == civ.CmdScope {
|
|
b.route(b.specCh, f)
|
|
continue
|
|
}
|
|
b.route(b.respCh, f)
|
|
}
|
|
}
|
|
}
|
|
|
|
// netScopeFeeder decodes the raw scope (0x27) CI-V frames the network transport
|
|
// delivers on its own channel and routes them into specCh — the same pipeline
|
|
// the USB reader feeds — so scopeLoop assembles them identically. Exits when the
|
|
// connection's reader does (done closes on Disconnect).
|
|
func (b *IcomSerial) netScopeFeeder(ch <-chan []byte, done chan struct{}) {
|
|
var buf []byte
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case raw, ok := <-ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
buf = append(buf, raw...)
|
|
frames, consumed := civ.Scan(buf)
|
|
if consumed > 0 {
|
|
buf = append(buf[:0], buf[consumed:]...)
|
|
}
|
|
for _, f := range frames {
|
|
if f.From == b.rigAddr && f.Cmd == civ.CmdScope {
|
|
b.route(b.specCh, f)
|
|
}
|
|
}
|
|
if len(buf) > 1<<16 { // a frame that never completes — don't grow forever
|
|
buf = buf[:0]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// route delivers a frame without ever blocking the reader: if the channel is
|
|
// full it drops the oldest entry to make room for the newest.
|
|
func (b *IcomSerial) route(ch chan civ.Decoded, f civ.Decoded) {
|
|
select {
|
|
case ch <- f:
|
|
default:
|
|
select { // buffer full — discard oldest, then enqueue newest
|
|
case <-ch:
|
|
default:
|
|
}
|
|
select {
|
|
case ch <- f:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
// drainResp empties any pending control frames (non-blocking).
|
|
func (b *IcomSerial) drainResp() {
|
|
for {
|
|
select {
|
|
case <-b.respCh:
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── spectrum scope (0x27) ───────────────────────────────────────────────────
|
|
|
|
// scopeLoop reassembles the Icom's divided waveform frames into complete sweeps.
|
|
// Frame layout (verified on an IC-7610): Data = [00, main/sub, seq, total, …].
|
|
// The first frame (seq==1) is a HEADER — [info, low-edge 5-BCD, high-edge 5-BCD]
|
|
// — and carries NO waveform bytes; frames 2..total each carry a block of
|
|
// amplitude bytes. So we parse the edges from frame 1 and concatenate frames
|
|
// 2..total for the trace.
|
|
func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
|
|
regions := make(map[byte][]byte)
|
|
var total byte
|
|
rawN := 0 // diagnostic: dump the first few raw 0x27 frames
|
|
loggedCfg := map[byte]bool{} // one-shot dump of each config read response
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case f := <-spec:
|
|
if len(f.Data) < 1 {
|
|
continue
|
|
}
|
|
if f.Data[0] != civ.SubScopeData {
|
|
// Non-waveform 0x27 frame = a config read response (mode/span/edge).
|
|
// Log each subcommand once so we can confirm its exact byte layout.
|
|
if !loggedCfg[f.Data[0]] {
|
|
loggedCfg[f.Data[0]] = true
|
|
applog.Printf("icom scope cfg 0x%02X: data=[% X]", f.Data[0], f.Data)
|
|
}
|
|
// The rig just told us its own layout: a mode/span/edge answer of
|
|
// three bytes or more carries the main/sub selector, one of two
|
|
// bytes does not. Worth reading, because the SET commands take the
|
|
// same shape and several firmwares answer a wrong-shaped set with
|
|
// silence rather than a rejection — which is not something the
|
|
// retry in execScope can act on.
|
|
if f.Data[0] == civ.SubScopeMode && len(f.Data) >= 2 {
|
|
if sel := len(f.Data) >= 3; sel != b.dualScope {
|
|
applog.Printf("icom scope: the rig answers 0x%02X with %d bytes — using the %s form",
|
|
f.Data[0], len(f.Data)-1, map[bool]string{true: "27 xx 00 …", false: "27 xx …"}[sel])
|
|
b.dualScope = sel
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if rawN < 24 {
|
|
rawN++
|
|
applog.Printf("icom scope raw #%d: len=%d data=[% X]", rawN, len(f.Data), f.Data)
|
|
}
|
|
// Frame layout after the 0x00 sub-command byte is one of:
|
|
// [selector][seq][total][data…] (IC-7610/9700 AND, as the logs prove,
|
|
// the IC-7300 — selector 0x00 = main)
|
|
// [seq][total][data…] (a rig with no selector byte)
|
|
// The sequence starts at 1, so a 0x00 at Data[1] can only be the main-scope
|
|
// selector — never a valid sequence. Keying this off the rig ADDRESS was the
|
|
// long-standing "blank scope on the IC-7300" bug: the 7300 DOES send the
|
|
// selector, so idx stayed 1, seq was read as 0x00, and every frame was
|
|
// dropped. Detect it from the byte itself instead — address-independent.
|
|
idx := 1
|
|
if len(f.Data) >= 2 && f.Data[1] == 0x00 {
|
|
idx = 2 // leading main-scope selector byte present
|
|
} else if b.dualScope {
|
|
continue // dual-scope rig SUB frame (selector != 0x00) — main only
|
|
}
|
|
if len(f.Data) < idx+2 {
|
|
continue
|
|
}
|
|
seq, tot := f.Data[idx], f.Data[idx+1]
|
|
region := f.Data[idx+2:]
|
|
if seq == 0 || tot == 0 {
|
|
continue
|
|
}
|
|
if tot == 1 {
|
|
// Network single-frame sweep: the WHOLE sweep is in one frame —
|
|
// region = [info][low 5-BCD][high 5-BCD][amplitude bytes…]. Parse the
|
|
// edges and take the rest as the trace, then publish immediately.
|
|
// (USB splits this across 21 frames; the net rig sends it as one.)
|
|
if len(region) >= 11 {
|
|
// Net single-frame layout (IC-7610): region = [info 1B][freq1 5-BCD]
|
|
// [freq2 5-BCD][amplitude bytes]. The two freq fields depend on the
|
|
// scope mode: FIXED sends [low-edge][high-edge] (both absolute), CENTRE
|
|
// sends [centre][span]. Tell them apart by magnitude — a second value
|
|
// BIGGER than the first is a real high edge; a small one is a span
|
|
// (e.g. 14.200 MHz + 100 kHz → centred 14.150-14.250; 21.000 +
|
|
// 21.070 → fixed 21.000-21.070). Guessing wrong here gave the absurd
|
|
// 21.000-42.070 span (low + a 21 MHz "span").
|
|
v1, ok1 := civ.BCDToFreq(region[1:6])
|
|
v2, ok2 := civ.BCDToFreq(region[6:11])
|
|
if !ok1 || !ok2 {
|
|
// Not decimal BCD — the frame was read at the wrong offset. Keep the
|
|
// edges we had; a wrong span is drawn, noticed, and mistrusted.
|
|
break
|
|
}
|
|
var low, high int64
|
|
if v2 > v1 {
|
|
low, high = v1, v2 // absolute low/high edges (fixed edge set)
|
|
} else {
|
|
low, high = v1-v2/2, v1+v2/2 // centre + span (centre-on-VFO)
|
|
}
|
|
amp := append([]byte(nil), region[11:]...)
|
|
b.scopeMu.Lock()
|
|
b.scopeLow, b.scopeHigh = low, high
|
|
b.scopeAmp = amp
|
|
b.scopeSeq++
|
|
firstLog := !b.scopeSeen
|
|
b.scopeSeen = true
|
|
b.scopeMu.Unlock()
|
|
if firstLog {
|
|
head := region
|
|
if len(head) > 16 {
|
|
head = head[:16]
|
|
}
|
|
applog.Printf("icom scope (net 1-frame): head=[% X] v1=%d v2=%d → %d..%d Hz points=%d", head, v1, v2, low, high, len(amp))
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if seq == 1 { // header frame — begins a new sweep, no waveform data
|
|
regions = make(map[byte][]byte)
|
|
total = tot
|
|
if len(region) >= 11 { // [info][freq1 5-BCD][freq2 5-BCD]
|
|
// Same center/fixed disambiguation as the single-frame (net) path:
|
|
// FIXED sends [low-edge][high-edge] (both absolute), CENTER sends
|
|
// [center][span]. Tell them apart by magnitude — a second value
|
|
// BIGGER than the first is a real high edge; a smaller one is a
|
|
// span. Without this, an IC-7300 in center (VFO-following) mode set
|
|
// high = span (e.g. 100 kHz), so high < low and the panadapter had
|
|
// no valid range to map the trace onto → blank scope. FIXED mode
|
|
// parsed fine, which is why only center mode was broken.
|
|
v1, ok1 := civ.BCDToFreq(region[1:6])
|
|
v2, ok2 := civ.BCDToFreq(region[6:11])
|
|
if !ok1 || !ok2 {
|
|
// Not decimal BCD — the frame was read at the wrong offset. Keep the
|
|
// edges we had; a wrong span is drawn, noticed, and mistrusted.
|
|
break
|
|
}
|
|
var low, high int64
|
|
if v2 > v1 {
|
|
low, high = v1, v2 // absolute low/high edges (fixed)
|
|
} else {
|
|
low, high = v1-v2/2, v1+v2/2 // centre + span (centre-on-VFO)
|
|
}
|
|
b.scopeMu.Lock()
|
|
b.scopeLow, b.scopeHigh = low, high
|
|
b.scopeMu.Unlock()
|
|
}
|
|
continue
|
|
}
|
|
if total == 0 || tot != total {
|
|
continue // stray frame from a sweep whose header we missed
|
|
}
|
|
regions[seq] = append([]byte(nil), region...)
|
|
if seq == total { // last data frame — assemble in sequence order
|
|
b.assembleSweep(regions, total)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *IcomSerial) assembleSweep(regions map[byte][]byte, total byte) {
|
|
var amp []byte
|
|
for s := byte(2); s <= total; s++ {
|
|
amp = append(amp, regions[s]...)
|
|
}
|
|
b.scopeMu.Lock()
|
|
b.scopeAmp = amp
|
|
b.scopeSeq++
|
|
firstLog := !b.scopeSeen
|
|
b.scopeSeen = true
|
|
low, high := b.scopeLow, b.scopeHigh
|
|
b.scopeMu.Unlock()
|
|
if firstLog {
|
|
applog.Printf("icom scope: first sweep — model=%s total=%d points=%d edges=%d..%d Hz",
|
|
b.model, total, len(amp), low, high)
|
|
}
|
|
}
|
|
|
|
// execScope sends a 0x27 SET and, if the rig rejects it, sends it once more in
|
|
// the other shape — with or without the leading main/sub selector byte — and
|
|
// remembers which one this rig speaks.
|
|
//
|
|
// The shape used to be decided from the CI-V address, which meant every new
|
|
// model was a blank scope until someone reported it: the IC-7851 (0x8E) rejects
|
|
// "27 11 01" outright and wants "27 11 00 01", exactly as the IC-7610 does not.
|
|
// A rejection is a cheap and unambiguous answer, so ask the rig instead of
|
|
// keeping a list. Only the SET commands need this — the waveform parser already
|
|
// detects the selector per frame.
|
|
func (b *IcomSerial) execScope(what string, sub byte, args ...byte) error {
|
|
try := func(sel bool) error {
|
|
p := []byte{civ.CmdScope, sub}
|
|
if sel {
|
|
p = append(p, 0x00) // main scope
|
|
}
|
|
return b.exec(append(p, args...)...)
|
|
}
|
|
err := try(b.dualScope)
|
|
// Only a REJECTION means "wrong shape". A timeout says nothing (several
|
|
// firmwares simply don't ack a 0x27 set), and retrying it in the other shape
|
|
// would flip a working rig onto the wrong one.
|
|
if err == nil || !strings.Contains(err.Error(), "rejected") {
|
|
return err
|
|
}
|
|
err2 := try(!b.dualScope)
|
|
applog.Printf("icom scope: %s rejected in the %s form — the other form gave: %v",
|
|
what, map[bool]string{true: "27 xx 00 …", false: "27 xx …"}[b.dualScope], err2)
|
|
if err2 == nil {
|
|
b.dualScope = !b.dualScope
|
|
applog.Printf("icom scope: %s rejected — this rig wants the %s form (selector=%v)",
|
|
what, map[bool]string{true: "27 xx 00 …", false: "27 xx …"}[b.dualScope], b.dualScope)
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// SetScope enables or disables the spectrum scope. Two commands are needed and
|
|
// RS-BA1 sends both: 0x27 0x10 turns the scope DISPLAY on (without it the rig
|
|
// streams nothing — the case when we're remote and can't touch the front panel),
|
|
// and 0x27 0x11 turns the waveform data OUTPUT over CI-V on. While on, the reader
|
|
// routes every 0x27 frame to scopeLoop.
|
|
func (b *IcomSerial) SetScope(on bool) error {
|
|
if on {
|
|
// Context for the scope-diagnostic log: which rig + whether we expect the
|
|
// dual-scope (main/sub) frame layout. If the IC-7300 (single scope) streams
|
|
// but nothing shows, compare its `icom scope raw` frames against this.
|
|
applog.Printf("icom scope: enable on rig=%q addr=0x%02X dualScope=%v (expect %s frame layout)",
|
|
b.model, b.rigAddr, b.dualScope, map[bool]string{true: "27 00 [MS] [seq] [total] …", false: "27 00 [seq] [total] …"}[b.dualScope])
|
|
// Turn the scope DISPLAY on — needed so the rig actually streams (esp. when
|
|
// remote and we can't touch the front panel). ONLY on enable: we must never
|
|
// switch the display OFF, because that is the operator's own scope on the
|
|
// radio, and closing OpsLog (SetScope(false)) blanking a local IC-7300's
|
|
// screen is exactly the regression this avoids. Some firmwares don't ack a
|
|
// 0x27 set; a timeout isn't fatal, so log and continue.
|
|
if err := b.execScope("display on", civ.SubScopeOnOff, 0x01); err != nil {
|
|
applog.Printf("icom scope: display on ack: %v", err)
|
|
}
|
|
}
|
|
// Waveform data OUTPUT over CI-V: enabled with the scope, and — crucially —
|
|
// the ONLY thing we switch off on disable, so the radio's own scope display is
|
|
// left exactly as the operator had it.
|
|
if err := b.execScope("data output", civ.SubScopeOn, boolByte(on)); err != nil {
|
|
applog.Printf("icom scope: output on=%v ack: %v", on, err)
|
|
// Rejected in both shapes = the command does not exist on this rig, which
|
|
// is a permanent answer and not a bad guess on our part. Remember it: the
|
|
// panel can then say so, and we stop asking a radio that has already
|
|
// said no.
|
|
if strings.Contains(err.Error(), "rejected") {
|
|
applog.Printf("icom scope: %s does not stream its scope over CI-V — control commands only", b.model)
|
|
b.scopeMu.Lock()
|
|
b.scopeUnsupported = true
|
|
b.scopeMu.Unlock()
|
|
}
|
|
}
|
|
b.scopeMu.Lock()
|
|
b.scopeOn = on
|
|
if !on {
|
|
b.scopeAmp = nil
|
|
}
|
|
b.scopeMu.Unlock()
|
|
if on {
|
|
// Fire read requests for the mode/span/edge settings; their 0x27 responses
|
|
// route to scopeLoop, which logs each once so we can confirm the layout.
|
|
// Best-effort (fire-and-forget) — responses are 0x27, not FB/FA acks.
|
|
b.scopeReadCfg()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// scopeReadCfg requests the scope's mode/span/edge settings for the diagnostic
|
|
// log. Sent both with and without the leading main/sub selector byte so we
|
|
// capture whichever form the rig answers.
|
|
func (b *IcomSerial) scopeReadCfg() {
|
|
for _, sub := range []byte{civ.SubScopeMode, civ.SubScopeSpan, civ.SubScopeEdge} {
|
|
_ = b.write(civ.CmdScope, sub)
|
|
if b.dualScope {
|
|
_ = b.write(civ.CmdScope, sub, 0x00)
|
|
}
|
|
}
|
|
}
|
|
|
|
// SetScopeMode selects fixed-span (true) or center-on-VFO (false). Center mode
|
|
// makes the scope follow the VFO, so tuning pans the view left/right.
|
|
func (b *IcomSerial) SetScopeMode(fixed bool) error {
|
|
mode := boolByte(fixed) // 0 = center, 1 = fixed (verify on rig via the cfg log)
|
|
if err := b.execScope("set mode", civ.SubScopeMode, mode); err != nil {
|
|
applog.Printf("icom scope: set mode fixed=%v ack: %v", fixed, err)
|
|
}
|
|
b.scopeMu.Lock()
|
|
b.scopeFixed = fixed
|
|
b.scopeMu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// icScopeRanges maps a frequency to the IC-7610's spectrum edge-frequency range
|
|
// id (from Hamlib's ic7610 caps). SetScopeEdges needs it to address the right
|
|
// fixed-edge memory. Each range spans a chunk of the tuning range.
|
|
var icScopeRanges = []struct {
|
|
lo, hi int64
|
|
id byte
|
|
}{
|
|
{30_000, 1_600_000, 1}, {1_600_000, 2_000_000, 2}, {2_000_000, 6_000_000, 3},
|
|
{6_000_000, 8_000_000, 4}, {8_000_000, 11_000_000, 5}, {11_000_000, 15_000_000, 6},
|
|
{15_000_000, 20_000_000, 7}, {20_000_000, 22_000_000, 8}, {22_000_000, 26_000_000, 9},
|
|
{26_000_000, 30_000_000, 10}, {30_000_000, 45_000_000, 11}, {45_000_000, 60_000_000, 12},
|
|
}
|
|
|
|
// scopeRangeBCD returns the range id (as a 1-byte BCD) for a frequency, or 0 if
|
|
// out of range.
|
|
func scopeRangeBCD(freq int64) byte {
|
|
for _, r := range icScopeRanges {
|
|
if freq >= r.lo && freq < r.hi {
|
|
return byte(r.id/10)<<4 | byte(r.id%10) // 1-byte BCD (11 → 0x11)
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// SetScopeEdges points the FIXED-mode scope at [low..high] by writing them into
|
|
// the rig's fixed-edge memory (edge set 1) and making that set active. This is
|
|
// how the panel's "centre on VFO" and pan ◀/▶ work: they just compute VFO±50 kHz
|
|
// (and shift it) and set the edges — no dependence on the waveform decode. CI-V:
|
|
// 0x27 0x14 fixed, 0x27 0x16 set 1 active, 0x27 0x1e [range][set][low][high].
|
|
func (b *IcomSerial) SetScopeEdges(low, high int64) error {
|
|
if low <= 0 || high <= low {
|
|
return fmt.Errorf("icom scope: bad edges %d..%d", low, high)
|
|
}
|
|
rangeID := scopeRangeBCD((low + high) / 2)
|
|
if rangeID == 0 {
|
|
return fmt.Errorf("icom scope: freq out of range")
|
|
}
|
|
_ = b.execScope("fixed mode", civ.SubScopeMode, 0x01)
|
|
_ = b.execScope("edge set 1", civ.SubScopeEdge, 0x01)
|
|
payload := append([]byte{civ.CmdScope, civ.SubScopeFixEdge, rangeID, 0x01}, civ.FreqToBCD(low)...)
|
|
payload = append(payload, civ.FreqToBCD(high)...)
|
|
b.scopeMu.Lock()
|
|
b.scopeFixed = true
|
|
b.scopeMu.Unlock()
|
|
return b.exec(payload...)
|
|
}
|
|
|
|
// SetRIT sets the RIT/ΔTX offset (signed Hz, ±9999).
|
|
func (b *IcomSerial) SetRIT(hz int) error {
|
|
if err := b.exec(append([]byte{civ.CmdRIT, civ.SubRITFreq}, civ.RITToBCD(hz)...)...); err != nil {
|
|
return err
|
|
}
|
|
if hz < -9999 {
|
|
hz = -9999
|
|
}
|
|
if hz > 9999 {
|
|
hz = 9999
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.RITHz = hz })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetRITOn(on bool) error {
|
|
if err := b.exec(civ.CmdRIT, civ.SubRITOn, boolByte(on)); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.RITOn = on })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetXITOn(on bool) error {
|
|
if err := b.exec(civ.CmdRIT, civ.SubXITOn, boolByte(on)); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.XITOn = on })
|
|
return nil
|
|
}
|
|
|
|
// SendCW keys a CW message through the rig's internal keyer (CI-V 0x17). The
|
|
// text is upper-cased and filtered to keyer-legal characters; the radio must be
|
|
// in CW mode. Messages longer than 30 characters are split across several 0x17
|
|
// commands (the rig queues them).
|
|
func (b *IcomSerial) SendCW(text string) error {
|
|
msg := civ.FilterCW(text)
|
|
if msg == "" {
|
|
applog.Printf("icom cw: nothing to send (filtered %q → empty)", text)
|
|
return nil
|
|
}
|
|
applog.Printf("icom cw: send %q (%d chars) to rig 0x%02X", msg, len(msg), b.rigAddr)
|
|
for len(msg) > 0 {
|
|
n := len(msg)
|
|
if n > 30 {
|
|
n = 30
|
|
}
|
|
chunk := msg[:n]
|
|
msg = msg[n:]
|
|
payload := append([]byte{civ.CmdSendCW}, []byte(chunk)...)
|
|
if err := b.write(payload...); err != nil {
|
|
applog.Printf("icom cw: write failed: %v", err)
|
|
return err
|
|
}
|
|
// A missing ack is NOT fatal: some firmwares don't acknowledge 0x17, and
|
|
// the message bytes were already written. Only an explicit NG (0xFA) means
|
|
// the rig refused it (typically: not in CW mode / break-in off).
|
|
f, err := b.recv(icomCmdTimeout, func(d civ.Decoded) bool { return d.Cmd == civ.OK || d.Cmd == civ.NG })
|
|
if err != nil {
|
|
applog.Printf("icom cw: chunk %q written, no ack (sent anyway): %v", chunk, err)
|
|
} else if f.Cmd == civ.NG {
|
|
applog.Printf("icom cw: rig REJECTED CW (0xFA) — put the rig in CW mode / enable break-in")
|
|
return fmt.Errorf("icom: rig rejected CW — check CW mode / break-in")
|
|
} else {
|
|
applog.Printf("icom cw: chunk %q acked OK", chunk)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetBreakIn sets CW break-in (0=OFF, 1=SEMI, 2=FULL). Break-in must be on for
|
|
// the 0x17 CW keyer to actually switch the rig to transmit.
|
|
func (b *IcomSerial) SetBreakIn(mode int) error {
|
|
if mode < 0 {
|
|
mode = 0
|
|
}
|
|
if mode > 2 {
|
|
mode = 2
|
|
}
|
|
applog.Printf("icom cw: set break-in %d", mode)
|
|
if err := b.exec(civ.CmdSwitch, civ.SubSwBreakIn, byte(mode)); err != nil {
|
|
applog.Printf("icom cw: set break-in failed: %v", err)
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.BreakIn = mode })
|
|
return nil
|
|
}
|
|
|
|
// StopCW aborts a CW message currently being sent (0x17 with the 0xFF stop code).
|
|
func (b *IcomSerial) StopCW() error {
|
|
applog.Printf("icom cw: stop")
|
|
if err := b.write(civ.CmdSendCW, civ.StopCWByte); err != nil {
|
|
return err
|
|
}
|
|
_, _ = b.recv(icomCmdTimeout, func(d civ.Decoded) bool { return d.Cmd == civ.OK || d.Cmd == civ.NG })
|
|
return nil
|
|
}
|
|
|
|
// SetKeySpeed sets the CW keyer speed in WPM (CmdLevel 0x0C).
|
|
func (b *IcomSerial) SetKeySpeed(wpm int) error {
|
|
lvl := civ.WPMToKeyLevel(wpm)
|
|
applog.Printf("icom cw: set key speed %d WPM (level %d)", wpm, lvl)
|
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelKeySpeed}, civ.LevelToBCD(lvl)...)...); err != nil {
|
|
applog.Printf("icom cw: set key speed failed: %v", err)
|
|
return err
|
|
}
|
|
if wpm < civ.KeyMinWPM {
|
|
wpm = civ.KeyMinWPM
|
|
}
|
|
if wpm > civ.KeyMaxWPM {
|
|
wpm = civ.KeyMaxWPM
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.KeySpeedWPM = wpm })
|
|
return nil
|
|
}
|
|
|
|
// readRIT reads the offset + RIT/ΔTX on-off flags into st (best-effort).
|
|
func (b *IcomSerial) readRIT(st *IcomTXState) {
|
|
if err := b.write(civ.CmdRIT, civ.SubRITFreq); err == nil {
|
|
if f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
|
return d.Cmd == civ.CmdRIT && len(d.Data) >= 4 && d.Data[0] == civ.SubRITFreq
|
|
}); err == nil {
|
|
st.RITHz = civ.BCDToRIT(f.Data[1:4])
|
|
}
|
|
}
|
|
if v, ok := b.readSwitchSub(civ.CmdRIT, civ.SubRITOn); ok {
|
|
st.RITOn = v != 0
|
|
}
|
|
if v, ok := b.readSwitchSub(civ.CmdRIT, civ.SubXITOn); ok {
|
|
st.XITOn = v != 0
|
|
}
|
|
}
|
|
|
|
// readSwitchSub reads a 1-byte on/off value for cmd+sub (generalises readSwitch).
|
|
func (b *IcomSerial) readSwitchSub(cmd, sub byte) (byte, bool) {
|
|
if err := b.write(cmd, sub); err != nil {
|
|
return 0, false
|
|
}
|
|
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
|
return d.Cmd == cmd && len(d.Data) >= 2 && d.Data[0] == sub
|
|
})
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return f.Data[1], true
|
|
}
|
|
|
|
// ScopeData returns a copy of the latest reassembled sweep as a number array.
|
|
func (b *IcomSerial) ScopeData() ScopeSweep {
|
|
b.scopeMu.Lock()
|
|
defer b.scopeMu.Unlock()
|
|
amp := make([]int, len(b.scopeAmp))
|
|
for i, v := range b.scopeAmp {
|
|
amp[i] = int(v)
|
|
}
|
|
return ScopeSweep{Amp: amp, Seq: b.scopeSeq, LowHz: b.scopeLow, HighHz: b.scopeHigh, Fixed: b.scopeFixed,
|
|
Unsupported: b.scopeUnsupported}
|
|
}
|
|
|
|
// exec sends a set command and waits for the rig's OK (FB) / NG (FA) ack.
|
|
func (b *IcomSerial) exec(payload ...byte) error {
|
|
if err := b.write(payload...); err != nil {
|
|
return err
|
|
}
|
|
f, err := b.recv(icomCmdTimeout, func(d civ.Decoded) bool {
|
|
return d.Cmd == civ.OK || d.Cmd == civ.NG
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if f.Cmd == civ.NG {
|
|
return fmt.Errorf("icom: rig rejected command 0x%02X", payload[0])
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) readFreq() (int64, error) {
|
|
if err := b.write(civ.CmdReadFreq); err != nil {
|
|
return 0, err
|
|
}
|
|
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
|
return d.Cmd == civ.CmdReadFreq || d.Cmd == civ.CmdTransceiveFreq
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
hz, ok := civ.BCDToFreq(f.Data)
|
|
if !ok {
|
|
// A frame that is not decimal BCD is a desynchronised read, not a
|
|
// frequency. Reporting the error keeps the caller on its last good value
|
|
// instead of publishing a number like 16445550000 Hz to the display.
|
|
return 0, fmt.Errorf("icom: frequency frame is not BCD: % X", f.Data)
|
|
}
|
|
return hz, nil
|
|
}
|
|
|
|
// readSplit reads the rig's split state (CI-V 0x0F). 0x01 = split on; 0x10/0x11
|
|
// are repeater duplex (not split) and 0x00 is off.
|
|
func (b *IcomSerial) readSplit() (on bool, ok bool) {
|
|
if err := b.write(civ.CmdSplit); err != nil {
|
|
return false, false
|
|
}
|
|
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
|
return d.Cmd == civ.CmdSplit && len(d.Data) >= 1
|
|
})
|
|
if err != nil {
|
|
return false, false
|
|
}
|
|
return f.Data[0] == 0x01, true
|
|
}
|
|
|
|
// readTXFreq reads the UNSELECTED VFO's frequency (CI-V 0x25/01) — the TX VFO
|
|
// when the rig is in split. Supported on IC-7610/7300/7851/705/9700 and similar.
|
|
func (b *IcomSerial) readTXFreq() (int64, bool) {
|
|
if err := b.write(civ.CmdVfoFreq, civ.SubVfoUnselected); err != nil {
|
|
return 0, false
|
|
}
|
|
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
|
return d.Cmd == civ.CmdVfoFreq && len(d.Data) >= 6 && d.Data[0] == civ.SubVfoUnselected
|
|
})
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
hz, ok := civ.BCDToFreq(f.Data[1:])
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
return hz, true
|
|
}
|
|
|
|
// readTX reads the transmit state (CI-V 0x1C 0x00): non-zero data = keyed.
|
|
func (b *IcomSerial) readTX() bool {
|
|
on, _ := b.readTXWithin(icomDSPTimeout)
|
|
return on
|
|
}
|
|
|
|
// icomPTTVerifyTimeout is how long the PTT check waits. Long, deliberately: it
|
|
// runs only after a command was already given up on, so it costs nothing in the
|
|
// normal case and everything depends on it in the abnormal one.
|
|
const icomPTTVerifyTimeout = 700 * time.Millisecond
|
|
|
|
// readTXWithin reads the transmit state, reporting whether the rig ANSWERED.
|
|
//
|
|
// The two are different questions and the caller usually needs both: "not
|
|
// transmitting" and "did not say" are the same bool but not the same fact.
|
|
func (b *IcomSerial) readTXWithin(timeout time.Duration) (on bool, answered bool) {
|
|
if err := b.write(civ.CmdPTT, civ.SubPTT); err != nil {
|
|
return false, false
|
|
}
|
|
f, err := b.recv(timeout, func(d civ.Decoded) bool {
|
|
return d.Cmd == civ.CmdPTT && len(d.Data) >= 2 && d.Data[0] == civ.SubPTT
|
|
})
|
|
if err != nil {
|
|
return false, false
|
|
}
|
|
return f.Data[1] != 0, true
|
|
}
|
|
|
|
// readMeter reads a meter (CI-V 0x15) and returns it scaled to 0-100.
|
|
func (b *IcomSerial) readMeter(sub byte) (int, bool) {
|
|
if err := b.write(civ.CmdMeter, sub); err != nil {
|
|
return 0, false
|
|
}
|
|
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
|
return d.Cmd == civ.CmdMeter && len(d.Data) >= 3 && d.Data[0] == sub
|
|
})
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return from255(civ.BCDToLevel(f.Data[1:3])), true
|
|
}
|
|
|
|
func (b *IcomSerial) readMode() (byte, bool) {
|
|
if err := b.write(civ.CmdReadMode); err != nil {
|
|
return 0, false
|
|
}
|
|
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
|
return (d.Cmd == civ.CmdReadMode || d.Cmd == civ.CmdTransceiveMode) && len(d.Data) >= 1
|
|
})
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return f.Data[0], true
|
|
}
|
|
|
|
func (b *IcomSerial) readDataMode() bool {
|
|
if err := b.write(civ.CmdExtra, civ.SubDataMode); err != nil {
|
|
return false
|
|
}
|
|
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
|
return d.Cmd == civ.CmdExtra && len(d.Data) >= 2 && d.Data[0] == civ.SubDataMode
|
|
})
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return f.Data[1] != 0
|
|
}
|
|
|
|
// modeCode maps an ADIF mode to an Icom mode byte plus whether the data-mode
|
|
// flag should be set. SSB sideband follows the usual convention (LSB below
|
|
// 10 MHz, USB above); the frequency just commanded is preferred over the last
|
|
// poll so a clicked spot (freq then mode) picks the right sideband immediately.
|
|
func (b *IcomSerial) modeCode(mode string) (code byte, data bool, err error) {
|
|
freq := b.curFreq
|
|
if b.lastSetFreq > 0 && time.Since(b.lastSetFreqAt) < 5*time.Second {
|
|
freq = b.lastSetFreq
|
|
}
|
|
usb := byte(civ.ModeUSB)
|
|
if freq > 0 && freq < 10_000_000 {
|
|
usb = civ.ModeLSB
|
|
}
|
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
|
case "CW":
|
|
return civ.ModeCW, false, nil
|
|
case "SSB":
|
|
return usb, false, nil
|
|
case "AM":
|
|
return civ.ModeAM, false, nil
|
|
case "FM":
|
|
return civ.ModeFM, false, nil
|
|
case "RTTY", "FSK":
|
|
return civ.ModeRTTY, false, nil
|
|
case "PSK":
|
|
// The console button, not the ADIF mode: the rigs that HAVE a native PSK
|
|
// mode (7610/7760/7851 class) get it; a 7300 NAKs 0x12 and the button
|
|
// stays dead there, exactly as RS-BA1's does. Soundcard PSK31 stays on
|
|
// USB-D below, where every rig can do it.
|
|
return civ.ModePSK, false, nil
|
|
case "FT8", "FT4", "PSK31", "MFSK", "JS8", "JT65", "JT9", "OLIVIA", "DATA", "DIGITALVOICE":
|
|
// Digital data modes ride on USB with the data flag set (FT8 etc.).
|
|
return civ.ModeUSB, true, nil
|
|
}
|
|
return 0, false, fmt.Errorf("icom: unsupported mode %q", mode)
|
|
}
|
|
|
|
// ── IcomController: receive-DSP controls for the Icom tab ───────────────────
|
|
|
|
func (b *IcomSerial) IcomState() IcomTXState {
|
|
// Asking for the snapshot is what marks the console as being watched, which
|
|
// is what re-enables the meter polling in ReadState.
|
|
b.dspWantMu.Lock()
|
|
b.dspWantAt = time.Now()
|
|
b.dspWantMu.Unlock()
|
|
|
|
b.dspMu.Lock()
|
|
defer b.dspMu.Unlock()
|
|
return b.dsp
|
|
}
|
|
|
|
// panelWatched reports whether the Icom console asked for the DSP snapshot
|
|
// recently — i.e. whether anything is on screen to read the meters.
|
|
func (b *IcomSerial) panelWatched() bool {
|
|
b.dspWantMu.Lock()
|
|
at := b.dspWantAt
|
|
b.dspWantMu.Unlock()
|
|
return !at.IsZero() && time.Since(at) < 3*time.Second
|
|
}
|
|
|
|
// RefreshIcom re-reads the whole DSP snapshot from the rig. Runs on the CAT
|
|
// goroutine (dispatched via IcomDo).
|
|
func (b *IcomSerial) RefreshIcom() error {
|
|
if b.port == nil {
|
|
return fmt.Errorf("not connected")
|
|
}
|
|
b.readDSP()
|
|
return nil
|
|
}
|
|
|
|
// readDSP polls every DSP value once and replaces the cache. Best-effort: a
|
|
// value the rig doesn't answer keeps its previous cached value rather than
|
|
// stalling (each read has a short timeout).
|
|
func (b *IcomSerial) readDSP() {
|
|
st := IcomTXState{Available: true, Model: b.model}
|
|
b.dspMu.Lock()
|
|
// Preserve the live fields ReadState polls (mode, TX/split, meters) — readDSP
|
|
// only refreshes the set-once DSP values.
|
|
st.Mode = b.dsp.Mode
|
|
st.Transmitting = b.dsp.Transmitting
|
|
st.Split = b.dsp.Split
|
|
st.SMeter = b.dsp.SMeter
|
|
st.PowerMeter = b.dsp.PowerMeter
|
|
st.SWRMeter = b.dsp.SWRMeter
|
|
b.dspMu.Unlock()
|
|
|
|
if v, ok := b.readLevel(civ.SubLevelAF); ok {
|
|
st.AFGain = from255(v)
|
|
}
|
|
if v, ok := b.readLevel(civ.SubLevelRF); ok {
|
|
st.RFGain = from255(v)
|
|
}
|
|
if v, ok := b.readLevel(civ.SubLevelRFPower); ok {
|
|
st.RFPower = from255(v)
|
|
}
|
|
if v, ok := b.readLevel(civ.SubLevelMic); ok {
|
|
st.MicGain = from255(v)
|
|
}
|
|
if v, ok := b.readLevel(civ.SubLevelNR); ok {
|
|
st.NRLevel = from255(v)
|
|
}
|
|
if v, ok := b.readLevel(civ.SubLevelNB); ok {
|
|
st.NBLevel = from255(v)
|
|
}
|
|
if v, ok := b.readSwitch(civ.SubSwNB); ok {
|
|
st.NB = v != 0
|
|
}
|
|
if v, ok := b.readSwitch(civ.SubSwNR); ok {
|
|
st.NR = v != 0
|
|
}
|
|
if v, ok := b.readSwitch(civ.SubSwANF); ok {
|
|
st.ANF = v != 0
|
|
}
|
|
if v, ok := b.readSwitch(civ.SubSwAPF); ok {
|
|
st.APF = v != 0
|
|
}
|
|
if v, ok := b.readSwitch(civ.SubSwAGC); ok {
|
|
st.AGC = agcName(v)
|
|
}
|
|
if v, ok := b.readSwitch(civ.SubSwPreamp); ok {
|
|
st.Preamp = int(v)
|
|
}
|
|
if v, ok := b.readAtt(); ok {
|
|
st.Att = v
|
|
}
|
|
if _, f, ok := b.readModeFilter(); ok {
|
|
st.Filter = int(f)
|
|
}
|
|
b.readRIT(&st)
|
|
if v, ok := b.readLevel(civ.SubLevelKeySpeed); ok {
|
|
st.KeySpeedWPM = civ.KeyLevelToWPM(v)
|
|
}
|
|
if v, ok := b.readSwitch(civ.SubSwBreakIn); ok {
|
|
st.BreakIn = int(v)
|
|
}
|
|
// Antenna + filter fine controls + TX extras.
|
|
if v, ok := b.readAnt(); ok {
|
|
st.Antenna = v
|
|
}
|
|
if v, ok := b.readLevel(civ.SubLevelPBTIn); ok {
|
|
st.PBTInner = from255(v)
|
|
}
|
|
if v, ok := b.readLevel(civ.SubLevelPBTOut); ok {
|
|
st.PBTOuter = from255(v)
|
|
}
|
|
if v, ok := b.readSwitch(civ.SubSwMN); ok {
|
|
st.ManualNotch = v != 0
|
|
}
|
|
if v, ok := b.readLevel(civ.SubLevelNotch); ok {
|
|
st.NotchPos = from255(v)
|
|
}
|
|
if v, ok := b.readLevel(civ.SubLevelSQL); ok {
|
|
st.Squelch = from255(v)
|
|
}
|
|
if v, ok := b.readSwitch(civ.SubSwComp); ok {
|
|
st.Comp = v != 0
|
|
}
|
|
if v, ok := b.readLevel(civ.SubLevelComp); ok {
|
|
st.CompLevel = from255(v)
|
|
}
|
|
if v, ok := b.readSwitch(civ.SubSwMon); ok {
|
|
st.Monitor = v != 0
|
|
}
|
|
if v, ok := b.readLevel(civ.SubLevelMon); ok {
|
|
st.MonLevel = from255(v)
|
|
}
|
|
if v, ok := b.readSwitch(civ.SubSwVOX); ok {
|
|
st.VOX = v != 0
|
|
}
|
|
if v, ok := b.readLevel(civ.SubLevelVOXGain); ok {
|
|
st.VOXGain = from255(v)
|
|
}
|
|
if v, ok := b.readLevel(civ.SubLevelAntiVOX); ok {
|
|
st.AntiVOX = from255(v)
|
|
}
|
|
|
|
b.dspMu.Lock()
|
|
b.dsp = st
|
|
b.dspMu.Unlock()
|
|
}
|
|
|
|
const icomDSPTimeout = 150 * time.Millisecond // shorter: unsupported reads mustn't stall the poll
|
|
|
|
func (b *IcomSerial) readLevel(sub byte) (int, bool) {
|
|
if err := b.write(civ.CmdLevel, sub); err != nil {
|
|
return 0, false
|
|
}
|
|
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
|
return d.Cmd == civ.CmdLevel && len(d.Data) >= 3 && d.Data[0] == sub
|
|
})
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return civ.BCDToLevel(f.Data[1:3]), true
|
|
}
|
|
|
|
func (b *IcomSerial) readSwitch(sub byte) (byte, bool) {
|
|
if err := b.write(civ.CmdSwitch, sub); err != nil {
|
|
return 0, false
|
|
}
|
|
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
|
return d.Cmd == civ.CmdSwitch && len(d.Data) >= 2 && d.Data[0] == sub
|
|
})
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return f.Data[1], true
|
|
}
|
|
|
|
func (b *IcomSerial) readAtt() (int, bool) {
|
|
if err := b.write(civ.CmdAtt); err != nil {
|
|
return 0, false
|
|
}
|
|
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
|
return d.Cmd == civ.CmdAtt && len(d.Data) >= 1
|
|
})
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return civ.BCDToByte(f.Data[0]), true
|
|
}
|
|
|
|
func (b *IcomSerial) readAnt() (int, bool) {
|
|
if err := b.write(civ.CmdAnt); err != nil {
|
|
return 0, false
|
|
}
|
|
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
|
return d.Cmd == civ.CmdAnt && len(d.Data) >= 1
|
|
})
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
if f.Data[0] == 0x01 {
|
|
return 2, true
|
|
}
|
|
return 1, true
|
|
}
|
|
|
|
func (b *IcomSerial) readModeFilter() (mode, filter byte, ok bool) {
|
|
if err := b.write(civ.CmdReadMode); err != nil {
|
|
return 0, 0, false
|
|
}
|
|
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
|
return d.Cmd == civ.CmdReadMode && len(d.Data) >= 2
|
|
})
|
|
if err != nil {
|
|
return 0, 0, false
|
|
}
|
|
return f.Data[0], f.Data[1], true
|
|
}
|
|
|
|
func (b *IcomSerial) SetAFGain(p int) error {
|
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelAF}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.AFGain = clampPct(p) })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetRFGain(p int) error {
|
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelRF}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.RFGain = clampPct(p) })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetNB(on bool) error {
|
|
if err := b.exec(civ.CmdSwitch, civ.SubSwNB, boolByte(on)); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.NB = on })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetNBLevel(p int) error {
|
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelNB}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.NBLevel = clampPct(p) })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetNR(on bool) error {
|
|
if err := b.exec(civ.CmdSwitch, civ.SubSwNR, boolByte(on)); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.NR = on })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetNRLevel(p int) error {
|
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelNR}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.NRLevel = clampPct(p) })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetANF(on bool) error {
|
|
if err := b.exec(civ.CmdSwitch, civ.SubSwANF, boolByte(on)); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.ANF = on })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetAPF(on bool) error {
|
|
if err := b.exec(civ.CmdSwitch, civ.SubSwAPF, boolByte(on)); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.APF = on })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetAGC(name string) error {
|
|
v := agcValue(name)
|
|
if v == 0 {
|
|
return fmt.Errorf("icom: invalid AGC %q", name)
|
|
}
|
|
if err := b.exec(civ.CmdSwitch, civ.SubSwAGC, v); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.AGC = strings.ToUpper(name) })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetPreamp(n int) error {
|
|
if n < 0 || n > 2 {
|
|
return fmt.Errorf("icom: invalid preamp %d", n)
|
|
}
|
|
if err := b.exec(civ.CmdSwitch, civ.SubSwPreamp, byte(n)); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.Preamp = n })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetAtt(db int) error {
|
|
if err := b.exec(civ.CmdAtt, civ.ByteToBCD(db)); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.Att = db })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetIcomFilter(n int) error {
|
|
if n < 1 || n > 3 {
|
|
return fmt.Errorf("icom: invalid filter %d", n)
|
|
}
|
|
if b.curModeByte == 0 {
|
|
// Need the current mode to re-send with the chosen filter.
|
|
if m, _, ok := b.readModeFilter(); ok {
|
|
b.curModeByte = m
|
|
}
|
|
}
|
|
if err := b.exec(civ.CmdSetMode, b.curModeByte, byte(n)); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.Filter = n })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetRFPower(p int) error {
|
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelRFPower}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.RFPower = clampPct(p) })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetMicGain(p int) error {
|
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelMic}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.MicGain = clampPct(p) })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetIcomSplit(on bool) error {
|
|
if on {
|
|
// Enable split with the usual "work him up" TX offset: +1 kHz on CW,
|
|
// +5 kHz otherwise (SSB). Set the unselected (TX) VFO to RX+offset first,
|
|
// then turn split on. 0x25 0x01 + BCD sets the unselected VFO's frequency.
|
|
rx := b.curFreq
|
|
if rx <= 0 {
|
|
if hz, err := b.readFreq(); err == nil {
|
|
rx = hz
|
|
}
|
|
}
|
|
if rx > 0 {
|
|
offset := int64(5000)
|
|
if b.curModeByte == civ.ModeCW || b.curModeByte == civ.ModeCWR {
|
|
offset = 1000
|
|
}
|
|
_ = b.exec(append([]byte{civ.CmdVfoFreq, civ.SubVfoUnselected}, civ.FreqToBCD(rx+offset)...)...)
|
|
}
|
|
}
|
|
if err := b.exec(civ.CmdSplit, boolByte(on)); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.Split = on })
|
|
return nil
|
|
}
|
|
|
|
// ── Antenna ────────────────────────────────────────────────────────────────
|
|
|
|
func (b *IcomSerial) SetAntenna(n int) error {
|
|
sub := byte(0x00) // ANT1
|
|
if n == 2 {
|
|
sub = 0x01 // ANT2
|
|
}
|
|
if err := b.exec(civ.CmdAnt, sub); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) {
|
|
if n == 2 {
|
|
s.Antenna = 2
|
|
} else {
|
|
s.Antenna = 1
|
|
}
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// ── Filter: Twin PBT + manual notch ────────────────────────────────────────
|
|
|
|
func (b *IcomSerial) SetPBTInner(p int) error {
|
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelPBTIn}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.PBTInner = clampPct(p) })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetPBTOuter(p int) error {
|
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelPBTOut}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.PBTOuter = clampPct(p) })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetManualNotch(on bool) error {
|
|
if err := b.exec(civ.CmdSwitch, civ.SubSwMN, boolByte(on)); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.ManualNotch = on })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetNotchPos(p int) error {
|
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelNotch}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.NotchPos = clampPct(p) })
|
|
return nil
|
|
}
|
|
|
|
// ── TX extras: squelch / compressor / monitor / VOX ────────────────────────
|
|
|
|
func (b *IcomSerial) SetSquelch(p int) error {
|
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelSQL}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.Squelch = clampPct(p) })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetComp(on bool) error {
|
|
if err := b.exec(civ.CmdSwitch, civ.SubSwComp, boolByte(on)); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.Comp = on })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetCompLevel(p int) error {
|
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelComp}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.CompLevel = clampPct(p) })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetMonitor(on bool) error {
|
|
if err := b.exec(civ.CmdSwitch, civ.SubSwMon, boolByte(on)); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.Monitor = on })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetMonLevel(p int) error {
|
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelMon}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.MonLevel = clampPct(p) })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetVOX(on bool) error {
|
|
if err := b.exec(civ.CmdSwitch, civ.SubSwVOX, boolByte(on)); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.VOX = on })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetVOXGain(p int) error {
|
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelVOXGain}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.VOXGain = clampPct(p) })
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetAntiVOX(p int) error {
|
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelAntiVOX}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
|
return err
|
|
}
|
|
b.setCache(func(s *IcomTXState) { s.AntiVOX = clampPct(p) })
|
|
return nil
|
|
}
|
|
|
|
// TuneATU triggers a one-shot antenna-tuner tune (CI-V 0x1C 0x01 0x02).
|
|
func (b *IcomSerial) TuneATU() error {
|
|
return b.exec(civ.CmdATU, civ.SubATU, 0x02)
|
|
}
|
|
|
|
func (b *IcomSerial) setCache(fn func(*IcomTXState)) {
|
|
b.dspMu.Lock()
|
|
fn(&b.dsp)
|
|
b.dspMu.Unlock()
|
|
}
|
|
|
|
// ── small helpers ──────────────────────────────────────────────────────────
|
|
|
|
func to255(p int) int { return clampPct(p) * 255 / 100 }
|
|
func from255(v int) int { return (v*100 + 127) / 255 }
|
|
func clampPct(p int) int { return min(100, max(0, p)) }
|
|
|
|
func boolByte(on bool) byte {
|
|
if on {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func agcName(v byte) string {
|
|
switch v {
|
|
case 1:
|
|
return "FAST"
|
|
case 2:
|
|
return "MID"
|
|
case 3:
|
|
return "SLOW"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func agcValue(name string) byte {
|
|
switch strings.ToUpper(strings.TrimSpace(name)) {
|
|
case "FAST":
|
|
return 1
|
|
case "MID":
|
|
return 2
|
|
case "SLOW":
|
|
return 3
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// PlayTXAudio hands a voice-keyer message to the network audio session, when
|
|
// this rig is reached over one. A USB-connected Icom takes its audio through
|
|
// its own sound card instead, and says so.
|
|
func (b *IcomSerial) PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
|
|
// port is written once per Connect, on the CAT goroutine this is fetched
|
|
// from (IcomDo); at worst a reconnect leaves this one connection stale, and
|
|
// a stale transport fails fast on its closed done channel.
|
|
port := b.port
|
|
type txPlayer interface {
|
|
PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error
|
|
}
|
|
if p, ok := port.(txPlayer); ok {
|
|
return p.PlayTXAudio(pcm, rate, ch, bits, stop)
|
|
}
|
|
return fmt.Errorf("this rig takes transmit audio through its USB sound card, not the CAT link")
|
|
}
|