chore: release v0.27.12
This commit is contained in:
+73
-2
@@ -9,6 +9,7 @@ package cat
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -240,15 +241,81 @@ func (m *Manager) freqOffsetHz() int64 {
|
||||
// display trick: the readout would say 144 and every spot click, band change and
|
||||
// memory recall would send the rig somewhere 116 MHz away.
|
||||
func (m *Manager) SetFrequency(hz int64) error {
|
||||
real := hz
|
||||
if off := m.freqOffsetHz(); off != 0 && hz > off {
|
||||
hz -= off
|
||||
}
|
||||
return m.exec(func(b Backend) error { return b.SetFrequency(hz) })
|
||||
err := m.exec(func(b Backend) error { return b.SetFrequency(hz) })
|
||||
if err == nil {
|
||||
m.noteCommandedFreq(real)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// noteCommandedFreq publishes a frequency the radio has just acknowledged,
|
||||
// without waiting for the next poll to come round and read it back.
|
||||
//
|
||||
// The wait is what this is about. A rigctl client — WSJT-X above all — sets a
|
||||
// frequency and then READS it back before it believes it is there, and until
|
||||
// then it will not decode, transmit or even update its own dial. Everything
|
||||
// answering "f" here comes from the last poll, so the answer was the OLD
|
||||
// frequency for as long as a poll cycle takes; on a rig reached over the
|
||||
// internet, where one cycle is many round trips, a band change from WSJT-X took
|
||||
// ten seconds to be believed while the radio itself had moved instantly.
|
||||
//
|
||||
// Only when NOT split. In split the two frequencies mean different VFOs and a
|
||||
// guess about which one just moved is how a client ends up writing the transmit
|
||||
// frequency onto the dial — the poll is left to settle that case.
|
||||
func (m *Manager) noteCommandedFreq(hz int64) {
|
||||
if hz <= 0 {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
st := m.state
|
||||
if !st.Connected || st.Split || st.FreqHz == hz {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
st.FreqHz = hz
|
||||
st.Band = BandFromHz(hz)
|
||||
st.UpdatedAt = time.Now()
|
||||
m.state = st
|
||||
m.mu.Unlock()
|
||||
m.emitState()
|
||||
}
|
||||
|
||||
// SetMode dispatches a SetMode call to the CAT goroutine.
|
||||
func (m *Manager) SetMode(mode string) error {
|
||||
return m.exec(func(b Backend) error { return b.SetMode(mode) })
|
||||
err := m.exec(func(b Backend) error { return b.SetMode(mode) })
|
||||
if err == nil {
|
||||
m.noteCommandedMode(mode)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// noteCommandedMode is the mode half of noteCommandedFreq, and exists for the
|
||||
// same client readback.
|
||||
//
|
||||
// "DATA" is deliberately not published. A backend reports data mode under the
|
||||
// operator's own digital mode (FT8, JS8, RTTY…), and that name is what a QSO is
|
||||
// logged with — a plain "DATA" standing in for a poll cycle is a mode nobody
|
||||
// works, in a field that ends up in an ADIF file. The poll is a fraction of a
|
||||
// second away and knows the real name.
|
||||
func (m *Manager) noteCommandedMode(mode string) {
|
||||
if mode == "" || strings.EqualFold(mode, "DATA") {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
st := m.state
|
||||
if !st.Connected || st.Mode == mode {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
st.Mode = mode
|
||||
st.UpdatedAt = time.Now()
|
||||
m.state = st
|
||||
m.mu.Unlock()
|
||||
m.emitState()
|
||||
}
|
||||
|
||||
// SetPTT dispatches a transmit on/off request to the CAT goroutine.
|
||||
@@ -704,6 +771,10 @@ type IcomController interface {
|
||||
SetVOXGain(int) error
|
||||
SetAntiVOX(int) error
|
||||
SetPower(bool) error // turn the transceiver on/off (manual — never auto on connect)
|
||||
// RecallBandStack moves the VFO to what the radio's own band stacking
|
||||
// register holds — the operator's last frequency and mode on that band.
|
||||
// Returns the frequency landed on.
|
||||
RecallBandStack(band, reg int) (int64, error)
|
||||
}
|
||||
|
||||
// ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's
|
||||
|
||||
@@ -425,3 +425,49 @@ func indexPreamble(buf []byte, from int) int {
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// ── Band stacking registers (CI-V 0x1A sub 0x01) ──────────────────────────
|
||||
//
|
||||
// Every modern Icom remembers the last few frequency/mode pairs used on each
|
||||
// band, and its front-panel band key walks through them. That is why pressing
|
||||
// [14] on the radio lands on the FT8 watering hole rather than on a number
|
||||
// somebody chose in software: the register is the operator's OWN last visit.
|
||||
//
|
||||
// The frame is a READ — it asks the rig what a register holds and changes
|
||||
// nothing — so a rig that does not know the command answers NG and the caller
|
||||
// is exactly where it started.
|
||||
//
|
||||
// → 1A 01 <band> <reg>
|
||||
// ← 1A 01 <band> <reg> <freq 5 BCD, LE> <mode> <filter> <data mode> …
|
||||
//
|
||||
// Anything past the data-mode byte (duplex, tone, DV squelch on the VHF rigs)
|
||||
// is not read here: the question is where the operator last was, and the answer
|
||||
// to that is the frequency and the mode.
|
||||
const SubBandStack = 0x01
|
||||
|
||||
// BandStack is one register's contents.
|
||||
type BandStack struct {
|
||||
FreqHz int64
|
||||
Mode byte
|
||||
Data bool // the data-mode flag that goes with Mode
|
||||
}
|
||||
|
||||
// DecodeBandStack reads the payload of a 0x1A 0x01 reply, i.e. everything after
|
||||
// the command byte. ok is false for a frame that is not the register asked for,
|
||||
// which is what a desynchronised read looks like.
|
||||
func DecodeBandStack(data []byte, band, reg byte) (BandStack, bool) {
|
||||
if len(data) < 9 || data[0] != SubBandStack || data[1] != band || data[2] != reg {
|
||||
return BandStack{}, false
|
||||
}
|
||||
hz, ok := BCDToFreq(data[3:8])
|
||||
if !ok || hz <= 0 {
|
||||
return BandStack{}, false
|
||||
}
|
||||
bs := BandStack{FreqHz: hz, Mode: data[8]}
|
||||
// Filter then data mode. A rig that stops at the filter byte is not an
|
||||
// error — it is a register without a data flag, so the flag stays false.
|
||||
if len(data) >= 11 {
|
||||
bs.Data = data[10] != 0
|
||||
}
|
||||
return bs, true
|
||||
}
|
||||
|
||||
@@ -204,3 +204,36 @@ func TestBCDToFreqRejectsNonDecimal(t *testing.T) {
|
||||
t.Error("a short but valid BCD frame must still decode")
|
||||
}
|
||||
}
|
||||
|
||||
// A band stacking register reply, byte for byte as the rigs send it:
|
||||
// 1A 01 <band> <reg> <freq 5 LE-BCD> <mode> <filter> <data>. The payload here
|
||||
// is everything after the command byte, which is what Decoded.Data holds.
|
||||
func TestDecodeBandStack(t *testing.T) {
|
||||
// 14.074.000 MHz, USB, FIL1, data mode on — the FT8 stack on 20 m.
|
||||
frame := []byte{0x01, 0x05, 0x03, 0x00, 0x40, 0x07, 0x14, 0x00, ModeUSB, 0x01, 0x01}
|
||||
bs, ok := DecodeBandStack(frame, 0x05, 0x03)
|
||||
if !ok {
|
||||
t.Fatal("a well-formed register was rejected")
|
||||
}
|
||||
if bs.FreqHz != 14_074_000 {
|
||||
t.Errorf("freq = %d, want 14074000", bs.FreqHz)
|
||||
}
|
||||
if bs.Mode != ModeUSB || !bs.Data {
|
||||
t.Errorf("mode = 0x%02X data = %v, want USB + data", bs.Mode, bs.Data)
|
||||
}
|
||||
// The register ASKED FOR is part of the answer: a reply about another one is
|
||||
// a desynchronised read, not a frequency to send the radio to.
|
||||
if _, ok := DecodeBandStack(frame, 0x05, 0x01); ok {
|
||||
t.Error("a reply for register 3 was accepted as register 1")
|
||||
}
|
||||
if _, ok := DecodeBandStack(frame, 0x03, 0x03); ok {
|
||||
t.Error("a reply about 40 m was accepted as 20 m")
|
||||
}
|
||||
// A register without the data-mode byte is a shorter frame, not a bad one.
|
||||
if bs, ok := DecodeBandStack(frame[:10], 0x05, 0x03); !ok || bs.FreqHz != 14_074_000 || bs.Data {
|
||||
t.Errorf("short register = %+v ok=%v, want the frequency with no data flag", bs, ok)
|
||||
}
|
||||
if _, ok := DecodeBandStack([]byte{0x01, 0x05, 0x03}, 0x05, 0x03); ok {
|
||||
t.Error("a truncated frame was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,6 +594,15 @@ func (b *IcomSerial) SetMode(mode string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.setModeBytes(mode, code, data)
|
||||
}
|
||||
|
||||
// setModeBytes is SetMode once the mode is already a CI-V byte and a data flag.
|
||||
// Split out for the band-stacking recall, which gets both FROM the radio and
|
||||
// must not go back through an ADIF name to reach them: a register holding CW-R
|
||||
// or LSB would come back as plain CW or as whatever the band convention says,
|
||||
// i.e. not the mode the operator left there.
|
||||
func (b *IcomSerial) setModeBytes(mode string, code byte, data bool) error {
|
||||
// Set the base mode (keeping the rig's current filter by sending only the
|
||||
// mode byte), then set the data-mode flag for digital modes.
|
||||
if err := b.execIdempotent("set mode "+mode, civ.CmdSetMode, code); err != nil {
|
||||
@@ -2188,3 +2197,59 @@ func (b *IcomSerial) TXAudioSender() (func([]byte) error, error) {
|
||||
}
|
||||
return nil, fmt.Errorf("this rig takes transmit audio through its USB sound card, not the CAT link")
|
||||
}
|
||||
|
||||
// ── Band stacking registers ───────────────────────────────────────────────
|
||||
|
||||
// RecallBandStack puts the VFO where the operator last was on a band, by asking
|
||||
// the radio rather than by holding an opinion about it.
|
||||
//
|
||||
// The console's band buttons used to send a frequency chosen in software — a
|
||||
// reasonable middle-of-the-band number, and never where anybody actually
|
||||
// operates. The radio already knows better: every band key press it has ever
|
||||
// had is remembered in that band's stacking registers, so register 1 is the
|
||||
// last place used on that band, and cycling through 2 and 3 walks back through
|
||||
// the ones before it — CW where CW was worked, and the FT8 frequency where FT8
|
||||
// was worked, without either being written down anywhere.
|
||||
//
|
||||
// Reads the register, then sets frequency and mode from it. Returns the
|
||||
// frequency it landed on, so the caller can say where it went; a register the
|
||||
// rig will not read leaves the radio untouched and returns an error, which is
|
||||
// what makes the caller's fallback to a plain frequency safe.
|
||||
func (b *IcomSerial) RecallBandStack(band, reg int) (int64, error) {
|
||||
if b.port == nil {
|
||||
return 0, fmt.Errorf("not connected")
|
||||
}
|
||||
if band <= 0 || reg < 1 || reg > 3 {
|
||||
return 0, fmt.Errorf("icom: band stack %d/%d is not a register", band, reg)
|
||||
}
|
||||
bb, rb := civ.ByteToBCD(band), civ.ByteToBCD(reg)
|
||||
if err := b.write(civ.CmdExtra, civ.SubBandStack, bb, rb); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
||||
return d.Cmd == civ.CmdExtra && len(d.Data) >= 2 && d.Data[0] == civ.SubBandStack
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
bs, ok := civ.DecodeBandStack(f.Data, bb, rb)
|
||||
if !ok {
|
||||
// Logged with the raw frame: the register layout has a tail that differs
|
||||
// between models, and a rig that answers something we cannot read is the
|
||||
// one thing worth seeing here.
|
||||
applog.Printf("icom: band stack %d/%d — cannot read the register from % X", band, reg, f.Data)
|
||||
return 0, fmt.Errorf("icom: band stacking register %d/%d not understood", band, reg)
|
||||
}
|
||||
if err := b.SetFrequency(bs.FreqHz); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// The mode is best-effort. Landing on the right frequency in the wrong mode
|
||||
// is a nuisance; refusing the whole recall over it would send the operator
|
||||
// back to a button that does less.
|
||||
if bs.Mode != 0 {
|
||||
if err := b.setModeBytes(civ.ModeToADIF(bs.Mode, bs.Data), bs.Mode, bs.Data); err != nil {
|
||||
applog.Printf("icom: band stack %d/%d — frequency set, mode 0x%02X refused: %v", band, reg, bs.Mode, err)
|
||||
}
|
||||
}
|
||||
return bs.FreqHz, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user