diff --git a/app.go b/app.go index 2ec6927..a8ca6f4 100644 --- a/app.go +++ b/app.go @@ -74,8 +74,11 @@ const ( keyListsRSTDigital = "lists.rst_digital" keyCATEnabled = "cat.enabled" - keyCATBackend = "cat.backend" // "omnirig" (only one for now) + keyCATBackend = "cat.backend" // "omnirig" | "flex" keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2 + keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend) + keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992) + keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter keyCATPollMs = "cat.poll_ms" keyCATDelayMs = "cat.delay_ms" // pause between commands keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA @@ -225,8 +228,11 @@ type QSLDefaults struct { // individual key/value pairs to keep the settings table flat. type CATSettings struct { Enabled bool `json:"enabled"` - Backend string `json:"backend"` // currently always "omnirig" + Backend string `json:"backend"` // "omnirig" | "flex" OmniRigNum int `json:"omnirig_rig"` // 1 or 2 (OmniRig "Rig1"/"Rig2" slot) + FlexHost string `json:"flex_host"` // FlexRadio IP (native backend) + FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992) + FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter PollMs int `json:"poll_ms"` // poll interval in ms (default 250) DelayMs int `json:"delay_ms"` // pause between commands (default 0) DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…) @@ -372,6 +378,7 @@ type App struct { logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite) dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite + catFlexSpots bool // push cluster spots to the FlexRadio panadapter awardSnapMu sync.Mutex // guards the award QSO snapshot awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations awardSnapRev string // logbook revision the snapshot was built at ("" = none) @@ -668,6 +675,16 @@ func (a *App) startup(ctx context.Context) { if a.ctx != nil { wruntime.EventsEmit(a.ctx, "cluster:spot", s) } + // Mirror the spot onto the FlexRadio panadapter when enabled. The + // Color is left to the backend default for now — status-based + // colouring can be filled in here later (new entity / worked / …). + if a.catFlexSpots && a.cat != nil { + a.cat.SendSpot(cat.SpotInfo{ + FreqHz: s.FreqHz, + Callsign: s.DXCall, + Comment: s.Comment, + }) + } }, func() { if a.ctx != nil { @@ -3558,7 +3575,7 @@ func (a *App) GetCATSettings() (CATSettings, error) { if a.settings == nil { return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized") } - m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault) + m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault) if err != nil { return CATSettings{}, err } @@ -3566,10 +3583,16 @@ func (a *App) GetCATSettings() (CATSettings, error) { Enabled: m[keyCATEnabled] == "1", Backend: m[keyCATBackend], OmniRigNum: 1, + FlexHost: m[keyCATFlexHost], + FlexPort: 4992, + FlexSpots: m[keyCATFlexSpots] == "1", PollMs: 250, DelayMs: 0, DigitalDefault: m[keyCATDigitalDefault], } + if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 { + out.FlexPort = n + } if out.Backend == "" { out.Backend = "omnirig" } @@ -3599,6 +3622,9 @@ func (a *App) SaveCATSettings(s CATSettings) error { if s.OmniRigNum != 1 && s.OmniRigNum != 2 { s.OmniRigNum = 1 } + if s.FlexPort <= 0 || s.FlexPort > 65535 { + s.FlexPort = 4992 + } if s.PollMs < 50 || s.PollMs > 2000 { s.PollMs = 250 } @@ -3609,6 +3635,10 @@ func (a *App) SaveCATSettings(s CATSettings) error { if s.Enabled { enabled = "1" } + flexSpots := "0" + if s.FlexSpots { + flexSpots = "1" + } if s.DigitalDefault == "" { s.DigitalDefault = "FT8" } @@ -3616,6 +3646,9 @@ func (a *App) SaveCATSettings(s CATSettings) error { keyCATEnabled: enabled, keyCATBackend: s.Backend, keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum), + keyCATFlexHost: strings.TrimSpace(s.FlexHost), + keyCATFlexPort: strconv.Itoa(s.FlexPort), + keyCATFlexSpots: flexSpots, keyCATPollMs: strconv.Itoa(s.PollMs), keyCATDelayMs: strconv.Itoa(s.DelayMs), keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)), @@ -6294,6 +6327,7 @@ func (a *App) reloadCAT() { } a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond) a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond) + a.catFlexSpots = s.Enabled && s.Backend == "flex" && s.FlexSpots if !s.Enabled { a.cat.Stop() return @@ -6306,12 +6340,28 @@ func (a *App) reloadCAT() { // reloadCAT raised the existing instance's window to the front, // which is what Log4OM avoids by relying entirely on COM activation. a.cat.Start(cat.NewOmniRig(s.OmniRigNum)) + case "flex": + // Native FlexRadio (SmartSDR) TCP API — no OmniRig needed. + fb := cat.NewFlex(s.FlexHost, s.FlexPort, s.FlexSpots) + // Clicking one of our spots on the panadapter fills the entry form. + fb.OnSpotClick = func(call string, hz int64) { + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "flex:spot_clicked", map[string]any{"call": call, "freq_hz": hz}) + } + } + a.cat.Start(fb) default: // Unknown backend → stop and emit a dummy state so the UI shows it. a.cat.Stop() } } +// DiscoverFlexRadios listens for FlexRadio discovery broadcasts on the LAN and +// returns the radios found (for the CAT settings "auto-detect" button). +func (a *App) DiscoverFlexRadios() ([]cat.FlexRadio, error) { + return cat.DiscoverFlex(2500 * time.Millisecond) +} + // ClearLookupCache empties the local callsign cache. func (a *App) ClearLookupCache() error { if a.cache == nil { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e8e2baa..393b069 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -39,6 +39,7 @@ import type { adif as adifModels, lookup as lookupModels, cat as catModels } fro import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import { Menubar, type Menu } from '@/components/Menubar'; +import { APP_VERSION, APP_AUTHOR } from '@/version'; import { QSLManagerPanel } from '@/components/QSLManagerModal'; import { QslDesignerModal } from '@/components/qsl/QslDesignerModal'; import { SendEQSLModal } from '@/components/qsl/SendEQSLModal'; @@ -90,9 +91,10 @@ type CATState = Omit; const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm']; const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE']; -// Modes the QSO recorder captures (voice + CW). Mirrors recordableMode() in -// app.go — digital modes carry no useful audio and are never recorded. -const RECORDABLE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV','CW']); +// Modes the QSO recorder captures (phone only). Mirrors recordableMode() in +// app.go — digital modes carry no useful audio, and CW has no DAX audio on Flex, +// so neither is recorded (no REC badge / timer for them). +const RECORDABLE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV']); const emptyDetails: DetailsState = { state: '', cnty: '', address: '', @@ -564,6 +566,16 @@ export default function App() { const [wkSent, setWkSent] = useState(''); // rolling text the keyer echoes as it transmits const [wkEscClears, setWkEscClears] = useState(true); // ESC also clears the callsign const [wkSendOnType, setWkSendOnType] = useState(false); // key chars live as typed + // Auto-call: repeat the clicked macro (e.g. F1 CQ) every (message + N seconds) + // until a reply is entered or it's stopped. Persisted as UI prefs. + const [wkAutoCall, setWkAutoCall] = useState(() => localStorage.getItem('opslog.wkAutoCall') === '1'); + const [wkAutoCallSecs, setWkAutoCallSecs] = useState(() => Number(localStorage.getItem('opslog.wkAutoCallSecs')) || 3); + const wkAutoCallRef = useRef(wkAutoCall); + const wkAutoCallSecsRef = useRef(wkAutoCallSecs); + const autoCallGenRef = useRef(0); // bump to cancel the running loop + const autoCallMacroRef = useRef(-1); // macro index currently auto-repeating (-1 = none) + useEffect(() => { wkAutoCallRef.current = wkAutoCall; }, [wkAutoCall]); + useEffect(() => { wkAutoCallSecsRef.current = wkAutoCallSecs; }, [wkAutoCallSecs]); // F1-F12 macro shortcuts active only when the keyer is enabled + connected. const wkActiveRef = useRef(false); const wkEscClearsRef = useRef(true); @@ -665,6 +677,7 @@ export default function App() { // close so the next plain "Preferences" launch reverts to default. const [settingsSection, setSettingsSection] = useState(undefined); const [showDeleteAll, setShowDeleteAll] = useState(false); + const [showAbout, setShowAbout] = useState(false); const [deletingAll, setDeletingAll] = useState(false); const [ctyRefreshing, setCtyRefreshing] = useState(false); const [refsDownloading, setRefsDownloading] = useState(false); @@ -1092,13 +1105,27 @@ export default function App() { } if (!lk.band && s.band) setBand(s.band); - // Mode resolution priority: digital watering-hole → CAT's DATA → CAT mode. + // Mode resolution. + // FlexRadio reports the exact mode for voice/CW (USB/LSB/CW…) → trust it. + // But all digital sub-modes share one radio mode (DIGU/DIGL → "DATA"), so + // when it's digital we still pick FT8/FT4/RTTY from the frequency's + // watering hole (e.g. 14.080 → FT4), else the operator's default. + // OmniRig & other rigs often can't tell digital from SSB on a digital + // freq, so for them we infer from the frequency regardless of reported mode. if (!lk.mode) { - const inferred = s.freq_hz ? inferDigitalMode(s.freq_hz) : ''; let nextMode = ''; - if (inferred) nextMode = inferred; - else if (s.mode === 'DATA') nextMode = digitalDefaultRef.current || 'FT8'; - else if (s.mode) nextMode = s.mode; + if (s.backend === 'flex') { + if (s.mode === 'DATA') { + nextMode = (s.freq_hz ? inferDigitalMode(s.freq_hz) : '') || digitalDefaultRef.current || 'FT8'; + } else if (s.mode) { + nextMode = s.mode; + } + } else { + const inferred = s.freq_hz ? inferDigitalMode(s.freq_hz) : ''; + if (inferred) nextMode = inferred; + else if (s.mode === 'DATA') nextMode = digitalDefaultRef.current || 'FT8'; + else if (s.mode) nextMode = s.mode; + } if (nextMode) { setMode(nextMode); applyModePreset(nextMode); // flip 599↔59 on mode change (respects edits) @@ -1191,6 +1218,12 @@ export default function App() { const unsubRC = EventsOn('udp:remote_call', (raw: string) => { if (applyUdpCall(raw)) restartRecordingForNewTarget(String(raw ?? '')); }); + // Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call + // (the radio already tuned via trigger_action=Tune, and CAT reads the freq). + const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => { + const call = String(p?.call ?? ''); + if (applyUdpCall(call)) restartRecordingForNewTarget(call); + }); const unsubProg = EventsOn('import:progress', (p: any) => { setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) }); }); @@ -1208,7 +1241,7 @@ export default function App() { else setError('UDP auto-log: ' + msg); } }); - return () => { unsubDX?.(); unsubRC?.(); unsubProg?.(); unsubLog?.(); }; + return () => { unsubDX?.(); unsubRC?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -1307,8 +1340,41 @@ export default function App() { for (let i = 0; i < 1200 && wkBusyRef.current; i++) await sleep(50); // ≤60s for it to finish void save(); } - function wkSendMacro(i: number) { const m = wkMacros[i]; if (m) wkSend(m.text); } + // stopAutoCall cancels any running auto-call loop. + function stopAutoCall() { autoCallMacroRef.current = -1; autoCallGenRef.current++; } + // runAutoCall sends macro i, waits for the keyer to finish, waits the chosen + // gap, then resends — looping until cancelled (reply entered, Stop, unchecked). + async function runAutoCall(i: number) { + const gen = ++autoCallGenRef.current; + autoCallMacroRef.current = i; + const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms)); + while (autoCallMacroRef.current === i && gen === autoCallGenRef.current && wkActiveRef.current) { + const m = wkMacros[i]; + if (!m) break; + await wkSend(m.text); + for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start + for (let k = 0; k < 2400 && wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤120s to finish + if (gen !== autoCallGenRef.current) break; + await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call + } + } + function wkSendMacro(i: number) { + const m = wkMacros[i]; + if (!m) return; + if (wkAutoCallRef.current) runAutoCall(i); // loop this macro until a reply / stop + else wkSend(m.text); + } wkSendMacroRef.current = wkSendMacro; + function wkToggleAutoCall(on: boolean) { + setWkAutoCall(on); + writeUiPref('opslog.wkAutoCall', on ? '1' : '0'); + if (!on) stopAutoCall(); + } + function wkSetAutoCallSecs(n: number) { + const v = Math.max(0, Math.min(120, n || 0)); + setWkAutoCallSecs(v); + writeUiPref('opslog.wkAutoCallSecs', String(v)); + } // send-on-type: key the typed chars verbatim (no variable substitution). function wkSendRaw(chars: string) { WinkeyerSend(chars).catch(() => {}); } function wkBackspace() { WinkeyerBackspace().catch(() => {}); } @@ -1660,6 +1726,9 @@ export default function App() { // still replace it. Without this, clicking a cluster spot froze the call: // applyUdpCall saw current != lastUdpCall and refused every later UDP call. if (opts?.force) lastUdpCallRef.current = v.trim().toUpperCase(); + // A callsign appeared (someone answered the CQ, or a spot was clicked) → + // stop auto-calling so we don't key over the contact. + if (v.trim() !== '') stopAutoCall(); // No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call // on every status packet. If it matches what's already in the entry, // do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and @@ -1777,7 +1846,7 @@ export default function App() { { type: 'item', label: refsDownloading ? 'Downloading reference lists…' : 'Download reference lists (IOTA/POTA/WWFF/SOTA)', action: 'tools.downloadRefs', disabled: refsDownloading }, ]}, { name: 'help', label: 'Help', items: [ - { type: 'item', label: 'About OpsLog', action: 'help.about', disabled: true }, + { type: 'item', label: 'About OpsLog', action: 'help.about' }, ]}, ], [total, selectedId, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled]); @@ -1797,6 +1866,7 @@ export default function App() { case 'tools.dvk': setDvkEnabled((v) => !v); break; case 'tools.refreshCty': refreshCtyDat(); break; case 'tools.downloadRefs': downloadRefs(); break; + case 'help.about': setShowAbout(true); break; } } @@ -2196,7 +2266,7 @@ export default function App() {
OpsLog - v0.1 + setShowAbout(true)} title="About OpsLog">v{APP_VERSION}
@@ -2438,6 +2508,26 @@ export default function App() { { setShowFirstRun(false); loadStation(); refresh(); }} /> )} + {showAbout && ( +
setShowAbout(false)}> +
e.stopPropagation()}> +
+
+

OpsLog

+
+

Ham-radio logbook

+

version {APP_VERSION}

+

+ Developed by {APP_AUTHOR} +

+

73 & good DX

+ +
+
+ )} + {/* Transient toasts (bottom-right). Errors stack on top of the green success toast; both auto-dismiss. */} {/* Unlock encrypted passwords (set via Settings → Security). Dismissable: @@ -2557,7 +2647,11 @@ export default function App() { {/* Right of the entry (Log4OM-style): F1 Stats (matrix) + F2-F5 detail tabs, then reserved free space. Hidden in compact mode. */} {!compact && ( -
+ // relative + absolute inner: the panel's content can't grow the row, so + // the row height is set by the ENTRY STRIP. A taller tab (Awards/F3) then + // scrolls inside this fixed height instead of pushing everything down. +
+
+
)} {/* Reserved free space to the right. The WinKeyer CW keyer and/or the @@ -2608,7 +2703,7 @@ export default function App() {
)} {wkEnabled && ( -
+
{ setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }} onSend={wkSend} onSendMacro={wkSendMacro} - onStop={() => WinkeyerStop().catch(() => {})} + onStop={() => { stopAutoCall(); WinkeyerStop().catch(() => {}); }} onClose={() => wkSetEnabled(false)} sendOnType={wkSendOnType} onToggleSendOnType={wkToggleSendOnType} onSendRaw={wkSendRaw} onBackspace={wkBackspace} + autoCall={wkAutoCall} + autoCallSecs={wkAutoCallSecs} + onToggleAutoCall={wkToggleAutoCall} + onSetAutoCallSecs={wkSetAutoCallSecs} />
)} diff --git a/frontend/src/components/AwardEditor.tsx b/frontend/src/components/AwardEditor.tsx index 15de1e1..583fd1a 100644 --- a/frontend/src/components/AwardEditor.tsx +++ b/frontend/src/components/AwardEditor.tsx @@ -31,11 +31,17 @@ export type AwardDef = { url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string; type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string; leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[]; + or_rules?: AwardOrRule[]; dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[]; confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean; total: number; builtin?: boolean; }; +type AwardOrRule = { + field: string; match_by?: string; exact_match?: boolean; pattern?: string; + leading_str?: string; trailing_str?: string; prefix?: string; +}; + type AwardRef = { code: string; name: string; dxcc: number; group: string; subgrp: string; dxcc_list?: number[]; pattern?: string; valid: boolean; valid_from?: string; valid_to?: string; @@ -162,7 +168,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) { if (!open) return; setErr(''); Promise.all([GetAwardDefs(), AwardFields(), GetAwardPresets(), ListCountries()]) - .then(([d, f, p, c]) => { setDefs((d ?? []) as any); setFields((f ?? []) as any); setPresets((p ?? []) as any); setCountries((c ?? []) as any); }) + .then(([d, f, p, c]) => { setDefs((d ?? []) as any); setFields(((f ?? []) as string[]).slice().sort((a, b) => a.localeCompare(b))); setPresets((p ?? []) as any); setCountries((c ?? []) as any); }) .catch((e) => setErr(String(e?.message ?? e))); loadMeta(); }, [open]); @@ -344,6 +350,46 @@ export function AwardEditor({ open, onClose, onSaved }: Props) { patch({ leading_str: e.target.value })} /> patch({ trailing_str: e.target.value })} />
+ + {/* Additional OR searches: a QSO earns a reference if the + primary rule OR any of these match. */} +
+
+

Additional searches (OR) — also match the reference if any of these hit

+ +
+ {(cur.or_rules ?? []).map((r, ri) => { + const upd = (p: Partial) => patch({ or_rules: (cur.or_rules ?? []).map((x, j) => (j === ri ? { ...x, ...p } : x)) }); + const del = () => patch({ or_rules: (cur.or_rules ?? []).filter((_, j) => j !== ri) }); + return ( +
+
+ OR — search in + +
+ {['code', 'description', 'pattern'].map((m) => ( + + ))} +
+ + +
+
+ upd({ pattern: e.target.value })} placeholder="regex — group 1 = reference (e.g. \b(\d{2})\d{3}\b for postal → dept)" /> + upd({ prefix: e.target.value })} placeholder="prefix (D)" title="Prepended to each found reference, e.g. 74 → D74" /> +
+
+ ); + })} +
diff --git a/frontend/src/components/AwardRefSelector.tsx b/frontend/src/components/AwardRefSelector.tsx index e4542a7..8f41314 100644 --- a/frontend/src/components/AwardRefSelector.tsx +++ b/frontend/src/components/AwardRefSelector.tsx @@ -29,9 +29,13 @@ interface Props { // of these and it's already filled (e.g. VE9CF → state NB), the award counts // automatically — we surface that so the operator needn't pick it by hand. fieldValues?: Record; + // Height of the selector. Default is a fixed 210px (the QSO editor modal); + // the live entry panel passes "flex-1 min-h-0" so it fills the available + // space instead of overflowing and forcing a scrollbar. + heightClass?: string; } -export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props) { +export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightClass = 'h-[210px]' }: Props) { const [defs, setDefs] = useState([]); const [metas, setMetas] = useState>({}); const [awardCode, setAwardCode] = useState('POTA'); @@ -172,7 +176,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props) } return ( -
+
{/* Left panel */}
diff --git a/frontend/src/components/DetailsPanel.tsx b/frontend/src/components/DetailsPanel.tsx index 88248f0..a60a371 100644 --- a/frontend/src/components/DetailsPanel.tsx +++ b/frontend/src/components/DetailsPanel.tsx @@ -1,4 +1,5 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { ComputeQSOAwardRefs } from '../../wailsjs/go/main/App'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; @@ -121,6 +122,40 @@ function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) { const [internalOpen, setInternalOpen] = useState('stats'); const open = tab ?? internalOpen; // controlled when `tab` is provided + + // Live award detection: run the SAME engine used at log time over the current + // contact (callsign + looked-up address/state/zones) so award references the + // QSO will earn — e.g. WAPC matching "Beijing" inside the address — are + // surfaced and auto-added the moment you enter a call or click a spot, instead + // of only appearing after logging. Pickable matches are merged into award_refs + // (idempotent; award_refs is NOT a dependency, so removing one by hand sticks). + const [detected, setDetected] = useState>([]); + useEffect(() => { + if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; } + const t = window.setTimeout(async () => { + try { + const q: any = { + callsign, band, mode, + address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '', + cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz, + qso_date: new Date().toISOString(), + }; + const all = ((await ComputeQSOAwardRefs(q)) ?? []) as any[]; + setDetected(all as any); + const cur = details.award_refs ?? ''; + const have = new Set(cur.split(';').filter(Boolean)); + let next = cur; + for (const r of all) { + if (!r.pickable) continue; + const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`; + if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); } + } + if (next !== cur) onChange({ award_refs: next }); + } catch { /* leave detection empty on failure */ } + }, 400); + return () => window.clearTimeout(t); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, band, mode]); // Bearing/distance from operator's home grid to the remote station. // Recomputed only when either grid actually changes. const path = useMemo(() => { @@ -179,7 +214,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai ))} -
+
{open === 'stats' && (
@@ -257,13 +292,24 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai )} {open === 'awards' && ( -
+
onChange({ award_refs: v })} fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }} + heightClass="flex-1 min-h-0" /> + {detected.length > 0 && ( +
+ Detected — this contact will count for:{' '} + {detected.map((r) => ( + + {r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? {r.name} : null} + + ))} +
+ )}
)} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 9cde991..0cfeba7 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -8,7 +8,7 @@ import { import { GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider, GetListsSettings, SaveListsSettings, - GetCATSettings, SaveCATSettings, + GetCATSettings, SaveCATSettings, DiscoverFlexRadios, ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile, GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop, GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, @@ -445,6 +445,44 @@ function TelemetryToggle() { ); } +// FlexDiscover scans the LAN for FlexRadio broadcasts and lets the user pick one +// (fills the IP/port). Self-contained so it can own its state (rendered inside +// the hook-less CATPanel). +function FlexDiscover({ onPick }: { onPick: (ip: string, port: number) => void }) { + const [busy, setBusy] = useState(false); + const [found, setFound] = useState>([]); + const [msg, setMsg] = useState(''); + async function scan() { + setBusy(true); setMsg(''); + try { + const r = ((await DiscoverFlexRadios()) ?? []) as any[]; + setFound(r as any); + if (r.length === 0) setMsg('No radio found — check it\'s on the same network, or enter the IP manually.'); + } catch (e: any) { + setMsg(String(e?.message ?? e)); + } finally { setBusy(false); } + } + return ( +
+
+ + listens for FlexRadio broadcast on the LAN +
+ {found.map((r) => ( + + ))} + {msg &&
{msg}
} +
+ ); +} + function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) { const label = SECTION_LABELS[id] ?? id; const IconCmp = Icon ?? Construction; @@ -492,7 +530,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { const [bandDraft, setBandDraft] = useState(''); const [modeDraft, setModeDraft] = useState(''); const [catCfg, setCatCfg] = useState({ - enabled: false, backend: 'omnirig', omnirig_rig: 1, poll_ms: 250, delay_ms: 0, + enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, poll_ms: 250, delay_ms: 0, digital_default: 'FT8', }); const [rotator, setRotator] = useState({ @@ -1634,9 +1672,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { <> -
+
+ {catCfg.backend === 'omnirig' && (
+ )} + {catCfg.backend === 'flex' && ( + <> +
+ + setCatCfg((s) => ({ ...s, flex_host: e.target.value }))} /> +
+
+ + setCatCfg((s) => ({ ...s, flex_port: parseInt(e.target.value) || 4992 }))} /> +
+
+ setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} /> +
+ + + )} + {catCfg.backend === 'omnirig' && ( + <>
setCatCfg((s) => ({ ...s, delay_ms: parseInt(e.target.value) || 0 }))} />
+ + )}
onToggleAutoCall(e.target.checked)} /> + Auto-call + + gap +
+ onSetAutoCallSecs(parseInt(e.target.value) || 0)} /> + sec +
+ {autoCall && click a macro to loop it} +
+ {/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
{macros.map((m, i) => ( diff --git a/frontend/src/version.ts b/frontend/src/version.ts new file mode 100644 index 0000000..4929a39 --- /dev/null +++ b/frontend/src/version.ts @@ -0,0 +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.1'; + +// Author / credits, shown in Help → About. +export const APP_AUTHOR = 'F4BPO'; diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index dcbcaed..e3c72cc 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -3,10 +3,10 @@ import {adif} from '../models'; import {qso} from '../models'; import {main} from '../models'; +import {cat} from '../models'; import {profile} from '../models'; import {award} from '../models'; import {awardref} from '../models'; -import {cat} from '../models'; import {cluster} from '../models'; import {extsvc} from '../models'; import {winkeyer} from '../models'; @@ -91,6 +91,8 @@ export function DisconnectAllClusters():Promise; export function DisconnectClusterServer(arg1:number):Promise; +export function DiscoverFlexRadios():Promise>; + export function DownloadAllReferenceLists():Promise; export function DownloadClublogCty():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index f179eae..37129ac 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -154,6 +154,10 @@ export function DisconnectClusterServer(arg1) { return window['go']['main']['App']['DisconnectClusterServer'](arg1); } +export function DiscoverFlexRadios() { + return window['go']['main']['App']['DiscoverFlexRadios'](); +} + export function DownloadAllReferenceLists() { return window['go']['main']['App']['DownloadAllReferenceLists'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 87e99ad..bf37463 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -104,6 +104,30 @@ export namespace award { this.confirmed = source["confirmed"]; } } + export class OrRule { + field: string; + match_by?: string; + exact_match?: boolean; + pattern?: string; + leading_str?: string; + trailing_str?: string; + prefix?: string; + + static createFrom(source: any = {}) { + return new OrRule(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.field = source["field"]; + this.match_by = source["match_by"]; + this.exact_match = source["exact_match"]; + this.pattern = source["pattern"]; + this.leading_str = source["leading_str"]; + this.trailing_str = source["trailing_str"]; + this.prefix = source["prefix"]; + } + } export class Def { code: string; name: string; @@ -126,6 +150,7 @@ export namespace award { multi?: boolean; dynamic?: boolean; add_prefixes?: string[]; + or_rules?: OrRule[]; dxcc_filter: number[]; valid_bands?: string[]; valid_modes?: string[]; @@ -164,6 +189,7 @@ export namespace award { this.multi = source["multi"]; this.dynamic = source["dynamic"]; this.add_prefixes = source["add_prefixes"]; + this.or_rules = this.convertValues(source["or_rules"], OrRule); this.dxcc_filter = source["dxcc_filter"]; this.valid_bands = source["valid_bands"]; this.valid_modes = source["valid_modes"]; @@ -175,7 +201,26 @@ export namespace award { this.total = source["total"]; this.builtin = source["builtin"]; } + + 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 Ref { ref: string; name?: string; @@ -340,6 +385,28 @@ export namespace awardref { export namespace cat { + export class FlexRadio { + ip: string; + port: number; + model: string; + nickname: string; + serial: string; + callsign: string; + + static createFrom(source: any = {}) { + return new FlexRadio(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.ip = source["ip"]; + this.port = source["port"]; + this.model = source["model"]; + this.nickname = source["nickname"]; + this.serial = source["serial"]; + this.callsign = source["callsign"]; + } + } export class RigState { enabled: boolean; connected: boolean; @@ -830,6 +897,9 @@ export namespace main { enabled: boolean; backend: string; omnirig_rig: number; + flex_host: string; + flex_port: number; + flex_spots: boolean; poll_ms: number; delay_ms: number; digital_default: string; @@ -843,6 +913,9 @@ export namespace main { this.enabled = source["enabled"]; this.backend = source["backend"]; this.omnirig_rig = source["omnirig_rig"]; + this.flex_host = source["flex_host"]; + this.flex_port = source["flex_port"]; + this.flex_spots = source["flex_spots"]; this.poll_ms = source["poll_ms"]; this.delay_ms = source["delay_ms"]; this.digital_default = source["digital_default"]; diff --git a/internal/award/award.go b/internal/award/award.go index f49933b..8d934cd 100644 --- a/internal/award/award.go +++ b/internal/award/award.go @@ -68,6 +68,12 @@ type Def struct { Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts) AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes + // OrRules are ADDITIONAL searches OR'd with the primary one above: a QSO + // earns a reference if the primary match OR any of these match. Lets a + // French department (DDFM) be found from "D74" in the note AND from a postal + // code "74140" in the address (pattern captures "74", Prefix "D" → "D74"). + OrRules []OrRule `json:"or_rules,omitempty"` + // --- Scope --- DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any) ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands @@ -84,6 +90,20 @@ type Def struct { Builtin bool `json:"builtin"` // shipped default (informational) } +// OrRule is one additional search OR'd with the award's primary matching rule. +// Same knobs as the primary (field + how to match), plus Prefix which is +// prepended to each reference it finds so a captured value can be normalised to +// the award's reference codes (e.g. postal "74" + Prefix "D" → "D74"). +type OrRule struct { + Field string `json:"field"` // QSO field to scan + MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern" + ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring + Pattern string `json:"pattern,omitempty"` // Go regexp; group 1 = reference + LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching + TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching + Prefix string `json:"prefix,omitempty"` // prepended to each found reference +} + // Defaults are the built-in awards seeded on first run (then user-editable). func Defaults() []Def { // Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter @@ -511,13 +531,15 @@ func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf Nam // candidates extracts the reference(s) a QSO contributes to an award, enforcing // a predefined list when one applies. -func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string { - raw := strings.TrimSpace(stripAffix(fieldRaw(d.Field, q), d.LeadingStr, d.TrailingStr)) +// searchOne runs one matching rule (the primary or an OR rule) over a QSO and +// returns the reference codes it finds, each prefixed with `prefix` (so a +// captured "74" becomes "D74"). predefined enables list-aware matching. +func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, trailing, prefix string, q *qso.QSO, rl refList, predefined bool) []string { + raw := strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing)) if raw == "" { return nil } - predefined := hasList && !d.Dynamic - byDesc := predefined && strings.EqualFold(strings.TrimSpace(d.MatchBy), "description") + byDesc := predefined && strings.EqualFold(strings.TrimSpace(matchBy), "description") var found []string switch { @@ -530,7 +552,7 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) // the field equals the name; otherwise the name is a substring of it. up := strings.ToUpper(raw) for _, nc := range rl.names { - if d.ExactMatch { + if exact { if up == nc.name { found = append(found, nc.code) } @@ -538,7 +560,7 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) found = append(found, nc.code) } } - case predefined && !d.ExactMatch: + case predefined && !exact: // "Search reference inside the field": look up each token of the field in // the list — O(tokens), not O(all references) — plus test the few // references that declare a regex. @@ -558,6 +580,31 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) // counts each reference separately. found = splitRefs(raw) } + if prefix != "" { + for i := range found { + found[i] = prefix + found[i] + } + } + return found +} + +func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string { + predefined := hasList && !d.Dynamic + + // Primary search, then each OR rule — a QSO earns a reference if any matches. + found := searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined) + for i := range d.OrRules { + r := &d.OrRules[i] + var rre *regexp.Regexp + if p := strings.TrimSpace(r.Pattern); p != "" { + c, err := regexp.Compile(p) + if err != nil { + continue // skip a rule with a bad regex rather than failing the award + } + rre = c + } + found = append(found, searchOne(r.Field, r.MatchBy, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix, q, rl, predefined)...) + } if !predefined { return dedupe(found) diff --git a/internal/cat/cat.go b/internal/cat/cat.go index 5810401..647ed2d 100644 --- a/internal/cat/cat.go +++ b/internal/cat/cat.go @@ -184,6 +184,48 @@ func (m *Manager) SetPTT(on bool) error { return m.exec(func(b Backend) error { return b.SetPTT(on) }) } +// SpotInfo is one cluster spot to render on a backend that supports a spot +// overlay (the FlexRadio panadapter). Color is an optional "#AARRGGBB" string; +// the backend picks a default when it's empty. (Status-based colouring can be +// driven later by setting Color per spot.) +type SpotInfo struct { + FreqHz int64 + Callsign string + Mode string + Color string + Comment string +} + +// Spotter is an OPTIONAL backend capability: show cluster spots on the radio +// (FlexRadio panadapter). Backends that don't implement it are simply skipped. +type Spotter interface { + SendSpot(SpotInfo) error +} + +// SendSpot pushes a cluster spot to the backend if it supports spotting. Runs on +// the CAT goroutine and is fire-and-forget (dropped if the queue is busy) — a +// missed spot on the panadapter is harmless. +func (m *Manager) SendSpot(s SpotInfo) { + m.mu.RLock() + cmds := m.cmdCh + b := m.backend + m.mu.RUnlock() + if cmds == nil || b == nil { + return + } + if _, ok := b.(Spotter); !ok { + return + } + select { + case cmds <- func() { + if sp, ok := b.(Spotter); ok { + _ = sp.SendSpot(s) + } + }: + default: // queue busy → drop this spot + } +} + // exec marshals a backend operation onto the CAT goroutine. Returns the // operation's error or a "busy"/"not running" error if dispatch failed. func (m *Manager) exec(fn func(Backend) error) error { @@ -210,23 +252,27 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol defer runtime.UnlockOSThread() defer close(done) - if err := b.Connect(); err != nil { - m.update(RigState{ - Enabled: true, Backend: b.Name(), Connected: false, - Error: err.Error(), UpdatedAt: time.Now(), - }) - // Stay idle until Stop is called — let the user fix config and re-Start. - for { - select { - case <-stop: - return - case fn := <-cmds: - fn() - } - } - } defer b.Disconnect() + // Connection is (re)established lazily and retried with a backoff, so a rig + // that's off at startup — or a FlexRadio that reboots/drops its TCP link — + // reconnects on its own instead of staying dead until the user toggles CAT. + const reconnectEvery = 5 * time.Second + connected := false + var lastAttempt time.Time + tryConnect := func() { + if connected || time.Since(lastAttempt) < reconnectEvery { + return + } + lastAttempt = time.Now() + if err := b.Connect(); err != nil { + m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()}) + return + } + connected = true + } + tryConnect() + ticker := time.NewTicker(pollEvery) defer ticker.Stop() @@ -238,12 +284,18 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol fn() m.applyCommandDelay() case <-ticker.C: + if !connected { + tryConnect() + continue + } ns, err := b.ReadState() if err != nil { - m.update(RigState{ - Enabled: true, Backend: b.Name(), Connected: false, - Error: err.Error(), UpdatedAt: time.Now(), - }) + // Lost the rig — drop the backend so the next attempt reconnects + // cleanly, then back off before retrying. + connected = false + lastAttempt = time.Now() + b.Disconnect() + m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()}) continue } ns.Enabled = true diff --git a/internal/cat/flex.go b/internal/cat/flex.go new file mode 100644 index 0000000..f09ddaa --- /dev/null +++ b/internal/cat/flex.go @@ -0,0 +1,600 @@ +//go:build windows + +package cat + +import ( + "bufio" + "fmt" + "math" + "net" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +// Flex is a native FlexRadio (SmartSDR) CAT backend. It speaks the radio's TCP +// API on port 4992 — a line-based text protocol — and tracks slice state pushed +// by the radio in REAL TIME, so frequency/mode/split are always current (unlike +// the polled, lagging OmniRig path that needed a second click to fix a mode). +// Pure Go, no CGO, and no OmniRig install required for Flex users. +type Flex struct { + host string + port int + + mu sync.Mutex + conn net.Conn + wmu sync.Mutex // serialises writes to conn + seq int + handle string + model string + gotHandle bool + + slices map[int]*flexSlice + lastStateSig string // last logged derived-state signature (log only on change) + + spotsEnabled bool // push cluster spots + manage the panadapter overlay + spotIdx map[int]bool // panadapter spot indices currently known to the radio + pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response + spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click) + + // OnSpotClick is called (off the reader goroutine's hot path) when the user + // clicks one of our spots on the panadapter, with the spot's callsign and + // frequency. The host wires this to fill the entry form. Set before Connect. + OnSpotClick func(callsign string, freqHz int64) +} + +type flexSlice struct { + freqHz int64 + mode string // raw Flex mode (USB/LSB/CW/DIGU/…) + active bool + tx bool + inUse bool +} + +// flexTriggerRe matches the radio's "spot triggered" notification, sent +// when the user clicks one of our spots on the panadapter. +var flexTriggerRe = regexp.MustCompile(`spot (\d+) triggered`) + +// NewFlex builds a Flex backend for the given radio IP (host) and port (4992). +// spotsEnabled turns on the panadapter spot overlay (subscribe + clear leftovers +// on connect + accept SendSpot). +func NewFlex(host string, port int, spotsEnabled bool) *Flex { + if port == 0 { + port = 4992 + } + return &Flex{ + host: strings.TrimSpace(host), port: port, + slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled, + spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, + } +} + +func (f *Flex) Name() string { return "flex" } + +// Connect dials the radio and subscribes to slice/radio status. The reader +// goroutine then keeps our cached state current from the radio's push messages. +func (f *Flex) Connect() error { + f.mu.Lock() + already := f.conn != nil + host := f.host + port := f.port + f.mu.Unlock() + if already { + return nil + } + if host == "" { + return fmt.Errorf("flex: no radio IP configured") + } + conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), 5*time.Second) + if err != nil { + return fmt.Errorf("flex: connect %s:%d: %w", host, port, err) + } + f.mu.Lock() + f.conn = conn + f.gotHandle = false + f.slices = map[int]*flexSlice{} + f.mu.Unlock() + debugLog.Printf("Flex: connected to %s:%d", host, port) + + go f.reader(conn) + // Identify ourselves in SmartSDR's client list, then stream slice + transmit + // (TX/split) status. Command names per the SmartSDR TCP/IP API docs. + f.send("client program=OpsLog") + f.send("sub slice all") + f.send("sub transmit all") + f.send("sub radio all") + if f.spotsEnabled { + // Subscribe so the radio pushes existing spots (we learn their indices), + // then wipe the panadapter so stale spots from a previous session or + // another logger are cleared before we start adding our own. + f.send("sub spot all") + go f.clearSpotsOnConnect(conn) + } + return nil +} + +func (f *Flex) Disconnect() { + f.mu.Lock() + c := f.conn + f.conn = nil + f.gotHandle = false + f.mu.Unlock() + if c != nil { + _ = c.Close() + debugLog.Printf("Flex: disconnected") + } +} + +// send writes a sequenced command (C|) to the radio and returns the +// sequence number (so the caller can match the R response, e.g. to learn a +// new spot's index). Returns 0 when not connected. Best effort. +func (f *Flex) send(cmd string) int { + f.mu.Lock() + c := f.conn + f.seq++ + seq := f.seq + f.mu.Unlock() + if c == nil { + return 0 + } + f.wmu.Lock() + _, err := fmt.Fprintf(c, "C%d|%s\n", seq, cmd) + f.wmu.Unlock() + if err != nil { + debugLog.Printf("Flex: send %q failed: %v", cmd, err) + return 0 + } + debugLog.Printf("Flex: → %s", cmd) + return seq +} + +// reader consumes the radio's line stream until the connection drops. +func (f *Flex) reader(conn net.Conn) { + sc := bufio.NewScanner(conn) + sc.Buffer(make([]byte, 0, 64*1024), 1<<20) + for sc.Scan() { + line := strings.TrimRight(sc.Text(), "\r\n") + if line == "" { + continue + } + // Panadapter spot click → "…spot triggered…". Resolve the index + // back to the callsign we stored at spot-add time and notify the host. + if mm := flexTriggerRe.FindStringSubmatch(line); mm != nil { + if idx, err := strconv.Atoi(mm[1]); err == nil { + f.mu.Lock() + call := f.spotCall[idx] + handler := f.OnSpotClick + f.mu.Unlock() + if call != "" && handler != nil { + debugLog.Printf("Flex: spot %d triggered → %s", idx, call) + go handler(call, 0) + } + } + } + switch line[0] { + case 'V': // version banner, e.g. "V1.4.0.0" + debugLog.Printf("Flex: radio %s", line) + case 'H': // our client handle + f.mu.Lock() + f.handle = line[1:] + f.gotHandle = true + f.mu.Unlock() + debugLog.Printf("Flex: handshake ok, handle=%s", line[1:]) + case 'S': // status push: S| + if i := strings.IndexByte(line, '|'); i >= 0 { + f.handleStatus(line[i+1:]) + } + case 'M': // message + debugLog.Printf("Flex: msg %s", line) + case 'R': // command response: R|| + parts := strings.SplitN(line[1:], "|", 3) + if len(parts) < 2 { + break + } + seq, _ := strconv.Atoi(parts[0]) + ok := parts[1] == "0" || parts[1] == "00000000" + if !ok { + debugLog.Printf("Flex: cmd error %s", line) + } + // A successful "spot add" returns the new spot's index in the message; + // pair it with the callsign we stashed under this seq. + f.mu.Lock() + call, pending := f.pendingSpot[seq] + if pending { + delete(f.pendingSpot, seq) + } + if pending && ok && len(parts) >= 3 { + if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil { + f.spotCall[idx] = call + f.spotIdx[idx] = true + } + } + f.mu.Unlock() + } + } + // Connection ended. + f.mu.Lock() + if f.conn == conn { + f.conn = nil + f.gotHandle = false + } + f.mu.Unlock() +} + +// handleStatus parses one status payload, e.g. +// "slice 0 in_use=1 RF_frequency=14.150000 mode=USB active=1 tx=1 …" +func (f *Flex) handleStatus(payload string) { + fields := strings.Fields(payload) + if len(fields) < 2 || fields[0] != "slice" { + // radio … model=FLEX-6400 — grab the model when present. + if len(fields) >= 1 && fields[0] == "radio" { + for _, kv := range fields[1:] { + if strings.HasPrefix(kv, "model=") { + f.mu.Lock() + f.model = strings.TrimPrefix(kv, "model=") + f.mu.Unlock() + } + } + } + if len(fields) >= 1 && fields[0] == "transmit" { + debugLog.Printf("Flex: status %s", payload) + } + // Spot status: "spot …". Track the index so we can clear the + // panadapter, and log it verbatim — a click on a panadapter spot pushes a + // spot status, which we'll use to fill the callsign once we see its shape. + if len(fields) >= 2 && fields[0] == "spot" { + // The click ("spot N triggered") is handled in the reader; here we + // just keep the set of live spot indices for ClearSpots. + if idx, err := strconv.Atoi(fields[1]); err == nil { + removed := false + for _, kv := range fields[2:] { + if kv == "removed" || kv == "in_use=0" { + removed = true + } + } + f.mu.Lock() + if removed { + delete(f.spotIdx, idx) + delete(f.spotCall, idx) + } else { + f.spotIdx[idx] = true + } + f.mu.Unlock() + } + debugLog.Printf("Flex: status %s", payload) + } + return + } + // Slice status — log it so split/freq/mode issues are diagnosable. + debugLog.Printf("Flex: status %s", payload) + idx, err := strconv.Atoi(fields[1]) + if err != nil { + return + } + f.mu.Lock() + s := f.slices[idx] + if s == nil { + s = &flexSlice{} + f.slices[idx] = s + } + for _, kv := range fields[2:] { + eq := strings.IndexByte(kv, '=') + if eq <= 0 { + continue + } + key, val := kv[:eq], kv[eq+1:] + switch key { + case "RF_frequency": + if mhz, e := strconv.ParseFloat(val, 64); e == nil { + s.freqHz = int64(math.Round(mhz * 1e6)) + } + case "mode": + s.mode = val + case "active": + s.active = val == "1" + case "tx": + s.tx = val == "1" + case "in_use": + s.inUse = val == "1" + } + } + f.mu.Unlock() +} + +// ReadState returns the cached state derived from the radio's push messages — +// no round-trip, so it's always current. +func (f *Flex) ReadState() (RigState, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.conn == nil { + return RigState{}, fmt.Errorf("flex: not connected") + } + st := RigState{Connected: f.gotHandle, Rig: f.model} + if !f.gotHandle { + return st, nil // connected TCP but radio hasn't handshaked yet + } + rx, tx := f.pickSlicesLocked() + if rx == nil && tx == nil { + return st, nil + } + if tx == nil { + tx = rx + } + if rx == nil { + rx = tx + } + st.FreqHz = tx.freqHz + st.Mode = flexModeToADIF(tx.mode) + if rx.freqHz != tx.freqHz { + st.Split = true + st.RxFreqHz = rx.freqHz + } + sig := fmt.Sprintf("%d/%d/%v/%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode) + if sig != f.lastStateSig { + f.lastStateSig = sig + debugLog.Printf("Flex: state tx=%d rx=%d split=%v mode=%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode) + } + return st, nil +} + +// pickSlicesLocked chooses the TX and RX slices among in-use slices. TX is the +// slice flagged tx=1. RX is the slice you actually receive on — the NON-TX slice +// (preferring the active/focused one), NOT simply the active slice: tuning the +// TX slice makes it the active/focused slice, which would otherwise collapse RX +// onto TX and hide the split. Caller holds f.mu. +func (f *Flex) pickSlicesLocked() (rx, tx *flexSlice) { + idxs := make([]int, 0, len(f.slices)) + for i, s := range f.slices { + if s.inUse { + idxs = append(idxs, i) + } + } + sort.Ints(idxs) + var active, txS, nonTx, first *flexSlice + for _, i := range idxs { + s := f.slices[i] + if first == nil { + first = s + } + if s.active { + active = s + } + if s.tx { + txS = s + } else if nonTx == nil { + nonTx = s + } + } + tx = txS + if tx == nil { + if active != nil { + tx = active + } else { + tx = first + } + } + // RX = the receive slice: the active one if it isn't the TX slice, else the + // first non-TX slice; fall back to TX (simplex) when there's only one slice. + switch { + case active != nil && active != tx: + rx = active + case nonTx != nil: + rx = nonTx + default: + rx = tx + } + return rx, tx +} + +// activeSliceIndexLocked returns the slice index to send commands to (the active +// slice, else the lowest in-use index, else 0). Caller holds f.mu. +func (f *Flex) activeSliceIndexLocked() int { + best, found := 1<<30, false + for idx, s := range f.slices { + if !s.inUse { + continue + } + if s.active { + return idx + } + if idx < best { + best, found = idx, true + } + } + if found { + return best + } + return 0 +} + +func (f *Flex) SetFrequency(hz int64) error { + if hz <= 0 { + return fmt.Errorf("flex: invalid frequency") + } + f.mu.Lock() + idx := f.activeSliceIndexLocked() + connected := f.conn != nil + f.mu.Unlock() + if !connected { + return fmt.Errorf("flex: not connected") + } + // "slice t " — tune command per the SmartSDR API (MHz, 6 dp). + f.send(fmt.Sprintf("slice t %d %.6f", idx, float64(hz)/1e6)) + return nil +} + +func (f *Flex) SetMode(mode string) error { + f.mu.Lock() + idx := f.activeSliceIndexLocked() + var freq int64 + if s := f.slices[idx]; s != nil { + freq = s.freqHz + } + connected := f.conn != nil + f.mu.Unlock() + if !connected { + return fmt.Errorf("flex: not connected") + } + fm := adifModeToFlex(mode, freq) + if fm == "" { + return fmt.Errorf("flex: unsupported mode %q", mode) + } + // "slice s mode=" — set command per the SmartSDR API. + f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm)) + return nil +} + +// SendSpot renders a cluster spot on the panadapter via "spot add". Spots carry +// a lifetime so the radio expires them on its own (the API has no "spot clear"). +// Per the SmartSDR API, spaces inside a field value are encoded as 0x7F. +func (f *Flex) SendSpot(s SpotInfo) error { + f.mu.Lock() + connected := f.conn != nil && f.gotHandle + f.mu.Unlock() + if !connected { + return fmt.Errorf("flex: not connected") + } + call := flexEncode(s.Callsign) + if call == "" || s.FreqHz <= 0 { + return nil + } + color := s.Color + if color == "" { + color = "#FFFFA500" // opaque orange default + } + cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=1800 trigger_action=Tune timestamp=%d", + float64(s.FreqHz)/1e6, call, color, time.Now().Unix()) + if m := flexEncode(s.Mode); m != "" { + cmd += " mode=" + m + } + if c := flexEncode(s.Comment); c != "" { + cmd += " comment=" + c + } + seq := f.send(cmd) + if seq > 0 { + // Remember which call this add was for; the R response carries the + // radio-assigned spot index, which we map to the call so a later click + // (trigger) can be resolved back to the callsign. + f.mu.Lock() + f.pendingSpot[seq] = s.Callsign + f.mu.Unlock() + } + return nil +} + +// clearSpotsOnConnect waits until the radio handshake completes (we're truly +// connected), then sends "spot clear" so launching OpsLog — or enabling the +// option — starts from a clean panadapter, including spots left by another +// logger or a previous session. +func (f *Flex) clearSpotsOnConnect(conn net.Conn) { + for i := 0; i < 50; i++ { // up to ~5s for the handshake + f.mu.Lock() + ready := f.gotHandle && f.conn == conn + gone := f.conn != conn + f.mu.Unlock() + if gone { + return // reconnected/closed in the meantime + } + if ready { + f.ClearSpots() + return + } + time.Sleep(100 * time.Millisecond) + } +} + +// ClearSpots wipes ALL panadapter spots in one command ("spot clear") — removes +// stale spots from a previous session or another logger, not just our own. +func (f *Flex) ClearSpots() error { + f.mu.Lock() + f.spotIdx = map[int]bool{} + f.spotCall = map[int]string{} + connected := f.conn != nil + f.mu.Unlock() + if !connected { + return fmt.Errorf("flex: not connected") + } + f.send("spot clear") + debugLog.Printf("Flex: spot clear sent") + return nil +} + +// flexEncode prepares a value for the Flex command line: trimmed, with any +// internal spaces replaced by 0x7F as the SmartSDR API requires. +func flexEncode(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + return strings.ReplaceAll(s, " ", "\x7f") +} + +func (f *Flex) SetPTT(on bool) error { + f.mu.Lock() + connected := f.conn != nil + f.mu.Unlock() + if !connected { + return fmt.Errorf("flex: not connected") + } + if on { + f.send("xmit 1") + } else { + f.send("xmit 0") + } + return nil +} + +// flexModeToADIF maps a Flex slice mode to a generic ADIF mode. +func flexModeToADIF(m string) string { + switch strings.ToUpper(strings.TrimSpace(m)) { + case "USB", "LSB": + return "SSB" + case "CW": + return "CW" + case "AM", "SAM": + return "AM" + case "FM", "NFM", "DFM": + return "FM" + case "DIGU", "DIGL": + return "DATA" + case "RTTY": + return "RTTY" + case "FDV": + return "DIGITALVOICE" + case "": + return "" + default: + return strings.ToUpper(m) + } +} + +// adifModeToFlex maps an ADIF mode to a Flex slice mode. SSB picks USB/LSB from +// the frequency (LSB below 10 MHz, USB above) — the standard convention. +func adifModeToFlex(mode string, freqHz int64) string { + switch strings.ToUpper(strings.TrimSpace(mode)) { + case "SSB": + if freqHz > 0 && freqHz < 10_000_000 { + return "LSB" + } + return "USB" + case "USB": + return "USB" + case "LSB": + return "LSB" + case "CW": + return "CW" + case "AM": + return "AM" + case "FM": + return "FM" + case "RTTY", "FSK": + return "RTTY" + case "FT8", "FT4", "PSK31", "MFSK", "JS8", "JT65", "JT9", "OLIVIA", "DATA", "DIGITALVOICE": + return "DIGU" + default: + return "" + } +} diff --git a/internal/cat/flexdiscover.go b/internal/cat/flexdiscover.go new file mode 100644 index 0000000..6a58f07 --- /dev/null +++ b/internal/cat/flexdiscover.go @@ -0,0 +1,112 @@ +//go:build windows + +package cat + +import ( + "context" + "net" + "regexp" + "strconv" + "syscall" + "time" + + "golang.org/x/sys/windows" +) + +// FlexRadio is one radio found by discovery. +type FlexRadio struct { + IP string `json:"ip"` + Port int `json:"port"` + Model string `json:"model"` + Nickname string `json:"nickname"` + Serial string `json:"serial"` + Callsign string `json:"callsign"` +} + +// FlexRadios on the LAN broadcast a discovery datagram to UDP :4992 about once a +// second. DiscoverFlex listens for that broadcast for the given duration and +// returns the unique radios seen. Best effort: if the port can't be bound +// (SmartSDR running, firewall…), it returns what it has (often nothing) and the +// user falls back to entering the IP by hand. +func DiscoverFlex(timeout time.Duration) ([]FlexRadio, error) { + if timeout <= 0 { + timeout = 2 * time.Second + } + // Bind :4992 with SO_REUSEADDR so we coexist with SmartSDR, which also + // listens for the same broadcast. + lc := net.ListenConfig{ + Control: func(_, _ string, c syscall.RawConn) error { + var serr error + _ = c.Control(func(fd uintptr) { + serr = windows.SetsockoptInt(windows.Handle(fd), windows.SOL_SOCKET, windows.SO_REUSEADDR, 1) + }) + return serr + }, + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + pc, err := lc.ListenPacket(ctx, "udp4", ":4992") + if err != nil { + return nil, err + } + defer pc.Close() + + _ = pc.SetReadDeadline(time.Now().Add(timeout)) + found := map[string]FlexRadio{} + buf := make([]byte, 2048) + for { + n, _, err := pc.ReadFrom(buf) + if err != nil { + break // deadline reached or socket closed + } + if r, ok := parseFlexDiscovery(buf[:n]); ok && r.IP != "" { + if _, dup := found[r.IP]; !dup { + found[r.IP] = r + } + } + } + out := make([]FlexRadio, 0, len(found)) + for _, r := range found { + out = append(out, r) + } + return out, nil +} + +var ( + reFlexModel = regexp.MustCompile(`model=(\S+)`) + reFlexIP = regexp.MustCompile(`ip=(\S+)`) + reFlexPort = regexp.MustCompile(`port=(\d+)`) + reFlexSerial = regexp.MustCompile(`serial=(\S+)`) + reFlexNickname = regexp.MustCompile(`nickname=(\S+)`) + reFlexCallsign = regexp.MustCompile(`callsign=(\S+)`) +) + +// parseFlexDiscovery extracts radio fields from a VITA-49 discovery datagram. +// The payload carries a space-separated key=value ASCII blob after a binary +// header, so we scan the whole packet text for the keys we need. +func parseFlexDiscovery(pkt []byte) (FlexRadio, bool) { + s := string(pkt) + m := reFlexIP.FindStringSubmatch(s) + if m == nil { + return FlexRadio{}, false + } + r := FlexRadio{IP: m[1], Port: 4992} + if mm := reFlexPort.FindStringSubmatch(s); mm != nil { + if p, err := strconv.Atoi(mm[1]); err == nil && p > 0 { + r.Port = p + } + } + if mm := reFlexModel.FindStringSubmatch(s); mm != nil { + r.Model = mm[1] + } + if mm := reFlexSerial.FindStringSubmatch(s); mm != nil { + r.Serial = mm[1] + } + if mm := reFlexNickname.FindStringSubmatch(s); mm != nil { + r.Nickname = mm[1] + } + if mm := reFlexCallsign.FindStringSubmatch(s); mm != nil { + r.Callsign = mm[1] + } + return r, true +} diff --git a/release.ps1 b/release.ps1 new file mode 100644 index 0000000..4ba090b --- /dev/null +++ b/release.ps1 @@ -0,0 +1,100 @@ +# OpsLog release script — source → Gitea (origin), exe → Gitea + GitHub releases. +# Mirrors the DXHunter workflow, adapted for the Wails build and OpsLog's version +# files. Run from the repo root in PowerShell. + +# Force UTF-8 throughout — prevents git log em-dashes / accents from corrupting the API body +$OutputEncoding = [System.Text.Encoding]::UTF8 +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +# ── Config ──────────────────────────────────────────────────────────────────── +$GitHubRepo = "GregTroar/OpsLog" # GitHub repo that hosts the public exe (adjust if different) +$ExePath = "build/bin/OpsLog.exe" # Wails build output +$Wails = Join-Path $HOME "go\bin\wails.exe" # the v2.11 wails (not the global one) +if (-not (Test-Path $Wails)) { $Wails = "wails" } # fall back to PATH + +# Parse token, host, and repo path from the Gitea remote URL (origin) +$remoteUrl = git remote get-url origin +if ($remoteUrl -match 'https://([^@]+)@([^/]+)/(.+?)\.git') { + $token = $Matches[1] + $gitHost = $Matches[2] + $repo = $Matches[3] +} else { + Write-Host "Cannot parse Gitea remote URL (expected https://@/.git)." -ForegroundColor Red; exit 1 +} + +git add . +$msg = Read-Host "Commit message" +if ($msg) { git commit -m $msg } + +$ver = Read-Host "Version (ex: 0.2)" +if (-not $ver) { Write-Host "Aborted." -ForegroundColor Red; exit 1 } + +# ── Bump the version in the single sources of truth ───────────────────────────── +$lastMsg = git log -1 --pretty=format:"%s" +if ($lastMsg -ne "chore: release v$ver") { + # Frontend (UI header + About popup) + (Get-Content frontend/src/version.ts) -replace "APP_VERSION = '.*'", "APP_VERSION = '$ver'" | Set-Content frontend/src/version.ts -Encoding utf8 + # Backend (telemetry heartbeat version) + (Get-Content telemetry.go) -replace 'appVersion = ".*"', "appVersion = `"$ver`"" | Set-Content telemetry.go -Encoding utf8 + git add frontend/src/version.ts telemetry.go + git commit -m "chore: release v$ver" +} else { + Write-Host "Release commit already exists, skipping version bump..." -ForegroundColor Yellow +} +git tag "v$ver" 2>$null +if ($LASTEXITCODE -ne 0) { Write-Host "Tag v$ver already exists locally, continuing..." -ForegroundColor Yellow } + +# Push source to Gitea (origin) — source code stays on Gitea only +git push +if ($LASTEXITCODE -ne 0) { Write-Host "git push failed!" -ForegroundColor Red; exit 1 } +git push --tags +if ($LASTEXITCODE -ne 0) { Write-Host "git push --tags failed!" -ForegroundColor Red; exit 1 } + +# ── Release notes from commits since the previous tag ─────────────────────────── +$prevTag = git describe --tags --abbrev=0 "v$ver^" 2>$null +$changelog = if ($prevTag) { + git log "$prevTag..v$ver" --pretty=format:"- %s" --no-merges +} else { + git log "v$ver" --pretty=format:"- %s" --no-merges +} +$body = "## Changelog`n`n$($changelog -join "`n")" +Write-Host "`nRelease notes:`n$body`n" -ForegroundColor DarkGray + +# ── Build the Windows exe (Wails compiles frontend + Go) ───────────────────────── +Write-Host "Building OpsLog.exe v$ver ..." -ForegroundColor Cyan +& $Wails build +if ($LASTEXITCODE -ne 0) { Write-Host "Build failed!" -ForegroundColor Red; exit 1 } +if (-not (Test-Path $ExePath)) { Write-Host "Built exe not found at $ExePath" -ForegroundColor Red; exit 1 } + +# ── Gitea release — get existing or create new, then upload the exe ────────────── +$api = "https://$gitHost/api/v1/repos/$repo" +$headers = @{ Authorization = "token $token"; 'Content-Type' = 'application/json' } +try { + $release = Invoke-RestMethod "$api/releases/tags/v$ver" -Method GET -Headers $headers + Write-Host "Gitea: found existing release for v$ver (id=$($release.id)), uploading exe..." -ForegroundColor Yellow +} catch { + $payloadBytes = [System.Text.Encoding]::UTF8.GetBytes((@{ tag_name = "v$ver"; target_commitish = "main"; name = "OpsLog v$ver"; body = $body } | ConvertTo-Json)) + try { + $release = Invoke-RestMethod "$api/releases" -Method POST -Headers $headers -Body $payloadBytes + } catch { + Write-Host "Gitea release creation failed: $_" -ForegroundColor Red; exit 1 + } +} +$uploadUri = "https://$gitHost/api/v1/repos/$repo/releases/$($release.id)/assets?name=OpsLog.exe" +curl.exe -s -H "Authorization: token $token" -F "attachment=@$ExePath" $uploadUri | Out-Null +Write-Host "Gitea: release v$ver published." -ForegroundColor Green + +# ── GitHub release (requires gh CLI: https://cli.github.com) ──────────────────── +if (Get-Command gh -ErrorAction SilentlyContinue) { + Write-Host "Creating GitHub release v$ver ..." -ForegroundColor Cyan + $notesFile = [System.IO.Path]::GetTempFileName() + $body | Out-File -FilePath $notesFile -Encoding utf8 + gh release create "v$ver" $ExePath ` + --repo $GitHubRepo ` + --title "OpsLog v$ver" ` + --notes-file $notesFile + Remove-Item $notesFile + Write-Host "GitHub: release v$ver published." -ForegroundColor Green +} else { + Write-Host "gh CLI not found - skipping GitHub release (install from https://cli.github.com, then: gh auth login)" -ForegroundColor Yellow +}