feat: icom Scope in Icom Tab

This commit is contained in:
2026-07-04 22:06:26 +02:00
parent abbdde9367
commit dd4b0004a5
8 changed files with 918 additions and 56 deletions
+397 -38
View File
@@ -6,6 +6,7 @@ import (
"sync"
"time"
"hamlog/internal/applog"
"hamlog/internal/cat/civ"
"go.bug.st/serial"
@@ -23,11 +24,36 @@ type IcomSerial struct {
digital string // mode to command for DATA (FT8/RTTY/…)
port serial.Port
rx []byte // accumulated bytes awaiting a complete frame
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)
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
lastSetFreqAt time.Time
@@ -57,11 +83,12 @@ func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *I
digitalDefault = "FT8"
}
return &IcomSerial{
portName: portName,
baud: baud,
rigAddr: byte(civAddr),
digital: strings.ToUpper(digitalDefault),
model: "Icom",
portName: portName,
baud: baud,
rigAddr: byte(civAddr),
digital: strings.ToUpper(digitalDefault),
model: "Icom",
scopeFixed: true, // rigs default to a fixed-span scope
}
}
@@ -85,9 +112,17 @@ func (b *IcomSerial) Connect() error {
_ = port.SetDTR(false)
_ = port.SetRTS(false)
b.port = port
b.rx = b.rx[:0]
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)
// 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 {
@@ -96,15 +131,22 @@ func (b *IcomSerial) Connect() error {
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
b.readDSP() // best-effort initial snapshot for the control tab
return nil
}
func (b *IcomSerial) Disconnect() {
if b.port != nil {
_ = b.port.Close()
_ = 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
@@ -151,15 +193,49 @@ func (b *IcomSerial) ReadState() (RigState, error) {
b.dspMu.Unlock()
}
b.pollN++
// Split: the selected VFO (read above) is RX; the unselected VFO is TX. ADIF
// convention → FreqHz = TX, RxFreqHz = RX.
if on, ok := b.readSplit(); ok && on {
if txHz, ok2 := b.readTXFreq(); ok2 && txHz > 0 && txHz != s.FreqHz {
s.Split = true
s.RxFreqHz = s.FreqHz // selected VFO = RX
s.FreqHz = txHz // unselected VFO = TX
// 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()
return s, nil
}
@@ -201,39 +277,252 @@ func (b *IcomSerial) SetPTT(on bool) error {
// ── helpers ───────────────────────────────────────────────────────────────
func (b *IcomSerial) write(payload ...byte) error {
// 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 reads from the port until a frame from the rig satisfies match or the
// timeout elapses. Frames that are our own echo (from == controller) or don't
// match are discarded.
// 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.
func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (civ.Decoded, error) {
deadline := time.Now().Add(timeout)
tmp := make([]byte, 256)
for time.Now().Before(deadline) {
n, err := b.port.Read(tmp)
if err != nil {
return civ.Decoded{}, err
}
if n == 0 {
continue
}
b.rx = append(b.rx, tmp[:n]...)
frames, consumed := civ.Scan(b.rx)
if consumed > 0 {
b.rx = append(b.rx[:0], b.rx[consumed:]...)
}
for _, f := range frames {
if f.From != b.rigAddr {
continue // skip echo of our own commands
}
deadline := time.After(timeout)
for {
select {
case f := <-b.respCh:
if match(f) {
return f, nil
}
case <-deadline:
return civ.Decoded{}, fmt.Errorf("icom: timeout waiting for response")
}
}
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 serial.Port, 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)
}
}
}
// 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 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
}
// 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.
@@ -296,6 +585,34 @@ func (b *IcomSerial) readTXFreq() (int64, bool) {
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
@@ -377,7 +694,14 @@ func (b *IcomSerial) RefreshIcom() error {
func (b *IcomSerial) readDSP() {
st := IcomTXState{Available: true, Model: b.model}
b.dspMu.Lock()
st.Mode = b.dsp.Mode // preserve mode (set by ReadState)
// 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 {
@@ -386,6 +710,12 @@ func (b *IcomSerial) readDSP() {
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)
}
@@ -577,6 +907,35 @@ func (b *IcomSerial) SetIcomFilter(n int) error {
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 err := b.exec(civ.CmdSplit, boolByte(on)); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.Split = on })
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)