1617 lines
51 KiB
Go
1617 lines
51 KiB
Go
package cat
|
|
|
|
import (
|
|
"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
|
|
}
|
|
|
|
// 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
|
|
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)
|
|
dspLoaded bool // readDSP has run since the rig became responsive (loads all
|
|
// the panel's set-once controls once the rig actually answers)
|
|
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", b.portName, b.baud, 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.
|
|
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])
|
|
}
|
|
}
|
|
// Dual-scope rigs (IC-7610/9700) prefix each waveform frame with a main/sub
|
|
// selector byte; single-scope rigs (IC-7300…) do not.
|
|
b.dualScope = b.rigAddr == 0x98 || b.rigAddr == 0xA2
|
|
// 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 {
|
|
if at.Alive() {
|
|
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
|
|
}
|
|
return RigState{}, err // control link dead → 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
|
|
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).
|
|
tx := b.readTX()
|
|
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).
|
|
if !b.dspLoaded {
|
|
b.readDSP()
|
|
b.dspLoaded = true
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetFrequency(hz int64) error {
|
|
if hz <= 0 {
|
|
return fmt.Errorf("invalid frequency")
|
|
}
|
|
b.lastSetFreq, b.lastSetFreqAt = hz, time.Now()
|
|
return b.exec(append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)...)
|
|
}
|
|
|
|
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.exec(civ.CmdSetMode, code); err != nil {
|
|
return err
|
|
}
|
|
dataByte := byte(0)
|
|
if data {
|
|
dataByte = 1
|
|
}
|
|
// Filter 0x01 (FIL1) is the conventional default for the data-mode set.
|
|
_ = b.exec(civ.CmdExtra, civ.SubDataMode, dataByte, 0x01)
|
|
return nil
|
|
}
|
|
|
|
func (b *IcomSerial) SetPTT(on bool) error {
|
|
state := byte(0)
|
|
if on {
|
|
state = 1
|
|
}
|
|
return b.exec(civ.CmdPTT, civ.SubPTT, state)
|
|
}
|
|
|
|
// 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 {
|
|
buf := make([]byte, 0, 32)
|
|
for i := 0; i < 25; i++ {
|
|
buf = append(buf, 0xFE)
|
|
}
|
|
buf = append(buf, 0xFE, 0xFE, b.rigAddr, civ.AddrController, civ.CmdPower, 0x01, 0xFD)
|
|
_, err := b.port.Write(buf)
|
|
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()
|
|
_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, payload...))
|
|
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{}, fmt.Errorf("icom: timeout waiting for response")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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]...)
|
|
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
|
|
}
|
|
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)
|
|
}
|
|
continue
|
|
}
|
|
if rawN < 4 {
|
|
rawN++
|
|
applog.Printf("icom scope raw #%d: len=%d data=[% X]", rawN, len(f.Data), f.Data)
|
|
}
|
|
idx := 1
|
|
if b.dualScope {
|
|
if len(f.Data) < 2 || f.Data[1] != 0x00 {
|
|
continue // only the MAIN scope
|
|
}
|
|
idx = 2
|
|
}
|
|
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 := civ.BCDToFreq(region[1:6])
|
|
v2 := civ.BCDToFreq(region[6:11])
|
|
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][low 5][high 5]
|
|
low := civ.BCDToFreq(region[1:6])
|
|
high := civ.BCDToFreq(region[6:11])
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
// Some firmwares don't ack 0x27 sets; a timeout here isn't fatal, so log and
|
|
// continue rather than abort the second command.
|
|
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, boolByte(on)); err != nil {
|
|
applog.Printf("icom scope: display on=%v ack: %v", on, err)
|
|
}
|
|
if err := b.exec(civ.CmdScope, civ.SubScopeOn, boolByte(on)); err != nil {
|
|
applog.Printf("icom scope: output on=%v ack: %v", on, err)
|
|
}
|
|
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)
|
|
var payload []byte
|
|
if b.dualScope {
|
|
payload = []byte{civ.CmdScope, civ.SubScopeMode, 0x00, mode}
|
|
} else {
|
|
payload = []byte{civ.CmdScope, civ.SubScopeMode, mode}
|
|
}
|
|
if err := b.exec(payload...); 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")
|
|
}
|
|
if b.dualScope {
|
|
_ = b.exec(civ.CmdScope, civ.SubScopeMode, 0x00, 0x01) // fixed mode (main)
|
|
_ = b.exec(civ.CmdScope, civ.SubScopeEdge, 0x00, 0x01) // activate edge set 1
|
|
} else {
|
|
_ = b.exec(civ.CmdScope, civ.SubScopeMode, 0x01)
|
|
_ = b.exec(civ.CmdScope, 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}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
return civ.BCDToFreq(f.Data), 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
|
|
}
|
|
return civ.BCDToFreq(f.Data[1:]), true
|
|
}
|
|
|
|
// readTX reads the transmit state (CI-V 0x1C 0x00): non-zero data = keyed.
|
|
func (b *IcomSerial) readTX() bool {
|
|
if err := b.write(civ.CmdPTT, civ.SubPTT); err != nil {
|
|
return false
|
|
}
|
|
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
|
return d.Cmd == civ.CmdPTT && len(d.Data) >= 2 && d.Data[0] == civ.SubPTT
|
|
})
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return f.Data[1] != 0
|
|
}
|
|
|
|
// 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 "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 {
|
|
b.dspMu.Lock()
|
|
defer b.dspMu.Unlock()
|
|
return b.dsp
|
|
}
|
|
|
|
// 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.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) 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
|
|
}
|