feat: Implemented scope on Ethernet for Icom
This commit is contained in:
+358
-6
@@ -27,6 +27,26 @@ type civTransport interface {
|
||||
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
|
||||
@@ -75,6 +95,8 @@ type IcomSerial struct {
|
||||
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
|
||||
|
||||
@@ -83,6 +105,29 @@ type IcomSerial struct {
|
||||
// / 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 (
|
||||
@@ -130,6 +175,11 @@ 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
|
||||
@@ -154,6 +204,11 @@ func (b *IcomSerial) Connect() error {
|
||||
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 {
|
||||
@@ -166,7 +221,11 @@ func (b *IcomSerial) Connect() error {
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -191,11 +250,38 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
||||
|
||||
hz, err := b.readFreq()
|
||||
if err != nil {
|
||||
// The rig briefly stops answering CI-V while it switches band/VFO. Treat a
|
||||
// few consecutive misses as transient — keep the connection and report the
|
||||
// last known state — so a band change doesn't trigger a full disconnect +
|
||||
// 5 s reconnect (which showed the new frequency ~10 s late). Only after
|
||||
// several failures do we declare the rig lost so the Manager reconnects.
|
||||
// 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
|
||||
@@ -268,6 +354,15 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -306,6 +401,30 @@ func (b *IcomSerial) SetPTT(on bool) error {
|
||||
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 {
|
||||
@@ -374,6 +493,37 @@ func (b *IcomSerial) reader(port civTransport, done chan struct{}) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -451,6 +601,32 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
|
||||
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 {
|
||||
low := civ.BCDToFreq(region[1:6])
|
||||
high := civ.BCDToFreq(region[6:11])
|
||||
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) > 24 {
|
||||
head = head[:24]
|
||||
}
|
||||
applog.Printf("icom scope (net 1-frame): region=%dB head=[% X] → edges %d..%d Hz points=%d", len(region), head, low, high, len(amp))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if seq == 1 { // header frame — begins a new sweep, no waveform data
|
||||
regions = make(map[byte][]byte)
|
||||
total = tot
|
||||
@@ -935,6 +1111,46 @@ func (b *IcomSerial) readDSP() {
|
||||
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
|
||||
@@ -982,6 +1198,22 @@ func (b *IcomSerial) readAtt() (int, bool) {
|
||||
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
|
||||
@@ -1123,6 +1355,126 @@ func (b *IcomSerial) SetIcomSplit(on bool) error {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user