Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee9fb51066 | ||
|
|
5bbcaab71a | ||
|
|
18438d7737 | ||
|
|
97e24ea24f | ||
|
|
3e199f9ab6 | ||
|
|
8740a4ba66 | ||
|
|
8ccad7ca65 | ||
|
|
fa7df57435 | ||
|
|
812e4f05e5 | ||
|
|
6ec31b61ce | ||
|
|
93c8f6b9d3 | ||
|
|
65c22232dd | ||
|
|
edede0bc1e | ||
|
|
2712902057 | ||
|
|
9281645359 | ||
|
|
a05dd6b3a9 | ||
|
|
a2401d7fd3 | ||
|
|
053b351dab |
@@ -18,12 +18,14 @@ 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"
|
||||||
"hamlog/internal/award"
|
"hamlog/internal/award"
|
||||||
"hamlog/internal/awardref"
|
"hamlog/internal/awardref"
|
||||||
"hamlog/internal/backup"
|
"hamlog/internal/backup"
|
||||||
|
"hamlog/internal/cabrillo"
|
||||||
"hamlog/internal/cat"
|
"hamlog/internal/cat"
|
||||||
"hamlog/internal/clublog"
|
"hamlog/internal/clublog"
|
||||||
"hamlog/internal/cluster"
|
"hamlog/internal/cluster"
|
||||||
@@ -89,6 +91,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 +270,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 +417,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 +619,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 +768,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 +857,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.
|
||||||
@@ -3685,6 +3705,83 @@ func (a *App) ExportADIFSelected(path string, includeAppFields bool, ids []int64
|
|||||||
return ex.ExportFileByIDs(a.ctx, path, ids)
|
return ex.ExportFileByIDs(a.ctx, path, ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CabrilloResult reports how many QSOs a Cabrillo export wrote and where.
|
||||||
|
type CabrilloResult struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// cabrilloHeader fills the Cabrillo header from the active profile (callsign,
|
||||||
|
// operator, grid) plus the contest name the user supplied.
|
||||||
|
func (a *App) cabrilloHeader(contest string) cabrillo.Header {
|
||||||
|
h := cabrillo.Header{Contest: contest}
|
||||||
|
if a.profiles != nil {
|
||||||
|
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||||
|
h.Callsign = p.Callsign
|
||||||
|
h.Operators = p.Callsign
|
||||||
|
h.GridLocator = p.MyGrid
|
||||||
|
h.Name = p.OpName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCabrillo collects the QSOs via the given iterator and writes a Cabrillo
|
||||||
|
// document to path.
|
||||||
|
func (a *App) writeCabrillo(path, contest string, iterate func(func(qso.QSO) error) error) (CabrilloResult, error) {
|
||||||
|
if a.qso == nil {
|
||||||
|
return CabrilloResult{}, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
return CabrilloResult{}, fmt.Errorf("empty path")
|
||||||
|
}
|
||||||
|
var qsos []qso.QSO
|
||||||
|
if err := iterate(func(q qso.QSO) error { qsos = append(qsos, q); return nil }); err != nil {
|
||||||
|
return CabrilloResult{}, err
|
||||||
|
}
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return CabrilloResult{}, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
n, err := cabrillo.Generate(f, a.cabrilloHeader(contest), qsos)
|
||||||
|
if err != nil {
|
||||||
|
return CabrilloResult{}, err
|
||||||
|
}
|
||||||
|
return CabrilloResult{Count: n, Path: path}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportCabrillo writes every QSO to a Cabrillo file.
|
||||||
|
func (a *App) ExportCabrillo(path, contest string) (CabrilloResult, error) {
|
||||||
|
return a.writeCabrillo(path, contest, func(fn func(qso.QSO) error) error { return a.qso.IterateAll(a.ctx, fn) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportCabrilloFiltered writes the QSOs matching the current filter.
|
||||||
|
func (a *App) ExportCabrilloFiltered(path, contest string, f qso.QueryFilter) (CabrilloResult, error) {
|
||||||
|
return a.writeCabrillo(path, contest, func(fn func(qso.QSO) error) error { return a.qso.IterateFiltered(a.ctx, f, fn) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportCabrilloSelected writes only the highlighted QSOs.
|
||||||
|
func (a *App) ExportCabrilloSelected(path, contest string, ids []int64) (CabrilloResult, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return CabrilloResult{}, fmt.Errorf("no QSOs selected")
|
||||||
|
}
|
||||||
|
return a.writeCabrillo(path, contest, func(fn func(qso.QSO) error) error { return a.qso.IterateByIDs(a.ctx, ids, fn) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveCabrilloFile opens a save dialog for a Cabrillo (.cbr / .log) file.
|
||||||
|
func (a *App) SaveCabrilloFile() (string, error) {
|
||||||
|
suggested := "OpsLog_" + time.Now().UTC().Format("20060102_150405") + ".cbr"
|
||||||
|
return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
|
||||||
|
Title: "Export Cabrillo",
|
||||||
|
DefaultFilename: suggested,
|
||||||
|
Filters: []wruntime.FileFilter{
|
||||||
|
{DisplayName: "Cabrillo files (*.cbr, *.log)", Pattern: "*.cbr;*.log"},
|
||||||
|
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// --- Lookup bindings ---
|
// --- Lookup bindings ---
|
||||||
|
|
||||||
// LookupCallsign returns the cached or freshly-fetched info for a callsign.
|
// LookupCallsign returns the cached or freshly-fetched info for a callsign.
|
||||||
@@ -3851,7 +3948,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
|
||||||
}
|
}
|
||||||
@@ -3865,6 +3962,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],
|
||||||
@@ -3872,6 +3972,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
|
||||||
}
|
}
|
||||||
@@ -3930,6 +4033,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"
|
||||||
}
|
}
|
||||||
@@ -3943,6 +4050,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)),
|
||||||
@@ -4242,6 +4352,136 @@ func (a *App) toast(msg string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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))
|
||||||
@@ -7426,6 +7666,90 @@ func (a *App) FlexSetMute(on bool) error {
|
|||||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMute(on) })
|
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")
|
||||||
@@ -7552,7 +7876,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
|
||||||
@@ -7579,6 +7903,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()
|
||||||
|
|||||||
+135
-7
@@ -7,13 +7,14 @@ import {
|
|||||||
import {
|
import {
|
||||||
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
|
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
|
||||||
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
|
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
|
||||||
|
SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected,
|
||||||
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
||||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
||||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||||
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 +68,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';
|
||||||
@@ -730,6 +732,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]);
|
||||||
@@ -817,11 +821,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(() => ''),
|
||||||
@@ -1099,6 +1104,35 @@ export default function App() {
|
|||||||
return () => { off(); };
|
return () => { off(); };
|
||||||
}, [showToast]);
|
}, [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(() => {
|
||||||
@@ -1431,6 +1465,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() {
|
||||||
@@ -1934,6 +1981,40 @@ export default function App() {
|
|||||||
showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
|
showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
|
||||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
// Cabrillo (contest log) export — prompts for the contest name, then writes a
|
||||||
|
// .cbr file. All / filtered / selected variants mirror the ADIF ones.
|
||||||
|
async function exportCabrillo() {
|
||||||
|
const contest = window.prompt('Contest name for Cabrillo (e.g. CQ-WW-SSB):', '');
|
||||||
|
if (contest === null) return;
|
||||||
|
try {
|
||||||
|
const path = await SaveCabrilloFile();
|
||||||
|
if (!path) return;
|
||||||
|
const r = await ExportCabrillo(path, contest);
|
||||||
|
showToast(`Cabrillo: ${r.count} QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function exportFilteredCabrillo() {
|
||||||
|
const contest = window.prompt('Contest name for Cabrillo (e.g. CQ-WW-SSB):', '');
|
||||||
|
if (contest === null) return;
|
||||||
|
try {
|
||||||
|
const path = await SaveCabrilloFile();
|
||||||
|
if (!path) return;
|
||||||
|
const f = buildActiveFilter();
|
||||||
|
const r = await ExportCabrilloFiltered(path, contest, { ...f, limit: 0, offset: 0 } as any);
|
||||||
|
showToast(`Cabrillo: ${r.count} QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function exportSelectedCabrillo(ids: number[]) {
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
const contest = window.prompt('Contest name for Cabrillo (e.g. CQ-WW-SSB):', '');
|
||||||
|
if (contest === null) return;
|
||||||
|
try {
|
||||||
|
const path = await SaveCabrilloFile();
|
||||||
|
if (!path) return;
|
||||||
|
const r = await ExportCabrilloSelected(path, contest, ids as any);
|
||||||
|
showToast(`Cabrillo: ${r.count} selected QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
function askDelete(id: number) { setDeletingIds([id]); }
|
function askDelete(id: number) { setDeletingIds([id]); }
|
||||||
// Delete the whole multi-row selection (Edit menu / Delete key).
|
// Delete the whole multi-row selection (Edit menu / Delete key).
|
||||||
function askDeleteSelected() {
|
function askDeleteSelected() {
|
||||||
@@ -1978,6 +2059,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
|
||||||
@@ -2015,6 +2101,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
|
||||||
@@ -2065,8 +2154,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
|
||||||
@@ -2156,6 +2251,7 @@ export default function App() {
|
|||||||
{ name: 'file', label: 'File', items: [
|
{ name: 'file', label: 'File', items: [
|
||||||
{ type: 'item', label: 'Import ADIF…', action: 'file.import', shortcut: 'Ctrl+O' },
|
{ type: 'item', label: 'Import ADIF…', action: 'file.import', shortcut: 'Ctrl+O' },
|
||||||
{ type: 'item', label: exporting ? 'Exporting…' : 'Export ADIF…', action: 'file.export', shortcut: 'Ctrl+E', disabled: exporting || total === 0 },
|
{ type: 'item', label: exporting ? 'Exporting…' : 'Export ADIF…', action: 'file.export', shortcut: 'Ctrl+E', disabled: exporting || total === 0 },
|
||||||
|
{ type: 'item', label: 'Export Cabrillo…', action: 'file.exportCabrillo', disabled: total === 0 },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: 'Delete all QSOs…', action: 'file.deleteall', disabled: total === 0 },
|
{ type: 'item', label: 'Delete all QSOs…', action: 'file.deleteall', disabled: total === 0 },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
@@ -2181,6 +2277,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.
|
||||||
@@ -2196,6 +2293,7 @@ export default function App() {
|
|||||||
switch (action) {
|
switch (action) {
|
||||||
case 'file.import': importAdif(); break;
|
case 'file.import': importAdif(); break;
|
||||||
case 'file.export': exportAdif(); break;
|
case 'file.export': exportAdif(); break;
|
||||||
|
case 'file.exportCabrillo': exportCabrillo(); break;
|
||||||
case 'file.deleteall': setShowDeleteAll(true); break;
|
case 'file.deleteall': setShowDeleteAll(true); break;
|
||||||
case 'view.refresh': refresh(); break;
|
case 'view.refresh': refresh(); break;
|
||||||
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
|
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
|
||||||
@@ -2209,6 +2307,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;
|
||||||
@@ -2804,6 +2903,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':
|
||||||
@@ -2834,7 +2934,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3661,6 +3783,8 @@ export default function App() {
|
|||||||
onBulkEdit={openBulkEdit}
|
onBulkEdit={openBulkEdit}
|
||||||
onExportSelected={exportSelectedADIF}
|
onExportSelected={exportSelectedADIF}
|
||||||
onExportFiltered={exportFilteredADIF}
|
onExportFiltered={exportFilteredADIF}
|
||||||
|
onExportCabrilloSelected={exportSelectedCabrillo}
|
||||||
|
onExportCabrilloFiltered={exportFilteredCabrillo}
|
||||||
onDelete={(ids) => setDeletingIds(ids)}
|
onDelete={(ids) => setDeletingIds(ids)}
|
||||||
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
||||||
/>
|
/>
|
||||||
@@ -3867,7 +3991,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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -4050,6 +4174,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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,6 +52,18 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
|
|
||||||
const entries = value ? value.split(';').filter(Boolean) : [];
|
const entries = value ? value.split(';').filter(Boolean) : [];
|
||||||
|
|
||||||
|
// When the reference set is cleared (a QSO was logged or the call was reset),
|
||||||
|
// also blank the picker's own state — the search box and the highlighted /
|
||||||
|
// selected reference — so the Awards tab starts fresh for the next contact.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!value) {
|
||||||
|
setSelectedEntry(null);
|
||||||
|
setSelectedRef(null);
|
||||||
|
setQ('');
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([GetAwardDefs(), GetAwardReferenceMeta()])
|
Promise.all([GetAwardDefs(), GetAwardReferenceMeta()])
|
||||||
.then(([d, m]) => {
|
.then(([d, m]) => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||||
FlexMox, FlexAmpOperate,
|
FlexMox, FlexAmpOperate,
|
||||||
GetPGXLStatus, PGXLSetFanMode,
|
GetPGXLStatus, PGXLSetFanMode,
|
||||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute,
|
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,
|
||||||
@@ -20,6 +20,8 @@ type FlexState = {
|
|||||||
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; mute: boolean;
|
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, mute: false,
|
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,6 +379,25 @@ 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}
|
||||||
|
|||||||
@@ -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,15 +165,16 @@ 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 single FILLED shape that HUGS the great-circle
|
// Draw the lobe as a DENSE fan of translucent great-circle radials, not
|
||||||
// curve: bound it by the two edge radials (az ± half-beamwidth), each a
|
// a filled polygon: a polygon smears badly near the poles on Mercator
|
||||||
// great-circle line, joined by a short far cap. Filling between the two
|
// (a poleward beam degenerates into a giant triangle), while each radial
|
||||||
// curved edges follows the arc instead of making a straight triangle.
|
// LINE stays clean at any azimuth. A small angular step makes the lines
|
||||||
const edgeL = radial(az - half);
|
// overlap into a solid-looking lobe (no separate strokes), darker near
|
||||||
const edgeR = radial(az + half);
|
// the antenna and fanning out toward the front.
|
||||||
const lobe = [[from.lat, from.lon], ...edgeL, ...edgeR.slice().reverse()] as [number, number][];
|
for (let b = az - half; b <= az + half + 0.001; b += 0.5) {
|
||||||
L.polygon(unwrapLon(lobe) as L.LatLngExpression[],
|
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]);
|
||||||
{ stroke: false, fillColor: '#ff2d2d', fillOpacity: 0.18, 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
|
||||||
@@ -224,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);
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ type Props = {
|
|||||||
onBulkEdit?: (ids: number[]) => void;
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
|
onExportCabrilloFiltered?: () => void;
|
||||||
onDelete?: (ids: number[]) => void;
|
onDelete?: (ids: number[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
|||||||
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
||||||
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
||||||
// which used to dismiss the menu the instant it appeared.)
|
// which used to dismiss the menu the instant it appeared.)
|
||||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete }: Props) {
|
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
const close = () => onClose();
|
const close = () => onClose();
|
||||||
@@ -142,6 +144,24 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
<span>Export filtered view to ADIF (no limit)</span>
|
<span>Export filtered view to ADIF (no limit)</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onExportCabrilloSelected && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onExportCabrilloSelected(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<FileDown className="size-4 text-amber-600" />
|
||||||
|
<span>Export selected to Cabrillo ({n})</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onExportCabrilloFiltered && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onExportCabrilloFiltered(); onClose(); }}
|
||||||
|
>
|
||||||
|
<FileDown className="size-4 text-amber-700" />
|
||||||
|
<span>Export filtered view to Cabrillo (no limit)</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ type Props = {
|
|||||||
onBulkEdit?: (ids: number[]) => void;
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
|
onExportCabrilloFiltered?: () => void;
|
||||||
onDelete?: (ids: number[]) => void;
|
onDelete?: (ids: number[]) => void;
|
||||||
// One column per defined award; the cell shows the reference this QSO counts
|
// One column per defined award; the cell shows the reference this QSO counts
|
||||||
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
||||||
@@ -228,7 +230,7 @@ export const GROUP_ORDER = [
|
|||||||
'Contest', 'Propagation', 'My station', 'Misc',
|
'Contest', 'Propagation', 'My station', 'Misc',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, selectAllSignal, 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, onExportCabrilloSelected, onExportCabrilloFiltered, 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);
|
||||||
@@ -254,7 +256,14 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
// 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 };
|
||||||
@@ -290,6 +299,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
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);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -301,9 +311,11 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
// 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>) {
|
||||||
@@ -406,6 +418,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
onBulkEdit={onBulkEdit}
|
onBulkEdit={onBulkEdit}
|
||||||
onExportSelected={onExportSelected}
|
onExportSelected={onExportSelected}
|
||||||
onExportFiltered={onExportFiltered}
|
onExportFiltered={onExportFiltered}
|
||||||
|
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||||
|
onExportCabrilloFiltered={onExportCabrilloFiltered}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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,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.1';
|
export const APP_VERSION = '0.16.4';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+31
@@ -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>;
|
||||||
@@ -128,6 +131,12 @@ export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):
|
|||||||
|
|
||||||
export function ExportAwards():Promise<string>;
|
export function ExportAwards():Promise<string>;
|
||||||
|
|
||||||
|
export function ExportCabrillo(arg1:string,arg2:string):Promise<main.CabrilloResult>;
|
||||||
|
|
||||||
|
export function ExportCabrilloFiltered(arg1:string,arg2:string,arg3:qso.QueryFilter):Promise<main.CabrilloResult>;
|
||||||
|
|
||||||
|
export function ExportCabrilloSelected(arg1:string,arg2:string,arg3:Array<number>):Promise<main.CabrilloResult>;
|
||||||
|
|
||||||
export function FilterFields():Promise<Array<string>>;
|
export function FilterFields():Promise<Array<string>>;
|
||||||
|
|
||||||
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.QSO>>;
|
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.QSO>>;
|
||||||
@@ -138,6 +147,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>;
|
||||||
@@ -190,8 +201,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>;
|
||||||
@@ -204,6 +221,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>;
|
||||||
@@ -258,6 +277,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>;
|
||||||
@@ -344,6 +365,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>>;
|
||||||
@@ -502,6 +525,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>;
|
||||||
@@ -516,12 +541,16 @@ export function SaveBackupSettings(arg1:main.BackupSettings):Promise<void>;
|
|||||||
|
|
||||||
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveCabrilloFile():Promise<string>;
|
||||||
|
|
||||||
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
|
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
|
||||||
|
|
||||||
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
|
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>;
|
||||||
@@ -562,6 +591,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>;
|
||||||
|
|||||||
@@ -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']();
|
||||||
}
|
}
|
||||||
@@ -222,6 +226,18 @@ export function ExportAwards() {
|
|||||||
return window['go']['main']['App']['ExportAwards']();
|
return window['go']['main']['App']['ExportAwards']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ExportCabrillo(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['ExportCabrillo'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportCabrilloFiltered(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['ExportCabrilloFiltered'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportCabrilloSelected(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['ExportCabrilloSelected'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
export function FilterFields() {
|
export function FilterFields() {
|
||||||
return window['go']['main']['App']['FilterFields']();
|
return window['go']['main']['App']['FilterFields']();
|
||||||
}
|
}
|
||||||
@@ -242,6 +258,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);
|
||||||
}
|
}
|
||||||
@@ -346,10 +366,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);
|
||||||
}
|
}
|
||||||
@@ -374,6 +406,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']();
|
||||||
}
|
}
|
||||||
@@ -482,6 +518,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']();
|
||||||
}
|
}
|
||||||
@@ -654,6 +694,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']();
|
||||||
}
|
}
|
||||||
@@ -970,6 +1014,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);
|
||||||
}
|
}
|
||||||
@@ -998,6 +1046,10 @@ export function SaveCATSettings(arg1) {
|
|||||||
return window['go']['main']['App']['SaveCATSettings'](arg1);
|
return window['go']['main']['App']['SaveCATSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveCabrilloFile() {
|
||||||
|
return window['go']['main']['App']['SaveCabrilloFile']();
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveClusterServer(arg1) {
|
export function SaveClusterServer(arg1) {
|
||||||
return window['go']['main']['App']['SaveClusterServer'](arg1);
|
return window['go']['main']['App']['SaveClusterServer'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1010,6 +1062,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);
|
||||||
}
|
}
|
||||||
@@ -1090,6 +1146,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 +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;
|
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;
|
||||||
@@ -561,10 +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.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"];
|
||||||
@@ -1162,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;
|
||||||
@@ -1181,11 +1245,28 @@ 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"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class CabrilloResult {
|
||||||
|
count: number;
|
||||||
|
path: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new CabrilloResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.count = source["count"];
|
||||||
|
this.path = source["path"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ChatMessage {
|
export class ChatMessage {
|
||||||
id: number;
|
id: number;
|
||||||
operator: string;
|
operator: string;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
// Package cabrillo writes a Cabrillo v3 contest log from OpsLog QSOs — the
|
||||||
|
// format most contest sponsors require for log submission (a header of
|
||||||
|
// CATEGORY-* / CONTEST / CALLSIGN tags followed by one "QSO:" line each).
|
||||||
|
package cabrillo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Header holds the Cabrillo header fields. Only Callsign and Contest really
|
||||||
|
// matter; the rest carry sensible defaults the operator can edit in the file
|
||||||
|
// before submitting (categories vary per contest).
|
||||||
|
type Header struct {
|
||||||
|
Callsign string
|
||||||
|
Contest string
|
||||||
|
Operators string
|
||||||
|
CatOperator string // SINGLE-OP | MULTI-OP | CHECKLOG
|
||||||
|
CatBand string // ALL | 20M | …
|
||||||
|
CatMode string // CW | SSB | MIXED | …
|
||||||
|
CatPower string // HIGH | LOW | QRP
|
||||||
|
GridLocator string
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
Club string
|
||||||
|
}
|
||||||
|
|
||||||
|
func def(v, d string) string {
|
||||||
|
if strings.TrimSpace(v) == "" {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate writes the Cabrillo document for qsos and returns the number of QSO
|
||||||
|
// lines written (rows without a usable frequency/callsign are skipped).
|
||||||
|
func Generate(w io.Writer, h Header, qsos []qso.QSO) (int, error) {
|
||||||
|
myCall := strings.ToUpper(strings.TrimSpace(h.Callsign))
|
||||||
|
fmt.Fprintln(w, "START-OF-LOG: 3.0")
|
||||||
|
fmt.Fprintln(w, "CREATED-BY: OpsLog")
|
||||||
|
fmt.Fprintf(w, "CONTEST: %s\n", strings.ToUpper(def(h.Contest, "UNKNOWN")))
|
||||||
|
fmt.Fprintf(w, "CALLSIGN: %s\n", myCall)
|
||||||
|
fmt.Fprintf(w, "CATEGORY-OPERATOR: %s\n", strings.ToUpper(def(h.CatOperator, "SINGLE-OP")))
|
||||||
|
fmt.Fprintf(w, "CATEGORY-BAND: %s\n", strings.ToUpper(def(h.CatBand, "ALL")))
|
||||||
|
fmt.Fprintf(w, "CATEGORY-MODE: %s\n", strings.ToUpper(def(h.CatMode, "MIXED")))
|
||||||
|
fmt.Fprintf(w, "CATEGORY-POWER: %s\n", strings.ToUpper(def(h.CatPower, "HIGH")))
|
||||||
|
fmt.Fprintln(w, "CLAIMED-SCORE: 0")
|
||||||
|
fmt.Fprintf(w, "OPERATORS: %s\n", strings.ToUpper(def(h.Operators, myCall)))
|
||||||
|
if h.GridLocator != "" {
|
||||||
|
fmt.Fprintf(w, "GRID-LOCATOR: %s\n", strings.ToUpper(h.GridLocator))
|
||||||
|
}
|
||||||
|
if h.Name != "" {
|
||||||
|
fmt.Fprintf(w, "NAME: %s\n", h.Name)
|
||||||
|
}
|
||||||
|
if h.Club != "" {
|
||||||
|
fmt.Fprintf(w, "CLUB: %s\n", h.Club)
|
||||||
|
}
|
||||||
|
if h.Email != "" {
|
||||||
|
fmt.Fprintf(w, "EMAIL: %s\n", h.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
n := 0
|
||||||
|
for _, q := range qsos {
|
||||||
|
call := strings.ToUpper(strings.TrimSpace(q.Callsign))
|
||||||
|
if call == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
khz := freqKHz(q)
|
||||||
|
mode := cabrilloMode(q.Mode)
|
||||||
|
myRST := def(q.RSTSent, defaultRST(mode))
|
||||||
|
hisRST := def(q.RSTRcvd, defaultRST(mode))
|
||||||
|
myExch := exchange(q.STX, q.STXString)
|
||||||
|
hisExch := exchange(q.SRX, q.SRXString)
|
||||||
|
// QSO: <freq> <mo> <date> <time> <mycall> <myrst> <myexch> <call> <rst> <exch>
|
||||||
|
fmt.Fprintf(w, "QSO: %5d %-2s %s %s %-13s %-3s %-6s %-13s %-3s %-6s\n",
|
||||||
|
khz, mode,
|
||||||
|
q.QSODate.UTC().Format("2006-01-02"), q.QSODate.UTC().Format("1504"),
|
||||||
|
myCall, myRST, myExch, call, hisRST, hisExch)
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, "END-OF-LOG:")
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// freqKHz returns the QSO frequency in kHz, falling back to a nominal frequency
|
||||||
|
// for the band when the exact frequency wasn't logged.
|
||||||
|
func freqKHz(q qso.QSO) int {
|
||||||
|
if q.FreqHz != nil && *q.FreqHz > 0 {
|
||||||
|
return int((*q.FreqHz + 500) / 1000)
|
||||||
|
}
|
||||||
|
return bandNominalKHz(q.Band)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bandNominalKHz(band string) int {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(band)) {
|
||||||
|
case "160m":
|
||||||
|
return 1800
|
||||||
|
case "80m":
|
||||||
|
return 3500
|
||||||
|
case "60m":
|
||||||
|
return 5350
|
||||||
|
case "40m":
|
||||||
|
return 7000
|
||||||
|
case "30m":
|
||||||
|
return 10100
|
||||||
|
case "20m":
|
||||||
|
return 14000
|
||||||
|
case "17m":
|
||||||
|
return 18068
|
||||||
|
case "15m":
|
||||||
|
return 21000
|
||||||
|
case "12m":
|
||||||
|
return 24890
|
||||||
|
case "10m":
|
||||||
|
return 28000
|
||||||
|
case "6m":
|
||||||
|
return 50000
|
||||||
|
case "2m":
|
||||||
|
return 144000
|
||||||
|
case "70cm":
|
||||||
|
return 432000
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// cabrilloMode maps an ADIF mode to the two-letter Cabrillo mode code.
|
||||||
|
func cabrilloMode(mode string) string {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
|
case "CW":
|
||||||
|
return "CW"
|
||||||
|
case "RTTY":
|
||||||
|
return "RY"
|
||||||
|
case "FM":
|
||||||
|
return "FM"
|
||||||
|
case "SSB", "USB", "LSB", "AM":
|
||||||
|
return "PH"
|
||||||
|
case "":
|
||||||
|
return "PH"
|
||||||
|
default:
|
||||||
|
return "DG" // FT8/FT4/PSK/JT/DATA and other digital modes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultRST(cabMode string) string {
|
||||||
|
if cabMode == "CW" || cabMode == "RY" || cabMode == "DG" {
|
||||||
|
return "599"
|
||||||
|
}
|
||||||
|
return "59"
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchange renders the contest exchange: the string field if set, else the
|
||||||
|
// numeric serial, else empty.
|
||||||
|
func exchange(num *int, str string) string {
|
||||||
|
if s := strings.TrimSpace(str); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if num != nil && *num > 0 {
|
||||||
|
return fmt.Sprintf("%d", *num)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -248,10 +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"`
|
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"`
|
||||||
@@ -314,6 +321,9 @@ type FlexController interface {
|
|||||||
SetAGCThreshold(int) error
|
SetAGCThreshold(int) error
|
||||||
SetAudioLevel(int) error
|
SetAudioLevel(int) error
|
||||||
SetMute(bool) 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
|
||||||
|
|||||||
+114
-1
@@ -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
|
||||||
|
|
||||||
@@ -84,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
|
||||||
@@ -144,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{},
|
||||||
}
|
}
|
||||||
@@ -310,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.
|
||||||
@@ -673,6 +689,14 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
s.audioLevel = atoiDefault(val, s.audioLevel)
|
s.audioLevel = atoiDefault(val, s.audioLevel)
|
||||||
case "audio_mute", "mute":
|
case "audio_mute", "mute":
|
||||||
s.mute = val == "1"
|
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":
|
||||||
@@ -960,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 {
|
||||||
@@ -1026,6 +1062,11 @@ 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)
|
||||||
@@ -1043,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
|
||||||
@@ -1098,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()
|
||||||
@@ -1133,6 +1185,67 @@ 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) 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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
+1
-1
@@ -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.1"
|
appVersion = "0.16.4"
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user