feat: added versionning & About window
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
+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 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 (inferred) nextMode = inferred;
|
if (s.backend === 'flex') {
|
||||||
else if (s.mode === 'DATA') nextMode = digitalDefaultRef.current || 'FT8';
|
if (s.mode === 'DATA') {
|
||||||
else if (s.mode) nextMode = s.mode;
|
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) {
|
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}
|
||||||
@@ -2574,6 +2668,7 @@ export default function App() {
|
|||||||
onTab={setDetailTab}
|
onTab={setDetailTab}
|
||||||
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
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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 {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>;
|
||||||
|
|||||||
@@ -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']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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