feat: Implemented UDP Outbound Adif message, freq to pstrotator
This commit is contained in:
@@ -386,6 +386,9 @@ type IcomTXState struct {
|
||||
RITHz int `json:"rit_hz"` // RIT/XIT offset, signed Hz
|
||||
RITOn bool `json:"rit_on"`
|
||||
XITOn bool `json:"xit_on"`
|
||||
// CW keyer (send messages via the rig's internal keyer, CI-V 0x17).
|
||||
KeySpeedWPM int `json:"key_speed_wpm"` // current KEY SPEED in WPM
|
||||
BreakIn int `json:"break_in"` // CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||
// Set controls.
|
||||
RFPower int `json:"rf_power"` // 0-100 (TX output)
|
||||
MicGain int `json:"mic_gain"` // 0-100
|
||||
@@ -429,6 +432,10 @@ type IcomController interface {
|
||||
SetRIT(int) error // RIT/ΔTX offset in signed Hz
|
||||
SetRITOn(bool) error // RIT on/off
|
||||
SetXITOn(bool) error // ΔTX (XIT) on/off
|
||||
SendCW(string) error // key a CW message via the rig's keyer (CI-V 0x17)
|
||||
StopCW() error // abort the CW message being sent
|
||||
SetKeySpeed(int) error // CW keyer speed in WPM
|
||||
SetBreakIn(int) error // CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||
}
|
||||
|
||||
// ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's
|
||||
|
||||
@@ -45,6 +45,15 @@ const (
|
||||
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)
|
||||
@@ -85,6 +94,14 @@ const (
|
||||
SubSwNB = 0x22 // noise blanker on/off
|
||||
SubSwNR = 0x40 // noise reduction on/off
|
||||
SubSwANF = 0x41 // auto-notch on/off
|
||||
SubSwBreakIn = 0x47 // CW break-in: 0=OFF, 1=SEMI, 2=FULL (needed so 0x17 CW keys TX)
|
||||
)
|
||||
|
||||
// CW break-in modes (CmdSwitch 0x47).
|
||||
const (
|
||||
BreakInOff = 0
|
||||
BreakInSemi = 1
|
||||
BreakInFull = 2
|
||||
)
|
||||
|
||||
// Icom mode codes (used by CmdReadMode / CmdSetMode).
|
||||
@@ -191,6 +208,51 @@ func BCDToRIT(b []byte) int {
|
||||
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 {
|
||||
|
||||
@@ -551,6 +551,91 @@ func (b *IcomSerial) SetXITOn(on bool) error {
|
||||
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 {
|
||||
@@ -812,6 +897,12 @@ func (b *IcomSerial) readDSP() {
|
||||
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)
|
||||
}
|
||||
|
||||
b.dspMu.Lock()
|
||||
b.dsp = st
|
||||
|
||||
@@ -325,15 +325,12 @@ func (d *Decoder) endMark(hops int) {
|
||||
}
|
||||
|
||||
// adaptDot nudges the dot-length estimate toward an observation (EMA, clamped
|
||||
// to ~5–100 WPM). The first marks of a new signal adapt FAST so the WPM (and
|
||||
// therefore the character/word gap thresholds) converge within the first
|
||||
// character or two — otherwise a wrong seed mis-times early gaps and runs
|
||||
// characters/words together.
|
||||
// to ~5–60 WPM). The EMA is deliberately GENTLE (0.2) and NOT accelerated on the
|
||||
// opening marks: a fast alpha let short noise blips (misclassified as dots) drag
|
||||
// the dot-length down to the clamp within a few marks — the "60 WPM, all dits"
|
||||
// garbage. The slow EMA is self-correcting because genuine marks pull it back up.
|
||||
func (d *Decoder) adaptDot(obs float64) {
|
||||
alpha := 0.2
|
||||
if d.markCount < 6 {
|
||||
alpha = 0.5 // fast convergence on the opening marks
|
||||
}
|
||||
const alpha = 0.2
|
||||
d.markCount++
|
||||
d.dotHops = d.dotHops*(1-alpha) + obs*alpha
|
||||
if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
|
||||
|
||||
@@ -29,11 +29,14 @@ const (
|
||||
type ServiceType string
|
||||
|
||||
const (
|
||||
ServiceWSJT ServiceType = "wsjt" // WSJT-X / JTDX / MSHV binary
|
||||
ServiceADIF ServiceType = "adif" // text ADIF over UDP
|
||||
ServiceN1MM ServiceType = "n1mm" // N1MM Logger+ XML
|
||||
ServiceRemoteCall ServiceType = "remote_call" // plain text callsign
|
||||
ServiceDBUpdated ServiceType = "db_updated" // outbound ADIF of local QSO
|
||||
ServiceWSJT ServiceType = "wsjt" // WSJT-X / JTDX / MSHV binary (inbound)
|
||||
ServiceADIF ServiceType = "adif" // text ADIF over UDP (inbound)
|
||||
ServiceN1MM ServiceType = "n1mm" // N1MM Logger+ XML (inbound)
|
||||
ServiceRemoteCall ServiceType = "remote_call" // plain text callsign (inbound)
|
||||
// Outbound emitters.
|
||||
ServiceDBUpdated ServiceType = "db_updated" // ADIF of each locally-logged QSO (on save)
|
||||
ServicePstFreq ServiceType = "pstrotator_freq" // <PST><FREQUENCY> radio freq (on freq change)
|
||||
ServiceN1MMRadio ServiceType = "n1mm_radioinfo" // N1MM RadioInfo XML: freq+mode (on freq/mode change)
|
||||
)
|
||||
|
||||
// Config is one user-defined UDP connection.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package udp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
// This file holds the outbound emitters: OpsLog → other programs over UDP.
|
||||
// Formats are chosen per connection row (like the inbound parsers), so the user
|
||||
// can point PstRotator, a second logger, an SDR, etc. at OpsLog.
|
||||
|
||||
// tensOfHz converts a frequency in Hz to the "tens of Hz" unit N1MM and
|
||||
// PstRotator use for their frequency fields (e.g. 14 025 500 Hz → 1 402 550).
|
||||
func tensOfHz(freqHz int64) int64 { return freqHz / 10 }
|
||||
|
||||
// BuildPstFreq builds the datagram PstRotatorAz expects for its "DXLog.net"
|
||||
// tracker: <PST><FREQUENCY>{tens of Hz}</FREQUENCY></PST>. Verified by probing a
|
||||
// live PstRotatorAz — it reads the value as tens of Hz (14025.5 kHz → 1402550 →
|
||||
// displayed 3.5255 MHz for 3525.5 kHz, etc.).
|
||||
func BuildPstFreq(freqHz int64) []byte {
|
||||
return []byte(fmt.Sprintf("<PST><FREQUENCY>%d</FREQUENCY></PST>", tensOfHz(freqHz)))
|
||||
}
|
||||
|
||||
// BuildN1MMRadioInfo builds an N1MM Logger+ RadioInfo XML datagram. <Freq> and
|
||||
// <TXFreq> are in tens of Hz. Consumed by PstRotator (as the "N1MM Logger"
|
||||
// tracker) and many other programs. mode is passed through (CW/USB/LSB/…).
|
||||
func BuildN1MMRadioInfo(station string, rxFreqHz, txFreqHz int64, mode, opCall string) []byte {
|
||||
if station == "" {
|
||||
station = "OPSLOG"
|
||||
}
|
||||
if txFreqHz == 0 {
|
||||
txFreqHz = rxFreqHz
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="utf-8"?>`)
|
||||
b.WriteString(`<RadioInfo>`)
|
||||
b.WriteString(`<StationName>` + xmlEsc(station) + `</StationName>`)
|
||||
b.WriteString(`<RadioNr>1</RadioNr>`)
|
||||
b.WriteString(fmt.Sprintf(`<Freq>%d</Freq>`, tensOfHz(rxFreqHz)))
|
||||
b.WriteString(fmt.Sprintf(`<TXFreq>%d</TXFreq>`, tensOfHz(txFreqHz)))
|
||||
b.WriteString(`<Mode>` + xmlEsc(mode) + `</Mode>`)
|
||||
b.WriteString(`<OpCall>` + xmlEsc(opCall) + `</OpCall>`)
|
||||
b.WriteString(`<IsRunning>True</IsRunning>`)
|
||||
b.WriteString(`<FocusEntry>0</FocusEntry>`)
|
||||
b.WriteString(`<Antenna>0</Antenna>`)
|
||||
b.WriteString(`<Rotors></Rotors>`)
|
||||
b.WriteString(`<FocusRadioNr>1</FocusRadioNr>`)
|
||||
b.WriteString(`<IsStereo>False</IsStereo>`)
|
||||
b.WriteString(`<ActiveRadioNr>1</ActiveRadioNr>`)
|
||||
b.WriteString(`</RadioInfo>`)
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
func xmlEsc(s string) string {
|
||||
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// RadioState is a snapshot the app pushes to EmitRadioState on freq/mode change.
|
||||
type RadioState struct {
|
||||
StationName string
|
||||
OpCall string
|
||||
RxFreqHz int64 // operating/RX frequency
|
||||
TxFreqHz int64 // TX frequency (may equal RX when not split)
|
||||
Mode string
|
||||
}
|
||||
|
||||
// EmitRadioState sends the current radio frequency/mode to every enabled
|
||||
// outbound row whose format is frequency-based (PstRotator, N1MM RadioInfo).
|
||||
// Best-effort: send errors are logged, never returned to the caller.
|
||||
func (m *Manager) EmitRadioState(st RadioState) {
|
||||
for _, c := range m.Outbound(ServicePstFreq) {
|
||||
m.sendTo(c, BuildPstFreq(st.RxFreqHz))
|
||||
}
|
||||
for _, c := range m.Outbound(ServiceN1MMRadio) {
|
||||
m.sendTo(c, BuildN1MMRadioInfo(st.StationName, st.RxFreqHz, st.TxFreqHz, st.Mode, st.OpCall))
|
||||
}
|
||||
}
|
||||
|
||||
// EmitLoggedADIF sends the ADIF of a just-logged QSO to every enabled outbound
|
||||
// "ADIF message" row (db_updated) — lets a second logger or Cloudlog gateway
|
||||
// pick up contacts as they're logged.
|
||||
func (m *Manager) EmitLoggedADIF(adif string) {
|
||||
if strings.TrimSpace(adif) == "" {
|
||||
return
|
||||
}
|
||||
for _, c := range m.Outbound(ServiceDBUpdated) {
|
||||
m.sendTo(c, []byte(adif))
|
||||
}
|
||||
}
|
||||
|
||||
// sendTo resolves the row's destination (host:port) and fires one datagram.
|
||||
func (m *Manager) sendTo(c Config, payload []byte) {
|
||||
host := strings.TrimSpace(c.DestinationIP)
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
dst := fmt.Sprintf("%s:%d", host, c.Port)
|
||||
if err := SendUDP(dst, payload); err != nil {
|
||||
applog.Printf("udp: [%s] outbound send to %s failed: %v", c.Name, dst, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user