Files
OpsLog/internal/cat/civ/civ.go
T

381 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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) — 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
}