aduio mail

This commit is contained in:
2026-06-05 02:29:49 +02:00
parent a2a29c66d2
commit 95fdc1ccd1
14 changed files with 673 additions and 126 deletions
+289 -12
View File
@@ -23,6 +23,7 @@ import (
"hamlog/internal/clublog"
"hamlog/internal/cluster"
"hamlog/internal/db"
"hamlog/internal/email"
"hamlog/internal/extsvc"
"hamlog/internal/integrations/udp"
"hamlog/internal/operating"
@@ -85,9 +86,24 @@ const (
keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr"
keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT
keyAudioFormat = "audio.qso_format" // "wav" | "mp3"
keyAudioFromGain = "audio.from_gain" // From Radio (RX) mix level, percent
keyAudioMicGain = "audio.mic_gain" // mic mix level, percent
keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions
// E-mail / SMTP — send QSO recordings to the correspondent.
keyEmailEnabled = "email.enabled"
keyEmailHost = "email.smtp_host"
keyEmailPort = "email.smtp_port"
keyEmailUser = "email.smtp_user"
keyEmailPassword = "email.smtp_password"
keyEmailFrom = "email.from"
keyEmailEncryption = "email.encryption" // "ssl" | "starttls" | "none"
keyEmailAuth = "email.auth" // "1" → SMTP requires authorization (send user/password)
keyEmailAutoSend = "email.auto_send" // "1" → auto-send recording on log when an e-mail is known
keyEmailSubject = "email.subject"
keyEmailBody = "email.body"
// 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"
@@ -1033,9 +1049,17 @@ func (a *App) AddQSO(q qso.QSO) (int64, error) {
a.applyDXCCNumber(&q)
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
a.applyQSLDefaults(&q)
// Fill the contacted operator's e-mail from the (cached) lookup so the
// recording can be auto-sent. Cheap: the entry already looked the call up.
if strings.TrimSpace(q.Email) == "" && a.lookup != nil {
if lr, e := a.lookup.Lookup(a.ctx, q.Callsign); e == nil && lr.Email != "" {
q.Email = lr.Email
}
}
id, err := a.qso.Add(a.ctx, q)
if err == nil {
a.saveQSORecording(q.Callsign)
q.ID = id
a.saveQSORecording(&q)
if a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
}
@@ -1667,6 +1691,8 @@ type AudioSettings struct {
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"
FromGain int `json:"from_gain"` // From Radio (RX) mix level %, default 100
MicGain int `json:"mic_gain"` // mic mix level %, default 100
}
// ListAudioInputDevices / ListAudioOutputDevices enumerate WASAPI endpoints
@@ -1676,13 +1702,14 @@ func (a *App) ListAudioOutputDevices() ([]audio.Device, error) { return audio.Li
// GetAudioSettings returns the stored audio config (preroll defaults to 8s).
func (a *App) GetAudioSettings() (AudioSettings, error) {
out := AudioSettings{PrerollSeconds: 8, PTTMethod: "none", Format: "wav"}
out := AudioSettings{PrerollSeconds: 8, PTTMethod: "none", Format: "wav", FromGain: 100, MicGain: 100}
if a.settings == nil {
return out, nil
}
m, err := a.settings.GetMany(a.ctx,
keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice,
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat)
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat,
keyAudioFromGain, keyAudioMicGain)
if err != nil {
return out, err
}
@@ -1704,6 +1731,12 @@ func (a *App) GetAudioSettings() (AudioSettings, error) {
out.PrerollSeconds = n
}
}
if n, _ := strconv.Atoi(m[keyAudioFromGain]); n > 0 && n <= 400 {
out.FromGain = n
}
if n, _ := strconv.Atoi(m[keyAudioMicGain]); n > 0 && n <= 400 {
out.MicGain = n
}
return out, nil
}
@@ -1727,6 +1760,12 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
if format != "mp3" {
format = "wav"
}
if s.FromGain <= 0 || s.FromGain > 400 {
s.FromGain = 100
}
if s.MicGain <= 0 || s.MicGain > 400 {
s.MicGain = 100
}
for k, v := range map[string]string{
keyAudioFromRadio: s.FromRadio,
keyAudioToRadio: s.ToRadio,
@@ -1738,6 +1777,8 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
keyAudioPTTMethod: pttMethod,
keyAudioPTTPort: strings.TrimSpace(s.PTTPort),
keyAudioFormat: format,
keyAudioFromGain: strconv.Itoa(s.FromGain),
keyAudioMicGain: strconv.Itoa(s.MicGain),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
@@ -1777,7 +1818,15 @@ func (a *App) startQSORecorderIfEnabled() {
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)
fromGain, micGain := float64(cfg.FromGain)/100, float64(cfg.MicGain)/100
if cfg.FromGain == 0 {
fromGain = 1
}
if cfg.MicGain == 0 {
micGain = 1
}
a.qsoRec.SetGains(fromGain, micGain)
applog.Printf("qso-rec: running (preroll %ds, mix=%v, gains rx=%.2f mic=%.2f)", cfg.PrerollSeconds, cfg.RecordingDevice != "" && cfg.RecordingDevice != cfg.FromRadio, fromGain, micGain)
}
// qsoRecDir returns the configured recordings folder, defaulting to
@@ -1792,23 +1841,66 @@ func (a *App) qsoRecDir() string {
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) {
// saveQSORecording finalises the active recording (if any) into a file named
// CALL_BAND_MODE_YYYYMMDD_HHMMSS.ext, stores the filename on the QSO (so it can
// be e-mailed later), and auto-sends it to the contacted operator when enabled
// and an e-mail is known. Called right after a QSO is inserted (manual + UDP);
// q must have its ID set.
// recordableMode reports whether a QSO mode is worth an audio recording —
// only voice (SSB/AM/FM) and CW. Digital modes (FT8/FT4/RTTY/PSK/JT…) carry no
// useful audio, so they are never recorded.
func recordableMode(mode string) bool {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "SSB", "USB", "LSB", "AM", "FM", "DV", "CW":
return true
}
return false
}
func (a *App) saveQSORecording(q *qso.QSO) {
if a.qsoRec == nil || !a.qsoRec.Active() {
return
}
if !recordableMode(q.Mode) {
a.qsoRec.DiscardQSO() // digital mode — drop the buffered audio
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)
parts := []string{sanitizeFilename(q.Callsign)}
if b := strings.TrimSpace(q.Band); b != "" {
parts = append(parts, sanitizeFilename(b))
}
if m := strings.TrimSpace(q.Mode); m != "" {
parts = append(parts, sanitizeFilename(m))
}
parts = append(parts, time.Now().UTC().Format("20060102_150405"))
name := strings.Join(parts, "_") + "." + 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)
// Remember the recording on the QSO so it can be e-mailed later.
if q.ID != 0 {
if q.Extras == nil {
q.Extras = map[string]string{}
}
q.Extras["APP_OPSLOG_RECORDING"] = name
if err := a.qso.Update(a.ctx, *q); err != nil {
applog.Printf("qso-rec: store recording path: %v", err)
}
}
// Auto-send to the correspondent when enabled and an e-mail is known.
if es, _ := a.GetEmailSettings(); es.Enabled && es.AutoSend && strings.TrimSpace(q.Email) != "" {
qc := *q
go func() { _ = a.sendRecordingEmail(qc, path) }()
}
}
// sanitizeFilename makes a callsign safe for a filename (slashes etc.).
@@ -1829,10 +1921,15 @@ func sanitizeFilename(s string) string {
// 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()
// QSOAudioBegin starts accumulating a recording for the current QSO. It
// returns true when a recording is actually running (recorder enabled and
// capturing), so the UI can show a "REC" indicator.
func (a *App) QSOAudioBegin() bool {
if a.qsoRec == nil {
return false
}
a.qsoRec.BeginQSO()
return a.qsoRec.Active()
}
// QSOAudioCancel drops the in-progress recording (callsign cleared, QSO
@@ -1846,6 +1943,184 @@ func (a *App) QSOAudioCancel() {
// RestartQSORecorder applies new audio settings to the running recorder.
func (a *App) RestartQSORecorder() { a.startQSORecorderIfEnabled() }
// ── E-mail / SMTP (send QSO recordings) ───────────────────────────────
const (
defaultEmailSubject = "Our QSO recording — {CALL}"
defaultEmailBody = "Hi,\n\nGreat to work you! Please find attached the audio recording of our QSO.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}"
)
// EmailSettings is the user's SMTP config + auto-send + message templates.
type EmailSettings struct {
Enabled bool `json:"enabled"`
Host string `json:"smtp_host"`
Port int `json:"smtp_port"`
User string `json:"smtp_user"`
Password string `json:"smtp_password"`
From string `json:"from"`
Encryption string `json:"encryption"` // "ssl" | "starttls" | "none"
Auth bool `json:"auth"` // SMTP requires authorization
AutoSend bool `json:"auto_send"`
Subject string `json:"subject"`
Body string `json:"body"`
}
// GetEmailSettings returns the stored SMTP config (with sensible defaults).
func (a *App) GetEmailSettings() (EmailSettings, error) {
out := EmailSettings{Port: 587, Encryption: "starttls", Auth: true, Subject: defaultEmailSubject, Body: defaultEmailBody}
if a.settings == nil {
return out, nil
}
m, err := a.settings.GetMany(a.ctx,
keyEmailEnabled, keyEmailHost, keyEmailPort, keyEmailUser, keyEmailPassword,
keyEmailFrom, keyEmailEncryption, keyEmailAuth, keyEmailAutoSend, keyEmailSubject, keyEmailBody)
if err != nil {
return out, err
}
out.Enabled = m[keyEmailEnabled] == "1"
out.Host = m[keyEmailHost]
if p, _ := strconv.Atoi(m[keyEmailPort]); p > 0 {
out.Port = p
}
out.User = m[keyEmailUser]
out.Password = m[keyEmailPassword]
out.From = m[keyEmailFrom]
if e := m[keyEmailEncryption]; e == "ssl" || e == "starttls" || e == "none" {
out.Encryption = e
}
out.Auth = m[keyEmailAuth] != "0" // default true (unset → required)
out.AutoSend = m[keyEmailAutoSend] == "1"
if s := m[keyEmailSubject]; s != "" {
out.Subject = s
}
if b := m[keyEmailBody]; b != "" {
out.Body = b
}
return out, nil
}
// SaveEmailSettings persists the SMTP config.
func (a *App) SaveEmailSettings(s EmailSettings) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
enc := s.Encryption
if enc != "ssl" && enc != "none" {
enc = "starttls"
}
if s.Port <= 0 {
s.Port = 587
}
b2s := func(b bool) string {
if b {
return "1"
}
return "0"
}
for k, v := range map[string]string{
keyEmailEnabled: b2s(s.Enabled),
keyEmailHost: strings.TrimSpace(s.Host),
keyEmailPort: strconv.Itoa(s.Port),
keyEmailUser: strings.TrimSpace(s.User),
keyEmailPassword: s.Password,
keyEmailFrom: strings.TrimSpace(s.From),
keyEmailEncryption: enc,
keyEmailAuth: b2s(s.Auth),
keyEmailAutoSend: b2s(s.AutoSend),
keyEmailSubject: s.Subject,
keyEmailBody: s.Body,
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
}
}
return nil
}
func (a *App) emailConfig(s EmailSettings) email.Config {
return email.Config{Host: s.Host, Port: s.Port, User: s.User, Password: s.Password, From: s.From, Encryption: s.Encryption, Auth: s.Auth}
}
// TestEmail sends a test message to `to` (defaults to the From address) to
// validate the SMTP configuration.
func (a *App) TestEmail(to string) error {
s, _ := a.GetEmailSettings()
if to == "" {
to = s.From
}
if to == "" {
to = s.User
}
return email.Send(a.emailConfig(s), to,
"OpsLog SMTP test", "This is a test message from OpsLog — your SMTP settings work. 73", "")
}
// fillTemplate substitutes {CALL} {DATE} {BAND} {MODE} {MYCALL} in a string.
func (a *App) fillTemplate(tmpl string, q qso.QSO) string {
myCall := ""
if a.profiles != nil {
if p, err := a.profiles.Active(a.ctx); err == nil {
myCall = p.Callsign
}
}
r := strings.NewReplacer(
"{CALL}", q.Callsign,
"{DATE}", q.QSODate.UTC().Format("2006-01-02 15:04 UTC"),
"{BAND}", q.Band,
"{MODE}", q.Mode,
"{MYCALL}", myCall,
)
return r.Replace(tmpl)
}
// sendRecordingEmail e-mails a QSO recording to the contacted operator.
func (a *App) sendRecordingEmail(q qso.QSO, attachPath string) error {
s, _ := a.GetEmailSettings()
to := strings.TrimSpace(q.Email)
if to == "" {
return fmt.Errorf("no e-mail address for %s", q.Callsign)
}
subject := s.Subject
if subject == "" {
subject = defaultEmailSubject
}
body := s.Body
if body == "" {
body = defaultEmailBody
}
err := email.Send(a.emailConfig(s), to, a.fillTemplate(subject, q), a.fillTemplate(body, q), attachPath)
if err != nil {
applog.Printf("email: send recording to %s failed: %v", to, err)
} else {
applog.Printf("email: recording sent to %s (%s)", to, q.Callsign)
}
return err
}
// SendQSORecordingEmail e-mails the stored recording for a QSO id (right-click
// "Send recording by e-mail"). Errors if the QSO has no recording or e-mail.
func (a *App) SendQSORecordingEmail(id int64) error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
q, err := a.qso.GetByID(a.ctx, id)
if err != nil {
return err
}
name := ""
if q.Extras != nil {
name = q.Extras["APP_OPSLOG_RECORDING"]
}
if name == "" {
return fmt.Errorf("no recording stored for this QSO")
}
path := filepath.Join(a.qsoRecDir(), name)
if _, e := os.Stat(path); e != nil {
return fmt.Errorf("recording file missing: %s", name)
}
return a.sendRecordingEmail(q, path)
}
// ── ClubLog Country File (cty.xml) exceptions ─────────────────────────
// ClublogCtyInfo is the UI status of the ClubLog exception data.
@@ -3389,7 +3664,8 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
if err != nil {
return 0, fmt.Errorf("insert qso: %w", err)
}
a.saveQSORecording(q.Callsign)
q.ID = id
a.saveQSORecording(&q)
if a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
}
@@ -4567,6 +4843,7 @@ func (a *App) SendClusterSpot(call string, freqKHz float64, comment string) erro
if c := strings.TrimSpace(comment); c != "" {
cmd += " " + c
}
applog.Printf("cluster: send spot — freqKHz=%v → command %q", freqKHz, cmd)
return a.SendClusterCommand(cmd)
}
+68 -43
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Eraser, ExternalLink, Hash, Loader2, Lock,
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Eraser, Hash, Loader2, Lock,
Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, Square, Trash2, Unlock, X,
} from 'lucide-react';
@@ -8,7 +8,7 @@ import {
AddQSO, ListQSO, CountQSO,
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF,
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual,
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
LookupCallsign, GetStationSettings, GetListsSettings,
GetStartupStatus,
WorkedBefore,
@@ -76,6 +76,9 @@ type CATState = Omit<catModels.RigState, 'convertValues'>;
const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm'];
const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE'];
// Modes the QSO recorder captures (voice + CW). Mirrors recordableMode() in
// app.go — digital modes carry no useful audio and are never recorded.
const RECORDABLE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV','CW']);
const emptyDetails: DetailsState = {
state: '', cnty: '', address: '',
@@ -419,6 +422,16 @@ export default function App() {
setToast(msg);
window.setTimeout(() => setToast((t) => (t === msg ? '' : t)), 3500);
}, []);
// Error banners auto-dismiss after a few seconds (longer than toasts since
// they may be multi-line). The X button still closes them immediately.
useEffect(() => {
if (!error) return;
const t = window.setTimeout(() => setError(''), 6000);
return () => window.clearTimeout(t);
}, [error]);
// True while the QSO recorder is capturing the current contact (set when we
// leave the callsign field, cleared on log/cancel). Drives the "REC" badge.
const [recording, setRecording] = useState(false);
const [saving, setSaving] = useState(false);
const [filterCallsign, setFilterCallsign] = useState('');
const [filterBand, setFilterBand] = useState('');
@@ -843,7 +856,6 @@ export default function App() {
const mine = myCallRef.current;
if (mine && (sp.dx_call ?? '').toUpperCase() === mine) {
setSelfSpot({ spotter: cleanSpotter(sp.spotter ?? ''), freqKHz: sp.freq_khz, band: sp.band, comment: sp.comment, at: Date.now() });
showToast(`You've been spotted by ${cleanSpotter(sp.spotter ?? '') || '?'} on ${sp.freq_khz?.toFixed(1)} kHz`);
// Auto-hide 3 s after the last self-spot; a new one resets the timer.
if (selfSpotTimerRef.current) window.clearTimeout(selfSpotTimerRef.current);
selfSpotTimerRef.current = window.setTimeout(() => setSelfSpot(null), 3000);
@@ -878,7 +890,11 @@ export default function App() {
await LogUDPLoggedADIF(text);
await refresh();
} catch (e: any) {
setError('UDP auto-log: ' + String(e?.message ?? e));
const msg = String(e?.message ?? e);
// A re-broadcast of an already-logged QSO (Log4OM/WSJT-X) is benign —
// show a quiet toast, not a red error.
if (/duplicate/i.test(msg)) showToast('UDP QSO already logged — skipped');
else setError('UDP auto-log: ' + msg);
}
});
return () => { unsubDX?.(); unsubRC?.(); unsubProg?.(); unsubLog?.(); };
@@ -1048,7 +1064,7 @@ export default function App() {
function resetEntry() {
// Discard any in-progress QSO recording (no-op if it was already saved on
// log, or if the recorder is off).
QSOAudioCancel();
QSOAudioCancel(); setRecording(false);
setCallsign(''); setComment(''); setNote('');
if (!locks.start) setQsoStartedAt(null);
if (!locks.end) setQsoEndedAt(null);
@@ -1132,6 +1148,17 @@ export default function App() {
try { await afterBulkUpdate(await UpdateQSOsFromClublog(ids as any), 'from ClubLog'); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
async function bulkSendRecording(ids: number[]) {
if (ids.length === 0) return;
showToast(`Sending ${ids.length} recording${ids.length > 1 ? 's' : ''} by e-mail…`);
let ok = 0; const errs: string[] = [];
for (const id of ids) {
try { await SendQSORecordingEmail(id as any); ok++; }
catch (e: any) { errs.push(String(e?.message ?? e)); }
}
if (errs.length) setError(`Recording e-mail: ${ok} sent, ${errs.length} failed — ${errs[0]}`);
else showToast(`${ok} recording${ok > 1 ? 's' : ''} sent`);
}
// Right-click "Send to QRZ.com / Club Log / LoTW": uploads the selected QSOs
// on demand (regardless of their current upload status). Runs in the
// background; qslmgr:done refreshes the grid when finished.
@@ -1238,8 +1265,9 @@ export default function App() {
if (v.trim().toUpperCase() === callsignValRef.current.trim().toUpperCase()) return;
// QSO recorder: a non-empty callsign marks the QSO start (the recorder
// keeps the pre-roll from before this); clearing it discards the take.
// Both are no-ops when the recorder is off.
if (v.trim() === '') QSOAudioCancel(); else QSOAudioBegin();
// Recording START happens on blur (leaving the callsign field), NOT here —
// you may type a call and work it minutes later. Clearing it cancels.
if (v.trim() === '') { QSOAudioCancel(); setRecording(false); }
const wasEmpty = callsign.trim() === '';
const isEmpty = v.trim() === '';
if (wasEmpty && !isEmpty && !locks.start) {
@@ -1454,24 +1482,6 @@ export default function App() {
<div className="flex flex-col w-36">
<Label className="mb-1 flex items-center gap-2 h-3.5">
Callsign
{callsign.trim() && (
<button
type="button"
tabIndex={-1}
onClick={() => {
const c = callsign.trim().toUpperCase();
// Encode each segment but keep the '/' literal — QRZ's URL is
// /db/5Z4/MM0ZBH, not /db/5Z4%2FMM0ZBH (which 404s).
const path = c.split('/').map(encodeURIComponent).join('/');
OpenExternalURL(`https://www.qrz.com/db/${path}`)
.catch((err) => setError(String(err?.message ?? err)));
}}
title="Open this callsign on QRZ.com"
className="inline-flex items-center justify-center size-3.5 rounded text-muted-foreground/60 hover:text-primary transition-colors"
>
<ExternalLink className="size-3" />
</button>
)}
{lookupBusy && <Badge variant="secondary" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider"><Loader2 className="size-2.5 mr-1 animate-spin" />Looking up</Badge>}
{!lookupBusy && lookupResult && (
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider" variant="outline">
@@ -1482,12 +1492,22 @@ export default function App() {
<Badge variant="destructive" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider">{lookupError}</Badge>
)}
</Label>
<div className="relative">
{recording && RECORDABLE_MODES.has(mode.toUpperCase()) && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[9px] font-semibold tracking-wider text-red-600 whitespace-nowrap pointer-events-none">
<span className="size-2 rounded-full bg-red-600 animate-pulse" />REC
</span>
)}
<Input
ref={callsignRef}
className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card"
value={callsign}
onChange={(e) => onCallsignInput(e.target.value)}
// Start the QSO recording when leaving the callsign field (the pre-roll
// covers the seconds before). No-op when the recorder is off.
onBlur={() => { if (callsign.trim()) QSOAudioBegin().then(setRecording).catch(() => {}); }}
/>
</div>
</div>
);
const rstTxBlock = (
@@ -1866,14 +1886,6 @@ export default function App() {
</header>
)}
{error && (
<div className="bg-destructive/10 text-destructive border-b border-destructive/30 px-4 py-2 flex items-start gap-3 text-xs">
<AlertCircle className="size-4 mt-0.5 shrink-0" />
<pre className="flex-1 font-mono whitespace-pre-wrap m-0">{error}</pre>
<button className="hover:text-destructive/70" onClick={() => setError('')}><X className="size-4" /></button>
</div>
)}
{/* QRZ profile photo lightbox — full size, in-app. Click anywhere or
press Esc to close; click the image itself doesn't close. */}
{photoModal && (
@@ -1899,12 +1911,24 @@ export default function App() {
</div>
)}
{/* Transient success toast (bottom-right). */}
{toast && (
<div className="fixed bottom-4 right-4 z-[100] flex items-center gap-2 rounded-lg border border-emerald-300 bg-emerald-50 text-emerald-800 px-3.5 py-2 text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2">
<Satellite className="size-4 shrink-0" />
<span>{toast}</span>
<button className="ml-1 text-emerald-600 hover:text-emerald-800" onClick={() => setToast('')}><X className="size-3.5" /></button>
{/* Transient toasts (bottom-right). Errors stack on top of the green
success toast; both auto-dismiss. */}
{(error || toast) && (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[100] flex flex-col items-center gap-2 max-w-md">
{error && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/10 text-destructive px-3.5 py-2 text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2">
<AlertCircle className="size-4 mt-0.5 shrink-0" />
<pre className="flex-1 font-sans whitespace-pre-wrap m-0 leading-snug">{error}</pre>
<button className="ml-1 hover:text-destructive/70" onClick={() => setError('')}><X className="size-3.5" /></button>
</div>
)}
{toast && (
<div className="flex items-center gap-2 rounded-lg border border-emerald-300 bg-emerald-50 text-emerald-800 px-3.5 py-2 text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2">
<Satellite className="size-4 shrink-0" />
<span>{toast}</span>
<button className="ml-1 text-emerald-600 hover:text-emerald-800" onClick={() => setToast('')}><X className="size-3.5" /></button>
</div>
)}
</div>
)}
@@ -1913,7 +1937,7 @@ export default function App() {
so it never shifts the layout (push-down / spring-back) and never
covers the entry fields; auto-hides 3s after the last self-spot. */}
{!compact && selfSpot && (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[100] flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-100 text-amber-900 px-3.5 py-2 text-xs shadow-lg animate-in fade-in slide-in-from-bottom-2">
<div className="fixed bottom-16 left-1/2 -translate-x-1/2 z-[100] flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-100 text-amber-900 px-3.5 py-2 text-xs shadow-lg animate-in fade-in slide-in-from-bottom-2">
<RadioTower className="size-3.5 shrink-0" />
<span>
You've been spotted by <strong className="font-mono">{selfSpot.spotter || '?'}</strong>
@@ -2213,6 +2237,7 @@ export default function App() {
onUpdateFromQRZ={bulkUpdateFromQRZ}
onUpdateFromClublog={bulkUpdateFromClublog}
onSendTo={bulkSendTo}
onSendRecording={bulkSendRecording}
onRowSelected={(id) => setSelectedId(id)}
/>
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
@@ -2544,7 +2569,7 @@ export default function App() {
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} />
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} />
</TabsContent>
{/* Opened on demand from Tools → QSL Manager; closable via the
@@ -2735,8 +2760,8 @@ export default function App() {
</div>
</button>
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setShowExportChoice(false)}>Cancel</Button>
<DialogFooter className="px-2 bg-transparent border-t-0">
<Button variant="outline" onClick={() => setShowExportChoice(false)}>Cancel</Button>
</DialogFooter>
</DialogContent>
</Dialog>
+16 -2
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { Globe2, RefreshCw, Upload, BadgeCheck } from 'lucide-react';
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail } from 'lucide-react';
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
@@ -10,6 +10,7 @@ type Props = {
onUpdateFromQRZ: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
};
const UPLOAD_TARGETS: { service: string; label: string }[] = [
@@ -21,7 +22,7 @@ const UPLOAD_TARGETS: { service: string; label: string }[] = [
// Lightweight right-click menu for the QSO grids. AG Grid's native context
// menu is an Enterprise feature, so this is a plain floating menu driven by
// onCellContextMenu. Closes on any outside click, scroll or Escape.
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo }: Props) {
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording }: Props) {
useEffect(() => {
if (!menu) return;
const close = () => onClose();
@@ -77,6 +78,19 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
</button>
)}
{onSendRecording && (
<>
<div className="my-1 border-t border-border" />
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onSendRecording(menu.ids); onClose(); }}
>
<Mail className="size-4 text-rose-600" />
<span>Send recording by e-mail</span>
</button>
</>
)}
{onSendTo && (
<>
<div className="my-1 border-t border-border" />
+3 -1
View File
@@ -51,6 +51,7 @@ type Props = {
onUpdateFromQRZ?: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
};
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
@@ -208,7 +209,7 @@ export const GROUP_ORDER = [
'Contest', 'Propagation', 'My station', 'Misc',
];
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo }: Props) {
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording }: Props) {
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null);
@@ -353,6 +354,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
onUpdateFromClublog={onUpdateFromClublog}
onSendTo={onSendTo}
onSendRecording={onSendRecording}
/>
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
+89 -25
View File
@@ -14,6 +14,7 @@ import {
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
GetEmailSettings, SaveEmailSettings, TestEmail,
GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
ListClusterServers, SaveClusterServer, DeleteClusterServer,
GetClusterAutoConnect, SetClusterAutoConnect,
@@ -136,6 +137,7 @@ interface Props {
`disabled: true` greys them out and shows the "coming soon" placeholder. */
type SectionId =
| 'general'
| 'email'
| 'station'
| 'profiles'
| 'operating'
@@ -172,6 +174,7 @@ const TREE: TreeNode[] = [
{
kind: 'group', label: 'Software Configuration', icon: Cog, defaultOpen: true, children: [
{ kind: 'item', label: 'General', id: 'general' },
{ kind: 'item', label: 'E-mail (SMTP)', id: 'email' },
{ kind: 'item', label: 'Callsign Lookup', id: 'lookup' },
{ kind: 'group', label: 'Lists', icon: Database, defaultOpen: true, children: [
{ kind: 'item', label: 'Bands', id: 'lists-bands' },
@@ -386,11 +389,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
qso_record: boolean; qso_dir: string; preroll_seconds: number;
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3';
from_gain: number; mic_gain: number;
};
type AudioDev = { id: string; name: string; default: boolean };
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav',
from_gain: 100, mic_gain: 100,
});
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
const [audioOutputs, setAudioOutputs] = useState<AudioDev[]>([]);
@@ -408,6 +413,18 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
// General behaviour prefs (machine-local, applied live via localStorage).
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
// E-mail / SMTP (send QSO recordings).
type EmailCfg = {
enabled: boolean; smtp_host: string; smtp_port: number; smtp_user: string; smtp_password: string;
from: string; encryption: 'ssl' | 'starttls' | 'none'; auth: boolean; auto_send: boolean; subject: string; body: string;
};
const [emailCfg, setEmailCfg] = useState<EmailCfg>({
enabled: false, smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '',
from: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
});
const [emailMsg, setEmailMsg] = useState('');
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
// ClubLog Country File (cty.xml) exception status.
type ClubInfo = { enabled: boolean; loaded: boolean; date: string; count: number };
const [clubInfo, setClubInfo] = useState<ClubInfo>({ enabled: false, loaded: false, date: '', count: 0 });
@@ -569,6 +586,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
try { setWk(await GetWinkeyerSettings() as any); } catch {}
try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
reloadAudioDevices();
reloadDvk();
} catch (e: any) {
@@ -726,6 +744,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
await SaveRotatorSettings(rotator as any);
await SaveWinkeyerSettings(wk as any);
await SaveAudioSettings(audioCfg as any);
await SaveEmailSettings(emailCfg as any);
await SaveBackupSettings(backupCfg as any);
await SaveQSLDefaults(qslDefaults as any);
await SaveExternalServices(extSvc as any);
@@ -2538,9 +2557,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<>
<div className="flex items-start justify-between">
<SectionHeader
title="Audio devices & voice keyer"
hint="Machine-local audio routing for the Digital Voice Keyer and the QSO recorder. Pick the soundcard endpoints wired to your rig. (Pure-Go WASAPI no extra driver.)"
/>
title="Audio devices & voice keyer"/>
<Button variant="outline" size="sm" className="h-7 text-[11px] shrink-0" onClick={reloadAudioDevices}>
Refresh devices
</Button>
@@ -2565,15 +2582,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<div className="border-t border-border/60 pt-3 space-y-3 max-w-2xl">
<h4 className="text-sm font-semibold text-foreground">QSO recorder</h4>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={audioCfg.qso_record} onCheckedChange={(c) => setAudioField({ qso_record: !!c })} className="mt-0.5" />
<span>
Record every QSO to an audio file
<span className="block text-xs text-muted-foreground mt-0.5">
Captures <strong>From Radio + your mic</strong> continuously into a rolling buffer; on <em>Log QSO</em> the
file is saved from a few seconds <em>before</em> you entered the callsign through the end of the contact.
</span>
</span>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={audioCfg.qso_record} onCheckedChange={(c) => setAudioField({ qso_record: !!c })} />
Record every QSO to an audio file (From Radio + your mic)
</label>
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">Recordings folder</Label>
@@ -2597,19 +2608,28 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<SelectItem value="mp3">MP3 (compressed, small)</SelectItem>
</SelectContent>
</Select>
<Label className="text-sm">From Radio level</Label>
<div className="flex items-center gap-2">
<input type="range" min={10} max={300} step={5} value={audioCfg.from_gain}
onChange={(e) => setAudioField({ from_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
<span className="font-mono text-xs w-12 text-right">{audioCfg.from_gain}%</span>
</div>
<Label className="text-sm">Mic level</Label>
<div className="flex items-center gap-2">
<input type="range" min={10} max={300} step={5} value={audioCfg.mic_gain}
onChange={(e) => setAudioField({ mic_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
<span className="font-mono text-xs w-12 text-right">{audioCfg.mic_gain}%</span>
</div>
</div>
<p className="text-[11px] text-muted-foreground">
Files are named <span className="font-mono">CALL_YYYYMMDD_HHMMSS.{audioCfg.format}</span>.
{audioCfg.format === 'mp3' ? ' MP3 ≈ 7× smaller — handy to send to correspondents.' : ' WAV is lossless (~115 KB/min).'}
</p>
<p className="text-xs text-muted-foreground">If your voice is louder than the station, lower Mic level.</p>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={emailCfg.auto_send} onCheckedChange={(c) => setEmailField({ auto_send: !!c })} />
Auto-send the recording to the station by e-mail when I log a QSO
</label>
</div>
<div className="border-t border-border/60 pt-3 space-y-2 max-w-2xl">
<h4 className="text-sm font-semibold text-foreground">Voice keyer messages (F1F6)</h4>
<p className="text-[11px] text-muted-foreground">
<strong>Press and hold</strong> Rec while you speak (release to save). Preview on <strong>Listening</strong>;
during operation they transmit via <strong>To Radio</strong>.
</p>
<div className="rounded-md border border-border/60 p-2.5 space-y-2">
<div className="grid grid-cols-[120px_1fr] gap-2 items-center">
<Label className="text-sm">PTT method</Label>
@@ -2648,11 +2668,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</>
)}
</div>
<p className="text-[11px] text-muted-foreground">
<strong>CAT (OmniRig)</strong> keys TX through the rig control (sets OmniRig's Tx parameter) — needs CAT
connected. <strong>Serial RTS/DTR</strong> asserts a COM line (e.g. a SmartSDR CAT port set to PTT-on-RTS).
<strong> None (VOX)</strong> lets the rig key on audio. Use <strong>Test PTT</strong> to confirm.
</p>
</div>
{dvkErr && <p className="text-[11px] text-destructive">{dvkErr}</p>}
<div className="space-y-1.5">
@@ -2772,9 +2787,58 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
);
}
function EmailPanel() {
return (
<>
<SectionHeader title="E-mail"/>
<div className="space-y-3 max-w-2xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={emailCfg.enabled} onCheckedChange={(c) => setEmailField({ enabled: !!c })} />
Enable e-mail sending
</label>
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
<Label className="text-sm">SMTP server</Label>
<Input className="h-8" placeholder="ex5.mail.ovh.net" value={emailCfg.smtp_host} onChange={(e) => setEmailField({ smtp_host: e.target.value })} />
<Label className="text-sm">Port / encryption</Label>
<div className="flex gap-2 items-center">
<Input type="number" className="h-8 w-24 font-mono" value={emailCfg.smtp_port} onChange={(e) => setEmailField({ smtp_port: parseInt(e.target.value, 10) || 0 })} />
<Select value={emailCfg.encryption} onValueChange={(v) => setEmailField({ encryption: v as any })}>
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="starttls">STARTTLS (587)</SelectItem>
<SelectItem value="ssl">SSL/TLS (465)</SelectItem>
<SelectItem value="none">None</SelectItem>
</SelectContent>
</Select>
</div>
<div />
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={emailCfg.auth} onCheckedChange={(c) => setEmailField({ auth: !!c })} />
SMTP requires authorization
</label>
<Label className="text-sm">Username</Label>
<Input className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_user} onChange={(e) => setEmailField({ smtp_user: e.target.value })} />
<Label className="text-sm">Password</Label>
<Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
<Label className="text-sm">From address</Label>
<Input className="h-8" placeholder="you@example.com" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
</div>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" className="h-8"
onClick={() => { setEmailMsg('Sending test…'); SaveEmailSettings(emailCfg as any).then(() => TestEmail('')).then(() => setEmailMsg('Test e-mail sent ✓')).catch((e: any) => setEmailMsg('Test failed: ' + String(e?.message ?? e))); }}>
Send test e-mail
</Button>
<span className="text-[11px] text-muted-foreground">{emailMsg}</span>
</div>
</div>
</>
);
}
// Map sections to their content + icon (for placeholder).
const PANELS: Record<SectionId, () => JSX.Element> = {
general: GeneralPanel,
email: EmailPanel,
station: StationPanel,
profiles: ProfilesPanel,
operating: OperatingPanelWrapper,
+3 -1
View File
@@ -51,6 +51,7 @@ type Props = {
onUpdateFromQRZ?: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
};
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v2';
@@ -63,7 +64,7 @@ function fmtDate(s: any): string {
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
}
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo }: Props) {
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording }: Props) {
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null);
@@ -236,6 +237,7 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
onUpdateFromClublog={onUpdateFromClublog}
onSendTo={onSendTo}
onSendRecording={onSendRecording}
/>
{count > entries.length && (
+9 -1
View File
@@ -97,6 +97,8 @@ export function GetDVKStatus():Promise<main.DVKStatus>;
export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
export function GetEmailSettings():Promise<main.EmailSettings>;
export function GetExternalServices():Promise<extsvc.ExternalServices>;
export function GetListsSettings():Promise<main.ListsSettings>;
@@ -167,7 +169,7 @@ export function PickOpenDatabase():Promise<string>;
export function PickSaveDatabase():Promise<string>;
export function QSOAudioBegin():Promise<void>;
export function QSOAudioBegin():Promise<boolean>;
export function QSOAudioCancel():Promise<void>;
@@ -199,6 +201,8 @@ export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>;
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
@@ -225,6 +229,8 @@ export function SendClusterCommand(arg1:string):Promise<void>;
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
export function SendQSORecordingEmail(arg1:number):Promise<void>;
export function SetCATFrequency(arg1:number):Promise<void>;
export function SetCATMode(arg1:string):Promise<void>;
@@ -243,6 +249,8 @@ export function SwitchCATRig(arg1:number):Promise<void>;
export function TestClublogUpload():Promise<string>;
export function TestEmail(arg1:string):Promise<void>;
export function TestLoTWUpload():Promise<string>;
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
+16
View File
@@ -170,6 +170,10 @@ export function GetDatabaseSettings() {
return window['go']['main']['App']['GetDatabaseSettings']();
}
export function GetEmailSettings() {
return window['go']['main']['App']['GetEmailSettings']();
}
export function GetExternalServices() {
return window['go']['main']['App']['GetExternalServices']();
}
@@ -374,6 +378,10 @@ export function SaveClusterServer(arg1) {
return window['go']['main']['App']['SaveClusterServer'](arg1);
}
export function SaveEmailSettings(arg1) {
return window['go']['main']['App']['SaveEmailSettings'](arg1);
}
export function SaveExternalServices(arg1) {
return window['go']['main']['App']['SaveExternalServices'](arg1);
}
@@ -426,6 +434,10 @@ export function SendClusterSpot(arg1, arg2, arg3) {
return window['go']['main']['App']['SendClusterSpot'](arg1, arg2, arg3);
}
export function SendQSORecordingEmail(arg1) {
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
}
export function SetCATFrequency(arg1) {
return window['go']['main']['App']['SetCATFrequency'](arg1);
}
@@ -462,6 +474,10 @@ export function TestClublogUpload() {
return window['go']['main']['App']['TestClublogUpload']();
}
export function TestEmail(arg1) {
return window['go']['main']['App']['TestEmail'](arg1);
}
export function TestLoTWUpload() {
return window['go']['main']['App']['TestLoTWUpload']();
}
+36
View File
@@ -385,6 +385,8 @@ export namespace main {
ptt_method: string;
ptt_port: string;
format: string;
from_gain: number;
mic_gain: number;
static createFrom(source: any = {}) {
return new AudioSettings(source);
@@ -402,6 +404,8 @@ export namespace main {
this.ptt_method = source["ptt_method"];
this.ptt_port = source["ptt_port"];
this.format = source["format"];
this.from_gain = source["from_gain"];
this.mic_gain = source["mic_gain"];
}
}
export class BackupSettings {
@@ -534,6 +538,38 @@ export namespace main {
this.is_custom = source["is_custom"];
}
}
export class EmailSettings {
enabled: boolean;
smtp_host: string;
smtp_port: number;
smtp_user: string;
smtp_password: string;
from: string;
encryption: string;
auth: boolean;
auto_send: boolean;
subject: string;
body: string;
static createFrom(source: any = {}) {
return new EmailSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.smtp_host = source["smtp_host"];
this.smtp_port = source["smtp_port"];
this.smtp_user = source["smtp_user"];
this.smtp_password = source["smtp_password"];
this.from = source["from"];
this.encryption = source["encryption"];
this.auth = source["auth"];
this.auto_send = source["auto_send"];
this.subject = source["subject"];
this.body = source["body"];
}
}
export class ModePreset {
name: string;
default_rst_sent?: string;
+2 -1
View File
@@ -7,10 +7,11 @@ require (
github.com/go-ole/go-ole v1.3.0
github.com/moutend/go-wca v0.3.0
github.com/wailsapp/wails/v2 v2.11.0
github.com/wneessen/go-mail v0.7.3
go.bug.st/serial v1.7.1
golang.org/x/net v0.35.0
golang.org/x/sys v0.45.0
golang.org/x/text v0.22.0
golang.org/x/text v0.37.0
modernc.org/sqlite v1.50.1
)
+8 -6
View File
@@ -74,12 +74,14 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0=
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
go.bug.st/serial v1.7.1 h1:5aP8wYL0UjEYOVs3oPAGscjaSfRQLHtCvBFXNN/rwtc=
go.bug.st/serial v1.7.1/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
@@ -96,11 +98,11 @@ golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
+36 -3
View File
@@ -31,6 +31,8 @@ type Recorder struct {
bufA []int16 // From Radio
bufB []int16 // mic
twoSrc bool
gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu
gainB float64 // mic gain
// Mixed output state (guarded by mu).
ring []int16 // last prerollSamples of mixed audio
@@ -38,7 +40,36 @@ type Recorder struct {
acc []int16 // active QSO accumulation (seeded from ring on BeginQSO)
}
func NewRecorder() *Recorder { return &Recorder{} }
func NewRecorder() *Recorder { return &Recorder{gainA: 1, gainB: 1} }
// SetGains sets the per-source mix levels (1.0 = unity). Use this to balance a
// hot mic against quieter rig RX audio. Values ≤0 fall back to unity.
func (r *Recorder) SetGains(fromGain, micGain float64) {
if fromGain <= 0 {
fromGain = 1
}
if micGain <= 0 {
micGain = 1
}
r.srcMu.Lock()
r.gainA, r.gainB = fromGain, micGain
r.srcMu.Unlock()
}
// scaleSample applies gain to a sample with clamping.
func scaleSample(s int16, g float64) int16 {
if g == 1 {
return s
}
v := float64(s) * g
if v > 32767 {
return 32767
}
if v < -32768 {
return -32768
}
return int16(v)
}
func (r *Recorder) Running() bool {
r.mu.Lock()
@@ -128,7 +159,7 @@ func (r *Recorder) mixTick() {
if n > 0 {
mixed = make([]int16, n)
for i := 0; i < n; i++ {
mixed[i] = clampSum(r.bufA[i], r.bufB[i])
mixed[i] = clampSum(scaleSample(r.bufA[i], r.gainA), scaleSample(r.bufB[i], r.gainB))
}
r.bufA = append(r.bufA[:0], r.bufA[n:]...)
r.bufB = append(r.bufB[:0], r.bufB[n:]...)
@@ -142,7 +173,9 @@ func (r *Recorder) mixTick() {
}
} else if len(r.bufA) > 0 {
mixed = make([]int16, len(r.bufA))
copy(mixed, r.bufA)
for i, s := range r.bufA {
mixed[i] = scaleSample(s, r.gainA)
}
r.bufA = r.bufA[:0]
}
r.srcMu.Unlock()
+25 -31
View File
@@ -202,39 +202,33 @@ func (o *OmniRig) SetFrequency(hz int64) error {
debugLog.Printf("OmniRig.SetFrequency(%d Hz / %.6f MHz): rig=%q status=%d(%s) vfo=%q(raw=%d) split=%d writeableParams=0x%X",
hz, float64(hz)/1e6, rigType, status, statusStr, vfo, rawVfo, split, writeable)
// Pick the active VFO's specific property. Many rig .ini files only define
// a WRITE command for FreqA/FreqB but not the generic Freq.
prop := "FreqA"
switch vfo {
case "B", "BB", "BA":
prop = "FreqB"
case "A", "AA", "AB":
prop = "FreqA"
}
wroteOK := false
if _, err := oleutil.PutProperty(o.rig, prop, hz32); err != nil {
debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s) error: %v", prop, err)
// Primary path: OmniRig's SetSimplexMode is the rig-agnostic "QSY here"
// method (RX=TX=freq, simplex). It works on rigs — notably Icom (IC-9100) —
// where direct FreqA/FreqB writes are accepted but never move the radio.
// Clearing split is the right thing when tuning to a spot anyway.
if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil {
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode(%d) OK", hz32)
} else {
debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s, %d) OK", prop, hz32)
wroteOK = true
}
// Belt-and-suspenders: when NOT in split, also write the generic Freq.
// Icom .ini files commonly honour Freq (CI-V "set operating frequency")
// but ignore FreqA/FreqB, so the rig changed mode but never moved — this
// is exactly the IC-9100 "mode changes, freq doesn't" symptom.
if split == 0 {
if _, err := oleutil.PutProperty(o.rig, "Freq", hz32); err != nil {
debugLog.Printf("OmniRig.SetFrequency: PutProperty(Freq) error: %v", err)
if !wroteOK {
return err
}
} else {
debugLog.Printf("OmniRig.SetFrequency: PutProperty(Freq, %d) OK", hz32)
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v) — using property writes", err)
// Fallback: write the active VFO's property AND the generic Freq
// (always — some .ini honour only one, and split here is often misread).
prop := "FreqA"
switch vfo {
case "B", "BB", "BA":
prop = "FreqB"
}
okAny := false
for _, p := range []string{prop, "Freq"} {
if _, e := oleutil.PutProperty(o.rig, p, hz32); e != nil {
debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s) error: %v", p, e)
} else {
debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s, %d) OK", p, hz32)
okAny = true
}
}
if !okAny {
return fmt.Errorf("OmniRig: no writable frequency property for this rig")
}
} else if !wroteOK {
return fmt.Errorf("OmniRig: could not write %s and split is on (won't touch generic Freq)", prop)
}
// Read back all three immediately. OmniRig is async (the CAT command is
+73
View File
@@ -0,0 +1,73 @@
// Package email sends QSO recordings to correspondents via SMTP. Pure Go (no
// CGO) using go-mail; supports implicit SSL (465), STARTTLS (587) or none.
package email
import (
"fmt"
"time"
"github.com/wneessen/go-mail"
)
// Config is the user's SMTP configuration.
type Config struct {
Host string
Port int
User string
Password string
From string
Encryption string // "ssl" | "starttls" | "none"
Auth bool // SMTP requires authorization (send username/password)
}
func (c Config) opts() []mail.Option {
o := []mail.Option{mail.WithPort(c.Port), mail.WithTimeout(30 * time.Second)}
if c.Auth && c.User != "" {
// AutoDiscover negotiates whatever mechanism the server advertises
// (LOGIN, PLAIN, CRAM-MD5, …). OVH, for instance, rejects forced PLAIN.
o = append(o, mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), mail.WithUsername(c.User), mail.WithPassword(c.Password))
}
switch c.Encryption {
case "ssl":
o = append(o, mail.WithSSL())
case "none":
o = append(o, mail.WithTLSPolicy(mail.NoTLS))
default: // starttls
o = append(o, mail.WithTLSPolicy(mail.TLSMandatory))
}
return o
}
// Send delivers a plain-text email to `to`, optionally attaching a file.
func Send(cfg Config, to, subject, body, attachPath string) error {
if cfg.Host == "" {
return fmt.Errorf("SMTP server not configured")
}
if to == "" {
return fmt.Errorf("no recipient e-mail")
}
from := cfg.From
if from == "" {
from = cfg.User
}
m := mail.NewMsg()
if err := m.From(from); err != nil {
return fmt.Errorf("bad sender %q: %w", from, err)
}
if err := m.To(to); err != nil {
return fmt.Errorf("bad recipient %q: %w", to, err)
}
m.Subject(subject)
m.SetBodyString(mail.TypeTextPlain, body)
if attachPath != "" {
m.AttachFile(attachPath)
}
client, err := mail.NewClient(cfg.Host, cfg.opts()...)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
if err := client.DialAndSend(m); err != nil {
return fmt.Errorf("send: %w", err)
}
return nil
}