feat: added full support in USB (local) & ethernet (local or remote) of audio for Icom

This commit is contained in:
2026-07-09 11:30:06 +02:00
parent 1f5e5759cc
commit 521f8266cf
14 changed files with 882 additions and 22 deletions
+2
View File
@@ -313,6 +313,8 @@ func ModelName(addr byte) string {
return "IC-7300"
case 0x98:
return "IC-7610"
case 0x7C:
return "IC-9100"
case 0xA2:
return "IC-9700"
case 0xA4:
+47 -10
View File
@@ -803,24 +803,44 @@ func (f *Flex) ReadState() (RigState, error) {
// band) never hijacks the main frequency. Returns (-1, nil) when no slice is in
// use. Caller holds f.mu.
func (f *Flex) mainSliceLocked() (int, *flexSlice) {
best, bestS := 1<<30, (*flexSlice)(nil)
for idx, s := range f.slices {
// Iterate in ASCENDING index order — NEVER map-iteration order, which Go
// randomises. When two slices transiently BOTH report active=1 (e.g. an
// external controller like DXHunter activates a slice on another band while
// ours still holds active, before SmartSDR sends active=0 to the old one),
// map order returned a RANDOM active slice each call → the operating frequency
// flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever.
// Deterministic order = the lowest-indexed active slice wins, stably.
firstInUse := -1
for _, idx := range f.sortedSliceIdxLocked() {
s := f.slices[idx]
if !s.inUse {
continue
}
if firstInUse < 0 {
firstInUse = idx
}
if s.active {
return idx, s
}
if idx < best {
best, bestS = idx, s
}
}
if bestS != nil {
return best, bestS
if firstInUse >= 0 {
return firstInUse, f.slices[firstInUse]
}
return -1, nil
}
// sortedSliceIdxLocked returns the slice indices in ascending order so every
// slice-selection helper is deterministic (map iteration is randomised). Caller
// holds f.mu.
func (f *Flex) sortedSliceIdxLocked() []int {
idxs := make([]int, 0, len(f.slices))
for idx := range f.slices {
idxs = append(idxs, idx)
}
sort.Ints(idxs)
return idxs
}
// activeSliceIndexLocked returns the slice index to send commands to (the main
// slice, else 0). Caller holds f.mu.
func (f *Flex) activeSliceIndexLocked() int {
@@ -841,8 +861,8 @@ func sliceLetter(idx int) string {
// txSliceLocked returns the slice flagged as the transmitter (tx=1), or nil.
// Caller holds f.mu.
func (f *Flex) txSliceLocked() *flexSlice {
for _, s := range f.slices {
if s.inUse && s.tx {
for _, idx := range f.sortedSliceIdxLocked() {
if s := f.slices[idx]; s.inUse && s.tx {
return s
}
}
@@ -871,7 +891,8 @@ func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) {
if main != nil && main != txS && main.freqHz != txS.freqHz && BandFromHz(main.freqHz) == bt {
rx = main
} else {
for _, s := range f.slices {
for _, idx := range f.sortedSliceIdxLocked() {
s := f.slices[idx]
if s.inUse && s != txS && s.freqHz != txS.freqHz && BandFromHz(s.freqHz) == bt {
rx = s
break
@@ -927,6 +948,15 @@ func (f *Flex) SetFrequency(hz int64) error {
f.mu.Lock()
idx := f.activeSliceIndexLocked()
connected := f.conn != nil
// Optimistically update the active slice's cached freq NOW, before the radio
// echoes the slice status back. Otherwise ReadState/FlexState keep reporting
// the OLD freq for the round-trip: the top display (optimistic liveFreqHz)
// jumped to the new band while the slice cache — which the FlexPanel and the
// Ultrabeam follow loop read — still showed the old one, so the antenna chased
// the stale value. The real echo confirms/corrects this a moment later.
if s := f.slices[idx]; s != nil {
s.freqHz = hz
}
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
@@ -952,6 +982,13 @@ func (f *Flex) SetMode(mode string) error {
if fm == "" {
return fmt.Errorf("flex: unsupported mode %q", mode)
}
// Optimistically cache the new mode too (same reasoning as SetFrequency) so the
// panel reflects it immediately instead of lagging the radio's echo.
f.mu.Lock()
if s := f.slices[idx]; s != nil {
s.mode = fm
}
f.mu.Unlock()
// "slice s <rx> mode=<m>" — set command per the SmartSDR API.
f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm))
return nil
+219
View File
@@ -0,0 +1,219 @@
package cat
// icomaudio.go — the NETWORK AUDIO stream (UDP 50003) for the Icom LAN protocol.
// It is the third stream alongside control (50001) and CI-V (50002): once the
// control login + conninfo (with rxenable=1) authorize audio, the rig streams RX
// audio here as data packets. This file dials/handshakes/keeps-alive that socket
// exactly like the CI-V stream (icomnet.go) — those parts are byte-for-byte the
// PROVEN transport — and hands each received audio payload to a sink callback
// (the app decodes it via an audio.Codec and plays it through the RX monitor).
//
// Reuses icomnet.go's helpers (icnCtrl, icnHandshake, icnPingReply, icnRecv,
// icnLocalID, icnLE) and the same seq/retransmit discipline.
//
// ⚠️ PAYLOAD OFFSET PENDING ON-RIG VERIFICATION. The stream framing (handshake,
// ping, idle, retransmit, common 16-byte header) is identical to CI-V and proven.
// The AUDIO data packet's inner layout — where the PCM starts and the datalen
// field — is reconstructed from wfview's audio_packet (ident@0x10, datalen@0x12,
// sendseq@0x14, audio@0x16) but NOT yet confirmed against a real 50003 capture.
// audioPump logs the first few raw packets (icaDumpFirst) so the offset can be
// confirmed/corrected on the first on-rig test without a packet capture, the same
// way the CI-V/scope framing was iterated. Nothing here can destabilize CAT: the
// audio stream is opt-in and entirely separate from control/CI-V.
import (
"net"
"sync"
"sync/atomic"
"time"
)
// icaAudioOffset is where the PCM payload begins inside an audio data packet
// (wfview audio_packet: 16-byte common header + ident@0x10 + datalen@0x12 +
// sendseq@0x14 → audio@0x16). Isolated as a const so a capture-confirmed change
// is a one-line edit.
const icaAudioOffset = 0x16
// icaDumpFirst is how many initial audio packets to hex-dump to the debug log for
// offset verification. After the layout is confirmed on a real rig this can go to
// 0 (or the const above corrected).
const icaDumpFirst = 6
// icomAudio is the connected audio stream. RX only for now (Phase 4); TX (Phase
// 5) will add an encode+send path mirroring icomNet.Write.
type icomAudio struct {
conn *net.UDPConn
aID, aRemote uint32
sink func([]byte) // receives each raw audio payload (app decodes + plays)
// Receive-side retransmit (audio is a heavy stream, like the scope): track the
// rig's data-packet send seq and ask it to resend gaps, or the rig drops the
// session. Same mechanism as icomNet. Owned solely by audioPump → no lock.
rxHaveSeq bool
rxLastSeq uint16
rxMissing map[uint16]int
dumped int // packets hex-dumped so far (≤ icaDumpFirst)
lastRx atomic.Int64 // UnixNano of last packet (liveness)
done chan struct{}
closeOnce sync.Once
}
func (a *icomAudio) markRx() { a.lastRx.Store(time.Now().UnixNano()) }
// Close tears the audio stream down (disconnect a few times; UDP is lossy).
func (a *icomAudio) Close() {
a.closeOnce.Do(func() {
close(a.done)
for i := 0; i < 3; i++ {
_, _ = a.conn.Write(icnCtrl(0x05, 0, a.aID, a.aRemote)) // disconnect
time.Sleep(15 * time.Millisecond)
}
_ = a.conn.Close()
debugLog.Printf("icom audio: stream closed")
})
}
// dialIcomAudio opens the audio UDP stream to rig:50003, binding LOCAL :50003
// (mirroring the civ stream's local :50002). The control conninfo (rxenable=1,
// audioport=50003) must already have authorized it. sink receives each raw audio
// payload. cancel aborts a slow dial (Stop/Start).
func dialIcomAudio(host string, sink func([]byte), cancel <-chan struct{}) (*icomAudio, error) {
araddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50003"))
if err != nil {
return nil, err
}
conn, err := net.DialUDP("udp4", &net.UDPAddr{Port: 50003}, araddr)
if err != nil {
debugLog.Printf("icom audio: cannot bind local :50003 (Remote Utility running?): %v", err)
return nil, err
}
aID := icnLocalID(conn)
aRemote, err := icnHandshake(conn, aID, cancel)
if err != nil {
_ = conn.Close()
debugLog.Printf("icom audio: handshake FAILED: %v", err)
return nil, err
}
_ = conn.SetReadBuffer(1 << 20)
a := &icomAudio{
conn: conn, aID: aID, aRemote: aRemote,
sink: sink,
rxMissing: make(map[uint16]int),
done: make(chan struct{}),
}
a.markRx()
debugLog.Printf("icom audio: stream up (rig id 0x%08X) — awaiting RX audio", aRemote)
go a.audioPump()
return a, nil
}
// audioPump drains the audio socket: replies to pings, sends idle keepalives,
// requests retransmits for lost packets, and hands each audio payload to sink.
func (a *icomAudio) audioPump() {
buf := make([]byte, 8192)
lastIdle := time.Now()
lastReq := time.Now()
for {
select {
case <-a.done:
return
default:
}
_ = a.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
if k, err := a.conn.Read(buf); err == nil && k >= 16 {
a.markRx()
switch typ := icnLE.Uint16(buf[4:]); {
case typ == 0x07: // ping
_, _ = a.conn.Write(icnPingReply(buf[:k], a.aID, a.aRemote))
case typ == 0x01: // retransmit request from the rig (we send no tracked audio yet)
case typ == 0x05: // rig-initiated disconnect
debugLog.Printf("icom audio: rig sent DISCONNECT — audio stream dropped by the rig")
case typ == 0x00 && k > icaAudioOffset: // audio data packet
a.trackRxSeq(icnLE.Uint16(buf[6:]))
if a.dumped < icaDumpFirst {
a.dumped++
debugLog.Printf("icom audio raw #%d: len=%d head=% X", a.dumped, k, buf[:min(icaAudioOffset+8, k)])
}
if a.sink != nil {
payload := append([]byte(nil), buf[icaAudioOffset:k]...)
a.sink(payload)
}
}
}
if time.Since(lastIdle) > 100*time.Millisecond {
_, _ = a.conn.Write(icnCtrl(0x00, 0, a.aID, a.aRemote))
lastIdle = time.Now()
}
if time.Since(lastReq) > 100*time.Millisecond {
a.sendRetransmitReq()
lastReq = time.Now()
}
}
}
// trackRxSeq / sendRetransmitReq mirror icomNet's receive-side retransmit exactly
// (audio is as loss-sensitive as the scope stream). Duplicated deliberately so
// the audio stream owns its own seq state with no shared locking.
func (a *icomAudio) trackRxSeq(seq uint16) {
if !a.rxHaveSeq {
a.rxHaveSeq = true
a.rxLastSeq = seq
return
}
switch d := int16(seq - a.rxLastSeq); {
case d == 0:
case d < 0:
delete(a.rxMissing, seq)
case d == 1:
a.rxLastSeq = seq
case int(d) <= icnMaxMissing:
for f := a.rxLastSeq + 1; f != seq; f++ {
a.rxMissing[f] = 0
}
a.rxLastSeq = seq
default:
a.rxMissing = make(map[uint16]int)
a.rxLastSeq = seq
}
}
func (a *icomAudio) sendRetransmitReq() {
if len(a.rxMissing) == 0 {
return
}
if len(a.rxMissing) > icnMaxMissing {
a.rxMissing = make(map[uint16]int)
return
}
var seqs []uint16
for s, cnt := range a.rxMissing {
if cnt >= 4 {
delete(a.rxMissing, s)
continue
}
a.rxMissing[s] = cnt + 1
seqs = append(seqs, s)
}
switch {
case len(seqs) == 0:
return
case len(seqs) == 1:
_, _ = a.conn.Write(icnCtrl(0x01, seqs[0], a.aID, a.aRemote))
default:
b := make([]byte, 16+4*len(seqs))
icnLE.PutUint32(b[0:], uint32(len(b)))
icnLE.PutUint16(b[4:], 0x01)
icnLE.PutUint32(b[8:], a.aID)
icnLE.PutUint32(b[12:], a.aRemote)
off := 16
for _, s := range seqs {
icnLE.PutUint16(b[off:], s)
icnLE.PutUint16(b[off+2:], s)
off += 4
}
_, _ = a.conn.Write(b)
}
}
+38 -8
View File
@@ -36,7 +36,13 @@ var icnBE = binary.BigEndian
// NewIcomNet builds an (unconnected) Icom backend whose transport is the network
// stream. host is the rig's IP/hostname; user/pass are the rig's Network User1
// credentials. Reuses the whole IcomSerial controller — only `open` differs.
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string) *IcomSerial {
//
// audioSink (optional) enables the network RX audio stream (UDP 50003): when
// non-nil the conninfo asks the rig to stream audio and each received payload is
// passed to audioSink (the app decodes it via an audio.Codec and plays it). nil
// = CI-V only (the proven default). The audio stream is fully separate from CAT,
// so enabling it can't affect freq/mode/DSP control.
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string, audioSink func([]byte)) *IcomSerial {
if civAddr <= 0 || civAddr > 0xFF {
civAddr = 0x98 // IC-7610
}
@@ -57,7 +63,7 @@ func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string) *Ic
b.dialMu.Lock()
cancel := b.dialCancel
b.dialMu.Unlock()
return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel)
return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel, audioSink)
}
return b
}
@@ -130,6 +136,10 @@ type icomNet struct {
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
lastRx atomic.Int64
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
// Torn down alongside the CI-V/control streams in Close.
audio *icomAudio
done chan struct{}
closeOnce sync.Once
}
@@ -232,6 +242,9 @@ var icnTrace = false
func (n *icomNet) Close() error {
n.closeOnce.Do(func() {
close(n.done)
if n.audio != nil {
n.audio.Close()
}
// Tell the rig we're leaving so it frees its SINGLE control session at
// once. If it never gets a disconnect it holds the session for minutes and
// refuses every new login — which is why a lost link (or a hard app exit)
@@ -475,8 +488,9 @@ func (n *icomNet) resend(seq uint16) {
// ------------------------- connect -------------------------
func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan struct{}) (*icomNet, error) {
debugLog.Printf("icom net: connecting to %s (user %q, comp %q, rig addr 0x%02X)", host, user, compName, rigAddr)
func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan struct{}, audioSink func([]byte)) (*icomNet, error) {
wantAudio := audioSink != nil
debugLog.Printf("icom net: connecting to %s (user %q, comp %q, rig addr 0x%02X, audio=%v)", host, user, compName, rigAddr, wantAudio)
// ---- control stream (50001): handshake → login → token → conninfo ----
craddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50001"))
if err != nil {
@@ -562,7 +576,11 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan
rigMAC = make([]byte, 6)
}
_, _ = ctrl.Write(icnConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003))
var rxEnable byte
if wantAudio {
rxEnable = 0x01 // ask the rig to stream RX audio on 50003
}
_, _ = ctrl.Write(icnConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003, rxEnable))
cTracked++
cInner++
drainEnd := time.Now().Add(500 * time.Millisecond)
@@ -634,6 +652,18 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan
go n.ctrlPump()
go n.civPump()
// Optional RX audio stream (50003). The rig was told (conninfo rxEnable=1) to
// stream audio; open the socket + handshake now. A failure here is NON-fatal:
// CAT works without audio, so we log and continue rather than tear down a
// perfectly good control/CI-V session.
if wantAudio {
if a, err := dialIcomAudio(host, audioSink, cancel); err != nil {
debugLog.Printf("icom net: audio stream FAILED (CAT unaffected): %v", err)
} else {
n.audio = a
}
}
return n, nil
}
@@ -772,7 +802,7 @@ func icnTokenRenew(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) [
return b
}
func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16) []byte {
func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16, rxEnable byte) []byte {
b := make([]byte, 0x90)
icnLE.PutUint32(b[0:], 0x90)
icnLE.PutUint16(b[6:], seq)
@@ -788,8 +818,8 @@ func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, use
copy(b[0x2a:0x30], rigMAC)
copy(b[0x40:0x60], []byte("IC-7610"))
copy(b[0x60:0x70], icnPasscode(user))
b[0x70] = 0x00 // rxenable (audio off — CI-V only)
b[0x71] = 0x00 // txenable
b[0x70] = rxEnable // rxenable: 1 opens the 50003 RX audio stream, 0 = CI-V only
b[0x71] = 0x00 // txenable (Phase 5)
b[0x72] = 0x10 // rxcodec
b[0x73] = 0x04 // txcodec
icnBE.PutUint32(b[0x74:], 16000)