Adds the IC-7300MKII at CI-V 0xB6. Doing so exposed a drift between the two hand-kept copies of the model table: the settings offered the IC-7700 at 0x88 and the IC-7800 at 0x80, which are the IC-7100's and the IC-7410's factory addresses. Picking either set an address the rig never answers on — the symptom is a radio that simply stays silent — and the backend then named it as the other model. Corrected to 0x74 and 0x6A, and the four models the backend already knew (IC-7100, IC-7410, IC-7600, IC-7851) are now offered too instead of forcing a manual address. A test reads the model list out of the .tsx and asserts civ.ModelName agrees, so the next model added on one side alone fails the build rather than someone's radio.
408 lines
12 KiB
Go
408 lines
12 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)
|
||
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)
|
||
SubScopeSpan = 0x15 // span in center mode (±span/2 as 5 LE-BCD)
|
||
SubScopeEdge = 0x16 // fixed-mode ACTIVE edge set 1-4 (vfo + set#)
|
||
SubScopeFixEdge = 0x1e // fixed-mode edge FREQUENCIES: [range][set#][low 5-BCD][high 5-BCD]
|
||
|
||
// 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
|
||
SubSwAPF = 0x32 // audio peak filter on/off (CW only)
|
||
)
|
||
|
||
// 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.
|
||
//
|
||
// The name is not cosmetic: the UI derives model-dependent behaviour from it —
|
||
// notably the attenuator steps, which are 6/12/18 dB on the big rigs and a single
|
||
// 20 dB on the small ones. An address missing here therefore shows the WRONG
|
||
// attenuator buttons, and the rig NAKs them.
|
||
//
|
||
// Two entries here used to be wrong in a way that pointed at each other: 0x80 was
|
||
// labelled IC-7800 (it is the IC-7410) and 0x88 IC-7700 (it is the IC-7100), while
|
||
// the real IC-7800 (0x6A) and IC-7700 (0x74) were absent — so a 7800 came up as
|
||
// "Icom (0x6A)" with a 20 dB attenuator it does not have.
|
||
//
|
||
// Addresses cross-checked against the TR4W CI-V table and an independent
|
||
// published list; both agree on every value below.
|
||
func ModelName(addr byte) string {
|
||
switch addr {
|
||
case 0x6A:
|
||
return "IC-7800"
|
||
case 0x74:
|
||
return "IC-7700"
|
||
case 0x7A:
|
||
return "IC-7600"
|
||
case 0x7C:
|
||
return "IC-9100"
|
||
case 0x80:
|
||
return "IC-7410"
|
||
case 0x88:
|
||
return "IC-7100"
|
||
case 0x8E:
|
||
return "IC-7851" // shared with the IC-7850
|
||
case 0x94:
|
||
return "IC-7300"
|
||
case 0x98:
|
||
return "IC-7610"
|
||
case 0xA2:
|
||
return "IC-9700"
|
||
case 0xA4:
|
||
return "IC-705"
|
||
case 0xB6:
|
||
return "IC-7300MKII"
|
||
}
|
||
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
|
||
}
|