Files
rouggy 7ace2cc602 Initial codebase: Go + Wails amateur radio logbook
Backend (Go 1.25 / Wails v2):
- QSO storage on SQLite (modernc) with embedded migrations (0001..0005)
- Streaming ADIF import (batch insert) + WorkedBefore per callsign and DXCC
- Callsign lookup with QRZ.com + HamQTH providers (primary/failsafe routing)
  and SQLite-backed TTL cache
- DXCC resolver from cty.dat (auto-download, longest-prefix-match)
- Multi-profile operator identities (home/portable/SOTA/contest) — every
  QSO stamps MY_* from the active profile
- CAT control via OmniRig COM on a single OS-locked goroutine, with
  bidirectional sync (freq/mode/band/split/VFOs) and Rig1/Rig2 hot-swap
- Settings store (key/value), CAT debug log at %APPDATA%/HamLog/cat.log

Frontend (React 18 + TypeScript + Tailwind v4 + shadcn-style):
- Single-row entry strip with CAT-aware band/mode/freq, RST, Start/End
  UTC, per-field locks (band/mode/freq/start/end) for backdated QSOs
- Topbar: live freq (MHz.kHz.Hz dotted), live UTC, band/mode/SPLIT badges,
  CAT pill with rig selector and clickable Azimuth pill (rotor TODO)
- Settings tree: Profiles (Log4OM-style manager), Station Information
  (edits the active profile), unified Callsign Lookup with Test buttons,
  Bands/Modes lists, CAT
- Worked-before matrix (band × mode × class) with new-DXCC highlighting
- ADIF import from menu + Maintenance > Refresh cty.dat

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:16:45 +02:00

322 lines
9.9 KiB
Go

package cat
import (
"fmt"
"strings"
"github.com/go-ole/go-ole"
"github.com/go-ole/go-ole/oleutil"
)
// OmniRig talks to the user's installed OmniRig server over COM.
//
// All methods MUST be called from the same OS thread (the one Manager.run
// locks). COM is thread-affine on Windows — calling these from random
// goroutines will return E_FAIL or crash.
//
// The user must install OmniRig separately and configure their rig (COM port,
// baud rate) in OmniRig's own GUI. HamLog just reads/writes through it.
type OmniRig struct {
RigNum int // 1 (Rig1) or 2 (Rig2)
omnirig *ole.IDispatch
rig *ole.IDispatch
}
// NewOmniRig creates a non-connected backend. Call Connect before use.
func NewOmniRig(rigNum int) *OmniRig {
if rigNum < 1 || rigNum > 2 {
rigNum = 1
}
return &OmniRig{RigNum: rigNum}
}
func (o *OmniRig) Name() string { return "omnirig" }
func (o *OmniRig) Connect() error {
debugLog.Printf("OmniRig.Connect Rig%d — log path: %s", o.RigNum, DebugLogPath())
if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); err != nil {
// 0x1 = S_FALSE → COM already initialised on this thread, fine.
if oerr, ok := err.(*ole.OleError); !ok || oerr.Code() != 0x00000001 {
return fmt.Errorf("CoInitializeEx: %w", err)
}
}
unk, err := oleutil.CreateObject("Omnirig.OmnirigX")
if err != nil {
return fmt.Errorf("Omnirig.OmnirigX not available — is OmniRig installed and running?: %w", err)
}
omnirig, err := unk.QueryInterface(ole.IID_IDispatch)
unk.Release()
if err != nil {
return fmt.Errorf("query interface: %w", err)
}
rigVar, err := oleutil.GetProperty(omnirig, fmt.Sprintf("Rig%d", o.RigNum))
if err != nil {
omnirig.Release()
return fmt.Errorf("get Rig%d: %w", o.RigNum, err)
}
o.omnirig = omnirig
o.rig = rigVar.ToIDispatch()
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, rt.ToString())
}
return nil
}
func (o *OmniRig) Disconnect() {
if o.rig != nil {
o.rig.Release()
o.rig = nil
}
if o.omnirig != nil {
o.omnirig.Release()
o.omnirig = nil
}
ole.CoUninitialize()
}
func (o *OmniRig) ReadState() (RigState, error) {
if o.rig == nil {
return RigState{}, fmt.Errorf("not connected")
}
var s RigState
s.Backend = o.Name()
s.RigNum = o.RigNum
// Status: 0 = NOTCONFIGURED, 1 = DISABLED, 2 = PORTBUSY,
// 3 = NOTRESPONDING, 4 = ONLINE.
if statusVar, err := oleutil.GetProperty(o.rig, "Status"); err == nil {
s.Connected = statusVar.Val == 4
}
if rigTypeVar, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
s.Rig = rigTypeVar.ToString()
}
if !s.Connected {
// Status string from OmniRig is informative for the user.
if statusStrVar, err := oleutil.GetProperty(o.rig, "StatusStr"); err == nil {
s.Error = statusStrVar.ToString()
}
return s, nil
}
if modeVar, err := oleutil.GetProperty(o.rig, "Mode"); err == nil {
s.Mode = omniRigMode(modeVar.Val)
}
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
s.Vfo = omniRigVfo(vfoVar.Val)
}
// Read both VFO frequencies separately so we can expose split TX/RX.
// Fall back to generic Freq if the rig only exposes the merged property.
freqA, freqB := int64(0), int64(0)
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
freqA = v.Val
}
if v, err := oleutil.GetProperty(o.rig, "FreqB"); err == nil {
freqB = v.Val
}
// Split detection: trust the explicit Split property when it's set,
// BUT only call it a real split if both VFO frequencies are non-zero
// and distinct. Bridges like SmartSDR-OmniRig report Split=ON by
// default (raw bit PM_SPLITON = 65536) with FreqB=0 because the Flex's
// slice model doesn't map to VFO A/B — that would yield a useless
// permanent SPLIT badge.
if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil && v.Val != 0 {
s.Split = true
}
if s.Split && (freqB == 0 || freqA == freqB) {
s.Split = false
s.RxFreqHz = 0
}
// OmniRig's Vfo enum is RX-letter then TX-letter (AB = RX A, TX B).
// We follow ADIF: FreqHz = TX, RxFreqHz = RX (only meaningful in split).
switch s.Vfo {
case "AB":
s.FreqHz = freqB // TX
s.RxFreqHz = freqA // RX
case "BA":
s.FreqHz = freqA // TX
s.RxFreqHz = freqB // RX
case "B", "BB":
s.FreqHz = freqB
default: // "A", "AA", "" — single VFO on A or unknown
s.FreqHz = freqA
}
if s.FreqHz == 0 {
// Last resort — some rigs only update generic Freq.
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
s.FreqHz = v.Val
}
}
return s, nil
}
func (o *OmniRig) SetFrequency(hz int64) error {
if o.rig == nil {
return fmt.Errorf("not connected")
}
// OmniRig Freq is a Long (int32). Validate to avoid silent truncation.
if hz < 0 || hz > 0x7fffffff {
return fmt.Errorf("frequency out of OmniRig int32 range")
}
hz32 := int32(hz)
// Pick the right OmniRig property. Many rig .ini files only define a
// WRITE command for FreqA/FreqB but not the generic Freq — in which case
// PutProperty(Freq) silently succeeds but the rig never moves. Write to
// the active VFO's specific property when we know it; fall back to Freq.
prop := "FreqA"
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
switch omniRigVfo(vfoVar.Val) {
case "B", "BB", "BA":
prop = "FreqB"
case "A", "AA", "AB":
prop = "FreqA"
}
}
debugLog.Printf("OmniRig.SetFrequency(%d Hz / %.6f MHz) → %s", hz, float64(hz)/1e6, prop)
if _, err := oleutil.PutProperty(o.rig, prop, hz32); err != nil {
debugLog.Printf("OmniRig.SetFrequency(%s) error: %v — falling back to Freq", prop, err)
if _, err2 := oleutil.PutProperty(o.rig, "Freq", hz32); err2 != nil {
debugLog.Printf("OmniRig.SetFrequency(Freq) also failed: %v", err2)
return err2
}
}
// Read back the active VFO freq after a short delay so the log shows
// whether the rig actually moved. Useful when the .ini accepts the write
// silently but the rig doesn't honour it (wrong WRITE command etc.).
if fv, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
debugLog.Printf("OmniRig.Freq immediately after Put = %d Hz", fv.Val)
}
return nil
}
// SetMode maps an ADIF mode to the OmniRig PM_* bit and pushes it to the rig.
// For SSB, the USB/LSB side is chosen from the rig's current frequency
// following worldwide convention (LSB below 14 MHz, USB above).
//
// IMPORTANT: OmniRig's Mode property is typed as Long (VT_I4). go-ole would
// otherwise wrap a Go int64 into a VT_I8 variant which COM marshalling can
// reject silently or misinterpret — passing the wrong bit. Always cast to
// int32 explicitly.
//
// Logs each call to stdout so the user can cross-check what HamLog sent
// against OmniRig's Monitor window (right-click systray → Monitor) to find
// rig-specific mismatches (e.g. a Kenwood without FM on HF, an .ini with the
// wrong CAT command for a mode, etc.).
func (o *OmniRig) SetMode(mode string) error {
if o.rig == nil {
return fmt.Errorf("not connected")
}
var (
bit int64
bitName string
)
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "CW":
bit, bitName = pmCWU, "PM_CW_U"
case "SSB":
// Read current freq to decide USB vs LSB.
var freq int64
if freqVar, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
freq = freqVar.Val
}
if freq > 0 && freq < 10_000_000 {
bit, bitName = pmSSBL, "PM_SSB_L"
} else {
bit, bitName = pmSSBU, "PM_SSB_U"
}
case "AM":
bit, bitName = pmAM, "PM_AM"
case "FM":
bit, bitName = pmFM, "PM_FM"
case "RTTY", "FSK":
// OmniRig has no specific RTTY/FSK mode — falls back to generic
// digital USB. Many rigs need RTTY selected manually on the panel.
bit, bitName = pmDIGU, "PM_DIG_U"
case "FT8", "FT4", "PSK31", "MFSK", "JS8", "JT65", "JT9", "OLIVIA", "DIGITALVOICE", "DATA":
bit, bitName = pmDIGU, "PM_DIG_U"
default:
return fmt.Errorf("OmniRig: unsupported mode %q", mode)
}
debugLog.Printf("OmniRig.SetMode(%q) → %s = 0x%08X (%d)", mode, bitName, bit, bit)
_, err := oleutil.PutProperty(o.rig, "Mode", int32(bit))
if err != nil {
debugLog.Printf("OmniRig.SetMode error: %v", err)
return fmt.Errorf("SetMode(%s) → %s: %w", mode, bitName, err)
}
// Read back what OmniRig now thinks the rig is on (best-effort —
// OmniRig is async so this may still be the old value for one poll).
if mv, err := oleutil.GetProperty(o.rig, "Mode"); err == nil {
debugLog.Printf("OmniRig.Mode immediately after Put = 0x%08X (%d) → %s",
mv.Val, mv.Val, omniRigMode(mv.Val))
}
return nil
}
// ===== OmniRig enum decoders =====
// Bit flags from OmniRig type library (RigParamX enum in OmniRig_TLB.pas).
//
// Cross-checked against https://github.com/VE3NEA/OmniRig — be careful when
// referencing other people's writeups online, several have these one bit
// too low which causes every mode to map to the slot below it (AM → DIG_L,
// FT8 → SSB_L, etc.).
const (
pmCWU int64 = 1 << 23 // 0x00800000
pmCWL int64 = 1 << 24 // 0x01000000
pmSSBU int64 = 1 << 25 // 0x02000000
pmSSBL int64 = 1 << 26 // 0x04000000
pmDIGU int64 = 1 << 27 // 0x08000000
pmDIGL int64 = 1 << 28 // 0x10000000
pmAM int64 = 1 << 29 // 0x20000000
pmFM int64 = 1 << 30 // 0x40000000 — still fits in int32 (max 2^31-1)
)
// omniRigMode maps the OmniRig Mode bit-flag to an ADIF mode string.
// OmniRig only reports rough categories; specific digital modes
// (FT8, RTTY, PSK31…) can't be inferred — DATA is returned and the user
// can keep / override the mode they already had in the entry form.
func omniRigMode(m int64) string {
switch {
case m&(pmCWU|pmCWL) != 0:
return "CW"
case m&(pmSSBU|pmSSBL) != 0:
return "SSB"
case m&(pmDIGU|pmDIGL) != 0:
return "DATA"
case m&pmAM != 0:
return "AM"
case m&pmFM != 0:
return "FM"
}
return ""
}
func omniRigVfo(v int64) string {
switch {
case v&1024 != 0:
return "A"
case v&2048 != 0:
return "B"
case v&64 != 0:
return "AA"
case v&128 != 0:
return "AB"
case v&256 != 0:
return "BA"
case v&512 != 0:
return "BB"
}
return ""
}