22 Commits
Author SHA1 Message Date
rouggy 18438d7737 chore: release v0.16.3 2026-07-04 09:01:55 +02:00
rouggy 97e24ea24f chore: release v0.16.2 2026-07-03 19:09:17 +02:00
rouggy 3e199f9ab6 feat: Cluster alert implemented 2026-07-03 19:08:50 +02:00
rouggy 8740a4ba66 chore: release v0.16.1 2026-07-03 15:31:29 +02:00
rouggy 8ccad7ca65 feat: spots to panadapter for TCI radio 2026-07-03 15:30:53 +02:00
rouggy fa7df57435 chore: release v0.16 2026-07-03 15:13:03 +02:00
rouggy 812e4f05e5 feat: TCI implementation for CAT Control of SunSDR 2026-07-03 15:11:32 +02:00
rouggy 6ec31b61ce fix: bug while creating a new profile 2026-07-02 11:41:53 +02:00
rouggy 93c8f6b9d3 fix: persistence of awards column bug corrected 2026-06-30 11:11:16 +02:00
rouggy 65c22232dd fix: bug on map zoom 2026-06-30 09:39:21 +02:00
rouggy edede0bc1e fix: zoom taking too long after clicking a spot 2026-06-30 09:14:50 +02:00
rouggy 2712902057 chore: release v0.15 2026-06-29 23:27:11 +02:00
rouggy 9281645359 feat: better looking buttons in flexradio panel 2026-06-29 23:21:54 +02:00
rouggy a05dd6b3a9 feat: Added Antenna selection to Flex FlexPanel
feat: New settings flexradio to select antennas per band
2026-06-29 23:07:52 +02:00
rouggy a2401d7fd3 fix: when in autocall cw writing a call stop transmission 2026-06-28 20:55:59 +02:00
rouggy 053b351dab feat: adding choice for recent qso in main tab 2026-06-28 20:49:14 +02:00
rouggy 64f2d38d87 chore: release v0.14.1 2026-06-28 20:39:15 +02:00
rouggy 299184712a fix: beam heading full color 2026-06-28 20:38:59 +02:00
rouggy 76c1e2df60 fix: forcing 3 sec for each notification instead of one replacing the other 2026-06-28 19:12:55 +02:00
rouggy 165f33caa5 fix: recording not being sent if mail enable was not checked 2026-06-28 19:08:32 +02:00
rouggy 464a1c702c feat: added mute and AGC-T to Flex control 2026-06-27 21:03:13 +02:00
rouggy 19c5045dc6 fix: autocall in remote not working properly 2026-06-27 20:28:29 +02:00
24 changed files with 2115 additions and 253 deletions
+321 -8
View File
@@ -18,6 +18,7 @@ import (
"time" "time"
"hamlog/internal/adif" "hamlog/internal/adif"
"hamlog/internal/alerts"
"hamlog/internal/antgenius" "hamlog/internal/antgenius"
"hamlog/internal/applog" "hamlog/internal/applog"
"hamlog/internal/audio" "hamlog/internal/audio"
@@ -89,6 +90,9 @@ const (
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5) keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200) keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200)
keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98) keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98)
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
keyCATTCISpots = "cat.tci.spots" // push cluster spots to the TCI panorama
// Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so // Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so
// global (not per-profile) like CAT/rotator. Device fields store the // global (not per-profile) like CAT/rotator. Device fields store the
@@ -265,6 +269,9 @@ type CATSettings struct {
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5) IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200) IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152) IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR)
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
PollMs int `json:"poll_ms"` // poll interval in ms (default 250) PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
DelayMs int `json:"delay_ms"` // pause between commands (default 0) DelayMs int `json:"delay_ms"` // pause between commands (default 0)
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…) DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
@@ -409,6 +416,8 @@ type App struct {
netActive []*qso.QSO // on-air QSO drafts (transient negative ids), check-in order netActive []*qso.QSO // on-air QSO drafts (transient negative ids), check-in order
netSeq int64 // transient-id counter for on-air drafts (decrements: -1, -2, …) netSeq int64 // transient-id counter for on-air drafts (decrements: -1, -2, …)
alertStore *alerts.Store // DX-cluster spot alert rules (global JSON)
cwMu sync.Mutex // guards the CW decoder lifecycle cwMu sync.Mutex // guards the CW decoder lifecycle
cwStop chan struct{} // stops the CW decoder capture loop; nil when off cwStop chan struct{} // stops the CW decoder capture loop; nil when off
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch) cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
@@ -609,6 +618,7 @@ func (a *App) startup(ctx context.Context) {
// to a separate cat.log in the old HamLog folder, which users couldn't find). // to a separate cat.log in the old HamLog folder, which users couldn't find).
cat.LogSink = applog.Printf cat.LogSink = applog.Printf
audio.LogSink = applog.Printf // capture audio-goroutine panics in the app log audio.LogSink = applog.Printf // capture audio-goroutine panics in the app log
extsvc.LogSink = applog.Printf // log raw QRZ (and other) service responses for diagnosis
applog.Printf("startup: data dir = %s", dataDir) applog.Printf("startup: data dir = %s", dataDir)
// The local SQLite file ALWAYS holds per-operator configuration — settings, // The local SQLite file ALWAYS holds per-operator configuration — settings,
// station profiles, rigs/antennas, cluster nodes, UDP, QSL templates, award // station profiles, rigs/antennas, cluster nodes, UDP, QSL templates, award
@@ -757,6 +767,8 @@ func (a *App) startup(ctx context.Context) {
if a.ctx != nil { if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cluster:spot", s) wruntime.EventsEmit(a.ctx, "cluster:spot", s)
} }
// Fire any matching alert rules (sound / visual / e-mail).
a.evaluateAlerts(s)
// Mirror the spot onto the FlexRadio panadapter when enabled. The // Mirror the spot onto the FlexRadio panadapter when enabled. The
// Color is left to the backend default for now — status-based // Color is left to the backend default for now — status-based
// colouring can be filled in here later (new entity / worked / …). // colouring can be filled in here later (new entity / worked / …).
@@ -844,6 +856,13 @@ func (a *App) startup(ctx context.Context) {
a.netStore = ns a.netStore = ns
} }
// DX-cluster spot alert rules (global JSON).
if as, err := alerts.Open(filepath.Join(a.dataDir, "alerts.json")); err != nil {
applog.Printf("alerts: open failed: %v", err)
} else {
a.alertStore = as
}
// Ultrabeam antenna: connect in the background if enabled. // Ultrabeam antenna: connect in the background if enabled.
a.startUltrabeam() a.startUltrabeam()
// Antenna Genius switch: connect in the background if enabled. // Antenna Genius switch: connect in the background if enabled.
@@ -3356,6 +3375,8 @@ type QSLBulkUpdate struct {
SentDate string `json:"sent_date"` // YYYYMMDD — "" = unchanged SentDate string `json:"sent_date"` // YYYYMMDD — "" = unchanged
RcvdDate string `json:"rcvd_date"` // YYYYMMDD — "" = unchanged RcvdDate string `json:"rcvd_date"` // YYYYMMDD — "" = unchanged
Via string `json:"via"` // QSL_VIA — "" = unchanged Via string `json:"via"` // QSL_VIA — "" = unchanged
Notes string `json:"notes"` // NOTES — "" = unchanged
Comment string `json:"comment"` // COMMENT — "" = unchanged
} }
// BulkUpdateQSL applies paper-QSL sent/received status, dates and via to the // BulkUpdateQSL applies paper-QSL sent/received status, dates and via to the
@@ -3388,6 +3409,12 @@ func (a *App) BulkUpdateQSL(ids []int64, u QSLBulkUpdate) (int, error) {
if v := strings.TrimSpace(u.Via); v != "" { if v := strings.TrimSpace(u.Via); v != "" {
q.QSLVia, changed = v, true q.QSLVia, changed = v, true
} }
if v := strings.TrimSpace(u.Notes); v != "" {
q.Notes, changed = v, true
}
if v := strings.TrimSpace(u.Comment); v != "" {
q.Comment, changed = v, true
}
if changed { if changed {
if a.qso.Update(a.ctx, q) == nil { if a.qso.Update(a.ctx, q) == nil {
n++ n++
@@ -3843,7 +3870,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil { if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized") return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
} }
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault) m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
if err != nil { if err != nil {
return CATSettings{}, err return CATSettings{}, err
} }
@@ -3857,6 +3884,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
IcomPort: m[keyCATIcomPort], IcomPort: m[keyCATIcomPort],
IcomBaud: 115200, IcomBaud: 115200,
IcomAddr: 0x98, // IC-7610 default IcomAddr: 0x98, // IC-7610 default
TCIHost: m[keyCATTCIHost],
TCIPort: 40001,
TCISpots: m[keyCATTCISpots] == "1",
PollMs: 250, PollMs: 250,
DelayMs: 0, DelayMs: 0,
DigitalDefault: m[keyCATDigitalDefault], DigitalDefault: m[keyCATDigitalDefault],
@@ -3864,6 +3894,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 { if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
out.FlexPort = n out.FlexPort = n
} }
if n, _ := strconv.Atoi(m[keyCATTCIPort]); n > 0 && n <= 65535 {
out.TCIPort = n
}
if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 { if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 {
out.IcomBaud = n out.IcomBaud = n
} }
@@ -3922,6 +3955,10 @@ func (a *App) SaveCATSettings(s CATSettings) error {
if s.FlexSpots { if s.FlexSpots {
flexSpots = "1" flexSpots = "1"
} }
tciSpots := "0"
if s.TCISpots {
tciSpots = "1"
}
if s.DigitalDefault == "" { if s.DigitalDefault == "" {
s.DigitalDefault = "FT8" s.DigitalDefault = "FT8"
} }
@@ -3935,6 +3972,9 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATIcomPort: strings.TrimSpace(s.IcomPort), keyCATIcomPort: strings.TrimSpace(s.IcomPort),
keyCATIcomBaud: strconv.Itoa(s.IcomBaud), keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
keyCATIcomAddr: strconv.Itoa(s.IcomAddr), keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
keyCATTCIHost: strings.TrimSpace(s.TCIHost),
keyCATTCIPort: strconv.Itoa(s.TCIPort),
keyCATTCISpots: tciSpots,
keyCATPollMs: strconv.Itoa(s.PollMs), keyCATPollMs: strconv.Itoa(s.PollMs),
keyCATDelayMs: strconv.Itoa(s.DelayMs), keyCATDelayMs: strconv.Itoa(s.DelayMs),
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)), keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
@@ -4207,13 +4247,163 @@ func (a *App) saveQSORecording(q *qso.QSO) {
return return
} }
applog.Printf("qso-rec: saved %s", path) applog.Printf("qso-rec: saved %s", path)
// Auto-send the recording once the file exists. // Auto-send the recording once the file exists. Gated ONLY on its own
if es, _ := a.GetEmailSettings(); es.Enabled && es.AutoSend && strings.TrimSpace(qc.Email) != "" { // "auto-send recording" toggle (email.auto_send) — NOT the SMTP panel's
_ = a.sendRecordingEmail(qc, path) // master "Enabled" flag, mirroring the eQSL-card auto-send. (Requiring
// Enabled silently skipped sending with no log when only this box was on.)
// Give the operator visible feedback either way.
if es, _ := a.GetEmailSettings(); es.AutoSend {
if strings.TrimSpace(qc.Email) == "" {
applog.Printf("qso-rec: %s not e-mailed — no recipient address known", qc.Callsign)
a.toast(fmt.Sprintf("Recording saved — no e-mail for %s, not sent", qc.Callsign))
} else if err := a.sendRecordingEmail(qc, path); err != nil {
a.toast(fmt.Sprintf("Recording e-mail to %s failed", qc.Callsign))
} else {
a.toast(fmt.Sprintf("Recording e-mailed to %s", qc.Callsign))
a.markRecordingSent(qc.ID)
}
} }
}() }()
} }
// toast emits a transient message the frontend shows as a toast notification.
// Used for background events (auto-send results) the UI can't otherwise surface.
func (a *App) toast(msg string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "toast", msg)
}
}
// ── DX-cluster spot alerts ─────────────────────────────────────────────────
const keyAlertEmailTo = "alerts.email_to" // where alert e-mails are sent (default: SMTP from)
// ListAlertRules returns every alert rule.
func (a *App) ListAlertRules() []alerts.Rule {
if a.alertStore == nil {
return nil
}
return a.alertStore.List()
}
// SaveAlertRule upserts a rule (empty id → create) and returns it.
func (a *App) SaveAlertRule(r alerts.Rule) (alerts.Rule, error) {
if a.alertStore == nil {
return alerts.Rule{}, fmt.Errorf("alert store unavailable")
}
return a.alertStore.Save(r)
}
// DeleteAlertRule removes a rule by id.
func (a *App) DeleteAlertRule(id string) error {
if a.alertStore == nil {
return fmt.Errorf("alert store unavailable")
}
return a.alertStore.Delete(id)
}
// GetAlertEmailTo returns the address alert e-mails go to (empty = use SMTP from).
func (a *App) GetAlertEmailTo() string {
if a.settings == nil {
return ""
}
v, _ := a.settings.GetGlobal(a.ctx, keyAlertEmailTo)
return v
}
// SetAlertEmailTo stores the alert e-mail recipient.
func (a *App) SetAlertEmailTo(addr string) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
return a.settings.SetGlobal(a.ctx, keyAlertEmailTo, strings.TrimSpace(addr))
}
// evaluateAlerts runs a spot through the rules and fires notifications for every
// match (visual/sound via a frontend event, e-mail via SMTP). Cheap when no
// rules exist; the worked-before check only runs for rules that opt into it and
// already matched, so it's off the hot path.
func (a *App) evaluateAlerts(s cluster.Spot) {
if a.alertStore == nil {
return
}
sp := alerts.Spot{
DXCall: s.DXCall,
Band: s.Band,
Mode: alerts.InferMode(s.Comment, s.FreqHz),
Country: s.Country,
Continent: s.Continent,
Spotter: s.Spotter,
}
// The spotter's country/continent (cty.dat) — resolved lazily for Origin rules.
if a.dxcc != nil && s.Spotter != "" {
if m, ok := a.dxcc.Lookup(s.Spotter); ok && m.Entity != nil {
sp.SpotterCountry = m.Entity.Name
sp.SpotterContinent = m.Continent
}
}
matches := a.alertStore.Evaluate(sp, time.Now(), a.isWorkedBandMode)
for _, mt := range matches {
if a.ctx != nil && (mt.Rule.Visual || mt.Rule.Sound) {
wruntime.EventsEmit(a.ctx, "alert:fired", map[string]any{
"rule": mt.Rule.Name,
"call": s.DXCall,
"band": s.Band,
"mode": sp.Mode,
"freq_hz": s.FreqHz,
"country": s.Country,
"comment": s.Comment,
"sound": mt.Rule.Sound,
"visual": mt.Rule.Visual,
})
}
if mt.Rule.Email {
go a.sendAlertEmail(mt.Rule, s, sp.Mode)
}
}
}
// isWorkedBandMode reports whether the exact callsign is already logged on the
// given band+mode (used by rules with "skip worked before").
func (a *App) isWorkedBandMode(call, band, mode string) bool {
if a.qso == nil || strings.TrimSpace(call) == "" {
return false
}
rows, err := a.qso.List(a.ctx, qso.ListFilter{Callsign: call, Band: band, Mode: mode, Limit: 5})
if err != nil {
return false
}
call = strings.ToUpper(strings.TrimSpace(call))
for _, q := range rows {
if strings.ToUpper(strings.TrimSpace(q.Callsign)) == call {
return true
}
}
return false
}
// sendAlertEmail notifies the operator by e-mail that a wanted station was
// spotted. Best effort; requires SMTP configured (E-mail settings).
func (a *App) sendAlertEmail(r alerts.Rule, s cluster.Spot, mode string) {
es, _ := a.GetEmailSettings()
to := strings.TrimSpace(a.GetAlertEmailTo())
if to == "" {
to = strings.TrimSpace(es.From)
}
if to == "" {
applog.Printf("alert: e-mail skipped (no recipient / SMTP from) for rule %q", r.Name)
return
}
subject := fmt.Sprintf("OpsLog alert: %s spotted on %s", s.DXCall, s.Band)
body := fmt.Sprintf("Rule: %s\n\nCall: %s\nBand: %s\nMode: %s\nFreq: %.1f kHz\nCountry: %s\nSpotter: %s\nComment: %s\n",
r.Name, s.DXCall, s.Band, mode, float64(s.FreqHz)/1000, s.Country, s.Spotter, s.Comment)
if err := email.Send(a.emailConfig(es), to, subject, body, ""); err != nil {
applog.Printf("alert: e-mail to %s failed: %v", to, err)
} else {
applog.Printf("alert: e-mailed %s (rule %q) to %s", s.DXCall, r.Name, to)
}
}
// sanitizeFilename makes a callsign safe for a filename (slashes etc.). // sanitizeFilename makes a callsign safe for a filename (slashes etc.).
func sanitizeFilename(s string) string { func sanitizeFilename(s string) string {
s = strings.ToUpper(strings.TrimSpace(s)) s = strings.ToUpper(strings.TrimSpace(s))
@@ -4687,6 +4877,28 @@ func (a *App) fillTemplate(tmpl string, q qso.QSO) string {
return r.Replace(tmpl) return r.Replace(tmpl)
} }
// markRecordingSent stamps the QSO so the UI (and the operator) knows its audio
// recording was e-mailed — exposed as the APP_OPSLOG_RECORDING_SENT extra and a
// "Recording sent" column in Recent QSOs. Re-reads the row first so a concurrent
// status update (eQSL/upload) isn't clobbered by writing back the whole row.
func (a *App) markRecordingSent(id int64) {
if a.qso == nil || id == 0 {
return
}
q, err := a.qso.GetByID(a.ctx, id)
if err != nil {
applog.Printf("qso-rec: mark sent: load %d: %v", id, err)
return
}
if q.Extras == nil {
q.Extras = map[string]string{}
}
q.Extras["APP_OPSLOG_RECORDING_SENT"] = time.Now().UTC().Format("2006-01-02")
if err := a.qso.Update(a.ctx, q); err != nil {
applog.Printf("qso-rec: mark sent: update %d: %v", id, err)
}
}
// sendRecordingEmail e-mails a QSO recording to the contacted operator. // sendRecordingEmail e-mails a QSO recording to the contacted operator.
func (a *App) sendRecordingEmail(q qso.QSO, attachPath string) error { func (a *App) sendRecordingEmail(q qso.QSO, attachPath string) error {
s, _ := a.GetEmailSettings() s, _ := a.GetEmailSettings()
@@ -4732,7 +4944,11 @@ func (a *App) SendQSORecordingEmail(id int64) error {
if _, e := os.Stat(path); e != nil { if _, e := os.Stat(path); e != nil {
return fmt.Errorf("recording file missing: %s", name) return fmt.Errorf("recording file missing: %s", name)
} }
return a.sendRecordingEmail(q, path) if err := a.sendRecordingEmail(q, path); err != nil {
return err
}
a.markRecordingSent(id)
return nil
} }
// ── ClubLog Country File (cty.xml) exceptions ───────────────────────── // ── ClubLog Country File (cty.xml) exceptions ─────────────────────────
@@ -5626,7 +5842,9 @@ func uploadColumnFor(service string) string {
// FindQSOsForUpload returns QSOs whose sent status for the given service // FindQSOsForUpload returns QSOs whose sent status for the given service
// matches sentStatus ("" = blank). Powers the QSL Manager's Select required. // matches sentStatus ("" = blank). Powers the QSL Manager's Select required.
func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.UploadRow, error) { // FindQSOsForUpload returns FULL QSO rows eligible for upload to a service, so
// the QSL Manager shows the same rich, column-pickable table as Recent QSOs.
func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.QSO, error) {
if a.qso == nil { if a.qso == nil {
return nil, fmt.Errorf("db not initialized") return nil, fmt.Errorf("db not initialized")
} }
@@ -5634,7 +5852,7 @@ func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.UploadRow, er
if col == "" { if col == "" {
return nil, fmt.Errorf("unknown service %q", service) return nil, fmt.Errorf("unknown service %q", service)
} }
return a.qso.ListForUpload(a.ctx, col, strings.ToUpper(strings.TrimSpace(sentStatus))) return a.qso.ListForUploadFull(a.ctx, col, strings.ToUpper(strings.TrimSpace(sentStatus)))
} }
// UploadQSOsManual uploads the given QSO ids to a service on demand // UploadQSOsManual uploads the given QSO ids to a service on demand
@@ -7363,6 +7581,97 @@ func (a *App) FlexSetAudioLevel(l int) error {
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAudioLevel(l) }) return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAudioLevel(l) })
} }
func (a *App) FlexSetMute(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMute(on) })
}
func (a *App) FlexSetRXAntenna(ant string) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRXAntenna(ant) })
}
func (a *App) FlexSetTXAntenna(ant string) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXAntenna(ant) })
}
func (a *App) FlexSetSplit(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSplit(on) })
}
// keyFlexBandAnt stores the per-band RX/TX antenna map (global, machine-local).
const keyFlexBandAnt = "flex.band_antennas"
// FlexBandAnt is the antenna pair to select on a given band.
type FlexBandAnt struct {
RX string `json:"rx"`
TX string `json:"tx"`
}
// GetFlexBandAntennas returns the band→{rx,tx} antenna map (band key uppercased,
// e.g. "20M").
func (a *App) GetFlexBandAntennas() (map[string]FlexBandAnt, error) {
out := map[string]FlexBandAnt{}
if a.settings == nil {
return out, nil
}
v, _ := a.settings.GetGlobal(a.ctx, keyFlexBandAnt)
if strings.TrimSpace(v) == "" {
return out, nil
}
_ = json.Unmarshal([]byte(v), &out)
return out, nil
}
// SaveFlexBandAntennas persists the band→antenna map.
func (a *App) SaveFlexBandAntennas(m map[string]FlexBandAnt) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
b, err := json.Marshal(m)
if err != nil {
return err
}
return a.settings.SetGlobal(a.ctx, keyFlexBandAnt, string(b))
}
// FlexApplyBandAntenna selects the configured RX/TX antennas for the given band
// (no-op if the backend isn't a Flex or the band has no mapping). Called on band
// changes and spot clicks so the right antennas follow the frequency.
func (a *App) FlexApplyBandAntenna(band string) error {
if a.cat == nil {
return nil
}
band = strings.ToUpper(strings.TrimSpace(band))
if band == "" {
return nil
}
m, _ := a.GetFlexBandAntennas()
ant, ok := m[band]
if !ok {
return nil
}
return a.cat.FlexDo(func(fc cat.FlexController) error {
if strings.TrimSpace(ant.RX) != "" {
_ = fc.SetRXAntenna(ant.RX)
}
if strings.TrimSpace(ant.TX) != "" {
_ = fc.SetTXAntenna(ant.TX)
}
return nil
})
}
func (a *App) FlexSetNB(on bool) error { func (a *App) FlexSetNB(on bool) error {
if a.cat == nil { if a.cat == nil {
return fmt.Errorf("cat not initialized") return fmt.Errorf("cat not initialized")
@@ -7489,7 +7798,7 @@ func (a *App) reloadCAT() {
} }
a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond) a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond)
a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond) a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond)
a.catFlexSpots = s.Enabled && s.Backend == "flex" && s.FlexSpots a.catFlexSpots = s.Enabled && ((s.Backend == "flex" && s.FlexSpots) || (s.Backend == "tci" && s.TCISpots))
if !s.Enabled { if !s.Enabled {
a.cat.Stop() a.cat.Stop()
return return
@@ -7516,6 +7825,10 @@ func (a *App) reloadCAT() {
// Native Icom CI-V over the radio's USB serial port (local control). // Native Icom CI-V over the radio's USB serial port (local control).
// Same civ protocol a future network backend will reuse for remote. // Same civ protocol a future network backend will reuse for remote.
a.cat.Start(cat.NewIcomSerial(s.IcomPort, s.IcomBaud, s.IcomAddr, s.DigitalDefault)) a.cat.Start(cat.NewIcomSerial(s.IcomPort, s.IcomBaud, s.IcomAddr, s.DigitalDefault))
case "tci":
// Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any
// TCI-compatible server.
a.cat.Start(cat.NewTCI(s.TCIHost, s.TCIPort, s.DigitalDefault, s.TCISpots))
default: default:
// Unknown backend → stop and emit a dummy state so the UI shows it. // Unknown backend → stop and emit a dummy state so the UI shows it.
a.cat.Stop() a.cat.Stop()
+193 -31
View File
@@ -13,7 +13,7 @@ import {
GetStartupStatus, CheckForUpdate, GetStartupStatus, CheckForUpdate,
WorkedBefore, WorkedBefore,
SetCompactMode, SetCompactMode,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
GetSecretStatus, UnlockSecrets, GetSecretStatus, UnlockSecrets,
RefreshCtyDat, DownloadAllReferenceLists, RefreshCtyDat, DownloadAllReferenceLists,
RotatorGoTo, RotatorStop, GetRotatorHeading, RotatorGoTo, RotatorStop, GetRotatorHeading,
@@ -67,6 +67,7 @@ import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot'; import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
import { NetControlPanel } from '@/components/NetControlPanel'; import { NetControlPanel } from '@/components/NetControlPanel';
import { AlertsModal } from '@/components/AlertsModal';
import { BulkEditModal } from '@/components/BulkEditModal'; import { BulkEditModal } from '@/components/BulkEditModal';
import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover'; import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover';
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel'; import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
@@ -117,7 +118,7 @@ const emptyDetails: DetailsState = {
prop_mode: '', my_rig: '', my_antenna: '', prop_mode: '', my_rig: '', my_antenna: '',
tx_pwr: undefined, tx_pwr: undefined,
sat_name: '', sat_mode: '', sat_name: '', sat_mode: '',
contest_id: '', srx: undefined, stx: undefined, contest_id: '', srx_string: '', stx_string: '',
email: '', email: '',
award_refs: '', award_refs: '',
}; };
@@ -206,6 +207,24 @@ function rstCategory(mode: string): keyof RSTLists {
if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw'; if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw';
return 'phone'; return 'phone';
} }
// estimateCwMs roughly estimates how long the resolved text takes to send in CW
// at wpm: ~10 dot-units per character + ~4 extra per word gap, 1 unit = 1200/wpm
// ms (PARIS standard). Used to cap waits on the keyer's "busy" status, which can
// lag by tens of seconds over a remote/serial-over-IP link.
function estimateCwMs(resolved: string, wpm: number): number {
const w = Math.max(5, wpm || 25);
const chars = resolved.replace(/\s/g, '').length;
const spaces = (resolved.match(/\s/g) || []).length;
return ((chars * 10 + spaces * 4) * 1200) / w;
}
// exchangeFields splits a contest exchange the operator typed as free text into
// the ADIF numeric field (SRX/STX, when it's all digits) or the string field
// (SRX_STRING/STX_STRING, when it's alphanumeric like a section/zone).
function exchangeFields(raw?: string): { num?: number; str: string } {
const t = (raw || '').trim();
if (t === '') return { str: '' };
return /^\d+$/.test(t) ? { num: parseInt(t, 10), str: '' } : { str: t };
}
// rstOptions returns the valid report choices for a mode from the user's // rstOptions returns the valid report choices for a mode from the user's
// editable lists (Settings → Modes), with a tiny fallback before they load. // editable lists (Settings → Modes), with a tiny fallback before they load.
function rstOptions(mode: string, lists: RSTLists): string[] { function rstOptions(mode: string, lists: RSTLists): string[] {
@@ -403,10 +422,12 @@ export default function App() {
// User changed band/mode in the entry strip → push to the rig if CAT is up. // User changed band/mode in the entry strip → push to the rig if CAT is up.
// Both calls are fire-and-forget; CAT will reflect back via cat:state. // Both calls are fire-and-forget; CAT will reflect back via cat:state.
// When the field is LOCKED (🔒, e.g. logging an old QSO off-frequency), we do
// NOT drive the radio — the lock means "this value is decoupled from the rig".
function onBandUserChange(v: string) { function onBandUserChange(v: string) {
setBand(v); setBand(v);
noteManualEdit(); noteManualEdit();
if (catState.enabled && catState.connected) { if (catState.enabled && catState.connected && !locks.band && !locks.freq) {
const hz = qsyFreqHz(v, mode); const hz = qsyFreqHz(v, mode);
if (hz > 0) SetCATFrequency(hz).catch(() => {}); if (hz > 0) SetCATFrequency(hz).catch(() => {});
} }
@@ -415,7 +436,7 @@ export default function App() {
setMode(v); setMode(v);
applyModePreset(v); applyModePreset(v);
noteManualEdit(); noteManualEdit();
if (catState.enabled && catState.connected) { if (catState.enabled && catState.connected && !locks.mode) {
SetCATMode(v).catch(() => {}); SetCATMode(v).catch(() => {});
} }
} }
@@ -470,11 +491,25 @@ export default function App() {
}; };
// Transient success toast (bottom-right, auto-dismiss). Used for things // Transient success toast (bottom-right, auto-dismiss). Used for things
// like "spot sent" where a blocking error banner would be overkill. // like "spot sent" where a blocking error banner would be overkill.
// Toasts are QUEUED so two firing at once don't clobber each other: each shows
// for 3s, then the next takes over (no gap). `toast` is the one on screen now.
const [toast, setToast] = useState(''); const [toast, setToast] = useState('');
const showToast = useCallback((msg: string) => { const toastQueue = useRef<string[]>([]);
setToast(msg); const toastTimer = useRef<number | undefined>(undefined);
window.setTimeout(() => setToast((t) => (t === msg ? '' : t)), 3500); const advanceToast = useCallback(() => {
const next = toastQueue.current.shift();
if (next === undefined) { toastTimer.current = undefined; setToast(''); return; }
setToast(next);
toastTimer.current = window.setTimeout(advanceToast, 3000);
}, []); }, []);
const showToast = useCallback((msg: string) => {
toastQueue.current.push(msg);
if (toastTimer.current === undefined) advanceToast(); // nothing showing → start now
}, [advanceToast]);
const dismissToast = useCallback(() => {
if (toastTimer.current !== undefined) { window.clearTimeout(toastTimer.current); toastTimer.current = undefined; }
advanceToast(); // skip to the next queued toast (or clear if none)
}, [advanceToast]);
// Error banners auto-dismiss after a few seconds (longer than toasts since // Error banners auto-dismiss after a few seconds (longer than toasts since
// they may be multi-line). The X button still closes them immediately. // they may be multi-line). The X button still closes them immediately.
useEffect(() => { useEffect(() => {
@@ -696,6 +731,8 @@ export default function App() {
} }
const chatShown = chatOpen && chatAvailable; const chatShown = chatOpen && chatAvailable;
const [alertsOpen, setAlertsOpen] = useState(false); // Alert management modal
// NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster). // NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster).
const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1'); const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1');
useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]); useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]);
@@ -783,11 +820,12 @@ export default function App() {
// map ("map1"), the locator street map ("map2"), the cluster grid or the // map ("map1"), the locator street map ("map2"), the cluster grid or the
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed), // worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
// so it's loaded async on mount and re-read on profile:changed below. // so it's loaded async on mount and re-read on profile:changed below.
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex'; type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent';
const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1'); const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2'); const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
const loadMainPanes = useCallback(async () => { const loadMainPanes = useCallback(async () => {
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex'; const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent';
const [l, r] = await Promise.all([ const [l, r] = await Promise.all([
GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneLeft').catch(() => ''),
GetUIPref('mainPaneRight').catch(() => ''), GetUIPref('mainPaneRight').catch(() => ''),
@@ -1059,6 +1097,41 @@ export default function App() {
return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); }; return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
}, [refresh]); }, [refresh]);
// Backend-emitted toast messages (e.g. recording auto-send result/skip).
useEffect(() => {
const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); });
return () => { off(); };
}, [showToast]);
// DX-cluster spot alerts: a matched rule fires here. Play a beep (WebAudio, no
// asset needed — CSP-safe) and/or show a toast, per the rule's chosen actions.
useEffect(() => {
const off = EventsOn('alert:fired', (p: any) => {
if (p?.sound) {
try {
const AC = (window as any).AudioContext || (window as any).webkitAudioContext;
const ctx = new AC();
const beep = (freq: number, at: number, dur: number) => {
const o = ctx.createOscillator(); const g = ctx.createGain();
o.type = 'sine'; o.frequency.value = freq;
o.connect(g); g.connect(ctx.destination);
g.gain.setValueAtTime(0.0001, ctx.currentTime + at);
g.gain.exponentialRampToValueAtTime(0.25, ctx.currentTime + at + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + at + dur);
o.start(ctx.currentTime + at); o.stop(ctx.currentTime + at + dur + 0.02);
};
beep(880, 0, 0.14); beep(1320, 0.16, 0.18); // two-tone chirp
window.setTimeout(() => ctx.close().catch(() => {}), 600);
} catch { /* audio blocked — ignore */ }
}
if (p?.visual) {
const call = String(p?.call ?? ''); const band = String(p?.band ?? ''); const rule = String(p?.rule ?? 'alert');
showToast(`🔔 ${rule}: ${call}${band ? ` on ${band}` : ''}${p?.country ? `${p.country}` : ''}`);
}
});
return () => { off(); };
}, [showToast]);
// Poll PstRotator for the live antenna heading (status bar). Cheap when the // Poll PstRotator for the live antenna heading (status bar). Cheap when the
// rotator is disabled (the backend just reads settings and returns). // rotator is disabled (the backend just reads settings and returns).
useEffect(() => { useEffect(() => {
@@ -1246,12 +1319,20 @@ export default function App() {
// source behaves identically. // source behaves identically.
function handleSpotClick(s: any) { function handleSpotClick(s: any) {
const m = inferSpotMode(s.comment ?? '', s.freq_hz); const m = inferSpotMode(s.comment ?? '', s.freq_hz);
if (catState.connected) { // Reflect the spot's freq/band in the entry strip IMMEDIATELY (optimistic),
tuneRigCAT(s.freq_hz, m); // then tune the rig if CAT is connected. We must not wait for the CAT status
} else { // echo to update the display: on a remote Flex link that echo lags by seconds,
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5)); // so the frequency used to only catch up once you nudged the VFO. noteManualEdit()
if (s.band) setBand(s.band); // freezes the CAT-driven overwrite for 1.5s so a stale in-flight status can't
// revert the optimistic value before the new freq is reported back.
if (s.freq_hz && s.freq_hz > 0) {
const mhz = (s.freq_hz / 1_000_000).toFixed(5);
setFreqMhz(mhz);
setRxFreqMhz(mhz);
noteManualEdit();
} }
if (s.band) setBand(s.band);
if (catState.connected) tuneRigCAT(s.freq_hz, m);
if (m) applyModeFromSpot(m); if (m) applyModeFromSpot(m);
onCallsignInput(s.dx_call, { force: true }); onCallsignInput(s.dx_call, { force: true });
applySpotPOTA((s as any).pota_ref); applySpotPOTA((s as any).pota_ref);
@@ -1383,6 +1464,19 @@ export default function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// FlexRadio: apply the per-band RX/TX antennas whenever the band changes (rig
// QSY, spot click, manual band) — so the right antennas follow the frequency.
// Skipped while the band is LOCKED (entering an old QSO off-air) and for non-Flex
// backends. The backend no-ops if the band has no configured mapping.
const lastAntBandRef = useRef('');
useEffect(() => {
if (catState.backend !== 'flex' || locks.band) return;
const b = band.trim();
if (!b || b === lastAntBandRef.current) return;
lastAntBandRef.current = b;
FlexApplyBandAntenna(b).catch(() => {});
}, [band, catState.backend, locks.band]);
// Cluster live wiring: hydrate per-server status + saved server list, // Cluster live wiring: hydrate per-server status + saved server list,
// then subscribe to push events. // then subscribe to push events.
async function reloadClusterMeta() { async function reloadClusterMeta() {
@@ -1441,7 +1535,11 @@ export default function App() {
// typing: only update when the field is empty, already shows this call, or // typing: only update when the field is empty, already shows this call, or
// still shows the previous broadcast (i.e. the field content is ours, not // still shows the previous broadcast (i.e. the field content is ours, not
// a different call the user typed). Returns true if it actually changed. // a different call the user typed). Returns true if it actually changed.
const applyUdpCall = (raw: string): boolean => { // force = true means an EXPLICIT action (a panadapter/spot click, a remote
// "set call" command) that should always win, even over a call the operator
// already typed. Only WSJT-X's CONTINUOUS DX-call stream keeps the no-clobber
// guard (it re-broadcasts constantly and must not fight what you typed).
const applyUdpCall = (raw: string, force = false): boolean => {
const call = String(raw ?? '').trim(); const call = String(raw ?? '').trim();
if (!call) return false; if (!call) return false;
const upper = call.toUpperCase(); const upper = call.toUpperCase();
@@ -1449,22 +1547,26 @@ export default function App() {
const prev = lastUdpCallRef.current; const prev = lastUdpCallRef.current;
lastUdpCallRef.current = upper; // remember this broadcast either way lastUdpCallRef.current = upper; // remember this broadcast either way
if (current === upper) return false; // already shown → no-op if (current === upper) return false; // already shown → no-op
if (current !== '' && current !== prev) return false; // user typed a different call → leave it if (!force && current !== '' && current !== prev) return false; // user typed a different call → leave it
onCallsignInput(call, { force: true }); // programmatic → always look up onCallsignInput(call, { force: true }); // programmatic → always look up
return true; return true;
}; };
const unsubDX = EventsOn('udp:dx_call', (p: any) => { const unsubDX = EventsOn('udp:dx_call', (p: any) => {
// Anything that isn't WSJT-X (N1MM, ADIF, a panadapter/cluster click relayed
// over UDP…) is an explicit pick → force it over an existing call.
const force = String(p?.service ?? '').toLowerCase() !== 'wsjt';
// External app moved to a new station → fresh recording for the new target. // External app moved to a new station → fresh recording for the new target.
if (applyUdpCall(p?.call)) restartRecordingForNewTarget(String(p?.call ?? '')); if (applyUdpCall(p?.call, force)) restartRecordingForNewTarget(String(p?.call ?? ''));
}); });
const unsubRC = EventsOn('udp:remote_call', (raw: string) => { const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
if (applyUdpCall(raw)) restartRecordingForNewTarget(String(raw ?? '')); if (applyUdpCall(raw, true)) restartRecordingForNewTarget(String(raw ?? '')); // explicit remote pick
}); });
// Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call // Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call
// (the radio already tuned via trigger_action=Tune, and CAT reads the freq). // (the radio already tuned via trigger_action=Tune, and CAT reads the freq).
// An explicit click always wins over whatever call is currently in the field.
const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => { const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => {
const call = String(p?.call ?? ''); const call = String(p?.call ?? '');
if (applyUdpCall(call)) restartRecordingForNewTarget(call); if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
}); });
const unsubProg = EventsOn('import:progress', (p: any) => { const unsubProg = EventsOn('import:progress', (p: any) => {
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) }); setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
@@ -1561,7 +1663,7 @@ export default function App() {
GRID: station.my_grid || '', COUNTRY: (station as any).my_country || '', GRID: station.my_grid || '', COUNTRY: (station as any).my_country || '',
MY_QTH: (station as any).my_city || '', MY_RIG: details.my_rig || '', MY_ANTENNA: details.my_antenna || '', MY_QTH: (station as any).my_city || '', MY_RIG: details.my_rig || '', MY_ANTENNA: details.my_antenna || '',
MY_IOTA: (station as any).my_iota || '', MY_SOTA: (station as any).my_sota_ref || '', MY_IOTA: (station as any).my_iota || '', MY_SOTA: (station as any).my_sota_ref || '',
CONT_RX: details.srx != null ? String(details.srx) : '', CONT_TX: details.stx != null ? String(details.stx) : '', CONT_RX: details.srx_string || '', CONT_TX: details.stx_string || '',
}; };
let out = text.replace(/<([A-Z_]+)>/g, (_m, k) => vars[k] ?? ''); let out = text.replace(/<([A-Z_]+)>/g, (_m, k) => vars[k] ?? '');
out = out.replace(/\*/g, myCall).replace(/!/g, his); out = out.replace(/\*/g, myCall).replace(/!/g, his);
@@ -1576,15 +1678,22 @@ export default function App() {
} }
async function wkSend(rawText: string) { async function wkSend(rawText: string) {
setWkSent(''); setWkSent('');
const resolved = resolveCW(rawText);
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "") const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
await WinkeyerSend(resolveCW(rawText)).catch((e) => setError(String(e?.message ?? e))); await WinkeyerSend(resolved).catch((e) => setError(String(e?.message ?? e)));
// <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has // <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has
// finished sending — wait for the busy flag to rise then fall, so the QSO // finished sending — so the QSO isn't logged (and the form cleared) while CW
// isn't logged (and the form cleared) while the CW is still going out. // is still going out. We'd like to wait for the busy flag to rise then fall,
// but over a remote/serial-over-IP link that status echo can lag by tens of
// seconds, which used to delay logging ~50s. So cap the wait at the ESTIMATED
// send duration (text length × WPM) plus slack: log when busy clears OR the
// estimate elapses, whichever comes first.
if (!doLog) return; if (!doLog) return;
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms)); const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500; // slack for keyer buffering/lag
for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start
for (let i = 0; i < 1200 && wkBusyRef.current; i++) await sleep(50); // ≤60s for it to finish const deadline = Date.now() + capMs;
while (wkBusyRef.current && Date.now() < deadline) await sleep(50);
void save(); void save();
} }
// stopAutoCall cancels any running auto-call loop. // stopAutoCall cancels any running auto-call loop.
@@ -1599,8 +1708,14 @@ export default function App() {
const m = wkMacros[i]; const m = wkMacros[i];
if (!m) break; if (!m) break;
await wkSend(m.text); await wkSend(m.text);
// Wait for the message to finish before the gap+resend. Cap the wait at the
// ESTIMATED send time (not the busy flag alone): over a remote/serial-over-IP
// link the keyer's "busy" status lags badly and stays stuck true for tens of
// seconds, which made the next CQ fire ~120s late — i.e. auto-call looked dead.
const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500;
for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start
for (let k = 0; k < 2400 && wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤120s to finish const deadline = Date.now() + capMs;
while (wkBusyRef.current && gen === autoCallGenRef.current && Date.now() < deadline) await sleep(50);
if (gen !== autoCallGenRef.current) break; if (gen !== autoCallGenRef.current) break;
await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call
} }
@@ -1691,6 +1806,10 @@ export default function App() {
// when you first entered the call (minutes early) and won't match LoTW. // when you first entered the call (minutes early) and won't match LoTW.
const startEqualsEnd = localStorage.getItem('opslog.startEqualsEnd') === '1'; const startEqualsEnd = localStorage.getItem('opslog.startEqualsEnd') === '1';
const start = (startEqualsEnd && !locks.start) ? end : baseStart; const start = (startEqualsEnd && !locks.start) ? end : baseStart;
// Contest exchanges: a purely-numeric exchange → ADIF SRX/STX (int); an
// alphanumeric one (e.g. a section/zone) → SRX_STRING/STX_STRING.
const srxE = exchangeFields(details.srx_string);
const stxE = exchangeFields(details.stx_string);
const payload: any = { const payload: any = {
callsign: callsign.trim().toUpperCase(), callsign: callsign.trim().toUpperCase(),
qso_date: start.toISOString(), qso_date: start.toISOString(),
@@ -1709,7 +1828,7 @@ export default function App() {
tx_pwr: details.tx_pwr, tx_pwr: details.tx_pwr,
sat_name: details.sat_name, sat_mode: details.sat_mode, sat_name: details.sat_name, sat_mode: details.sat_mode,
contest_id: details.contest_id, contest_id: details.contest_id,
srx: details.srx, stx: details.stx, srx: srxE.num, stx: stxE.num, srx_string: srxE.str, stx_string: stxE.str,
email: details.email, email: details.email,
}; };
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current); applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
@@ -1905,6 +2024,11 @@ export default function App() {
setLookupBusy(true); setLookupBusy(true);
try { try {
const r = await LookupCallsign(call); const r = await LookupCallsign(call);
// Discard a STALE result: the operator already moved to another call
// (clicked a new spot / typed) while this lookup was in flight. Applying it
// would clobber the current call's fields and zoom the map to the wrong
// station — the bug where replacing a call didn't re-zoom the map.
if (call !== callsignValRef.current.trim().toUpperCase()) return;
lastLookedUpRef.current = call; lastLookedUpRef.current = call;
// cty.dat carries ONLY DXCC-entity data (country / CQ / ITU zones / continent). // cty.dat carries ONLY DXCC-entity data (country / CQ / ITU zones / continent).
// A QRZ/HamQTH hit is far richer (name, QTH, grid, address, image). When the // A QRZ/HamQTH hit is far richer (name, QTH, grid, address, image). When the
@@ -1942,6 +2066,9 @@ export default function App() {
qsl_via: d.qsl_via || (r.qsl_via ?? ''), qsl_via: d.qsl_via || (r.qsl_via ?? ''),
})); }));
if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc); if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc);
// The DX location is now known (grid set above) — force the world map to
// auto-zoom right away, so it doesn't lag behind the resolved QSO.
setMapZoomSignal((n) => n + 1);
// Recording: tie it to the resolved callsign. Start once a real (≥3-char) // Recording: tie it to the resolved callsign. Start once a real (≥3-char)
// call resolves — covers the fast CW workflow (type → Enter, no blur). If // call resolves — covers the fast CW workflow (type → Enter, no blur). If
// we're already recording a DIFFERENT call (the operator edited the // we're already recording a DIFFERENT call (the operator edited the
@@ -1992,8 +2119,14 @@ export default function App() {
// applyUdpCall saw current != lastUdpCall and refused every later UDP call. // applyUdpCall saw current != lastUdpCall and refused every later UDP call.
if (opts?.force) lastUdpCallRef.current = v.trim().toUpperCase(); if (opts?.force) lastUdpCallRef.current = v.trim().toUpperCase();
// A callsign appeared (someone answered the CQ, or a spot was clicked) → // A callsign appeared (someone answered the CQ, or a spot was clicked) →
// stop auto-calling so we don't key over the contact. // stop auto-calling so we don't key over the contact. If a CQ was actually
if (v.trim() !== '') stopAutoCall(); // in flight, abort the current transmission too so it stops IMMEDIATELY
// rather than finishing the buffered call. (autoCallMacroRef flips to -1 on
// the first keystroke, so we only abort once.)
if (v.trim() !== '') {
if (autoCallMacroRef.current !== -1) WinkeyerStop().catch(() => {});
stopAutoCall();
}
// No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call // No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
// on every status packet. If it matches what's already in the entry, // on every status packet. If it matches what's already in the entry,
// do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and // do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and
@@ -2108,6 +2241,7 @@ export default function App() {
{ type: 'item', label: cwEnabled ? '✓ CW decoder (RX audio)' : 'CW decoder (RX audio)', action: 'tools.cwdecoder' }, { type: 'item', label: cwEnabled ? '✓ CW decoder (RX audio)' : 'CW decoder (RX audio)', action: 'tools.cwdecoder' },
{ type: 'separator' }, { type: 'separator' },
{ type: 'item', label: netEnabled ? '✓ NET Control' : 'NET Control', action: 'tools.net' }, { type: 'item', label: netEnabled ? '✓ NET Control' : 'NET Control', action: 'tools.net' },
{ type: 'item', label: 'Alert management…', action: 'tools.alerts' },
{ type: 'separator' }, { type: 'separator' },
// Maintenance — bumped here while we only have one entry. Will move // Maintenance — bumped here while we only have one entry. Will move
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land. // to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
@@ -2136,6 +2270,7 @@ export default function App() {
case 'tools.dvk': setDvkEnabled((v) => !v); break; case 'tools.dvk': setDvkEnabled((v) => !v); break;
case 'tools.cwdecoder': toggleCwDecoder(); break; case 'tools.cwdecoder': toggleCwDecoder(); break;
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break; case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
case 'tools.alerts': setAlertsOpen(true); break;
case 'tools.refreshCty': refreshCtyDat(); break; case 'tools.refreshCty': refreshCtyDat(); break;
case 'tools.downloadRefs': downloadRefs(); break; case 'tools.downloadRefs': downloadRefs(); break;
case 'help.about': setShowAbout(true); break; case 'help.about': setShowAbout(true); break;
@@ -2731,6 +2866,7 @@ export default function App() {
toLabel={callsign} toLabel={callsign}
beamAzimuths={showBeamOnMap ? beamHeadings : []} beamAzimuths={showBeamOnMap ? beamHeadings : []}
boomAzimuth={showBeamOnMap && ubPattern && ubPattern !== 'normal' ? boomHeading : null} boomAzimuth={showBeamOnMap && ubPattern && ubPattern !== 'normal' ? boomHeading : null}
zoomSignal={mapZoomSignal}
/> />
); );
case 'map2': case 'map2':
@@ -2761,7 +2897,29 @@ export default function App() {
case 'flex': case 'flex':
return ( return (
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border"> <div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
<FlexPanel /> <FlexPanel onCWSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }} />
</div>
);
case 'recent':
return (
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
<RecentQSOsGrid
rows={qsosWithAwards as any}
total={total}
awardCols={awardCols}
onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty}
onUpdateFromQRZ={bulkUpdateFromQRZ}
onUpdateFromClublog={bulkUpdateFromClublog}
onSendTo={bulkSendTo}
onSendRecording={bulkSendRecording}
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
onBulkEdit={openBulkEdit}
onExportSelected={exportSelectedADIF}
onExportFiltered={exportFilteredADIF}
onDelete={(ids) => setDeletingIds(ids)}
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
/>
</div> </div>
); );
} }
@@ -2819,7 +2977,7 @@ export default function App() {
<div className="flex items-center gap-1.5 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in"> <div className="flex items-center gap-1.5 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
<Satellite className="size-3.5 shrink-0" /> <Satellite className="size-3.5 shrink-0" />
<span className="truncate" title={toast}>{toast}</span> <span className="truncate" title={toast}>{toast}</span>
<button className="shrink-0 text-emerald-600 hover:text-emerald-800" onClick={() => setToast('')}><X className="size-3" /></button> <button className="shrink-0 text-emerald-600 hover:text-emerald-800" onClick={dismissToast}><X className="size-3" /></button>
</div> </div>
)} )}
</div> </div>
@@ -3794,7 +3952,7 @@ export default function App() {
backend is a FlexRadio. */} backend is a FlexRadio. */}
{catState.backend === 'flex' && ( {catState.backend === 'flex' && (
<TabsContent value="flex" className="flex-1 min-h-0 p-0"> <TabsContent value="flex" className="flex-1 min-h-0 p-0">
<FlexPanel /> <FlexPanel onCWSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }} />
</TabsContent> </TabsContent>
)} )}
@@ -3977,6 +4135,10 @@ export default function App() {
/> />
)} )}
{alertsOpen && (
<AlertsModal onClose={() => setAlertsOpen(false)} bands={bands} modes={modes} countries={countries} />
)}
<AutoEQSL <AutoEQSL
onSent={(call) => showToast(`OpsLog QSL sent to ${call}`)} onSent={(call) => showToast(`OpsLog QSL sent to ${call}`)}
onError={(msg) => showToast(msg)} onError={(msg) => showToast(msg)}
+256
View File
@@ -0,0 +1,256 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Bell, Plus, Trash2, Volume2, Mail, Eye, X, Search } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import {
ListAlertRules, SaveAlertRule, DeleteAlertRule, GetAlertEmailTo, SetAlertEmailTo,
} from '@/../wailsjs/go/main/App';
import { alerts } from '@/../wailsjs/go/models';
type Rule = alerts.Rule;
const CONTINENTS = ['AF', 'AN', 'AS', 'EU', 'NA', 'OC', 'SA'];
function emptyRule(): Rule {
return alerts.Rule.createFrom({
id: '', name: 'New alert', enabled: true,
calls: [], countries: [], continents: [], bands: [], modes: [],
spotter_call: '', spotter_continents: [], spotter_countries: [],
sound: true, visual: true, email: false, again_after_min: 0, skip_worked: false,
});
}
// MultiCheck — a scrollable checkbox list for a set of options with an optional
// search box (used for the long country list). Empty selection = ALL.
function MultiCheck({ options, selected, onToggle, searchable, height = 'h-52' }: {
options: string[]; selected: string[]; onToggle: (v: string) => void; searchable?: boolean; height?: string;
}) {
const [q, setQ] = useState('');
const shown = useMemo(
() => (q ? options.filter((o) => o.toLowerCase().includes(q.toLowerCase())) : options),
[options, q],
);
const sel = new Set((selected ?? []).map((s) => s.toLowerCase()));
return (
<div className="rounded-md border border-border">
{searchable && (
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border/60">
<Search className="size-3 text-muted-foreground" />
<input className="flex-1 bg-transparent text-xs outline-none" placeholder="Filter…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
)}
<div className={cn('overflow-y-auto p-1', height)}>
{shown.map((o) => (
<label key={o} className="flex items-center gap-2 text-xs px-1.5 py-0.5 rounded hover:bg-accent/40 cursor-pointer">
<Checkbox checked={sel.has(o.toLowerCase())} onCheckedChange={() => onToggle(o)} />
<span className="truncate">{o}</span>
</label>
))}
{shown.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-3 text-center">no match</div>}
</div>
<div className="px-2 py-1 border-t border-border/60 text-[10px] text-muted-foreground">
{(selected?.length ?? 0) === 0 ? 'none selected = ALL' : `${selected!.length} selected`}
</div>
</div>
);
}
export function AlertsModal({ onClose, bands, modes, countries }: {
onClose: () => void; bands: string[]; modes: string[]; countries: string[];
}) {
const [rules, setRules] = useState<Rule[]>([]);
const [draft, setDraft] = useState<Rule | null>(null);
const [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select)
const [emailTo, setEmailTo] = useState('');
const [err, setErr] = useState('');
const refresh = useCallback(async () => {
try { setRules(((await ListAlertRules()) ?? []) as Rule[]); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}, []);
useEffect(() => { refresh(); GetAlertEmailTo().then((v) => setEmailTo(v || '')).catch(() => {}); }, [refresh]);
const patch = (p: Partial<Rule>) => setDraft((d) => (d ? alerts.Rule.createFrom({ ...d, ...p }) : d));
const toggleIn = (key: keyof Rule, v: string) => setDraft((d) => {
if (!d) return d;
const cur = ((d as any)[key] as string[]) ?? [];
const has = cur.some((x) => x.toLowerCase() === v.toLowerCase());
const next = has ? cur.filter((x) => x.toLowerCase() !== v.toLowerCase()) : [...cur, v];
return alerts.Rule.createFrom({ ...d, [key]: next });
});
async function save() {
if (!draft) return;
if (!draft.name.trim()) { setErr('Give the rule a name'); return; }
try { const saved = await SaveAlertRule(draft); await refresh(); setDraft(saved as Rule); setErr(''); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function del() {
if (!draft) return;
if (!draft.id) { setDraft(null); return; }
if (!window.confirm(`Delete alert "${draft.name}"?`)) return;
try { await DeleteAlertRule(draft.id); setDraft(null); await refresh(); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2"><Bell className="size-4 text-primary" /> Alert management</DialogTitle>
<DialogDescription>Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).</DialogDescription>
</DialogHeader>
<div className="flex gap-3 min-h-0 px-5" style={{ height: '60vh' }}>
{/* Rule list */}
<div className="w-56 shrink-0 flex flex-col border border-border rounded-md">
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex-1">Rules</span>
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { setDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
</div>
<div className="flex-1 overflow-y-auto p-1">
{rules.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-4 text-center">No rules yet click +</div>}
{rules.map((r) => (
<button key={r.id} onClick={() => { setDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
className={cn('w-full text-left px-2 py-1.5 rounded text-xs flex items-center gap-1.5',
draft?.id === r.id ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60')}>
<span className={cn('size-1.5 rounded-full shrink-0', r.enabled ? 'bg-emerald-500' : 'bg-muted-foreground/40')} />
<span className="truncate flex-1">{r.name}</span>
{r.sound && <Volume2 className="size-3 text-muted-foreground shrink-0" />}
{r.email && <Mail className="size-3 text-muted-foreground shrink-0" />}
</button>
))}
</div>
<div className="px-2 py-1.5 border-t border-border/60">
<Label className="text-[10px] text-muted-foreground">Alert e-mail to</Label>
<Input className="h-7 text-xs mt-0.5" placeholder="[email protected]" value={emailTo}
onChange={(e) => setEmailTo(e.target.value)}
onBlur={() => SetAlertEmailTo(emailTo).catch(() => {})} />
</div>
</div>
{/* Editor */}
<div className="flex-1 min-w-0 border border-border rounded-md flex flex-col">
{!draft ? (
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">Select or create a rule.</div>
) : (
<Tabs value={tab} onValueChange={setTab} className="flex flex-col flex-1 min-h-0">
<div className="flex items-center gap-2 px-2 pt-2">
<TabsList>
<TabsTrigger value="def">Definition</TabsTrigger>
<TabsTrigger value="call">Call / DXCC</TabsTrigger>
<TabsTrigger value="bm">Band / Mode</TabsTrigger>
<TabsTrigger value="orig">Origin</TabsTrigger>
</TabsList>
</div>
<div className="flex-1 min-h-0 overflow-y-auto p-3">
<TabsContent value="def" className="mt-0 space-y-3">
<div>
<Label className="text-xs">Rule name</Label>
<Input value={draft.name} onChange={(e) => patch({ name: e.target.value })} />
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={draft.enabled} onCheckedChange={(c) => patch({ enabled: !!c })} /> Alert enabled
</label>
<div className="flex items-center gap-2">
<Label className="text-xs w-40 shrink-0">Alert again after (min)</Label>
<Input type="number" className="h-8 w-24" value={draft.again_after_min}
onChange={(e) => patch({ again_after_min: parseInt(e.target.value) || 0 })} />
<span className="text-[11px] text-muted-foreground">0 = once/session · -1 = always</span>
</div>
<div className="border-t border-border/60 pt-3 space-y-2">
<Label className="text-xs font-semibold">Actions</Label>
<div className="flex gap-4">
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.visual} onCheckedChange={(c) => patch({ visual: !!c })} /><Eye className="size-3.5" /> Visual</label>
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.sound} onCheckedChange={(c) => patch({ sound: !!c })} /><Volume2 className="size-3.5" /> Sound</label>
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.email} onCheckedChange={(c) => patch({ email: !!c })} /><Mail className="size-3.5" /> E-mail</label>
</div>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
<Checkbox checked={draft.skip_worked} onCheckedChange={(c) => patch({ skip_worked: !!c })} /> Skip calls already worked (same band + mode)
</label>
</TabsContent>
<TabsContent value="call" className="mt-0 grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Callsigns (one per line, wildcards: IW3*, */P)</Label>
<textarea className="w-full h-52 rounded-md border border-border bg-background p-2 text-xs font-mono resize-none"
placeholder={'DL1ABC\nIW3*\n*/P'}
value={(draft.calls ?? []).join('\n')}
onChange={(e) => patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) })} />
</div>
<div className="space-y-1">
<Label className="text-xs">Countries (DXCC)</Label>
<MultiCheck options={countries} selected={draft.countries ?? []} onToggle={(v) => toggleIn('countries', v)} searchable />
</div>
<div className="space-y-1 col-span-2">
<Label className="text-xs">Continents</Label>
<div className="flex gap-3 flex-wrap">
{CONTINENTS.map((c) => (
<label key={c} className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox checked={(draft.continents ?? []).includes(c)} onCheckedChange={() => toggleIn('continents', c)} /> {c}
</label>
))}
</div>
</div>
</TabsContent>
<TabsContent value="bm" className="mt-0 grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Bands</Label>
<MultiCheck options={bands} selected={draft.bands ?? []} onToggle={(v) => toggleIn('bands', v)} />
</div>
<div className="space-y-1">
<Label className="text-xs">Modes</Label>
<MultiCheck options={modes} selected={draft.modes ?? []} onToggle={(v) => toggleIn('modes', v)} />
</div>
</TabsContent>
<TabsContent value="orig" className="mt-0 grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Spotter callsign (wildcard)</Label>
<Input className="font-mono" placeholder="e.g. F* or DL1ABC" value={draft.spotter_call ?? ''}
onChange={(e) => patch({ spotter_call: e.target.value })} />
<Label className="text-xs mt-2 block">Spotter continents</Label>
<div className="flex gap-3 flex-wrap">
{CONTINENTS.map((c) => (
<label key={c} className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox checked={(draft.spotter_continents ?? []).includes(c)} onCheckedChange={() => toggleIn('spotter_continents', c)} /> {c}
</label>
))}
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Spotter countries</Label>
<MultiCheck options={countries} selected={draft.spotter_countries ?? []} onToggle={(v) => toggleIn('spotter_countries', v)} searchable />
</div>
</TabsContent>
</div>
{/* Editor actions */}
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/60">
{err && <span className="text-[11px] text-rose-600 flex-1 truncate">{err}</span>}
<div className="flex-1" />
<Button variant="ghost" size="sm" className="text-rose-700" onClick={del}><Trash2 className="size-3.5" /> Delete</Button>
<Button size="sm" onClick={save}>Save rule</Button>
</div>
</Tabs>
)}
</div>
</div>
<div className="flex justify-end px-5 pb-4">
<Button variant="outline" size="sm" onClick={onClose}><X className="size-3.5" /> Close</Button>
</div>
</DialogContent>
</Dialog>
);
}
+7 -4
View File
@@ -35,8 +35,11 @@ export interface DetailsState {
sat_name: string; sat_name: string;
sat_mode: string; sat_mode: string;
contest_id: string; contest_id: string;
srx?: number; // Contest exchanges as free text — most are serials (001) but some are
stx?: number; // alphanumeric (e.g. a section/zone like "ON4"). Stored to ADIF SRX/STX when
// purely numeric, else to SRX_STRING/STX_STRING (handled in App on save).
srx_string?: string;
stx_string?: string;
email: string; email: string;
// Award references for the contacted station (set via the Awards tab picker). // Award references for the contacted station (set via the Awards tab picker).
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064". // Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
@@ -371,10 +374,10 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
<Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} /> <Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} />
</Field> </Field>
<Field label="SRX"> <Field label="SRX">
<Input type="number" value={details.srx ?? ''} onChange={(e) => onChange({ srx: numOrUndef(e.target.value) })} /> <Input value={details.srx_string ?? ''} placeholder="rcvd exchange" onChange={(e) => onChange({ srx_string: e.target.value })} />
</Field> </Field>
<Field label="STX"> <Field label="STX">
<Input type="number" value={details.stx ?? ''} onChange={(e) => onChange({ stx: numOrUndef(e.target.value) })} /> <Input value={details.stx_string ?? ''} placeholder="sent exchange" onChange={(e) => onChange({ stx_string: e.target.value })} />
</Field> </Field>
<Field label="Contacted email" span={3}> <Field label="Contacted email" span={3}>
<Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} /> <Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} />
+62 -14
View File
@@ -1,11 +1,11 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Radio, Zap, Power, AudioLines, Flame, Gauge } from 'lucide-react'; import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX } from 'lucide-react';
import { import {
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay, GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic, FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexAmpOperate, FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode, GetPGXLStatus, PGXLSetFanMode,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel, FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay, FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter, FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
@@ -19,7 +19,9 @@ type FlexState = {
proc_enable: boolean; proc_level: number; proc_enable: boolean; proc_level: number;
mon: boolean; mon_level: number; mic_level: number; mon: boolean; mon_level: number; mic_level: number;
atu_status?: string; atu_memories: boolean; atu_status?: string; atu_memories: boolean;
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[];
split: boolean; rx_freq_hz?: number; tx_freq_hz?: number;
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number; nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
mode?: string; mode?: string;
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number; cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
@@ -34,7 +36,7 @@ const ZERO: FlexState = {
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false, available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0, vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
mon: false, mon_level: 0, mic_level: 0, atu_memories: false, mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
rx_avail: false, agc_threshold: 0, audio_level: 0, rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false,
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0, nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0, cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0, apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
@@ -110,8 +112,11 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
}[accent]; }[accent];
return ( return (
<button type="button" onClick={onClick} disabled={disabled} <button type="button" onClick={onClick} disabled={disabled}
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors disabled:opacity-30', className={cn(
on ? onCls : 'bg-card text-muted-foreground border-border hover:bg-muted')}> // min width (not fixed) so a longer label like STONE keeps symmetric
// padding instead of overflowing; short labels (NB/NR/APF) stay aligned.
'min-w-[3.5rem] shrink-0 px-2.5 py-1 rounded-md text-[11px] font-bold border text-center tracking-wide transition-all disabled:opacity-30',
on ? cn(onCls, 'shadow-sm') : 'bg-card text-muted-foreground border-border hover:bg-muted hover:border-muted-foreground/30')}>
{label} {label}
</button> </button>
); );
@@ -182,7 +187,9 @@ function Card({ icon: Icon, title, accent, children }: { icon: any; title: strin
); );
} }
export function FlexPanel() { // onCWSpeed (optional): notified when the operator changes CW speed here, so the
// host can keep the WinKeyer (which actually sends the macros) in sync.
export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } = {}) {
const [st, setSt] = useState<FlexState>(ZERO); const [st, setSt] = useState<FlexState>(ZERO);
const hold = useRef<Record<string, number>>({}); const hold = useRef<Record<string, number>>({});
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters // Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
@@ -302,6 +309,21 @@ export function FlexPanel() {
<Power className="size-4 inline mr-1 -mt-0.5" /> MOX <Power className="size-4 inline mr-1 -mt-0.5" /> MOX
</button> </button>
</div> </div>
<div className="flex items-center gap-2">
<button type="button" disabled={off}
title="Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR."
onClick={() => change('split', !st.split, () => FlexSetSplit(!st.split))}
className={cn('px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
st.split ? 'bg-sky-600 text-white border-sky-600 shadow-[0_0_12px] shadow-sky-600/50' : 'bg-card text-sky-700 border-sky-400 hover:bg-sky-50')}>
SPLIT
</button>
{st.split && !!st.tx_freq_hz && (
<span className="text-[11px] font-mono text-muted-foreground whitespace-nowrap">
TX {(st.tx_freq_hz / 1e6).toFixed(3)}
{!!st.rx_freq_hz && ` (${(st.tx_freq_hz - st.rx_freq_hz) >= 0 ? '+' : ''}${((st.tx_freq_hz - st.rx_freq_hz) / 1000).toFixed(1)} kHz)`}
</span>
)}
</div>
{!isCW ? ( {!isCW ? (
<div className="border-t border-border/60 pt-3 space-y-3"> <div className="border-t border-border/60 pt-3 space-y-3">
@@ -335,7 +357,7 @@ export function FlexPanel() {
<div className="border-t border-border/60 pt-3 space-y-3"> <div className="border-t border-border/60 pt-3 space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Speed</span> <span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Speed</span>
<Slider value={st.cw_speed} disabled={off} max={60} accent="#0d9488" onChange={(v) => change('cw_speed', v, () => FlexSetCWSpeed(v))} /> <Slider value={st.cw_speed} disabled={off} max={60} accent="#0d9488" onChange={(v) => { change('cw_speed', v, () => FlexSetCWSpeed(v)); onCWSpeed?.(v); }} />
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_speed} wpm</span> <span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_speed} wpm</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -357,20 +379,46 @@ export function FlexPanel() {
{/* RECEIVE */} {/* RECEIVE */}
<Card icon={AudioLines} title="Receive (active slice)" accent="#0891b2"> <Card icon={AudioLines} title="Receive (active slice)" accent="#0891b2">
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<span className="text-[10px] text-muted-foreground">RX</span>
<select disabled={rxOff} value={st.rx_ant ?? ''}
onChange={(e) => change('rx_ant', e.target.value, () => FlexSetRXAntenna(e.target.value))}
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
{(st.ant_list ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
</select>
<span className="text-[10px] text-muted-foreground">TX</span>
<select disabled={rxOff} value={st.tx_ant ?? ''}
onChange={(e) => change('tx_ant', e.target.value, () => FlexSetTXAntenna(e.target.value))}
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
{((st.tx_ant_list?.length ? st.tx_ant_list : st.ant_list) ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</div>
</div>
)}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span> <span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff} <Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
onChange={(v) => change('agc_mode', v, () => FlexSetAGCMode(v))} /> onChange={(v) => change('agc_mode', v, () => FlexSetAGCMode(v))} />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Thresh</span> <span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span>
<Slider value={st.agc_threshold} disabled={rxOff} accent="#64748b" onChange={(v) => change('agc_threshold', v, () => FlexSetAGCThreshold(v))} /> <button type="button" disabled={rxOff}
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span> title={st.mute ? 'Muted — click to unmute' : 'Mute RX audio'}
onClick={() => change('mute', !st.mute, () => FlexSetMute(!st.mute))}
className={cn('shrink-0 rounded p-1 transition-colors disabled:opacity-30',
st.mute ? 'bg-red-600 text-white' : 'text-muted-foreground hover:bg-muted')}>
{st.mute ? <VolumeX className="size-3.5" /> : <Volume2 className="size-3.5" />}
</button>
<Slider value={st.audio_level} disabled={rxOff || st.mute} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mute ? '—' : st.audio_level}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span> <span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC-T</span>
<Slider value={st.audio_level} disabled={rxOff} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} /> <Slider value={st.agc_threshold} disabled={rxOff} accent="#64748b" onChange={(v) => change('agc_threshold', v, () => FlexSetAGCThreshold(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.audio_level}</span> <span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
</div> </div>
<div className="border-t border-border/60 pt-3 space-y-3"> <div className="border-t border-border/60 pt-3 space-y-3">
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706" <LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
+13 -13
View File
@@ -81,10 +81,11 @@ interface WorldProps {
beamAzimuths?: number[]; // radiating heading(s) (deg) → draw a beam lobe each beamAzimuths?: number[]; // radiating heading(s) (deg) → draw a beam lobe each
beamWidth?: number; // beamwidth (deg), default 30 beamWidth?: number; // beamwidth (deg), default 30
boomAzimuth?: number | null; // mechanical boom (rotor) heading → grey reference line boomAzimuth?: number | null; // mechanical boom (rotor) heading → grey reference line
zoomSignal?: number; // bump to force an auto-zoom now (e.g. QRZ lookup finished)
} }
// WorldMap — great-circle path + beam lobe(s), the "map1" pane. // WorldMap — great-circle path + beam lobe(s), the "map1" pane.
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth }: WorldProps) { export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth, zoomSignal }: WorldProps) {
const worldRef = useRef<HTMLDivElement>(null); const worldRef = useRef<HTMLDivElement>(null);
const worldMap = useRef<L.Map | null>(null); const worldMap = useRef<L.Map | null>(null);
const worldOverlay = useRef<L.LayerGroup | null>(null); const worldOverlay = useRef<L.LayerGroup | null>(null);
@@ -164,21 +165,20 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
return out; return out;
}; };
for (const az of beamAzimuths) { for (const az of beamAzimuths) {
// Draw the lobe as a FAN of translucent great-circle radials, not a // Draw the lobe as a DENSE fan of translucent great-circle radials, not
// filled polygon: a polygon breaks badly near the poles on Mercator // a filled polygon: a polygon smears badly near the poles on Mercator
// (its edges run off toward ±90° and the fill smears across the map), // (a poleward beam degenerates into a giant triangle), while each radial
// while each radial LINE stays clean. The overlapping lines read as a // LINE stays clean at any azimuth. A small angular step makes the lines
// lobe — solid near the antenna, fanning out toward the front. Works // overlap into a solid-looking lobe (no separate strokes), darker near
// for any azimuth, north/south included. // the antenna and fanning out toward the front.
for (let b = az - half; b <= az + half + 0.001; b += 1.5) { for (let b = az - half; b <= az + half + 0.001; b += 0.5) {
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]); const line = unwrapLon([[from.lat, from.lon], ...radial(b)]);
L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.12, smoothFactor: 0 }).addTo(wo); L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.10, smoothFactor: 0 }).addTo(wo);
} }
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]); const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
// Dark casing under the boresight so the bright dashed line stays // Dark casing under the boresight so the bright dashed line stays
// readable on any basemap (esp. dark satellite imagery). Same dashArray // readable on any basemap. Same dashArray so the casing tracks each dash.
// as the red line so the casing tracks each dash — otherwise the wide
// casing peeks through the gaps and the line looks bumpy.
L.polyline(cl as L.LatLngExpression[], { color: '#000', weight: 4, opacity: 0.4, dashArray: '5 4', smoothFactor: 0 }).addTo(wo); L.polyline(cl as L.LatLngExpression[], { color: '#000', weight: 4, opacity: 0.4, dashArray: '5 4', smoothFactor: 0 }).addTo(wo);
L.polyline(cl as L.LatLngExpression[], { color: '#ff2d2d', weight: 2, opacity: 0.95, dashArray: '5 4', smoothFactor: 0 }) L.polyline(cl as L.LatLngExpression[], { color: '#ff2d2d', weight: 2, opacity: 0.95, dashArray: '5 4', smoothFactor: 0 })
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo); .bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
@@ -226,7 +226,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
} }
setTimeout(() => { wm.invalidateSize(); }, 0); setTimeout(() => { wm.invalidateSize(); }, 0);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom]); }, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom, zoomSignal]);
const path = pathBetween(fromGrid, toGrid); const path = pathBetween(fromGrid, toGrid);
+58 -63
View File
@@ -10,6 +10,7 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App'; import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime'; import { EventsOn } from '../../wailsjs/runtime/runtime';
ModuleRegistry.registerModules([AllCommunityModule]); ModuleRegistry.registerModules([AllCommunityModule]);
@@ -60,6 +61,14 @@ const QSL_STATUSES = [
{ v: 'I', label: 'Ignore' }, { v: 'I', label: 'Ignore' },
]; ];
// QSL routing methods for the paper-QSL "Via" dropdown (was free text).
const QSL_VIA_OPTIONS = [
{ v: '_', label: '— leave —' },
{ v: 'Bureau', label: 'Bureau' },
{ v: 'Direct', label: 'Direct' },
{ v: 'Electronic', label: 'Electronic' },
];
type LogQSO = { type LogQSO = {
id: number; qso_date: string; callsign: string; band: string; mode: string; country?: string; id: number; qso_date: string; callsign: string; band: string; mode: string; country?: string;
qsl_sent?: string; qsl_rcvd?: string; qsl_via?: string; qsl_sent_date?: string; qsl_rcvd_date?: string; qsl_sent?: string; qsl_rcvd?: string; qsl_via?: string; qsl_sent_date?: string; qsl_rcvd_date?: string;
@@ -127,13 +136,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const [paperCall, setPaperCall] = useState(''); const [paperCall, setPaperCall] = useState('');
const [paperRows, setPaperRows] = useState<LogQSO[]>([]); const [paperRows, setPaperRows] = useState<LogQSO[]>([]);
const [paperSel, setPaperSel] = useState<Set<number>>(new Set()); const [paperSel, setPaperSel] = useState<Set<number>>(new Set());
const [paperSelAllSig, setPaperSelAllSig] = useState(0); // bump → grid selects all
const [paperBusy, setPaperBusy] = useState(false); const [paperBusy, setPaperBusy] = useState(false);
const [paperMsg, setPaperMsg] = useState(''); const [paperMsg, setPaperMsg] = useState('');
const [qslRcvd, setQslRcvd] = useState('Y'); const [qslRcvd, setQslRcvd] = useState('N');
const [qslSent, setQslSent] = useState('_'); const [qslSent, setQslSent] = useState('_');
const [qslRcvdDate, setQslRcvdDate] = useState(''); const [qslRcvdDate, setQslRcvdDate] = useState('');
const [qslSentDate, setQslSentDate] = useState(''); const [qslSentDate, setQslSentDate] = useState('');
const [qslVia, setQslVia] = useState(''); const [qslVia, setQslVia] = useState('_');
const [qslNotes, setQslNotes] = useState('');
const [qslComment, setQslComment] = useState('');
const searchPaper = useCallback(async () => { const searchPaper = useCallback(async () => {
const c = paperCall.trim().toUpperCase(); const c = paperCall.trim().toUpperCase();
@@ -144,14 +156,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const list = (r ?? []) as LogQSO[]; const list = (r ?? []) as LogQSO[];
setPaperRows(list); setPaperRows(list);
setPaperSel(new Set(list.map((x) => x.id))); setPaperSel(new Set(list.map((x) => x.id)));
setPaperSelAllSig((n) => n + 1); // tell the grid to select every row
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); setPaperRows([]); } } catch (e: any) { setPaperMsg(String(e?.message ?? e)); setPaperRows([]); }
finally { setPaperBusy(false); } finally { setPaperBusy(false); }
}, [paperCall]); }, [paperCall]);
function togglePaper(id: number) {
setPaperSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
}
const paperAllSel = paperRows.length > 0 && paperSel.size === paperRows.length;
async function applyPaper() { async function applyPaper() {
const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id); const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id);
@@ -164,7 +174,9 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
rcvd_status: qslRcvd === '_' ? '' : qslRcvd, rcvd_status: qslRcvd === '_' ? '' : qslRcvd,
sent_date: ymd(qslSentDate), sent_date: ymd(qslSentDate),
rcvd_date: ymd(qslRcvdDate), rcvd_date: ymd(qslRcvdDate),
via: qslVia, via: qslVia === '_' ? '' : qslVia,
notes: qslNotes,
comment: qslComment,
} as any); } as any);
setPaperMsg(`${n} QSO updated.`); setPaperMsg(`${n} QSO updated.`);
await searchPaper(); await searchPaper();
@@ -172,11 +184,11 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
finally { setPaperBusy(false); } finally { setPaperBusy(false); }
} }
const [sent, setSent] = useState('R'); const [sent, setSent] = useState('R');
const [rows, setRows] = useState<UploadRow[]>([]); // Full QSO rows (so the upload view uses the same rich grid as Recent QSOs).
// Selection lives in the (virtualized) ag-grid — it handles 25k rows smoothly. const [rows, setRows] = useState<any[]>([]);
const gridRef = useRef<AgGridReact<UploadRow>>(null);
const [selectedCount, setSelectedCount] = useState(0); const [selectedCount, setSelectedCount] = useState(0);
const selectAllNext = useRef(false); // selectAll once after the next data load const [uploadSelIds, setUploadSelIds] = useState<number[]>([]); // selected QSO ids → upload
const [uploadSelAllSig, setUploadSelAllSig] = useState(0); // bump → grid selects all
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [addNotFound, setAddNotFound] = useState(false); const [addNotFound, setAddNotFound] = useState(false);
@@ -210,15 +222,9 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]); const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
// Grid selection → just track the count; ids are read from the grid at upload. // Grid selection → just track the count; ids are read from the grid at upload.
function onUploadSelChanged() { function onUploadRowSelected(ids: number[]) {
setSelectedCount(gridRef.current?.api?.getSelectedNodes()?.length ?? 0); setUploadSelIds(ids);
} setSelectedCount(ids.length);
// After "Select required" loads new rows, select them all (the old default).
function onUploadRowsLoaded() {
if (selectAllNext.current) {
selectAllNext.current = false;
gridRef.current?.api?.selectAll();
}
} }
const shownConfs = useMemo(() => confirmations.filter((c) => { const shownConfs = useMemo(() => confirmations.filter((c) => {
@@ -236,10 +242,11 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
setError(''); setError('');
try { try {
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent); const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
const list = (r ?? []) as UploadRow[]; const list = (r ?? []) as any[];
selectAllNext.current = true; // pre-select everything once the grid renders
setRows(list); setRows(list);
setUploadSelIds(list.map((x) => x.id));
setSelectedCount(list.length); setSelectedCount(list.length);
setUploadSelAllSig((n) => n + 1); // pre-select everything once the grid renders
setViewMode('upload'); setViewMode('upload');
setShowLog(false); setShowLog(false);
} catch (e: any) { } catch (e: any) {
@@ -252,7 +259,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
}, [service, sent]); }, [service, sent]);
async function upload() { async function upload() {
const ids = ((gridRef.current?.api?.getSelectedRows() as UploadRow[] | undefined) ?? []).map((r) => r.id); const ids = uploadSelIds;
if (ids.length === 0) return; if (ids.length === 0) return;
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true); setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
try { await UploadQSOsManual(service, ids); } try { await UploadQSOsManual(service, ids); }
@@ -387,32 +394,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
paperRows.length === 0 ? ( paperRows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">Search a callsign to list its QSOs, then set QSL status below.</div> <div className="text-sm text-muted-foreground py-10 text-center">Search a callsign to list its QSOs, then set QSL status below.</div>
) : ( ) : (
<table className="w-full text-xs border-collapse"> // Same grid as Recent QSOs (full columns + column picker). Selection
<thead className="sticky top-0 bg-card"> // drives which QSOs the apply-form below updates; a search selects all.
<tr className="text-left text-muted-foreground border-b border-border"> <div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
<th className="py-1.5 px-2 w-8"><Checkbox checked={paperAllSel} onCheckedChange={() => setPaperSel(paperAllSel ? new Set() : new Set(paperRows.map((r) => r.id)))} /></th> <RecentQSOsGrid
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th> rows={paperRows as any}
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th> total={paperRows.length}
<th className="py-1.5 px-2">QSL Sent</th><th className="py-1.5 px-2">QSL Rcvd</th><th className="py-1.5 px-2">Via</th> selectAllSignal={paperSelAllSig}
</tr> onRowSelected={(ids) => setPaperSel(new Set(ids))}
</thead> />
<tbody> </div>
{paperRows.map((r) => (
<tr key={r.id}
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', paperSel.has(r.id) && 'bg-primary/5')}
onClick={() => togglePaper(r.id)}>
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}><Checkbox checked={paperSel.has(r.id)} onCheckedChange={() => togglePaper(r.id)} /></td>
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
<td className="py-1 px-2">{r.band}</td>
<td className="py-1 px-2">{r.mode}</td>
<td className="py-1 px-2 font-mono">{r.qsl_sent || '—'}{r.qsl_sent_date ? ` ${fmtQslDate(r.qsl_sent_date)}` : ''}</td>
<td className="py-1 px-2 font-mono">{r.qsl_rcvd || '—'}{r.qsl_rcvd_date ? ` ${fmtQslDate(r.qsl_rcvd_date)}` : ''}</td>
<td className="py-1 px-2 text-muted-foreground truncate max-w-[160px]">{r.qsl_via}</td>
</tr>
))}
</tbody>
</table>
) )
) : service === 'pota' ? ( ) : service === 'pota' ? (
<div className="space-y-3"> <div className="space-y-3">
@@ -509,19 +500,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then Select required.</div> <div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then Select required.</div>
) : ( ) : (
<div className="h-full w-full"> <div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
<AgGridReact<UploadRow> <RecentQSOsGrid
ref={gridRef} rows={rows as any}
theme={qslTheme} total={rows.length}
rowData={rows} selectAllSignal={uploadSelAllSig}
columnDefs={UPLOAD_COLS} onRowSelected={onUploadRowSelected}
defaultColDef={{ sortable: true, resizable: true, filter: true }}
rowSelection={{ mode: 'multiRow', checkboxes: true, headerCheckbox: true }}
onSelectionChanged={onUploadSelChanged}
onRowDataUpdated={onUploadRowsLoaded}
animateRows={false}
suppressCellFocus
getRowId={(p) => String((p.data as any).id)}
/> />
</div> </div>
)} )}
@@ -552,7 +536,18 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Via</label> <label className="text-[10px] uppercase tracking-wider text-muted-foreground">Via</label>
<Input className="h-8 w-40" value={qslVia} onChange={(e) => setQslVia(e.target.value)} placeholder="BUREAU / DIRECT / manager" /> <Select value={qslVia} onValueChange={setQslVia}>
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Notes</label>
<Input className="h-8 w-40" value={qslNotes} onChange={(e) => setQslNotes(e.target.value)} placeholder="e.g. paid 3€" />
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Comment</label>
<Input className="h-8 w-40" value={qslComment} onChange={(e) => setQslComment(e.target.value)} placeholder="comment" />
</div> </div>
<div className="flex-1" /> <div className="flex-1" />
{paperMsg && <span className="text-[11px] text-muted-foreground self-center">{paperMsg}</span>} {paperMsg && <span className="text-[11px] text-muted-foreground self-center">{paperMsg}</span>}
+11 -5
View File
@@ -215,6 +215,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
return () => window.clearTimeout(t); return () => window.clearTimeout(t);
}, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]); }, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]);
// Contest exchange typed as free text → numeric SRX/STX when all-digits, else
// the SRX_STRING/STX_STRING field. Mirrors the entry strip (F5) so the field
// accepts letters (sections/zones), not just numbers.
function setExchange(which: 'srx' | 'stx', raw: string) {
const t = raw.trim();
const num = /^\d+$/.test(t) ? parseInt(t, 10) : undefined;
setDraft((d) => ({ ...d, [which]: num, [`${which}_string`]: num != null ? '' : t } as any));
}
function set<K extends keyof QSO>(key: K, value: QSO[K]) { function set<K extends keyof QSO>(key: K, value: QSO[K]) {
setDraft((d) => ({ ...d, [key]: value })); setDraft((d) => ({ ...d, [key]: value }));
} }
@@ -561,7 +569,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</thead> </thead>
<tbody> <tbody>
{CONFIRMATIONS.map((c) => ( {CONFIRMATIONS.map((c) => (
<tr key={c.key} className={cn('text-xs', c.key === confSel && 'bg-accent/40')}> <tr key={c.key} className="text-xs">
<td className="font-medium pr-2 py-0.5">{c.label}</td> <td className="font-medium pr-2 py-0.5">{c.label}</td>
<td className="w-24"><StatusCell value={val(c.sent)} /></td> <td className="w-24"><StatusCell value={val(c.sent)} /></td>
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground"></span>}</td> <td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground"></span>}</td>
@@ -578,10 +586,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<TabsContent value="contest" className="mt-0"> <TabsContent value="contest" className="mt-0">
<div className="grid grid-cols-6 gap-3"> <div className="grid grid-cols-6 gap-3">
<F label="Contest ID" span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F> <F label="Contest ID" span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F>
<F label="SRX"><Input type="number" value={draft.srx ?? ''} onChange={(e) => set('srx', intOrUndef(e.target.value) as any)} /></F> <F label="SRX" span={2}><Input value={draft.srx_string || (draft.srx ?? '')} placeholder="rcvd exchange" onChange={(e) => setExchange('srx', e.target.value)} /></F>
<F label="STX"><Input type="number" value={draft.stx ?? ''} onChange={(e) => set('stx', intOrUndef(e.target.value) as any)} /></F> <F label="STX" span={2}><Input value={draft.stx_string || (draft.stx ?? '')} placeholder="sent exchange" onChange={(e) => setExchange('stx', e.target.value)} /></F>
<F label="SRX string" span={3}><Input value={draft.srx_string ?? ''} onChange={(e) => set('srx_string', e.target.value)} /></F>
<F label="STX string" span={3}><Input value={draft.stx_string ?? ''} onChange={(e) => set('stx_string', e.target.value)} /></F>
<F label="Check"><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F> <F label="Check"><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
<F label="Precedence"><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F> <F label="Precedence"><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F>
<F label="ARRL section"><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F> <F label="ARRL section"><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F>
+23 -3
View File
@@ -45,6 +45,9 @@ const hamlogTheme = themeQuartz.withParams({
type Props = { type Props = {
rows: QSOForm[]; rows: QSOForm[];
total: number; total: number;
// Bump this number to programmatically select every row (e.g. after a search
// in the QSL Manager, where the default is "all selected").
selectAllSignal?: number;
onRowDoubleClicked?: (q: QSOForm) => void; onRowDoubleClicked?: (q: QSOForm) => void;
onRowSelected?: (ids: number[]) => void; onRowSelected?: (ids: number[]) => void;
onUpdateFromCty?: (ids: number[]) => void; onUpdateFromCty?: (ids: number[]) => void;
@@ -157,6 +160,8 @@ export const COL_CATALOG: ColEntry[] = [
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc. // App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
{ group: 'QSL', label: 'OpsLog QSL', colId: 'opslog_qsl_card_sent', headerName: 'OpsLog QSL', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true }, { group: 'QSL', label: 'OpsLog QSL', colId: 'opslog_qsl_card_sent', headerName: 'OpsLog QSL', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
// App-specific: when the QSO's audio recording was e-mailed to the station.
{ group: 'QSL', label: 'Recording sent', colId: 'opslog_recording_sent', headerName: 'Rec sent', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
// ── Uploads (online logbooks) ── // ── Uploads (online logbooks) ──
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and, // ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
@@ -223,7 +228,7 @@ export const GROUP_ORDER = [
'Contest', 'Propagation', 'My station', 'Misc', 'Contest', 'Propagation', 'My station', 'Misc',
]; ];
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete, awardCols }: Props) { export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete, awardCols }: Props) {
const gridRef = useRef<any>(null); const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null); const [menu, setMenu] = useState<QSOMenuState>(null);
@@ -249,7 +254,14 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
// Compute initial column defs: all columns defined, but those not marked // Compute initial column defs: all columns defined, but those not marked
// defaultVisible start hidden. The user's saved state (loaded onGridReady) // defaultVisible start hidden. The user's saved state (loaded onGridReady)
// overrides this so a previously toggled column wins. // overrides this so a previously toggled column wins.
// While AG Grid rebuilds columns (award columns load async), it fires column
// events that would otherwise trigger a save of the DEFAULT visibility before
// we re-apply the user's saved state — clobbering it. This flag suppresses the
// auto-save during that window. Set in the memo (runs at render, before the
// column events) and cleared by the re-apply effect below.
const restoringRef = useRef(true);
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => { const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
restoringRef.current = true;
const base = COL_CATALOG.map((c) => { const base = COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c; const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible }; return { ...rest, hide: !defaultVisible };
@@ -285,6 +297,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
}); });
} }
const saveColumnState = useCallback(() => { const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState(); const state = gridRef.current?.api?.getColumnState();
if (state) saveState(COL_STATE_KEY, state); if (state) saveState(COL_STATE_KEY, state);
}, []); }, []);
@@ -296,9 +309,11 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
// every rebuild so the user's choices win. No-op before the grid is ready. // every rebuild so the user's choices win. No-op before the grid is ready.
useEffect(() => { useEffect(() => {
const api = gridRef.current?.api; const api = gridRef.current?.api;
if (!api) return;
const local = loadLocal(COL_STATE_KEY); const local = loadLocal(COL_STATE_KEY);
if (local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true }); if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
// Re-enable saving once AG Grid has settled the column events from the rebuild.
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t);
}, [awardCols]); }, [awardCols]);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) { function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
@@ -308,6 +323,11 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? []; const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null)); onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
} }
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
useEffect(() => {
if (selectAllSignal === undefined) return;
gridRef.current?.api?.selectAll();
}, [selectAllSignal]);
// ── Column picker (visibility) ── // ── Column picker (visibility) ──
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React // Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
+141 -20
View File
@@ -38,6 +38,7 @@ import {
TestLoTWUpload, ListTQSLStationLocations, TestLoTWUpload, ListTQSLStationLocations,
ComputeStationInfo, ComputeStationInfo,
GetUIPref, SetUIPref, GetUIPref, SetUIPref,
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models'; import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -132,8 +133,10 @@ const emptyProfile = (): Profile => ({
is_active: false, is_active: false,
sort_order: 0, sort_order: 0,
db: { backend: '', host: '', port: 3306, user: '', password: '', database: '' }, db: { backend: '', host: '', port: 3306, user: '', password: '', database: '' },
created_at: '' as any, // Server-managed timestamps — send null (NOT "") so Go's time.Time unmarshal
updated_at: '' as any, // leaves the zero value instead of failing to parse an empty RFC3339 string.
created_at: null as any,
updated_at: null as any,
}); });
interface Props { interface Props {
@@ -174,13 +177,27 @@ type SectionId =
| 'antenna' | 'antenna'
| 'antgenius' | 'antgenius'
| 'pgxl' | 'pgxl'
| 'flex'
| 'audio'; | 'audio';
type TreeNode = type TreeNode =
| { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] } | { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] }
| { kind: 'item'; label: string; id: SectionId; disabled?: boolean }; | { kind: 'item'; label: string; id: SectionId; disabled?: boolean };
const TREE: TreeNode[] = [ // buildTree returns the settings sidebar. The FlexRadio item only appears when
// the active CAT backend is a Flex (per-band antenna config is Flex-specific).
function buildTree(flexAvailable: boolean): TreeNode[] {
const hardware: TreeNode[] = [
{ kind: 'item', label: 'CAT interface', id: 'cat' },
{ kind: 'item', label: 'PstRotator', id: 'rotator' },
{ kind: 'item', label: 'CW Keyer', id: 'winkeyer' },
{ kind: 'item', label: 'UltraBeam', id: 'antenna' },
{ kind: 'item', label: 'Antenna Genius', id: 'antgenius' },
{ kind: 'item', label: 'Power Genius', id: 'pgxl' },
...(flexAvailable ? [{ kind: 'item', label: 'FlexRadio', id: 'flex' } as TreeNode] : []),
{ kind: 'item', label: 'Audio devices', id: 'audio' },
];
return [
{ {
kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [ kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [
{ kind: 'item', label: 'Station Information', id: 'station' }, { kind: 'item', label: 'Station Information', id: 'station' },
@@ -206,17 +223,10 @@ const TREE: TreeNode[] = [
], ],
}, },
{ {
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [ kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: hardware,
{ kind: 'item', label: 'CAT interface', id: 'cat' },
{ kind: 'item', label: 'PstRotator', id: 'rotator' },
{ kind: 'item', label: 'CW Keyer', id: 'winkeyer' },
{ kind: 'item', label: 'UltraBeam', id: 'antenna' },
{ kind: 'item', label: 'Antenna Genius', id: 'antgenius' },
{ kind: 'item', label: 'Power Genius', id: 'pgxl' },
{ kind: 'item', label: 'Audio devices', id: 'audio' },
],
}, },
]; ];
}
// Map section id → friendly name (used in breadcrumb / placeholders). // Map section id → friendly name (used in breadcrumb / placeholders).
const SECTION_LABELS: Partial<Record<SectionId, string>> = { const SECTION_LABELS: Partial<Record<SectionId, string>> = {
@@ -240,6 +250,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
antenna: 'UltraBeam', antenna: 'UltraBeam',
antgenius: 'Antenna Genius', antgenius: 'Antenna Genius',
pgxl: 'Power Genius', pgxl: 'Power Genius',
flex: 'FlexRadio',
audio: 'Audio devices', audio: 'Audio devices',
}; };
@@ -248,12 +259,13 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
interface TreeProps { interface TreeProps {
selected: SectionId; selected: SectionId;
onSelect: (id: SectionId) => void; onSelect: (id: SectionId) => void;
flexAvailable?: boolean;
} }
function Tree({ selected, onSelect }: TreeProps) { function Tree({ selected, onSelect, flexAvailable }: TreeProps) {
return ( return (
<nav className="text-sm"> <nav className="text-sm">
{TREE.map((node, i) => ( {buildTree(!!flexAvailable).map((node, i) => (
<TreeNodeView key={i} node={node} depth={0} selected={selected} onSelect={onSelect} /> <TreeNodeView key={i} node={node} depth={0} selected={selected} onSelect={onSelect} />
))} ))}
</nav> </nav>
@@ -485,14 +497,16 @@ const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
{ value: 'map2', label: 'Map — locator (street)' }, { value: 'map2', label: 'Map — locator (street)' },
{ value: 'cluster', label: 'Cluster spots' }, { value: 'cluster', label: 'Cluster spots' },
{ value: 'worked', label: 'Worked before' }, { value: 'worked', label: 'Worked before' },
{ value: 'recent', label: 'Recent QSOs' },
]; ];
function MainViewPanes({ onChanged, flexAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean }) { function MainViewPanes({ onChanged, flexAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean }) {
const [left, setLeft] = useState('map1'); const [left, setLeft] = useState('map1');
const [right, setRight] = useState('map2'); const [right, setRight] = useState('map2');
// FlexRadio is only offered when the CAT backend is a Flex. // FlexRadio is only offered when the CAT backend is a Flex. Sorted A→Z.
const options = flexAvailable const options = (flexAvailable
? [...MAIN_PANE_OPTIONS, { value: 'flex', label: 'FlexRadio controls' }] ? [...MAIN_PANE_OPTIONS, { value: 'flex', label: 'FlexRadio controls' }]
: MAIN_PANE_OPTIONS; : [...MAIN_PANE_OPTIONS]
).sort((a, b) => a.label.localeCompare(b.label));
useEffect(() => { useEffect(() => {
const valid = (v: string) => v === 'flex' || MAIN_PANE_OPTIONS.some((o) => o.value === v); const valid = (v: string) => v === 'flex' || MAIN_PANE_OPTIONS.some((o) => o.value === v);
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')]) Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
@@ -583,6 +597,90 @@ function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
); );
} }
// FlexBandAntennasPanel — pick the RX/TX antenna per band. Applied automatically
// when the band changes (frequency change / spot click). Antennas come live from
// the connected FlexRadio; bands come from Lists → Bands.
function FlexBandAntennasPanel({ bands }: { bands: string[] }) {
const [rxList, setRxList] = useState<string[]>([]);
const [txList, setTxList] = useState<string[]>([]);
const [map, setMap] = useState<Record<string, { rx: string; tx: string }>>({});
const [msg, setMsg] = useState('');
useEffect(() => {
GetFlexState().then((s: any) => {
setRxList((s?.ant_list ?? []) as string[]);
setTxList(((s?.tx_ant_list?.length ? s.tx_ant_list : s?.ant_list) ?? []) as string[]);
}).catch(() => {});
GetFlexBandAntennas().then((m: any) => setMap(m ?? {})).catch(() => {});
}, []);
const set = (band: string, side: 'rx' | 'tx', v: string) => {
const key = band.toUpperCase();
setMap((m) => {
const cur = m[key] ?? { rx: '', tx: '' };
const next = { ...m, [key]: { ...cur, [side]: v } };
SaveFlexBandAntennas(next as any)
.then(() => { setMsg('Saved'); window.setTimeout(() => setMsg(''), 1200); })
.catch((e: any) => setMsg(String(e?.message ?? e)));
return next;
});
};
return (
<div className="space-y-3">
<div>
<h3 className="text-base font-semibold text-foreground">FlexRadio per-band antennas</h3>
<p className="text-xs text-muted-foreground">
Choose the RX and TX antenna for each band. They're applied automatically when the band
changes (frequency change or clicking a spot).
</p>
</div>
{rxList.length === 0 && (
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-md px-3 py-2 max-w-xl">
No antennas reported yet — make sure the FlexRadio is connected (CAT interface), then reopen this panel.
</div>
)}
{bands.length === 0 ? (
<p className="text-xs text-muted-foreground">No bands configured — add them in Lists → Bands.</p>
) : (
<div className="rounded-md border border-border overflow-hidden max-w-xl">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-muted-foreground text-xs">
<tr>
<th className="text-left px-3 py-1.5 font-semibold">Band</th>
<th className="text-left px-3 py-1.5 font-semibold">RX antenna</th>
<th className="text-left px-3 py-1.5 font-semibold">TX antenna</th>
</tr>
</thead>
<tbody>
{bands.map((b) => {
const e = map[b.toUpperCase()] ?? { rx: '', tx: '' };
return (
<tr key={b} className="border-t border-border/50">
<td className="px-3 py-1.5 font-mono font-semibold">{b}</td>
<td className="px-3 py-1.5">
<select value={e.rx ?? ''} onChange={(ev) => set(b, 'rx', ev.target.value)}
className="h-8 w-32 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
<option value="">— none —</option>
{rxList.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</td>
<td className="px-3 py-1.5">
<select value={e.tx ?? ''} onChange={(ev) => set(b, 'tx', ev.target.value)}
className="h-8 w-32 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
<option value="">— none —</option>
{txList.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{msg && <span className="text-[11px] text-muted-foreground">{msg}</span>}
</div>
);
}
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable }: Props) { export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable }: Props) {
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station'); const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -619,7 +717,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [modeDraft, setModeDraft] = useState(''); const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({ const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false,
icom_port: '', icom_baud: 115200, icom_addr: 0x98, poll_ms: 250, delay_ms: 0, icom_port: '', icom_baud: 115200, icom_addr: 0x98, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8', digital_default: 'FT8',
}); });
const [rotator, setRotator] = useState<RotatorSettings>({ const [rotator, setRotator] = useState<RotatorSettings>({
@@ -1796,6 +1894,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SelectItem value="omnirig">OmniRig (any rig, Windows COM)</SelectItem> <SelectItem value="omnirig">OmniRig (any rig, Windows COM)</SelectItem>
<SelectItem value="flex">FlexRadio / SmartSDR (native)</SelectItem> <SelectItem value="flex">FlexRadio / SmartSDR (native)</SelectItem>
<SelectItem value="icom">Icom CI-V (USB serial)</SelectItem> <SelectItem value="icom">Icom CI-V (USB serial)</SelectItem>
<SelectItem value="tci">TCI (Expert Electronics / SunSDR)</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -1865,6 +1964,27 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
</> </>
)} )}
{catCfg.backend === 'tci' && (
<>
<div className="space-y-1">
<Label>TCI host</Label>
<Input placeholder="127.0.0.1" value={catCfg.tci_host ?? ''}
onChange={(e) => setCatCfg((s) => ({ ...s, tci_host: e.target.value }))} />
</div>
<div className="space-y-1">
<Label>Port</Label>
<Input type="number" value={catCfg.tci_port || 40001}
onChange={(e) => setCatCfg((s) => ({ ...s, tci_port: parseInt(e.target.value) || 40001 }))} />
</div>
<p className="col-span-2 text-xs text-muted-foreground">
Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.
</p>
<label className="col-span-2 flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={!!catCfg.tci_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, tci_spots: !!c }))} />
Show cluster spots on the panorama <span className="text-xs text-muted-foreground">(spots from OpsLog's DX cluster appear on the SDR panadapter)</span>
</label>
</>
)}
{(catCfg.backend === 'omnirig' || catCfg.backend === 'icom') && ( {(catCfg.backend === 'omnirig' || catCfg.backend === 'icom') && (
<> <>
<div className="space-y-1"> <div className="space-y-1">
@@ -3846,6 +3966,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
antenna: UltrabeamPanel, antenna: UltrabeamPanel,
antgenius: AntGeniusPanelSettings, antgenius: AntGeniusPanelSettings,
pgxl: PGXLPanelSettings, pgxl: PGXLPanelSettings,
flex: () => <FlexBandAntennasPanel bands={lists.bands ?? []} />,
audio: AudioPanel, audio: AudioPanel,
}; };
@@ -3863,7 +3984,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className="grid grid-cols-[320px_1fr] min-h-0 overflow-hidden"> <div className="grid grid-cols-[320px_1fr] min-h-0 overflow-hidden">
{/* Left sidebar tree */} {/* Left sidebar tree */}
<aside className="border-r border-border bg-muted/30 overflow-y-auto p-2"> <aside className="border-r border-border bg-muted/30 overflow-y-auto p-2">
<Tree selected={selected} onSelect={setSelected} /> <Tree selected={selected} onSelect={setSelected} flexAvailable={flexAvailable} />
</aside> </aside>
{/* Right content pane */} {/* Right content pane */}
+17 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
AllCommunityModule, ModuleRegistry, themeQuartz, AllCommunityModule, ModuleRegistry, themeQuartz,
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent, type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
@@ -96,7 +96,12 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
const count = wb?.count ?? 0; const count = wb?.count ?? 0;
const entries = wb?.entries ?? []; const entries = wb?.entries ?? [];
// Suppress auto-save while AG Grid rebuilds columns (award columns load async),
// so the default visibility doesn't clobber the user's saved state. See the
// matching note in RecentQSOsGrid.
const restoringRef = useRef(true);
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => { const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => {
restoringRef.current = true;
const base = COL_CATALOG.map((c) => { const base = COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c; const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible }; return { ...rest, hide: !defaultVisible };
@@ -127,10 +132,21 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
}); });
} }
const saveColumnState = useCallback(() => { const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState(); const state = gridRef.current?.api?.getColumnState();
if (state) saveState(COL_STATE_KEY, state); if (state) saveState(COL_STATE_KEY, state);
}, []); }, []);
// Re-apply the saved column state after the award columns load (they rebuild
// the column set), so the user's visibility choices win over the defaults.
useEffect(() => {
const api = gridRef.current?.api;
const local = loadLocal(COL_STATE_KEY);
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t);
}, [awardCols]);
function isColVisible(colId: string): boolean { function isColVisible(colId: string): boolean {
const col = gridRef.current?.api?.getColumn(colId); const col = gridRef.current?.api?.getColumn(colId);
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible; return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.14'; export const APP_VERSION = '0.16.3';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+26 -1
View File
@@ -12,6 +12,7 @@ import {cluster} from '../models';
import {extsvc} from '../models'; import {extsvc} from '../models';
import {powergenius} from '../models'; import {powergenius} from '../models';
import {winkeyer} from '../models'; import {winkeyer} from '../models';
import {alerts} from '../models';
import {audio} from '../models'; import {audio} from '../models';
import {operating} from '../models'; import {operating} from '../models';
import {udp} from '../models'; import {udp} from '../models';
@@ -88,6 +89,8 @@ export function DXCCForCountry(arg1:string):Promise<number>;
export function DXCCName(arg1:number):Promise<string>; export function DXCCName(arg1:number):Promise<string>;
export function DeleteAlertRule(arg1:string):Promise<void>;
export function DeleteAllQSO():Promise<number>; export function DeleteAllQSO():Promise<number>;
export function DeleteAwardReference(arg1:string,arg2:string):Promise<void>; export function DeleteAwardReference(arg1:string,arg2:string):Promise<void>;
@@ -130,7 +133,7 @@ export function ExportAwards():Promise<string>;
export function FilterFields():Promise<Array<string>>; export function FilterFields():Promise<Array<string>>;
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>; export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.QSO>>;
export function FlexATUBypass():Promise<void>; export function FlexATUBypass():Promise<void>;
@@ -138,6 +141,8 @@ export function FlexATUStart():Promise<void>;
export function FlexAmpOperate(arg1:boolean):Promise<void>; export function FlexAmpOperate(arg1:boolean):Promise<void>;
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
export function FlexMox(arg1:boolean):Promise<void>; export function FlexMox(arg1:boolean):Promise<void>;
export function FlexSetAGCMode(arg1:string):Promise<void>; export function FlexSetAGCMode(arg1:string):Promise<void>;
@@ -174,6 +179,8 @@ export function FlexSetMon(arg1:boolean):Promise<void>;
export function FlexSetMonLevel(arg1:number):Promise<void>; export function FlexSetMonLevel(arg1:number):Promise<void>;
export function FlexSetMute(arg1:boolean):Promise<void>;
export function FlexSetNB(arg1:boolean):Promise<void>; export function FlexSetNB(arg1:boolean):Promise<void>;
export function FlexSetNBLevel(arg1:number):Promise<void>; export function FlexSetNBLevel(arg1:number):Promise<void>;
@@ -188,8 +195,14 @@ export function FlexSetProcessor(arg1:boolean):Promise<void>;
export function FlexSetProcessorLevel(arg1:number):Promise<void>; export function FlexSetProcessorLevel(arg1:number):Promise<void>;
export function FlexSetRXAntenna(arg1:string):Promise<void>;
export function FlexSetSidetoneLevel(arg1:number):Promise<void>; export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
export function FlexSetSplit(arg1:boolean):Promise<void>;
export function FlexSetTXAntenna(arg1:string):Promise<void>;
export function FlexSetTunePower(arg1:number):Promise<void>; export function FlexSetTunePower(arg1:number):Promise<void>;
export function FlexSetVox(arg1:boolean):Promise<void>; export function FlexSetVox(arg1:boolean):Promise<void>;
@@ -202,6 +215,8 @@ export function FlexTune(arg1:boolean):Promise<void>;
export function GetActiveProfile():Promise<profile.Profile>; export function GetActiveProfile():Promise<profile.Profile>;
export function GetAlertEmailTo():Promise<string>;
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>; export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
export function GetAntGeniusStatus():Promise<antgenius.Status>; export function GetAntGeniusStatus():Promise<antgenius.Status>;
@@ -256,6 +271,8 @@ export function GetEmailSettings():Promise<main.EmailSettings>;
export function GetExternalServices():Promise<extsvc.ExternalServices>; export function GetExternalServices():Promise<extsvc.ExternalServices>;
export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
export function GetFlexState():Promise<cat.FlexTXState>; export function GetFlexState():Promise<cat.FlexTXState>;
export function GetIcomState():Promise<cat.IcomTXState>; export function GetIcomState():Promise<cat.IcomTXState>;
@@ -342,6 +359,8 @@ export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunch
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>; export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
export function ListAlertRules():Promise<Array<alerts.Rule>>;
export function ListAudioInputDevices():Promise<Array<audio.Device>>; export function ListAudioInputDevices():Promise<Array<audio.Device>>;
export function ListAudioOutputDevices():Promise<Array<audio.Device>>; export function ListAudioOutputDevices():Promise<Array<audio.Device>>;
@@ -500,6 +519,8 @@ export function RunBackupNow():Promise<string>;
export function SaveADIFFile():Promise<string>; export function SaveADIFFile():Promise<string>;
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>; export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>; export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
@@ -520,6 +541,8 @@ export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>; export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>;
export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Promise<void>;
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>; export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>; export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
@@ -560,6 +583,8 @@ export function SendEQSL(arg1:number,arg2:number,arg3:string):Promise<void>;
export function SendQSORecordingEmail(arg1:number):Promise<void>; export function SendQSORecordingEmail(arg1:number):Promise<void>;
export function SetAlertEmailTo(arg1:string):Promise<void>;
export function SetCATFrequency(arg1:number):Promise<void>; export function SetCATFrequency(arg1:number):Promise<void>;
export function SetCATMode(arg1:string):Promise<void>; export function SetCATMode(arg1:string):Promise<void>;
+48
View File
@@ -142,6 +142,10 @@ export function DXCCName(arg1) {
return window['go']['main']['App']['DXCCName'](arg1); return window['go']['main']['App']['DXCCName'](arg1);
} }
export function DeleteAlertRule(arg1) {
return window['go']['main']['App']['DeleteAlertRule'](arg1);
}
export function DeleteAllQSO() { export function DeleteAllQSO() {
return window['go']['main']['App']['DeleteAllQSO'](); return window['go']['main']['App']['DeleteAllQSO']();
} }
@@ -242,6 +246,10 @@ export function FlexAmpOperate(arg1) {
return window['go']['main']['App']['FlexAmpOperate'](arg1); return window['go']['main']['App']['FlexAmpOperate'](arg1);
} }
export function FlexApplyBandAntenna(arg1) {
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
}
export function FlexMox(arg1) { export function FlexMox(arg1) {
return window['go']['main']['App']['FlexMox'](arg1); return window['go']['main']['App']['FlexMox'](arg1);
} }
@@ -314,6 +322,10 @@ export function FlexSetMonLevel(arg1) {
return window['go']['main']['App']['FlexSetMonLevel'](arg1); return window['go']['main']['App']['FlexSetMonLevel'](arg1);
} }
export function FlexSetMute(arg1) {
return window['go']['main']['App']['FlexSetMute'](arg1);
}
export function FlexSetNB(arg1) { export function FlexSetNB(arg1) {
return window['go']['main']['App']['FlexSetNB'](arg1); return window['go']['main']['App']['FlexSetNB'](arg1);
} }
@@ -342,10 +354,22 @@ export function FlexSetProcessorLevel(arg1) {
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1); return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
} }
export function FlexSetRXAntenna(arg1) {
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
}
export function FlexSetSidetoneLevel(arg1) { export function FlexSetSidetoneLevel(arg1) {
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1); return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
} }
export function FlexSetSplit(arg1) {
return window['go']['main']['App']['FlexSetSplit'](arg1);
}
export function FlexSetTXAntenna(arg1) {
return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
}
export function FlexSetTunePower(arg1) { export function FlexSetTunePower(arg1) {
return window['go']['main']['App']['FlexSetTunePower'](arg1); return window['go']['main']['App']['FlexSetTunePower'](arg1);
} }
@@ -370,6 +394,10 @@ export function GetActiveProfile() {
return window['go']['main']['App']['GetActiveProfile'](); return window['go']['main']['App']['GetActiveProfile']();
} }
export function GetAlertEmailTo() {
return window['go']['main']['App']['GetAlertEmailTo']();
}
export function GetAntGeniusSettings() { export function GetAntGeniusSettings() {
return window['go']['main']['App']['GetAntGeniusSettings'](); return window['go']['main']['App']['GetAntGeniusSettings']();
} }
@@ -478,6 +506,10 @@ export function GetExternalServices() {
return window['go']['main']['App']['GetExternalServices'](); return window['go']['main']['App']['GetExternalServices']();
} }
export function GetFlexBandAntennas() {
return window['go']['main']['App']['GetFlexBandAntennas']();
}
export function GetFlexState() { export function GetFlexState() {
return window['go']['main']['App']['GetFlexState'](); return window['go']['main']['App']['GetFlexState']();
} }
@@ -650,6 +682,10 @@ export function LaunchAutostartPrograms() {
return window['go']['main']['App']['LaunchAutostartPrograms'](); return window['go']['main']['App']['LaunchAutostartPrograms']();
} }
export function ListAlertRules() {
return window['go']['main']['App']['ListAlertRules']();
}
export function ListAudioInputDevices() { export function ListAudioInputDevices() {
return window['go']['main']['App']['ListAudioInputDevices'](); return window['go']['main']['App']['ListAudioInputDevices']();
} }
@@ -966,6 +1002,10 @@ export function SaveADIFFile() {
return window['go']['main']['App']['SaveADIFFile'](); return window['go']['main']['App']['SaveADIFFile']();
} }
export function SaveAlertRule(arg1) {
return window['go']['main']['App']['SaveAlertRule'](arg1);
}
export function SaveAntGeniusSettings(arg1) { export function SaveAntGeniusSettings(arg1) {
return window['go']['main']['App']['SaveAntGeniusSettings'](arg1); return window['go']['main']['App']['SaveAntGeniusSettings'](arg1);
} }
@@ -1006,6 +1046,10 @@ export function SaveExternalServices(arg1) {
return window['go']['main']['App']['SaveExternalServices'](arg1); return window['go']['main']['App']['SaveExternalServices'](arg1);
} }
export function SaveFlexBandAntennas(arg1) {
return window['go']['main']['App']['SaveFlexBandAntennas'](arg1);
}
export function SaveListsSettings(arg1) { export function SaveListsSettings(arg1) {
return window['go']['main']['App']['SaveListsSettings'](arg1); return window['go']['main']['App']['SaveListsSettings'](arg1);
} }
@@ -1086,6 +1130,10 @@ export function SendQSORecordingEmail(arg1) {
return window['go']['main']['App']['SendQSORecordingEmail'](arg1); return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
} }
export function SetAlertEmailTo(arg1) {
return window['go']['main']['App']['SetAlertEmailTo'](arg1);
}
export function SetCATFrequency(arg1) { export function SetCATFrequency(arg1) {
return window['go']['main']['App']['SetCATFrequency'](arg1); return window['go']['main']['App']['SetCATFrequency'](arg1);
} }
+73 -24
View File
@@ -65,6 +65,53 @@ export namespace adif {
} }
export namespace alerts {
export class Rule {
id: string;
name: string;
enabled: boolean;
calls?: string[];
countries?: string[];
continents?: string[];
bands?: string[];
modes?: string[];
spotter_call?: string;
spotter_continents?: string[];
spotter_countries?: string[];
sound: boolean;
visual: boolean;
email: boolean;
again_after_min: number;
skip_worked: boolean;
static createFrom(source: any = {}) {
return new Rule(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.enabled = source["enabled"];
this.calls = source["calls"];
this.countries = source["countries"];
this.continents = source["continents"];
this.bands = source["bands"];
this.modes = source["modes"];
this.spotter_call = source["spotter_call"];
this.spotter_continents = source["spotter_continents"];
this.spotter_countries = source["spotter_countries"];
this.sound = source["sound"];
this.visual = source["visual"];
this.email = source["email"];
this.again_after_min = source["again_after_min"];
this.skip_worked = source["skip_worked"];
}
}
}
export namespace antgenius { export namespace antgenius {
export class Antenna { export class Antenna {
@@ -512,9 +559,17 @@ export namespace cat {
atu_status?: string; atu_status?: string;
atu_memories: boolean; atu_memories: boolean;
rx_avail: boolean; rx_avail: boolean;
split: boolean;
rx_freq_hz?: number;
tx_freq_hz?: number;
agc_mode?: string; agc_mode?: string;
agc_threshold: number; agc_threshold: number;
audio_level: number; audio_level: number;
mute: boolean;
rx_ant?: string;
tx_ant?: string;
ant_list?: string[];
tx_ant_list?: string[];
nb: boolean; nb: boolean;
nb_level: number; nb_level: number;
nr: boolean; nr: boolean;
@@ -560,9 +615,17 @@ export namespace cat {
this.atu_status = source["atu_status"]; this.atu_status = source["atu_status"];
this.atu_memories = source["atu_memories"]; this.atu_memories = source["atu_memories"];
this.rx_avail = source["rx_avail"]; this.rx_avail = source["rx_avail"];
this.split = source["split"];
this.rx_freq_hz = source["rx_freq_hz"];
this.tx_freq_hz = source["tx_freq_hz"];
this.agc_mode = source["agc_mode"]; this.agc_mode = source["agc_mode"];
this.agc_threshold = source["agc_threshold"]; this.agc_threshold = source["agc_threshold"];
this.audio_level = source["audio_level"]; this.audio_level = source["audio_level"];
this.mute = source["mute"];
this.rx_ant = source["rx_ant"];
this.tx_ant = source["tx_ant"];
this.ant_list = source["ant_list"];
this.tx_ant_list = source["tx_ant_list"];
this.nb = source["nb"]; this.nb = source["nb"];
this.nb_level = source["nb_level"]; this.nb_level = source["nb_level"];
this.nr = source["nr"]; this.nr = source["nr"];
@@ -1160,6 +1223,9 @@ export namespace main {
icom_port: string; icom_port: string;
icom_baud: number; icom_baud: number;
icom_addr: number; icom_addr: number;
tci_host: string;
tci_port: number;
tci_spots: boolean;
poll_ms: number; poll_ms: number;
delay_ms: number; delay_ms: number;
digital_default: string; digital_default: string;
@@ -1179,6 +1245,9 @@ export namespace main {
this.icom_port = source["icom_port"]; this.icom_port = source["icom_port"];
this.icom_baud = source["icom_baud"]; this.icom_baud = source["icom_baud"];
this.icom_addr = source["icom_addr"]; this.icom_addr = source["icom_addr"];
this.tci_host = source["tci_host"];
this.tci_port = source["tci_port"];
this.tci_spots = source["tci_spots"];
this.poll_ms = source["poll_ms"]; this.poll_ms = source["poll_ms"];
this.delay_ms = source["delay_ms"]; this.delay_ms = source["delay_ms"];
this.digital_default = source["digital_default"]; this.digital_default = source["digital_default"];
@@ -1562,6 +1631,8 @@ export namespace main {
sent_date: string; sent_date: string;
rcvd_date: string; rcvd_date: string;
via: string; via: string;
notes: string;
comment: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new QSLBulkUpdate(source); return new QSLBulkUpdate(source);
@@ -1574,6 +1645,8 @@ export namespace main {
this.sent_date = source["sent_date"]; this.sent_date = source["sent_date"];
this.rcvd_date = source["rcvd_date"]; this.rcvd_date = source["rcvd_date"];
this.via = source["via"]; this.via = source["via"];
this.notes = source["notes"];
this.comment = source["comment"];
} }
} }
export class QSLDefaults { export class QSLDefaults {
@@ -2895,30 +2968,6 @@ export namespace qso {
return a; return a;
} }
} }
export class UploadRow {
id: number;
qso_date: string;
callsign: string;
band: string;
mode: string;
country: string;
status: string;
static createFrom(source: any = {}) {
return new UploadRow(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.qso_date = source["qso_date"];
this.callsign = source["callsign"];
this.band = source["band"];
this.mode = source["mode"];
this.country = source["country"];
this.status = source["status"];
}
}
export class WorkedBefore { export class WorkedBefore {
callsign: string; callsign: string;
count: number; count: number;
+1 -1
View File
@@ -6,6 +6,7 @@ require (
github.com/braheezy/shine-mp3 v0.1.0 github.com/braheezy/shine-mp3 v0.1.0
github.com/go-ole/go-ole v1.3.0 github.com/go-ole/go-ole v1.3.0
github.com/go-sql-driver/mysql v1.10.0 github.com/go-sql-driver/mysql v1.10.0
github.com/gorilla/websocket v1.5.3
github.com/moutend/go-wca v0.3.0 github.com/moutend/go-wca v0.3.0
github.com/wailsapp/wails/v2 v2.11.0 github.com/wailsapp/wails/v2 v2.11.0
github.com/wneessen/go-mail v0.7.3 github.com/wneessen/go-mail v0.7.3
@@ -22,7 +23,6 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect github.com/labstack/gommon v0.4.2 // indirect
+309
View File
@@ -0,0 +1,309 @@
// Package alerts evaluates incoming DX-cluster spots against user-defined rules
// and reports which rules fire, so the app can notify the operator (sound /
// visual / e-mail) when a wanted station is spotted — like Log4OM's Alert
// Management. Rules are persisted as JSON (global, machine-local).
package alerts
import (
"encoding/json"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
// Rule is one alert definition. Every filter dimension is optional: an empty
// list/string matches ANY value, and the dimensions are ANDed together, so a
// rule with Countries=[France] and Bands=[20m] fires only for French stations
// on 20m. Calls / SpotterCall accept wildcards (IW3*, */P).
type Rule struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
// DX filters.
Calls []string `json:"calls,omitempty"` // wildcard patterns on the DX call
Countries []string `json:"countries,omitempty"` // DXCC entity names
Continents []string `json:"continents,omitempty"` // AF/AN/AS/EU/NA/OC/SA
// Band / mode filters.
Bands []string `json:"bands,omitempty"`
Modes []string `json:"modes,omitempty"`
// Spotter (origin) filters.
SpotterCall string `json:"spotter_call,omitempty"` // wildcard
SpotterContinents []string `json:"spotter_continents,omitempty"`
SpotterCountries []string `json:"spotter_countries,omitempty"`
// Actions.
Sound bool `json:"sound"`
Visual bool `json:"visual"`
Email bool `json:"email"`
// Throttling: minutes to wait before re-alerting the same callsign. 0 = only
// once per session, -1 = always, >0 = that many minutes.
AgainAfterMin int `json:"again_after_min"`
// Skip a spot whose DX call is already worked on this band+mode.
SkipWorked bool `json:"skip_worked"`
}
// Spot is the subset of a cluster spot the engine matches against.
type Spot struct {
DXCall string
Band string
Mode string // inferred (see InferMode)
Country string // DXCC entity name
Continent string
Spotter string
SpotterCountry string
SpotterContinent string
}
// Store persists the rule set as a JSON file.
type Store struct {
mu sync.Mutex
path string
rules []Rule
// last fired time per (ruleID|call), for the AgainAfter throttle.
lastFired map[string]time.Time
}
// Open loads the store (empty when the file is absent).
func Open(path string) (*Store, error) {
s := &Store{path: path, lastFired: map[string]time.Time{}}
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return s, nil
}
return nil, err
}
if len(b) > 0 {
_ = json.Unmarshal(b, &s.rules) // corrupt file → start empty
}
return s, nil
}
func (s *Store) save() error {
b, err := json.MarshalIndent(s.rules, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// List returns a copy of all rules.
func (s *Store) List() []Rule {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Rule, len(s.rules))
copy(out, s.rules)
return out
}
func newID() string { return strconv.FormatInt(time.Now().UnixNano(), 36) }
// Save upserts a rule (creating an id when empty) and returns it.
func (s *Store) Save(r Rule) (Rule, error) {
s.mu.Lock()
defer s.mu.Unlock()
if strings.TrimSpace(r.ID) == "" {
r.ID = newID()
s.rules = append(s.rules, r)
} else {
found := false
for i := range s.rules {
if s.rules[i].ID == r.ID {
s.rules[i] = r
found = true
break
}
}
if !found {
s.rules = append(s.rules, r)
}
}
return r, s.save()
}
// Delete removes a rule by id.
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.rules {
if s.rules[i].ID == id {
s.rules = append(s.rules[:i], s.rules[i+1:]...)
return s.save()
}
}
return nil
}
// Match is a rule that fired for a spot (returned by Evaluate).
type Match struct {
Rule Rule
Spot Spot
}
// Evaluate returns every enabled rule that matches the spot AND isn't currently
// throttled. It records the fire time for matched rules so the AgainAfter window
// is honoured. workedFn (may be nil) reports whether the DX call is already
// worked on this band+mode — used by rules with SkipWorked.
func (s *Store) Evaluate(sp Spot, now time.Time, workedFn func(call, band, mode string) bool) []Match {
s.mu.Lock()
defer s.mu.Unlock()
var out []Match
for _, r := range s.rules {
if !r.Enabled || !ruleMatches(r, sp) {
continue
}
if r.SkipWorked && workedFn != nil && workedFn(sp.DXCall, sp.Band, sp.Mode) {
continue
}
key := r.ID + "|" + strings.ToUpper(sp.DXCall)
if r.AgainAfterMin >= 0 {
last, seen := s.lastFired[key]
if seen {
if r.AgainAfterMin == 0 {
continue // once per session
}
if now.Sub(last) < time.Duration(r.AgainAfterMin)*time.Minute {
continue
}
}
}
s.lastFired[key] = now
out = append(out, Match{Rule: r, Spot: sp})
}
return out
}
// ruleMatches reports whether every specified filter dimension matches the spot.
func ruleMatches(r Rule, sp Spot) bool {
if len(r.Calls) > 0 && !anyWildcard(r.Calls, sp.DXCall) {
return false
}
if len(r.Countries) > 0 && !containsFold(r.Countries, sp.Country) {
return false
}
if len(r.Continents) > 0 && !containsFold(r.Continents, sp.Continent) {
return false
}
if len(r.Bands) > 0 && !containsFold(r.Bands, sp.Band) {
return false
}
if len(r.Modes) > 0 && !containsFold(r.Modes, sp.Mode) {
return false
}
if strings.TrimSpace(r.SpotterCall) != "" && !matchWildcard(r.SpotterCall, sp.Spotter) {
return false
}
if len(r.SpotterContinents) > 0 && !containsFold(r.SpotterContinents, sp.SpotterContinent) {
return false
}
if len(r.SpotterCountries) > 0 && !containsFold(r.SpotterCountries, sp.SpotterCountry) {
return false
}
return true
}
func containsFold(list []string, v string) bool {
v = strings.TrimSpace(v)
if v == "" {
return false
}
for _, x := range list {
if strings.EqualFold(strings.TrimSpace(x), v) {
return true
}
}
return false
}
func anyWildcard(patterns []string, v string) bool {
for _, p := range patterns {
if matchWildcard(p, v) {
return true
}
}
return false
}
// matchWildcard does a case-insensitive full-string match where '*' matches any
// run of characters and '?' matches one (e.g. "IW3*", "*/P", "F?BPO").
func matchWildcard(pattern, v string) bool {
pattern = strings.TrimSpace(pattern)
v = strings.TrimSpace(v)
if pattern == "" {
return true
}
var b strings.Builder
b.WriteString("(?i)^")
for _, r := range pattern {
switch r {
case '*':
b.WriteString(".*")
case '?':
b.WriteString(".")
default:
b.WriteString(regexp.QuoteMeta(string(r)))
}
}
b.WriteString("$")
re, err := regexp.Compile(b.String())
if err != nil {
return strings.EqualFold(pattern, v)
}
return re.MatchString(v)
}
// InferMode guesses a spot's mode from its comment and frequency. Cluster spots
// don't carry a mode field, so we read common tags (FT8/FT4/CW/RTTY/…) then fall
// back to the digital watering holes and the band-plan CW/phone split.
func InferMode(comment string, freqHz int64) string {
c := strings.ToUpper(comment)
switch {
case strings.Contains(c, "FT8"):
return "FT8"
case strings.Contains(c, "FT4"):
return "FT4"
case strings.Contains(c, "RTTY"):
return "RTTY"
case strings.Contains(c, "PSK"):
return "PSK"
case strings.Contains(c, "JS8"):
return "JS8"
case strings.Contains(c, "CW"):
return "CW"
case strings.Contains(c, "SSB") || strings.Contains(c, "USB") || strings.Contains(c, "LSB") || strings.Contains(c, "PH"):
return "SSB"
}
khz := float64(freqHz) / 1000
// FT8 watering holes (…074) and FT4 (…080/…140) as a fallback.
for _, f := range []float64{1840, 3573, 7074, 10136, 14074, 18100, 21074, 24915, 28074, 50313} {
if khz >= f-1 && khz <= f+3 {
return "FT8"
}
}
// Band-plan CW segments (bottom of each band).
switch {
case khz >= 1810 && khz <= 1840,
khz >= 3500 && khz <= 3570,
khz >= 7000 && khz <= 7040,
khz >= 10100 && khz <= 10130,
khz >= 14000 && khz <= 14070,
khz >= 18068 && khz <= 18095,
khz >= 21000 && khz <= 21070,
khz >= 24890 && khz <= 24910,
khz >= 28000 && khz <= 28070:
return "CW"
}
return "SSB"
}
+12
View File
@@ -248,9 +248,17 @@ type FlexTXState struct {
ATUMemories bool `json:"atu_memories"` ATUMemories bool `json:"atu_memories"`
// Active RX slice DSP controls. // Active RX slice DSP controls.
RXAvail bool `json:"rx_avail"` // an RX slice exists RXAvail bool `json:"rx_avail"` // an RX slice exists
Split bool `json:"split"` // RX/TX on separate slices
RXFreqHz int64 `json:"rx_freq_hz,omitempty"` // RX slice freq when split
TXFreqHz int64 `json:"tx_freq_hz,omitempty"` // TX slice freq when split
AGCMode string `json:"agc_mode,omitempty"` AGCMode string `json:"agc_mode,omitempty"`
AGCThreshold int `json:"agc_threshold"` AGCThreshold int `json:"agc_threshold"`
AudioLevel int `json:"audio_level"` AudioLevel int `json:"audio_level"`
Mute bool `json:"mute"`
RXAnt string `json:"rx_ant,omitempty"` // selected RX antenna
TXAnt string `json:"tx_ant,omitempty"` // selected TX antenna
AntList []string `json:"ant_list,omitempty"` // antennas selectable for RX
TXAntList []string `json:"tx_ant_list,omitempty"` // antennas selectable for TX
NB bool `json:"nb"` NB bool `json:"nb"`
NBLevel int `json:"nb_level"` NBLevel int `json:"nb_level"`
NR bool `json:"nr"` NR bool `json:"nr"`
@@ -312,6 +320,10 @@ type FlexController interface {
SetAGCMode(string) error SetAGCMode(string) error
SetAGCThreshold(int) error SetAGCThreshold(int) error
SetAudioLevel(int) error SetAudioLevel(int) error
SetMute(bool) error
SetRXAntenna(string) error
SetTXAntenna(string) error
SetSplit(bool) error
SetNB(bool) error SetNB(bool) error
SetNBLevel(int) error SetNBLevel(int) error
SetNR(bool) error SetNR(bool) error
+121 -1
View File
@@ -54,6 +54,7 @@ type Flex struct {
spotsEnabled bool // push cluster spots + manage the panadapter overlay spotsEnabled bool // push cluster spots + manage the panadapter overlay
spotIdx map[int]bool // panadapter spot indices currently known to the radio spotIdx map[int]bool // panadapter spot indices currently known to the radio
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click) spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
sentCmds map[int]string // seq → command text, so an R<seq> error names the command sentCmds map[int]string // seq → command text, so an R<seq> error names the command
@@ -73,6 +74,7 @@ type flexSlice struct {
agcMode string // off | slow | med | fast agcMode string // off | slow | med | fast
agcThreshold int // 0-100 agcThreshold int // 0-100
audioLevel int // 0-100 (RX volume) audioLevel int // 0-100 (RX volume)
mute bool // RX audio muted
nb bool // noise blanker nb bool // noise blanker
nbLevel int nbLevel int
nr bool // noise reduction nr bool // noise reduction
@@ -83,6 +85,10 @@ type flexSlice struct {
apfLevel int apfLevel int
filterLo int // slice filter low cut (Hz) filterLo int // slice filter low cut (Hz)
filterHi int // slice filter high cut (Hz) filterHi int // slice filter high cut (Hz)
rxAnt string // selected RX antenna (e.g. ANT1, ANT2, RX_A)
txAnt string // selected TX antenna
antList []string // antennas valid for this slice (RX side)
txAntList []string // antennas valid for TX
} }
// flexTX mirrors the radio's transmit/ATU/interlock objects (the SmartSDR-style // flexTX mirrors the radio's transmit/ATU/interlock objects (the SmartSDR-style
@@ -143,7 +149,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
return &Flex{ return &Flex{
host: strings.TrimSpace(host), port: port, host: strings.TrimSpace(host), port: port,
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled, slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, pendingSplit: map[int]bool{},
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{}, meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{}, sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
} }
@@ -309,7 +315,18 @@ func (f *Flex) reader(conn net.Conn) {
f.spotIdx[idx] = true f.spotIdx[idx] = true
} }
} }
// A successful "slice create" for split returns the new slice's index;
// make that slice the transmitter so RX/TX are on separate slices.
splitSeq := f.pendingSplit[seq]
if splitSeq {
delete(f.pendingSplit, seq)
}
f.mu.Unlock() f.mu.Unlock()
if splitSeq && ok && len(parts) >= 3 {
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
f.send(fmt.Sprintf("slice s %d tx=1", idx))
}
}
} }
} }
// Connection ended. // Connection ended.
@@ -670,6 +687,16 @@ func (f *Flex) handleStatus(payload string) {
s.agcThreshold = atoiDefault(val, s.agcThreshold) s.agcThreshold = atoiDefault(val, s.agcThreshold)
case "audio_level": case "audio_level":
s.audioLevel = atoiDefault(val, s.audioLevel) s.audioLevel = atoiDefault(val, s.audioLevel)
case "audio_mute", "mute":
s.mute = val == "1"
case "rxant":
s.rxAnt = val
case "txant":
s.txAnt = val
case "ant_list":
s.antList = splitCSV(val)
case "tx_ant_list":
s.txAntList = splitCSV(val)
case "nb": case "nb":
s.nb = val == "1" s.nb = val == "1"
case "nb_level": case "nb_level":
@@ -957,6 +984,18 @@ func splitKV(kv string) (key, val string, ok bool) {
} }
// atoiDefault parses an int (or a float like "20.0", truncated), else def. // atoiDefault parses an int (or a float like "20.0", truncated), else def.
// splitCSV splits a comma-separated antenna list (e.g. "ANT1,ANT2,RX_A") into a
// trimmed slice, dropping empties.
func splitCSV(s string) []string {
out := []string{}
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func atoiDefault(s string, def int) int { func atoiDefault(s string, def int) int {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
if n, err := strconv.Atoi(s); err == nil { if n, err := strconv.Atoi(s); err == nil {
@@ -1023,12 +1062,18 @@ func (f *Flex) FlexState() FlexTXState {
CWSidetone: f.tx.cwSidetone, CWSidetone: f.tx.cwSidetone,
CWMonLevel: f.tx.cwMonLevel, CWMonLevel: f.tx.cwMonLevel,
} }
if rx, tx := f.pickSlicesLocked(); rx != nil && tx != nil && rx != tx && rx.freqHz != tx.freqHz {
st.Split = true
st.RXFreqHz = rx.freqHz
st.TXFreqHz = tx.freqHz
}
if _, rx := f.rxSliceLocked(); rx != nil { if _, rx := f.rxSliceLocked(); rx != nil {
st.RXAvail = true st.RXAvail = true
st.Mode = strings.ToUpper(rx.mode) st.Mode = strings.ToUpper(rx.mode)
st.AGCMode = rx.agcMode st.AGCMode = rx.agcMode
st.AGCThreshold = rx.agcThreshold st.AGCThreshold = rx.agcThreshold
st.AudioLevel = rx.audioLevel st.AudioLevel = rx.audioLevel
st.Mute = rx.mute
st.NB = rx.nb st.NB = rx.nb
st.NBLevel = rx.nbLevel st.NBLevel = rx.nbLevel
st.NR = rx.nr st.NR = rx.nr
@@ -1039,6 +1084,13 @@ func (f *Flex) FlexState() FlexTXState {
st.APFLevel = rx.apfLevel st.APFLevel = rx.apfLevel
st.FilterLo = rx.filterLo st.FilterLo = rx.filterLo
st.FilterHi = rx.filterHi st.FilterHi = rx.filterHi
st.RXAnt = rx.rxAnt
st.TXAnt = rx.txAnt
st.AntList = rx.antList
st.TXAntList = rx.txAntList
if len(st.TXAntList) == 0 {
st.TXAntList = rx.antList // many configs share one antenna list
}
} }
if f.amp.handle != "" { if f.amp.handle != "" {
st.AmpAvailable = true st.AmpAvailable = true
@@ -1076,6 +1128,8 @@ func (f *Flex) sendSlice(param string, val any) error {
rx.agcThreshold = toInt(val) rx.agcThreshold = toInt(val)
case "audio_level": case "audio_level":
rx.audioLevel = toInt(val) rx.audioLevel = toInt(val)
case "audio_mute", "mute":
rx.mute = fmt.Sprint(val) == "1"
case "nb": case "nb":
rx.nb = val == "1" rx.nb = val == "1"
case "nb_level": case "nb_level":
@@ -1092,6 +1146,10 @@ func (f *Flex) sendSlice(param string, val any) error {
rx.apf = val == "1" rx.apf = val == "1"
case "apf_level": case "apf_level":
rx.apfLevel = toInt(val) rx.apfLevel = toInt(val)
case "rxant":
rx.rxAnt = fmt.Sprint(val)
case "txant":
rx.txAnt = fmt.Sprint(val)
} }
} }
f.mu.Unlock() f.mu.Unlock()
@@ -1126,6 +1184,68 @@ func (f *Flex) SetAGCMode(m string) error {
} }
func (f *Flex) SetAGCThreshold(l int) error { return f.sendSlice("agc_threshold", clampLevel(l)) } func (f *Flex) SetAGCThreshold(l int) error { return f.sendSlice("agc_threshold", clampLevel(l)) }
func (f *Flex) SetAudioLevel(l int) error { return f.sendSlice("audio_level", clampLevel(l)) } func (f *Flex) SetAudioLevel(l int) error { return f.sendSlice("audio_level", clampLevel(l)) }
func (f *Flex) SetMute(on bool) error { return f.sendSlice("audio_mute", boolFlex(on)) }
func (f *Flex) SetRXAntenna(a string) error { return f.sendSlice("rxant", a) }
func (f *Flex) SetTXAntenna(a string) error { return f.sendSlice("txant", a) }
// SetSplit toggles 2-slice split like SmartSDR's SPLIT button. ON creates a TX
// slice at RX freq + offset (+1 kHz on CW, +5 kHz otherwise) and makes it the
// transmitter; OFF removes the extra TX slice and returns to simplex (RX slice
// transmits again).
func (f *Flex) SetSplit(on bool) error {
f.mu.Lock()
if f.conn == nil {
f.mu.Unlock()
return fmt.Errorf("flex: not connected")
}
rx, tx := f.pickSlicesLocked()
rxIdx, txIdx := -1, -1
for i, s := range f.slices {
if s == rx {
rxIdx = i
}
if s == tx {
txIdx = i
}
}
alreadySplit := rx != nil && tx != nil && rx != tx
var rxFreq int64
var rxMode, rxAnt string
if rx != nil {
rxFreq, rxMode, rxAnt = rx.freqHz, rx.mode, rx.rxAnt
}
f.mu.Unlock()
if on {
if alreadySplit || rx == nil {
return nil // already split (or no slice)
}
offset := int64(5000)
if strings.HasPrefix(strings.ToUpper(rxMode), "CW") {
offset = 1000
}
cmd := fmt.Sprintf("slice create freq=%.6f mode=%s", float64(rxFreq+offset)/1e6, rxMode)
if rxAnt != "" {
cmd += " ant=" + rxAnt
}
if seq := f.send(cmd); seq > 0 {
f.mu.Lock()
f.pendingSplit[seq] = true // mark the new slice TX once its index returns
f.mu.Unlock()
}
return nil
}
if !alreadySplit {
return nil
}
if txIdx >= 0 {
f.send(fmt.Sprintf("slice remove %d", txIdx))
}
if rxIdx >= 0 {
f.send(fmt.Sprintf("slice s %d tx=1", rxIdx))
}
return nil
}
func (f *Flex) SetNB(on bool) error { return f.sendSlice("nb", boolFlex(on)) } func (f *Flex) SetNB(on bool) error { return f.sendSlice("nb", boolFlex(on)) }
func (f *Flex) SetNBLevel(l int) error { return f.sendSlice("nb_level", clampLevel(l)) } func (f *Flex) SetNBLevel(l int) error { return f.sendSlice("nb_level", clampLevel(l)) }
func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on)) } func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on)) }
+14 -7
View File
@@ -177,14 +177,21 @@ func (o *OmniRig) ReadState() (RigState, error) {
BandFromHz(freqA) == BandFromHz(freqB) BandFromHz(freqA) == BandFromHz(freqB)
if genuineSplit { if genuineSplit {
// OmniRig's Vfo enum is RX-letter then TX-letter (AB = RX A, TX B). // ADIF: FreqHz = TX, RxFreqHz = RX. Determine which VFO is RX from the
// ADIF: FreqHz = TX, RxFreqHz = RX. // ACTIVE frequency (OmniRig's generic Freq — the VFO you're listening on):
// RX = the active VFO, TX = the other one. This is far more reliable than
// trusting OmniRig's Vfo AB/BA enum, which several rigs (e.g. Yaesu FTDX10)
// report inverted — the split then showed TX/RX swapped.
s.Split = true s.Split = true
switch s.Vfo { switch {
case "BA": case freqMain != 0 && freqMain == freqA:
s.FreqHz, s.RxFreqHz = freqA, freqB // TX A, RX B s.RxFreqHz, s.FreqHz = freqA, freqB // listening on A → TX on B
default: // "AB" and the usual "TX on the other VFO" case case freqMain != 0 && freqMain == freqB:
s.FreqHz, s.RxFreqHz = freqB, freqA // TX B, RX A s.RxFreqHz, s.FreqHz = freqB, freqA // listening on B → TX on A
case s.Vfo == "BA":
s.FreqHz, s.RxFreqHz = freqA, freqB // fall back to the Vfo enum
default:
s.FreqHz, s.RxFreqHz = freqB, freqA
} }
} else { } else {
// Simplex: the operating frequency is OmniRig's generic Freq (the active // Simplex: the operating frequency is OmniRig's generic Freq (the active
+321
View File
@@ -0,0 +1,321 @@
//go:build windows
package cat
import (
"fmt"
"net"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
// TCI is a native backend for Expert Electronics' TCI protocol (SunSDR2/MB1/
// ColibriNANO via ExpertSDR2/EESDR, and TCI-compatible apps). TCI is a text
// protocol over a WebSocket: the server streams state ("vfo:0,0,14100000;",
// "modulation:0,cw;", "trx:0,true;") and accepts the same commands to control
// the rig. We keep the pushed state cached so ReadState is instant, like Flex.
//
// Pure Go (gorilla/websocket, no CGO). Default port 40001.
type TCI struct {
host string
port int
digitalDefault string // surfaced when the rig reports a digital mode (FT8/…)
spotsEnabled bool // mirror cluster spots onto the TCI panorama
mu sync.Mutex // guards conn + writes + state
conn *websocket.Conn
ready bool
// Cached state pushed by the radio.
device string
freqA int64 // VFO A (RX) frequency, Hz (vfo:0,0)
freqB int64 // VFO B (TX in split), Hz (vfo:0,1)
mode string
split bool
tx bool
lastSig string // last logged state signature (log only on change)
}
const tciDefaultPort = 40001
// NewTCI builds a TCI backend for the given host/port. digitalDefault is the
// mode surfaced when the radio reports a generic digital modulation; spots turns
// on mirroring OpsLog's cluster spots onto the TCI panorama.
func NewTCI(host string, port int, digitalDefault string, spots bool) *TCI {
if port <= 0 || port > 65535 {
port = tciDefaultPort
}
return &TCI{host: strings.TrimSpace(host), port: port, digitalDefault: strings.TrimSpace(digitalDefault), spotsEnabled: spots}
}
func (t *TCI) Name() string { return "tci" }
// Connect opens the WebSocket and starts the reader goroutine. The reader keeps
// our cached state current from the radio's push messages.
func (t *TCI) Connect() error {
t.mu.Lock()
already := t.conn != nil
host, port := t.host, t.port
t.mu.Unlock()
if already {
return nil
}
if host == "" {
return fmt.Errorf("tci: no host configured")
}
url := fmt.Sprintf("ws://%s", net.JoinHostPort(host, strconv.Itoa(port)))
dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
conn, _, err := dialer.Dial(url, nil)
if err != nil {
return fmt.Errorf("tci: connect %s: %w", url, err)
}
t.mu.Lock()
t.conn = conn
t.ready = false
t.mu.Unlock()
debugLog.Printf("TCI: connected to %s", url)
go t.reader(conn)
if t.spotsEnabled {
_ = t.send("spot_clear;") // drop any leftover spots from a previous session
}
return nil
}
// SendSpot mirrors a cluster spot onto the TCI panorama (implements Spotter).
// The radio replaces a spot that has the same callsign, so re-spotting updates
// it in place. No-op when spot mirroring is disabled.
func (t *TCI) SendSpot(s SpotInfo) error {
if !t.spotsEnabled {
return nil
}
call := strings.TrimSpace(s.Callsign)
if call == "" || s.FreqHz <= 0 {
return nil
}
color := strings.TrimSpace(s.Color)
if color == "" {
color = "#FFFFA500" // opaque orange default
}
color = "0x" + strings.TrimPrefix(color, "#")
mode := strings.ToLower(strings.TrimSpace(s.Mode))
// Commas/semicolons would break TCI's comma-separated argument parsing.
text := strings.NewReplacer(",", " ", ";", " ").Replace(s.Comment)
return t.send(fmt.Sprintf("spot:%s,%s,%d,%s,%s;", call, mode, s.FreqHz, color, text))
}
// Disconnect closes the WebSocket; the reader goroutine then exits.
func (t *TCI) Disconnect() {
t.mu.Lock()
c := t.conn
t.conn = nil
t.ready = false
t.mu.Unlock()
if c != nil {
_ = c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
_ = c.Close()
}
}
// ReadState returns the cached state pushed by the radio.
func (t *TCI) ReadState() (RigState, error) {
t.mu.Lock()
defer t.mu.Unlock()
if t.conn == nil {
return RigState{}, fmt.Errorf("tci: not connected")
}
st := RigState{Connected: t.ready, Rig: t.device}
if !t.ready {
return st, nil
}
// ADIF convention: FreqHz is the TX freq. In split, TX is VFO B.
if t.split && t.freqB > 0 {
st.FreqHz = t.freqB
st.RxFreqHz = t.freqA
st.Split = true
} else {
st.FreqHz = t.freqA
}
st.Mode = tciModeToADIF(t.mode, t.digitalDefault)
if st.FreqHz > 0 {
st.Band = BandFromHz(st.FreqHz)
}
sig := fmt.Sprintf("%d/%d/%v/%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
if sig != t.lastSig {
t.lastSig = sig
debugLog.Printf("TCI: state tx=%d rx=%d split=%v mode=%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
}
return st, nil
}
// SetFrequency tunes VFO A (the main/RX VFO).
func (t *TCI) SetFrequency(hz int64) error {
return t.send(fmt.Sprintf("vfo:0,0,%d;", hz))
}
// SetMode maps an ADIF mode to a TCI modulation and sets it. USB vs LSB is
// chosen from the current VFO-A frequency (< 10 MHz → LSB).
func (t *TCI) SetMode(mode string) error {
t.mu.Lock()
freq := t.freqA
t.mu.Unlock()
m := adifToTCIMode(mode, freq)
if m == "" {
return nil
}
return t.send(fmt.Sprintf("modulation:0,%s;", m))
}
// SetPTT keys or unkeys the transmitter (VFO 0).
func (t *TCI) SetPTT(on bool) error {
return t.send(fmt.Sprintf("trx:0,%t;", on))
}
// send writes a command to the WebSocket (one writer at a time).
func (t *TCI) send(cmd string) error {
t.mu.Lock()
c := t.conn
t.mu.Unlock()
if c == nil {
return fmt.Errorf("tci: not connected")
}
_ = c.SetWriteDeadline(time.Now().Add(3 * time.Second))
if err := c.WriteMessage(websocket.TextMessage, []byte(cmd)); err != nil {
debugLog.Printf("TCI: send %q failed: %v", cmd, err)
return err
}
debugLog.Printf("TCI: → %s", cmd)
return nil
}
// reader drains push messages and keeps the cached state current until the
// connection closes.
func (t *TCI) reader(conn *websocket.Conn) {
for {
_, data, err := conn.ReadMessage()
if err != nil {
break
}
// A frame may carry several ";"-terminated commands.
for _, cmd := range strings.Split(string(data), ";") {
t.handle(strings.TrimSpace(cmd))
}
}
t.mu.Lock()
if t.conn == conn {
t.conn = nil
t.ready = false
}
t.mu.Unlock()
debugLog.Printf("TCI: reader ended")
}
// handle parses one "command:args" message and updates the cache.
func (t *TCI) handle(msg string) {
if msg == "" {
return
}
name, args := msg, ""
if i := strings.IndexByte(msg, ':'); i >= 0 {
name, args = msg[:i], msg[i+1:]
}
f := strings.Split(args, ",")
get := func(i int) string {
if i < len(f) {
return strings.TrimSpace(f[i])
}
return ""
}
t.mu.Lock()
defer t.mu.Unlock()
switch strings.ToLower(name) {
case "device":
t.device = strings.TrimSpace(args)
case "ready", "start":
t.ready = true
case "stop":
t.ready = false
case "vfo":
// vfo:<rx>,<channel>,<freq>
if get(0) == "0" {
hz, _ := strconv.ParseInt(get(2), 10, 64)
if hz > 0 {
t.ready = true // receiving live state → treat as ready even without an explicit "ready;"
switch get(1) {
case "0":
t.freqA = hz
case "1":
t.freqB = hz
}
}
}
case "modulation":
if get(0) == "0" {
t.mode = strings.ToLower(get(1))
}
case "split_enable":
if get(0) == "0" {
t.split = get(1) == "true"
}
case "trx":
if get(0) == "0" {
t.tx = get(1) == "true"
}
}
}
// tciModeToADIF converts a TCI modulation to an ADIF mode. Generic digital
// modulations surface the operator's chosen digital default (FT8/FT4/RTTY…).
func tciModeToADIF(m, digitalDefault string) string {
switch strings.ToLower(strings.TrimSpace(m)) {
case "usb", "lsb", "dsb":
return "SSB"
case "cw":
return "CW"
case "am", "sam":
return "AM"
case "nfm", "wfm", "fm":
return "FM"
case "digu", "digl":
if digitalDefault != "" {
return strings.ToUpper(digitalDefault)
}
return "DATA"
case "drm":
return "DIGITALVOICE"
case "":
return ""
default:
return strings.ToUpper(m)
}
}
// adifToTCIMode maps an ADIF mode to a TCI modulation. USB/LSB is chosen from
// the frequency (< 10 MHz → LSB) as usual. Digital modes → digu.
func adifToTCIMode(mode string, freqHz int64) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "SSB", "USB", "LSB":
if freqHz > 0 && freqHz < 10_000_000 {
return "lsb"
}
return "usb"
case "CW", "CWR", "CW-R":
return "cw"
case "AM":
return "am"
case "FM", "NFM":
return "nfm"
case "RTTY":
return "digl"
case "":
return ""
default:
// FT8/FT4/PSK/DATA/JT… → upper-sideband digital.
return "digu"
}
}
+6
View File
@@ -16,6 +16,10 @@ import (
// subscription used elsewhere for callsign data — they're different keys. // subscription used elsewhere for callsign data — they're different keys.
const qrzAPIURL = "https://logbook.qrz.com/api" const qrzAPIURL = "https://logbook.qrz.com/api"
// LogSink receives diagnostics such as raw QRZ responses. Set to applog.Printf
// at startup; defaults to a no-op so the package is usable without wiring.
var LogSink = func(string, ...any) {}
// UploadQRZ pushes one ADIF record to the QRZ.com logbook identified by // UploadQRZ pushes one ADIF record to the QRZ.com logbook identified by
// apiKey. It returns OK when the QSO is inserted or already present // apiKey. It returns OK when the QSO is inserted or already present
// (QRZ reports a duplicate as a FAIL with a "duplicate" reason, which we // (QRZ reports a duplicate as a FAIL with a "duplicate" reason, which we
@@ -67,6 +71,7 @@ func UploadQRZ(ctx context.Context, client *http.Client, apiKey, adifRecord stri
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return UploadResult{}, fmt.Errorf("qrz: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) return UploadResult{}, fmt.Errorf("qrz: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
} }
LogSink("qrz: INSERT raw response: %s", strings.TrimSpace(string(body)))
return parseQRZResponse(string(body)) return parseQRZResponse(string(body))
} }
@@ -171,6 +176,7 @@ func TestQRZ(ctx context.Context, client *http.Client, apiKey string) (string, e
} }
defer resp.Body.Close() defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
LogSink("qrz: STATUS raw response: %s", strings.TrimSpace(string(body)))
vals, err := url.ParseQuery(strings.TrimSpace(string(body))) vals, err := url.ParseQuery(strings.TrimSpace(string(body)))
if err != nil { if err != nil {
return "", fmt.Errorf("qrz: bad response: %w", err) return "", fmt.Errorf("qrz: bad response: %w", err)
+25
View File
@@ -497,6 +497,31 @@ var uploadStatusCols = map[string]bool{
// ListForUpload returns QSOs whose per-service sent-status column equals // ListForUpload returns QSOs whose per-service sent-status column equals
// value ("" matches blank/NULL). Used by the QSL Manager's "Select required". // value ("" matches blank/NULL). Used by the QSL Manager's "Select required".
// ListForUploadFull is like ListForUpload but returns FULL QSO rows so the UI
// can show the same rich, column-pickable table as Recent QSOs. column is an
// upload-status column (validated); value is the status to match ("" = not yet
// uploaded).
func (r *Repo) ListForUploadFull(ctx context.Context, column, value string) ([]QSO, error) {
if !uploadStatusCols[column] {
return nil, fmt.Errorf("invalid upload column %q", column)
}
rows, err := r.db.QueryContext(ctx,
`SELECT `+selectCols+` FROM qso WHERE COALESCE(`+column+`,'') = ? ORDER BY qso_date DESC, id DESC`, value)
if err != nil {
return nil, fmt.Errorf("list for upload: %w", err)
}
defer rows.Close()
out := make([]QSO, 0, 64)
for rows.Next() {
q, err := scanQSO(rows)
if err != nil {
return nil, err
}
out = append(out, q)
}
return out, rows.Err()
}
func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]UploadRow, error) { func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]UploadRow, error) {
if !uploadStatusCols[column] { if !uploadStatusCols[column] {
return nil, fmt.Errorf("invalid upload column %q", column) return nil, fmt.Errorf("invalid upload column %q", column)
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.14" appVersion = "0.16.3"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.