package cat import ( "fmt" "strings" "sync" "time" "hamlog/internal/applog" "hamlog/internal/cat/civ" "go.bug.st/serial" ) // civTransport is everything IcomSerial needs from its underlying link. It's // satisfied directly by a go.bug.st serial.Port (USB, local control) and, for // remote control, by the icomnet UDP stream which presents the tunnelled CI-V // byte stream as plain Read/Write (there SetDTR/SetRTS/SetReadTimeout are // no-ops). Abstracting it here means the whole IcomController surface — DSP, // scope, RIT, CW — is reused unchanged over the network; only `open` differs. type civTransport interface { Read(p []byte) (int, error) Write(p []byte) (int, error) Close() error SetReadTimeout(time.Duration) error SetDTR(bool) error SetRTS(bool) error } // aliveTransport is an OPTIONAL transport capability: report whether the link is // still up independently of whether the rig answers CI-V. The network transport // implements it (the rig's server pings even in standby), letting ReadState keep // the session "connected but rig off" instead of tearing it down and flapping. // USB doesn't implement it (no such out-of-band signal), so it keeps the bounded // read-failure tolerance instead. type aliveTransport interface { Alive() bool } // scopeTransport is an OPTIONAL transport capability: deliver spectrum-scope // (0x27) frames on a SEPARATE channel from control replies. The network transport // implements it so the continuous panadapter stream can't crowd control replies // out of the main Read path (which made every command time out with the scope // on). USB doesn't implement it — there the scope frames ride the normal Read // path and the reader splits them off to specCh. type scopeTransport interface { ScopeChan() <-chan []byte } // IcomSerial controls an Icom transceiver over the shared civ protocol. The // transport is pluggable via `open`: NewIcomSerial opens a USB/serial port; // NewIcomNet (later) returns one configured with a network transport. 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/…) // open dials the underlying link (serial port or network stream). Set by the // constructor; called by Connect. Blocks until connected (the network opener // performs the full UDP handshake + login before returning). open func() (civTransport, error) port civTransport model string // I/O routing. A single reader goroutine owns port.Read and dispatches every // decoded rig frame: control replies go to respCh (drained by recv), while // spectrum-scope frames (0x27) go to specCh for the panadapter. This decouples // the continuous scope stream from the request/response control path — without // it, scope frames would flood recv() and stall polling. respCh chan civ.Decoded specCh chan civ.Decoded readerDone chan struct{} // Spectrum scope (0x27). dualScope marks rigs whose waveform frames carry a // leading main/sub selector byte (IC-7610/9700). scopeAmp is the latest // reassembled sweep; scopeMu guards it (written by the scope goroutine, read // via ScopeData from the binding goroutine). dualScope bool scopeMu sync.Mutex scopeAmp []byte scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame) scopeHigh int64 // spectrum right-edge frequency scopeSeq int scopeOn bool scopeFixed bool // true = fixed-span mode (tracked optimistically) scopeSeen bool // logged the first sweep's structure once (on-rig verification) curFreq int64 // last frequency read (for sideband choice) curModeByte byte // last raw Icom mode byte (for filter re-send) pollN int // ReadState cycle counter (staggers slow reads) splitOn bool // last read split state (refreshed every few cycles) splitTXFreq int64 // last read unselected/TX VFO freq while in split readFails int // consecutive ReadState freq-read failures (transient tolerance) dspLoaded bool // readDSP has run since the rig became responsive (loads all // the panel's set-once controls once the rig actually answers) 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 // dialCancel is closed by Interrupt() to abort an in-progress network dial // (icomnet's handshake/login/boot-wait can block ~tens of seconds). A fresh // channel is made by each Connect. Guarded by dialMu: written on the CAT // goroutine, closed from the goroutine calling Stop. dialMu sync.Mutex dialCancel chan struct{} } // Interrupt aborts an in-progress network Connect so Stop()/Start() don't block // on a slow UDP handshake (or the 25 s boot-from-standby wait). Safe to call at // any time and from another goroutine; harmless when no dial is in progress and // a no-op for the USB transport (which dials instantly). func (b *IcomSerial) Interrupt() { b.dialMu.Lock() if b.dialCancel != nil { select { case <-b.dialCancel: // already closed default: close(b.dialCancel) } } b.dialMu.Unlock() } 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" } b := &IcomSerial{ portName: portName, baud: baud, rigAddr: byte(civAddr), digital: strings.ToUpper(digitalDefault), model: "Icom", scopeFixed: true, // rigs default to a fixed-span scope } // USB/serial transport opener. b.open = func() (civTransport, error) { if b.portName == "" { return nil, fmt.Errorf("no serial port configured") } p, err := serial.Open(b.portName, &serial.Mode{BaudRate: b.baud}) if err != nil { return nil, fmt.Errorf("open %s @ %d baud: %w", b.portName, b.baud, err) } return p, nil } return b } func (b *IcomSerial) Name() string { return "icom" } func (b *IcomSerial) Connect() error { if b.open == nil { return fmt.Errorf("no transport configured") } // Fresh cancel channel for this dial so Interrupt() (called by Stop) can abort // a slow network handshake instead of freezing the UI. b.dialMu.Lock() b.dialCancel = make(chan struct{}) b.dialMu.Unlock() port, err := b.open() if err != nil { return err } // Short read timeout so recv() polls in a tight loop without blocking the // CAT goroutine when the rig is silent. (No-op on the network transport.) _ = port.SetReadTimeout(60 * time.Millisecond) // Deassert DTR/RTS. Icom USB rigs (IC-7610, IC-7300…) let "USB SEND" and // "USB Keying (CW)" be mapped to the RTS or DTR line: if the port opens with // those asserted, the rig keys into TRANSMIT. PTT here is CI-V only, so both // hardware lines must stay low. _ = port.SetDTR(false) _ = port.SetRTS(false) b.port = port b.model = civ.ModelName(b.rigAddr) // Start the reader before any request: recv() now waits on respCh, which only // the reader feeds. respCh is buffered so a burst (or the scope stream) never // blocks the reader; specCh holds the latest scope frames for the panadapter. b.respCh = make(chan civ.Decoded, 64) b.specCh = make(chan civ.Decoded, 32) b.readerDone = make(chan struct{}) go b.reader(port, b.readerDone) go b.scopeLoop(b.specCh, b.readerDone) // On the network the scope frames come on their own channel (kept off the // control Read path); feed them into the same scope pipeline. if sc, ok := port.(scopeTransport); ok { go b.netScopeFeeder(sc.ScopeChan(), b.readerDone) } // Best-effort model identification: ask the rig for its own CI-V address. The // 0x19 ID read returns the rig's FACTORY default address (e.g. 0x94 for an // IC-7300) regardless of the address it's currently OPERATING on — so it's the // reliable model signal even when the user runs the rig at a non-default CI-V // address (a common trick to make CAT "just work" at 0x98). idAddr := b.rigAddr // fallback: the configured address if the ID read fails 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]) idAddr = f.Data[1] } } // Dual-scope rigs (IC-7610/9700) prefix each waveform frame with a main/sub // selector byte; single-scope rigs (IC-7300…) do not. Decide from the // IDENTIFIED model, NOT the configured address: an IC-7300 run at 0x98 must // still parse single-scope frames (this was the "scope blank on the 7300" bug). b.dualScope = idAddr == 0x98 || idAddr == 0xA2 // Defer the DSP snapshot until the rig actually answers CI-V. Over the network // the rig may still be booting (or off) at Connect, so an immediate readDSP // would time out and leave every control at 0 / off with no retry. ReadState // loads it once on the first successful freq read instead (see dspLoaded). b.dspLoaded = false return nil } func (b *IcomSerial) Disconnect() { if b.port != nil { _ = b.port.Close() // unblocks the reader's pending Read b.port = nil } if b.readerDone != nil { <-b.readerDone // wait for the reader goroutine to exit cleanly b.readerDone = 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 { // Network transport: if the control link is still alive, the rig is simply // silent — either in standby / powered OFF (the ON button is manual now), or // mid band-change. Stay CONNECTED and show last-known state (empty until the // rig is switched on) rather than tearing the whole UDP session down and // flapping every few seconds. The panel stays up so the ON button works. if at, ok := b.port.(aliveTransport); ok { if at.Alive() { b.readFails = 0 s.FreqHz = b.curFreq // 0 until the rig is powered on and first read if b.curModeByte != 0 { s.Mode = civ.ModeToADIF(b.curModeByte, false) if s.Mode == "DATA" { s.Mode = b.digital } } // Keep the Icom panel visible (so ON/OFF are reachable) but show no // live meters while the rig is silent. b.dspMu.Lock() b.dsp.Available = true b.dsp.Model = b.model b.dsp.Transmitting = false b.dsp.SMeter, b.dsp.PowerMeter, b.dsp.SWRMeter = 0, 0, 0 b.dspMu.Unlock() return s, nil } debugLog.Printf("icom net: control link went quiet (no rig packets for >6 s) → reconnecting. If this recurs every ~2-3 min, the rig is invalidating the session (token renewal rejected).") return RigState{}, err // control link dead → let the Manager reconnect } // USB (no liveness signal): the rig briefly stops answering CI-V while it // switches band/VFO. Tolerate a few consecutive misses as transient — keep // the connection and report last-known state — so a band change doesn't // trigger a full disconnect + 5 s reconnect. Only after several failures do // we declare the rig lost so the Manager reconnects. b.readFails++ if b.readFails <= 6 && b.curFreq > 0 { s.FreqHz = b.curFreq if b.curModeByte != 0 { s.Mode = civ.ModeToADIF(b.curModeByte, false) if s.Mode == "DATA" { s.Mode = b.digital } } return s, nil } return RigState{}, err } b.readFails = 0 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() } b.pollN++ // Split: the selected VFO (read above) is RX; the unselected VFO is TX. ADIF // convention → FreqHz = TX, RxFreqHz = RX. Split changes rarely and its read // (0x0F + 0x25, each with a 350 ms timeout) is the costliest part of a poll, // so refresh it only every 4th cycle and reuse the cached value between — // this keeps the CAT thread free for the freq/mode/meter reads and, above // all, for the user's Set* commands. if b.pollN%4 == 1 { b.splitOn, b.splitTXFreq = false, 0 if on, ok := b.readSplit(); ok && on { if txHz, ok2 := b.readTXFreq(); ok2 && txHz > 0 { b.splitOn, b.splitTXFreq = true, txHz } } } if b.splitOn && b.splitTXFreq > 0 && b.splitTXFreq != s.FreqHz { s.Split = true s.RxFreqHz = s.FreqHz // selected VFO = RX s.FreqHz = b.splitTXFreq // unselected VFO = TX } // Live meters + TX state for the Icom panel (the rig doesn't push these). tx := b.readTX() sm, _ := b.readMeter(civ.SubMeterS) po, swr := 0, 0 if tx { if v, ok := b.readMeter(civ.SubMeterPo); ok { po = v } if v, ok := b.readMeter(civ.SubMeterSWR); ok { swr = v } } b.dspMu.Lock() b.dsp.Available = true b.dsp.Model = b.model b.dsp.Transmitting = tx b.dsp.Split = s.Split b.dsp.SMeter = sm b.dsp.PowerMeter = po b.dsp.SWRMeter = swr b.dspMu.Unlock() // First time the rig answers (it's booted/responsive): load the full DSP // snapshot so the panel's antenna, sliders, RIT, notch, etc. reflect the rig // instead of sitting at their zero defaults. Runs once; ↻ Refresh re-reads on // demand, and a reconnect re-arms it (Connect clears dspLoaded). if !b.dspLoaded { b.readDSP() b.dspLoaded = true } 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) } // SetPower turns the transceiver on or off (CI-V 0x18). Power-ON is prefixed with // a run of 0xFE — the wake preamble Icom rigs need to notice a command while // asleep (harmless when already awake); after it the rig boots for ~10-15 s. // Sent raw with no ack wait, since a rig waking up or shutting down won't // reliably answer. On the network transport the whole buffer becomes one data // packet, exactly as the Remote Utility sends it. Power is manual (the app never // wakes the rig on connect), so this is driven by the panel's ON/OFF button. func (b *IcomSerial) SetPower(on bool) error { if b.port == nil { return fmt.Errorf("icom: not connected") } if on { buf := make([]byte, 0, 32) for i := 0; i < 25; i++ { buf = append(buf, 0xFE) } buf = append(buf, 0xFE, 0xFE, b.rigAddr, civ.AddrController, civ.CmdPower, 0x01, 0xFD) _, err := b.port.Write(buf) return err } _, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, civ.CmdPower, 0x00)) return err } // ── 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). b.drainResp() _, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, payload...)) return err } // recv waits for a frame the reader routed to respCh that satisfies match, or // times out. The reader has already discarded echoes and split off scope frames, // so recv only ever sees candidate control replies. It also bails out at once if // Interrupt() fires (Stop) so an in-flight ReadState — which can be a dozen reads, // each up to icomReadTimeout when the rig is slow — doesn't make Stop/Save-&-Close // wait several seconds for the poll goroutine to finish. func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (civ.Decoded, error) { b.dialMu.Lock() cancel := b.dialCancel b.dialMu.Unlock() deadline := time.After(timeout) for { select { case f := <-b.respCh: if match(f) { return f, nil } case <-cancel: return civ.Decoded{}, fmt.Errorf("icom: interrupted") case <-deadline: return civ.Decoded{}, fmt.Errorf("icom: timeout waiting for response") } } } // reader is the sole owner of port.Read. It decodes the CI-V byte stream into // frames and routes each: our own echoes are dropped, spectrum-scope frames // (0x27) go to specCh, everything else (control replies, acks, unsolicited // transceive updates) goes to respCh. It exits when the port is closed. func (b *IcomSerial) reader(port civTransport, done chan struct{}) { defer close(done) tmp := make([]byte, 512) var rx []byte for { n, err := port.Read(tmp) if err != nil { return // port closed or failed — Disconnect/reconnect handles it } if n == 0 { continue // read timeout with no data } rx = append(rx, tmp[:n]...) frames, consumed := civ.Scan(rx) if consumed > 0 { rx = append(rx[:0], rx[consumed:]...) } for _, f := range frames { if f.From != b.rigAddr { continue // echo of our own command } if f.Cmd == civ.CmdScope { b.route(b.specCh, f) continue } b.route(b.respCh, f) } } } // netScopeFeeder decodes the raw scope (0x27) CI-V frames the network transport // delivers on its own channel and routes them into specCh — the same pipeline // the USB reader feeds — so scopeLoop assembles them identically. Exits when the // connection's reader does (done closes on Disconnect). func (b *IcomSerial) netScopeFeeder(ch <-chan []byte, done chan struct{}) { var buf []byte for { select { case <-done: return case raw, ok := <-ch: if !ok { return } buf = append(buf, raw...) frames, consumed := civ.Scan(buf) if consumed > 0 { buf = append(buf[:0], buf[consumed:]...) } for _, f := range frames { if f.From == b.rigAddr && f.Cmd == civ.CmdScope { b.route(b.specCh, f) } } if len(buf) > 1<<16 { // a frame that never completes — don't grow forever buf = buf[:0] } } } } // route delivers a frame without ever blocking the reader: if the channel is // full it drops the oldest entry to make room for the newest. func (b *IcomSerial) route(ch chan civ.Decoded, f civ.Decoded) { select { case ch <- f: default: select { // buffer full — discard oldest, then enqueue newest case <-ch: default: } select { case ch <- f: default: } } } // drainResp empties any pending control frames (non-blocking). func (b *IcomSerial) drainResp() { for { select { case <-b.respCh: default: return } } } // ── spectrum scope (0x27) ─────────────────────────────────────────────────── // scopeLoop reassembles the Icom's divided waveform frames into complete sweeps. // Frame layout (verified on an IC-7610): Data = [00, main/sub, seq, total, …]. // The first frame (seq==1) is a HEADER — [info, low-edge 5-BCD, high-edge 5-BCD] // — and carries NO waveform bytes; frames 2..total each carry a block of // amplitude bytes. So we parse the edges from frame 1 and concatenate frames // 2..total for the trace. func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) { regions := make(map[byte][]byte) var total byte rawN := 0 // diagnostic: dump the first few raw 0x27 frames loggedCfg := map[byte]bool{} // one-shot dump of each config read response for { select { case <-done: return case f := <-spec: if len(f.Data) < 1 { continue } if f.Data[0] != civ.SubScopeData { // Non-waveform 0x27 frame = a config read response (mode/span/edge). // Log each subcommand once so we can confirm its exact byte layout. if !loggedCfg[f.Data[0]] { loggedCfg[f.Data[0]] = true applog.Printf("icom scope cfg 0x%02X: data=[% X]", f.Data[0], f.Data) } continue } if rawN < 24 { rawN++ applog.Printf("icom scope raw #%d: len=%d data=[% X]", rawN, len(f.Data), f.Data) } idx := 1 if b.dualScope { if len(f.Data) < 2 || f.Data[1] != 0x00 { continue // only the MAIN scope } idx = 2 } if len(f.Data) < idx+2 { continue } seq, tot := f.Data[idx], f.Data[idx+1] region := f.Data[idx+2:] if seq == 0 || tot == 0 { continue } if tot == 1 { // Network single-frame sweep: the WHOLE sweep is in one frame — // region = [info][low 5-BCD][high 5-BCD][amplitude bytes…]. Parse the // edges and take the rest as the trace, then publish immediately. // (USB splits this across 21 frames; the net rig sends it as one.) if len(region) >= 11 { // Net single-frame layout (IC-7610): region = [info 1B][freq1 5-BCD] // [freq2 5-BCD][amplitude bytes]. The two freq fields depend on the // scope mode: FIXED sends [low-edge][high-edge] (both absolute), CENTRE // sends [centre][span]. Tell them apart by magnitude — a second value // BIGGER than the first is a real high edge; a small one is a span // (e.g. 14.200 MHz + 100 kHz → centred 14.150-14.250; 21.000 + // 21.070 → fixed 21.000-21.070). Guessing wrong here gave the absurd // 21.000-42.070 span (low + a 21 MHz "span"). v1 := civ.BCDToFreq(region[1:6]) v2 := civ.BCDToFreq(region[6:11]) var low, high int64 if v2 > v1 { low, high = v1, v2 // absolute low/high edges (fixed edge set) } else { low, high = v1-v2/2, v1+v2/2 // centre + span (centre-on-VFO) } amp := append([]byte(nil), region[11:]...) b.scopeMu.Lock() b.scopeLow, b.scopeHigh = low, high b.scopeAmp = amp b.scopeSeq++ firstLog := !b.scopeSeen b.scopeSeen = true b.scopeMu.Unlock() if firstLog { head := region if len(head) > 16 { head = head[:16] } applog.Printf("icom scope (net 1-frame): head=[% X] v1=%d v2=%d → %d..%d Hz points=%d", head, v1, v2, low, high, len(amp)) } } continue } if seq == 1 { // header frame — begins a new sweep, no waveform data regions = make(map[byte][]byte) total = tot if len(region) >= 11 { // [info][low 5][high 5] low := civ.BCDToFreq(region[1:6]) high := civ.BCDToFreq(region[6:11]) b.scopeMu.Lock() b.scopeLow, b.scopeHigh = low, high b.scopeMu.Unlock() } continue } if total == 0 || tot != total { continue // stray frame from a sweep whose header we missed } regions[seq] = append([]byte(nil), region...) if seq == total { // last data frame — assemble in sequence order b.assembleSweep(regions, total) } } } } func (b *IcomSerial) assembleSweep(regions map[byte][]byte, total byte) { var amp []byte for s := byte(2); s <= total; s++ { amp = append(amp, regions[s]...) } b.scopeMu.Lock() b.scopeAmp = amp b.scopeSeq++ firstLog := !b.scopeSeen b.scopeSeen = true low, high := b.scopeLow, b.scopeHigh b.scopeMu.Unlock() if firstLog { applog.Printf("icom scope: first sweep — model=%s total=%d points=%d edges=%d..%d Hz", b.model, total, len(amp), low, high) } } // SetScope enables or disables the spectrum scope. Two commands are needed and // RS-BA1 sends both: 0x27 0x10 turns the scope DISPLAY on (without it the rig // streams nothing — the case when we're remote and can't touch the front panel), // and 0x27 0x11 turns the waveform data OUTPUT over CI-V on. While on, the reader // routes every 0x27 frame to scopeLoop. func (b *IcomSerial) SetScope(on bool) error { if on { // Context for the scope-diagnostic log: which rig + whether we expect the // dual-scope (main/sub) frame layout. If the IC-7300 (single scope) streams // but nothing shows, compare its `icom scope raw` frames against this. applog.Printf("icom scope: enable on rig=%q addr=0x%02X dualScope=%v (expect %s frame layout)", b.model, b.rigAddr, b.dualScope, map[bool]string{true: "27 00 [MS] [seq] [total] …", false: "27 00 [seq] [total] …"}[b.dualScope]) } // Some firmwares don't ack 0x27 sets; a timeout here isn't fatal, so log and // continue rather than abort the second command. if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, boolByte(on)); err != nil { applog.Printf("icom scope: display on=%v ack: %v", on, err) } if err := b.exec(civ.CmdScope, civ.SubScopeOn, boolByte(on)); err != nil { applog.Printf("icom scope: output on=%v ack: %v", on, err) } b.scopeMu.Lock() b.scopeOn = on if !on { b.scopeAmp = nil } b.scopeMu.Unlock() if on { // Fire read requests for the mode/span/edge settings; their 0x27 responses // route to scopeLoop, which logs each once so we can confirm the layout. // Best-effort (fire-and-forget) — responses are 0x27, not FB/FA acks. b.scopeReadCfg() } return nil } // scopeReadCfg requests the scope's mode/span/edge settings for the diagnostic // log. Sent both with and without the leading main/sub selector byte so we // capture whichever form the rig answers. func (b *IcomSerial) scopeReadCfg() { for _, sub := range []byte{civ.SubScopeMode, civ.SubScopeSpan, civ.SubScopeEdge} { _ = b.write(civ.CmdScope, sub) if b.dualScope { _ = b.write(civ.CmdScope, sub, 0x00) } } } // SetScopeMode selects fixed-span (true) or center-on-VFO (false). Center mode // makes the scope follow the VFO, so tuning pans the view left/right. func (b *IcomSerial) SetScopeMode(fixed bool) error { mode := boolByte(fixed) // 0 = center, 1 = fixed (verify on rig via the cfg log) var payload []byte if b.dualScope { payload = []byte{civ.CmdScope, civ.SubScopeMode, 0x00, mode} } else { payload = []byte{civ.CmdScope, civ.SubScopeMode, mode} } if err := b.exec(payload...); err != nil { applog.Printf("icom scope: set mode fixed=%v ack: %v", fixed, err) } b.scopeMu.Lock() b.scopeFixed = fixed b.scopeMu.Unlock() return nil } // icScopeRanges maps a frequency to the IC-7610's spectrum edge-frequency range // id (from Hamlib's ic7610 caps). SetScopeEdges needs it to address the right // fixed-edge memory. Each range spans a chunk of the tuning range. var icScopeRanges = []struct { lo, hi int64 id byte }{ {30_000, 1_600_000, 1}, {1_600_000, 2_000_000, 2}, {2_000_000, 6_000_000, 3}, {6_000_000, 8_000_000, 4}, {8_000_000, 11_000_000, 5}, {11_000_000, 15_000_000, 6}, {15_000_000, 20_000_000, 7}, {20_000_000, 22_000_000, 8}, {22_000_000, 26_000_000, 9}, {26_000_000, 30_000_000, 10}, {30_000_000, 45_000_000, 11}, {45_000_000, 60_000_000, 12}, } // scopeRangeBCD returns the range id (as a 1-byte BCD) for a frequency, or 0 if // out of range. func scopeRangeBCD(freq int64) byte { for _, r := range icScopeRanges { if freq >= r.lo && freq < r.hi { return byte(r.id/10)<<4 | byte(r.id%10) // 1-byte BCD (11 → 0x11) } } return 0 } // SetScopeEdges points the FIXED-mode scope at [low..high] by writing them into // the rig's fixed-edge memory (edge set 1) and making that set active. This is // how the panel's "centre on VFO" and pan ◀/▶ work: they just compute VFO±50 kHz // (and shift it) and set the edges — no dependence on the waveform decode. CI-V: // 0x27 0x14 fixed, 0x27 0x16 set 1 active, 0x27 0x1e [range][set][low][high]. func (b *IcomSerial) SetScopeEdges(low, high int64) error { if low <= 0 || high <= low { return fmt.Errorf("icom scope: bad edges %d..%d", low, high) } rangeID := scopeRangeBCD((low + high) / 2) if rangeID == 0 { return fmt.Errorf("icom scope: freq out of range") } if b.dualScope { _ = b.exec(civ.CmdScope, civ.SubScopeMode, 0x00, 0x01) // fixed mode (main) _ = b.exec(civ.CmdScope, civ.SubScopeEdge, 0x00, 0x01) // activate edge set 1 } else { _ = b.exec(civ.CmdScope, civ.SubScopeMode, 0x01) _ = b.exec(civ.CmdScope, civ.SubScopeEdge, 0x01) } payload := append([]byte{civ.CmdScope, civ.SubScopeFixEdge, rangeID, 0x01}, civ.FreqToBCD(low)...) payload = append(payload, civ.FreqToBCD(high)...) b.scopeMu.Lock() b.scopeFixed = true b.scopeMu.Unlock() return b.exec(payload...) } // 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 } // SendCW keys a CW message through the rig's internal keyer (CI-V 0x17). The // text is upper-cased and filtered to keyer-legal characters; the radio must be // in CW mode. Messages longer than 30 characters are split across several 0x17 // commands (the rig queues them). func (b *IcomSerial) SendCW(text string) error { msg := civ.FilterCW(text) if msg == "" { applog.Printf("icom cw: nothing to send (filtered %q → empty)", text) return nil } applog.Printf("icom cw: send %q (%d chars) to rig 0x%02X", msg, len(msg), b.rigAddr) for len(msg) > 0 { n := len(msg) if n > 30 { n = 30 } chunk := msg[:n] msg = msg[n:] payload := append([]byte{civ.CmdSendCW}, []byte(chunk)...) if err := b.write(payload...); err != nil { applog.Printf("icom cw: write failed: %v", err) return err } // A missing ack is NOT fatal: some firmwares don't acknowledge 0x17, and // the message bytes were already written. Only an explicit NG (0xFA) means // the rig refused it (typically: not in CW mode / break-in off). f, err := b.recv(icomCmdTimeout, func(d civ.Decoded) bool { return d.Cmd == civ.OK || d.Cmd == civ.NG }) if err != nil { applog.Printf("icom cw: chunk %q written, no ack (sent anyway): %v", chunk, err) } else if f.Cmd == civ.NG { applog.Printf("icom cw: rig REJECTED CW (0xFA) — put the rig in CW mode / enable break-in") return fmt.Errorf("icom: rig rejected CW — check CW mode / break-in") } else { applog.Printf("icom cw: chunk %q acked OK", chunk) } } return nil } // SetBreakIn sets CW break-in (0=OFF, 1=SEMI, 2=FULL). Break-in must be on for // the 0x17 CW keyer to actually switch the rig to transmit. func (b *IcomSerial) SetBreakIn(mode int) error { if mode < 0 { mode = 0 } if mode > 2 { mode = 2 } applog.Printf("icom cw: set break-in %d", mode) if err := b.exec(civ.CmdSwitch, civ.SubSwBreakIn, byte(mode)); err != nil { applog.Printf("icom cw: set break-in failed: %v", err) return err } b.setCache(func(s *IcomTXState) { s.BreakIn = mode }) return nil } // StopCW aborts a CW message currently being sent (0x17 with the 0xFF stop code). func (b *IcomSerial) StopCW() error { applog.Printf("icom cw: stop") if err := b.write(civ.CmdSendCW, civ.StopCWByte); err != nil { return err } _, _ = b.recv(icomCmdTimeout, func(d civ.Decoded) bool { return d.Cmd == civ.OK || d.Cmd == civ.NG }) return nil } // SetKeySpeed sets the CW keyer speed in WPM (CmdLevel 0x0C). func (b *IcomSerial) SetKeySpeed(wpm int) error { lvl := civ.WPMToKeyLevel(wpm) applog.Printf("icom cw: set key speed %d WPM (level %d)", wpm, lvl) if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelKeySpeed}, civ.LevelToBCD(lvl)...)...); err != nil { applog.Printf("icom cw: set key speed failed: %v", err) return err } if wpm < civ.KeyMinWPM { wpm = civ.KeyMinWPM } if wpm > civ.KeyMaxWPM { wpm = civ.KeyMaxWPM } b.setCache(func(s *IcomTXState) { s.KeySpeedWPM = wpm }) 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() defer b.scopeMu.Unlock() amp := make([]int, len(b.scopeAmp)) for i, v := range b.scopeAmp { amp[i] = int(v) } return ScopeSweep{Amp: amp, Seq: b.scopeSeq, LowHz: b.scopeLow, HighHz: b.scopeHigh, Fixed: b.scopeFixed} } // 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 } // readSplit reads the rig's split state (CI-V 0x0F). 0x01 = split on; 0x10/0x11 // are repeater duplex (not split) and 0x00 is off. func (b *IcomSerial) readSplit() (on bool, ok bool) { if err := b.write(civ.CmdSplit); err != nil { return false, false } f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool { return d.Cmd == civ.CmdSplit && len(d.Data) >= 1 }) if err != nil { return false, false } return f.Data[0] == 0x01, true } // readTXFreq reads the UNSELECTED VFO's frequency (CI-V 0x25/01) — the TX VFO // when the rig is in split. Supported on IC-7610/7300/7851/705/9700 and similar. func (b *IcomSerial) readTXFreq() (int64, bool) { if err := b.write(civ.CmdVfoFreq, civ.SubVfoUnselected); err != nil { return 0, false } f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool { return d.Cmd == civ.CmdVfoFreq && len(d.Data) >= 6 && d.Data[0] == civ.SubVfoUnselected }) if err != nil { return 0, false } return civ.BCDToFreq(f.Data[1:]), true } // readTX reads the transmit state (CI-V 0x1C 0x00): non-zero data = keyed. func (b *IcomSerial) readTX() bool { if err := b.write(civ.CmdPTT, civ.SubPTT); err != nil { return false } f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool { return d.Cmd == civ.CmdPTT && len(d.Data) >= 2 && d.Data[0] == civ.SubPTT }) if err != nil { return false } return f.Data[1] != 0 } // readMeter reads a meter (CI-V 0x15) and returns it scaled to 0-100. func (b *IcomSerial) readMeter(sub byte) (int, bool) { if err := b.write(civ.CmdMeter, sub); err != nil { return 0, false } f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool { return d.Cmd == civ.CmdMeter && len(d.Data) >= 3 && d.Data[0] == sub }) if err != nil { return 0, false } return from255(civ.BCDToLevel(f.Data[1:3])), true } 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() // Preserve the live fields ReadState polls (mode, TX/split, meters) — readDSP // only refreshes the set-once DSP values. st.Mode = b.dsp.Mode st.Transmitting = b.dsp.Transmitting st.Split = b.dsp.Split st.SMeter = b.dsp.SMeter st.PowerMeter = b.dsp.PowerMeter st.SWRMeter = b.dsp.SWRMeter 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.SubLevelRFPower); ok { st.RFPower = from255(v) } if v, ok := b.readLevel(civ.SubLevelMic); ok { st.MicGain = 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.SubSwAPF); ok { st.APF = 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.readRIT(&st) if v, ok := b.readLevel(civ.SubLevelKeySpeed); ok { st.KeySpeedWPM = civ.KeyLevelToWPM(v) } if v, ok := b.readSwitch(civ.SubSwBreakIn); ok { st.BreakIn = int(v) } // Antenna + filter fine controls + TX extras. if v, ok := b.readAnt(); ok { st.Antenna = v } if v, ok := b.readLevel(civ.SubLevelPBTIn); ok { st.PBTInner = from255(v) } if v, ok := b.readLevel(civ.SubLevelPBTOut); ok { st.PBTOuter = from255(v) } if v, ok := b.readSwitch(civ.SubSwMN); ok { st.ManualNotch = v != 0 } if v, ok := b.readLevel(civ.SubLevelNotch); ok { st.NotchPos = from255(v) } if v, ok := b.readLevel(civ.SubLevelSQL); ok { st.Squelch = from255(v) } if v, ok := b.readSwitch(civ.SubSwComp); ok { st.Comp = v != 0 } if v, ok := b.readLevel(civ.SubLevelComp); ok { st.CompLevel = from255(v) } if v, ok := b.readSwitch(civ.SubSwMon); ok { st.Monitor = v != 0 } if v, ok := b.readLevel(civ.SubLevelMon); ok { st.MonLevel = from255(v) } if v, ok := b.readSwitch(civ.SubSwVOX); ok { st.VOX = v != 0 } if v, ok := b.readLevel(civ.SubLevelVOXGain); ok { st.VOXGain = from255(v) } if v, ok := b.readLevel(civ.SubLevelAntiVOX); ok { st.AntiVOX = from255(v) } 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) readAnt() (int, bool) { if err := b.write(civ.CmdAnt); err != nil { return 0, false } f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool { return d.Cmd == civ.CmdAnt && len(d.Data) >= 1 }) if err != nil { return 0, false } if f.Data[0] == 0x01 { return 2, true } return 1, 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) SetAPF(on bool) error { if err := b.exec(civ.CmdSwitch, civ.SubSwAPF, boolByte(on)); err != nil { return err } b.setCache(func(s *IcomTXState) { s.APF = 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) SetRFPower(p int) error { if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelRFPower}, civ.LevelToBCD(to255(p))...)...); err != nil { return err } b.setCache(func(s *IcomTXState) { s.RFPower = clampPct(p) }) return nil } func (b *IcomSerial) SetMicGain(p int) error { if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelMic}, civ.LevelToBCD(to255(p))...)...); err != nil { return err } b.setCache(func(s *IcomTXState) { s.MicGain = clampPct(p) }) return nil } func (b *IcomSerial) SetIcomSplit(on bool) error { if on { // Enable split with the usual "work him up" TX offset: +1 kHz on CW, // +5 kHz otherwise (SSB). Set the unselected (TX) VFO to RX+offset first, // then turn split on. 0x25 0x01 + BCD sets the unselected VFO's frequency. rx := b.curFreq if rx <= 0 { if hz, err := b.readFreq(); err == nil { rx = hz } } if rx > 0 { offset := int64(5000) if b.curModeByte == civ.ModeCW || b.curModeByte == civ.ModeCWR { offset = 1000 } _ = b.exec(append([]byte{civ.CmdVfoFreq, civ.SubVfoUnselected}, civ.FreqToBCD(rx+offset)...)...) } } if err := b.exec(civ.CmdSplit, boolByte(on)); err != nil { return err } b.setCache(func(s *IcomTXState) { s.Split = on }) return nil } // ── Antenna ──────────────────────────────────────────────────────────────── func (b *IcomSerial) SetAntenna(n int) error { sub := byte(0x00) // ANT1 if n == 2 { sub = 0x01 // ANT2 } if err := b.exec(civ.CmdAnt, sub); err != nil { return err } b.setCache(func(s *IcomTXState) { if n == 2 { s.Antenna = 2 } else { s.Antenna = 1 } }) return nil } // ── Filter: Twin PBT + manual notch ──────────────────────────────────────── func (b *IcomSerial) SetPBTInner(p int) error { if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelPBTIn}, civ.LevelToBCD(to255(p))...)...); err != nil { return err } b.setCache(func(s *IcomTXState) { s.PBTInner = clampPct(p) }) return nil } func (b *IcomSerial) SetPBTOuter(p int) error { if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelPBTOut}, civ.LevelToBCD(to255(p))...)...); err != nil { return err } b.setCache(func(s *IcomTXState) { s.PBTOuter = clampPct(p) }) return nil } func (b *IcomSerial) SetManualNotch(on bool) error { if err := b.exec(civ.CmdSwitch, civ.SubSwMN, boolByte(on)); err != nil { return err } b.setCache(func(s *IcomTXState) { s.ManualNotch = on }) return nil } func (b *IcomSerial) SetNotchPos(p int) error { if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelNotch}, civ.LevelToBCD(to255(p))...)...); err != nil { return err } b.setCache(func(s *IcomTXState) { s.NotchPos = clampPct(p) }) return nil } // ── TX extras: squelch / compressor / monitor / VOX ──────────────────────── func (b *IcomSerial) SetSquelch(p int) error { if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelSQL}, civ.LevelToBCD(to255(p))...)...); err != nil { return err } b.setCache(func(s *IcomTXState) { s.Squelch = clampPct(p) }) return nil } func (b *IcomSerial) SetComp(on bool) error { if err := b.exec(civ.CmdSwitch, civ.SubSwComp, boolByte(on)); err != nil { return err } b.setCache(func(s *IcomTXState) { s.Comp = on }) return nil } func (b *IcomSerial) SetCompLevel(p int) error { if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelComp}, civ.LevelToBCD(to255(p))...)...); err != nil { return err } b.setCache(func(s *IcomTXState) { s.CompLevel = clampPct(p) }) return nil } func (b *IcomSerial) SetMonitor(on bool) error { if err := b.exec(civ.CmdSwitch, civ.SubSwMon, boolByte(on)); err != nil { return err } b.setCache(func(s *IcomTXState) { s.Monitor = on }) return nil } func (b *IcomSerial) SetMonLevel(p int) error { if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelMon}, civ.LevelToBCD(to255(p))...)...); err != nil { return err } b.setCache(func(s *IcomTXState) { s.MonLevel = clampPct(p) }) return nil } func (b *IcomSerial) SetVOX(on bool) error { if err := b.exec(civ.CmdSwitch, civ.SubSwVOX, boolByte(on)); err != nil { return err } b.setCache(func(s *IcomTXState) { s.VOX = on }) return nil } func (b *IcomSerial) SetVOXGain(p int) error { if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelVOXGain}, civ.LevelToBCD(to255(p))...)...); err != nil { return err } b.setCache(func(s *IcomTXState) { s.VOXGain = clampPct(p) }) return nil } func (b *IcomSerial) SetAntiVOX(p int) error { if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelAntiVOX}, civ.LevelToBCD(to255(p))...)...); err != nil { return err } b.setCache(func(s *IcomTXState) { s.AntiVOX = clampPct(p) }) return nil } // TuneATU triggers a one-shot antenna-tuner tune (CI-V 0x1C 0x01 0x02). func (b *IcomSerial) TuneATU() error { return b.exec(civ.CmdATU, civ.SubATU, 0x02) } 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 }