feat: added versionning & About window

This commit is contained in:
2026-06-16 19:36:56 +02:00
parent 33af122964
commit 69d0780bac
16 changed files with 1398 additions and 56 deletions
+53 -3
View File
@@ -74,8 +74,11 @@ const (
keyListsRSTDigital = "lists.rst_digital" keyListsRSTDigital = "lists.rst_digital"
keyCATEnabled = "cat.enabled" keyCATEnabled = "cat.enabled"
keyCATBackend = "cat.backend" // "omnirig" (only one for now) keyCATBackend = "cat.backend" // "omnirig" | "flex"
keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2 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" keyCATPollMs = "cat.poll_ms"
keyCATDelayMs = "cat.delay_ms" // pause between commands keyCATDelayMs = "cat.delay_ms" // pause between commands
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA 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. // individual key/value pairs to keep the settings table flat.
type CATSettings struct { type CATSettings struct {
Enabled bool `json:"enabled"` 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) 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) PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
DelayMs int `json:"delay_ms"` // pause between commands (default 0) DelayMs int `json:"delay_ms"` // pause between commands (default 0)
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…) DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
@@ -372,6 +378,7 @@ type App struct {
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite) 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 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 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 awardSnapMu sync.Mutex // guards the award QSO snapshot
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
awardSnapRev string // logbook revision the snapshot was built at ("" = none) awardSnapRev string // logbook revision the snapshot was built at ("" = none)
@@ -668,6 +675,16 @@ func (a *App) startup(ctx context.Context) {
if a.ctx != nil { if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cluster:spot", s) wruntime.EventsEmit(a.ctx, "cluster:spot", s)
} }
// 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() { func() {
if a.ctx != nil { if a.ctx != nil {
@@ -3558,7 +3575,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil { if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized") return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
} }
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault) m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
if err != nil { if err != nil {
return CATSettings{}, err return CATSettings{}, err
} }
@@ -3566,10 +3583,16 @@ func (a *App) GetCATSettings() (CATSettings, error) {
Enabled: m[keyCATEnabled] == "1", Enabled: m[keyCATEnabled] == "1",
Backend: m[keyCATBackend], Backend: m[keyCATBackend],
OmniRigNum: 1, OmniRigNum: 1,
FlexHost: m[keyCATFlexHost],
FlexPort: 4992,
FlexSpots: m[keyCATFlexSpots] == "1",
PollMs: 250, PollMs: 250,
DelayMs: 0, DelayMs: 0,
DigitalDefault: m[keyCATDigitalDefault], DigitalDefault: m[keyCATDigitalDefault],
} }
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
out.FlexPort = n
}
if out.Backend == "" { if out.Backend == "" {
out.Backend = "omnirig" out.Backend = "omnirig"
} }
@@ -3599,6 +3622,9 @@ func (a *App) SaveCATSettings(s CATSettings) error {
if s.OmniRigNum != 1 && s.OmniRigNum != 2 { if s.OmniRigNum != 1 && s.OmniRigNum != 2 {
s.OmniRigNum = 1 s.OmniRigNum = 1
} }
if s.FlexPort <= 0 || s.FlexPort > 65535 {
s.FlexPort = 4992
}
if s.PollMs < 50 || s.PollMs > 2000 { if s.PollMs < 50 || s.PollMs > 2000 {
s.PollMs = 250 s.PollMs = 250
} }
@@ -3609,6 +3635,10 @@ func (a *App) SaveCATSettings(s CATSettings) error {
if s.Enabled { if s.Enabled {
enabled = "1" enabled = "1"
} }
flexSpots := "0"
if s.FlexSpots {
flexSpots = "1"
}
if s.DigitalDefault == "" { if s.DigitalDefault == "" {
s.DigitalDefault = "FT8" s.DigitalDefault = "FT8"
} }
@@ -3616,6 +3646,9 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATEnabled: enabled, keyCATEnabled: enabled,
keyCATBackend: s.Backend, keyCATBackend: s.Backend,
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum), keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
keyCATFlexPort: strconv.Itoa(s.FlexPort),
keyCATFlexSpots: flexSpots,
keyCATPollMs: strconv.Itoa(s.PollMs), keyCATPollMs: strconv.Itoa(s.PollMs),
keyCATDelayMs: strconv.Itoa(s.DelayMs), keyCATDelayMs: strconv.Itoa(s.DelayMs),
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)), keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
@@ -6294,6 +6327,7 @@ func (a *App) reloadCAT() {
} }
a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond) a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond)
a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond) a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond)
a.catFlexSpots = s.Enabled && s.Backend == "flex" && s.FlexSpots
if !s.Enabled { if !s.Enabled {
a.cat.Stop() a.cat.Stop()
return return
@@ -6306,12 +6340,28 @@ func (a *App) reloadCAT() {
// reloadCAT raised the existing instance's window to the front, // reloadCAT raised the existing instance's window to the front,
// which is what Log4OM avoids by relying entirely on COM activation. // which is what Log4OM avoids by relying entirely on COM activation.
a.cat.Start(cat.NewOmniRig(s.OmniRigNum)) 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: default:
// Unknown backend → stop and emit a dummy state so the UI shows it. // Unknown backend → stop and emit a dummy state so the UI shows it.
a.cat.Stop() a.cat.Stop()
} }
} }
// 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. // ClearLookupCache empties the local callsign cache.
func (a *App) ClearLookupCache() error { func (a *App) ClearLookupCache() error {
if a.cache == nil { if a.cache == nil {
+111 -12
View File
@@ -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 type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
import { Menubar, type Menu } from '@/components/Menubar'; import { Menubar, type Menu } from '@/components/Menubar';
import { APP_VERSION, APP_AUTHOR } from '@/version';
import { QSLManagerPanel } from '@/components/QSLManagerModal'; import { QSLManagerPanel } from '@/components/QSLManagerModal';
import { QslDesignerModal } from '@/components/qsl/QslDesignerModal'; import { QslDesignerModal } from '@/components/qsl/QslDesignerModal';
import { SendEQSLModal } from '@/components/qsl/SendEQSLModal'; 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_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']; const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE'];
// Modes the QSO recorder captures (voice + CW). Mirrors recordableMode() in // Modes the QSO recorder captures (phone only). Mirrors recordableMode() in
// app.go — digital modes carry no useful audio and are never recorded. // app.go — digital modes carry no useful audio, and CW has no DAX audio on Flex,
const RECORDABLE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV','CW']); // so neither is recorded (no REC badge / timer for them).
const RECORDABLE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV']);
const emptyDetails: DetailsState = { const emptyDetails: DetailsState = {
state: '', cnty: '', address: '', state: '', cnty: '', address: '',
@@ -564,6 +566,16 @@ export default function App() {
const [wkSent, setWkSent] = useState(''); // rolling text the keyer echoes as it transmits const [wkSent, setWkSent] = useState(''); // rolling text the keyer echoes as it transmits
const [wkEscClears, setWkEscClears] = useState(true); // ESC also clears the callsign const [wkEscClears, setWkEscClears] = useState(true); // ESC also clears the callsign
const [wkSendOnType, setWkSendOnType] = useState(false); // key chars live as typed 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. // F1-F12 macro shortcuts active only when the keyer is enabled + connected.
const wkActiveRef = useRef(false); const wkActiveRef = useRef(false);
const wkEscClearsRef = useRef(true); const wkEscClearsRef = useRef(true);
@@ -665,6 +677,7 @@ export default function App() {
// close so the next plain "Preferences" launch reverts to default. // close so the next plain "Preferences" launch reverts to default.
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined); const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
const [showDeleteAll, setShowDeleteAll] = useState(false); const [showDeleteAll, setShowDeleteAll] = useState(false);
const [showAbout, setShowAbout] = useState(false);
const [deletingAll, setDeletingAll] = useState(false); const [deletingAll, setDeletingAll] = useState(false);
const [ctyRefreshing, setCtyRefreshing] = useState(false); const [ctyRefreshing, setCtyRefreshing] = useState(false);
const [refsDownloading, setRefsDownloading] = useState(false); const [refsDownloading, setRefsDownloading] = useState(false);
@@ -1092,13 +1105,27 @@ export default function App() {
} }
if (!lk.band && s.band) setBand(s.band); 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) { if (!lk.mode) {
const inferred = s.freq_hz ? inferDigitalMode(s.freq_hz) : '';
let nextMode = ''; let nextMode = '';
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; if (inferred) nextMode = inferred;
else if (s.mode === 'DATA') nextMode = digitalDefaultRef.current || 'FT8'; else if (s.mode === 'DATA') nextMode = digitalDefaultRef.current || 'FT8';
else if (s.mode) nextMode = s.mode; else if (s.mode) nextMode = s.mode;
}
if (nextMode) { if (nextMode) {
setMode(nextMode); setMode(nextMode);
applyModePreset(nextMode); // flip 599↔59 on mode change (respects edits) 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) => { const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
if (applyUdpCall(raw)) restartRecordingForNewTarget(String(raw ?? '')); 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) => { const unsubProg = EventsOn('import:progress', (p: any) => {
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) }); 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); 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 // 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 for (let i = 0; i < 1200 && wkBusyRef.current; i++) await sleep(50); // ≤60s for it to finish
void save(); 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; 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). // send-on-type: key the typed chars verbatim (no variable substitution).
function wkSendRaw(chars: string) { WinkeyerSend(chars).catch(() => {}); } function wkSendRaw(chars: string) { WinkeyerSend(chars).catch(() => {}); }
function wkBackspace() { WinkeyerBackspace().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: // still replace it. Without this, clicking a cluster spot froze the call:
// applyUdpCall saw current != lastUdpCall and refused every later UDP call. // applyUdpCall saw current != lastUdpCall and refused every later UDP call.
if (opts?.force) lastUdpCallRef.current = v.trim().toUpperCase(); if (opts?.force) lastUdpCallRef.current = v.trim().toUpperCase();
// A callsign appeared (someone answered the CQ, or a spot was clicked) →
// 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 // No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
// on every status packet. If it matches what's already in the entry, // on every status packet. If it matches what's already in the entry,
// do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and // do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and
@@ -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 }, { type: 'item', label: refsDownloading ? 'Downloading reference lists…' : 'Download reference lists (IOTA/POTA/WWFF/SOTA)', action: 'tools.downloadRefs', disabled: refsDownloading },
]}, ]},
{ name: 'help', label: 'Help', items: [ { 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]); ], [total, selectedId, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled]);
@@ -1797,6 +1866,7 @@ export default function App() {
case 'tools.dvk': setDvkEnabled((v) => !v); break; case 'tools.dvk': setDvkEnabled((v) => !v); break;
case 'tools.refreshCty': refreshCtyDat(); break; case 'tools.refreshCty': refreshCtyDat(); break;
case 'tools.downloadRefs': downloadRefs(); break; case 'tools.downloadRefs': downloadRefs(); break;
case 'help.about': setShowAbout(true); break;
} }
} }
@@ -2196,7 +2266,7 @@ export default function App() {
<div className="flex items-center gap-2 pr-2 border-r border-border/60"> <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)]" /> <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="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> </div>
<Menubar menus={menus} onAction={handleMenu} /> <Menubar menus={menus} onAction={handleMenu} />
@@ -2438,6 +2508,26 @@ export default function App() {
<FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} /> <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 {/* Transient toasts (bottom-right). Errors stack on top of the green
success toast; both auto-dismiss. */} success toast; both auto-dismiss. */}
{/* Unlock encrypted passwords (set via Settings → Security). Dismissable: {/* 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 {/* Right of the entry (Log4OM-style): F1 Stats (matrix) + F2-F5 detail
tabs, then reserved free space. Hidden in compact mode. */} tabs, then reserved free space. Hidden in compact mode. */}
{!compact && ( {!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 <DetailsPanel
callsign={callsign} callsign={callsign}
prefix={prefix} prefix={prefix}
@@ -2575,6 +2669,7 @@ export default function App() {
keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled} keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled}
/> />
</div> </div>
</div>
)} )}
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the {/* Reserved free space to the right. The WinKeyer CW keyer and/or the
Digital Voice Keyer take this slot when enabled (Log4OM-style); Digital Voice Keyer take this slot when enabled (Log4OM-style);
@@ -2608,7 +2703,7 @@ export default function App() {
</div> </div>
)} )}
{wkEnabled && ( {wkEnabled && (
<div className="w-[500px] shrink-0 min-h-0"> <div className="w-[380px] shrink-0 min-h-0">
<WinkeyerPanel <WinkeyerPanel
status={wkStatus} status={wkStatus}
ports={wkPorts} ports={wkPorts}
@@ -2623,12 +2718,16 @@ export default function App() {
onSetSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }} onSetSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }}
onSend={wkSend} onSend={wkSend}
onSendMacro={wkSendMacro} onSendMacro={wkSendMacro}
onStop={() => WinkeyerStop().catch(() => {})} onStop={() => { stopAutoCall(); WinkeyerStop().catch(() => {}); }}
onClose={() => wkSetEnabled(false)} onClose={() => wkSetEnabled(false)}
sendOnType={wkSendOnType} sendOnType={wkSendOnType}
onToggleSendOnType={wkToggleSendOnType} onToggleSendOnType={wkToggleSendOnType}
onSendRaw={wkSendRaw} onSendRaw={wkSendRaw}
onBackspace={wkBackspace} onBackspace={wkBackspace}
autoCall={wkAutoCall}
autoCallSecs={wkAutoCallSecs}
onToggleAutoCall={wkToggleAutoCall}
onSetAutoCallSecs={wkSetAutoCallSecs}
/> />
</div> </div>
)} )}
+47 -1
View File
@@ -31,11 +31,17 @@ export type AwardDef = {
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string; 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; 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[]; 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[]; 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; confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
total: number; builtin?: 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 = { type AwardRef = {
code: string; name: string; dxcc: number; group: string; subgrp: string; code: string; name: string; dxcc: number; group: string; subgrp: string;
dxcc_list?: number[]; pattern?: string; valid: boolean; valid_from?: string; valid_to?: 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; if (!open) return;
setErr(''); setErr('');
Promise.all([GetAwardDefs(), AwardFields(), GetAwardPresets(), ListCountries()]) 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))); .catch((e) => setErr(String(e?.message ?? e)));
loadMeta(); loadMeta();
}, [open]); }, [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="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> <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> </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> </div>
</TabsContent> </TabsContent>
+6 -2
View File
@@ -29,9 +29,13 @@ interface Props {
// of these and it's already filled (e.g. VE9CF → state NB), the award counts // 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. // automatically — we surface that so the operator needn't pick it by hand.
fieldValues?: Record<string, string>; 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 [defs, setDefs] = useState<AwardDef[]>([]);
const [metas, setMetas] = useState<Record<string, Meta>>({}); const [metas, setMetas] = useState<Record<string, Meta>>({});
const [awardCode, setAwardCode] = useState('POTA'); const [awardCode, setAwardCode] = useState('POTA');
@@ -172,7 +176,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
} }
return ( return (
<div className="flex gap-2 h-[210px]"> <div className={`flex gap-2 ${heightClass}`}>
{/* Left panel */} {/* Left panel */}
<div className="flex flex-col gap-1.5 flex-1 min-w-0"> <div className="flex flex-col gap-1.5 flex-1 min-w-0">
+49 -3
View File
@@ -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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox'; 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) { 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 [internalOpen, setInternalOpen] = useState<TabName>('stats');
const open = tab ?? internalOpen; // controlled when `tab` is provided 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. // Bearing/distance from operator's home grid to the remote station.
// Recomputed only when either grid actually changes. // Recomputed only when either grid actually changes.
const path = useMemo(() => { const path = useMemo(() => {
@@ -179,7 +214,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
))} ))}
</nav> </nav>
<div className="overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0">
{open === 'stats' && ( {open === 'stats' && (
<div className="px-3 py-2.5"> <div className="px-3 py-2.5">
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} /> <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' && ( {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 <AwardRefSelector
dxcc={details.dxcc} dxcc={details.dxcc}
value={details.award_refs ?? ''} value={details.award_refs ?? ''}
onChange={(v) => onChange({ award_refs: v })} onChange={(v) => onChange({ award_refs: v })}
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }} 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> </div>
)} )}
+83 -6
View File
@@ -8,7 +8,7 @@ import {
import { import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider, GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
GetListsSettings, SaveListsSettings, GetListsSettings, SaveListsSettings,
GetCATSettings, SaveCATSettings, GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile, ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop, GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, 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 }) { function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
const label = SECTION_LABELS[id] ?? id; const label = SECTION_LABELS[id] ?? id;
const IconCmp = Icon ?? Construction; const IconCmp = Icon ?? Construction;
@@ -492,7 +530,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [bandDraft, setBandDraft] = useState(''); const [bandDraft, setBandDraft] = useState('');
const [modeDraft, setModeDraft] = useState(''); const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({ const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, 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', digital_default: 'FT8',
}); });
const [rotator, setRotator] = useState<RotatorSettings>({ const [rotator, setRotator] = useState<RotatorSettings>({
@@ -1634,9 +1672,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<> <>
<SectionHeader <SectionHeader
title="CAT interface" 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"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} /> <Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
Enable CAT Enable CAT
@@ -1648,11 +1686,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<Select value={catCfg.backend} onValueChange={(v) => setCatCfg((s) => ({ ...s, backend: v }))}> <Select value={catCfg.backend} onValueChange={(v) => setCatCfg((s) => ({ ...s, backend: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="omnirig">OmniRig (Windows COM)</SelectItem> <SelectItem value="omnirig">OmniRig (any rig, Windows COM)</SelectItem>
<SelectItem value="flex" disabled>Flex SmartSDR (coming soon)</SelectItem> <SelectItem value="flex">FlexRadio / SmartSDR (native)</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{catCfg.backend === 'omnirig' && (
<div className="space-y-1"> <div className="space-y-1">
<Label>OmniRig rig slot</Label> <Label>OmniRig rig slot</Label>
<Select value={String(catCfg.omnirig_rig)} onValueChange={(v) => setCatCfg((s) => ({ ...s, omnirig_rig: parseInt(v) as 1 | 2 }))}> <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> </SelectContent>
</Select> </Select>
</div> </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"> <div className="space-y-1">
<Label>Poll interval (ms)</Label> <Label>Poll interval (ms)</Label>
<Input <Input
@@ -1679,6 +1742,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
onChange={(e) => setCatCfg((s) => ({ ...s, delay_ms: parseInt(e.target.value) || 0 }))} onChange={(e) => setCatCfg((s) => ({ ...s, delay_ms: parseInt(e.target.value) || 0 }))}
/> />
</div> </div>
</>
)}
<div className="space-y-1 col-span-2"> <div className="space-y-1 col-span-2">
<Label>Default digital mode (when rig reports DIG)</Label> <Label>Default digital mode (when rig reports DIG)</Label>
<Select <Select
@@ -1694,6 +1759,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</Select> </Select>
</div> </div>
</div> </div>
{catCfg.backend === 'omnirig' && (
<>
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox <Checkbox
checked={catModeBeforeFreq} 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> OmniRig only reports generic "DIG" for digital modes <strong>Default digital mode</strong>
{' '}is the specific mode OpsLog will surface (and log). {' '}is the specific mode OpsLog will surface (and log).
</p> </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> </div>
</> </>
); );
+24
View File
@@ -38,6 +38,10 @@ interface Props {
onToggleSendOnType: (on: boolean) => void; onToggleSendOnType: (on: boolean) => void;
onSendRaw: (chars: string) => void; // key typed chars as-is (no variables) onSendRaw: (chars: string) => void; // key typed chars as-is (no variables)
onBackspace: () => void; // remove last not-yet-keyed char 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 // WinkeyerPanel — Log4OM-style CW keyer operating window. Lives in the
@@ -48,6 +52,7 @@ export function WinkeyerPanel({
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed, onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
onSend, onSendMacro, onStop, onClose, onSend, onSendMacro, onStop, onClose,
sendOnType, onToggleSendOnType, onSendRaw, onBackspace, sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
autoCall, autoCallSecs, onToggleAutoCall, onSetAutoCallSecs,
}: Props) { }: Props) {
const [cwText, setCwText] = useState(''); const [cwText, setCwText] = useState('');
const [speed, setSpeed] = useState(wpm); const [speed, setSpeed] = useState(wpm);
@@ -172,6 +177,25 @@ export function WinkeyerPanel({
</Button> </Button>
</div> </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. */} {/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
<div className="grid grid-cols-3 gap-1"> <div className="grid grid-cols-3 gap-1">
{macros.map((m, i) => ( {macros.map((m, i) => (
+6
View File
@@ -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';
+3 -1
View File
@@ -3,10 +3,10 @@
import {adif} from '../models'; import {adif} from '../models';
import {qso} from '../models'; import {qso} from '../models';
import {main} from '../models'; import {main} from '../models';
import {cat} from '../models';
import {profile} from '../models'; import {profile} from '../models';
import {award} from '../models'; import {award} from '../models';
import {awardref} from '../models'; import {awardref} from '../models';
import {cat} from '../models';
import {cluster} from '../models'; import {cluster} from '../models';
import {extsvc} from '../models'; import {extsvc} from '../models';
import {winkeyer} from '../models'; import {winkeyer} from '../models';
@@ -91,6 +91,8 @@ export function DisconnectAllClusters():Promise<void>;
export function DisconnectClusterServer(arg1:number):Promise<void>; export function DisconnectClusterServer(arg1:number):Promise<void>;
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
export function DownloadAllReferenceLists():Promise<string>; export function DownloadAllReferenceLists():Promise<string>;
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>; export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
+4
View File
@@ -154,6 +154,10 @@ export function DisconnectClusterServer(arg1) {
return window['go']['main']['App']['DisconnectClusterServer'](arg1); return window['go']['main']['App']['DisconnectClusterServer'](arg1);
} }
export function DiscoverFlexRadios() {
return window['go']['main']['App']['DiscoverFlexRadios']();
}
export function DownloadAllReferenceLists() { export function DownloadAllReferenceLists() {
return window['go']['main']['App']['DownloadAllReferenceLists'](); return window['go']['main']['App']['DownloadAllReferenceLists']();
} }
+73
View File
@@ -104,6 +104,30 @@ export namespace award {
this.confirmed = source["confirmed"]; 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 { export class Def {
code: string; code: string;
name: string; name: string;
@@ -126,6 +150,7 @@ export namespace award {
multi?: boolean; multi?: boolean;
dynamic?: boolean; dynamic?: boolean;
add_prefixes?: string[]; add_prefixes?: string[];
or_rules?: OrRule[];
dxcc_filter: number[]; dxcc_filter: number[];
valid_bands?: string[]; valid_bands?: string[];
valid_modes?: string[]; valid_modes?: string[];
@@ -164,6 +189,7 @@ export namespace award {
this.multi = source["multi"]; this.multi = source["multi"];
this.dynamic = source["dynamic"]; this.dynamic = source["dynamic"];
this.add_prefixes = source["add_prefixes"]; this.add_prefixes = source["add_prefixes"];
this.or_rules = this.convertValues(source["or_rules"], OrRule);
this.dxcc_filter = source["dxcc_filter"]; this.dxcc_filter = source["dxcc_filter"];
this.valid_bands = source["valid_bands"]; this.valid_bands = source["valid_bands"];
this.valid_modes = source["valid_modes"]; this.valid_modes = source["valid_modes"];
@@ -175,7 +201,26 @@ export namespace award {
this.total = source["total"]; this.total = source["total"];
this.builtin = source["builtin"]; 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 { export class Ref {
ref: string; ref: string;
name?: string; name?: string;
@@ -340,6 +385,28 @@ export namespace awardref {
export namespace cat { 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 { export class RigState {
enabled: boolean; enabled: boolean;
connected: boolean; connected: boolean;
@@ -830,6 +897,9 @@ export namespace main {
enabled: boolean; enabled: boolean;
backend: string; backend: string;
omnirig_rig: number; omnirig_rig: number;
flex_host: string;
flex_port: number;
flex_spots: boolean;
poll_ms: number; poll_ms: number;
delay_ms: number; delay_ms: number;
digital_default: string; digital_default: string;
@@ -843,6 +913,9 @@ export namespace main {
this.enabled = source["enabled"]; this.enabled = source["enabled"];
this.backend = source["backend"]; this.backend = source["backend"];
this.omnirig_rig = source["omnirig_rig"]; 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.poll_ms = source["poll_ms"];
this.delay_ms = source["delay_ms"]; this.delay_ms = source["delay_ms"];
this.digital_default = source["digital_default"]; this.digital_default = source["digital_default"];
+53 -6
View File
@@ -68,6 +68,12 @@ type Def struct {
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts) Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes 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 --- // --- Scope ---
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any) DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands
@@ -84,6 +90,20 @@ type Def struct {
Builtin bool `json:"builtin"` // shipped default (informational) 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). // Defaults are the built-in awards seeded on first run (then user-editable).
func Defaults() []Def { func Defaults() []Def {
// Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter // 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 // candidates extracts the reference(s) a QSO contributes to an award, enforcing
// a predefined list when one applies. // a predefined list when one applies.
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string { // searchOne runs one matching rule (the primary or an OR rule) over a QSO and
raw := strings.TrimSpace(stripAffix(fieldRaw(d.Field, q), d.LeadingStr, d.TrailingStr)) // 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 == "" { if raw == "" {
return nil return nil
} }
predefined := hasList && !d.Dynamic byDesc := predefined && strings.EqualFold(strings.TrimSpace(matchBy), "description")
byDesc := predefined && strings.EqualFold(strings.TrimSpace(d.MatchBy), "description")
var found []string var found []string
switch { 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. // the field equals the name; otherwise the name is a substring of it.
up := strings.ToUpper(raw) up := strings.ToUpper(raw)
for _, nc := range rl.names { for _, nc := range rl.names {
if d.ExactMatch { if exact {
if up == nc.name { if up == nc.name {
found = append(found, nc.code) 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) 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 // "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 // the list — O(tokens), not O(all references) — plus test the few
// references that declare a regex. // 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. // counts each reference separately.
found = splitRefs(raw) 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 { if !predefined {
return dedupe(found) return dedupe(found)
+71 -19
View File
@@ -184,6 +184,48 @@ func (m *Manager) SetPTT(on bool) error {
return m.exec(func(b Backend) error { return b.SetPTT(on) }) 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 // exec marshals a backend operation onto the CAT goroutine. Returns the
// operation's error or a "busy"/"not running" error if dispatch failed. // operation's error or a "busy"/"not running" error if dispatch failed.
func (m *Manager) exec(fn func(Backend) error) error { 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 runtime.UnlockOSThread()
defer close(done) 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() 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) ticker := time.NewTicker(pollEvery)
defer ticker.Stop() defer ticker.Stop()
@@ -238,12 +284,18 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
fn() fn()
m.applyCommandDelay() m.applyCommandDelay()
case <-ticker.C: case <-ticker.C:
if !connected {
tryConnect()
continue
}
ns, err := b.ReadState() ns, err := b.ReadState()
if err != nil { if err != nil {
m.update(RigState{ // Lost the rig — drop the backend so the next attempt reconnects
Enabled: true, Backend: b.Name(), Connected: false, // cleanly, then back off before retrying.
Error: err.Error(), UpdatedAt: time.Now(), connected = false
}) lastAttempt = time.Now()
b.Disconnect()
m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
continue continue
} }
ns.Enabled = true ns.Enabled = true
+600
View File
@@ -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 ""
}
}
+112
View File
@@ -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
View File
@@ -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
}