Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d327db3f57 | ||
|
|
59e6570f17 | ||
|
|
82a2c6cb7f | ||
|
|
24eaf597fd | ||
|
|
14a22ddb66 | ||
|
|
9156acea5f | ||
|
|
5d0906f00e | ||
|
|
901e967b53 | ||
|
|
4ab4f70349 | ||
|
|
64e80986ea | ||
|
|
816c6ffcf1 | ||
|
|
2166d1aa4b | ||
|
|
0a9a09bec2 | ||
|
|
34ec91684e | ||
|
|
11f1e332f7 | ||
|
|
bd9e091e65 | ||
|
|
d38c783dcc | ||
|
|
c825caa7a8 | ||
|
|
215652570c | ||
|
|
79552bfae1 | ||
|
|
8fc04563e1 | ||
|
|
19993bafc1 | ||
|
|
da1793a902 | ||
|
|
14c87f7fa9 | ||
|
|
9d4ccb9254 |
+251
@@ -0,0 +1,251 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// ADIF monitor: watches a configurable list of external ADIF files (fldigi's
|
||||||
|
// RTTY logbook, N1MM, VarAC…) and imports newly appended QSOs into OpsLog as if
|
||||||
|
// they had been logged here — same enrichment, dedup and automatic upload to
|
||||||
|
// external services (QRZ, Club Log…). Deliberately simpler than Log4OM's monitor:
|
||||||
|
// no per-file "upload" / "delete after load" toggles — importing + auto-upload is
|
||||||
|
// just what happens.
|
||||||
|
//
|
||||||
|
// Design notes:
|
||||||
|
// - A newly added file starts at its CURRENT size (Offset = -1 sentinel → set to
|
||||||
|
// size on first scan) so the QSOs already in it are NOT bulk-imported; only
|
||||||
|
// contacts appended AFTER you add the file come in.
|
||||||
|
// - Reads only up to the last complete <eor>, so a half-written record waits for
|
||||||
|
// the next poll instead of importing a truncated QSO.
|
||||||
|
// - Each record is fed through LogUDPLoggedADIF, which already does the full
|
||||||
|
// enrichment + ±2-minute dedup (shared udpLogMu, so it can't race the UDP
|
||||||
|
// auto-log) + automatic external-service upload.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyADIFMonitor = "adifmon.config"
|
||||||
|
|
||||||
|
// ADIFWatchFile is one monitored ADIF file.
|
||||||
|
type ADIFWatchFile struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// Offset is the number of bytes already consumed. -1 means "not yet
|
||||||
|
// initialised": the first scan sets it to the file's current size so existing
|
||||||
|
// history is skipped.
|
||||||
|
Offset int64 `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADIFMonitorConfig is the whole monitor setup: a master switch + the file list.
|
||||||
|
type ADIFMonitorConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Files []ADIFWatchFile `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const eorTag = "<eor>"
|
||||||
|
|
||||||
|
func (a *App) loadADIFMonitorLocked() ADIFMonitorConfig {
|
||||||
|
var cfg ADIFMonitorConfig
|
||||||
|
if a.settings == nil {
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
s, _ := a.settings.GetGlobal(a.ctx, keyADIFMonitor)
|
||||||
|
if strings.TrimSpace(s) != "" {
|
||||||
|
_ = json.Unmarshal([]byte(s), &cfg)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) saveADIFMonitorLocked(cfg ADIFMonitorConfig) {
|
||||||
|
b, _ := json.Marshal(cfg)
|
||||||
|
a.setSettingGlobal(keyADIFMonitor, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetADIFMonitor returns the monitor configuration for the settings UI.
|
||||||
|
func (a *App) GetADIFMonitor() ADIFMonitorConfig {
|
||||||
|
a.adifMonMu.Lock()
|
||||||
|
defer a.adifMonMu.Unlock()
|
||||||
|
return a.loadADIFMonitorLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveADIFMonitor persists the monitor configuration. A file the user just added
|
||||||
|
// gets Offset = -1 so its existing content is skipped (only QSOs logged AFTER it
|
||||||
|
// was added import); a file already present keeps its current read position.
|
||||||
|
func (a *App) SaveADIFMonitor(cfg ADIFMonitorConfig) error {
|
||||||
|
a.adifMonMu.Lock()
|
||||||
|
defer a.adifMonMu.Unlock()
|
||||||
|
old := a.loadADIFMonitorLocked()
|
||||||
|
oldOff := make(map[string]int64, len(old.Files))
|
||||||
|
for _, f := range old.Files {
|
||||||
|
oldOff[strings.TrimSpace(f.Path)] = f.Offset
|
||||||
|
}
|
||||||
|
for i := range cfg.Files {
|
||||||
|
cfg.Files[i].Path = strings.TrimSpace(cfg.Files[i].Path)
|
||||||
|
if off, ok := oldOff[cfg.Files[i].Path]; ok {
|
||||||
|
cfg.Files[i].Offset = off // keep the read position of an existing file
|
||||||
|
} else {
|
||||||
|
cfg.Files[i].Offset = -1 // new file → skip its existing history
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.saveADIFMonitorLocked(cfg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickADIFMonitorFile opens a file dialog to choose an ADIF file to monitor.
|
||||||
|
func (a *App) PickADIFMonitorFile() (string, error) {
|
||||||
|
if a.ctx == nil {
|
||||||
|
return "", fmt.Errorf("no app context")
|
||||||
|
}
|
||||||
|
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||||
|
Title: "Choose an ADIF file to monitor",
|
||||||
|
Filters: []wruntime.FileFilter{
|
||||||
|
{DisplayName: "ADIF (*.adi;*.adif)", Pattern: "*.adi;*.adif"},
|
||||||
|
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adifMonitorLoop polls the enabled ADIF files every few seconds and imports any
|
||||||
|
// newly appended QSOs. Runs for the app's lifetime on its own goroutine.
|
||||||
|
func (a *App) adifMonitorLoop() {
|
||||||
|
tick := time.NewTicker(5 * time.Second)
|
||||||
|
defer tick.Stop()
|
||||||
|
for range tick.C {
|
||||||
|
if a.ctx == nil || a.qso == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.scanADIFMonitors()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanADIFMonitors walks the enabled files once, importing new records and
|
||||||
|
// persisting advanced offsets.
|
||||||
|
func (a *App) scanADIFMonitors() {
|
||||||
|
a.adifMonMu.Lock()
|
||||||
|
cfg := a.loadADIFMonitorLocked()
|
||||||
|
a.adifMonMu.Unlock()
|
||||||
|
if !cfg.Enabled || len(cfg.Files) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// path → new offset, for the files we advanced this pass.
|
||||||
|
advanced := map[string]int64{}
|
||||||
|
for i := range cfg.Files {
|
||||||
|
f := &cfg.Files[i]
|
||||||
|
if !f.Enabled || strings.TrimSpace(f.Path) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fi, err := os.Stat(f.Path)
|
||||||
|
if err != nil {
|
||||||
|
continue // not present (yet) — try again next tick
|
||||||
|
}
|
||||||
|
size := fi.Size()
|
||||||
|
if f.Offset < 0 {
|
||||||
|
// First sight of this file → skip whatever history it already holds.
|
||||||
|
advanced[f.Path] = size
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if size < f.Offset {
|
||||||
|
f.Offset = 0 // truncated / rotated → re-read from the start (dedup protects us)
|
||||||
|
}
|
||||||
|
if size == f.Offset {
|
||||||
|
continue // nothing new
|
||||||
|
}
|
||||||
|
newOff, n := a.importADIFAppend(f.Path, f.Offset, size)
|
||||||
|
if newOff != f.Offset {
|
||||||
|
advanced[f.Path] = newOff
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
applog.Printf("adif monitor: imported %d QSO(s) from %s", n, f.Path)
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "adifmon:imported", map[string]any{"file": f.Path, "count": n})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(advanced) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Persist the advanced offsets WITHOUT clobbering a concurrent UI save of the
|
||||||
|
// file list: re-read the stored config and only patch offsets for paths that
|
||||||
|
// still exist there.
|
||||||
|
a.adifMonMu.Lock()
|
||||||
|
cur := a.loadADIFMonitorLocked()
|
||||||
|
for i := range cur.Files {
|
||||||
|
if off, ok := advanced[strings.TrimSpace(cur.Files[i].Path)]; ok {
|
||||||
|
cur.Files[i].Offset = off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.saveADIFMonitorLocked(cur)
|
||||||
|
a.adifMonMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// importADIFAppend reads bytes [from,to) of an ADIF file, imports every COMPLETE
|
||||||
|
// record found (up to the last <eor>) and returns the new offset (just past that
|
||||||
|
// last <eor>) plus how many QSOs were actually imported (duplicates excluded).
|
||||||
|
func (a *App) importADIFAppend(path string, from, to int64) (int64, int) {
|
||||||
|
fh, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return from, 0
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
if _, err := fh.Seek(from, io.SeekStart); err != nil {
|
||||||
|
return from, 0
|
||||||
|
}
|
||||||
|
buf := make([]byte, to-from)
|
||||||
|
nRead, err := io.ReadFull(fh, buf)
|
||||||
|
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
||||||
|
return from, 0
|
||||||
|
}
|
||||||
|
buf = buf[:nRead]
|
||||||
|
|
||||||
|
// Only consume up to the last complete <eor>; a half-written trailing record
|
||||||
|
// waits for the next poll.
|
||||||
|
lower := bytes.ToLower(buf)
|
||||||
|
last := bytes.LastIndex(lower, []byte(eorTag))
|
||||||
|
if last < 0 {
|
||||||
|
return from, 0 // no complete record yet
|
||||||
|
}
|
||||||
|
end := last + len(eorTag)
|
||||||
|
chunk := buf[:end]
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for _, rec := range splitADIFRecords(chunk) {
|
||||||
|
if strings.TrimSpace(rec) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := a.LogUDPLoggedADIF(rec); err == nil {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
// A duplicate (already in the log within ±2 min) returns an error and is
|
||||||
|
// simply not counted — expected when the same QSO is also logged in OpsLog.
|
||||||
|
}
|
||||||
|
return from + int64(end), count
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitADIFRecords cuts an ADIF byte slice into individual record texts, each
|
||||||
|
// ending at its <eor> (case-insensitive). Any leading file header (up to the
|
||||||
|
// first record's <eor>) rides along with the first record — LogUDPLoggedADIF
|
||||||
|
// parses past an <EOH> header fine, and prepends one when there is none.
|
||||||
|
func splitADIFRecords(b []byte) []string {
|
||||||
|
lower := bytes.ToLower(b)
|
||||||
|
var out []string
|
||||||
|
start := 0
|
||||||
|
for {
|
||||||
|
rel := bytes.Index(lower[start:], []byte(eorTag))
|
||||||
|
if rel < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end := start + rel + len(eorTag)
|
||||||
|
out = append(out, string(b[start:end]))
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -483,6 +483,10 @@ type App struct {
|
|||||||
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
||||||
pttMu sync.Mutex
|
pttMu sync.Mutex
|
||||||
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
|
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
|
||||||
|
adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets)
|
||||||
|
relayAutoMu sync.Mutex // serialises relay auto-control evaluation
|
||||||
|
relayAutoLast map[string]bool // deviceID|relay → last applied on/off, so we only switch on a real change
|
||||||
|
relayAutoOn atomic.Bool // cached "auto-control enabled" so the CAT hot path skips work when off
|
||||||
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
||||||
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
||||||
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
|
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
|
||||||
@@ -501,6 +505,7 @@ type App struct {
|
|||||||
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
|
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
|
||||||
liveBand string
|
liveBand string
|
||||||
liveMode string
|
liveMode string
|
||||||
|
liveLastQSOAt time.Time // when this operator last logged a NEW contact — drives online/offline
|
||||||
awardSnapMu sync.Mutex // guards the award QSO snapshot
|
awardSnapMu sync.Mutex // guards the award QSO snapshot
|
||||||
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
|
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
|
||||||
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
|
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
|
||||||
@@ -808,6 +813,8 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
a.logDb = logbookConn
|
a.logDb = logbookConn
|
||||||
a.qso = qso.NewRepo(logbookConn)
|
a.qso = qso.NewRepo(logbookConn)
|
||||||
go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks
|
go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks
|
||||||
|
go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs
|
||||||
|
a.relayAutoOn.Store(a.GetRelayAuto().Enabled) // prime the relay auto-control hot-path flag
|
||||||
|
|
||||||
// cty.dat for offline DXCC / country resolution. Cached on disk; first
|
// cty.dat for offline DXCC / country resolution. Cached on disk; first
|
||||||
// run downloads it from country-files.com in the background so startup
|
// run downloads it from country-files.com in the background so startup
|
||||||
@@ -893,6 +900,13 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
||||||
}
|
}
|
||||||
a.emitRadioUDP(s)
|
a.emitRadioUDP(s)
|
||||||
|
// Drive station relays by the current frequency/band (PstRotator-style
|
||||||
|
// automatic control). Cheap cached-flag check keeps this a no-op when the
|
||||||
|
// feature is off; when on, run off this callback so a slow relay board never
|
||||||
|
// stalls rig-state processing.
|
||||||
|
if a.relayAutoOn.Load() {
|
||||||
|
go a.applyRelayAuto(s.FreqHz, s.Band)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
a.reloadCAT()
|
a.reloadCAT()
|
||||||
|
|
||||||
@@ -1837,13 +1851,12 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
||||||
a.applyQSLDefaults(&q)
|
a.applyQSLDefaults(&q)
|
||||||
a.applySolar(&q) // stamp SFI / A / K (and SSN as an extra) from live space-weather
|
a.applySolar(&q) // stamp SFI / A / K (and SSN as an extra) from live space-weather
|
||||||
// Fill the contacted operator's e-mail from the (cached) lookup so the
|
// NOTE: the contacted operator's e-mail already rides in on the QSO — the entry
|
||||||
// recording can be auto-sent. Cheap: the entry already looked the call up.
|
// lookup (when you typed the call) fetched it along with name/QTH/grid and the
|
||||||
if strings.TrimSpace(q.Email) == "" && a.lookup != nil {
|
// form carries it here. There is deliberately NO second lookup at log time: it
|
||||||
if lr, e := a.lookup.Lookup(a.ctx, q.Callsign); e == nil && lr.Email != "" {
|
// was redundant with the entry lookup and only slowed logging (a call not yet in
|
||||||
q.Email = lr.Email
|
// the cache made AddQSO wait on QRZ/HamQTH). If the entry lookup hadn't finished
|
||||||
}
|
// when you logged (fast CW: type → Enter), the e-mail is simply blank — fine.
|
||||||
}
|
|
||||||
id, err = a.qso.Add(a.ctx, q)
|
id, err = a.qso.Add(a.ctx, q)
|
||||||
if err != nil && db.IsConnLost(err) {
|
if err != nil && db.IsConnLost(err) {
|
||||||
// The database is UNREACHABLE (not a data error) — park the QSO in the
|
// The database is UNREACHABLE (not a data error) — park the QSO in the
|
||||||
@@ -1859,6 +1872,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
q.ID = id
|
q.ID = id
|
||||||
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh
|
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh
|
||||||
|
a.noteLiveQSO() // multi-op: flip this operator back "online"
|
||||||
// Announce the log so UI widgets can react (e.g. the Flex panel zeroing RIT).
|
// Announce the log so UI widgets can react (e.g. the Flex panel zeroing RIT).
|
||||||
wruntime.EventsEmit(a.ctx, "qso:logged", id)
|
wruntime.EventsEmit(a.ctx, "qso:logged", id)
|
||||||
a.saveQSORecording(&q)
|
a.saveQSORecording(&q)
|
||||||
@@ -4501,6 +4515,36 @@ func (a *App) GetOperators() ([]string, error) {
|
|||||||
return a.qso.Operators(a.ctx)
|
return a.qso.Operators(a.ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QSORate is the live QSO-rate meter shown in the header: how many QSOs were
|
||||||
|
// logged in the trailing 10 and 60 minutes.
|
||||||
|
type QSORate struct {
|
||||||
|
Last10 int `json:"last10"`
|
||||||
|
Last60 int `json:"last60"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQSORate returns the number of QSOs logged in the last 10 and 60 minutes.
|
||||||
|
// Cheap (scans only the most recent rows); polled by the header and refreshed on
|
||||||
|
// each qso:logged event.
|
||||||
|
func (a *App) GetQSORate() QSORate {
|
||||||
|
if a.qso == nil {
|
||||||
|
return QSORate{}
|
||||||
|
}
|
||||||
|
// Per-operator on a shared logbook: count only the ACTIVE profile's operator
|
||||||
|
// so each op sees their own performance, not the cumulative station rate. An
|
||||||
|
// empty operator (single-op / station owner) matches all their QSOs.
|
||||||
|
operator := ""
|
||||||
|
if a.profiles != nil {
|
||||||
|
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||||
|
operator = p.Operator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
counts, err := a.qso.RecentRate(a.ctx, time.Now(), operator, 10*time.Minute, 60*time.Minute)
|
||||||
|
if err != nil || len(counts) < 2 {
|
||||||
|
return QSORate{}
|
||||||
|
}
|
||||||
|
return QSORate{Last10: counts[0], Last60: counts[1]}
|
||||||
|
}
|
||||||
|
|
||||||
// GetContestRuns lists the (contest, year) pairs actually present in the log, so
|
// GetContestRuns lists the (contest, year) pairs actually present in the log, so
|
||||||
// the Statistics picker only ever offers contests you really entered.
|
// the Statistics picker only ever offers contests you really entered.
|
||||||
func (a *App) GetContestRuns() ([]qso.ContestRun, error) {
|
func (a *App) GetContestRuns() ([]qso.ContestRun, error) {
|
||||||
@@ -5258,7 +5302,14 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
|
|||||||
if a.lookup == nil {
|
if a.lookup == nil {
|
||||||
return lookup.Result{}, fmt.Errorf("lookup not initialized")
|
return lookup.Result{}, fmt.Errorf("lookup not initialized")
|
||||||
}
|
}
|
||||||
r, err := a.lookup.Lookup(a.ctx, callsign)
|
// Bound the whole lookup: give the providers a couple of seconds, then let
|
||||||
|
// Lookup fall through to cty.dat (country/zones). Without this a call that isn't
|
||||||
|
// in QRZ.com — or a slow/unresponsive provider — left the "looking up" spinner
|
||||||
|
// turning for 10 s+ before the cty.dat fallback showed. The providers respect
|
||||||
|
// the context, so they're cancelled at the deadline and cty.dat answers instantly.
|
||||||
|
ctx, cancel := context.WithTimeout(a.ctx, 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
r, err := a.lookup.Lookup(ctx, callsign)
|
||||||
if errors.Is(err, lookup.ErrNotFound) {
|
if errors.Is(err, lookup.ErrNotFound) {
|
||||||
return lookup.Result{}, fmt.Errorf("callsign not found")
|
return lookup.Result{}, fmt.Errorf("callsign not found")
|
||||||
}
|
}
|
||||||
@@ -5788,8 +5839,10 @@ func (a *App) saveQSORecording(q *qso.QSO) {
|
|||||||
if q.Extras == nil {
|
if q.Extras == nil {
|
||||||
q.Extras = map[string]string{}
|
q.Extras = map[string]string{}
|
||||||
}
|
}
|
||||||
q.Extras["APP_OPSLOG_RECORDING"] = name
|
q.Extras["APP_OPSLOG_RECORDING"] = name // in-memory copy for the encode goroutine
|
||||||
if err := a.qso.Update(a.ctx, *q); err != nil {
|
// Persist ONLY this extras key (targeted) — a full-row Update from this
|
||||||
|
// in-memory copy could revert a column a concurrent post-log action changed.
|
||||||
|
if err := a.qso.SetExtra(a.ctx, q.ID, "APP_OPSLOG_RECORDING", name); err != nil {
|
||||||
applog.Printf("qso-rec: store recording path: %v", err)
|
applog.Printf("qso-rec: store recording path: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6661,17 +6714,10 @@ func (a *App) markRecordingSent(id int64) {
|
|||||||
if a.qso == nil || id == 0 {
|
if a.qso == nil || id == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
q, err := a.qso.GetByID(a.ctx, id)
|
// Targeted extras write — never a full-row Update, which (from a stale copy)
|
||||||
if err != nil {
|
// could revert a clublog/qrz upload-status another action just stamped.
|
||||||
applog.Printf("qso-rec: mark sent: load %d: %v", id, err)
|
if err := a.qso.SetExtra(a.ctx, id, "APP_OPSLOG_RECORDING_SENT", time.Now().UTC().Format("2006-01-02")); err != nil {
|
||||||
return
|
applog.Printf("qso-rec: mark sent %d: %v", id, err)
|
||||||
}
|
|
||||||
if q.Extras == nil {
|
|
||||||
q.Extras = map[string]string{}
|
|
||||||
}
|
|
||||||
q.Extras["APP_OPSLOG_RECORDING_SENT"] = time.Now().UTC().Format("2006-01-02")
|
|
||||||
if err := a.qso.Update(a.ctx, q); err != nil {
|
|
||||||
applog.Printf("qso-rec: mark sent: update %d: %v", id, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7825,7 +7871,15 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
msg = err.Error()
|
msg = err.Error()
|
||||||
}
|
}
|
||||||
|
// Name the QSOs in the failing batch so a per-record rejection
|
||||||
|
// (e.g. a field value nginx's WAF blocks with a 403) can actually
|
||||||
|
// be located — otherwise "batch FAILED" hides which contact it is.
|
||||||
|
who := make([]string, 0, len(batch))
|
||||||
|
for _, it := range batch {
|
||||||
|
who = append(who, fmt.Sprintf("%s#%d", it.call, it.id))
|
||||||
|
}
|
||||||
emit(fmt.Sprintf("Club Log: batch of %d FAILED: %s", len(batch), msg))
|
emit(fmt.Sprintf("Club Log: batch of %d FAILED: %s", len(batch), msg))
|
||||||
|
applog.Printf("extsvc: Club Log batch FAILED (%s) — QSOs: %s", msg, strings.Join(who, ", "))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -9235,6 +9289,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
|||||||
return 0, fmt.Errorf("insert qso: %w", err)
|
return 0, fmt.Errorf("insert qso: %w", err)
|
||||||
}
|
}
|
||||||
q.ID = id
|
q.ID = id
|
||||||
|
a.noteLiveQSO() // multi-op: flip this operator back "online"
|
||||||
a.saveQSORecording(&q)
|
a.saveQSORecording(&q)
|
||||||
if a.extsvc != nil {
|
if a.extsvc != nil {
|
||||||
a.extsvc.OnQSOLogged(id)
|
a.extsvc.OnQSOLogged(id)
|
||||||
|
|||||||
+10
-11
@@ -437,17 +437,16 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
|
|||||||
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
|
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field —
|
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field — NOT
|
||||||
// NOT the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay
|
// the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay independent.
|
||||||
// independent. q came straight from GetByID, so a full Update rewrites the
|
//
|
||||||
// row unchanged apart from this field.
|
// Stamp ONLY this extras key (targeted UPDATE), never a full-row write. The `q`
|
||||||
if q.Extras == nil {
|
// read up top is now stale after the slow e-mail send, and rewriting the whole
|
||||||
q.Extras = map[string]string{}
|
// row would revert any column an auto-upload changed meanwhile — that's how
|
||||||
}
|
// sending a QSL card was flipping clublog_qso_upload_status back from Y to R.
|
||||||
q.Extras[appQSLCardSentField] = time.Now().UTC().Format(time.RFC3339)
|
if err := a.qso.SetExtra(a.ctx, qsoID, appQSLCardSentField, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||||
if err := a.qso.Update(a.ctx, q); err != nil {
|
applog.Printf("qsl: card sent to %s but marking failed: %v", q.Callsign, err)
|
||||||
applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err)
|
return fmt.Errorf("QSL card sent but status not saved: %w", err)
|
||||||
return fmt.Errorf("eQSL sent but status not saved: %w", err)
|
|
||||||
}
|
}
|
||||||
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
|
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
|
||||||
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
|
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
|
||||||
|
|||||||
+296
-43
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
||||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
|
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
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, DownloadAndApplyUpdate, GetLiveStations,
|
||||||
WorkedBefore,
|
WorkedBefore,
|
||||||
SetCompactMode,
|
SetCompactMode,
|
||||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
|
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
||||||
GetCATSettings,
|
GetCATSettings,
|
||||||
GetSolarData,
|
GetSolarData,
|
||||||
|
GetQSORate,
|
||||||
LoTWUserInfo,
|
LoTWUserInfo,
|
||||||
OperatingDefaultForBand,
|
OperatingDefaultForBand,
|
||||||
LogUDPLoggedADIF,
|
LogUDPLoggedADIF,
|
||||||
@@ -41,7 +42,7 @@ import {
|
|||||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
||||||
GetAwardDefs,
|
GetAwardDefs,
|
||||||
GetUIPref,
|
GetUIPref,
|
||||||
ReportLiveActivity,
|
ReportLiveActivity, GetLiveStatusEnabled, LiveLastQSOAgeSec,
|
||||||
AwardRefsForQSOs,
|
AwardRefsForQSOs,
|
||||||
} from '../wailsjs/go/main/App';
|
} from '../wailsjs/go/main/App';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
@@ -209,6 +210,16 @@ function bandForMHz(mhz: number): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// modeAccent maps a mode to a theme-aware colour for the live-stations widget:
|
||||||
|
// CW gold, phone green, digital blue, unknown muted.
|
||||||
|
function modeAccent(mode?: string): string {
|
||||||
|
const m = (mode || '').toUpperCase();
|
||||||
|
if (/CW/.test(m)) return 'var(--chart-3)';
|
||||||
|
if (/SSB|USB|LSB|AM|FM|PHONE|DV/.test(m)) return 'var(--chart-2)';
|
||||||
|
if (/FT8|FT4|RTTY|PSK|JT|JS8|Q65|MSK|FST|MFSK|OLIVIA|DIG|DATA|WSPR/.test(m)) return 'var(--chart-1)';
|
||||||
|
return 'var(--muted-foreground)';
|
||||||
|
}
|
||||||
|
|
||||||
// rstCategory buckets a mode into the report family used for its RST list.
|
// rstCategory buckets a mode into the report family used for its RST list.
|
||||||
type RSTLists = { phone: string[]; cw: string[]; digital: string[] };
|
type RSTLists = { phone: string[]; cw: string[]; digital: string[] };
|
||||||
function rstCategory(mode: string): keyof RSTLists {
|
function rstCategory(mode: string): keyof RSTLists {
|
||||||
@@ -409,6 +420,18 @@ export default function App() {
|
|||||||
// click reverts the UI and the click looks like it did nothing.
|
// click reverts the UI and the click looks like it did nothing.
|
||||||
const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
|
const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
|
||||||
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
|
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
|
||||||
|
// Multi-op "who's on air" widget: every operator's live status from the shared
|
||||||
|
// MySQL logbook (freq/mode/version). Only polled on a MySQL logbook.
|
||||||
|
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
|
||||||
|
const [liveStations, setLiveStations] = useState<LiveStation[]>([]);
|
||||||
|
const [showLiveStations, setShowLiveStations] = useState(() => localStorage.getItem('opslog.showLiveStations') === '1');
|
||||||
|
useEffect(() => {
|
||||||
|
if (dbConn?.backend !== 'mysql') { setLiveStations([]); return; }
|
||||||
|
const load = () => GetLiveStations().then((s) => setLiveStations((s ?? []) as LiveStation[])).catch(() => {});
|
||||||
|
load();
|
||||||
|
const id = window.setInterval(load, 15 * 1000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [dbConn]);
|
||||||
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
|
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
|
||||||
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
||||||
// in Preferences > Hardware > CAT interface.
|
// in Preferences > Hardware > CAT interface.
|
||||||
@@ -604,6 +627,11 @@ export default function App() {
|
|||||||
QSOAudioResetClock().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
|
QSOAudioResetClock().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
|
||||||
};
|
};
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
// Synchronous re-entrancy guard: `saving` is React state (updates async), so it
|
||||||
|
// can't stop a burst of Enter presses / clicks fired before the re-render — each
|
||||||
|
// would run a full AddQSO and log the SAME contact several times when a slow QRZ
|
||||||
|
// lookup made the log take seconds. This ref blocks the repeat immediately.
|
||||||
|
const savingRef = useRef(false);
|
||||||
const [filterCallsign, setFilterCallsign] = useState('');
|
const [filterCallsign, setFilterCallsign] = useState('');
|
||||||
// Advanced filter builder (replaces the old band/mode dropdowns).
|
// Advanced filter builder (replaces the old band/mode dropdowns).
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
@@ -1085,21 +1113,77 @@ export default function App() {
|
|||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
||||||
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
||||||
|
// "ON AIR" status-bar badge: mirrors the multi-op live status this operator
|
||||||
|
// publishes — online (blinking) when a QSO was logged in the last 5 min, else
|
||||||
|
// offline. Only shown when live-status publishing is enabled (Settings→General).
|
||||||
|
const [liveStatusOn, setLiveStatusOn] = useState(false);
|
||||||
|
const [onAir, setOnAir] = useState(false);
|
||||||
|
useEffect(() => { if (!showSettings) GetLiveStatusEnabled().then((v) => setLiveStatusOn(!!v)).catch(() => {}); }, [showSettings]);
|
||||||
|
useEffect(() => {
|
||||||
|
// Read the ON-AIR state straight from the backend (single source of truth:
|
||||||
|
// liveLastQSOAt, stamped on every log and seeded from the DB at launch). Poll
|
||||||
|
// it + refresh on each logged QSO — no fragile frontend timestamp to drift.
|
||||||
|
const refresh = () => LiveLastQSOAgeSec()
|
||||||
|
.then((sec: number) => setOnAir(liveStatusOn && typeof sec === 'number' && sec >= 0 && sec < 300))
|
||||||
|
.catch(() => {});
|
||||||
|
refresh();
|
||||||
|
const off = EventsOn('qso:logged', refresh);
|
||||||
|
const id = window.setInterval(refresh, 5 * 1000); // responsive without hammering (cheap 400-row scan)
|
||||||
|
return () => { off(); window.clearInterval(id); };
|
||||||
|
}, [liveStatusOn]);
|
||||||
|
// QSO-rate meter (10/60 min) in the header — opt-in via Settings→General.
|
||||||
|
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
||||||
|
useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]);
|
||||||
|
const [qsoRate, setQsoRate] = useState<{ last10: number; last60: number }>({ last10: 0, last60: 0 });
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showQsoRate) return;
|
||||||
|
const load = () => { GetQSORate().then((r) => setQsoRate({ last10: r?.last10 ?? 0, last60: r?.last60 ?? 0 })).catch(() => {}); };
|
||||||
|
load();
|
||||||
|
// Refresh on each logged QSO (immediate feedback) and on a 30s tick so the
|
||||||
|
// trailing windows roll forward even when nothing new is logged.
|
||||||
|
const off = EventsOn('qso:logged', load);
|
||||||
|
const id = window.setInterval(load, 30 * 1000);
|
||||||
|
return () => { off(); window.clearInterval(id); };
|
||||||
|
}, [showQsoRate]);
|
||||||
// Optional deep-link: which Preferences section to open. Cleared on
|
// Optional deep-link: which Preferences section to open. Cleared on
|
||||||
// close so the next plain "Preferences" launch reverts to default.
|
// close so the next plain "Preferences" launch reverts to default.
|
||||||
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
|
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
|
||||||
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
||||||
const [showAbout, setShowAbout] = useState(false);
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
const [showDuplicates, setShowDuplicates] = useState(false);
|
const [showDuplicates, setShowDuplicates] = useState(false);
|
||||||
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string } | null>(null);
|
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
|
||||||
// Check GitHub for a newer release once at startup (unless disabled in
|
const [updating, setUpdating] = useState(false);
|
||||||
// General); surface a toast if one exists. Best effort — silent on failure.
|
const [updateProgress, setUpdateProgress] = useState(0);
|
||||||
|
const [updateError, setUpdateError] = useState('');
|
||||||
|
// Check GitHub for a newer release at startup AND every 10 minutes (unless
|
||||||
|
// disabled in General). Best effort — silent on failure.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (localStorage.getItem('opslog.checkUpdates') === '0') return;
|
if (localStorage.getItem('opslog.checkUpdates') === '0') return;
|
||||||
CheckForUpdate().then((u: any) => {
|
const check = () => CheckForUpdate().then((u: any) => {
|
||||||
if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? '') });
|
if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') });
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
check();
|
||||||
|
const id = window.setInterval(check, 10 * 60 * 1000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
// Live download progress for the in-app updater.
|
||||||
|
useEffect(() => {
|
||||||
|
const off = EventsOn('update:progress', (p: any) => setUpdateProgress(Math.max(0, Math.min(100, Number(p) || 0))));
|
||||||
|
return () => { off(); };
|
||||||
|
}, []);
|
||||||
|
// startUpdate downloads the new build in-app and (on success) swaps + relaunches.
|
||||||
|
// Falls back to opening the release page when the release has no auto-download asset.
|
||||||
|
const startUpdate = useCallback(async () => {
|
||||||
|
if (!updateInfo) return;
|
||||||
|
if (!updateInfo.downloadUrl) { if (updateInfo.url) BrowserOpenURL(updateInfo.url); return; }
|
||||||
|
setUpdating(true); setUpdateProgress(0); setUpdateError('');
|
||||||
|
try {
|
||||||
|
await DownloadAndApplyUpdate(updateInfo.downloadUrl); // app quits + relaunches on success
|
||||||
|
} catch (e: any) {
|
||||||
|
setUpdateError(String(e?.message ?? e));
|
||||||
|
setUpdating(false);
|
||||||
|
}
|
||||||
|
}, [updateInfo]);
|
||||||
const [deletingAll, setDeletingAll] = useState(false);
|
const [deletingAll, setDeletingAll] = useState(false);
|
||||||
const [ctyRefreshing, setCtyRefreshing] = useState(false);
|
const [ctyRefreshing, setCtyRefreshing] = useState(false);
|
||||||
const [refsDownloading, setRefsDownloading] = useState(false);
|
const [refsDownloading, setRefsDownloading] = useState(false);
|
||||||
@@ -1138,6 +1222,10 @@ export default function App() {
|
|||||||
const [lookupError, setLookupError] = useState('');
|
const [lookupError, setLookupError] = useState('');
|
||||||
const lookupTimerRef = useRef<number | null>(null);
|
const lookupTimerRef = useRef<number | null>(null);
|
||||||
const wbTimerRef = useRef<number | null>(null);
|
const wbTimerRef = useRef<number | null>(null);
|
||||||
|
// Bumped whenever the entry is cleared (ESC) or a new call starts, so a still
|
||||||
|
// in-flight lookup discards its result when it finally returns instead of
|
||||||
|
// re-populating a field the operator just cleared.
|
||||||
|
const lookupGenRef = useRef(0);
|
||||||
const [wb, setWb] = useState<WB | null>(null);
|
const [wb, setWb] = useState<WB | null>(null);
|
||||||
const [wbBusy, setWbBusy] = useState(false);
|
const [wbBusy, setWbBusy] = useState(false);
|
||||||
|
|
||||||
@@ -1299,20 +1387,28 @@ export default function App() {
|
|||||||
// surface the error — used by the startup retry, since the logbook DB (a remote
|
// surface the error — used by the startup retry, since the logbook DB (a remote
|
||||||
// MySQL especially) can take a few seconds to connect while the UI is already
|
// MySQL especially) can take a few seconds to connect while the UI is already
|
||||||
// mounted, and we don't want to flash "db not available" during that window.
|
// mounted, and we don't want to flash "db not available" during that window.
|
||||||
|
const refreshSeqRef = useRef(0); // guards against an older refresh clobbering a newer one's data
|
||||||
const refresh = useCallback(async (silent = false): Promise<boolean> => {
|
const refresh = useCallback(async (silent = false): Promise<boolean> => {
|
||||||
|
// Monotonic guard: two refreshes can be in flight at once (e.g. the immediate
|
||||||
|
// one after a UDP auto-log, which reads the QSO at "R", and the debounced one
|
||||||
|
// after extsvc:uploaded, which reads it at "Y"). MySQL query latency can make
|
||||||
|
// the OLDER one resolve LAST and clobber the newer data — the Club Log status
|
||||||
|
// flicking R→Y→R. Only the most-recently-issued refresh is allowed to apply.
|
||||||
|
const seq = ++refreshSeqRef.current;
|
||||||
try {
|
try {
|
||||||
const f = buildActiveFilter();
|
const f = buildActiveFilter();
|
||||||
const list = await ListQSOFiltered(f as any);
|
const list = await ListQSOFiltered(f as any);
|
||||||
const n = await CountQSO();
|
const n = await CountQSO();
|
||||||
const hasFilter = !!(f.quick_callsign || (f.conditions && f.conditions.length));
|
const hasFilter = !!(f.quick_callsign || (f.conditions && f.conditions.length));
|
||||||
const matched = hasFilter ? await CountQSOFiltered(f as any) : n;
|
const matched = hasFilter ? await CountQSOFiltered(f as any) : n;
|
||||||
|
if (seq !== refreshSeqRef.current) return true; // a newer refresh superseded us — drop this stale result
|
||||||
setQsos(list);
|
setQsos(list);
|
||||||
setTotal(n);
|
setTotal(n);
|
||||||
setMatchCount(matched);
|
setMatchCount(matched);
|
||||||
setError('');
|
setError('');
|
||||||
return true;
|
return true;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (!silent) setError(String(e?.message ?? e));
|
if (!silent && seq === refreshSeqRef.current) setError(String(e?.message ?? e));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [buildActiveFilter]);
|
}, [buildActiveFilter]);
|
||||||
@@ -1898,7 +1994,16 @@ export default function App() {
|
|||||||
else setError('UDP auto-log: ' + msg);
|
else setError('UDP auto-log: ' + msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); };
|
// ADIF monitor imported new QSOs (backend file watcher) → refresh the grid
|
||||||
|
// and show how many, from which file.
|
||||||
|
const unsubAdifMon = EventsOn('adifmon:imported', async (p: any) => {
|
||||||
|
const n = Number(p?.count ?? 0);
|
||||||
|
if (n <= 0) return;
|
||||||
|
await refresh();
|
||||||
|
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
||||||
|
showToast(`ADIF: ${n} QSO imported${file ? ` from ${file}` : ''}`);
|
||||||
|
});
|
||||||
|
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); unsubAdifMon?.(); };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1993,17 +2098,21 @@ export default function App() {
|
|||||||
async function wkSend(rawText: string) {
|
async function wkSend(rawText: string) {
|
||||||
setWkSent('');
|
setWkSent('');
|
||||||
const resolved = resolveCW(rawText);
|
const resolved = resolveCW(rawText);
|
||||||
|
// Trailing word space so two macros fired back-to-back don't run together in
|
||||||
|
// the keyer buffer ("CQ" + "TEST" → "CQTEST"). The keyer keys a space as a
|
||||||
|
// word gap at the CURRENT speed, so it scales with WPM automatically.
|
||||||
|
const keyed = resolved ? resolved + ' ' : resolved;
|
||||||
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
|
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
|
||||||
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
|
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
|
||||||
if (cwSourceRef.current === 'icom') {
|
if (cwSourceRef.current === 'icom') {
|
||||||
// The rig's keyer gives no busy echo back, so show the text we sent and,
|
// The rig's keyer gives no busy echo back, so show the text we sent and,
|
||||||
// for <LOGQSO>, wait the estimated send duration before logging.
|
// for <LOGQSO>, wait the estimated send duration before logging.
|
||||||
setWkSent(resolved);
|
setWkSent(resolved);
|
||||||
await IcomSendCW(resolved).catch((e) => setError(String(e?.message ?? e)));
|
await IcomSendCW(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||||
if (doLog) { await sleep(Math.round(estimateCwMs(resolved, wkWpm)) + 600); void save(); }
|
if (doLog) { await sleep(Math.round(estimateCwMs(resolved, wkWpm)) + 600); void save(); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await WinkeyerSend(resolved).catch((e) => setError(String(e?.message ?? e)));
|
await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||||
// <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has
|
// <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has
|
||||||
// finished sending — so the QSO isn't logged (and the form cleared) while CW
|
// finished sending — so the QSO isn't logged (and the form cleared) while CW
|
||||||
// is still going out. We'd like to wait for the busy flag to rise then fall,
|
// is still going out. We'd like to wait for the busy flag to rise then fall,
|
||||||
@@ -2119,7 +2228,9 @@ export default function App() {
|
|||||||
}, [spots]);
|
}, [spots]);
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
if (savingRef.current) return; // a log is already in flight — ignore the repeat
|
||||||
if (!callsign.trim()) { setError('Callsign required'); return; }
|
if (!callsign.trim()) { setError('Callsign required'); return; }
|
||||||
|
savingRef.current = true;
|
||||||
setSaving(true); setError('');
|
setSaving(true); setError('');
|
||||||
try {
|
try {
|
||||||
const freqHz = freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined;
|
const freqHz = freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined;
|
||||||
@@ -2189,13 +2300,21 @@ export default function App() {
|
|||||||
await refresh();
|
await refresh();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(String(e?.message ?? e));
|
setError(String(e?.message ?? e));
|
||||||
} finally { setSaving(false); }
|
} finally { setSaving(false); savingRef.current = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// resetEntry clears the form for the next QSO. Triggered after a
|
// resetEntry clears the form for the next QSO. Triggered after a
|
||||||
// successful log AND by ESC. Locked values (band/mode/freq/start/end)
|
// successful log AND by ESC. Locked values (band/mode/freq/start/end)
|
||||||
// are preserved so backdated batches stay productive.
|
// are preserved so backdated batches stay productive.
|
||||||
function resetEntry() {
|
function resetEntry() {
|
||||||
|
// Stop any callsign lookup DEAD: cancel the pending debounce, hide the "looking
|
||||||
|
// up" spinner immediately, and invalidate any request already in flight so its
|
||||||
|
// late result can't re-fill the field we're about to clear (the "ESC clears the
|
||||||
|
// QSO but the QRZ lookup keeps going" bug).
|
||||||
|
if (lookupTimerRef.current) { window.clearTimeout(lookupTimerRef.current); lookupTimerRef.current = null; }
|
||||||
|
if (wbTimerRef.current) { window.clearTimeout(wbTimerRef.current); wbTimerRef.current = null; }
|
||||||
|
lookupGenRef.current++;
|
||||||
|
setLookupBusy(false);
|
||||||
// Discard any in-progress QSO recording (no-op if it was already saved on
|
// Discard any in-progress QSO recording (no-op if it was already saved on
|
||||||
// log, or if the recorder is off).
|
// log, or if the recorder is off).
|
||||||
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
|
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
|
||||||
@@ -2400,14 +2519,15 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
async function runLookup(call: string) {
|
async function runLookup(call: string) {
|
||||||
if (call !== lastLookedUpRef.current) resetAutoFill();
|
if (call !== lastLookedUpRef.current) resetAutoFill();
|
||||||
|
const gen = lookupGenRef.current; // invalidated by ESC / resetEntry
|
||||||
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
|
// Discard a STALE result: the operator already moved to another call
|
||||||
// (clicked a new spot / typed) while this lookup was in flight. Applying it
|
// (clicked a new spot / typed) OR cleared the entry (ESC) while this lookup
|
||||||
// would clobber the current call's fields and zoom the map to the wrong
|
// was in flight. Applying it would clobber the current fields and zoom the
|
||||||
// station — the bug where replacing a call didn't re-zoom the map.
|
// map to the wrong station.
|
||||||
if (call !== callsignValRef.current.trim().toUpperCase()) return;
|
if (gen !== lookupGenRef.current || 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
|
||||||
@@ -2460,9 +2580,15 @@ export default function App() {
|
|||||||
QSOAudioBegin().then(setRecording).catch(() => {});
|
QSOAudioBegin().then(setRecording).catch(() => {});
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setLookupResult(null);
|
if (gen === lookupGenRef.current && call === callsignValRef.current.trim().toUpperCase()) {
|
||||||
setLookupError(String(e?.message ?? e));
|
setLookupResult(null);
|
||||||
} finally { setLookupBusy(false); }
|
setLookupError(String(e?.message ?? e));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Only clear the spinner if we're still the current lookup — a newer one
|
||||||
|
// (or an ESC that already reset it) owns the busy state otherwise.
|
||||||
|
if (gen === lookupGenRef.current) setLookupBusy(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function scheduleLookup(value: string, force?: boolean) {
|
function scheduleLookup(value: string, force?: boolean) {
|
||||||
setLookupError('');
|
setLookupError('');
|
||||||
@@ -2843,12 +2969,12 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
const rstTxBlock = (
|
const rstTxBlock = (
|
||||||
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
|
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
|
||||||
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} allowFreeText commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
|
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const rstRxBlock = (
|
const rstRxBlock = (
|
||||||
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
|
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
|
||||||
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} allowFreeText commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
|
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
// DX country flag, shown large next to RST (moved here from the Country field).
|
// DX country flag, shown large next to RST (moved here from the Country field).
|
||||||
@@ -3736,8 +3862,49 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* Multi-op "who's on air": a dockable widget (toggle), not a popover. */}
|
||||||
|
{dbConn?.backend === 'mysql' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { const v = !showLiveStations; setShowLiveStations(v); writeUiPref('opslog.showLiveStations', v ? '1' : '0'); }}
|
||||||
|
title={showLiveStations ? `${t('live.stationsTitle')} — shown · click to hide` : `${t('live.stationsTitle')} · click to show`}
|
||||||
|
className={cn('relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||||
|
showLiveStations ? 'border-info-border bg-info-muted text-info-muted-foreground hover:bg-info-muted'
|
||||||
|
: 'border-border text-muted-foreground hover:bg-muted')}
|
||||||
|
>
|
||||||
|
<Radio className="size-4" />
|
||||||
|
{liveStations.filter((s) => s.online).length > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 min-w-3.5 h-3.5 px-0.5 rounded-full bg-danger text-danger-foreground text-[9px] font-bold leading-[14px] text-center">
|
||||||
|
{(() => { const n = liveStations.filter((s) => s.online).length; return n > 9 ? '9+' : n; })()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* QSO-rate meter (opt-in) + propagation share ONE grid cell: the header
|
||||||
|
is a fixed 6-column grid, so adding the meter as its own child pushed
|
||||||
|
the last columns (profile / band map / compact) onto a 2nd row. */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{showQsoRate && (
|
||||||
|
<div className="flex items-center gap-2.5 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap"
|
||||||
|
title={t('rate.title')}>
|
||||||
|
<Activity className={cn('size-3.5', (qsoRate.last10 + qsoRate.last60) > 0 ? 'text-primary' : 'text-muted-foreground')} />
|
||||||
|
{/* Contest-style rate: QSOs/hour projected from each window
|
||||||
|
(10-min count ×6; the 60-min count is already per hour). Numbers
|
||||||
|
glow the brand accent when active, dim to muted when idle. */}
|
||||||
|
<span className="inline-flex items-baseline gap-1">
|
||||||
|
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">10′</span>
|
||||||
|
<span className={cn('font-bold text-[12px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span>
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-baseline gap-1">
|
||||||
|
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">60′</span>
|
||||||
|
<span className={cn('font-bold text-[12px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-[9px] uppercase tracking-wider">Q/h</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Space-weather / propagation — compact, in the header. Live from N0NBH
|
{/* Space-weather / propagation — compact, in the header. Live from N0NBH
|
||||||
(hamqsl.com), auto-refreshed hourly; the same SFI / A / K are stamped
|
(hamqsl.com), auto-refreshed hourly; the same SFI / A / K are stamped
|
||||||
onto each logged QSO. Always renders one element so the grid columns
|
onto each logged QSO. Always renders one element so the grid columns
|
||||||
@@ -3749,6 +3916,14 @@ export default function App() {
|
|||||||
const geo = String(solar.geomag_field || '').toUpperCase();
|
const geo = String(solar.geomag_field || '').toUpperCase();
|
||||||
const geoCls = /STORM|SEVERE/.test(geo) ? 'text-danger'
|
const geoCls = /STORM|SEVERE/.test(geo) ? 'text-danger'
|
||||||
: /ACTIVE|UNSETTLED/.test(geo) ? 'text-warning' : 'text-success';
|
: /ACTIVE|UNSETTLED/.test(geo) ? 'text-warning' : 'text-success';
|
||||||
|
const num = (v: any) => { const n = Number(v); return Number.isFinite(n) ? n : null; };
|
||||||
|
// Semantic colour by band condition: higher flux/sunspots = better HF
|
||||||
|
// (green when strong); A and K measure geomagnetic disturbance, so LOW
|
||||||
|
// is good (green quiet → yellow unsettled → red storm).
|
||||||
|
const sfiCls = (v: any) => { const n = num(v); return n == null ? 'text-foreground' : n >= 120 ? 'text-success' : n >= 90 ? 'text-foreground' : 'text-warning'; };
|
||||||
|
const ssnCls = (v: any) => { const n = num(v); return n != null && n >= 80 ? 'text-success' : 'text-foreground'; };
|
||||||
|
const aCls = (v: any) => { const n = num(v); return n == null ? 'text-foreground' : n <= 7 ? 'text-success' : n <= 15 ? 'text-warning' : 'text-danger'; };
|
||||||
|
const kCls = (v: any) => { const n = num(v); return n == null ? 'text-foreground' : n <= 2 ? 'text-success' : n <= 3 ? 'text-warning' : 'text-danger'; };
|
||||||
const it = (label: string, val: any, cls = 'text-foreground') => (
|
const it = (label: string, val: any, cls = 'text-foreground') => (
|
||||||
<span className="inline-flex items-baseline gap-1">
|
<span className="inline-flex items-baseline gap-1">
|
||||||
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">{label}</span>
|
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">{label}</span>
|
||||||
@@ -3756,15 +3931,16 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
return (<>
|
return (<>
|
||||||
{it('SFI', solar.sfi)}
|
{it('SFI', solar.sfi, sfiCls(solar.sfi))}
|
||||||
{it('SSN', solar.ssn)}
|
{it('SSN', solar.ssn, ssnCls(solar.ssn))}
|
||||||
{it('A', solar.a_index)}
|
{it('A', solar.a_index, aCls(solar.a_index))}
|
||||||
{it('K', solar.k_index)}
|
{it('K', solar.k_index, kCls(solar.k_index))}
|
||||||
{geo ? <span className={cn('font-bold text-[12px]', geoCls)}>{geo}</span> : null}
|
{geo ? <span className={cn('font-bold text-[12px]', geoCls)}>{geo}</span> : null}
|
||||||
</>);
|
</>);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
) : <span />}
|
) : <span />}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
|
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
|
||||||
<Clock className="size-3" />
|
<Clock className="size-3" />
|
||||||
@@ -3846,22 +4022,42 @@ export default function App() {
|
|||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<div className="size-2.5 mt-1 rounded-full bg-primary shrink-0 animate-pulse" />
|
<div className="size-2.5 mt-1 rounded-full bg-primary shrink-0 animate-pulse" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-semibold">OpsLog v{updateInfo.latest} available</p>
|
<p className="text-sm font-semibold">{t('upd.available', { v: updateInfo.latest })}</p>
|
||||||
<p className="text-xs text-muted-foreground">You're on v{APP_VERSION}.</p>
|
<p className="text-xs text-muted-foreground">{t('upd.current', { v: APP_VERSION })}</p>
|
||||||
<div className="mt-2 flex items-center gap-2">
|
|
||||||
<button
|
{updating ? (
|
||||||
onClick={() => { if (updateInfo.url) BrowserOpenURL(updateInfo.url); setUpdateInfo(null); }}
|
<div className="mt-2">
|
||||||
className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">
|
<div className="flex items-center justify-between text-[11px] text-muted-foreground mb-1">
|
||||||
Download
|
<span>{updateProgress >= 100 ? t('upd.installing') : t('upd.downloading')}</span>
|
||||||
</button>
|
<span className="tabular-nums">{updateProgress}%</span>
|
||||||
<button onClick={() => setUpdateInfo(null)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">
|
</div>
|
||||||
Later
|
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
|
||||||
</button>
|
<div className="h-full bg-primary transition-[width] duration-150" style={{ width: `${updateProgress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground">{t('upd.restartNote')}</p>
|
||||||
|
</div>
|
||||||
|
) : updateError ? (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-[11px] text-destructive break-words">{updateError}</p>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<button onClick={startUpdate} className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">{t('upd.retry')}</button>
|
||||||
|
{updateInfo.url && <button onClick={() => BrowserOpenURL(updateInfo.url)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">{t('upd.browser')}</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<button onClick={startUpdate} className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">
|
||||||
|
{updateInfo.downloadUrl ? t('upd.install') : t('upd.download')}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setUpdateInfo(null)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">{t('upd.later')}</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
|
{!updating && (
|
||||||
<X className="size-4" />
|
<button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
|
||||||
</button>
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -4052,8 +4248,55 @@ export default function App() {
|
|||||||
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
||||||
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
||||||
otherwise it shows the QRZ profile photo. */}
|
otherwise it shows the QRZ profile photo. */}
|
||||||
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled)) && (
|
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
||||||
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
|
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
|
||||||
|
{/* Multi-op "who's on air" widget: every operator on the shared logbook,
|
||||||
|
their freq/mode (colour-coded) and OpsLog version. */}
|
||||||
|
{showLiveStations && dbConn?.backend === 'mysql' && (
|
||||||
|
<div className="w-[248px] shrink-0 min-h-0 relative">
|
||||||
|
<div className="absolute inset-0 flex flex-col min-h-0 rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-1.5 px-3 h-8 border-b border-border shrink-0">
|
||||||
|
<Radio className="size-3.5 text-primary" />
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider truncate">{t('live.stationsTitle')}</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<span className="text-[10px] text-muted-foreground tabular-nums">{liveStations.filter((s) => s.online).length}/{liveStations.length}</span>
|
||||||
|
<button type="button" className="text-muted-foreground hover:text-foreground shrink-0"
|
||||||
|
onClick={() => { setShowLiveStations(false); writeUiPref('opslog.showLiveStations', '0'); }} title={t('live.stationsHide')}>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto p-1.5 flex flex-col gap-1">
|
||||||
|
{liveStations.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground italic px-1 py-2">{t('live.stationsEmpty')}</p>
|
||||||
|
) : liveStations.map((s, i) => {
|
||||||
|
const mc = modeAccent(s.mode);
|
||||||
|
return (
|
||||||
|
<div key={i} className={cn('flex items-center gap-2 rounded-md px-2 py-1.5 border', s.online ? 'bg-muted/40 border-border' : 'border-transparent opacity-60')}>
|
||||||
|
<span className={cn('size-2 rounded-full shrink-0', s.online ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')}
|
||||||
|
title={s.online ? t('live.onAir') : t('live.offline')} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-baseline gap-1.5 min-w-0">
|
||||||
|
<span className="text-xs font-bold font-mono truncate">{s.operator}</span>
|
||||||
|
{s.version && <span className="text-[9px] text-muted-foreground shrink-0 tabular-nums ml-auto">v{s.version}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5 min-w-0">
|
||||||
|
<span className="font-mono text-[11px] font-semibold tabular-nums" style={{ color: mc }}>
|
||||||
|
{s.freq_hz ? (s.freq_hz / 1e6).toFixed(3) : '—'}
|
||||||
|
</span>
|
||||||
|
{s.mode && (
|
||||||
|
<span className="text-[9px] font-bold uppercase px-1.5 rounded-full leading-[15px] shrink-0"
|
||||||
|
style={{ background: `${mc}22`, color: mc }}>{s.mode}</span>
|
||||||
|
)}
|
||||||
|
{s.band && <span className="text-[10px] text-muted-foreground shrink-0">{s.band}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{chatShown && (
|
{chatShown && (
|
||||||
// relative + absolute inner: the chat takes the row height (set by the
|
// relative + absolute inner: the chat takes the row height (set by the
|
||||||
// entry strip) WITHOUT its message list growing the row, like the
|
// entry strip) WITHOUT its message list growing the row, like the
|
||||||
@@ -4786,6 +5029,16 @@ export default function App() {
|
|||||||
disabled={!rotatorHeading.enabled}
|
disabled={!rotatorHeading.enabled}
|
||||||
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
||||||
/>
|
/>
|
||||||
|
{liveStatusOn && (
|
||||||
|
<div
|
||||||
|
className={cn('inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider shrink-0 transition-colors',
|
||||||
|
onAir ? 'border-danger-border bg-danger-muted text-danger-muted-foreground' : 'border-border text-muted-foreground')}
|
||||||
|
title={onAir ? t('live.onAirTip') : t('live.offlineTip')}
|
||||||
|
>
|
||||||
|
<span className={cn('size-2 rounded-full', onAir ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')} />
|
||||||
|
{onAir ? t('live.onAir') : t('live.offline')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Toasts / errors: the status bar's free space is far wider than the
|
{/* Toasts / errors: the status bar's free space is far wider than the
|
||||||
header band they used to sit in. Still one line (the bar is 28px),
|
header band they used to sit in. Still one line (the bar is 28px),
|
||||||
but CLICK opens the full text wrapped — long messages (a TQSL or
|
but CLICK opens the full text wrapped — long messages (a TQSL or
|
||||||
|
|||||||
@@ -412,9 +412,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col w-20"><Label>S</Label>
|
<div className="flex flex-col w-20"><Label>S</Label>
|
||||||
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} allowFreeText commitOnType onChange={(v) => set('rst_sent', v)} /></div>
|
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_sent', v)} /></div>
|
||||||
<div className="flex flex-col w-20"><Label>R</Label>
|
<div className="flex flex-col w-20"><Label>R</Label>
|
||||||
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} allowFreeText commitOnType onChange={(v) => set('rst_rcvd', v)} /></div>
|
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_rcvd', v)} /></div>
|
||||||
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
||||||
title={t('qedit.fetchTitle')}>
|
title={t('qedit.fetchTitle')}>
|
||||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ import {
|
|||||||
ComputeStationInfo,
|
ComputeStationInfo,
|
||||||
GetUIPref, SetUIPref,
|
GetUIPref, SetUIPref,
|
||||||
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
||||||
|
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
||||||
|
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
||||||
} 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';
|
||||||
@@ -169,6 +171,7 @@ type SectionId =
|
|||||||
| 'confirmations'
|
| 'confirmations'
|
||||||
| 'external-services'
|
| 'external-services'
|
||||||
| 'udp'
|
| 'udp'
|
||||||
|
| 'adifmon'
|
||||||
| 'lookup'
|
| 'lookup'
|
||||||
| 'lists-bands'
|
| 'lists-bands'
|
||||||
| 'lists-modes'
|
| 'lists-modes'
|
||||||
@@ -185,6 +188,7 @@ type SectionId =
|
|||||||
| 'antgenius'
|
| 'antgenius'
|
||||||
| 'pgxl'
|
| 'pgxl'
|
||||||
| 'flex'
|
| 'flex'
|
||||||
|
| 'relayauto'
|
||||||
| 'audio';
|
| 'audio';
|
||||||
|
|
||||||
type TreeNode =
|
type TreeNode =
|
||||||
@@ -202,6 +206,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
|||||||
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius' },
|
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius' },
|
||||||
{ kind: 'item', label: t('sec.pgxl'), id: 'pgxl' },
|
{ kind: 'item', label: t('sec.pgxl'), id: 'pgxl' },
|
||||||
...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []),
|
...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []),
|
||||||
|
{ kind: 'item', label: t('sec.relayauto'), id: 'relayauto' },
|
||||||
{ kind: 'item', label: t('sec.audio'), id: 'audio' },
|
{ kind: 'item', label: t('sec.audio'), id: 'audio' },
|
||||||
];
|
];
|
||||||
return [
|
return [
|
||||||
@@ -225,6 +230,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
|||||||
]},
|
]},
|
||||||
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
|
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
|
||||||
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
|
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
|
||||||
|
{ kind: 'item', label: t('sec.adifmon'), id: 'adifmon' },
|
||||||
{ kind: 'item', label: t('sec.uscounties'), id: 'uscounties' },
|
{ kind: 'item', label: t('sec.uscounties'), id: 'uscounties' },
|
||||||
{ kind: 'item', label: t('sec.database'), id: 'database' },
|
{ kind: 'item', label: t('sec.database'), id: 'database' },
|
||||||
{ kind: 'item', label: t('sec.autostart'), id: 'autostart' },
|
{ kind: 'item', label: t('sec.autostart'), id: 'autostart' },
|
||||||
@@ -241,9 +247,11 @@ const SECTION_KEY: Partial<Record<SectionId, string>> = {
|
|||||||
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
|
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
|
||||||
'external-services': 'sec.external', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
|
'external-services': 'sec.external', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
|
||||||
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
|
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
|
||||||
|
adifmon: 'sec.adifmon',
|
||||||
uscounties: 'sec.uscounties',
|
uscounties: 'sec.uscounties',
|
||||||
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
|
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
|
||||||
antgenius: 'sec.antgenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email',
|
antgenius: 'sec.antgenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email',
|
||||||
|
relayauto: 'sec.relayauto',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map section id → friendly name (used in breadcrumb / placeholders).
|
// Map section id → friendly name (used in breadcrumb / placeholders).
|
||||||
@@ -261,6 +269,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
|||||||
database: 'Database',
|
database: 'Database',
|
||||||
autostart: 'Autostart',
|
autostart: 'Autostart',
|
||||||
udp: 'UDP integrations',
|
udp: 'UDP integrations',
|
||||||
|
adifmon: 'ADIF monitor',
|
||||||
awards: 'Awards',
|
awards: 'Awards',
|
||||||
cat: 'CAT interface',
|
cat: 'CAT interface',
|
||||||
rotator: 'Rotator',
|
rotator: 'Rotator',
|
||||||
@@ -269,6 +278,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
|||||||
antgenius: 'Antenna Genius',
|
antgenius: 'Antenna Genius',
|
||||||
pgxl: 'Power Genius',
|
pgxl: 'Power Genius',
|
||||||
flex: 'FlexRadio',
|
flex: 'FlexRadio',
|
||||||
|
relayauto: 'Relay auto-control',
|
||||||
audio: 'Audio devices',
|
audio: 'Audio devices',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -576,6 +586,172 @@ function LiveStatusToggle() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ADIFMonitorPanel watches a list of external ADIF files (fldigi RTTY, N1MM,
|
||||||
|
// VarAC…) and auto-imports newly appended QSOs — deliberately option-free: a
|
||||||
|
// contact that arrives is imported and uploaded automatically like any log entry.
|
||||||
|
type ADIFWatchFileUI = { path: string; enabled: boolean; offset: number };
|
||||||
|
type ADIFMonitorCfgUI = { enabled: boolean; files: ADIFWatchFileUI[] };
|
||||||
|
function ADIFMonitorPanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [cfg, setCfg] = useState<ADIFMonitorCfgUI>({ enabled: false, files: [] });
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
GetADIFMonitor()
|
||||||
|
.then((c: any) => { if (c) setCfg({ enabled: !!c.enabled, files: (c.files ?? []) as ADIFWatchFileUI[] }); })
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoaded(true));
|
||||||
|
}, []);
|
||||||
|
// Offsets are managed backend-side; SaveADIFMonitor ignores the ones we send and
|
||||||
|
// keeps each existing file's read position (a new file starts at end-of-file).
|
||||||
|
const persist = (next: ADIFMonitorCfgUI) => { setCfg(next); SaveADIFMonitor(next as any).catch(() => {}); };
|
||||||
|
const addFile = async () => {
|
||||||
|
try {
|
||||||
|
const p = await PickADIFMonitorFile();
|
||||||
|
if (!p || cfg.files.some((f) => f.path === p)) return;
|
||||||
|
persist({ ...cfg, files: [...cfg.files, { path: p, enabled: true, offset: -1 }] });
|
||||||
|
} catch { /* dialog cancelled */ }
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<p className="text-xs text-muted-foreground">{t('adifmon.hint')}</p>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={cfg.enabled} disabled={!loaded} onCheckedChange={(c) => persist({ ...cfg, enabled: !!c })} />
|
||||||
|
{t('adifmon.enable')}
|
||||||
|
</label>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{cfg.files.length === 0 && <p className="text-xs text-muted-foreground italic">{t('adifmon.empty')}</p>}
|
||||||
|
{cfg.files.map((f, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 rounded-md border border-border bg-muted/20 px-2 py-1.5">
|
||||||
|
<Checkbox checked={f.enabled}
|
||||||
|
onCheckedChange={(c) => persist({ ...cfg, files: cfg.files.map((x, idx) => idx === i ? { ...x, enabled: !!c } : x) })} />
|
||||||
|
<span className="flex-1 font-mono text-xs truncate" title={f.path}>{f.path}</span>
|
||||||
|
<button type="button" title={t('adifmon.remove')}
|
||||||
|
className="text-muted-foreground hover:text-destructive shrink-0"
|
||||||
|
onClick={() => persist({ ...cfg, files: cfg.files.filter((_, idx) => idx !== i) })}>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={addFile}>
|
||||||
|
<FolderOpen className="size-3.5 mr-1" /> {t('adifmon.add')}
|
||||||
|
</Button>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('adifmon.note')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelayAutoPanel configures automatic control of the Station Control relay boards
|
||||||
|
// from the rig's frequency / band (PstRotator-style). Each relay carries one rule:
|
||||||
|
// off, a frequency window (kHz), or a set of bands.
|
||||||
|
type RelayRuleUI = { device_id: string; relay: number; mode: string; freq_lo_khz: number; freq_hi_khz: number; bands: string[] };
|
||||||
|
type StationDevUI = { id: string; type: string; name: string; labels: string[] };
|
||||||
|
const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
|
||||||
|
const relayCountUI = (type: string) => (type === 'kmtronic' ? 8 : 5);
|
||||||
|
function RelayAutoPanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [rules, setRules] = useState<RelayRuleUI[]>([]);
|
||||||
|
const [devices, setDevices] = useState<StationDevUI[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([GetRelayAuto(), GetStationDevices()])
|
||||||
|
.then(([cfg, devs]: any[]) => {
|
||||||
|
setEnabled(!!cfg?.enabled);
|
||||||
|
setRules((cfg?.rules ?? []) as RelayRuleUI[]);
|
||||||
|
setDevices((devs ?? []) as StationDevUI[]);
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoaded(true));
|
||||||
|
}, []);
|
||||||
|
const save = (en: boolean, rs: RelayRuleUI[]) => { SaveRelayAuto({ enabled: en, rules: rs } as any).catch(() => {}); };
|
||||||
|
const ruleFor = (dev: string, relay: number): RelayRuleUI =>
|
||||||
|
rules.find((r) => r.device_id === dev && r.relay === relay) ?? { device_id: dev, relay, mode: 'off', freq_lo_khz: 0, freq_hi_khz: 0, bands: [] };
|
||||||
|
// Apply a patch and persist (commit=true) or keep local only (commit=false, for
|
||||||
|
// freq inputs that persist on blur so we don't switch relays on every keystroke).
|
||||||
|
const patchRule = (dev: string, relay: number, patch: Partial<RelayRuleUI>, commit = true) => {
|
||||||
|
const next = { ...ruleFor(dev, relay), ...patch };
|
||||||
|
const others = rules.filter((r) => !(r.device_id === dev && r.relay === relay));
|
||||||
|
const all = [...others, next];
|
||||||
|
setRules(all);
|
||||||
|
if (commit) save(enabled, all);
|
||||||
|
};
|
||||||
|
const toggleBand = (dev: string, relay: number, band: string) => {
|
||||||
|
const cur = ruleFor(dev, relay);
|
||||||
|
const has = cur.bands.includes(band);
|
||||||
|
patchRule(dev, relay, { bands: has ? cur.bands.filter((b) => b !== band) : [...cur.bands, band] });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 max-w-3xl">
|
||||||
|
<p className="text-xs text-muted-foreground">{t('relayauto.hint')}</p>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={enabled} disabled={!loaded} onCheckedChange={(c) => { const v = !!c; setEnabled(v); save(v, rules); }} />
|
||||||
|
{t('relayauto.enable')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{devices.length === 0 && loaded && (
|
||||||
|
<p className="text-xs text-muted-foreground italic">{t('relayauto.noDevices')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{devices.map((dev) => (
|
||||||
|
<div key={dev.id} className="rounded-md border border-border">
|
||||||
|
<div className="px-3 py-1.5 border-b border-border bg-muted/40 text-xs font-semibold">{dev.name || dev.id}</div>
|
||||||
|
<div className="divide-y divide-border/60">
|
||||||
|
{Array.from({ length: relayCountUI(dev.type) }, (_, i) => i + 1).map((relay) => {
|
||||||
|
const r = ruleFor(dev.id, relay);
|
||||||
|
const label = (dev.labels?.[relay - 1] || '').trim() || `${t('relayauto.relay')} ${relay}`;
|
||||||
|
return (
|
||||||
|
<div key={relay} className="flex items-start gap-3 px-3 py-2">
|
||||||
|
<span className="w-28 shrink-0 text-xs font-mono pt-1.5 truncate" title={label}>{label}</span>
|
||||||
|
<Select value={r.mode || 'off'} onValueChange={(v) => patchRule(dev.id, relay, { mode: v })}>
|
||||||
|
<SelectTrigger className="h-8 w-32 text-xs shrink-0"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="off">{t('relayauto.modeOff')}</SelectItem>
|
||||||
|
<SelectItem value="freq">{t('relayauto.modeFreq')}</SelectItem>
|
||||||
|
<SelectItem value="band">{t('relayauto.modeBand')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="flex-1 min-w-0 pt-0.5">
|
||||||
|
{r.mode === 'freq' && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<Input type="number" className="h-8 w-24 text-xs" placeholder={t('relayauto.from')}
|
||||||
|
defaultValue={r.freq_lo_khz || ''}
|
||||||
|
onChange={(e) => patchRule(dev.id, relay, { freq_lo_khz: parseFloat(e.target.value) || 0 }, false)}
|
||||||
|
onBlur={() => save(enabled, rules)} />
|
||||||
|
<span className="text-muted-foreground">–</span>
|
||||||
|
<Input type="number" className="h-8 w-24 text-xs" placeholder={t('relayauto.to')}
|
||||||
|
defaultValue={r.freq_hi_khz || ''}
|
||||||
|
onChange={(e) => patchRule(dev.id, relay, { freq_hi_khz: parseFloat(e.target.value) || 0 }, false)}
|
||||||
|
onBlur={() => save(enabled, rules)} />
|
||||||
|
<span className="text-muted-foreground">kHz</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{r.mode === 'band' && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{RELAY_BANDS.map((b) => {
|
||||||
|
const on = r.bands.includes(b);
|
||||||
|
return (
|
||||||
|
<button key={b} type="button" onClick={() => toggleBand(dev.id, relay, b)}
|
||||||
|
className={cn('px-1.5 py-0.5 rounded text-[11px] font-mono border transition-colors',
|
||||||
|
on ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||||
|
{b}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// MainViewPanes lets the operator choose what the Main tab's left and right
|
// MainViewPanes lets the operator choose what the Main tab's left and right
|
||||||
// panes show, independently: the great-circle map, the locator street map, the
|
// panes show, independently: the great-circle map, the locator street map, the
|
||||||
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
|
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
|
||||||
@@ -909,6 +1085,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||||
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
||||||
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
||||||
|
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
||||||
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
||||||
// Password-encryption (secret vault) state.
|
// Password-encryption (secret vault) state.
|
||||||
const [secret, setSecret] = useState<{ has_passphrase: boolean; unlocked: boolean }>({ has_passphrase: false, unlocked: false });
|
const [secret, setSecret] = useState<{ has_passphrase: boolean; unlocked: boolean }>({ has_passphrase: false, unlocked: false });
|
||||||
@@ -4194,6 +4371,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Checkbox checked={startEqEnd} onCheckedChange={(c) => { const v = !!c; setStartEqEnd(v); writeUiPref('opslog.startEqualsEnd', v ? '1' : '0'); }} />
|
<Checkbox checked={startEqEnd} onCheckedChange={(c) => { const v = !!c; setStartEqEnd(v); writeUiPref('opslog.startEqualsEnd', v ? '1' : '0'); }} />
|
||||||
{t('gen.startEqEnd')} <span className="text-xs text-muted-foreground">{t('gen.startEqEndHint')}</span>
|
{t('gen.startEqEnd')} <span className="text-xs text-muted-foreground">{t('gen.startEqEndHint')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={showQsoRate} onCheckedChange={(c) => { const v = !!c; setShowQsoRate(v); writeUiPref('opslog.showQsoRate', v ? '1' : '0'); }} />
|
||||||
|
{t('gen.showQsoRate')} <span className="text-xs text-muted-foreground">{t('gen.showQsoRateHint')}</span>
|
||||||
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
|
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
|
||||||
{t('gen.lookupOnBlur')} <span className="text-xs text-muted-foreground">{t('gen.lookupOnBlurHint')}</span>
|
{t('gen.lookupOnBlur')} <span className="text-xs text-muted-foreground">{t('gen.lookupOnBlurHint')}</span>
|
||||||
@@ -4446,6 +4627,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
'lists-modes': ModesPanel,
|
'lists-modes': ModesPanel,
|
||||||
cluster: ClusterPanel,
|
cluster: ClusterPanel,
|
||||||
udp: UDPIntegrationsPanelWrapper,
|
udp: UDPIntegrationsPanelWrapper,
|
||||||
|
// Rendered as a real element (not called as a bare function) so its own hooks
|
||||||
|
// — useState/useEffect/useI18n — get a proper component context; PANELS[x]()
|
||||||
|
// is a plain call and hook-holding panels must go through JSX like this.
|
||||||
|
adifmon: () => <ADIFMonitorPanel />,
|
||||||
|
relayauto: () => <RelayAutoPanel />,
|
||||||
backup: BackupPanel,
|
backup: BackupPanel,
|
||||||
database: DatabasePanel,
|
database: DatabasePanel,
|
||||||
uscounties: USCountiesPanel,
|
uscounties: USCountiesPanel,
|
||||||
|
|||||||
@@ -52,11 +52,14 @@ const nf = (n: number) => n.toLocaleString('en-US');
|
|||||||
|
|
||||||
// ── Shell ────────────────────────────────────────────────────────────────────
|
// ── Shell ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Card({ title, sub, children, className }: { title: string; sub?: string; children: React.ReactNode; className?: string }) {
|
function Card({ title, sub, children, className, accent = 'var(--chart-1)' }: { title: string; sub?: string; children: React.ReactNode; className?: string; accent?: string }) {
|
||||||
return (
|
return (
|
||||||
<section className={cn('rounded-lg border border-border bg-card p-3.5 flex flex-col min-w-0', className)}>
|
<section className={cn('rounded-xl border border-border bg-card p-3.5 flex flex-col min-w-0 shadow-sm', className)}>
|
||||||
<header className="mb-3 shrink-0">
|
<header className="mb-3 shrink-0">
|
||||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{title}</h3>
|
<h3 className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
<span className="size-1.5 rounded-full shrink-0" style={{ background: accent }} />
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
{sub && <p className="text-[11px] text-muted-foreground/80 mt-0.5">{sub}</p>}
|
{sub && <p className="text-[11px] text-muted-foreground/80 mt-0.5">{sub}</p>}
|
||||||
</header>
|
</header>
|
||||||
{children}
|
{children}
|
||||||
@@ -64,14 +67,22 @@ function Card({ title, sub, children, className }: { title: string; sub?: string
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A headline number IS the chart — a one-bar bar chart would be noise.
|
// A headline number IS the chart — a one-bar bar chart would be noise. Each tile
|
||||||
function StatTile({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
// carries an accent (a categorical chart hue or a semantic token): a soft tint
|
||||||
|
// wash + a left accent bar give it a colourful identity, while the number itself
|
||||||
|
// stays in high-contrast foreground ink so it reads on every theme.
|
||||||
|
function StatTile({ label, value, sub, accent = 'var(--chart-1)' }: { label: string; value: string; sub?: string; accent?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border bg-card px-4 py-3 min-w-0">
|
<div className="relative overflow-hidden rounded-xl border border-border bg-card px-4 py-3 min-w-0 shadow-sm">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
|
<div className="pointer-events-none absolute inset-0 opacity-[0.08]" style={{ background: accent }} />
|
||||||
{/* Proportional figures: tabular-nums makes a big standalone number look loose. */}
|
<div className="pointer-events-none absolute -right-5 -top-7 size-20 rounded-full blur-xl opacity-[0.14]" style={{ background: accent }} />
|
||||||
<p className="mt-1 text-[28px] leading-none font-semibold text-foreground">{value}</p>
|
<div className="absolute left-0 inset-y-0 w-1" style={{ background: accent }} />
|
||||||
{sub && <p className="mt-1 text-[11px] text-muted-foreground truncate">{sub}</p>}
|
<div className="relative">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
|
||||||
|
{/* Proportional figures: tabular-nums makes a big standalone number look loose. */}
|
||||||
|
<p className="mt-1 text-[28px] leading-none font-semibold text-foreground">{value}</p>
|
||||||
|
{sub && <p className="mt-1 text-[11px] text-muted-foreground truncate">{sub}</p>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,7 +91,7 @@ function StatTile({ label, value, sub }: { label: string; value: string; sub?: s
|
|||||||
// One series → one hue. The value is direct-labelled, so no reader ever depends
|
// One series → one hue. The value is direct-labelled, so no reader ever depends
|
||||||
// on a tooltip to get a number.
|
// on a tooltip to get a number.
|
||||||
|
|
||||||
function HBars({ data, max, empty, share, labelWidth = 'w-20' }: { data: Bucket[]; max?: number; empty: string; share?: boolean; labelWidth?: string }) {
|
function HBars({ data, max, empty, share, labelWidth = 'w-20', colorful, color = 'var(--chart-1)' }: { data: Bucket[]; max?: number; empty: string; share?: boolean; labelWidth?: string; colorful?: boolean; color?: string }) {
|
||||||
// max is a display cap for long tails (top entities). Where EVERY row matters —
|
// max is a display cap for long tails (top entities). Where EVERY row matters —
|
||||||
// the operators of a multi-op — it is deliberately not set: a capped chart would
|
// the operators of a multi-op — it is deliberately not set: a capped chart would
|
||||||
// silently drop the 9th operator, and "who worked what" is the whole question.
|
// silently drop the 9th operator, and "who worked what" is the whole question.
|
||||||
@@ -90,13 +101,13 @@ function HBars({ data, max, empty, share, labelWidth = 'w-20' }: { data: Bucket[
|
|||||||
if (top.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
if (top.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5 min-w-0">
|
<div className="flex flex-col gap-1.5 min-w-0">
|
||||||
{top.map((d) => (
|
{top.map((d, i) => (
|
||||||
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key} — ${nf(d.count)}`}>
|
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key} — ${nf(d.count)}`}>
|
||||||
<span className={`${labelWidth} shrink-0 truncate text-[11px] text-muted-foreground text-right`} title={d.key}>{d.key}</span>
|
<span className={`${labelWidth} shrink-0 truncate text-[11px] text-muted-foreground text-right`} title={d.key}>{d.key}</span>
|
||||||
<div className="flex-1 min-w-0 h-[14px] flex items-center">
|
<div className="flex-1 min-w-0 h-[14px] flex items-center">
|
||||||
<div
|
<div
|
||||||
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
|
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
|
||||||
style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
|
style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: colorful ? `var(--chart-${(i % 8) + 1})` : color }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span>
|
<span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span>
|
||||||
@@ -629,12 +640,12 @@ export function StatsPanel() {
|
|||||||
|
|
||||||
{/* Headline figures: stat tiles, not a grouped bar chart. */}
|
{/* Headline figures: stat tiles, not a grouped bar chart. */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
|
||||||
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} />
|
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} accent="var(--chart-1)" />
|
||||||
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} />
|
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} accent="var(--chart-2)" />
|
||||||
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" />
|
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" accent="var(--chart-5)" />
|
||||||
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" />
|
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" accent="var(--chart-8)" />
|
||||||
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
|
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
|
||||||
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} />
|
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} accent="var(--success)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Period / contest block — ONLY when a window is selected. "12 QSO/h" across
|
{/* Period / contest block — ONLY when a window is selected. "12 QSO/h" across
|
||||||
@@ -723,43 +734,43 @@ export function StatsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||||
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')}>
|
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')} accent="var(--chart-3)">
|
||||||
<VBars data={stats.by_band} empty={empty} showValues colorful />
|
<VBars data={stats.by_band} empty={empty} showValues colorful />
|
||||||
</Card>
|
</Card>
|
||||||
<Card title={t('stats.byMode')}>
|
<Card title={t('stats.byMode')} accent="var(--chart-2)">
|
||||||
<HBars data={stats.by_mode} max={8} empty={empty} />
|
<HBars data={stats.by_mode} max={8} empty={empty} colorful />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2">
|
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
|
||||||
<AreaTrend data={stats.by_month} empty={empty} />
|
<AreaTrend data={stats.by_month} empty={empty} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
||||||
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
||||||
instead of truncating. */}
|
instead of truncating. */}
|
||||||
<Card title={t('stats.byOperator')}>
|
<Card title={t('stats.byOperator')} accent="var(--chart-5)">
|
||||||
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
|
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
|
||||||
<HBars data={stats.by_operator} empty={empty} share />
|
<HBars data={stats.by_operator} empty={empty} share color="var(--chart-5)" />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')}>
|
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')} accent="var(--chart-6)">
|
||||||
<Donut data={stats.by_continent} empty={empty} />
|
<Donut data={stats.by_continent} empty={empty} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title={t('stats.topEntities')}>
|
<Card title={t('stats.topEntities')} accent="var(--chart-4)">
|
||||||
<HBars data={stats.top_entities} max={12} empty={empty} labelWidth="w-40" />
|
<HBars data={stats.top_entities} max={12} empty={empty} labelWidth="w-40" color="var(--chart-4)" />
|
||||||
</Card>
|
</Card>
|
||||||
<div className="flex flex-col gap-2.5 min-w-0">
|
<div className="flex flex-col gap-2.5 min-w-0">
|
||||||
<Card title={t('stats.confirmations')} className="flex-1">
|
<Card title={t('stats.confirmations')} className="flex-1" accent="var(--success)">
|
||||||
<div className="flex flex-col gap-3 justify-center flex-1">
|
<div className="flex flex-col gap-3 justify-center flex-1">
|
||||||
<Meter label="LoTW" value={stats.confirmed_lotw} total={stats.total} />
|
<Meter label="LoTW" value={stats.confirmed_lotw} total={stats.total} />
|
||||||
<Meter label="eQSL" value={stats.confirmed_eqsl} total={stats.total} />
|
<Meter label="eQSL" value={stats.confirmed_eqsl} total={stats.total} />
|
||||||
<Meter label={t('stats.paperQSL')} value={stats.confirmed_qsl} total={stats.total} />
|
<Meter label={t('stats.paperQSL')} value={stats.confirmed_qsl} total={stats.total} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card title={t('stats.byStation')}>
|
<Card title={t('stats.byStation')} accent="var(--chart-8)">
|
||||||
<div className="max-h-[140px] overflow-auto pr-1 min-w-0">
|
<div className="max-h-[140px] overflow-auto pr-1 min-w-0">
|
||||||
<HBars data={stats.by_station} empty={empty} share />
|
<HBars data={stats.by_station} empty={empty} share color="var(--chart-8)" />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,7 +64,17 @@ export function Combobox({
|
|||||||
// Focus selects the text so a keystroke replaces it — but does NOT
|
// Focus selects the text so a keystroke replaces it — but does NOT
|
||||||
// open the list (so tabbing in doesn't pop the dropdown).
|
// open the list (so tabbing in doesn't pop the dropdown).
|
||||||
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
|
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
|
||||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (commitOnType) onChange(e.target.value); }}
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setQuery(v);
|
||||||
|
setOpen(true);
|
||||||
|
// Commit-on-type pushes the value live to the parent (so a CW macro sent
|
||||||
|
// without leaving the field uses what was just typed). With free text that's
|
||||||
|
// any input; a restricted field (allowFreeText=false) commits ONLY a value
|
||||||
|
// that's actually in the list, so a half-typed or invalid report never
|
||||||
|
// becomes the committed value — blur then reverts the leftover text.
|
||||||
|
if (commitOnType && (allowFreeText || options.some((o) => o.toLowerCase() === v.trim().toLowerCase()))) onChange(v);
|
||||||
|
}}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ type Dict = Record<string, string>;
|
|||||||
const en: Dict = {
|
const en: Dict = {
|
||||||
// Menu bar
|
// Menu bar
|
||||||
'prop.title': 'Propagation', 'prop.geomag': 'Geomag', 'prop.refresh': 'Refresh space weather',
|
'prop.title': 'Propagation', 'prop.geomag': 'Geomag', 'prop.refresh': 'Refresh space weather',
|
||||||
|
'live.onAir': 'On air', 'live.offline': 'Offline',
|
||||||
|
'live.onAirTip': 'On air — a QSO was logged in the last 5 minutes (published to the live status)',
|
||||||
|
'live.offlineTip': 'Offline — no QSO logged in the last 5 minutes',
|
||||||
|
'live.stationsTitle': 'Stations on air', 'live.stationsEmpty': 'No station reporting yet.', 'live.stationsHide': 'Hide',
|
||||||
|
'upd.available': 'OpsLog v{v} available', 'upd.current': "You're on v{v}.",
|
||||||
|
'upd.install': 'Update now', 'upd.download': 'Download', 'upd.later': 'Later',
|
||||||
|
'upd.downloading': 'Downloading…', 'upd.installing': 'Installing…',
|
||||||
|
'upd.restartNote': 'OpsLog will restart on the new version.',
|
||||||
|
'upd.retry': 'Retry', 'upd.browser': 'Open page',
|
||||||
|
'rate.title': 'QSO rate (QSOs/hour) — projected from the last 10 / 60 minutes',
|
||||||
'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
|
'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
|
||||||
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
|
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
|
||||||
'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…',
|
'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…',
|
||||||
@@ -101,6 +111,13 @@ const en: Dict = {
|
|||||||
'sec.general': 'General', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup',
|
'sec.general': 'General', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup',
|
||||||
'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster',
|
'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster',
|
||||||
'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'sec.uscounties': 'US Counties',
|
'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'sec.uscounties': 'US Counties',
|
||||||
|
'sec.adifmon': 'ADIF monitor',
|
||||||
|
'adifmon.hint': 'Watch external ADIF files and import new QSOs automatically — e.g. fldigi logging RTTY, or N1MM/VarAC. Imported QSOs are enriched, de-duplicated and uploaded to your external services just like a QSO logged here.',
|
||||||
|
'adifmon.enable': 'Enable ADIF monitor',
|
||||||
|
'adifmon.empty': 'No file watched yet. Add an ADIF file below.',
|
||||||
|
'adifmon.add': 'Add ADIF file…',
|
||||||
|
'adifmon.remove': 'Stop watching this file',
|
||||||
|
'adifmon.note': 'A newly added file starts from its current end — QSOs already in it are NOT imported, only contacts logged after you add it.',
|
||||||
'uscty.title': 'US Counties (USA-CA)',
|
'uscty.title': 'US Counties (USA-CA)',
|
||||||
'uscty.intro': 'Resolve a US callsign to its county and grid offline, from the FCC ULS licence database. This powers the US Counties award and county hunting — including on CW/SSB, where a spot carries only a callsign.',
|
'uscty.intro': 'Resolve a US callsign to its county and grid offline, from the FCC ULS licence database. This powers the US Counties award and county hunting — including on CW/SSB, where a spot carries only a callsign.',
|
||||||
'uscty.needDownload': 'County resolution requires downloading the FCC database first (about 150 MB, stored locally). Nothing is resolved until you download it.',
|
'uscty.needDownload': 'County resolution requires downloading the FCC database first (about 150 MB, stored locally). Nothing is resolved until you download it.',
|
||||||
@@ -117,11 +134,19 @@ const en: Dict = {
|
|||||||
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save',
|
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save',
|
||||||
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
|
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
|
||||||
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
|
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
|
||||||
|
'sec.relayauto': 'Relay auto-control',
|
||||||
|
'relayauto.hint': 'Automatically switch Station Control relays from the rig frequency / band (like PstRotator). Each relay: a frequency window (ON inside, OFF outside) or a set of bands. Relays are set up in the Station Control panel.',
|
||||||
|
'relayauto.enable': 'Enable relay auto-control',
|
||||||
|
'relayauto.noDevices': 'No relay board configured. Add one in the Station Control panel first.',
|
||||||
|
'relayauto.relay': 'Relay',
|
||||||
|
'relayauto.modeOff': 'Off (manual)', 'relayauto.modeFreq': 'Frequency', 'relayauto.modeBand': 'Band',
|
||||||
|
'relayauto.from': 'from', 'relayauto.to': 'to',
|
||||||
// General panel
|
// General panel
|
||||||
'gen.hint': 'App behaviour (saved instantly).',
|
'gen.hint': 'App behaviour (saved instantly).',
|
||||||
'gen.autofocusWB': 'Auto-focus "Worked before" for known stations',
|
'gen.autofocusWB': 'Auto-focus "Worked before" for known stations',
|
||||||
'gen.showBeam': 'Show the antenna beam heading on the Main map',
|
'gen.showBeam': 'Show the antenna beam heading on the Main map',
|
||||||
'gen.startEqEnd': 'QSO start time = end time', 'gen.startEqEndHint': '(matches LoTW when you call a while)',
|
'gen.startEqEnd': 'QSO start time = end time', 'gen.startEqEndHint': '(matches LoTW when you call a while)',
|
||||||
|
'gen.showQsoRate': 'Show QSO rate in the header', 'gen.showQsoRateHint': '(QSOs/hour, projected from the last 10 / 60 min)',
|
||||||
'gen.lookupOnBlur': 'Look up the callsign only after leaving the field', 'gen.lookupOnBlurHint': '(not while typing)',
|
'gen.lookupOnBlur': 'Look up the callsign only after leaving the field', 'gen.lookupOnBlurHint': '(not while typing)',
|
||||||
'gen.checkUpdates': 'Check for updates at startup', 'gen.checkUpdatesHint': '(notifies when a newer OpsLog is published)',
|
'gen.checkUpdates': 'Check for updates at startup', 'gen.checkUpdatesHint': '(notifies when a newer OpsLog is published)',
|
||||||
'email.title': 'E-mail',
|
'email.title': 'E-mail',
|
||||||
@@ -308,6 +333,11 @@ const en: Dict = {
|
|||||||
|
|
||||||
const fr: Dict = {
|
const fr: Dict = {
|
||||||
'prop.title': 'Propagation', 'prop.geomag': 'Géomag', 'prop.refresh': 'Actualiser la météo spatiale',
|
'prop.title': 'Propagation', 'prop.geomag': 'Géomag', 'prop.refresh': 'Actualiser la météo spatiale',
|
||||||
|
'live.onAir': 'On air', 'live.offline': 'Hors ligne',
|
||||||
|
'live.onAirTip': "On air — un QSO a été loggé dans les 5 dernières minutes (publié dans le statut live)",
|
||||||
|
'live.offlineTip': 'Hors ligne — aucun QSO loggé depuis 5 minutes',
|
||||||
|
'live.stationsTitle': 'Stations on air', 'live.stationsEmpty': 'Aucune station ne reporte pour le moment.', 'live.stationsHide': 'Masquer',
|
||||||
|
'rate.title': 'Rythme QSO (QSO/heure) — projeté sur les 10 / 60 dernières minutes',
|
||||||
'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)',
|
'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)',
|
||||||
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
|
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
|
||||||
'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…',
|
'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…',
|
||||||
@@ -390,6 +420,13 @@ const fr: Dict = {
|
|||||||
'sec.general': 'Général', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif",
|
'sec.general': 'Général', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif",
|
||||||
'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster',
|
'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster',
|
||||||
'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'sec.uscounties': 'Comtés US',
|
'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'sec.uscounties': 'Comtés US',
|
||||||
|
'sec.adifmon': 'Moniteur ADIF',
|
||||||
|
'adifmon.hint': "Surveille des fichiers ADIF externes et importe les nouveaux QSO automatiquement — ex. fldigi en RTTY, ou N1MM/VarAC. Les QSO importés sont enrichis, dédoublonnés et envoyés à tes services externes comme un QSO loggé ici.",
|
||||||
|
'adifmon.enable': 'Activer le moniteur ADIF',
|
||||||
|
'adifmon.empty': 'Aucun fichier surveillé. Ajoute un fichier ADIF ci-dessous.',
|
||||||
|
'adifmon.add': 'Ajouter un fichier ADIF…',
|
||||||
|
'adifmon.remove': 'Ne plus surveiller ce fichier',
|
||||||
|
'adifmon.note': "Un fichier ajouté démarre à sa fin actuelle — les QSO déjà présents ne sont PAS importés, seulement les contacts loggés après l'ajout.",
|
||||||
'uscty.title': 'Comtés US (USA-CA)',
|
'uscty.title': 'Comtés US (USA-CA)',
|
||||||
'uscty.intro': "Résout un indicatif US en comté et locator, hors-ligne, depuis la base de licences FCC ULS. Ça alimente le diplôme Comtés US et la chasse aux comtés — même en CW/SSB, où le spot ne porte qu'un indicatif.",
|
'uscty.intro': "Résout un indicatif US en comté et locator, hors-ligne, depuis la base de licences FCC ULS. Ça alimente le diplôme Comtés US et la chasse aux comtés — même en CW/SSB, où le spot ne porte qu'un indicatif.",
|
||||||
'uscty.needDownload': "La résolution des comtés nécessite d'abord de télécharger la base FCC (environ 150 Mo, stockée en local). Rien n'est résolu tant que tu ne l'as pas téléchargée.",
|
'uscty.needDownload': "La résolution des comtés nécessite d'abord de télécharger la base FCC (environ 150 Mo, stockée en local). Rien n'est résolu tant que tu ne l'as pas téléchargée.",
|
||||||
@@ -406,10 +443,18 @@ const fr: Dict = {
|
|||||||
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer',
|
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer',
|
||||||
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
|
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
|
||||||
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
||||||
|
'sec.relayauto': 'Relais automatiques',
|
||||||
|
'relayauto.hint': "Commute automatiquement les relais du Station Control selon la fréquence / bande du poste (comme PstRotator). Par relais : une plage de fréquence (ON dedans, OFF dehors) ou un ensemble de bandes. Les relais se configurent dans le panneau Station Control.",
|
||||||
|
'relayauto.enable': 'Activer les relais automatiques',
|
||||||
|
'relayauto.noDevices': "Aucune carte relais configurée. Ajoutes-en une dans le panneau Station Control d'abord.",
|
||||||
|
'relayauto.relay': 'Relais',
|
||||||
|
'relayauto.modeOff': 'Off (manuel)', 'relayauto.modeFreq': 'Fréquence', 'relayauto.modeBand': 'Bande',
|
||||||
|
'relayauto.from': 'de', 'relayauto.to': 'à',
|
||||||
'gen.hint': 'Comportement de l\'application (enregistré immédiatement).',
|
'gen.hint': 'Comportement de l\'application (enregistré immédiatement).',
|
||||||
'gen.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues',
|
'gen.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues',
|
||||||
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
|
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
|
||||||
'gen.startEqEnd': 'Heure de début du QSO = heure de fin', 'gen.startEqEndHint': '(correspond à LoTW quand tu appelles un moment)',
|
'gen.startEqEnd': 'Heure de début du QSO = heure de fin', 'gen.startEqEndHint': '(correspond à LoTW quand tu appelles un moment)',
|
||||||
|
'gen.showQsoRate': 'Afficher le rythme QSO dans la barre du haut', 'gen.showQsoRateHint': '(QSO/heure, projeté sur les 10 / 60 dernières min)',
|
||||||
'gen.lookupOnBlur': 'Rechercher l\'indicatif seulement après avoir quitté le champ', 'gen.lookupOnBlurHint': '(pas pendant la saisie)',
|
'gen.lookupOnBlur': 'Rechercher l\'indicatif seulement après avoir quitté le champ', 'gen.lookupOnBlurHint': '(pas pendant la saisie)',
|
||||||
'gen.checkUpdates': 'Vérifier les mises à jour au démarrage', 'gen.checkUpdatesHint': '(prévient quand une version plus récente est publiée)',
|
'gen.checkUpdates': 'Vérifier les mises à jour au démarrage', 'gen.checkUpdatesHint': '(prévient quand une version plus récente est publiée)',
|
||||||
'email.title': 'E-mail',
|
'email.title': 'E-mail',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.showRotor', // rotor compass shown next to the keyers
|
'opslog.showRotor', // rotor compass shown next to the keyers
|
||||||
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
||||||
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
||||||
|
'opslog.showQsoRate', // QSO-rate meter (10/60 min) shown in the header
|
||||||
'opslog.catModeBeforeFreq', // send CAT mode before frequency (older rigs)
|
'opslog.catModeBeforeFreq', // send CAT mode before frequency (older rigs)
|
||||||
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
|
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
|
||||||
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
||||||
|
|||||||
@@ -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.19.9';
|
export const APP_VERSION = '0.20.2';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+18
@@ -146,6 +146,8 @@ export function DismissAwardUpdate(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function DownloadAllReferenceLists():Promise<string>;
|
export function DownloadAllReferenceLists():Promise<string>;
|
||||||
|
|
||||||
|
export function DownloadAndApplyUpdate(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||||
|
|
||||||
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
||||||
@@ -278,6 +280,8 @@ export function FlexSetXITFreq(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function FlexTune(arg1:boolean):Promise<void>;
|
export function FlexTune(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
|
||||||
|
|
||||||
export function GetActiveProfile():Promise<profile.Profile>;
|
export function GetActiveProfile():Promise<profile.Profile>;
|
||||||
|
|
||||||
export function GetAlertEmailTo():Promise<string>;
|
export function GetAlertEmailTo():Promise<string>;
|
||||||
@@ -350,6 +354,8 @@ export function GetIcomState():Promise<cat.IcomTXState>;
|
|||||||
|
|
||||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||||
|
|
||||||
|
export function GetLiveStations():Promise<Array<main.LiveStation>>;
|
||||||
|
|
||||||
export function GetLiveStatusEnabled():Promise<boolean>;
|
export function GetLiveStatusEnabled():Promise<boolean>;
|
||||||
|
|
||||||
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
||||||
@@ -382,6 +388,10 @@ export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
|||||||
|
|
||||||
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||||
|
|
||||||
|
export function GetQSORate():Promise<main.QSORate>;
|
||||||
|
|
||||||
|
export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
||||||
|
|
||||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||||
|
|
||||||
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
||||||
@@ -540,6 +550,8 @@ export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>
|
|||||||
|
|
||||||
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||||
|
|
||||||
|
export function LiveLastQSOAgeSec():Promise<number>;
|
||||||
|
|
||||||
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
||||||
|
|
||||||
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||||
@@ -598,6 +610,8 @@ export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefau
|
|||||||
|
|
||||||
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function PickADIFMonitorFile():Promise<string>;
|
||||||
|
|
||||||
export function PickAudioFolder():Promise<string>;
|
export function PickAudioFolder():Promise<string>;
|
||||||
|
|
||||||
export function PickBackupFolder():Promise<string>;
|
export function PickBackupFolder():Promise<string>;
|
||||||
@@ -688,6 +702,8 @@ export function RunBackupNow():Promise<string>;
|
|||||||
|
|
||||||
export function SaveADIFFile():Promise<string>;
|
export function SaveADIFFile():Promise<string>;
|
||||||
|
|
||||||
|
export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
|
||||||
|
|
||||||
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
|
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>;
|
||||||
@@ -732,6 +748,8 @@ export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
|||||||
|
|
||||||
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
||||||
|
|
||||||
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
||||||
|
|||||||
@@ -250,6 +250,10 @@ export function DownloadAllReferenceLists() {
|
|||||||
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DownloadAndApplyUpdate(arg1) {
|
||||||
|
return window['go']['main']['App']['DownloadAndApplyUpdate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function DownloadClublogCty() {
|
export function DownloadClublogCty() {
|
||||||
return window['go']['main']['App']['DownloadClublogCty']();
|
return window['go']['main']['App']['DownloadClublogCty']();
|
||||||
}
|
}
|
||||||
@@ -514,6 +518,10 @@ export function FlexTune(arg1) {
|
|||||||
return window['go']['main']['App']['FlexTune'](arg1);
|
return window['go']['main']['App']['FlexTune'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetADIFMonitor() {
|
||||||
|
return window['go']['main']['App']['GetADIFMonitor']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetActiveProfile() {
|
export function GetActiveProfile() {
|
||||||
return window['go']['main']['App']['GetActiveProfile']();
|
return window['go']['main']['App']['GetActiveProfile']();
|
||||||
}
|
}
|
||||||
@@ -658,6 +666,10 @@ export function GetListsSettings() {
|
|||||||
return window['go']['main']['App']['GetListsSettings']();
|
return window['go']['main']['App']['GetListsSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetLiveStations() {
|
||||||
|
return window['go']['main']['App']['GetLiveStations']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetLiveStatusEnabled() {
|
export function GetLiveStatusEnabled() {
|
||||||
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
||||||
}
|
}
|
||||||
@@ -722,6 +734,14 @@ export function GetQSO(arg1) {
|
|||||||
return window['go']['main']['App']['GetQSO'](arg1);
|
return window['go']['main']['App']['GetQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetQSORate() {
|
||||||
|
return window['go']['main']['App']['GetQSORate']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetRelayAuto() {
|
||||||
|
return window['go']['main']['App']['GetRelayAuto']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetRotatorHeading() {
|
export function GetRotatorHeading() {
|
||||||
return window['go']['main']['App']['GetRotatorHeading']();
|
return window['go']['main']['App']['GetRotatorHeading']();
|
||||||
}
|
}
|
||||||
@@ -1038,6 +1058,10 @@ export function ListUDPIntegrations() {
|
|||||||
return window['go']['main']['App']['ListUDPIntegrations']();
|
return window['go']['main']['App']['ListUDPIntegrations']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LiveLastQSOAgeSec() {
|
||||||
|
return window['go']['main']['App']['LiveLastQSOAgeSec']();
|
||||||
|
}
|
||||||
|
|
||||||
export function LoTWUserInfo(arg1) {
|
export function LoTWUserInfo(arg1) {
|
||||||
return window['go']['main']['App']['LoTWUserInfo'](arg1);
|
return window['go']['main']['App']['LoTWUserInfo'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1154,6 +1178,10 @@ export function PGXLSetFanMode(arg1) {
|
|||||||
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
|
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PickADIFMonitorFile() {
|
||||||
|
return window['go']['main']['App']['PickADIFMonitorFile']();
|
||||||
|
}
|
||||||
|
|
||||||
export function PickAudioFolder() {
|
export function PickAudioFolder() {
|
||||||
return window['go']['main']['App']['PickAudioFolder']();
|
return window['go']['main']['App']['PickAudioFolder']();
|
||||||
}
|
}
|
||||||
@@ -1334,6 +1362,10 @@ export function SaveADIFFile() {
|
|||||||
return window['go']['main']['App']['SaveADIFFile']();
|
return window['go']['main']['App']['SaveADIFFile']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveADIFMonitor(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveADIFMonitor'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveAlertRule(arg1) {
|
export function SaveAlertRule(arg1) {
|
||||||
return window['go']['main']['App']['SaveAlertRule'](arg1);
|
return window['go']['main']['App']['SaveAlertRule'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1422,6 +1454,10 @@ export function SaveQSLDefaults(arg1) {
|
|||||||
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
|
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveRelayAuto(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveRotatorSettings(arg1) {
|
export function SaveRotatorSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1296,6 +1296,55 @@ export namespace lotwusers {
|
|||||||
|
|
||||||
export namespace main {
|
export namespace main {
|
||||||
|
|
||||||
|
export class ADIFWatchFile {
|
||||||
|
path: string;
|
||||||
|
enabled: boolean;
|
||||||
|
offset: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ADIFWatchFile(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.path = source["path"];
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.offset = source["offset"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ADIFMonitorConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
files: ADIFWatchFile[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ADIFMonitorConfig(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.files = this.convertValues(source["files"], ADIFWatchFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class AntGeniusSettings {
|
export class AntGeniusSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
host: string;
|
host: string;
|
||||||
@@ -2005,6 +2054,32 @@ export namespace main {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class LiveStation {
|
||||||
|
operator: string;
|
||||||
|
station: string;
|
||||||
|
freq_hz: number;
|
||||||
|
band: string;
|
||||||
|
mode: string;
|
||||||
|
online: boolean;
|
||||||
|
version: string;
|
||||||
|
age_sec: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new LiveStation(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.operator = source["operator"];
|
||||||
|
this.station = source["station"];
|
||||||
|
this.freq_hz = source["freq_hz"];
|
||||||
|
this.band = source["band"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.online = source["online"];
|
||||||
|
this.version = source["version"];
|
||||||
|
this.age_sec = source["age_sec"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class LoTWUsersStatus {
|
export class LoTWUsersStatus {
|
||||||
count: number;
|
count: number;
|
||||||
updated?: string;
|
updated?: string;
|
||||||
@@ -2329,6 +2404,75 @@ export namespace main {
|
|||||||
this.pickable = source["pickable"];
|
this.pickable = source["pickable"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class QSORate {
|
||||||
|
last10: number;
|
||||||
|
last60: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new QSORate(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.last10 = source["last10"];
|
||||||
|
this.last60 = source["last60"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class RelayAutoRule {
|
||||||
|
device_id: string;
|
||||||
|
relay: number;
|
||||||
|
mode: string;
|
||||||
|
freq_lo_khz: number;
|
||||||
|
freq_hi_khz: number;
|
||||||
|
bands: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new RelayAutoRule(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.device_id = source["device_id"];
|
||||||
|
this.relay = source["relay"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.freq_lo_khz = source["freq_lo_khz"];
|
||||||
|
this.freq_hi_khz = source["freq_hi_khz"];
|
||||||
|
this.bands = source["bands"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class RelayAutoConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
rules: RelayAutoRule[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new RelayAutoConfig(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.rules = this.convertValues(source["rules"], RelayAutoRule);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class RotatorHeading {
|
export class RotatorHeading {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -2645,6 +2789,7 @@ export namespace main {
|
|||||||
latest: string;
|
latest: string;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
|
download_url: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new UpdateInfo(source);
|
return new UpdateInfo(source);
|
||||||
@@ -2656,6 +2801,7 @@ export namespace main {
|
|||||||
this.latest = source["latest"];
|
this.latest = source["latest"];
|
||||||
this.available = source["available"];
|
this.available = source["available"];
|
||||||
this.url = source["url"];
|
this.url = source["url"];
|
||||||
|
this.download_url = source["download_url"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class WKMacro {
|
export class WKMacro {
|
||||||
|
|||||||
@@ -136,6 +136,11 @@ type icomNet struct {
|
|||||||
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
|
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
|
||||||
lastRx atomic.Int64
|
lastRx atomic.Int64
|
||||||
|
|
||||||
|
// dead is set when the rig explicitly tears the session down (control 0x05):
|
||||||
|
// Alive() then returns false immediately so ReadState fails on the next poll and
|
||||||
|
// the manager reconnects cleanly, instead of waiting out the 6 s lastRx timeout.
|
||||||
|
dead atomic.Bool
|
||||||
|
|
||||||
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
|
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
|
||||||
// Torn down alongside the CI-V/control streams in Close.
|
// Torn down alongside the CI-V/control streams in Close.
|
||||||
audio *icomAudio
|
audio *icomAudio
|
||||||
@@ -178,6 +183,9 @@ func (n *icomNet) markRx() { n.lastRx.Store(time.Now().UnixNano()) }
|
|||||||
// the radio — is gone. Independent of CI-V replies, so a powered-off rig still
|
// the radio — is gone. Independent of CI-V replies, so a powered-off rig still
|
||||||
// reads as Alive and the session isn't torn down. Satisfies aliveTransport.
|
// reads as Alive and the session isn't torn down. Satisfies aliveTransport.
|
||||||
func (n *icomNet) Alive() bool {
|
func (n *icomNet) Alive() bool {
|
||||||
|
if n.dead.Load() {
|
||||||
|
return false // rig sent an explicit disconnect — reconnect now, don't wait
|
||||||
|
}
|
||||||
last := n.lastRx.Load()
|
last := n.lastRx.Load()
|
||||||
if last == 0 {
|
if last == 0 {
|
||||||
return true // just connected, nothing received yet — give it a chance
|
return true // just connected, nothing received yet — give it a chance
|
||||||
@@ -292,6 +300,7 @@ func (n *icomNet) ctrlPump() {
|
|||||||
n.ctrlResend(icnLE.Uint16(buf[6:]))
|
n.ctrlResend(icnLE.Uint16(buf[6:]))
|
||||||
}
|
}
|
||||||
case 0x05: // rig-initiated disconnect — it dropped US
|
case 0x05: // rig-initiated disconnect — it dropped US
|
||||||
|
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
|
||||||
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
|
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
|
||||||
default:
|
default:
|
||||||
// Anything else on the control stream is (almost always) the rig's
|
// Anything else on the control stream is (almost always) the rig's
|
||||||
@@ -367,6 +376,7 @@ func (n *icomNet) civPump() {
|
|||||||
n.resend(icnLE.Uint16(buf[6:]))
|
n.resend(icnLE.Uint16(buf[6:]))
|
||||||
}
|
}
|
||||||
case typ == 0x05: // rig-initiated disconnect — it dropped US
|
case typ == 0x05: // rig-initiated disconnect — it dropped US
|
||||||
|
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
|
||||||
debugLog.Printf("icom net: rig sent DISCONNECT on CI-V stream — session dropped by the rig")
|
debugLog.Printf("icom net: rig sent DISCONNECT on CI-V stream — session dropped by the rig")
|
||||||
case typ == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data
|
case typ == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data
|
||||||
n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit
|
n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit
|
||||||
|
|||||||
+17
-1
@@ -32,6 +32,7 @@ type OmniRig struct {
|
|||||||
omnirig *ole.IDispatch
|
omnirig *ole.IDispatch
|
||||||
rig *ole.IDispatch
|
rig *ole.IDispatch
|
||||||
lastSig string // last logged Split/VFO signature — only log on change
|
lastSig string // last logged Split/VFO signature — only log on change
|
||||||
|
rigType string // OmniRig's RigType string (the .ini title), e.g. "IC-7610"
|
||||||
|
|
||||||
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
|
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
|
||||||
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
|
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
|
||||||
@@ -80,11 +81,20 @@ func (o *OmniRig) Connect() error {
|
|||||||
o.rig = rigVar.ToIDispatch()
|
o.rig = rigVar.ToIDispatch()
|
||||||
|
|
||||||
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
|
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
|
||||||
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, rt.ToString())
|
o.rigType = rt.ToString()
|
||||||
|
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, o.rigType)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isIC7610 reports whether the connected rig is an IC-7610. OmniRig's generic
|
||||||
|
// Freq property reads the wrong VFO on the 7610 (its Main/Sub model confuses the
|
||||||
|
// stock ini), so we read VFO A explicitly for it instead — matching what Log4OM
|
||||||
|
// shows.
|
||||||
|
func (o *OmniRig) isIC7610() bool {
|
||||||
|
return strings.Contains(strings.ToUpper(o.rigType), "7610")
|
||||||
|
}
|
||||||
|
|
||||||
func (o *OmniRig) Disconnect() {
|
func (o *OmniRig) Disconnect() {
|
||||||
if o.rig != nil {
|
if o.rig != nil {
|
||||||
o.rig.Release()
|
o.rig.Release()
|
||||||
@@ -199,6 +209,12 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
|||||||
s.Split = false
|
s.Split = false
|
||||||
s.RxFreqHz = 0
|
s.RxFreqHz = 0
|
||||||
s.FreqHz = freqMain
|
s.FreqHz = freqMain
|
||||||
|
// IC-7610 quirk: OmniRig's generic Freq reports VFO B (its Main/Sub model
|
||||||
|
// confuses the stock ini), so OpsLog showed the wrong VFO. Read VFO A
|
||||||
|
// explicitly for the 7610 — what the operator actually wants to see.
|
||||||
|
if o.isIC7610() && freqA != 0 {
|
||||||
|
s.FreqHz = freqA
|
||||||
|
}
|
||||||
if s.FreqHz == 0 {
|
if s.FreqHz == 0 {
|
||||||
if s.Vfo == "B" || s.Vfo == "BB" {
|
if s.Vfo == "B" || s.Vfo == "BB" {
|
||||||
s.FreqHz = freqB
|
s.FreqHz = freqB
|
||||||
|
|||||||
+45
-18
@@ -89,6 +89,9 @@ type Manager struct {
|
|||||||
rnd *rand.Rand
|
rnd *rand.Rand
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxUploadAttempts bounds retries of a transient upload failure.
|
||||||
|
const maxUploadAttempts = 4
|
||||||
|
|
||||||
func NewManager(deps Deps) *Manager {
|
func NewManager(deps Deps) *Manager {
|
||||||
if deps.Client == nil {
|
if deps.Client == nil {
|
||||||
deps.Client = &http.Client{Timeout: 20 * time.Second}
|
deps.Client = &http.Client{Timeout: 20 * time.Second}
|
||||||
@@ -101,6 +104,24 @@ func NewManager(deps Deps) *Manager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// attemptUpload uploads a QSO in its OWN goroutine and, on a TRANSIENT failure
|
||||||
|
// (rate-limit / network), re-arms itself with exponential back-off. Each upload is
|
||||||
|
// independent — never serialised through a shared worker, because a single slow
|
||||||
|
// upload (LoTW signs via TQSL; a service on a 30 s timeout) would otherwise block
|
||||||
|
// every following QSO's upload and strand them all at "R" (the regression that hit
|
||||||
|
// the operator on the newest build while everyone on the old concurrent path was
|
||||||
|
// fine).
|
||||||
|
func (m *Manager) attemptUpload(svc Service, id int64, cfg ServiceConfig, attempt int) {
|
||||||
|
go func() {
|
||||||
|
ok, retryable := m.upload(svc, id, cfg)
|
||||||
|
if !ok && retryable && attempt+1 < maxUploadAttempts {
|
||||||
|
backoff := time.Duration(1<<uint(attempt)) * time.Second // 1s, 2s, 4s…
|
||||||
|
m.logf("extsvc: %s upload of QSO %d will retry (attempt %d) in %s", svc, id, attempt+2, backoff)
|
||||||
|
time.AfterFunc(backoff, func() { m.attemptUpload(svc, id, cfg, attempt+1) })
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) logf(format string, args ...any) {
|
func (m *Manager) logf(format string, args ...any) {
|
||||||
if m.deps.Logf != nil {
|
if m.deps.Logf != nil {
|
||||||
m.deps.Logf(format, args...)
|
m.deps.Logf(format, args...)
|
||||||
@@ -175,15 +196,17 @@ func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
|
|||||||
m.scheduleUpload(svc, id, cfg)
|
m.scheduleUpload(svc, id, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// scheduleUpload either uploads now (immediate) or arms a timer (delayed).
|
// scheduleUpload uploads now (immediate) or after a random fuse (delayed). Each
|
||||||
|
// upload runs in its own goroutine (attemptUpload) — never serialised — so a slow
|
||||||
|
// one never holds up the rest.
|
||||||
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
||||||
if cfg.UploadMode == ModeDelayed {
|
if cfg.UploadMode == ModeDelayed {
|
||||||
d := m.delaySeconds()
|
d := m.delaySeconds()
|
||||||
m.logf("extsvc: %s upload of QSO %d scheduled in %s", svc, id, d)
|
m.logf("extsvc: %s upload of QSO %d scheduled in %s", svc, id, d)
|
||||||
time.AfterFunc(d, func() { m.upload(svc, id, cfg) })
|
time.AfterFunc(d, func() { m.attemptUpload(svc, id, cfg, 0) })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go m.upload(svc, id, cfg)
|
m.attemptUpload(svc, id, cfg, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// onCloseServices returns the services configured for on-close auto-upload,
|
// onCloseServices returns the services configured for on-close auto-upload,
|
||||||
@@ -243,25 +266,25 @@ func (m *Manager) FlushOnClose() int {
|
|||||||
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
|
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
|
||||||
case ServiceQRZ:
|
case ServiceQRZ:
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if m.upload(svc, id, cfg.QRZ) {
|
if ok, _ := m.upload(svc, id, cfg.QRZ); ok {
|
||||||
uploaded++
|
uploaded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case ServiceClublog:
|
case ServiceClublog:
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if m.upload(svc, id, cfg.Clublog) {
|
if ok, _ := m.upload(svc, id, cfg.Clublog); ok {
|
||||||
uploaded++
|
uploaded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case ServiceHRDLog:
|
case ServiceHRDLog:
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if m.upload(svc, id, cfg.HRDLog) {
|
if ok, _ := m.upload(svc, id, cfg.HRDLog); ok {
|
||||||
uploaded++
|
uploaded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case ServiceEQSL:
|
case ServiceEQSL:
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if m.upload(svc, id, cfg.EQSL) {
|
if ok, _ := m.upload(svc, id, cfg.EQSL); ok {
|
||||||
uploaded++
|
uploaded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,12 +335,16 @@ func (m *Manager) flushLoTWBatch(ids []int64, cfg ServiceConfig) int {
|
|||||||
// upload performs the actual push and returns true on success. It builds a
|
// upload performs the actual push and returns true on success. It builds a
|
||||||
// fresh, lifecycle-independent context so a delayed upload still completes
|
// fresh, lifecycle-independent context so a delayed upload still completes
|
||||||
// even if it fires close to shutdown.
|
// even if it fires close to shutdown.
|
||||||
func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
// upload performs one upload. It returns ok=true when the QSO was uploaded (and
|
||||||
|
// marked), and retryable=true when it failed in a way worth retrying later (an
|
||||||
|
// HTTP/service error such as a rate-limit 403) as opposed to a permanent skip
|
||||||
|
// (not eligible, wrong station callsign, no record).
|
||||||
|
func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, retryable bool) {
|
||||||
// Skip QSOs that aren't eligible (already sent, or sent status doesn't
|
// Skip QSOs that aren't eligible (already sent, or sent status doesn't
|
||||||
// match the configured Upload flag).
|
// match the configured Upload flag).
|
||||||
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(svc, id) {
|
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(svc, id) {
|
||||||
m.logf("extsvc: %s upload of QSO %d skipped (not eligible)", svc, id)
|
m.logf("extsvc: %s upload of QSO %d skipped (not eligible)", svc, id)
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Station-callsign guard. Each logbook belongs to one callsign:
|
// Station-callsign guard. Each logbook belongs to one callsign:
|
||||||
@@ -345,7 +372,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
if m.deps.NotifyError != nil {
|
if m.deps.NotifyError != nil {
|
||||||
m.deps.NotifyError(svc, id, err)
|
m.deps.NotifyError(svc, id, err)
|
||||||
}
|
}
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +387,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
||||||
if !ok {
|
if !ok {
|
||||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
res, err = UploadQRZ(ctx, m.deps.Client, cfg.APIKey, record)
|
res, err = UploadQRZ(ctx, m.deps.Client, cfg.APIKey, record)
|
||||||
case ServiceClublog:
|
case ServiceClublog:
|
||||||
@@ -369,7 +396,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
record, ok := m.deps.BuildADIF(id, "")
|
record, ok := m.deps.BuildADIF(id, "")
|
||||||
if !ok {
|
if !ok {
|
||||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
|
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
|
||||||
case ServiceLoTW:
|
case ServiceLoTW:
|
||||||
@@ -378,7 +405,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
||||||
if !ok {
|
if !ok {
|
||||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
res, err = UploadLoTW(ctx, cfg, "", record)
|
res, err = UploadLoTW(ctx, cfg, "", record)
|
||||||
case ServiceHRDLog:
|
case ServiceHRDLog:
|
||||||
@@ -387,7 +414,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
record, ok := m.deps.BuildADIF(id, "")
|
record, ok := m.deps.BuildADIF(id, "")
|
||||||
if !ok {
|
if !ok {
|
||||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
res, err = UploadHRDLog(ctx, m.deps.Client, cfg.Callsign, cfg.Code, record)
|
res, err = UploadHRDLog(ctx, m.deps.Client, cfg.Callsign, cfg.Code, record)
|
||||||
case ServiceEQSL:
|
case ServiceEQSL:
|
||||||
@@ -396,11 +423,11 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
record, ok := m.deps.BuildADIF(id, "")
|
record, ok := m.deps.BuildADIF(id, "")
|
||||||
if !ok {
|
if !ok {
|
||||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
|
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
|
||||||
default:
|
default:
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil || !res.OK {
|
if err != nil || !res.OK {
|
||||||
@@ -411,12 +438,12 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
if m.deps.NotifyError != nil {
|
if m.deps.NotifyError != nil {
|
||||||
m.deps.NotifyError(svc, id, err)
|
m.deps.NotifyError(svc, id, err)
|
||||||
}
|
}
|
||||||
return false
|
return false, true // transient (rate-limit / network) → worth a retry
|
||||||
}
|
}
|
||||||
|
|
||||||
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
|
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
|
||||||
if m.deps.MarkUploaded != nil {
|
if m.deps.MarkUploaded != nil {
|
||||||
m.deps.MarkUploaded(svc, id, res.LogID)
|
m.deps.MarkUploaded(svc, id, res.LogID)
|
||||||
}
|
}
|
||||||
return true
|
return true, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -781,6 +781,38 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetExtra merges ONE key into a QSO's extras JSON without rewriting the rest of
|
||||||
|
// the row. A slow caller that only wants to stamp its own app field (e-mailing a
|
||||||
|
// QSL card, saving a recording) must not do a full-row Update: the row it read may
|
||||||
|
// be seconds stale, and writing it all back silently reverts any column another
|
||||||
|
// action changed meanwhile — e.g. an auto-upload flipping clublog_qso_upload_status
|
||||||
|
// from R to Y. Touching only `extras` makes that impossible. Empty value deletes
|
||||||
|
// the key.
|
||||||
|
func (r *Repo) SetExtra(ctx context.Context, id int64, key, value string) error {
|
||||||
|
if id == 0 || strings.TrimSpace(key) == "" {
|
||||||
|
return fmt.Errorf("missing id or key")
|
||||||
|
}
|
||||||
|
var extrasJSON sql.NullString
|
||||||
|
if err := r.db.QueryRowContext(ctx, `SELECT extras_json FROM qso WHERE id = ?`, id).Scan(&extrasJSON); err != nil {
|
||||||
|
return fmt.Errorf("load extras: %w", err)
|
||||||
|
}
|
||||||
|
m := decodeExtras(extrasJSON.String)
|
||||||
|
if m == nil {
|
||||||
|
m = map[string]string{}
|
||||||
|
}
|
||||||
|
if value == "" {
|
||||||
|
delete(m, key)
|
||||||
|
} else {
|
||||||
|
m[key] = value
|
||||||
|
}
|
||||||
|
if _, err := r.db.ExecContext(ctx,
|
||||||
|
`UPDATE qso SET extras_json = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
encodeExtras(m), db.NowISO(), id); err != nil {
|
||||||
|
return fmt.Errorf("set extra %s: %w", key, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
|
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
|
||||||
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
||||||
if q.ID == 0 {
|
if q.ID == 0 {
|
||||||
@@ -1863,6 +1895,73 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
|
|||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LastQSOTime returns the start time of the most recently LOGGED QSO for an
|
||||||
|
// operator (highest id wins) — used to seed the live "on air" state at launch so an
|
||||||
|
// operator who just worked someone before (re)starting OpsLog shows online right
|
||||||
|
// away instead of waiting for their next QSO. Empty operator matches every QSO.
|
||||||
|
func (r *Repo) LastQSOTime(ctx context.Context, operator string) (time.Time, bool) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 400`)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
opFilter := strings.ToUpper(strings.TrimSpace(operator))
|
||||||
|
for rows.Next() {
|
||||||
|
var oper, dateStr sql.NullString
|
||||||
|
if err := rows.Scan(&oper, &dateStr); err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if t := parseTimeLoose(dateStr.String).UTC(); !t.IsZero() {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentRate counts QSOs whose start time falls within each trailing window from
|
||||||
|
// `now` — the live "QSO rate" meter shown in the header. When operator is non-empty
|
||||||
|
// (multi-op on a shared logbook) only that operator's QSOs are counted, so each op
|
||||||
|
// sees their OWN performance, not the cumulative rate; empty operator matches every
|
||||||
|
// QSO. It scans only the most recently inserted rows (ORDER BY id DESC LIMIT), since
|
||||||
|
// any QSO in the last hour was inserted recently; that keeps it cheap even on a large
|
||||||
|
// log. qso_date is the repo's text column, parsed with parseTimeLoose (backend-format
|
||||||
|
// agnostic).
|
||||||
|
func (r *Repo) RecentRate(ctx context.Context, now time.Time, operator string, windows ...time.Duration) ([]int, error) {
|
||||||
|
counts := make([]int, len(windows))
|
||||||
|
// 2000 rows covers a full hour for one operator even in a busy multi-op run
|
||||||
|
// (other operators' rows are discarded before counting).
|
||||||
|
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 2000`)
|
||||||
|
if err != nil {
|
||||||
|
return counts, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
now = now.UTC()
|
||||||
|
opFilter := strings.ToUpper(strings.TrimSpace(operator))
|
||||||
|
for rows.Next() {
|
||||||
|
var oper, dateStr sql.NullString
|
||||||
|
if err := rows.Scan(&oper, &dateStr); err != nil {
|
||||||
|
return counts, err
|
||||||
|
}
|
||||||
|
if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter {
|
||||||
|
continue // a different operator's QSO — not part of my rate
|
||||||
|
}
|
||||||
|
t := parseTimeLoose(dateStr.String).UTC()
|
||||||
|
if t.IsZero() || t.After(now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
age := now.Sub(t)
|
||||||
|
for i, w := range windows {
|
||||||
|
if age <= w {
|
||||||
|
counts[i]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// ExistingDedupeKeys returns a set of every QSO key currently in the DB,
|
// ExistingDedupeKeys returns a set of every QSO key currently in the DB,
|
||||||
// used by the ADIF importer to skip records that would re-create the
|
// used by the ADIF importer to skip records that would re-create the
|
||||||
// same contact. The key is callsign|YYYY-MM-DDTHH:MM|band|mode — minute
|
// same contact. The key is callsign|YYYY-MM-DDTHH:MM|band|mode — minute
|
||||||
|
|||||||
+24
-1
@@ -24,6 +24,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
@@ -37,6 +38,10 @@ import (
|
|||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// errFCCMaintenance is raised when the FCC ULS download host bounces us to its
|
||||||
|
// maintenance page instead of serving the file (a frequent, FCC-side event).
|
||||||
|
var errFCCMaintenance = errors.New("fcc uls under maintenance")
|
||||||
|
|
||||||
// Default download URLs (overridable in Import for tests).
|
// Default download URLs (overridable in Import for tests).
|
||||||
const (
|
const (
|
||||||
fccAmateurURL = "https://data.fcc.gov/download/pub/uls/complete/l_amat.zip"
|
fccAmateurURL = "https://data.fcc.gov/download/pub/uls/complete/l_amat.zip"
|
||||||
@@ -325,8 +330,26 @@ func download(ctx context.Context, url, dest string, prog func(pct int)) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
resp, err := http.DefaultClient.Do(req)
|
// Catch the FCC maintenance bounce BEFORE following it: data.fcc.gov redirects
|
||||||
|
// to www.fcc.gov/system-maintenance during maintenance windows, and that page
|
||||||
|
// then HTTP/2-stream-errors — which surfaced as a cryptic "INTERNAL_ERROR"
|
||||||
|
// instead of a plain "try again later".
|
||||||
|
client := &http.Client{
|
||||||
|
CheckRedirect: func(r *http.Request, via []*http.Request) error {
|
||||||
|
if strings.Contains(r.URL.String(), "system-maintenance") {
|
||||||
|
return errFCCMaintenance
|
||||||
|
}
|
||||||
|
if len(via) >= 10 {
|
||||||
|
return errors.New("stopped after 10 redirects")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, errFCCMaintenance) || strings.Contains(err.Error(), "system-maintenance") {
|
||||||
|
return fmt.Errorf("the FCC ULS download service is under maintenance (fcc.gov redirected to its maintenance page) — please try again later")
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|||||||
+160
-8
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -19,6 +20,23 @@ import (
|
|||||||
|
|
||||||
const keyLiveStatusEnabled = "livestatus.enabled"
|
const keyLiveStatusEnabled = "livestatus.enabled"
|
||||||
|
|
||||||
|
// liveOnlineWindow is how long after the last logged contact an operator still
|
||||||
|
// counts as "on air". Leaving the log open without working anyone flips them
|
||||||
|
// offline once this elapses; logging a new QSO flips them back online.
|
||||||
|
const liveOnlineWindow = 5 * time.Minute
|
||||||
|
|
||||||
|
// noteLiveQSO records that this operator just logged a new contact and pushes the
|
||||||
|
// live status right away, so they flip back to online the instant they work
|
||||||
|
// someone. Called from the logging paths (manual entry, UDP auto-log).
|
||||||
|
func (a *App) noteLiveQSO() {
|
||||||
|
a.liveActMu.Lock()
|
||||||
|
a.liveLastQSOAt = time.Now()
|
||||||
|
a.liveActMu.Unlock()
|
||||||
|
if a.liveStatusActive() {
|
||||||
|
go a.publishLiveStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetLiveStatusEnabled reports whether this operator publishes live status.
|
// GetLiveStatusEnabled reports whether this operator publishes live status.
|
||||||
func (a *App) GetLiveStatusEnabled() bool {
|
func (a *App) GetLiveStatusEnabled() bool {
|
||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
@@ -50,11 +68,61 @@ func (a *App) SetLiveStatusEnabled(on bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedLiveLastQSO primes liveLastQSOAt from the DB at launch, so an operator who
|
||||||
|
// worked someone shortly before (re)starting OpsLog is shown "on air" right away
|
||||||
|
// instead of offline until their next QSO.
|
||||||
|
func (a *App) seedLiveLastQSO() {
|
||||||
|
if a.qso == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
op, _ := a.liveStatusOperator()
|
||||||
|
if op == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if t, ok := a.qso.LastQSOTime(a.ctx, op); ok {
|
||||||
|
a.liveActMu.Lock()
|
||||||
|
if a.liveLastQSOAt.IsZero() {
|
||||||
|
a.liveLastQSOAt = t
|
||||||
|
}
|
||||||
|
a.liveActMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveLastQSOTime is the authoritative "last contact" instant for this operator:
|
||||||
|
// the most recent of the in-memory stamp (this session's local logs, updated
|
||||||
|
// instantly) AND the DB (a contact that arrived via the SHARED logbook from another
|
||||||
|
// station, or one logged before launch). Used by both the published status and the
|
||||||
|
// UI badge so on-air/offline is right in every multi-op case.
|
||||||
|
func (a *App) liveLastQSOTime() time.Time {
|
||||||
|
a.liveActMu.Lock()
|
||||||
|
last := a.liveLastQSOAt
|
||||||
|
a.liveActMu.Unlock()
|
||||||
|
if a.qso != nil {
|
||||||
|
if op, _ := a.liveStatusOperator(); op != "" {
|
||||||
|
if t, ok := a.qso.LastQSOTime(a.ctx, op); ok && t.After(last) {
|
||||||
|
last = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveLastQSOAgeSec returns seconds since this operator's last logged QSO, or -1 if
|
||||||
|
// none is known — the UI polls it for the "on air" badge.
|
||||||
|
func (a *App) LiveLastQSOAgeSec() int {
|
||||||
|
last := a.liveLastQSOTime()
|
||||||
|
if last.IsZero() {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return int(time.Since(last).Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
// liveStatusLoop heartbeats the current activity while enabled. Started once at
|
// liveStatusLoop heartbeats the current activity while enabled. Started once at
|
||||||
// startup; cheap no-op when disabled or not on MySQL.
|
// startup; cheap no-op when disabled or not on MySQL.
|
||||||
func (a *App) liveStatusLoop() {
|
func (a *App) liveStatusLoop() {
|
||||||
defer func() { _ = recover() }() // never crash the app from here
|
defer func() { _ = recover() }() // never crash the app from here
|
||||||
applog.Printf("livestatus: loop started")
|
applog.Printf("livestatus: loop started")
|
||||||
|
a.seedLiveLastQSO() // so online/offline is right at launch, not only after the next QSO
|
||||||
a.publishLiveStatus() // attempt immediately, don't wait the first tick
|
a.publishLiveStatus() // attempt immediately, don't wait the first tick
|
||||||
t := time.NewTicker(15 * time.Second)
|
t := time.NewTicker(15 * time.Second)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
@@ -133,33 +201,117 @@ func (a *App) publishLiveStatus() {
|
|||||||
mode = a.liveMode
|
mode = a.liveMode
|
||||||
}
|
}
|
||||||
a.liveActMu.Unlock()
|
a.liveActMu.Unlock()
|
||||||
|
lastQSO := a.liveLastQSOTime() // authoritative (in-memory OR shared DB)
|
||||||
|
// Online = a new contact was logged within the window. An operator who leaves
|
||||||
|
// the log open but stops working shows offline after `liveOnlineWindow`; the
|
||||||
|
// next QSO flips them back on. never-logged (zero time) → offline.
|
||||||
|
online := 0
|
||||||
|
var lastQSOArg any
|
||||||
|
if !lastQSO.IsZero() {
|
||||||
|
lastQSOArg = lastQSO.UTC()
|
||||||
|
if time.Since(lastQSO) < liveOnlineWindow {
|
||||||
|
online = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := a.ensureLiveStatusTable(); err != nil {
|
if err := a.ensureLiveStatusTable(); err != nil {
|
||||||
applog.Printf("livestatus: CREATE TABLE failed: %v", err)
|
applog.Printf("livestatus: CREATE TABLE failed: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, err := a.logDb.ExecContext(a.ctx,
|
_, err := a.logDb.ExecContext(a.ctx,
|
||||||
"INSERT INTO live_status (operator, station, freq_hz, band, mode, updated_at) "+
|
"INSERT INTO live_status (operator, station, freq_hz, band, mode, online, version, last_qso_at, updated_at) "+
|
||||||
"VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
|
||||||
"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
|
"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
|
||||||
"band=VALUES(band), mode=VALUES(mode), updated_at=UTC_TIMESTAMP()",
|
"band=VALUES(band), mode=VALUES(mode), online=VALUES(online), version=VALUES(version), "+
|
||||||
op, station, freqHz, band, mode)
|
"last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()",
|
||||||
|
op, station, freqHz, band, mode, online, appVersion, lastQSOArg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
applog.Printf("livestatus: INSERT failed: %v", err)
|
applog.Printf("livestatus: INSERT failed: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s", op, station, freqHz, band, mode)
|
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s online=%d", op, station, freqHz, band, mode, online)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveStation is one operator's live status for the multi-op "who's on air" widget.
|
||||||
|
type LiveStation struct {
|
||||||
|
Operator string `json:"operator"`
|
||||||
|
Station string `json:"station"`
|
||||||
|
FreqHz int64 `json:"freq_hz"`
|
||||||
|
Band string `json:"band"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Online bool `json:"online"` // logged a QSO in the last 5 min
|
||||||
|
Version string `json:"version"` // that operator's OpsLog version
|
||||||
|
AgeSec int `json:"age_sec"` // seconds since their last heartbeat (stale = OpsLog closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLiveStations returns every operator's live status from the shared MySQL
|
||||||
|
// logbook (empty on a local SQLite logbook). Rows whose heartbeat is very stale
|
||||||
|
// (OpsLog closed without clearing its row) are dropped. Online stations first.
|
||||||
|
func (a *App) GetLiveStations() []LiveStation {
|
||||||
|
if a.logDb == nil || a.dbBackend != "mysql" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := a.ensureLiveStatusTable(); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rows, err := a.logDb.QueryContext(a.ctx,
|
||||||
|
"SELECT operator, COALESCE(station,''), COALESCE(freq_hz,0), COALESCE(band,''), "+
|
||||||
|
"COALESCE(mode,''), COALESCE(online,0), COALESCE(version,''), "+
|
||||||
|
"TIMESTAMPDIFF(SECOND, updated_at, UTC_TIMESTAMP()) "+
|
||||||
|
"FROM live_status ORDER BY online DESC, operator")
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("livestatus: list failed: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []LiveStation{}
|
||||||
|
for rows.Next() {
|
||||||
|
var s LiveStation
|
||||||
|
var online int
|
||||||
|
var age sql.NullInt64
|
||||||
|
if err := rows.Scan(&s.Operator, &s.Station, &s.FreqHz, &s.Band, &s.Mode, &online, &s.Version, &age); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Drop rows from an OpsLog that hasn't heartbeated in a while (closed): the
|
||||||
|
// heartbeat is every 15 s, so > 3 min means it's gone.
|
||||||
|
if age.Valid && age.Int64 > 180 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.Online = online == 1
|
||||||
|
if age.Valid {
|
||||||
|
s.AgeSec = int(age.Int64)
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ensureLiveStatusTable() error {
|
func (a *App) ensureLiveStatusTable() error {
|
||||||
_, err := a.logDb.ExecContext(a.ctx,
|
if _, err := a.logDb.ExecContext(a.ctx,
|
||||||
"CREATE TABLE IF NOT EXISTS live_status ("+
|
"CREATE TABLE IF NOT EXISTS live_status ("+
|
||||||
"operator VARCHAR(32) PRIMARY KEY, "+
|
"operator VARCHAR(32) PRIMARY KEY, "+
|
||||||
"station VARCHAR(32), "+
|
"station VARCHAR(32), "+
|
||||||
"freq_hz BIGINT, "+
|
"freq_hz BIGINT, "+
|
||||||
"band VARCHAR(16), "+
|
"band VARCHAR(16), "+
|
||||||
"mode VARCHAR(16), "+
|
"mode VARCHAR(16), "+
|
||||||
"updated_at DATETIME)")
|
"online TINYINT DEFAULT 0, "+
|
||||||
return err
|
"version VARCHAR(32), "+
|
||||||
|
"last_qso_at DATETIME NULL, "+
|
||||||
|
"updated_at DATETIME)"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Add newer columns to a table created by an older build. MySQL has no portable
|
||||||
|
// "ADD COLUMN IF NOT EXISTS", so just run the ALTERs and ignore the duplicate-
|
||||||
|
// column error when they already exist.
|
||||||
|
for _, ddl := range []string{
|
||||||
|
"ALTER TABLE live_status ADD COLUMN online TINYINT DEFAULT 0",
|
||||||
|
"ALTER TABLE live_status ADD COLUMN version VARCHAR(32)",
|
||||||
|
"ALTER TABLE live_status ADD COLUMN last_qso_at DATETIME NULL",
|
||||||
|
} {
|
||||||
|
if _, err := a.logDb.ExecContext(a.ctx, ddl); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
|
||||||
|
applog.Printf("livestatus: %q: %v", ddl, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// clearLiveStatus removes this operator's row (on disable / shutdown).
|
// clearLiveStatus removes this operator's row (on disable / shutdown).
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2"
|
"github.com/wailsapp/wails/v2"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options"
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
@@ -35,15 +36,53 @@ func profileArg(args []string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasFlag reports whether flag is present in args.
|
||||||
|
func hasFlag(args []string, flag string) bool {
|
||||||
|
for _, a := range args {
|
||||||
|
if a == flag {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// acquireInstance grabs the single-instance mutex. On a normal launch it's a plain
|
||||||
|
// try (fail → another OpsLog is running, so exit). On a --post-update relaunch the
|
||||||
|
// previous instance may still be shutting down and holding the mutex, so retry for
|
||||||
|
// a few seconds until it frees.
|
||||||
|
func acquireInstance(postUpdate bool) bool {
|
||||||
|
if acquireSingleInstance() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !postUpdate {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(20 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
if acquireSingleInstance() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Single-instance guard: if OpsLog is already running, focus that window and
|
// Single-instance guard: if OpsLog is already running, focus that window and
|
||||||
// exit instead of spawning a duplicate. A second process would open its own
|
// exit instead of spawning a duplicate. A second process would open its own
|
||||||
// CAT (FlexRadio) connection and Ultrabeam follow loop, and the two would
|
// CAT (FlexRadio) connection and Ultrabeam follow loop, and the two would
|
||||||
// fight over the rig/antenna frequency — the cause of "the antenna re-tunes on
|
// fight over the rig/antenna frequency — the cause of "the antenna re-tunes on
|
||||||
// its own" when a windowless zombie instance was left running.
|
// its own" when a windowless zombie instance was left running.
|
||||||
if !acquireSingleInstance() {
|
// A --post-update relaunch (from the auto-updater) may start while the previous
|
||||||
|
// instance is still exiting and holding the single-instance mutex — wait for it
|
||||||
|
// to free instead of bailing out. Then clear the old exe it left behind.
|
||||||
|
postUpdate := hasFlag(os.Args[1:], "--post-update")
|
||||||
|
if !acquireInstance(postUpdate) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if postUpdate {
|
||||||
|
cleanupOldUpdateBinary()
|
||||||
|
}
|
||||||
|
|
||||||
// Create an instance of the app structure
|
// Create an instance of the app structure
|
||||||
app := NewApp()
|
app := NewApp()
|
||||||
|
|||||||
+205
@@ -0,0 +1,205 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Relay auto-control: drives the Station Control relay boards automatically from
|
||||||
|
// the rig's current frequency / band — the equivalent of PstRotator's "Automatic
|
||||||
|
// Control". Each relay carries at most one rule:
|
||||||
|
// - "freq": ON while the frequency is inside [lo,hi] kHz, OFF otherwise;
|
||||||
|
// - "band": ON while the current band is one of the listed bands, OFF otherwise;
|
||||||
|
// - "off"/empty: not managed (left to manual control).
|
||||||
|
//
|
||||||
|
// Evaluated on every CAT frequency/band change. A relay is only switched when its
|
||||||
|
// desired state actually changed since the last apply, so a slow relay board isn't
|
||||||
|
// hammered while you tune within the same range.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyRelayAuto = "relayauto.config"
|
||||||
|
|
||||||
|
// RelayAutoRule is one relay's automatic-control rule.
|
||||||
|
type RelayAutoRule struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
Relay int `json:"relay"` // 1-based
|
||||||
|
Mode string `json:"mode"` // "off" | "freq" | "band"
|
||||||
|
FreqLoKHz float64 `json:"freq_lo_khz"`
|
||||||
|
FreqHiKHz float64 `json:"freq_hi_khz"`
|
||||||
|
Bands []string `json:"bands"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelayAutoConfig is the whole auto-control setup: a master switch + the rules.
|
||||||
|
type RelayAutoConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Rules []RelayAutoRule `json:"rules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRelayAuto returns the relay auto-control configuration for the settings UI.
|
||||||
|
func (a *App) GetRelayAuto() RelayAutoConfig {
|
||||||
|
var cfg RelayAutoConfig
|
||||||
|
if a.settings == nil {
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
s, _ := a.settings.GetGlobal(a.ctx, keyRelayAuto)
|
||||||
|
if strings.TrimSpace(s) != "" {
|
||||||
|
_ = json.Unmarshal([]byte(s), &cfg)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveRelayAuto persists the configuration and applies it immediately from the
|
||||||
|
// current rig state, so toggling a rule takes effect without waiting for the next
|
||||||
|
// frequency change.
|
||||||
|
func (a *App) SaveRelayAuto(cfg RelayAutoConfig) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := a.settings.SetGlobal(a.ctx, keyRelayAuto, string(b)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.relayAutoOn.Store(cfg.Enabled) // keep the CAT hot-path flag in sync
|
||||||
|
// Re-apply from the live frequency so a just-changed rule takes hold now. Also
|
||||||
|
// forget the last-applied cache so a rule the user just switched to "off" and
|
||||||
|
// back gets re-sent even if the value is unchanged.
|
||||||
|
a.relayAutoMu.Lock()
|
||||||
|
a.relayAutoLast = map[string]bool{}
|
||||||
|
a.relayAutoMu.Unlock()
|
||||||
|
if a.cat != nil {
|
||||||
|
st := a.cat.State()
|
||||||
|
go a.applyRelayAuto(st.FreqHz, st.Band)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func relayAutoKey(dev string, relay int) string { return dev + "|" + strconv.Itoa(relay) }
|
||||||
|
|
||||||
|
func bandInList(bands []string, band string) bool {
|
||||||
|
band = strings.ToLower(strings.TrimSpace(band))
|
||||||
|
if band == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, b := range bands {
|
||||||
|
if strings.ToLower(strings.TrimSpace(b)) == band {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// relayAction is one relay's computed desired state for this evaluation.
|
||||||
|
type relayAction struct {
|
||||||
|
dev string
|
||||||
|
relay int
|
||||||
|
want bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyRelayAuto evaluates every rule against the current frequency/band and
|
||||||
|
// switches only the relays that are NOT already in the wanted position. Two things
|
||||||
|
// it deliberately does NOT do, which used to make the relay clunk on every
|
||||||
|
// launch/close:
|
||||||
|
// - Never acts on an UNKNOWN frequency/band. When the CAT disconnects (app close)
|
||||||
|
// the frequency drops to 0; reading that as "out of range" and switching the
|
||||||
|
// relay off — then back on at the next launch — was the whole bug.
|
||||||
|
// - Never commands a relay already in the right position: on the first evaluation
|
||||||
|
// after launch/save it reads the boards' LIVE state, so a relay that's already
|
||||||
|
// correct is left untouched instead of being re-sent.
|
||||||
|
func (a *App) applyRelayAuto(freqHz int64, band string) {
|
||||||
|
a.relayAutoMu.Lock()
|
||||||
|
defer a.relayAutoMu.Unlock()
|
||||||
|
|
||||||
|
cfg := a.GetRelayAuto()
|
||||||
|
if !cfg.Enabled || len(cfg.Rules) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.relayAutoLast == nil {
|
||||||
|
a.relayAutoLast = map[string]bool{}
|
||||||
|
}
|
||||||
|
khz := float64(freqHz) / 1000.0
|
||||||
|
band = strings.TrimSpace(band)
|
||||||
|
|
||||||
|
// Compute desired states, skipping rules whose input is unknown right now.
|
||||||
|
var acts []relayAction
|
||||||
|
needLive := false
|
||||||
|
for _, r := range cfg.Rules {
|
||||||
|
if r.Relay < 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var want bool
|
||||||
|
switch r.Mode {
|
||||||
|
case "freq":
|
||||||
|
if freqHz <= 0 {
|
||||||
|
continue // no known frequency (CAT off/closing) → leave the relay as-is
|
||||||
|
}
|
||||||
|
if r.FreqLoKHz <= 0 && r.FreqHiKHz <= 0 {
|
||||||
|
continue // unconfigured range
|
||||||
|
}
|
||||||
|
lo, hi := r.FreqLoKHz, r.FreqHiKHz
|
||||||
|
if hi < lo {
|
||||||
|
lo, hi = hi, lo
|
||||||
|
}
|
||||||
|
want = khz >= lo && khz <= hi
|
||||||
|
case "band":
|
||||||
|
if band == "" {
|
||||||
|
continue // no known band → leave the relay as-is
|
||||||
|
}
|
||||||
|
if len(r.Bands) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
want = bandInList(r.Bands, band)
|
||||||
|
default:
|
||||||
|
continue // "off"/empty → not managed
|
||||||
|
}
|
||||||
|
acts = append(acts, relayAction{r.DeviceID, r.Relay, want})
|
||||||
|
if _, ok := a.relayAutoLast[relayAutoKey(r.DeviceID, r.Relay)]; !ok {
|
||||||
|
needLive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(acts) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// First evaluation after launch/save: read the boards' LIVE relay states once
|
||||||
|
// so we don't re-command a relay that's already in the wanted position.
|
||||||
|
var live map[string]bool
|
||||||
|
if needLive {
|
||||||
|
live = map[string]bool{}
|
||||||
|
for _, ds := range a.GetStationStatus() {
|
||||||
|
for _, rl := range ds.Relays {
|
||||||
|
live[relayAutoKey(ds.ID, rl.Number)] = rl.On
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
for _, ac := range acts {
|
||||||
|
key := relayAutoKey(ac.dev, ac.relay)
|
||||||
|
cur, known := a.relayAutoLast[key]
|
||||||
|
if !known && live != nil {
|
||||||
|
cur, known = live[key]
|
||||||
|
}
|
||||||
|
if known && cur == ac.want {
|
||||||
|
a.relayAutoLast[key] = ac.want // already in position — record it, don't switch
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := a.StationSetRelay(ac.dev, ac.relay, ac.want); err != nil {
|
||||||
|
applog.Printf("relay auto: set %s relay %d = %v failed: %v", ac.dev, ac.relay, ac.want, err)
|
||||||
|
continue // don't cache a failed write — retry next change
|
||||||
|
}
|
||||||
|
a.relayAutoLast[key] = ac.want
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed && a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "station:relay_auto", nil) // nudge the Station Control UI to re-poll
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.19.9"
|
appVersion = "0.20.2"
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
"hamlog/internal/applog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,12 +23,13 @@ import (
|
|||||||
// build (the exe lives there; source stays on Gitea). Adjust the repo if needed.
|
// build (the exe lives there; source stays on Gitea). Adjust the repo if needed.
|
||||||
const updateCheckURL = "https://api.github.com/repos/GregTroar/OpsLog/releases/latest"
|
const updateCheckURL = "https://api.github.com/repos/GregTroar/OpsLog/releases/latest"
|
||||||
|
|
||||||
// UpdateInfo is the result of the startup version check.
|
// UpdateInfo is the result of the version check.
|
||||||
type UpdateInfo struct {
|
type UpdateInfo struct {
|
||||||
Current string `json:"current"` // this build's version (appVersion)
|
Current string `json:"current"` // this build's version (appVersion)
|
||||||
Latest string `json:"latest"` // newest published release, "" if unknown
|
Latest string `json:"latest"` // newest published release, "" if unknown
|
||||||
Available bool `json:"available"` // Latest > Current
|
Available bool `json:"available"` // Latest > Current
|
||||||
URL string `json:"url"` // release page to open
|
URL string `json:"url"` // release page to open (manual fallback)
|
||||||
|
DownloadURL string `json:"download_url"` // the .exe/.zip asset to auto-download, "" if none
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckForUpdate asks GitHub for the latest release and compares it to this
|
// CheckForUpdate asks GitHub for the latest release and compares it to this
|
||||||
@@ -45,6 +55,10 @@ func (a *App) CheckForUpdate() UpdateInfo {
|
|||||||
var r struct {
|
var r struct {
|
||||||
TagName string `json:"tag_name"`
|
TagName string `json:"tag_name"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
|
Assets []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"browser_download_url"`
|
||||||
|
} `json:"assets"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
||||||
return out
|
return out
|
||||||
@@ -52,8 +66,25 @@ func (a *App) CheckForUpdate() UpdateInfo {
|
|||||||
out.Latest = strings.TrimPrefix(strings.TrimSpace(r.TagName), "v")
|
out.Latest = strings.TrimPrefix(strings.TrimSpace(r.TagName), "v")
|
||||||
out.URL = r.HTMLURL
|
out.URL = r.HTMLURL
|
||||||
out.Available = versionLess(appVersion, out.Latest)
|
out.Available = versionLess(appVersion, out.Latest)
|
||||||
|
// Pick the auto-download asset: a bare Windows .exe (portable build) first,
|
||||||
|
// else a .zip we can unpack. The frontend hands this straight to
|
||||||
|
// DownloadAndApplyUpdate for a one-click in-app update.
|
||||||
|
for _, as := range r.Assets {
|
||||||
|
if strings.HasSuffix(strings.ToLower(as.Name), ".exe") {
|
||||||
|
out.DownloadURL = as.URL
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.DownloadURL == "" {
|
||||||
|
for _, as := range r.Assets {
|
||||||
|
if strings.HasSuffix(strings.ToLower(as.Name), ".zip") {
|
||||||
|
out.DownloadURL = as.URL
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if out.Available {
|
if out.Available {
|
||||||
applog.Printf("update: newer version available — current=%s latest=%s", appVersion, out.Latest)
|
applog.Printf("update: newer version available — current=%s latest=%s asset=%q", appVersion, out.Latest, out.DownloadURL)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -82,6 +113,176 @@ func versionLess(a, b string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DownloadAndApplyUpdate downloads the new build, swaps it in for the running exe
|
||||||
|
// and relaunches — the fully in-app update. Progress is emitted on "update:progress"
|
||||||
|
// (0-100) so the UI can show a bar. On success it never returns normally: it starts
|
||||||
|
// the new process and quits this one.
|
||||||
|
func (a *App) DownloadAndApplyUpdate(url string) error {
|
||||||
|
if strings.TrimSpace(url) == "" {
|
||||||
|
return fmt.Errorf("no download URL")
|
||||||
|
}
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("locate executable: %w", err)
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(exe)
|
||||||
|
|
||||||
|
// Download to a temp file next to the exe (same volume, so the rename-swap is
|
||||||
|
// atomic and can't fail across drives).
|
||||||
|
tmp := filepath.Join(dir, ".opslog-update.download")
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
if err := a.downloadWithProgress(url, tmp); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return fmt.Errorf("download: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The asset is either the bare exe or a zip holding it. Resolve to the new exe.
|
||||||
|
newExe := tmp
|
||||||
|
if strings.HasSuffix(strings.ToLower(url), ".zip") {
|
||||||
|
extracted, xerr := extractExeFromZip(tmp, dir)
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
if xerr != nil {
|
||||||
|
return fmt.Errorf("unpack: %w", xerr)
|
||||||
|
}
|
||||||
|
newExe = extracted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap: rename the running exe out of the way (Windows allows renaming a
|
||||||
|
// running image), move the new one into its place, then relaunch. Roll back if
|
||||||
|
// the second rename fails so we never end up with no exe.
|
||||||
|
oldExe := exe + ".old"
|
||||||
|
_ = os.Remove(oldExe)
|
||||||
|
if err := os.Rename(exe, oldExe); err != nil {
|
||||||
|
_ = os.Remove(newExe)
|
||||||
|
return fmt.Errorf("stage current exe: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(newExe, exe); err != nil {
|
||||||
|
_ = os.Rename(oldExe, exe) // roll back
|
||||||
|
return fmt.Errorf("install new exe: %w", err)
|
||||||
|
}
|
||||||
|
// Clear the "downloaded from the internet" mark (NTFS Zone.Identifier stream).
|
||||||
|
// Otherwise Windows SmartScreen wants to prompt "are you sure you want to open
|
||||||
|
// this?" — but since we launch the exe programmatically that prompt never shows,
|
||||||
|
// and the launch is silently blocked. This is exactly why the relaunch failed.
|
||||||
|
_ = os.Remove(exe + ":Zone.Identifier")
|
||||||
|
applog.Printf("update: installed new exe, scheduling relaunch")
|
||||||
|
|
||||||
|
// Relaunch via a detached, hidden PowerShell that WAITS for this process to exit
|
||||||
|
// (so the single-instance mutex is free) and THEN starts the new exe. Launching
|
||||||
|
// the new exe directly while we're still alive raced the mutex and often left
|
||||||
|
// nothing running; waiting for our own exit first makes the restart reliable,
|
||||||
|
// and the launcher outlives us.
|
||||||
|
quoted := strings.ReplaceAll(exe, "'", "''")
|
||||||
|
ps := fmt.Sprintf(
|
||||||
|
"Wait-Process -Id %d -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 400; Start-Process -FilePath '%s' -ArgumentList '--post-update'",
|
||||||
|
os.Getpid(), quoted)
|
||||||
|
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("schedule relaunch: %w", err)
|
||||||
|
}
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.Quit(a.ctx)
|
||||||
|
} else {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadWithProgress streams url into dest, emitting "update:progress" (0-100).
|
||||||
|
func (a *App) downloadWithProgress(url, dest string) error {
|
||||||
|
client := &http.Client{Timeout: 10 * time.Minute}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
f, err := os.Create(dest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
total := resp.ContentLength
|
||||||
|
var read int64
|
||||||
|
last := -1
|
||||||
|
buf := make([]byte, 64*1024)
|
||||||
|
emit := func(pct int) {
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "update:progress", pct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit(0)
|
||||||
|
for {
|
||||||
|
n, rerr := resp.Body.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if _, werr := f.Write(buf[:n]); werr != nil {
|
||||||
|
return werr
|
||||||
|
}
|
||||||
|
read += int64(n)
|
||||||
|
if total > 0 {
|
||||||
|
if pct := int(read * 100 / total); pct != last {
|
||||||
|
last = pct
|
||||||
|
emit(pct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rerr == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if rerr != nil {
|
||||||
|
return rerr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit(100)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractExeFromZip unpacks the first *.exe found in the zip into dir and returns
|
||||||
|
// its path.
|
||||||
|
func extractExeFromZip(zipPath, dir string) (string, error) {
|
||||||
|
zr, err := zip.OpenReader(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
for _, zf := range zr.File {
|
||||||
|
if !strings.HasSuffix(strings.ToLower(zf.Name), ".exe") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rc, err := zf.Open()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out := filepath.Join(dir, ".opslog-update.exe")
|
||||||
|
f, err := os.Create(out)
|
||||||
|
if err != nil {
|
||||||
|
rc.Close()
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, cerr := io.Copy(f, rc)
|
||||||
|
rc.Close()
|
||||||
|
f.Close()
|
||||||
|
if cerr != nil {
|
||||||
|
return "", cerr
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no .exe inside the archive")
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupOldUpdateBinary removes the previous exe left behind by a self-update
|
||||||
|
// (exe + ".old"). Called at startup after a --post-update relaunch. Best-effort:
|
||||||
|
// the file may still be briefly locked, in which case the next launch gets it.
|
||||||
|
func cleanupOldUpdateBinary() {
|
||||||
|
if exe, err := os.Executable(); err == nil {
|
||||||
|
_ = os.Remove(exe + ".old")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// leadingInt parses the leading digits of s (e.g. "2beta" → 2), 0 if none.
|
// leadingInt parses the leading digits of s (e.g. "2beta" → 2), 0 if none.
|
||||||
func leadingInt(s string) int {
|
func leadingInt(s string) int {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
|
|||||||
Reference in New Issue
Block a user