305 lines
8.6 KiB
Go
305 lines
8.6 KiB
Go
// 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 <to> <from> <cmd> [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)
|
||
|
||
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
|
||
|
||
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
|
||
SubLevelNR = 0x06 // noise-reduction depth
|
||
SubLevelNB = 0x12 // noise-blanker depth
|
||
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
|
||
)
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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
|
||
}
|