feat: icom Scope in Icom Tab
This commit is contained in:
+58
-2
@@ -376,8 +376,17 @@ type IcomTXState struct {
|
||||
Available bool `json:"available"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
AFGain int `json:"af_gain"`
|
||||
RFGain int `json:"rf_gain"`
|
||||
// Transmit + live status (polled).
|
||||
Transmitting bool `json:"transmitting"`
|
||||
Split bool `json:"split"`
|
||||
SMeter int `json:"s_meter"` // 0-100 (raw 0-255; S9≈120)
|
||||
PowerMeter int `json:"power_meter"` // 0-100 (TX Po)
|
||||
SWRMeter int `json:"swr_meter"` // 0-100 (TX SWR)
|
||||
// Set controls.
|
||||
RFPower int `json:"rf_power"` // 0-100 (TX output)
|
||||
MicGain int `json:"mic_gain"` // 0-100
|
||||
AFGain int `json:"af_gain"`
|
||||
RFGain int `json:"rf_gain"`
|
||||
NB bool `json:"nb"`
|
||||
NBLevel int `json:"nb_level"`
|
||||
NR bool `json:"nr"`
|
||||
@@ -406,6 +415,25 @@ type IcomController interface {
|
||||
SetPreamp(int) error
|
||||
SetAtt(int) error
|
||||
SetIcomFilter(int) error
|
||||
SetRFPower(int) error
|
||||
SetMicGain(int) error
|
||||
SetIcomSplit(bool) error
|
||||
TuneATU() error
|
||||
SetScope(bool) error // enable/disable the spectrum-scope waveform stream
|
||||
SetScopeMode(bool) error // true = fixed span, false = center-on-VFO
|
||||
ScopeData() ScopeSweep // latest assembled sweep (empty until enabled)
|
||||
}
|
||||
|
||||
// ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's
|
||||
// divided 0x27 waveform frames. Amp holds one amplitude byte per pixel (raw rig
|
||||
// scale, typically 0-160). Seq increments on every completed sweep so the UI can
|
||||
// tell fresh data from a repeated poll.
|
||||
type ScopeSweep struct {
|
||||
Amp []int `json:"amp"` // []int (not []byte) so it marshals as a JSON number array
|
||||
Seq int `json:"seq"`
|
||||
LowHz int64 `json:"low_hz"` // left edge frequency (0 when unknown)
|
||||
HighHz int64 `json:"high_hz"` // right edge frequency (0 when unknown)
|
||||
Fixed bool `json:"fixed"` // true = fixed-span mode, false = center-on-VFO
|
||||
}
|
||||
|
||||
// IcomState returns the current Icom DSP state, or (zero, false) when the active
|
||||
@@ -420,6 +448,19 @@ func (m *Manager) IcomState() (IcomTXState, bool) {
|
||||
return IcomTXState{}, false
|
||||
}
|
||||
|
||||
// IcomScope returns the latest spectrum-scope sweep, or (zero, false) when the
|
||||
// active backend isn't an Icom. The sweep is mutex-guarded in the backend, so
|
||||
// this reads it directly (no CAT-goroutine round trip) — cheap enough to poll.
|
||||
func (m *Manager) IcomScope() (ScopeSweep, bool) {
|
||||
m.mu.RLock()
|
||||
b := m.backend
|
||||
m.mu.RUnlock()
|
||||
if ic, ok := b.(IcomController); ok {
|
||||
return ic.ScopeData(), true
|
||||
}
|
||||
return ScopeSweep{}, false
|
||||
}
|
||||
|
||||
// IcomDo dispatches an Icom control onto the CAT goroutine. Errors if the
|
||||
// active backend isn't an Icom.
|
||||
func (m *Manager) IcomDo(fn func(IcomController) error) error {
|
||||
@@ -490,6 +531,21 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
|
||||
fn()
|
||||
m.applyCommandDelay()
|
||||
case <-ticker.C:
|
||||
// Drain any queued commands before polling. A serial backend reads
|
||||
// many registers per ReadState, so without this the shared select's
|
||||
// fairness lets polls repeatedly win and a user's Set* can lag by
|
||||
// seconds. Servicing commands first bounds that latency to a single
|
||||
// ReadState.
|
||||
for {
|
||||
select {
|
||||
case fn := <-cmds:
|
||||
fn()
|
||||
m.applyCommandDelay()
|
||||
continue
|
||||
default:
|
||||
}
|
||||
break
|
||||
}
|
||||
if !connected {
|
||||
tryConnect()
|
||||
continue
|
||||
|
||||
+25
-4
@@ -40,7 +40,10 @@ const (
|
||||
|
||||
CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off)
|
||||
CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255)
|
||||
CmdMeter = 0x15 // meters (sub + 2 BCD bytes, 0000-0255): S-meter/Po/SWR
|
||||
CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte)
|
||||
CmdATU = 0x1C // sub 0x01 = antenna tuner (0x00 off, 0x01 through, 0x02 tune)
|
||||
CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off)
|
||||
|
||||
SubDataMode = 0x06
|
||||
SubPTT = 0x00
|
||||
@@ -48,10 +51,28 @@ const (
|
||||
SubVfoUnselected = 0x01 // CmdVfoFreq: the other VFO (TX in split)
|
||||
|
||||
// CmdLevel sub-commands.
|
||||
SubLevelAF = 0x01 // AF (volume)
|
||||
SubLevelRF = 0x02 // RF gain
|
||||
SubLevelNR = 0x06 // noise-reduction depth
|
||||
SubLevelNB = 0x12 // noise-blanker depth
|
||||
SubLevelAF = 0x01 // AF (volume)
|
||||
SubLevelRF = 0x02 // RF gain
|
||||
SubLevelNR = 0x06 // noise-reduction depth
|
||||
SubLevelNB = 0x12 // noise-blanker depth
|
||||
SubLevelRFPower = 0x0A // TX RF output power
|
||||
SubLevelMic = 0x0B // mic gain
|
||||
|
||||
// CmdMeter sub-commands.
|
||||
SubMeterS = 0x02 // S-meter (RX)
|
||||
SubMeterPo = 0x11 // power output (TX)
|
||||
SubMeterSWR = 0x12 // SWR (TX)
|
||||
|
||||
// CmdATU / CmdPTT sub-commands.
|
||||
SubATU = 0x01 // antenna tuner (data 0x02 = start tune)
|
||||
|
||||
// CmdScope sub-commands.
|
||||
SubScopeData = 0x00 // waveform data frame (divided across several frames)
|
||||
SubScopeOnOff = 0x10 // turn the scope display itself on/off (00/01)
|
||||
SubScopeOn = 0x11 // enable/disable waveform data output over CI-V (00/01)
|
||||
SubScopeMode = 0x14 // center/fixed mode (0=center, 1=fixed) — VERIFY on rig
|
||||
SubScopeSpan = 0x15 // span in center mode — VERIFY on rig
|
||||
SubScopeEdge = 0x16 // fixed-mode edge frequencies — VERIFY on rig
|
||||
|
||||
// CmdSwitch sub-commands.
|
||||
SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2
|
||||
|
||||
+397
-38
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user