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 }