feat: added versionning & About window
This commit is contained in:
@@ -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 {
|
||||
|
||||
+114
-15
@@ -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<catModels.RigState, 'convertValues'>;
|
||||
|
||||
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<string | undefined>(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() {
|
||||
<div className="flex items-center gap-2 pr-2 border-r border-border/60">
|
||||
<div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
|
||||
<span className="font-bold text-[15px] tracking-tight">OpsLog</span>
|
||||
<span className="text-[11px] text-muted-foreground">v0.1</span>
|
||||
<span className="text-[11px] text-muted-foreground cursor-pointer hover:text-foreground" onClick={() => setShowAbout(true)} title="About OpsLog">v{APP_VERSION}</span>
|
||||
</div>
|
||||
|
||||
<Menubar menus={menus} onAction={handleMenu} />
|
||||
@@ -2438,6 +2508,26 @@ export default function App() {
|
||||
<FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} />
|
||||
)}
|
||||
|
||||
{showAbout && (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={() => setShowAbout(false)}>
|
||||
<div className="w-full max-w-sm rounded-xl border border-border bg-card shadow-2xl p-6 text-center animate-in fade-in zoom-in-95" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-center gap-2 mb-1">
|
||||
<div className="size-3 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
|
||||
<h2 className="text-xl font-bold tracking-tight">OpsLog</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Ham-radio logbook</p>
|
||||
<p className="mt-3 font-mono text-sm">version <span className="font-semibold text-foreground">{APP_VERSION}</span></p>
|
||||
<p className="mt-3 text-sm">
|
||||
Developed by <span className="font-semibold text-primary">{APP_AUTHOR}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">73 & good DX</p>
|
||||
<button onClick={() => setShowAbout(false)} className="mt-5 h-8 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<div className="w-[560px] shrink-0 min-h-0 flex flex-col">
|
||||
// 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.
|
||||
<div className="w-[560px] shrink-0 min-h-0 relative">
|
||||
<div className="absolute inset-0 flex flex-col min-h-0">
|
||||
<DetailsPanel
|
||||
callsign={callsign}
|
||||
prefix={prefix}
|
||||
@@ -2574,6 +2668,7 @@ export default function App() {
|
||||
onTab={setDetailTab}
|
||||
keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
||||
@@ -2608,7 +2703,7 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
{wkEnabled && (
|
||||
<div className="w-[500px] shrink-0 min-h-0">
|
||||
<div className="w-[380px] shrink-0 min-h-0">
|
||||
<WinkeyerPanel
|
||||
status={wkStatus}
|
||||
ports={wkPorts}
|
||||
@@ -2623,12 +2718,16 @@ export default function App() {
|
||||
onSetSpeed={(w) => { 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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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) {
|
||||
<Field2 label="Leading string"><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
||||
<Field2 label="Trailing string"><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
||||
</div>
|
||||
|
||||
{/* Additional OR searches: a QSO earns a reference if the
|
||||
primary rule OR any of these match. */}
|
||||
<div className="border-t pt-2.5 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[11px] text-muted-foreground">Additional searches <span className="font-semibold">(OR)</span> — also match the reference if any of these hit</p>
|
||||
<Button size="sm" variant="outline" className="h-7"
|
||||
onClick={() => patch({ or_rules: [...(cur.or_rules ?? []), { field: cur.field || 'note', match_by: 'pattern', pattern: '', prefix: '' }] })}>
|
||||
<Plus className="size-3.5" /> Add OR
|
||||
</Button>
|
||||
</div>
|
||||
{(cur.or_rules ?? []).map((r, ri) => {
|
||||
const upd = (p: Partial<AwardOrRule>) => 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 (
|
||||
<div key={ri} className="rounded-md border border-border bg-muted/20 p-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] text-muted-foreground">OR — search in</span>
|
||||
<Select value={r.field} onValueChange={(v) => upd({ field: v })}>
|
||||
<SelectTrigger className="h-7 text-xs w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
{['code', 'description', 'pattern'].map((m) => (
|
||||
<label key={m} className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" name={`orby-${ri}`} checked={(r.match_by || 'code') === m} onChange={() => upd({ match_by: m })} className="accent-primary" /> {m}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer"><Checkbox checked={!!r.exact_match} onCheckedChange={(c) => upd({ exact_match: !!c })} /> exact</label>
|
||||
<button className="ml-auto text-destructive hover:opacity-70" onClick={del} title="Remove this OR search"><Trash2 className="size-4" /></button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_120px] gap-2">
|
||||
<Input className="h-7 font-mono text-xs" value={r.pattern ?? ''} onChange={(e) => upd({ pattern: e.target.value })} placeholder="regex — group 1 = reference (e.g. \b(\d{2})\d{3}\b for postal → dept)" />
|
||||
<Input className="h-7 font-mono text-xs" value={r.prefix ?? ''} onChange={(e) => upd({ prefix: e.target.value })} placeholder="prefix (D)" title="Prepended to each found reference, e.g. 74 → D74" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -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<string, string>;
|
||||
// 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<AwardDef[]>([]);
|
||||
const [metas, setMetas] = useState<Record<string, Meta>>({});
|
||||
const [awardCode, setAwardCode] = useState('POTA');
|
||||
@@ -172,7 +176,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 h-[210px]">
|
||||
<div className={`flex gap-2 ${heightClass}`}>
|
||||
{/* Left panel */}
|
||||
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
|
||||
|
||||
|
||||
@@ -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<TabName>('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<Array<{ code: string; ref: string; name?: string; pickable?: boolean }>>([]);
|
||||
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
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="overflow-y-auto min-h-0">
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{open === 'stats' && (
|
||||
<div className="px-3 py-2.5">
|
||||
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
|
||||
@@ -257,13 +292,24 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
)}
|
||||
|
||||
{open === 'awards' && (
|
||||
<div className="px-3 py-2.5">
|
||||
<div className="px-3 py-2.5 h-full flex flex-col min-h-0">
|
||||
<AwardRefSelector
|
||||
dxcc={details.dxcc}
|
||||
value={details.award_refs ?? ''}
|
||||
onChange={(v) => onChange({ award_refs: v })}
|
||||
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
|
||||
heightClass="flex-1 min-h-0"
|
||||
/>
|
||||
{detected.length > 0 && (
|
||||
<div className="mt-2 text-[11px] text-muted-foreground shrink-0">
|
||||
<span className="font-medium text-foreground/70">Detected — this contact will count for:</span>{' '}
|
||||
{detected.map((r) => (
|
||||
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono">
|
||||
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<Array<{ ip: string; port: number; model?: string; nickname?: string }>>([]);
|
||||
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 (
|
||||
<div className="rounded-md border border-border bg-muted/20 p-2 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={scan} disabled={busy}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Wifi className="size-3.5" />} Detect radios
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">listens for FlexRadio broadcast on the LAN</span>
|
||||
</div>
|
||||
{found.map((r) => (
|
||||
<button key={r.ip} type="button" onClick={() => onPick(r.ip, r.port || 4992)}
|
||||
className="w-full text-left text-xs rounded border border-border px-2 py-1 hover:bg-accent/50">
|
||||
<span className="font-mono font-semibold">{r.ip}</span>
|
||||
{r.model ? <span className="text-muted-foreground"> · {r.model}</span> : ''}
|
||||
{r.nickname ? <span className="text-muted-foreground"> ({r.nickname})</span> : ''}
|
||||
</button>
|
||||
))}
|
||||
{msg && <div className="text-[11px] text-muted-foreground">{msg}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<CATSettings>({
|
||||
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<RotatorSettings>({
|
||||
@@ -1634,9 +1672,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
<>
|
||||
<SectionHeader
|
||||
title="CAT interface"
|
||||
hint="Reads the rig's current frequency / band / mode and pushes them into the entry strip in real time. Requires OmniRig (free) installed and configured separately for your rig — OpsLog just talks to it."
|
||||
hint="Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug)."
|
||||
/>
|
||||
<div className="space-y-4 max-w-lg">
|
||||
<div className="space-y-4 max-w-3xl">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
|
||||
Enable CAT
|
||||
@@ -1648,11 +1686,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
<Select value={catCfg.backend} onValueChange={(v) => setCatCfg((s) => ({ ...s, backend: v }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="omnirig">OmniRig (Windows COM)</SelectItem>
|
||||
<SelectItem value="flex" disabled>Flex SmartSDR (coming soon)</SelectItem>
|
||||
<SelectItem value="omnirig">OmniRig (any rig, Windows COM)</SelectItem>
|
||||
<SelectItem value="flex">FlexRadio / SmartSDR (native)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{catCfg.backend === 'omnirig' && (
|
||||
<div className="space-y-1">
|
||||
<Label>OmniRig rig slot</Label>
|
||||
<Select value={String(catCfg.omnirig_rig)} onValueChange={(v) => setCatCfg((s) => ({ ...s, omnirig_rig: parseInt(v) as 1 | 2 }))}>
|
||||
@@ -1663,6 +1702,30 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{catCfg.backend === 'flex' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label>FlexRadio IP</Label>
|
||||
<Input placeholder="192.168.1.50" value={catCfg.flex_host ?? ''}
|
||||
onChange={(e) => setCatCfg((s) => ({ ...s, flex_host: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Port</Label>
|
||||
<Input type="number" value={catCfg.flex_port || 4992}
|
||||
onChange={(e) => setCatCfg((s) => ({ ...s, flex_port: parseInt(e.target.value) || 4992 }))} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} />
|
||||
</div>
|
||||
<label className="col-span-2 flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={!!catCfg.flex_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_spots: !!c }))} />
|
||||
Show cluster spots on the panadapter <span className="text-xs text-muted-foreground">(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{catCfg.backend === 'omnirig' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label>Poll interval (ms)</Label>
|
||||
<Input
|
||||
@@ -1679,6 +1742,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
onChange={(e) => setCatCfg((s) => ({ ...s, delay_ms: parseInt(e.target.value) || 0 }))}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>Default digital mode (when rig reports DIG)</Label>
|
||||
<Select
|
||||
@@ -1694,6 +1759,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{catCfg.backend === 'omnirig' && (
|
||||
<>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={catModeBeforeFreq}
|
||||
@@ -1708,6 +1775,16 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
OmniRig only reports generic "DIG" for digital modes — <strong>Default digital mode</strong>
|
||||
{' '}is the specific mode OpsLog will surface (and log).
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{catCfg.backend === 'flex' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Native SmartSDR API — no OmniRig needed. Frequency, mode and split are read in
|
||||
real time from the radio (no polling, no second-click mode bug). Use <strong>Detect
|
||||
radios</strong> or enter the IP. <strong>Default digital mode</strong> is what OpsLog
|
||||
logs when the slice is in a digital mode (DIGU/DIGL).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -38,6 +38,10 @@ interface Props {
|
||||
onToggleSendOnType: (on: boolean) => void;
|
||||
onSendRaw: (chars: string) => void; // key typed chars as-is (no variables)
|
||||
onBackspace: () => void; // remove last not-yet-keyed char
|
||||
autoCall: boolean; // repeat the clicked macro on a timer
|
||||
autoCallSecs: number; // gap (s) after the message before repeating
|
||||
onToggleAutoCall: (on: boolean) => void;
|
||||
onSetAutoCallSecs: (n: number) => void;
|
||||
}
|
||||
|
||||
// WinkeyerPanel — Log4OM-style CW keyer operating window. Lives in the
|
||||
@@ -48,6 +52,7 @@ export function WinkeyerPanel({
|
||||
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
|
||||
onSend, onSendMacro, onStop, onClose,
|
||||
sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
|
||||
autoCall, autoCallSecs, onToggleAutoCall, onSetAutoCallSecs,
|
||||
}: Props) {
|
||||
const [cwText, setCwText] = useState('');
|
||||
const [speed, setSpeed] = useState(wpm);
|
||||
@@ -172,6 +177,25 @@ export function WinkeyerPanel({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Auto-call: repeat the clicked macro (e.g. F1 CQ) automatically until
|
||||
someone answers. The seconds box is the gap AFTER the message. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer select-none"
|
||||
title="After you click a macro (e.g. F1 CQ), resend it on a loop — message, then the gap, then repeat — until a callsign is entered or you press Stop">
|
||||
<input type="checkbox" className="accent-primary" checked={autoCall} disabled={!connected}
|
||||
onChange={(e) => onToggleAutoCall(e.target.checked)} />
|
||||
Auto-call
|
||||
</label>
|
||||
<span className="text-[11px] text-muted-foreground">gap</span>
|
||||
<div className="flex items-center gap-1 h-7 rounded-md border border-border bg-muted/20 pl-2 pr-1" title="Seconds to wait after the message before resending">
|
||||
<input type="number" min={0} max={120}
|
||||
className="w-9 bg-transparent text-sm font-mono font-bold tabular-nums text-right outline-none"
|
||||
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
|
||||
<span className="text-[9px] text-muted-foreground">sec</span>
|
||||
</div>
|
||||
{autoCall && <span className="text-[10px] text-amber-600/80">click a macro to loop it</span>}
|
||||
</div>
|
||||
|
||||
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{macros.map((m, i) => (
|
||||
|
||||
@@ -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';
|
||||
Vendored
+3
-1
@@ -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<void>;
|
||||
|
||||
export function DisconnectClusterServer(arg1:number):Promise<void>;
|
||||
|
||||
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
|
||||
|
||||
export function DownloadAllReferenceLists():Promise<string>;
|
||||
|
||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||
|
||||
@@ -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']();
|
||||
}
|
||||
|
||||
@@ -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"];
|
||||
|
||||
+53
-6
@@ -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)
|
||||
|
||||
+71
-19
@@ -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
|
||||
|
||||
@@ -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 <index> 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<seq>|<cmd>) to the radio and returns the
|
||||
// sequence number (so the caller can match the R<seq> 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 <index> 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<handle>|<object ...>
|
||||
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<seq>|<hex>|<message>
|
||||
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 <index> …". 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 <rx> <freq_MHz>" — 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 <rx> mode=<m>" — 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<seq> 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 ""
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+100
@@ -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://<token>@<host>/<repo>.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
|
||||
}
|
||||
Reference in New Issue
Block a user