feat: added record qso dvk

This commit is contained in:
2026-06-04 00:46:35 +02:00
parent 1a425a1b0d
commit a2a29c66d2
24 changed files with 3098 additions and 346 deletions
+706 -7
View File
@@ -12,12 +12,15 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
"hamlog/internal/adif"
"hamlog/internal/applog"
"hamlog/internal/backup"
"hamlog/internal/audio"
"hamlog/internal/cat"
"hamlog/internal/clublog"
"hamlog/internal/cluster"
"hamlog/internal/db"
"hamlog/internal/extsvc"
@@ -32,6 +35,7 @@ import (
"hamlog/internal/settings"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"go.bug.st/serial"
)
// Setting keys.
@@ -68,6 +72,26 @@ const (
keyCATDelayMs = "cat.delay_ms" // pause between commands
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA
// Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so
// global (not per-profile) like CAT/rotator. Device fields store the
// WASAPI endpoint id; the UI resolves it to a friendly name.
keyAudioFromRadio = "audio.from_radio" // capture: rig RX audio in
keyAudioToRadio = "audio.to_radio" // render: DVK plays into rig
keyAudioRecDevice = "audio.rec_device" // capture: your mic (record DVK msgs)
keyAudioListenDevice = "audio.listen_device" // render: local preview speakers
keyAudioQSORecord = "audio.qso_record" // "1" → auto-record every QSO
keyAudioQSODir = "audio.qso_dir" // folder for QSO recordings
keyAudioPreroll = "audio.preroll_seconds" // rolling-buffer pre-roll length
keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr"
keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT
keyAudioFormat = "audio.qso_format" // "wav" | "mp3"
keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions
// clublogAppAPIKey is OpsLog's ClubLog API key, also used for the country
// file download. Visible in the binary but must not be exposed publicly.
clublogAppAPIKey = "5767f19333363a9ef432ee9cd4141fe76b8adf38"
keyRotatorEnabled = "rotator.enabled"
keyRotatorHost = "rotator.host"
keyRotatorPort = "rotator.port"
@@ -289,6 +313,14 @@ type App struct {
udpRepo *udp.Repo
extsvc *extsvc.Manager
winkeyer *winkeyer.Manager
clublog *clublog.Manager
audioMgr *audio.Manager
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
pttMu sync.Mutex
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
startupErr string // captured for surfacing to the frontend
dbPath string // active database file (may be a user-chosen location)
dataDir string // %APPDATA%/OpsLog — holds config.json, logs, cty.dat
@@ -495,6 +527,16 @@ func (a *App) startup(ctx context.Context) {
}
fmt.Println("OpsLog: cty.dat loaded —", a.dxcc.Info().Entities, "entities")
}()
// ClubLog Country File (cty.xml) — date-ranged callsign exceptions that
// cty.dat lacks (DXpeditions). Loaded from cache if present; downloaded on
// demand. Resolution applied only when the user enables it.
a.clublog = clublog.NewManager(clublogAppAPIKey, dataDir)
go func() {
if err := a.clublog.EnsureLoaded(); err == nil {
d, n := a.clublog.Info()
fmt.Printf("OpsLog: clublog cty.xml loaded — %d exceptions (%s)\n", n, d)
}
}()
// CAT manager: emit pushes state to the frontend via Wails events.
a.cat = cat.NewManager(func(s cat.RigState) {
if a.ctx != nil {
@@ -570,6 +612,21 @@ func (a *App) startup(ctx context.Context) {
},
)
// Digital Voice Keyer + QSO recorder (WASAPI). Idle until used.
a.audioMgr = audio.NewManager(func() {
st := a.dvkStatus()
// When a voice message finishes (or is stopped), drop CAT PTT.
if !st.Playing && a.dvkPttKeyed {
a.dvkPttKeyed = false
go a.dvkUnkeyPTT()
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "audio:status", st)
}
})
a.qsoRec = audio.NewRecorder()
a.startQSORecorderIfEnabled()
fmt.Println("OpsLog: db ready at", a.dbPath)
}
@@ -721,6 +778,9 @@ func (a *App) shutdown(ctx context.Context) {
if a.winkeyer != nil {
a.winkeyer.Disconnect()
}
if a.qsoRec != nil {
a.qsoRec.Stop()
}
if a.db != nil {
_ = a.db.Close()
}
@@ -971,10 +1031,14 @@ func (a *App) AddQSO(q qso.QSO) (int64, error) {
}
a.applyStationDefaults(&q)
a.applyDXCCNumber(&q)
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
a.applyQSLDefaults(&q)
id, err := a.qso.Add(a.ctx, q)
if err == nil && a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
if err == nil {
a.saveQSORecording(q.Callsign)
if a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
}
}
return id, err
}
@@ -1309,8 +1373,17 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool) (adif.Impor
default: // "skip"
im.SkipDuplicates = true
}
// When the user opts to fix countries on import, recompute from cty.dat and
// then apply ClubLog's date-ranged exceptions (which take precedence) if
// ClubLog is enabled + loaded. Unchecked = ADIF preserved verbatim.
clEnabled := a.clublogCtyEnabled() && a.clublog != nil && a.clublog.Loaded()
if applyCty {
im.Enrich = func(q *qso.QSO) { a.enrichContactedFromCtyForce(q) }
im.Enrich = func(q *qso.QSO) {
a.enrichContactedFromCtyForce(q)
if clEnabled {
a.applyClublogException(q, false)
}
}
}
im.OnProgress = func(processed, total int) {
wruntime.EventsEmit(a.ctx, "import:progress", map[string]int{"processed": processed, "total": total})
@@ -1369,6 +1442,25 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
r.ImageURL = ""
}
}
// ClubLog exception override (live entry → today's date): for an active
// DXpedition the entered call gets the right entity/zones immediately.
if a.clublogCtyEnabled() && a.clublog != nil {
if e, ok := a.clublog.Resolve(callsign, time.Now().UTC()); ok {
r.Country = titleEntity(e.Entity)
if e.Cont != "" {
r.Continent = e.Cont
}
if e.ADIF != 0 {
r.DXCC = e.ADIF
}
if e.CQZ != 0 {
r.CQZ = e.CQZ
}
if r.Callsign == "" {
r.Callsign = strings.ToUpper(strings.TrimSpace(callsign))
}
}
}
return r, err
}
@@ -1560,6 +1652,596 @@ func (a *App) SaveCATSettings(s CATSettings) error {
return nil
}
// ── Audio (Digital Voice Keyer + QSO recorder) ────────────────────────
// AudioSettings is the machine-local audio config for the voice keyer and
// the QSO recorder.
type AudioSettings struct {
FromRadio string `json:"from_radio"` // capture id: rig RX audio
ToRadio string `json:"to_radio"` // render id: into the rig
RecordingDevice string `json:"recording_device"` // capture id: your mic
ListeningDevice string `json:"listening_device"` // render id: preview
QSORecord bool `json:"qso_record"` // auto-record every QSO
QSODir string `json:"qso_dir"` // recordings folder
PrerollSeconds int `json:"preroll_seconds"` // rolling pre-roll (default 8)
PTTMethod string `json:"ptt_method"` // "none" (VOX) | "rts" | "dtr"
PTTPort string `json:"ptt_port"` // COM port for serial PTT
Format string `json:"format"` // "wav" | "mp3"
}
// ListAudioInputDevices / ListAudioOutputDevices enumerate WASAPI endpoints
// for the device dropdowns.
func (a *App) ListAudioInputDevices() ([]audio.Device, error) { return audio.ListInputDevices() }
func (a *App) ListAudioOutputDevices() ([]audio.Device, error) { return audio.ListOutputDevices() }
// GetAudioSettings returns the stored audio config (preroll defaults to 8s).
func (a *App) GetAudioSettings() (AudioSettings, error) {
out := AudioSettings{PrerollSeconds: 8, PTTMethod: "none", Format: "wav"}
if a.settings == nil {
return out, nil
}
m, err := a.settings.GetMany(a.ctx,
keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice,
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat)
if err != nil {
return out, err
}
if v := m[keyAudioFormat]; v == "mp3" || v == "wav" {
out.Format = v
}
if v := m[keyAudioPTTMethod]; v == "rts" || v == "dtr" || v == "cat" || v == "none" {
out.PTTMethod = v
}
out.PTTPort = m[keyAudioPTTPort]
out.FromRadio = m[keyAudioFromRadio]
out.ToRadio = m[keyAudioToRadio]
out.RecordingDevice = m[keyAudioRecDevice]
out.ListeningDevice = m[keyAudioListenDevice]
out.QSORecord = m[keyAudioQSORecord] == "1"
out.QSODir = m[keyAudioQSODir]
if n, _ := strconv.Atoi(m[keyAudioPreroll]); n >= 0 && n <= 60 {
if n > 0 {
out.PrerollSeconds = n
}
}
return out, nil
}
// SaveAudioSettings persists the audio config.
func (a *App) SaveAudioSettings(s AudioSettings) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if s.PrerollSeconds < 0 || s.PrerollSeconds > 60 {
s.PrerollSeconds = 8
}
qr := "0"
if s.QSORecord {
qr = "1"
}
pttMethod := s.PTTMethod
if pttMethod != "rts" && pttMethod != "dtr" && pttMethod != "cat" {
pttMethod = "none"
}
format := s.Format
if format != "mp3" {
format = "wav"
}
for k, v := range map[string]string{
keyAudioFromRadio: s.FromRadio,
keyAudioToRadio: s.ToRadio,
keyAudioRecDevice: s.RecordingDevice,
keyAudioListenDevice: s.ListeningDevice,
keyAudioQSORecord: qr,
keyAudioQSODir: strings.TrimSpace(s.QSODir),
keyAudioPreroll: strconv.Itoa(s.PrerollSeconds),
keyAudioPTTMethod: pttMethod,
keyAudioPTTPort: strings.TrimSpace(s.PTTPort),
keyAudioFormat: format,
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
}
}
// Apply device/preroll/enable changes to the running recorder.
a.startQSORecorderIfEnabled()
return nil
}
// PickAudioFolder opens a directory picker for the QSO-recordings folder.
func (a *App) PickAudioFolder() (string, error) {
if a.ctx == nil {
return "", fmt.Errorf("no app context")
}
cur, _ := a.GetAudioSettings()
return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "Pick a folder for QSO recordings",
DefaultDirectory: firstExistingAncestor(cur.QSODir),
})
}
// ── QSO recorder ──────────────────────────────────────────────────────
// startQSORecorderIfEnabled (re)starts the continuous recorder per the current
// settings. Safe to call repeatedly — it stops any running instance first.
func (a *App) startQSORecorderIfEnabled() {
if a.qsoRec == nil {
return
}
a.qsoRec.Stop()
cfg, _ := a.GetAudioSettings()
if !cfg.QSORecord {
return
}
if err := a.qsoRec.Start(cfg.FromRadio, cfg.RecordingDevice, cfg.PrerollSeconds); err != nil {
applog.Printf("qso-rec: start failed: %v", err)
return
}
applog.Printf("qso-rec: running (preroll %ds, mix=%v)", cfg.PrerollSeconds, cfg.RecordingDevice != "" && cfg.RecordingDevice != cfg.FromRadio)
}
// qsoRecDir returns the configured recordings folder, defaulting to
// <dataDir>/Recordings, and ensures it exists.
func (a *App) qsoRecDir() string {
cfg, _ := a.GetAudioSettings()
d := strings.TrimSpace(cfg.QSODir)
if d == "" {
d = filepath.Join(a.dataDir, "Recordings")
}
_ = os.MkdirAll(d, 0o755)
return d
}
// saveQSORecording finalises the active recording (if any) into a WAV named
// after the callsign. Called right after a QSO is inserted (manual + UDP).
func (a *App) saveQSORecording(call string) {
if a.qsoRec == nil || !a.qsoRec.Active() {
return
}
ext := "wav"
if cfg, _ := a.GetAudioSettings(); cfg.Format == "mp3" {
ext = "mp3"
}
name := fmt.Sprintf("%s_%s.%s", sanitizeFilename(call), time.Now().UTC().Format("20060102_150405"), ext)
path := filepath.Join(a.qsoRecDir(), name)
if err := a.qsoRec.SaveQSO(path); err != nil {
applog.Printf("qso-rec: save failed: %v", err)
return
}
applog.Printf("qso-rec: saved %s", path)
}
// sanitizeFilename makes a callsign safe for a filename (slashes etc.).
func sanitizeFilename(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
if s == "" {
s = "QSO"
}
repl := func(r rune) rune {
switch r {
case '/', '\\', ':', '*', '?', '"', '<', '>', '|', ' ':
return '_'
}
return r
}
return strings.Map(repl, s)
}
// QSOAudioBegin starts accumulating a QSO recording (seeded with the pre-roll).
// Called by the entry strip when a callsign is first entered.
func (a *App) QSOAudioBegin() {
if a.qsoRec != nil {
a.qsoRec.BeginQSO()
}
}
// QSOAudioCancel drops the in-progress recording (callsign cleared, QSO
// abandoned without logging).
func (a *App) QSOAudioCancel() {
if a.qsoRec != nil {
a.qsoRec.DiscardQSO()
}
}
// RestartQSORecorder applies new audio settings to the running recorder.
func (a *App) RestartQSORecorder() { a.startQSORecorderIfEnabled() }
// ── ClubLog Country File (cty.xml) exceptions ─────────────────────────
// ClublogCtyInfo is the UI status of the ClubLog exception data.
type ClublogCtyInfo struct {
Enabled bool `json:"enabled"`
Loaded bool `json:"loaded"`
Date string `json:"date"`
Count int `json:"count"`
}
func (a *App) clublogCtyEnabled() bool {
if a.settings == nil {
return false
}
v, _ := a.settings.Get(a.ctx, keyClublogCtyEnabled)
return v == "1"
}
// GetClublogCtyInfo returns the current ClubLog exception status.
func (a *App) GetClublogCtyInfo() ClublogCtyInfo {
info := ClublogCtyInfo{Enabled: a.clublogCtyEnabled()}
if a.clublog != nil {
info.Loaded = a.clublog.Loaded()
info.Date, info.Count = a.clublog.Info()
}
return info
}
// SetClublogCtyEnabled toggles ClubLog exception resolution, loading the cached
// file on first enable.
func (a *App) SetClublogCtyEnabled(on bool) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
v := "0"
if on {
v = "1"
}
if err := a.settings.Set(a.ctx, keyClublogCtyEnabled, v); err != nil {
return err
}
if on && a.clublog != nil && !a.clublog.Loaded() {
_ = a.clublog.EnsureLoaded() // ok if file not downloaded yet
}
return nil
}
// DownloadClublogCty fetches a fresh ClubLog country file.
func (a *App) DownloadClublogCty() (ClublogCtyInfo, error) {
if a.clublog == nil {
return ClublogCtyInfo{}, fmt.Errorf("clublog not initialized")
}
if err := a.clublog.Download(a.ctx); err != nil {
return a.GetClublogCtyInfo(), err
}
return a.GetClublogCtyInfo(), nil
}
// applyClublogException overrides a QSO's entity fields from a ClubLog
// exception matching its callsign at its date. force=true ignores the
// enable toggle (used by the explicit "Update from ClubLog" action).
// Returns true if something changed.
func (a *App) applyClublogException(q *qso.QSO, force bool) bool {
if a.clublog == nil || q.Callsign == "" {
return false
}
if !force && !a.clublogCtyEnabled() {
return false
}
date := q.QSODate
if date.IsZero() {
date = time.Now().UTC()
}
e, ok := a.clublog.Resolve(q.Callsign, date)
if !ok {
return false
}
q.Country = titleEntity(e.Entity)
if e.Cont != "" {
q.Continent = e.Cont
}
if e.ADIF != 0 {
n := e.ADIF
q.DXCC = &n
}
if e.CQZ != 0 {
v := e.CQZ
q.CQZ = &v
}
if e.Lat != 0 || e.Lon != 0 {
lat, lon := e.Lat, e.Lon
q.Lat, q.Lon = &lat, &lon
}
return true
}
// UpdateQSOsFromClublog re-resolves the selected QSOs against ClubLog
// exceptions (by their QSO date) and saves any that changed.
func (a *App) UpdateQSOsFromClublog(ids []int64) (int, error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
}
if a.clublog == nil || !a.clublog.Loaded() {
return 0, fmt.Errorf("ClubLog data not loaded — download it first")
}
changed := 0
for _, id := range ids {
q, err := a.qso.GetByID(a.ctx, id)
if err != nil {
continue
}
if a.applyClublogException(&q, true) {
if err := a.qso.Update(a.ctx, q); err == nil {
changed++
}
}
}
return changed, nil
}
// titleCaseIfUpper title-cases a string ONLY when it's entirely upper-case
// (e.g. Log4OM/contest ADIF sends "SANTO DOMINGO"); mixed-case values are
// left untouched. Codes like state "DN" stay as-is (no lower-case letters
// to gain, but they're short — callers pick which fields to pass).
func titleCaseIfUpper(s string) string {
t := strings.TrimSpace(s)
if t == "" || t != strings.ToUpper(t) {
return s
}
return titleEntity(t)
}
// titleEntity converts ClubLog's UPPERCASE entity names to title case
// ("LORD HOWE ISLAND" → "Lord Howe Island") for display consistency.
func titleEntity(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return s
}
words := strings.Fields(strings.ToLower(s))
for i, w := range words {
r := []rune(w)
r[0] = []rune(strings.ToUpper(string(r[0])))[0]
words[i] = string(r)
}
return strings.Join(words, " ")
}
// ── Digital Voice Keyer (DVK) ─────────────────────────────────────────
//
// Six voice-message slots (F1F6, like the WinKeyer macros). Each message is a
// WAV file in <dataDir>/dvk/dvk<N>.wav; its label lives in settings. Record via
// the configured "Recording mic", transmit via "To Radio", preview via
// "Listening".
const dvkSlots = 6
// DVKMessage is one voice-keyer slot for the UI.
type DVKMessage struct {
Slot int `json:"slot"`
Label string `json:"label"`
HasAudio bool `json:"has_audio"`
DurationSec float64 `json:"duration_sec"`
}
// DVKStatus reflects the live record/playback state for the operating panel.
type DVKStatus struct {
Recording bool `json:"recording"`
Playing bool `json:"playing"`
RecSlot int `json:"rec_slot"`
}
func (a *App) dvkDir() string {
d := filepath.Join(a.dataDir, "dvk")
_ = os.MkdirAll(d, 0o755)
return d
}
func (a *App) dvkPath(slot int) string {
return filepath.Join(a.dvkDir(), fmt.Sprintf("dvk%d.wav", slot))
}
func dvkLabelKey(slot int) string { return fmt.Sprintf("audio.dvk.label%d", slot) }
func (a *App) dvkStatus() DVKStatus {
st := DVKStatus{RecSlot: a.dvkRecSlot}
if a.audioMgr != nil {
st.Recording = a.audioMgr.IsRecording()
st.Playing = a.audioMgr.IsPlaying()
}
return st
}
// GetDVKStatus returns the current record/playback state.
func (a *App) GetDVKStatus() DVKStatus { return a.dvkStatus() }
// GetDVKMessages returns the six voice-keyer slots with their labels, whether
// a recording exists, and its duration.
func (a *App) GetDVKMessages() []DVKMessage {
out := make([]DVKMessage, 0, dvkSlots)
for s := 1; s <= dvkSlots; s++ {
m := DVKMessage{Slot: s}
if a.settings != nil {
if v, _ := a.settings.Get(a.ctx, dvkLabelKey(s)); v != "" {
m.Label = v
}
}
if fi, err := os.Stat(a.dvkPath(s)); err == nil && fi.Size() > 44 {
m.HasAudio = true
m.DurationSec = float64(fi.Size()-44) / 32000.0 // 16 kHz mono 16-bit
}
out = append(out, m)
}
return out
}
// SetDVKLabel renames a voice-keyer slot.
func (a *App) SetDVKLabel(slot int, label string) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if slot < 1 || slot > dvkSlots {
return fmt.Errorf("bad slot")
}
return a.settings.Set(a.ctx, dvkLabelKey(slot), strings.TrimSpace(label))
}
// DVKStartRecord begins recording a voice message into the given slot, using
// the configured Recording mic.
func (a *App) DVKStartRecord(slot int) error {
if a.audioMgr == nil {
return fmt.Errorf("audio not initialized")
}
if slot < 1 || slot > dvkSlots {
return fmt.Errorf("bad slot")
}
cfg, _ := a.GetAudioSettings()
a.dvkRecSlot = slot
return a.audioMgr.StartRecording(cfg.RecordingDevice)
}
// DVKStopRecord ends the recording and writes it to the slot's WAV file.
func (a *App) DVKStopRecord() error {
if a.audioMgr == nil {
return fmt.Errorf("audio not initialized")
}
return a.audioMgr.StopRecording(a.dvkPath(a.dvkRecSlot))
}
// DVKCancelRecord aborts a recording without saving.
func (a *App) DVKCancelRecord() { if a.audioMgr != nil { a.audioMgr.CancelRecording() } }
// DVKPlay transmits a slot's message to the rig ("To Radio"), asserting serial
// PTT (RTS/DTR) first unless the operator uses VOX. PTT is released
// automatically when playback ends (see the audio status callback).
func (a *App) DVKPlay(slot int) error {
if a.audioMgr == nil {
return fmt.Errorf("audio not initialized")
}
path := a.dvkPath(slot)
if fi, err := os.Stat(path); err != nil || fi.Size() <= 44 {
return fmt.Errorf("no recording in slot %d", slot)
}
cfg, _ := a.GetAudioSettings()
if err := a.pttKey(cfg); err != nil {
applog.Printf("dvk: PTT on failed: %v", err)
// Keep going — the audio still reaches the rig; the user may use VOX.
} else if cfg.PTTMethod != "" && cfg.PTTMethod != "none" {
a.dvkPttKeyed = true
}
if err := a.audioMgr.Play(cfg.ToRadio, path); err != nil {
if a.dvkPttKeyed {
a.dvkPttKeyed = false
go a.dvkUnkeyPTT()
}
return err
}
return nil
}
// dvkUnkeyPTT releases serial PTT after a short tail so the rig doesn't clip
// the end of the message.
func (a *App) dvkUnkeyPTT() {
time.Sleep(120 * time.Millisecond)
a.pttUnkey()
}
// pttKey keys the transmitter using the configured method:
// - "cat" → OmniRig (sets the Tx parameter to PM_TX)
// - "rts"/"dtr" → open the COM port and assert that line, held during TX
// - "none" → VOX, nothing to do
func (a *App) pttKey(cfg AudioSettings) error {
switch cfg.PTTMethod {
case "cat":
if a.cat == nil {
return fmt.Errorf("CAT not initialized")
}
if err := a.cat.SetPTT(true); err != nil {
return err
}
a.pttMu.Lock()
a.pttKeyedMethod = "cat"
a.pttMu.Unlock()
applog.Printf("dvk: PTT keyed (CAT/OmniRig)")
return nil
case "rts", "dtr":
if strings.TrimSpace(cfg.PTTPort) == "" {
return fmt.Errorf("no PTT COM port configured")
}
a.pttMu.Lock()
defer a.pttMu.Unlock()
if a.pttPort != nil {
return nil // already keyed
}
port, err := serial.Open(cfg.PTTPort, &serial.Mode{BaudRate: 9600})
if err != nil {
return fmt.Errorf("open %s: %w", cfg.PTTPort, err)
}
var lerr error
if cfg.PTTMethod == "rts" {
lerr = port.SetRTS(true)
_ = port.SetDTR(false)
} else {
lerr = port.SetDTR(true)
_ = port.SetRTS(false)
}
if lerr != nil {
_ = port.Close()
return fmt.Errorf("assert %s on %s: %w", strings.ToUpper(cfg.PTTMethod), cfg.PTTPort, lerr)
}
a.pttPort = port
a.pttKeyedMethod = cfg.PTTMethod
applog.Printf("dvk: PTT keyed (%s on %s)", strings.ToUpper(cfg.PTTMethod), cfg.PTTPort)
return nil
}
return nil // none / VOX
}
// pttUnkey releases whichever PTT was keyed (CAT back to RX, or drop the
// serial line + close the port).
func (a *App) pttUnkey() {
a.pttMu.Lock()
method := a.pttKeyedMethod
a.pttKeyedMethod = ""
port := a.pttPort
a.pttPort = nil
a.pttMu.Unlock()
switch method {
case "cat":
if a.cat != nil {
if err := a.cat.SetPTT(false); err != nil {
applog.Printf("dvk: PTT off (CAT) failed: %v", err)
}
}
case "rts", "dtr":
if port != nil {
_ = port.SetRTS(false)
_ = port.SetDTR(false)
_ = port.Close()
}
default:
return
}
applog.Printf("dvk: PTT released")
}
// TestPTT keys PTT for ~600ms so the user can confirm the rig transmits.
func (a *App) TestPTT() error {
cfg, _ := a.GetAudioSettings()
if cfg.PTTMethod == "" || cfg.PTTMethod == "none" {
return fmt.Errorf("PTT method is None (VOX) — nothing to test")
}
if err := a.pttKey(cfg); err != nil {
return err
}
go func() { time.Sleep(600 * time.Millisecond); a.pttUnkey() }()
return nil
}
// DVKPreview plays a slot's message locally on the "Listening" device.
func (a *App) DVKPreview(slot int) error {
if a.audioMgr == nil {
return fmt.Errorf("audio not initialized")
}
cfg, _ := a.GetAudioSettings()
return a.audioMgr.Play(cfg.ListeningDevice, a.dvkPath(slot))
}
// DVKStop halts any voice-keyer playback.
func (a *App) DVKStop() {
if a.audioMgr != nil {
a.audioMgr.StopPlayback()
}
}
// GetLogFilePath returns where the diagnostic log file lives so the user
// can open it from the Settings UI. Empty when applog hasn't initialised.
func (a *App) GetLogFilePath() string {
@@ -2650,6 +3332,15 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
}
}
// ── Name/city normalisation ──
// Log4OM / contest loggers often send NAME and QTH in ALL CAPS. Title-case
// them so UDP-logged QSOs match the manual + lookup paths ("SANTO DOMINGO"
// → "Santo Domingo"). Only all-caps values are touched.
q.Name = titleCaseIfUpper(q.Name)
q.QTH = titleCaseIfUpper(q.QTH)
q.Country = titleCaseIfUpper(q.Country)
q.MyCity = titleCaseIfUpper(q.MyCity)
// ── Operating-conditions stamp ──
// Pre-fill MY_RIG / MY_ANTENNA / TX_PWR from the default antenna for
// this band (if the user has configured Operating conditions).
@@ -2675,15 +3366,22 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
// entity-name table; QSL defaults are applied last so explicit ADIF
// fields (or what the lookup gave us) always win.
a.applyDXCCNumber(&q)
a.applyClublogException(&q, false) // date-ranged DXpedition override
a.applyQSLDefaults(&q)
// ── Dedup ──
// Match by call + minute + band + mode (same key the importer uses).
// Match by call + band + mode within a ±2-minute window: a QSO logged
// manually in OpsLog and re-broadcast by Log4OM over UDP often differs by
// a minute (the two apps stamp their own time), so a minute-exact key
// missed it and the contact got duplicated.
seen, err := a.qso.ExistingDedupeKeys(a.ctx)
if err == nil {
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
if _, dup := seen[key]; dup {
return 0, fmt.Errorf("duplicate (already in log)")
base := q.QSODate.UTC()
for d := -2; d <= 2; d++ {
min := base.Add(time.Duration(d) * time.Minute).Format("2006-01-02T15:04")
if _, dup := seen[qso.DedupeKey(q.Callsign, min, q.Band, q.Mode)]; dup {
return 0, fmt.Errorf("duplicate (already in log within ±2 min)")
}
}
}
@@ -2691,6 +3389,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
if err != nil {
return 0, fmt.Errorf("insert qso: %w", err)
}
a.saveQSORecording(q.Callsign)
if a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
}