feat: added RIT and XIT for Icom
This commit is contained in:
@@ -382,6 +382,10 @@ type IcomTXState struct {
|
||||
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)
|
||||
// RIT / ΔTX (XIT).
|
||||
RITHz int `json:"rit_hz"` // RIT/XIT offset, signed Hz
|
||||
RITOn bool `json:"rit_on"`
|
||||
XITOn bool `json:"xit_on"`
|
||||
// Set controls.
|
||||
RFPower int `json:"rf_power"` // 0-100 (TX output)
|
||||
MicGain int `json:"mic_gain"` // 0-100
|
||||
@@ -422,6 +426,9 @@ type IcomController interface {
|
||||
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)
|
||||
SetRIT(int) error // RIT/ΔTX offset in signed Hz
|
||||
SetRITOn(bool) error // RIT on/off
|
||||
SetXITOn(bool) error // ΔTX (XIT) on/off
|
||||
}
|
||||
|
||||
// ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's
|
||||
|
||||
@@ -44,6 +44,11 @@ const (
|
||||
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)
|
||||
CmdRIT = 0x21 // RIT/ΔTX: sub 0x00 offset freq, 0x01 RIT on/off, 0x02 ΔTX(XIT) on/off
|
||||
|
||||
SubRITFreq = 0x00 // RIT/ΔTX offset: 2 BCD bytes (LE, 0-9999) + sign byte (00 +, 01 -)
|
||||
SubRITOn = 0x01 // RIT on/off (00/01)
|
||||
SubXITOn = 0x02 // ΔTX (XIT) on/off (00/01)
|
||||
|
||||
SubDataMode = 0x06
|
||||
SubPTT = 0x00
|
||||
@@ -154,6 +159,38 @@ func BCDToLevel(b []byte) int {
|
||||
return int(b[0])*100 + int(b[1]>>4)*10 + int(b[1]&0x0F)
|
||||
}
|
||||
|
||||
// RITToBCD encodes a RIT/ΔTX offset (Hz, −9999..9999) as the 3 bytes CI-V
|
||||
// command 0x21 0x00 uses: 2 little-endian BCD bytes of the magnitude followed by
|
||||
// a sign byte (0x00 positive, 0x01 negative).
|
||||
func RITToBCD(hz int) []byte {
|
||||
neg := hz < 0
|
||||
if neg {
|
||||
hz = -hz
|
||||
}
|
||||
if hz > 9999 {
|
||||
hz = 9999
|
||||
}
|
||||
lo := byte(hz%10 | (hz/10%10)<<4)
|
||||
hi := byte(hz/100%10 | (hz/1000%10)<<4)
|
||||
sign := byte(0)
|
||||
if neg {
|
||||
sign = 1
|
||||
}
|
||||
return []byte{lo, hi, sign}
|
||||
}
|
||||
|
||||
// BCDToRIT decodes the 3 offset bytes of a 0x21 0x00 response back to signed Hz.
|
||||
func BCDToRIT(b []byte) int {
|
||||
if len(b) < 3 {
|
||||
return 0
|
||||
}
|
||||
v := int(b[0]&0x0F) + int(b[0]>>4)*10 + int(b[1]&0x0F)*100 + int(b[1]>>4)*1000
|
||||
if b[2] != 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ByteToBCD / BCDToByte handle a single packed-BCD byte (used by the
|
||||
// attenuator, where the value is dB: 0x00, 0x06, 0x12, 0x18…).
|
||||
func ByteToBCD(v int) byte {
|
||||
|
||||
@@ -277,6 +277,12 @@ func (b *IcomSerial) SetPTT(on bool) error {
|
||||
// ── 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).
|
||||
@@ -514,6 +520,68 @@ func (b *IcomSerial) SetScopeMode(fixed bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -743,6 +811,7 @@ func (b *IcomSerial) readDSP() {
|
||||
if _, f, ok := b.readModeFilter(); ok {
|
||||
st.Filter = int(f)
|
||||
}
|
||||
b.readRIT(&st)
|
||||
|
||||
b.dspMu.Lock()
|
||||
b.dsp = st
|
||||
|
||||
@@ -60,6 +60,7 @@ type Decoder struct {
|
||||
state bool // true = mark (key down)
|
||||
stateHops int
|
||||
dotHops float64 // adaptive dot length, in hops
|
||||
markCount int // marks seen since lock (fast WPM adaptation while small)
|
||||
elem []byte // current "." / "-" run for the in-progress character
|
||||
charEmitted bool
|
||||
wordEmitted bool
|
||||
@@ -145,6 +146,7 @@ func (d *Decoder) Reset() {
|
||||
d.state = false
|
||||
d.stateHops = 0
|
||||
d.dotHops = 15
|
||||
d.markCount = 0
|
||||
d.elem = d.elem[:0]
|
||||
d.charEmitted, d.wordEmitted = false, false
|
||||
}
|
||||
@@ -213,10 +215,11 @@ func (d *Decoder) analyze() {
|
||||
// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so we
|
||||
// don't eat the first element of a strong signal), a marginal/weak tone
|
||||
// locks after a couple of stable hops (so we don't lock onto pure noise).
|
||||
if snr > d.strongSNR || (d.candHops >= 3 && snr > d.acqSNR) {
|
||||
if snr > d.strongSNR || (d.candHops >= 2 && snr > d.acqSNR) {
|
||||
d.lockIdx = maxIdx
|
||||
d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
|
||||
d.quietHops = 0
|
||||
d.markCount = 0 // relearn WPM fast for this new signal
|
||||
}
|
||||
}
|
||||
if d.lockIdx >= 0 {
|
||||
@@ -267,6 +270,17 @@ func (d *Decoder) step() {
|
||||
} else {
|
||||
d.quietHops++
|
||||
if d.quietHops > d.relockHops {
|
||||
// End of the over: flush any pending character and drop a word
|
||||
// space so the next transmission starts a fresh word (the word-gap
|
||||
// timer above can't fire once the lock is gone).
|
||||
if len(d.elem) > 0 && !d.charEmitted {
|
||||
d.flushChar()
|
||||
d.charEmitted = true
|
||||
}
|
||||
if !d.wordEmitted && d.onChar != nil {
|
||||
d.onChar(" ")
|
||||
d.wordEmitted = true
|
||||
}
|
||||
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
|
||||
}
|
||||
}
|
||||
@@ -311,10 +325,18 @@ func (d *Decoder) endMark(hops int) {
|
||||
}
|
||||
|
||||
// adaptDot nudges the dot-length estimate toward an observation (EMA, clamped
|
||||
// to ~5–100 WPM).
|
||||
// to ~5–100 WPM). The first marks of a new signal adapt FAST so the WPM (and
|
||||
// therefore the character/word gap thresholds) converge within the first
|
||||
// character or two — otherwise a wrong seed mis-times early gaps and runs
|
||||
// characters/words together.
|
||||
func (d *Decoder) adaptDot(obs float64) {
|
||||
d.dotHops = d.dotHops*0.8 + obs*0.2 // slower: a few odd marks can't yank it
|
||||
if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
|
||||
alpha := 0.2
|
||||
if d.markCount < 6 {
|
||||
alpha = 0.5 // fast convergence on the opening marks
|
||||
}
|
||||
d.markCount++
|
||||
d.dotHops = d.dotHops*(1-alpha) + obs*alpha
|
||||
if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
|
||||
d.dotHops = 5
|
||||
}
|
||||
if d.dotHops > 55 {
|
||||
|
||||
Reference in New Issue
Block a user