aduio mail
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 (F1–F6)</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,
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Vendored
+9
-1
@@ -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>;
|
||||
|
||||
@@ -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']();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user