Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
pttMu sync.Mutex
|
||||
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
|
||||
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)
|
||||
@@ -808,6 +812,8 @@ func (a *App) startup(ctx context.Context) {
|
||||
a.logDb = logbookConn
|
||||
a.qso = qso.NewRepo(logbookConn)
|
||||
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
|
||||
// run downloads it from country-files.com in the background so startup
|
||||
@@ -893,6 +899,13 @@ func (a *App) startup(ctx context.Context) {
|
||||
wruntime.EventsEmit(a.ctx, "cat:state", 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()
|
||||
|
||||
@@ -4501,6 +4514,27 @@ func (a *App) GetOperators() ([]string, error) {
|
||||
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{}
|
||||
}
|
||||
counts, err := a.qso.RecentRate(a.ctx, time.Now(), 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
|
||||
// the Statistics picker only ever offers contests you really entered.
|
||||
func (a *App) GetContestRuns() ([]qso.ContestRun, error) {
|
||||
@@ -7825,7 +7859,15 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
||||
if err != nil {
|
||||
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))
|
||||
applog.Printf("extsvc: Club Log batch FAILED (%s) — QSOs: %s", msg, strings.Join(who, ", "))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
+79
-11
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
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,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
||||
GetCATSettings,
|
||||
GetSolarData,
|
||||
GetQSORate,
|
||||
LoTWUserInfo,
|
||||
OperatingDefaultForBand,
|
||||
LogUDPLoggedADIF,
|
||||
@@ -1085,6 +1086,20 @@ export default function App() {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
||||
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
||||
// 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
|
||||
// close so the next plain "Preferences" launch reverts to default.
|
||||
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
|
||||
@@ -1299,20 +1314,28 @@ export default function App() {
|
||||
// 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
|
||||
// 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> => {
|
||||
// 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 {
|
||||
const f = buildActiveFilter();
|
||||
const list = await ListQSOFiltered(f as any);
|
||||
const n = await CountQSO();
|
||||
const hasFilter = !!(f.quick_callsign || (f.conditions && f.conditions.length));
|
||||
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);
|
||||
setTotal(n);
|
||||
setMatchCount(matched);
|
||||
setError('');
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
if (!silent) setError(String(e?.message ?? e));
|
||||
if (!silent && seq === refreshSeqRef.current) setError(String(e?.message ?? e));
|
||||
return false;
|
||||
}
|
||||
}, [buildActiveFilter]);
|
||||
@@ -1898,7 +1921,16 @@ export default function App() {
|
||||
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
|
||||
}, []);
|
||||
|
||||
@@ -1993,17 +2025,21 @@ export default function App() {
|
||||
async function wkSend(rawText: string) {
|
||||
setWkSent('');
|
||||
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 sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
|
||||
if (cwSourceRef.current === 'icom') {
|
||||
// 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.
|
||||
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(); }
|
||||
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
|
||||
// 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,
|
||||
@@ -2843,12 +2879,12 @@ export default function App() {
|
||||
);
|
||||
const rstTxBlock = (
|
||||
<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>
|
||||
);
|
||||
const rstRxBlock = (
|
||||
<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>
|
||||
);
|
||||
// DX country flag, shown large next to RST (moved here from the Country field).
|
||||
@@ -3738,6 +3774,29 @@ export default function App() {
|
||||
)}
|
||||
</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
|
||||
(hamqsl.com), auto-refreshed hourly; the same SFI / A / K are stamped
|
||||
onto each logged QSO. Always renders one element so the grid columns
|
||||
@@ -3749,6 +3808,14 @@ export default function App() {
|
||||
const geo = String(solar.geomag_field || '').toUpperCase();
|
||||
const geoCls = /STORM|SEVERE/.test(geo) ? 'text-danger'
|
||||
: /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') => (
|
||||
<span className="inline-flex items-baseline gap-1">
|
||||
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">{label}</span>
|
||||
@@ -3756,15 +3823,16 @@ export default function App() {
|
||||
</span>
|
||||
);
|
||||
return (<>
|
||||
{it('SFI', solar.sfi)}
|
||||
{it('SSN', solar.ssn)}
|
||||
{it('A', solar.a_index)}
|
||||
{it('K', solar.k_index)}
|
||||
{it('SFI', solar.sfi, sfiCls(solar.sfi))}
|
||||
{it('SSN', solar.ssn, ssnCls(solar.ssn))}
|
||||
{it('A', solar.a_index, aCls(solar.a_index))}
|
||||
{it('K', solar.k_index, kCls(solar.k_index))}
|
||||
{geo ? <span className={cn('font-bold text-[12px]', geoCls)}>{geo}</span> : null}
|
||||
</>);
|
||||
})()}
|
||||
</div>
|
||||
) : <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">
|
||||
<Clock className="size-3" />
|
||||
|
||||
@@ -412,9 +412,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||
</div>
|
||||
<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>
|
||||
<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}
|
||||
title={t('qedit.fetchTitle')}>
|
||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
||||
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
ComputeStationInfo,
|
||||
GetUIPref, SetUIPref,
|
||||
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
||||
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
||||
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
@@ -169,6 +171,7 @@ type SectionId =
|
||||
| 'confirmations'
|
||||
| 'external-services'
|
||||
| 'udp'
|
||||
| 'adifmon'
|
||||
| 'lookup'
|
||||
| 'lists-bands'
|
||||
| 'lists-modes'
|
||||
@@ -185,6 +188,7 @@ type SectionId =
|
||||
| 'antgenius'
|
||||
| 'pgxl'
|
||||
| 'flex'
|
||||
| 'relayauto'
|
||||
| 'audio';
|
||||
|
||||
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.pgxl'), id: 'pgxl' },
|
||||
...(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' },
|
||||
];
|
||||
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.udp'), id: 'udp' },
|
||||
{ kind: 'item', label: t('sec.adifmon'), id: 'adifmon' },
|
||||
{ kind: 'item', label: t('sec.uscounties'), id: 'uscounties' },
|
||||
{ kind: 'item', label: t('sec.database'), id: 'database' },
|
||||
{ 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',
|
||||
'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',
|
||||
adifmon: 'sec.adifmon',
|
||||
uscounties: 'sec.uscounties',
|
||||
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',
|
||||
relayauto: 'sec.relayauto',
|
||||
};
|
||||
|
||||
// Map section id → friendly name (used in breadcrumb / placeholders).
|
||||
@@ -261,6 +269,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
||||
database: 'Database',
|
||||
autostart: 'Autostart',
|
||||
udp: 'UDP integrations',
|
||||
adifmon: 'ADIF monitor',
|
||||
awards: 'Awards',
|
||||
cat: 'CAT interface',
|
||||
rotator: 'Rotator',
|
||||
@@ -269,6 +278,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
||||
antgenius: 'Antenna Genius',
|
||||
pgxl: 'Power Genius',
|
||||
flex: 'FlexRadio',
|
||||
relayauto: 'Relay auto-control',
|
||||
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
|
||||
// panes show, independently: the great-circle map, the locator street map, the
|
||||
// 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 [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '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');
|
||||
// Password-encryption (secret vault) state.
|
||||
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'); }} />
|
||||
{t('gen.startEqEnd')} <span className="text-xs text-muted-foreground">{t('gen.startEqEndHint')}</span>
|
||||
</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">
|
||||
<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>
|
||||
@@ -4446,6 +4627,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
'lists-modes': ModesPanel,
|
||||
cluster: ClusterPanel,
|
||||
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,
|
||||
database: DatabasePanel,
|
||||
uscounties: USCountiesPanel,
|
||||
|
||||
@@ -52,11 +52,14 @@ const nf = (n: number) => n.toLocaleString('en-US');
|
||||
|
||||
// ── 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 (
|
||||
<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">
|
||||
<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>}
|
||||
</header>
|
||||
{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.
|
||||
function StatTile({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||
// A headline number IS the chart — a one-bar bar chart would be noise. Each tile
|
||||
// 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 (
|
||||
<div className="rounded-lg border border-border bg-card px-4 py-3 min-w-0">
|
||||
<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 className="relative overflow-hidden rounded-xl border border-border bg-card px-4 py-3 min-w-0 shadow-sm">
|
||||
<div className="pointer-events-none absolute inset-0 opacity-[0.08]" style={{ background: accent }} />
|
||||
<div className="pointer-events-none absolute -right-5 -top-7 size-20 rounded-full blur-xl opacity-[0.14]" style={{ background: accent }} />
|
||||
<div className="absolute left-0 inset-y-0 w-1" style={{ background: accent }} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
// 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 —
|
||||
// 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.
|
||||
@@ -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>;
|
||||
return (
|
||||
<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)}`}>
|
||||
<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="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>
|
||||
<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. */}
|
||||
<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.uniqueCalls')} value={nf(stats.unique_calls)} />
|
||||
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" />
|
||||
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" />
|
||||
<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)} accent="var(--chart-2)" />
|
||||
<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" accent="var(--chart-8)" />
|
||||
<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>
|
||||
|
||||
{/* Period / contest block — ONLY when a window is selected. "12 QSO/h" across
|
||||
@@ -723,43 +734,43 @@ export function StatsPanel() {
|
||||
</div>
|
||||
) : (
|
||||
<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 />
|
||||
</Card>
|
||||
<Card title={t('stats.byMode')}>
|
||||
<HBars data={stats.by_mode} max={8} empty={empty} />
|
||||
<Card title={t('stats.byMode')} accent="var(--chart-2)">
|
||||
<HBars data={stats.by_mode} max={8} empty={empty} colorful />
|
||||
</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} />
|
||||
</Card>
|
||||
|
||||
{/* 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
|
||||
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">
|
||||
<HBars data={stats.by_operator} empty={empty} share />
|
||||
<HBars data={stats.by_operator} empty={empty} share color="var(--chart-5)" />
|
||||
</div>
|
||||
</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} />
|
||||
</Card>
|
||||
|
||||
<Card title={t('stats.topEntities')}>
|
||||
<HBars data={stats.top_entities} max={12} empty={empty} labelWidth="w-40" />
|
||||
<Card title={t('stats.topEntities')} accent="var(--chart-4)">
|
||||
<HBars data={stats.top_entities} max={12} empty={empty} labelWidth="w-40" color="var(--chart-4)" />
|
||||
</Card>
|
||||
<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">
|
||||
<Meter label="LoTW" value={stats.confirmed_lotw} 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} />
|
||||
</div>
|
||||
</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">
|
||||
<HBars data={stats.by_station} empty={empty} share />
|
||||
<HBars data={stats.by_station} empty={empty} share color="var(--chart-8)" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -64,7 +64,17 @@ export function Combobox({
|
||||
// Focus selects the text so a keystroke replaces it — but does NOT
|
||||
// open the list (so tabbing in doesn't pop the dropdown).
|
||||
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}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
||||
|
||||
@@ -14,6 +14,7 @@ type Dict = Record<string, string>;
|
||||
const en: Dict = {
|
||||
// Menu bar
|
||||
'prop.title': 'Propagation', 'prop.geomag': 'Geomag', 'prop.refresh': 'Refresh space weather',
|
||||
'rate.title': 'QSO rate (QSOs/hour) — projected from the last 10 / 60 minutes',
|
||||
'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
|
||||
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
|
||||
'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…',
|
||||
@@ -101,6 +102,13 @@ const en: Dict = {
|
||||
'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.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.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.',
|
||||
@@ -117,11 +125,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',
|
||||
'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.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
|
||||
'gen.hint': 'App behaviour (saved instantly).',
|
||||
'gen.autofocusWB': 'Auto-focus "Worked before" for known stations',
|
||||
'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.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.checkUpdates': 'Check for updates at startup', 'gen.checkUpdatesHint': '(notifies when a newer OpsLog is published)',
|
||||
'email.title': 'E-mail',
|
||||
@@ -308,6 +324,7 @@ const en: Dict = {
|
||||
|
||||
const fr: Dict = {
|
||||
'prop.title': 'Propagation', 'prop.geomag': 'Géomag', 'prop.refresh': 'Actualiser la météo spatiale',
|
||||
'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)',
|
||||
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
|
||||
'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…',
|
||||
@@ -390,6 +407,13 @@ const fr: Dict = {
|
||||
'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.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.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.",
|
||||
@@ -406,10 +430,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',
|
||||
'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.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.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues',
|
||||
'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.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.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',
|
||||
|
||||
@@ -19,6 +19,7 @@ const PORTABLE_KEYS = [
|
||||
'opslog.showRotor', // rotor compass shown next to the keyers
|
||||
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
||||
'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.bandMapBands', // bands shown side-by-side in the Band Map tab
|
||||
'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).
|
||||
// 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.1';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+12
@@ -278,6 +278,8 @@ export function FlexSetXITFreq(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexTune(arg1:boolean):Promise<void>;
|
||||
|
||||
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
|
||||
|
||||
export function GetActiveProfile():Promise<profile.Profile>;
|
||||
|
||||
export function GetAlertEmailTo():Promise<string>;
|
||||
@@ -382,6 +384,10 @@ export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||
|
||||
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 GetRotatorSettings():Promise<main.RotatorSettings>;
|
||||
@@ -598,6 +604,8 @@ export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefau
|
||||
|
||||
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
||||
|
||||
export function PickADIFMonitorFile():Promise<string>;
|
||||
|
||||
export function PickAudioFolder():Promise<string>;
|
||||
|
||||
export function PickBackupFolder():Promise<string>;
|
||||
@@ -688,6 +696,8 @@ export function RunBackupNow():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 SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
|
||||
@@ -732,6 +742,8 @@ export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||
|
||||
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 SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
||||
|
||||
@@ -514,6 +514,10 @@ export function FlexTune(arg1) {
|
||||
return window['go']['main']['App']['FlexTune'](arg1);
|
||||
}
|
||||
|
||||
export function GetADIFMonitor() {
|
||||
return window['go']['main']['App']['GetADIFMonitor']();
|
||||
}
|
||||
|
||||
export function GetActiveProfile() {
|
||||
return window['go']['main']['App']['GetActiveProfile']();
|
||||
}
|
||||
@@ -722,6 +726,14 @@ export function 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() {
|
||||
return window['go']['main']['App']['GetRotatorHeading']();
|
||||
}
|
||||
@@ -1154,6 +1166,10 @@ export function PGXLSetFanMode(arg1) {
|
||||
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
|
||||
}
|
||||
|
||||
export function PickADIFMonitorFile() {
|
||||
return window['go']['main']['App']['PickADIFMonitorFile']();
|
||||
}
|
||||
|
||||
export function PickAudioFolder() {
|
||||
return window['go']['main']['App']['PickAudioFolder']();
|
||||
}
|
||||
@@ -1334,6 +1350,10 @@ export function SaveADIFFile() {
|
||||
return window['go']['main']['App']['SaveADIFFile']();
|
||||
}
|
||||
|
||||
export function SaveADIFMonitor(arg1) {
|
||||
return window['go']['main']['App']['SaveADIFMonitor'](arg1);
|
||||
}
|
||||
|
||||
export function SaveAlertRule(arg1) {
|
||||
return window['go']['main']['App']['SaveAlertRule'](arg1);
|
||||
}
|
||||
@@ -1422,6 +1442,10 @@ export function SaveQSLDefaults(arg1) {
|
||||
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
|
||||
}
|
||||
|
||||
export function SaveRelayAuto(arg1) {
|
||||
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
||||
}
|
||||
|
||||
export function SaveRotatorSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
||||
}
|
||||
|
||||
@@ -1296,6 +1296,55 @@ export namespace lotwusers {
|
||||
|
||||
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 {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
@@ -2329,6 +2378,75 @@ export namespace main {
|
||||
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 {
|
||||
enabled: boolean;
|
||||
ok: boolean;
|
||||
|
||||
+85
-20
@@ -87,17 +87,76 @@ type Manager struct {
|
||||
mu sync.Mutex
|
||||
cfg ExternalServices
|
||||
rnd *rand.Rand
|
||||
|
||||
// uploadCh serialises immediate auto-uploads through a single worker. Firing a
|
||||
// goroutine per QSO meant a pileup / ADIF-import burst hit a service with dozens
|
||||
// of concurrent requests at once — Club Log's nginx answers that with 403, the
|
||||
// upload is counted as failed and the QSO stays at "R" despite the others going
|
||||
// through. One-at-a-time with a small gap keeps every upload under the limit.
|
||||
uploadCh chan uploadJob
|
||||
}
|
||||
|
||||
// uploadJob is one queued auto-upload.
|
||||
type uploadJob struct {
|
||||
svc Service
|
||||
id int64
|
||||
cfg ServiceConfig
|
||||
attempt int // 0 on first try; incremented on each retry
|
||||
}
|
||||
|
||||
// uploadGap spaces serialized uploads so a burst never trips a service's per-IP
|
||||
// rate limiter. maxUploadAttempts bounds retries of a transient failure.
|
||||
const (
|
||||
uploadGap = 250 * time.Millisecond
|
||||
maxUploadAttempts = 4
|
||||
)
|
||||
|
||||
func NewManager(deps Deps) *Manager {
|
||||
if deps.Client == nil {
|
||||
deps.Client = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
return &Manager{
|
||||
m := &Manager{
|
||||
deps: deps,
|
||||
// Seeded from the clock; the delay only needs to be unpredictable
|
||||
// enough to spread bursts, not cryptographically random.
|
||||
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
uploadCh: make(chan uploadJob, 4096),
|
||||
}
|
||||
go m.uploadWorker()
|
||||
return m
|
||||
}
|
||||
|
||||
// uploadWorker drains the queue one upload at a time, spacing them so a burst of
|
||||
// freshly-logged QSOs can't hammer (and get 403'd by) a service. A transient
|
||||
// failure is re-queued with an exponential back-off, so a QSO that hit a
|
||||
// momentary rate-limit still ends up marked instead of stuck at "R".
|
||||
func (m *Manager) uploadWorker() {
|
||||
for job := range m.uploadCh {
|
||||
ok, retryable := m.upload(job.svc, job.id, job.cfg)
|
||||
if !ok && retryable && job.attempt+1 < maxUploadAttempts {
|
||||
next := job
|
||||
next.attempt++
|
||||
backoff := time.Duration(1<<uint(job.attempt)) * time.Second // 1s, 2s, 4s…
|
||||
m.logf("extsvc: %s upload of QSO %d will retry (attempt %d) in %s", job.svc, job.id, next.attempt+1, backoff)
|
||||
time.AfterFunc(backoff, func() { m.enqueueJob(next) })
|
||||
}
|
||||
time.Sleep(uploadGap)
|
||||
}
|
||||
}
|
||||
|
||||
// enqueueUpload queues a first-attempt upload without blocking the logging path.
|
||||
func (m *Manager) enqueueUpload(svc Service, id int64, cfg ServiceConfig) {
|
||||
m.enqueueJob(uploadJob{svc: svc, id: id, cfg: cfg})
|
||||
}
|
||||
|
||||
// enqueueJob queues a job (possibly a retry). If the queue is somehow full (an
|
||||
// enormous burst), it falls back to a goroutine rather than dropping the upload —
|
||||
// a dropped upload would leave the QSO stuck at "R".
|
||||
func (m *Manager) enqueueJob(job uploadJob) {
|
||||
select {
|
||||
case m.uploadCh <- job:
|
||||
default:
|
||||
go m.upload(job.svc, job.id, job.cfg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,15 +234,17 @@ func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
|
||||
m.scheduleUpload(svc, id, cfg)
|
||||
}
|
||||
|
||||
// scheduleUpload either uploads now (immediate) or arms a timer (delayed).
|
||||
// scheduleUpload either queues the upload now (immediate) or arms a timer that
|
||||
// queues it later (delayed). Both go through the serialised worker so uploads are
|
||||
// never fired concurrently in a burst.
|
||||
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
||||
if cfg.UploadMode == ModeDelayed {
|
||||
d := m.delaySeconds()
|
||||
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.enqueueUpload(svc, id, cfg) })
|
||||
return
|
||||
}
|
||||
go m.upload(svc, id, cfg)
|
||||
m.enqueueUpload(svc, id, cfg)
|
||||
}
|
||||
|
||||
// onCloseServices returns the services configured for on-close auto-upload,
|
||||
@@ -243,25 +304,25 @@ func (m *Manager) FlushOnClose() int {
|
||||
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
|
||||
case ServiceQRZ:
|
||||
for _, id := range ids {
|
||||
if m.upload(svc, id, cfg.QRZ) {
|
||||
if ok, _ := m.upload(svc, id, cfg.QRZ); ok {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
case ServiceClublog:
|
||||
for _, id := range ids {
|
||||
if m.upload(svc, id, cfg.Clublog) {
|
||||
if ok, _ := m.upload(svc, id, cfg.Clublog); ok {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
case ServiceHRDLog:
|
||||
for _, id := range ids {
|
||||
if m.upload(svc, id, cfg.HRDLog) {
|
||||
if ok, _ := m.upload(svc, id, cfg.HRDLog); ok {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
case ServiceEQSL:
|
||||
for _, id := range ids {
|
||||
if m.upload(svc, id, cfg.EQSL) {
|
||||
if ok, _ := m.upload(svc, id, cfg.EQSL); ok {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
@@ -312,12 +373,16 @@ func (m *Manager) flushLoTWBatch(ids []int64, cfg ServiceConfig) int {
|
||||
// upload performs the actual push and returns true on success. It builds a
|
||||
// fresh, lifecycle-independent context so a delayed upload still completes
|
||||
// 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
|
||||
// match the configured Upload flag).
|
||||
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(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:
|
||||
@@ -345,7 +410,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
||||
if m.deps.NotifyError != nil {
|
||||
m.deps.NotifyError(svc, id, err)
|
||||
}
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,7 +425,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
||||
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
||||
if !ok {
|
||||
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)
|
||||
case ServiceClublog:
|
||||
@@ -369,7 +434,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
||||
record, ok := m.deps.BuildADIF(id, "")
|
||||
if !ok {
|
||||
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)
|
||||
case ServiceLoTW:
|
||||
@@ -378,7 +443,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
||||
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
||||
if !ok {
|
||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
res, err = UploadLoTW(ctx, cfg, "", record)
|
||||
case ServiceHRDLog:
|
||||
@@ -387,7 +452,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
||||
record, ok := m.deps.BuildADIF(id, "")
|
||||
if !ok {
|
||||
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)
|
||||
case ServiceEQSL:
|
||||
@@ -396,11 +461,11 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
||||
record, ok := m.deps.BuildADIF(id, "")
|
||||
if !ok {
|
||||
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)
|
||||
default:
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
|
||||
if err != nil || !res.OK {
|
||||
@@ -411,12 +476,12 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
||||
if m.deps.NotifyError != nil {
|
||||
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)
|
||||
if m.deps.MarkUploaded != nil {
|
||||
m.deps.MarkUploaded(svc, id, res.LogID)
|
||||
}
|
||||
return true
|
||||
return true, false
|
||||
}
|
||||
|
||||
@@ -1863,6 +1863,40 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// RecentRate counts QSOs whose start time falls within each trailing window from
|
||||
// `now` — the live "QSO rate" meter shown in the header. 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, windows ...time.Duration) ([]int, error) {
|
||||
counts := make([]int, len(windows))
|
||||
// 400 rows covers a full hour even at a blistering contest rate (>300/h); any
|
||||
// QSO inside the trailing windows is among the most recently inserted.
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT qso_date FROM qso ORDER BY id DESC LIMIT 400`)
|
||||
if err != nil {
|
||||
return counts, err
|
||||
}
|
||||
defer rows.Close()
|
||||
now = now.UTC()
|
||||
for rows.Next() {
|
||||
var dateStr sql.NullString
|
||||
if err := rows.Scan(&dateStr); err != nil {
|
||||
return counts, err
|
||||
}
|
||||
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,
|
||||
// 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
|
||||
|
||||
+24
-1
@@ -24,6 +24,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
@@ -37,6 +38,10 @@ import (
|
||||
_ "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).
|
||||
const (
|
||||
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 {
|
||||
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 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
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
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
|
||||
}
|
||||
|
||||
// applyRelayAuto evaluates every rule against the current frequency/band and
|
||||
// switches only the relays whose desired state changed since the last apply.
|
||||
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
|
||||
|
||||
changed := false
|
||||
for _, r := range cfg.Rules {
|
||||
if r.Relay < 1 {
|
||||
continue
|
||||
}
|
||||
var want bool
|
||||
switch r.Mode {
|
||||
case "freq":
|
||||
if r.FreqLoKHz <= 0 && r.FreqHiKHz <= 0 {
|
||||
continue // unconfigured range → leave the relay alone
|
||||
}
|
||||
lo, hi := r.FreqLoKHz, r.FreqHiKHz
|
||||
if hi < lo {
|
||||
lo, hi = hi, lo
|
||||
}
|
||||
want = khz >= lo && khz <= hi
|
||||
case "band":
|
||||
if len(r.Bands) == 0 {
|
||||
continue
|
||||
}
|
||||
want = bandInList(r.Bands, band)
|
||||
default:
|
||||
continue // "off"/empty → not managed
|
||||
}
|
||||
|
||||
key := relayAutoKey(r.DeviceID, r.Relay)
|
||||
if last, ok := a.relayAutoLast[key]; ok && last == want {
|
||||
continue // no change → don't hammer the board
|
||||
}
|
||||
if err := a.StationSetRelay(r.DeviceID, r.Relay, want); err != nil {
|
||||
applog.Printf("relay auto: set %s relay %d = %v failed: %v", r.DeviceID, r.Relay, want, err)
|
||||
continue // don't cache a failed write — retry next change
|
||||
}
|
||||
a.relayAutoLast[key] = 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 (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.19.9"
|
||||
appVersion = "0.20.1"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user