// Package civ implements the Icom CI-V protocol independently of the transport // carrying it. The exact same frames travel over a USB/serial port (local // control) and, wrapped in Icom's UDP "serial" stream, over the network // (remote control). Keeping the wire format in one place means the USB backend // (icomserial) and a future network backend (icomnet) share all of it — only // the transport differs. // // Frame layout: FE FE [sub] [data…] FD package civ import ( "bytes" "fmt" ) // Protocol bytes. const ( Pre = 0xFE // preamble (sent twice at the start of every frame) End = 0xFD // end-of-message OK = 0xFB // rig acknowledged a set command NG = 0xFA // rig rejected a set command // AddrController is the conventional address software uses for itself. AddrController = 0xE0 ) // Commands (the few Phase-1 control needs; more get added with the panel). const ( CmdTransceiveFreq = 0x00 // unsolicited freq update (dial turned) CmdTransceiveMode = 0x01 // unsolicited mode update CmdReadFreq = 0x03 CmdReadMode = 0x04 CmdSetFreq = 0x05 CmdSetMode = 0x06 CmdSplit = 0x0F // read/set split & duplex (0x00 off, 0x01 split on) CmdVfoFreq = 0x25 // read a specific VFO's freq (sub 0x00 selected, 0x01 unselected) CmdPTT = 0x1C // sub 0x00 = PTT CmdExtra = 0x1A // sub 0x06 = data mode on modern Icoms CmdReadID = 0x19 // sub 0x00 = rig's own CI-V address (identifies model) CmdPower = 0x18 // power on/off (sub 0x01 = on, 0x00 = off; on needs an FE wake preamble) CmdAnt = 0x12 // antenna selector (sub 0x00 = ANT1, 0x01 = ANT2; read = no sub) CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off) CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255) CmdMeter = 0x15 // meters (sub + 2 BCD bytes, 0000-0255): S-meter/Po/SWR CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte) CmdATU = 0x1C // sub 0x01 = antenna tuner (0x00 off, 0x01 through, 0x02 tune) CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off) CmdRIT = 0x21 // RIT/ΔTX: sub 0x00 offset freq, 0x01 RIT on/off, 0x02 ΔTX(XIT) on/off CmdSendCW = 0x17 // send a CW message (ASCII, ≤30 chars) via the rig's keyer; data 0xFF = stop SubLevelKeySpeed = 0x0C // CmdLevel: CW keying speed (0-255 → KeyMinWPM..KeyMaxWPM) // CW keyer speed range for the KEY SPEED level (IC-7610: 6-48 WPM). KeyMinWPM = 6 KeyMaxWPM = 48 StopCWByte = 0xFF // 0x17 data byte that stops an in-progress CW message SubRITFreq = 0x00 // RIT/ΔTX offset: 2 BCD bytes (LE, 0-9999) + sign byte (00 +, 01 -) SubRITOn = 0x01 // RIT on/off (00/01) SubXITOn = 0x02 // ΔTX (XIT) on/off (00/01) SubDataMode = 0x06 SubPTT = 0x00 SubVfoSelected = 0x00 // CmdVfoFreq: the active/RX VFO SubVfoUnselected = 0x01 // CmdVfoFreq: the other VFO (TX in split) // CmdLevel sub-commands. SubLevelAF = 0x01 // AF (volume) SubLevelRF = 0x02 // RF gain SubLevelSQL = 0x03 // squelch level SubLevelPBTIn = 0x07 // Twin PBT (inside) — 0-255, 128 = centre SubLevelPBTOut = 0x08 // Twin PBT (outside) — 0-255, 128 = centre SubLevelNR = 0x06 // noise-reduction depth SubLevelNotch = 0x0D // manual-notch position — 0-255, 128 = centre SubLevelComp = 0x0E // speech-compressor level SubLevelNB = 0x12 // noise-blanker depth SubLevelMon = 0x15 // monitor gain SubLevelVOXGain = 0x16 // VOX gain SubLevelAntiVOX = 0x17 // anti-VOX level SubLevelRFPower = 0x0A // TX RF output power SubLevelMic = 0x0B // mic gain // CmdMeter sub-commands. SubMeterS = 0x02 // S-meter (RX) SubMeterPo = 0x11 // power output (TX) SubMeterSWR = 0x12 // SWR (TX) // CmdATU / CmdPTT sub-commands. SubATU = 0x01 // antenna tuner (data 0x02 = start tune) // CmdScope sub-commands. SubScopeData = 0x00 // waveform data frame (divided across several frames) SubScopeOnOff = 0x10 // turn the scope display itself on/off (00/01) SubScopeOn = 0x11 // enable/disable waveform data output over CI-V (00/01) SubScopeMode = 0x14 // center/fixed mode (0=center, 1=fixed) — VERIFY on rig SubScopeSpan = 0x15 // span in center mode — VERIFY on rig SubScopeEdge = 0x16 // fixed-mode edge frequencies — VERIFY on rig // CmdSwitch sub-commands. SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2 SubSwAGC = 0x12 // 1=FAST, 2=MID, 3=SLOW SubSwNB = 0x22 // noise blanker on/off SubSwNR = 0x40 // noise reduction on/off SubSwANF = 0x41 // auto-notch on/off SubSwComp = 0x44 // speech compressor on/off SubSwMon = 0x45 // monitor on/off SubSwVOX = 0x46 // VOX on/off SubSwBreakIn = 0x47 // CW break-in: 0=OFF, 1=SEMI, 2=FULL (needed so 0x17 CW keys TX) SubSwMN = 0x48 // manual notch on/off ) // CW break-in modes (CmdSwitch 0x47). const ( BreakInOff = 0 BreakInSemi = 1 BreakInFull = 2 ) // Icom mode codes (used by CmdReadMode / CmdSetMode). const ( ModeLSB = 0x00 ModeUSB = 0x01 ModeAM = 0x02 ModeCW = 0x03 ModeRTTY = 0x04 ModeFM = 0x05 ModeCWR = 0x07 ModeRTTYR = 0x08 ) // Frame builds a complete CI-V frame (preamble … end) for payload, which is the // command byte followed by any sub-command/data bytes. func Frame(to, from byte, payload ...byte) []byte { f := make([]byte, 0, len(payload)+5) f = append(f, Pre, Pre, to, from) f = append(f, payload...) f = append(f, End) return f } // FreqToBCD encodes a frequency in Hz as the 5 little-endian BCD bytes Icom // expects (10 digits, 2 per byte, least-significant byte first). func FreqToBCD(hz int64) []byte { if hz < 0 { hz = 0 } b := make([]byte, 5) for i := 0; i < 5; i++ { lo := hz % 10 hz /= 10 hi := hz % 10 hz /= 10 b[i] = byte(lo) | byte(hi)<<4 } return b } // BCDToFreq decodes Icom little-endian BCD frequency bytes back to Hz. func BCDToFreq(b []byte) int64 { var hz int64 mult := int64(1) for i := 0; i < len(b) && i < 5; i++ { hz += int64(b[i]&0x0F) * mult mult *= 10 hz += int64(b[i]>>4) * mult mult *= 10 } return hz } // LevelToBCD encodes a 0-255 level as the 2 big-endian BCD bytes Icom's // CmdLevel commands use (e.g. 128 → 0x01 0x28, 255 → 0x02 0x55). func LevelToBCD(v int) []byte { if v < 0 { v = 0 } if v > 255 { v = 255 } return []byte{byte(v / 100), byte(((v/10)%10)<<4 | v%10)} } // BCDToLevel decodes the 2 BCD bytes of a CmdLevel response back to 0-255. func BCDToLevel(b []byte) int { if len(b) < 2 { return 0 } return int(b[0])*100 + int(b[1]>>4)*10 + int(b[1]&0x0F) } // RITToBCD encodes a RIT/ΔTX offset (Hz, −9999..9999) as the 3 bytes CI-V // command 0x21 0x00 uses: 2 little-endian BCD bytes of the magnitude followed by // a sign byte (0x00 positive, 0x01 negative). func RITToBCD(hz int) []byte { neg := hz < 0 if neg { hz = -hz } if hz > 9999 { hz = 9999 } lo := byte(hz%10 | (hz/10%10)<<4) hi := byte(hz/100%10 | (hz/1000%10)<<4) sign := byte(0) if neg { sign = 1 } return []byte{lo, hi, sign} } // BCDToRIT decodes the 3 offset bytes of a 0x21 0x00 response back to signed Hz. func BCDToRIT(b []byte) int { if len(b) < 3 { return 0 } v := int(b[0]&0x0F) + int(b[0]>>4)*10 + int(b[1]&0x0F)*100 + int(b[1]>>4)*1000 if b[2] != 0 { return -v } return v } // WPMToKeyLevel maps a CW speed in words-per-minute to the 0-255 value the KEY // SPEED level (CmdLevel 0x0C) expects, linear across KeyMinWPM..KeyMaxWPM. func WPMToKeyLevel(wpm int) int { if wpm < KeyMinWPM { wpm = KeyMinWPM } if wpm > KeyMaxWPM { wpm = KeyMaxWPM } return (wpm - KeyMinWPM) * 255 / (KeyMaxWPM - KeyMinWPM) } // KeyLevelToWPM is the inverse of WPMToKeyLevel (0-255 → WPM). func KeyLevelToWPM(v int) int { if v < 0 { v = 0 } if v > 255 { v = 255 } return KeyMinWPM + (v*(KeyMaxWPM-KeyMinWPM)+127)/255 } // CWText is the set of characters the rig's keyer accepts (command 0x17). // Everything else is dropped. Space keys a word gap. const CWText = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 /?.,-=+@:" // FilterCW upper-cases text and keeps only keyer-legal characters. func FilterCW(text string) string { out := make([]byte, 0, len(text)) for i := 0; i < len(text); i++ { c := text[i] if c >= 'a' && c <= 'z' { c -= 32 } for j := 0; j < len(CWText); j++ { if CWText[j] == c { out = append(out, c) break } } } return string(out) } // ByteToBCD / BCDToByte handle a single packed-BCD byte (used by the // attenuator, where the value is dB: 0x00, 0x06, 0x12, 0x18…). func ByteToBCD(v int) byte { if v < 0 { v = 0 } if v > 99 { v = 99 } return byte((v/10)<<4 | v%10) } func BCDToByte(b byte) int { return int(b>>4)*10 + int(b&0x0F) } // ModeToADIF maps an Icom mode byte (plus the data-mode flag) to an ADIF mode // string. Data mode on USB/LSB is surfaced as "DATA" so the app can substitute // the user's preferred digital mode (FT8/RTTY/…), matching the OmniRig backend. func ModeToADIF(m byte, data bool) string { switch m { case ModeCW, ModeCWR: return "CW" case ModeRTTY, ModeRTTYR: return "RTTY" case ModeAM: return "AM" case ModeFM: return "FM" case ModeLSB, ModeUSB: if data { return "DATA" } return "SSB" } return "" } // ModelName maps a rig's default CI-V address (from CmdReadID) to a readable // model. Unknown addresses fall back to a hex label. func ModelName(addr byte) string { switch addr { case 0x94: return "IC-7300" case 0x98: return "IC-7610" case 0xA2: return "IC-9700" case 0xA4: return "IC-705" case 0x88: return "IC-7700" case 0x80: return "IC-7800" } return fmt.Sprintf("Icom (0x%02X)", addr) } // Decoded is one parsed CI-V frame. Data is everything after the command byte // (so it still carries the sub-command for multi-byte commands like 1A 06). type Decoded struct { To byte From byte Cmd byte Data []byte } // Scan extracts every complete frame from buf and reports how many leading // bytes the caller may now discard. A trailing partial frame (or a lone // preamble byte) is left unconsumed so it can be completed by the next read. func Scan(buf []byte) (frames []Decoded, consumed int) { pos := 0 for { p := indexPreamble(buf, pos) if p < 0 { // No further preamble. Keep a trailing FE (possible start of the // next preamble); otherwise everything seen is consumable. if len(buf) > 0 && buf[len(buf)-1] == Pre { return frames, len(buf) - 1 } return frames, len(buf) } start := p + 2 for start < len(buf) && buf[start] == Pre { // tolerate padding FEs start++ } end := bytes.IndexByte(buf[start:], End) if end < 0 { return frames, p // incomplete frame — keep from its preamble } end += start if body := buf[start:end]; len(body) >= 3 { frames = append(frames, Decoded{ To: body[0], From: body[1], Cmd: body[2], Data: append([]byte(nil), body[3:]...), }) } pos = end + 1 consumed = pos } } // indexPreamble returns the index of the next FE FE pair at or after from. func indexPreamble(buf []byte, from int) int { for i := from; i+1 < len(buf); i++ { if buf[i] == Pre && buf[i+1] == Pre { return i } } return -1 }