feat: Support for Antenna Genius
This commit is contained in:
@@ -0,0 +1,557 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/cat/civ"
|
||||
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
// IcomSerial controls an Icom transceiver over its USB/serial CI-V port (local
|
||||
// control). It speaks the shared civ protocol, so when the network backend
|
||||
// (icomnet) is added it will reuse the same encode/decode — only the transport
|
||||
// changes. Implements Backend; all methods run on the Manager's CAT goroutine,
|
||||
// so the port is accessed single-threaded (no locking needed).
|
||||
type IcomSerial struct {
|
||||
portName string
|
||||
baud int
|
||||
rigAddr byte // rig's CI-V address (IC-7610 default 0x98)
|
||||
digital string // mode to command for DATA (FT8/RTTY/…)
|
||||
|
||||
port serial.Port
|
||||
rx []byte // accumulated bytes awaiting a complete frame
|
||||
model string
|
||||
|
||||
curFreq int64 // last frequency read (for sideband choice)
|
||||
curModeByte byte // last raw Icom mode byte (for filter re-send)
|
||||
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
|
||||
lastSetFreqAt time.Time
|
||||
|
||||
// dsp caches the receive-DSP state for the Icom control tab. Read off the
|
||||
// CAT goroutine via IcomState(), written on the CAT goroutine (RefreshIcom
|
||||
// / setters) — hence the mutex.
|
||||
dspMu sync.Mutex
|
||||
dsp IcomTXState
|
||||
}
|
||||
|
||||
const (
|
||||
icomReadTimeout = 350 * time.Millisecond // wait for a poll response
|
||||
icomCmdTimeout = 400 * time.Millisecond // wait for a set ack (FB/FA)
|
||||
)
|
||||
|
||||
// NewIcomSerial builds an (unconnected) Icom serial backend. baud defaults to
|
||||
// 115200, rig address to the IC-7610's 0x98 when out of range.
|
||||
func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *IcomSerial {
|
||||
if baud <= 0 {
|
||||
baud = 115200
|
||||
}
|
||||
if civAddr <= 0 || civAddr > 0xFF {
|
||||
civAddr = 0x98 // IC-7610
|
||||
}
|
||||
if digitalDefault == "" {
|
||||
digitalDefault = "FT8"
|
||||
}
|
||||
return &IcomSerial{
|
||||
portName: portName,
|
||||
baud: baud,
|
||||
rigAddr: byte(civAddr),
|
||||
digital: strings.ToUpper(digitalDefault),
|
||||
model: "Icom",
|
||||
}
|
||||
}
|
||||
|
||||
func (b *IcomSerial) Name() string { return "icom" }
|
||||
|
||||
func (b *IcomSerial) Connect() error {
|
||||
if b.portName == "" {
|
||||
return fmt.Errorf("no serial port configured")
|
||||
}
|
||||
port, err := serial.Open(b.portName, &serial.Mode{BaudRate: b.baud})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s @ %d baud: %w", b.portName, b.baud, err)
|
||||
}
|
||||
// Short read timeout so recv() polls in a tight loop without blocking the
|
||||
// CAT goroutine when the rig is silent.
|
||||
_ = port.SetReadTimeout(60 * time.Millisecond)
|
||||
b.port = port
|
||||
b.rx = b.rx[:0]
|
||||
b.model = civ.ModelName(b.rigAddr)
|
||||
|
||||
// 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 {
|
||||
return d.Cmd == civ.CmdReadID && len(d.Data) >= 2 && d.Data[0] == 0x00
|
||||
}); err == nil {
|
||||
b.model = civ.ModelName(f.Data[1])
|
||||
}
|
||||
}
|
||||
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 = nil
|
||||
}
|
||||
}
|
||||
|
||||
// ReadState polls the rig for frequency and mode. A failed frequency read is
|
||||
// treated as "lost the rig" so the Manager reconnects.
|
||||
func (b *IcomSerial) ReadState() (RigState, error) {
|
||||
if b.port == nil {
|
||||
return RigState{}, fmt.Errorf("not connected")
|
||||
}
|
||||
s := RigState{Backend: b.Name(), Connected: true, Rig: b.model}
|
||||
|
||||
hz, err := b.readFreq()
|
||||
if err != nil {
|
||||
return RigState{}, err
|
||||
}
|
||||
s.FreqHz = hz
|
||||
b.curFreq = hz
|
||||
|
||||
if m, ok := b.readMode(); ok {
|
||||
b.curModeByte = m
|
||||
data := b.readDataMode() // best-effort; ignored on failure
|
||||
s.Mode = civ.ModeToADIF(m, data)
|
||||
if s.Mode == "DATA" {
|
||||
s.Mode = b.digital
|
||||
}
|
||||
b.dspMu.Lock()
|
||||
b.dsp.Mode = s.Mode
|
||||
b.dspMu.Unlock()
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (b *IcomSerial) SetFrequency(hz int64) error {
|
||||
if hz <= 0 {
|
||||
return fmt.Errorf("invalid frequency")
|
||||
}
|
||||
b.lastSetFreq, b.lastSetFreqAt = hz, time.Now()
|
||||
return b.exec(append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)...)
|
||||
}
|
||||
|
||||
func (b *IcomSerial) SetMode(mode string) error {
|
||||
code, data, err := b.modeCode(mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 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.exec(civ.CmdSetMode, code); err != nil {
|
||||
return err
|
||||
}
|
||||
dataByte := byte(0)
|
||||
if data {
|
||||
dataByte = 1
|
||||
}
|
||||
// Filter 0x01 (FIL1) is the conventional default for the data-mode set.
|
||||
_ = b.exec(civ.CmdExtra, civ.SubDataMode, dataByte, 0x01)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IcomSerial) SetPTT(on bool) error {
|
||||
state := byte(0)
|
||||
if on {
|
||||
state = 1
|
||||
}
|
||||
return b.exec(civ.CmdPTT, civ.SubPTT, state)
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
func (b *IcomSerial) write(payload ...byte) error {
|
||||
_, 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.
|
||||
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
|
||||
}
|
||||
if match(f) {
|
||||
return f, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return civ.Decoded{}, fmt.Errorf("icom: timeout waiting for response")
|
||||
}
|
||||
|
||||
// exec sends a set command and waits for the rig's OK (FB) / NG (FA) ack.
|
||||
func (b *IcomSerial) exec(payload ...byte) error {
|
||||
if err := b.write(payload...); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := b.recv(icomCmdTimeout, func(d civ.Decoded) bool {
|
||||
return d.Cmd == civ.OK || d.Cmd == civ.NG
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if f.Cmd == civ.NG {
|
||||
return fmt.Errorf("icom: rig rejected command 0x%02X", payload[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IcomSerial) readFreq() (int64, error) {
|
||||
if err := b.write(civ.CmdReadFreq); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
||||
return d.Cmd == civ.CmdReadFreq || d.Cmd == civ.CmdTransceiveFreq
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return civ.BCDToFreq(f.Data), nil
|
||||
}
|
||||
|
||||
func (b *IcomSerial) readMode() (byte, bool) {
|
||||
if err := b.write(civ.CmdReadMode); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
||||
return (d.Cmd == civ.CmdReadMode || d.Cmd == civ.CmdTransceiveMode) && len(d.Data) >= 1
|
||||
})
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f.Data[0], true
|
||||
}
|
||||
|
||||
func (b *IcomSerial) readDataMode() bool {
|
||||
if err := b.write(civ.CmdExtra, civ.SubDataMode); err != nil {
|
||||
return false
|
||||
}
|
||||
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
||||
return d.Cmd == civ.CmdExtra && len(d.Data) >= 2 && d.Data[0] == civ.SubDataMode
|
||||
})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return f.Data[1] != 0
|
||||
}
|
||||
|
||||
// modeCode maps an ADIF mode to an Icom mode byte plus whether the data-mode
|
||||
// flag should be set. SSB sideband follows the usual convention (LSB below
|
||||
// 10 MHz, USB above); the frequency just commanded is preferred over the last
|
||||
// poll so a clicked spot (freq then mode) picks the right sideband immediately.
|
||||
func (b *IcomSerial) modeCode(mode string) (code byte, data bool, err error) {
|
||||
freq := b.curFreq
|
||||
if b.lastSetFreq > 0 && time.Since(b.lastSetFreqAt) < 5*time.Second {
|
||||
freq = b.lastSetFreq
|
||||
}
|
||||
usb := byte(civ.ModeUSB)
|
||||
if freq > 0 && freq < 10_000_000 {
|
||||
usb = civ.ModeLSB
|
||||
}
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "CW":
|
||||
return civ.ModeCW, false, nil
|
||||
case "SSB":
|
||||
return usb, false, nil
|
||||
case "AM":
|
||||
return civ.ModeAM, false, nil
|
||||
case "FM":
|
||||
return civ.ModeFM, false, nil
|
||||
case "RTTY", "FSK":
|
||||
return civ.ModeRTTY, false, nil
|
||||
case "FT8", "FT4", "PSK31", "MFSK", "JS8", "JT65", "JT9", "OLIVIA", "DATA", "DIGITALVOICE":
|
||||
// Digital data modes ride on USB with the data flag set (FT8 etc.).
|
||||
return civ.ModeUSB, true, nil
|
||||
}
|
||||
return 0, false, fmt.Errorf("icom: unsupported mode %q", mode)
|
||||
}
|
||||
|
||||
// ── IcomController: receive-DSP controls for the Icom tab ───────────────────
|
||||
|
||||
func (b *IcomSerial) IcomState() IcomTXState {
|
||||
b.dspMu.Lock()
|
||||
defer b.dspMu.Unlock()
|
||||
return b.dsp
|
||||
}
|
||||
|
||||
// RefreshIcom re-reads the whole DSP snapshot from the rig. Runs on the CAT
|
||||
// goroutine (dispatched via IcomDo).
|
||||
func (b *IcomSerial) RefreshIcom() error {
|
||||
if b.port == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
b.readDSP()
|
||||
return nil
|
||||
}
|
||||
|
||||
// readDSP polls every DSP value once and replaces the cache. Best-effort: a
|
||||
// value the rig doesn't answer keeps its previous cached value rather than
|
||||
// stalling (each read has a short timeout).
|
||||
func (b *IcomSerial) readDSP() {
|
||||
st := IcomTXState{Available: true, Model: b.model}
|
||||
b.dspMu.Lock()
|
||||
st.Mode = b.dsp.Mode // preserve mode (set by ReadState)
|
||||
b.dspMu.Unlock()
|
||||
|
||||
if v, ok := b.readLevel(civ.SubLevelAF); ok {
|
||||
st.AFGain = from255(v)
|
||||
}
|
||||
if v, ok := b.readLevel(civ.SubLevelRF); ok {
|
||||
st.RFGain = from255(v)
|
||||
}
|
||||
if v, ok := b.readLevel(civ.SubLevelNR); ok {
|
||||
st.NRLevel = from255(v)
|
||||
}
|
||||
if v, ok := b.readLevel(civ.SubLevelNB); ok {
|
||||
st.NBLevel = from255(v)
|
||||
}
|
||||
if v, ok := b.readSwitch(civ.SubSwNB); ok {
|
||||
st.NB = v != 0
|
||||
}
|
||||
if v, ok := b.readSwitch(civ.SubSwNR); ok {
|
||||
st.NR = v != 0
|
||||
}
|
||||
if v, ok := b.readSwitch(civ.SubSwANF); ok {
|
||||
st.ANF = v != 0
|
||||
}
|
||||
if v, ok := b.readSwitch(civ.SubSwAGC); ok {
|
||||
st.AGC = agcName(v)
|
||||
}
|
||||
if v, ok := b.readSwitch(civ.SubSwPreamp); ok {
|
||||
st.Preamp = int(v)
|
||||
}
|
||||
if v, ok := b.readAtt(); ok {
|
||||
st.Att = v
|
||||
}
|
||||
if _, f, ok := b.readModeFilter(); ok {
|
||||
st.Filter = int(f)
|
||||
}
|
||||
|
||||
b.dspMu.Lock()
|
||||
b.dsp = st
|
||||
b.dspMu.Unlock()
|
||||
}
|
||||
|
||||
const icomDSPTimeout = 150 * time.Millisecond // shorter: unsupported reads mustn't stall the poll
|
||||
|
||||
func (b *IcomSerial) readLevel(sub byte) (int, bool) {
|
||||
if err := b.write(civ.CmdLevel, sub); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
||||
return d.Cmd == civ.CmdLevel && len(d.Data) >= 3 && d.Data[0] == sub
|
||||
})
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return civ.BCDToLevel(f.Data[1:3]), true
|
||||
}
|
||||
|
||||
func (b *IcomSerial) readSwitch(sub byte) (byte, bool) {
|
||||
if err := b.write(civ.CmdSwitch, sub); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
||||
return d.Cmd == civ.CmdSwitch && len(d.Data) >= 2 && d.Data[0] == sub
|
||||
})
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f.Data[1], true
|
||||
}
|
||||
|
||||
func (b *IcomSerial) readAtt() (int, bool) {
|
||||
if err := b.write(civ.CmdAtt); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
||||
return d.Cmd == civ.CmdAtt && len(d.Data) >= 1
|
||||
})
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return civ.BCDToByte(f.Data[0]), true
|
||||
}
|
||||
|
||||
func (b *IcomSerial) readModeFilter() (mode, filter byte, ok bool) {
|
||||
if err := b.write(civ.CmdReadMode); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
||||
return d.Cmd == civ.CmdReadMode && len(d.Data) >= 2
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return f.Data[0], f.Data[1], true
|
||||
}
|
||||
|
||||
func (b *IcomSerial) SetAFGain(p int) error {
|
||||
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelAF}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
||||
return err
|
||||
}
|
||||
b.setCache(func(s *IcomTXState) { s.AFGain = clampPct(p) })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IcomSerial) SetRFGain(p int) error {
|
||||
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelRF}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
||||
return err
|
||||
}
|
||||
b.setCache(func(s *IcomTXState) { s.RFGain = clampPct(p) })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IcomSerial) SetNB(on bool) error {
|
||||
if err := b.exec(civ.CmdSwitch, civ.SubSwNB, boolByte(on)); err != nil {
|
||||
return err
|
||||
}
|
||||
b.setCache(func(s *IcomTXState) { s.NB = on })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IcomSerial) SetNBLevel(p int) error {
|
||||
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelNB}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
||||
return err
|
||||
}
|
||||
b.setCache(func(s *IcomTXState) { s.NBLevel = clampPct(p) })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IcomSerial) SetNR(on bool) error {
|
||||
if err := b.exec(civ.CmdSwitch, civ.SubSwNR, boolByte(on)); err != nil {
|
||||
return err
|
||||
}
|
||||
b.setCache(func(s *IcomTXState) { s.NR = on })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IcomSerial) SetNRLevel(p int) error {
|
||||
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelNR}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
||||
return err
|
||||
}
|
||||
b.setCache(func(s *IcomTXState) { s.NRLevel = clampPct(p) })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IcomSerial) SetANF(on bool) error {
|
||||
if err := b.exec(civ.CmdSwitch, civ.SubSwANF, boolByte(on)); err != nil {
|
||||
return err
|
||||
}
|
||||
b.setCache(func(s *IcomTXState) { s.ANF = on })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IcomSerial) SetAGC(name string) error {
|
||||
v := agcValue(name)
|
||||
if v == 0 {
|
||||
return fmt.Errorf("icom: invalid AGC %q", name)
|
||||
}
|
||||
if err := b.exec(civ.CmdSwitch, civ.SubSwAGC, v); err != nil {
|
||||
return err
|
||||
}
|
||||
b.setCache(func(s *IcomTXState) { s.AGC = strings.ToUpper(name) })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IcomSerial) SetPreamp(n int) error {
|
||||
if n < 0 || n > 2 {
|
||||
return fmt.Errorf("icom: invalid preamp %d", n)
|
||||
}
|
||||
if err := b.exec(civ.CmdSwitch, civ.SubSwPreamp, byte(n)); err != nil {
|
||||
return err
|
||||
}
|
||||
b.setCache(func(s *IcomTXState) { s.Preamp = n })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IcomSerial) SetAtt(db int) error {
|
||||
if err := b.exec(civ.CmdAtt, civ.ByteToBCD(db)); err != nil {
|
||||
return err
|
||||
}
|
||||
b.setCache(func(s *IcomTXState) { s.Att = db })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IcomSerial) SetIcomFilter(n int) error {
|
||||
if n < 1 || n > 3 {
|
||||
return fmt.Errorf("icom: invalid filter %d", n)
|
||||
}
|
||||
if b.curModeByte == 0 {
|
||||
// Need the current mode to re-send with the chosen filter.
|
||||
if m, _, ok := b.readModeFilter(); ok {
|
||||
b.curModeByte = m
|
||||
}
|
||||
}
|
||||
if err := b.exec(civ.CmdSetMode, b.curModeByte, byte(n)); err != nil {
|
||||
return err
|
||||
}
|
||||
b.setCache(func(s *IcomTXState) { s.Filter = n })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IcomSerial) setCache(fn func(*IcomTXState)) {
|
||||
b.dspMu.Lock()
|
||||
fn(&b.dsp)
|
||||
b.dspMu.Unlock()
|
||||
}
|
||||
|
||||
// ── small helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
func to255(p int) int { return clampPct(p) * 255 / 100 }
|
||||
func from255(v int) int { return (v*100 + 127) / 255 }
|
||||
func clampPct(p int) int { return min(100, max(0, p)) }
|
||||
|
||||
func boolByte(on bool) byte {
|
||||
if on {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func agcName(v byte) string {
|
||||
switch v {
|
||||
case 1:
|
||||
return "FAST"
|
||||
case 2:
|
||||
return "MID"
|
||||
case 3:
|
||||
return "SLOW"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func agcValue(name string) byte {
|
||||
switch strings.ToUpper(strings.TrimSpace(name)) {
|
||||
case "FAST":
|
||||
return 1
|
||||
case "MID":
|
||||
return 2
|
||||
case "SLOW":
|
||||
return 3
|
||||
}
|
||||
return 0
|
||||
}
|
||||
Reference in New Issue
Block a user