Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97e24ea24f | ||
|
|
3e199f9ab6 | ||
|
|
8740a4ba66 | ||
|
|
8ccad7ca65 | ||
|
|
fa7df57435 | ||
|
|
812e4f05e5 | ||
|
|
6ec31b61ce | ||
|
|
93c8f6b9d3 | ||
|
|
65c22232dd | ||
|
|
edede0bc1e | ||
|
|
2712902057 | ||
|
|
9281645359 | ||
|
|
a05dd6b3a9 | ||
|
|
a2401d7fd3 | ||
|
|
053b351dab | ||
|
|
64f2d38d87 | ||
|
|
299184712a | ||
|
|
76c1e2df60 | ||
|
|
165f33caa5 | ||
|
|
464a1c702c | ||
|
|
19c5045dc6 | ||
|
|
fa09251039 | ||
|
|
d6626d96d0 | ||
|
|
8b831145ad | ||
|
|
81c60628c6 | ||
|
|
678787ec62 | ||
|
|
79dc20a859 | ||
|
|
824971d0a1 | ||
|
|
60bcd2422d | ||
|
|
9b0d7ce1dc | ||
|
|
572e8ca538 | ||
|
|
6ac9783f7c | ||
|
|
725600c341 | ||
|
|
5d9765be09 | ||
|
|
b302d4d87b | ||
|
|
8b7c42ec9b | ||
|
|
cde0add5e0 | ||
|
|
0e2ef317c3 |
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -157,6 +158,7 @@ func (a *App) GetOnlineOperators() ([]ChatPresence, error) {
|
|||||||
func (a *App) chatLoop() {
|
func (a *App) chatLoop() {
|
||||||
defer func() { _ = recover() }()
|
defer func() { _ = recover() }()
|
||||||
var lastID int64 = -1 // -1 = not yet baselined
|
var lastID int64 = -1 // -1 = not yet baselined
|
||||||
|
var lastDB *sql.DB // logbook the baseline belongs to
|
||||||
lastPresence := time.Time{}
|
lastPresence := time.Time{}
|
||||||
lastPurge := time.Time{}
|
lastPurge := time.Time{}
|
||||||
t := time.NewTicker(chatPollInterval)
|
t := time.NewTicker(chatPollInterval)
|
||||||
@@ -164,8 +166,15 @@ func (a *App) chatLoop() {
|
|||||||
for range t.C {
|
for range t.C {
|
||||||
if !a.chatActive() {
|
if !a.chatActive() {
|
||||||
lastID = -1 // re-baseline if the backend changes
|
lastID = -1 // re-baseline if the backend changes
|
||||||
|
lastDB = nil
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Profile switch swaps the logbook under us: re-baseline against the new
|
||||||
|
// DB so we don't query it with the previous log's id cursor.
|
||||||
|
if a.logDb != lastDB {
|
||||||
|
lastID = -1
|
||||||
|
lastDB = a.logDb
|
||||||
|
}
|
||||||
if err := a.ensureChatTables(); err != nil {
|
if err := a.ensureChatTables(); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
+345
-49
@@ -13,12 +13,13 @@ import {
|
|||||||
GetStartupStatus, CheckForUpdate,
|
GetStartupStatus, CheckForUpdate,
|
||||||
WorkedBefore,
|
WorkedBefore,
|
||||||
SetCompactMode,
|
SetCompactMode,
|
||||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
|
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
|
||||||
GetSecretStatus, UnlockSecrets,
|
GetSecretStatus, UnlockSecrets,
|
||||||
RefreshCtyDat, DownloadAllReferenceLists,
|
RefreshCtyDat, DownloadAllReferenceLists,
|
||||||
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
||||||
GetDBConnectionInfo, GetLogbookRevision,
|
GetDBConnectionInfo, GetLogbookRevision,
|
||||||
GetUltrabeamStatus, SetUltrabeamDirection,
|
GetUltrabeamStatus, SetUltrabeamDirection,
|
||||||
|
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
|
||||||
OpenExternalURL,
|
OpenExternalURL,
|
||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
||||||
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
||||||
@@ -31,7 +32,7 @@ import {
|
|||||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||||
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
||||||
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
||||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart,
|
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
||||||
GetAwardDefs,
|
GetAwardDefs,
|
||||||
GetUIPref,
|
GetUIPref,
|
||||||
ReportLiveActivity,
|
ReportLiveActivity,
|
||||||
@@ -56,6 +57,8 @@ import { QSOEditModal } from '@/components/QSOEditModal';
|
|||||||
import { BandMap } from '@/components/BandMap';
|
import { BandMap } from '@/components/BandMap';
|
||||||
import { WorldMap, LocatorMap } from '@/components/MainMap';
|
import { WorldMap, LocatorMap } from '@/components/MainMap';
|
||||||
import { FlexPanel } from '@/components/FlexPanel';
|
import { FlexPanel } from '@/components/FlexPanel';
|
||||||
|
import { IcomPanel } from '@/components/IcomPanel';
|
||||||
|
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
|
||||||
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
||||||
import { AwardsPanel } from '@/components/AwardsPanel';
|
import { AwardsPanel } from '@/components/AwardsPanel';
|
||||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
@@ -63,6 +66,8 @@ import { ShutdownProgress } from '@/components/ShutdownProgress';
|
|||||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||||
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
||||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||||
|
import { NetControlPanel } from '@/components/NetControlPanel';
|
||||||
|
import { AlertsModal } from '@/components/AlertsModal';
|
||||||
import { BulkEditModal } from '@/components/BulkEditModal';
|
import { BulkEditModal } from '@/components/BulkEditModal';
|
||||||
import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover';
|
import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover';
|
||||||
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
||||||
@@ -113,7 +118,7 @@ const emptyDetails: DetailsState = {
|
|||||||
prop_mode: '', my_rig: '', my_antenna: '',
|
prop_mode: '', my_rig: '', my_antenna: '',
|
||||||
tx_pwr: undefined,
|
tx_pwr: undefined,
|
||||||
sat_name: '', sat_mode: '',
|
sat_name: '', sat_mode: '',
|
||||||
contest_id: '', srx: undefined, stx: undefined,
|
contest_id: '', srx_string: '', stx_string: '',
|
||||||
email: '',
|
email: '',
|
||||||
award_refs: '',
|
award_refs: '',
|
||||||
};
|
};
|
||||||
@@ -202,6 +207,24 @@ function rstCategory(mode: string): keyof RSTLists {
|
|||||||
if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw';
|
if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw';
|
||||||
return 'phone';
|
return 'phone';
|
||||||
}
|
}
|
||||||
|
// estimateCwMs roughly estimates how long the resolved text takes to send in CW
|
||||||
|
// at wpm: ~10 dot-units per character + ~4 extra per word gap, 1 unit = 1200/wpm
|
||||||
|
// ms (PARIS standard). Used to cap waits on the keyer's "busy" status, which can
|
||||||
|
// lag by tens of seconds over a remote/serial-over-IP link.
|
||||||
|
function estimateCwMs(resolved: string, wpm: number): number {
|
||||||
|
const w = Math.max(5, wpm || 25);
|
||||||
|
const chars = resolved.replace(/\s/g, '').length;
|
||||||
|
const spaces = (resolved.match(/\s/g) || []).length;
|
||||||
|
return ((chars * 10 + spaces * 4) * 1200) / w;
|
||||||
|
}
|
||||||
|
// exchangeFields splits a contest exchange the operator typed as free text into
|
||||||
|
// the ADIF numeric field (SRX/STX, when it's all digits) or the string field
|
||||||
|
// (SRX_STRING/STX_STRING, when it's alphanumeric like a section/zone).
|
||||||
|
function exchangeFields(raw?: string): { num?: number; str: string } {
|
||||||
|
const t = (raw || '').trim();
|
||||||
|
if (t === '') return { str: '' };
|
||||||
|
return /^\d+$/.test(t) ? { num: parseInt(t, 10), str: '' } : { str: t };
|
||||||
|
}
|
||||||
// rstOptions returns the valid report choices for a mode from the user's
|
// rstOptions returns the valid report choices for a mode from the user's
|
||||||
// editable lists (Settings → Modes), with a tiny fallback before they load.
|
// editable lists (Settings → Modes), with a tiny fallback before they load.
|
||||||
function rstOptions(mode: string, lists: RSTLists): string[] {
|
function rstOptions(mode: string, lists: RSTLists): string[] {
|
||||||
@@ -332,6 +355,12 @@ export default function App() {
|
|||||||
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
|
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
|
||||||
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
|
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
|
||||||
const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false });
|
const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false });
|
||||||
|
const [agStatus, setAgStatus] = useState<AGStatus>({ connected: false, port_a: 0, port_b: 0, antennas: [] });
|
||||||
|
const [agEnabled, setAgEnabled] = useState(false);
|
||||||
|
// Per-port optimistic selection that the status poll must not revert until the
|
||||||
|
// device confirms it (or it expires) — otherwise a stale poll right after a
|
||||||
|
// click reverts the UI and the click looks like it did nothing.
|
||||||
|
const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
|
||||||
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
|
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
|
||||||
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
|
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
|
||||||
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
||||||
@@ -393,10 +422,12 @@ export default function App() {
|
|||||||
|
|
||||||
// User changed band/mode in the entry strip → push to the rig if CAT is up.
|
// User changed band/mode in the entry strip → push to the rig if CAT is up.
|
||||||
// Both calls are fire-and-forget; CAT will reflect back via cat:state.
|
// Both calls are fire-and-forget; CAT will reflect back via cat:state.
|
||||||
|
// When the field is LOCKED (🔒, e.g. logging an old QSO off-frequency), we do
|
||||||
|
// NOT drive the radio — the lock means "this value is decoupled from the rig".
|
||||||
function onBandUserChange(v: string) {
|
function onBandUserChange(v: string) {
|
||||||
setBand(v);
|
setBand(v);
|
||||||
noteManualEdit();
|
noteManualEdit();
|
||||||
if (catState.enabled && catState.connected) {
|
if (catState.enabled && catState.connected && !locks.band && !locks.freq) {
|
||||||
const hz = qsyFreqHz(v, mode);
|
const hz = qsyFreqHz(v, mode);
|
||||||
if (hz > 0) SetCATFrequency(hz).catch(() => {});
|
if (hz > 0) SetCATFrequency(hz).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -405,7 +436,7 @@ export default function App() {
|
|||||||
setMode(v);
|
setMode(v);
|
||||||
applyModePreset(v);
|
applyModePreset(v);
|
||||||
noteManualEdit();
|
noteManualEdit();
|
||||||
if (catState.enabled && catState.connected) {
|
if (catState.enabled && catState.connected && !locks.mode) {
|
||||||
SetCATMode(v).catch(() => {});
|
SetCATMode(v).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -460,11 +491,25 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
// Transient success toast (bottom-right, auto-dismiss). Used for things
|
// Transient success toast (bottom-right, auto-dismiss). Used for things
|
||||||
// like "spot sent" where a blocking error banner would be overkill.
|
// like "spot sent" where a blocking error banner would be overkill.
|
||||||
|
// Toasts are QUEUED so two firing at once don't clobber each other: each shows
|
||||||
|
// for 3s, then the next takes over (no gap). `toast` is the one on screen now.
|
||||||
const [toast, setToast] = useState('');
|
const [toast, setToast] = useState('');
|
||||||
const showToast = useCallback((msg: string) => {
|
const toastQueue = useRef<string[]>([]);
|
||||||
setToast(msg);
|
const toastTimer = useRef<number | undefined>(undefined);
|
||||||
window.setTimeout(() => setToast((t) => (t === msg ? '' : t)), 3500);
|
const advanceToast = useCallback(() => {
|
||||||
|
const next = toastQueue.current.shift();
|
||||||
|
if (next === undefined) { toastTimer.current = undefined; setToast(''); return; }
|
||||||
|
setToast(next);
|
||||||
|
toastTimer.current = window.setTimeout(advanceToast, 3000);
|
||||||
}, []);
|
}, []);
|
||||||
|
const showToast = useCallback((msg: string) => {
|
||||||
|
toastQueue.current.push(msg);
|
||||||
|
if (toastTimer.current === undefined) advanceToast(); // nothing showing → start now
|
||||||
|
}, [advanceToast]);
|
||||||
|
const dismissToast = useCallback(() => {
|
||||||
|
if (toastTimer.current !== undefined) { window.clearTimeout(toastTimer.current); toastTimer.current = undefined; }
|
||||||
|
advanceToast(); // skip to the next queued toast (or clear if none)
|
||||||
|
}, [advanceToast]);
|
||||||
// Error banners auto-dismiss after a few seconds (longer than toasts since
|
// Error banners auto-dismiss after a few seconds (longer than toasts since
|
||||||
// they may be multi-line). The X button still closes them immediately.
|
// they may be multi-line). The X button still closes them immediately.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -498,6 +543,12 @@ export default function App() {
|
|||||||
if (forCall !== undefined) recordingCallRef.current = forCall.trim().toUpperCase();
|
if (forCall !== undefined) recordingCallRef.current = forCall.trim().toUpperCase();
|
||||||
QSOAudioRestart().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
|
QSOAudioRestart().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
|
||||||
};
|
};
|
||||||
|
// Reset the recording to zero (drop everything so far, pre-roll included) —
|
||||||
|
// bound to clicking the REC timer. Use when the station was already in a long
|
||||||
|
// QSO and you only want your own exchange in the file.
|
||||||
|
const resetRecordingClock = () => {
|
||||||
|
QSOAudioResetClock().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
|
||||||
|
};
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [filterCallsign, setFilterCallsign] = useState('');
|
const [filterCallsign, setFilterCallsign] = useState('');
|
||||||
// Advanced filter builder (replaces the old band/mode dropdowns).
|
// Advanced filter builder (replaces the old band/mode dropdowns).
|
||||||
@@ -630,6 +681,7 @@ export default function App() {
|
|||||||
const [chatMsgs, setChatMsgs] = useState<ChatMsg[]>([]);
|
const [chatMsgs, setChatMsgs] = useState<ChatMsg[]>([]);
|
||||||
const [chatOnline, setChatOnline] = useState<ChatPresence[]>([]);
|
const [chatOnline, setChatOnline] = useState<ChatPresence[]>([]);
|
||||||
const [chatUnread, setChatUnread] = useState(0);
|
const [chatUnread, setChatUnread] = useState(0);
|
||||||
|
const [chatEpoch, setChatEpoch] = useState(0); // bumped on profile switch to reload the chat for the new logbook
|
||||||
const chatOpenRef = useRef(chatOpen); chatOpenRef.current = chatOpen;
|
const chatOpenRef = useRef(chatOpen); chatOpenRef.current = chatOpen;
|
||||||
const chatSeen = useRef<Set<number>>(new Set());
|
const chatSeen = useRef<Set<number>>(new Set());
|
||||||
// Availability (only on a shared MySQL logbook; re-checked as profiles switch).
|
// Availability (only on a shared MySQL logbook; re-checked as profiles switch).
|
||||||
@@ -667,7 +719,7 @@ export default function App() {
|
|||||||
lo();
|
lo();
|
||||||
const id = window.setInterval(lo, 15000);
|
const id = window.setInterval(lo, 15000);
|
||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [chatOpen]);
|
}, [chatOpen, chatEpoch]);
|
||||||
async function chatSend(t: string) {
|
async function chatSend(t: string) {
|
||||||
try {
|
try {
|
||||||
const m = (await SendChatMessage(t)) as any as ChatMsg;
|
const m = (await SendChatMessage(t)) as any as ChatMsg;
|
||||||
@@ -679,6 +731,12 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
const chatShown = chatOpen && chatAvailable;
|
const chatShown = chatOpen && chatAvailable;
|
||||||
|
|
||||||
|
const [alertsOpen, setAlertsOpen] = useState(false); // Alert management modal
|
||||||
|
|
||||||
|
// NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster).
|
||||||
|
const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1');
|
||||||
|
useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]);
|
||||||
|
|
||||||
const [dvkEnabled, setDvkEnabled] = useState(false);
|
const [dvkEnabled, setDvkEnabled] = useState(false);
|
||||||
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
||||||
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
||||||
@@ -719,7 +777,7 @@ export default function App() {
|
|||||||
const [clusterLockMode, setClusterLockMode] = useState(false);
|
const [clusterLockMode, setClusterLockMode] = useState(false);
|
||||||
// Status filter chips. Empty set = show every status (including
|
// Status filter chips. Empty set = show every status (including
|
||||||
// already-worked). Otherwise only matching spots pass.
|
// already-worked). Otherwise only matching spots pass.
|
||||||
type SpotStatusKey = 'new' | 'new-band' | 'new-slot' | 'worked';
|
type SpotStatusKey = 'new' | 'new-band' | 'new-mode' | 'new-slot' | 'worked';
|
||||||
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(new Set());
|
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(new Set());
|
||||||
// Mode filter chips. Empty set = show every mode. Categories map the
|
// Mode filter chips. Empty set = show every mode. Categories map the
|
||||||
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
||||||
@@ -762,11 +820,12 @@ export default function App() {
|
|||||||
// map ("map1"), the locator street map ("map2"), the cluster grid or the
|
// map ("map1"), the locator street map ("map2"), the cluster grid or the
|
||||||
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
|
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
|
||||||
// so it's loaded async on mount and re-read on profile:changed below.
|
// so it's loaded async on mount and re-read on profile:changed below.
|
||||||
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex';
|
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent';
|
||||||
|
const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now
|
||||||
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
|
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
|
||||||
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
|
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
|
||||||
const loadMainPanes = useCallback(async () => {
|
const loadMainPanes = useCallback(async () => {
|
||||||
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex';
|
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent';
|
||||||
const [l, r] = await Promise.all([
|
const [l, r] = await Promise.all([
|
||||||
GetUIPref('mainPaneLeft').catch(() => ''),
|
GetUIPref('mainPaneLeft').catch(() => ''),
|
||||||
GetUIPref('mainPaneRight').catch(() => ''),
|
GetUIPref('mainPaneRight').catch(() => ''),
|
||||||
@@ -965,6 +1024,7 @@ export default function App() {
|
|||||||
|
|
||||||
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
|
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
|
||||||
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
|
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
|
||||||
|
const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0');
|
||||||
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||||
|
|
||||||
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
|
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
|
||||||
@@ -1037,6 +1097,41 @@ export default function App() {
|
|||||||
return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
|
return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
// Backend-emitted toast messages (e.g. recording auto-send result/skip).
|
||||||
|
useEffect(() => {
|
||||||
|
const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); });
|
||||||
|
return () => { off(); };
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
// DX-cluster spot alerts: a matched rule fires here. Play a beep (WebAudio, no
|
||||||
|
// asset needed — CSP-safe) and/or show a toast, per the rule's chosen actions.
|
||||||
|
useEffect(() => {
|
||||||
|
const off = EventsOn('alert:fired', (p: any) => {
|
||||||
|
if (p?.sound) {
|
||||||
|
try {
|
||||||
|
const AC = (window as any).AudioContext || (window as any).webkitAudioContext;
|
||||||
|
const ctx = new AC();
|
||||||
|
const beep = (freq: number, at: number, dur: number) => {
|
||||||
|
const o = ctx.createOscillator(); const g = ctx.createGain();
|
||||||
|
o.type = 'sine'; o.frequency.value = freq;
|
||||||
|
o.connect(g); g.connect(ctx.destination);
|
||||||
|
g.gain.setValueAtTime(0.0001, ctx.currentTime + at);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.25, ctx.currentTime + at + 0.01);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + at + dur);
|
||||||
|
o.start(ctx.currentTime + at); o.stop(ctx.currentTime + at + dur + 0.02);
|
||||||
|
};
|
||||||
|
beep(880, 0, 0.14); beep(1320, 0.16, 0.18); // two-tone chirp
|
||||||
|
window.setTimeout(() => ctx.close().catch(() => {}), 600);
|
||||||
|
} catch { /* audio blocked — ignore */ }
|
||||||
|
}
|
||||||
|
if (p?.visual) {
|
||||||
|
const call = String(p?.call ?? ''); const band = String(p?.band ?? ''); const rule = String(p?.rule ?? 'alert');
|
||||||
|
showToast(`🔔 ${rule}: ${call}${band ? ` on ${band}` : ''}${p?.country ? ` — ${p.country}` : ''}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => { off(); };
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
|
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
|
||||||
// rotator is disabled (the backend just reads settings and returns).
|
// rotator is disabled (the backend just reads settings and returns).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1060,6 +1155,38 @@ export default function App() {
|
|||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Poll the Antenna Genius switch for active antenna per port + the list.
|
||||||
|
// Re-read the enabled flag each tick so toggling it in Settings makes the
|
||||||
|
// top-bar icon appear/disappear without an app restart.
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try { const en: any = await GetAntGeniusSettings(); if (alive) setAgEnabled(!!en?.enabled); } catch {}
|
||||||
|
try {
|
||||||
|
const s = (await GetAntGeniusStatus()) as AGStatus;
|
||||||
|
if (!alive || !s) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const pend = agPending.current;
|
||||||
|
// Keep an optimistic selection until the device confirms it or it ages out.
|
||||||
|
if (pend.a) { if (now > pend.a.t || s.port_a === pend.a.v) delete pend.a; else s.port_a = pend.a.v; }
|
||||||
|
if (pend.b) { if (now > pend.b.t || s.port_b === pend.b.v) delete pend.b; else s.port_b = pend.b.v; }
|
||||||
|
// Only update when something actually changed — avoids re-rendering the
|
||||||
|
// widget every 1.5s (which made buttons flicker on hover).
|
||||||
|
setAgStatus((prev) => (JSON.stringify(prev) === JSON.stringify(s) ? prev : s));
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1500);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
const agActivate = (port: number, antenna: number) => {
|
||||||
|
// Optimistic: reflect the change immediately and pin it for ~3s so the next
|
||||||
|
// poll (which may still carry the old cached value) can't revert it.
|
||||||
|
agPending.current[port === 1 ? 'a' : 'b'] = { v: antenna, t: Date.now() + 3000 };
|
||||||
|
setAgStatus((s) => ({ ...s, ...(port === 1 ? { port_a: antenna } : { port_b: antenna }) }));
|
||||||
|
AntGeniusActivate(port, antenna).catch((e) => setError(String(e?.message ?? e)));
|
||||||
|
};
|
||||||
|
|
||||||
// RX band auto-follows the TX band (only differs for cross-band work).
|
// RX band auto-follows the TX band (only differs for cross-band work).
|
||||||
useEffect(() => { setBandRx(band); }, [band]);
|
useEffect(() => { setBandRx(band); }, [band]);
|
||||||
|
|
||||||
@@ -1192,12 +1319,20 @@ export default function App() {
|
|||||||
// source behaves identically.
|
// source behaves identically.
|
||||||
function handleSpotClick(s: any) {
|
function handleSpotClick(s: any) {
|
||||||
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
|
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||||
if (catState.connected) {
|
// Reflect the spot's freq/band in the entry strip IMMEDIATELY (optimistic),
|
||||||
tuneRigCAT(s.freq_hz, m);
|
// then tune the rig if CAT is connected. We must not wait for the CAT status
|
||||||
} else {
|
// echo to update the display: on a remote Flex link that echo lags by seconds,
|
||||||
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
|
// so the frequency used to only catch up once you nudged the VFO. noteManualEdit()
|
||||||
if (s.band) setBand(s.band);
|
// freezes the CAT-driven overwrite for 1.5s so a stale in-flight status can't
|
||||||
|
// revert the optimistic value before the new freq is reported back.
|
||||||
|
if (s.freq_hz && s.freq_hz > 0) {
|
||||||
|
const mhz = (s.freq_hz / 1_000_000).toFixed(5);
|
||||||
|
setFreqMhz(mhz);
|
||||||
|
setRxFreqMhz(mhz);
|
||||||
|
noteManualEdit();
|
||||||
}
|
}
|
||||||
|
if (s.band) setBand(s.band);
|
||||||
|
if (catState.connected) tuneRigCAT(s.freq_hz, m);
|
||||||
if (m) applyModeFromSpot(m);
|
if (m) applyModeFromSpot(m);
|
||||||
onCallsignInput(s.dx_call, { force: true });
|
onCallsignInput(s.dx_call, { force: true });
|
||||||
applySpotPOTA((s as any).pota_ref);
|
applySpotPOTA((s as any).pota_ref);
|
||||||
@@ -1329,6 +1464,19 @@ export default function App() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// FlexRadio: apply the per-band RX/TX antennas whenever the band changes (rig
|
||||||
|
// QSY, spot click, manual band) — so the right antennas follow the frequency.
|
||||||
|
// Skipped while the band is LOCKED (entering an old QSO off-air) and for non-Flex
|
||||||
|
// backends. The backend no-ops if the band has no configured mapping.
|
||||||
|
const lastAntBandRef = useRef('');
|
||||||
|
useEffect(() => {
|
||||||
|
if (catState.backend !== 'flex' || locks.band) return;
|
||||||
|
const b = band.trim();
|
||||||
|
if (!b || b === lastAntBandRef.current) return;
|
||||||
|
lastAntBandRef.current = b;
|
||||||
|
FlexApplyBandAntenna(b).catch(() => {});
|
||||||
|
}, [band, catState.backend, locks.band]);
|
||||||
|
|
||||||
// Cluster live wiring: hydrate per-server status + saved server list,
|
// Cluster live wiring: hydrate per-server status + saved server list,
|
||||||
// then subscribe to push events.
|
// then subscribe to push events.
|
||||||
async function reloadClusterMeta() {
|
async function reloadClusterMeta() {
|
||||||
@@ -1387,7 +1535,11 @@ export default function App() {
|
|||||||
// typing: only update when the field is empty, already shows this call, or
|
// typing: only update when the field is empty, already shows this call, or
|
||||||
// still shows the previous broadcast (i.e. the field content is ours, not
|
// still shows the previous broadcast (i.e. the field content is ours, not
|
||||||
// a different call the user typed). Returns true if it actually changed.
|
// a different call the user typed). Returns true if it actually changed.
|
||||||
const applyUdpCall = (raw: string): boolean => {
|
// force = true means an EXPLICIT action (a panadapter/spot click, a remote
|
||||||
|
// "set call" command) that should always win, even over a call the operator
|
||||||
|
// already typed. Only WSJT-X's CONTINUOUS DX-call stream keeps the no-clobber
|
||||||
|
// guard (it re-broadcasts constantly and must not fight what you typed).
|
||||||
|
const applyUdpCall = (raw: string, force = false): boolean => {
|
||||||
const call = String(raw ?? '').trim();
|
const call = String(raw ?? '').trim();
|
||||||
if (!call) return false;
|
if (!call) return false;
|
||||||
const upper = call.toUpperCase();
|
const upper = call.toUpperCase();
|
||||||
@@ -1395,22 +1547,26 @@ export default function App() {
|
|||||||
const prev = lastUdpCallRef.current;
|
const prev = lastUdpCallRef.current;
|
||||||
lastUdpCallRef.current = upper; // remember this broadcast either way
|
lastUdpCallRef.current = upper; // remember this broadcast either way
|
||||||
if (current === upper) return false; // already shown → no-op
|
if (current === upper) return false; // already shown → no-op
|
||||||
if (current !== '' && current !== prev) return false; // user typed a different call → leave it
|
if (!force && current !== '' && current !== prev) return false; // user typed a different call → leave it
|
||||||
onCallsignInput(call, { force: true }); // programmatic → always look up
|
onCallsignInput(call, { force: true }); // programmatic → always look up
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
const unsubDX = EventsOn('udp:dx_call', (p: any) => {
|
const unsubDX = EventsOn('udp:dx_call', (p: any) => {
|
||||||
|
// Anything that isn't WSJT-X (N1MM, ADIF, a panadapter/cluster click relayed
|
||||||
|
// over UDP…) is an explicit pick → force it over an existing call.
|
||||||
|
const force = String(p?.service ?? '').toLowerCase() !== 'wsjt';
|
||||||
// External app moved to a new station → fresh recording for the new target.
|
// External app moved to a new station → fresh recording for the new target.
|
||||||
if (applyUdpCall(p?.call)) restartRecordingForNewTarget(String(p?.call ?? ''));
|
if (applyUdpCall(p?.call, force)) restartRecordingForNewTarget(String(p?.call ?? ''));
|
||||||
});
|
});
|
||||||
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, true)) restartRecordingForNewTarget(String(raw ?? '')); // explicit remote pick
|
||||||
});
|
});
|
||||||
// Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call
|
// 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).
|
// (the radio already tuned via trigger_action=Tune, and CAT reads the freq).
|
||||||
|
// An explicit click always wins over whatever call is currently in the field.
|
||||||
const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => {
|
const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => {
|
||||||
const call = String(p?.call ?? '');
|
const call = String(p?.call ?? '');
|
||||||
if (applyUdpCall(call)) restartRecordingForNewTarget(call);
|
if (applyUdpCall(call, true)) 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) });
|
||||||
@@ -1455,6 +1611,11 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const off = EventsOn('profile:changed', () => {
|
const off = EventsOn('profile:changed', () => {
|
||||||
loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes();
|
loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes();
|
||||||
|
// The chat is per shared logbook — clear the previous profile's messages
|
||||||
|
// and reload for the new logbook (or hide if it isn't a MySQL log).
|
||||||
|
setChatMsgs([]); chatSeen.current.clear(); setChatOnline([]); setChatUnread(0);
|
||||||
|
ChatAvailable().then((v: any) => setChatAvailable(!!v)).catch(() => setChatAvailable(false));
|
||||||
|
setChatEpoch((e) => e + 1);
|
||||||
});
|
});
|
||||||
return () => { off(); };
|
return () => { off(); };
|
||||||
}, [loadStation, loadLists, loadCATCfg, reloadWk, loadMainPanes]);
|
}, [loadStation, loadLists, loadCATCfg, reloadWk, loadMainPanes]);
|
||||||
@@ -1502,7 +1663,7 @@ export default function App() {
|
|||||||
GRID: station.my_grid || '', COUNTRY: (station as any).my_country || '',
|
GRID: station.my_grid || '', COUNTRY: (station as any).my_country || '',
|
||||||
MY_QTH: (station as any).my_city || '', MY_RIG: details.my_rig || '', MY_ANTENNA: details.my_antenna || '',
|
MY_QTH: (station as any).my_city || '', MY_RIG: details.my_rig || '', MY_ANTENNA: details.my_antenna || '',
|
||||||
MY_IOTA: (station as any).my_iota || '', MY_SOTA: (station as any).my_sota_ref || '',
|
MY_IOTA: (station as any).my_iota || '', MY_SOTA: (station as any).my_sota_ref || '',
|
||||||
CONT_RX: details.srx != null ? String(details.srx) : '', CONT_TX: details.stx != null ? String(details.stx) : '',
|
CONT_RX: details.srx_string || '', CONT_TX: details.stx_string || '',
|
||||||
};
|
};
|
||||||
let out = text.replace(/<([A-Z_]+)>/g, (_m, k) => vars[k] ?? '');
|
let out = text.replace(/<([A-Z_]+)>/g, (_m, k) => vars[k] ?? '');
|
||||||
out = out.replace(/\*/g, myCall).replace(/!/g, his);
|
out = out.replace(/\*/g, myCall).replace(/!/g, his);
|
||||||
@@ -1517,15 +1678,22 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
async function wkSend(rawText: string) {
|
async function wkSend(rawText: string) {
|
||||||
setWkSent('');
|
setWkSent('');
|
||||||
|
const resolved = resolveCW(rawText);
|
||||||
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
|
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
|
||||||
await WinkeyerSend(resolveCW(rawText)).catch((e) => setError(String(e?.message ?? e)));
|
await WinkeyerSend(resolved).catch((e) => setError(String(e?.message ?? e)));
|
||||||
// <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has
|
// <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has
|
||||||
// finished sending — wait for the busy flag to rise then fall, so the QSO
|
// finished sending — so the QSO isn't logged (and the form cleared) while CW
|
||||||
// isn't logged (and the form cleared) while the CW is still going out.
|
// is still going out. We'd like to wait for the busy flag to rise then fall,
|
||||||
|
// but over a remote/serial-over-IP link that status echo can lag by tens of
|
||||||
|
// seconds, which used to delay logging ~50s. So cap the wait at the ESTIMATED
|
||||||
|
// send duration (text length × WPM) plus slack: log when busy clears OR the
|
||||||
|
// estimate elapses, whichever comes first.
|
||||||
if (!doLog) return;
|
if (!doLog) return;
|
||||||
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
|
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
|
||||||
|
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500; // slack for keyer buffering/lag
|
||||||
for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start
|
for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start
|
||||||
for (let i = 0; i < 1200 && wkBusyRef.current; i++) await sleep(50); // ≤60s for it to finish
|
const deadline = Date.now() + capMs;
|
||||||
|
while (wkBusyRef.current && Date.now() < deadline) await sleep(50);
|
||||||
void save();
|
void save();
|
||||||
}
|
}
|
||||||
// stopAutoCall cancels any running auto-call loop.
|
// stopAutoCall cancels any running auto-call loop.
|
||||||
@@ -1540,8 +1708,14 @@ export default function App() {
|
|||||||
const m = wkMacros[i];
|
const m = wkMacros[i];
|
||||||
if (!m) break;
|
if (!m) break;
|
||||||
await wkSend(m.text);
|
await wkSend(m.text);
|
||||||
|
// Wait for the message to finish before the gap+resend. Cap the wait at the
|
||||||
|
// ESTIMATED send time (not the busy flag alone): over a remote/serial-over-IP
|
||||||
|
// link the keyer's "busy" status lags badly and stays stuck true for tens of
|
||||||
|
// seconds, which made the next CQ fire ~120s late — i.e. auto-call looked dead.
|
||||||
|
const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500;
|
||||||
for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start
|
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
|
const deadline = Date.now() + capMs;
|
||||||
|
while (wkBusyRef.current && gen === autoCallGenRef.current && Date.now() < deadline) await sleep(50);
|
||||||
if (gen !== autoCallGenRef.current) break;
|
if (gen !== autoCallGenRef.current) break;
|
||||||
await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call
|
await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call
|
||||||
}
|
}
|
||||||
@@ -1632,6 +1806,10 @@ export default function App() {
|
|||||||
// when you first entered the call (minutes early) and won't match LoTW.
|
// when you first entered the call (minutes early) and won't match LoTW.
|
||||||
const startEqualsEnd = localStorage.getItem('opslog.startEqualsEnd') === '1';
|
const startEqualsEnd = localStorage.getItem('opslog.startEqualsEnd') === '1';
|
||||||
const start = (startEqualsEnd && !locks.start) ? end : baseStart;
|
const start = (startEqualsEnd && !locks.start) ? end : baseStart;
|
||||||
|
// Contest exchanges: a purely-numeric exchange → ADIF SRX/STX (int); an
|
||||||
|
// alphanumeric one (e.g. a section/zone) → SRX_STRING/STX_STRING.
|
||||||
|
const srxE = exchangeFields(details.srx_string);
|
||||||
|
const stxE = exchangeFields(details.stx_string);
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
callsign: callsign.trim().toUpperCase(),
|
callsign: callsign.trim().toUpperCase(),
|
||||||
qso_date: start.toISOString(),
|
qso_date: start.toISOString(),
|
||||||
@@ -1650,7 +1828,7 @@ export default function App() {
|
|||||||
tx_pwr: details.tx_pwr,
|
tx_pwr: details.tx_pwr,
|
||||||
sat_name: details.sat_name, sat_mode: details.sat_mode,
|
sat_name: details.sat_name, sat_mode: details.sat_mode,
|
||||||
contest_id: details.contest_id,
|
contest_id: details.contest_id,
|
||||||
srx: details.srx, stx: details.stx,
|
srx: srxE.num, stx: stxE.num, srx_string: srxE.str, stx_string: stxE.str,
|
||||||
email: details.email,
|
email: details.email,
|
||||||
};
|
};
|
||||||
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
|
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
|
||||||
@@ -1846,6 +2024,11 @@ export default function App() {
|
|||||||
setLookupBusy(true);
|
setLookupBusy(true);
|
||||||
try {
|
try {
|
||||||
const r = await LookupCallsign(call);
|
const r = await LookupCallsign(call);
|
||||||
|
// Discard a STALE result: the operator already moved to another call
|
||||||
|
// (clicked a new spot / typed) while this lookup was in flight. Applying it
|
||||||
|
// would clobber the current call's fields and zoom the map to the wrong
|
||||||
|
// station — the bug where replacing a call didn't re-zoom the map.
|
||||||
|
if (call !== callsignValRef.current.trim().toUpperCase()) return;
|
||||||
lastLookedUpRef.current = call;
|
lastLookedUpRef.current = call;
|
||||||
// cty.dat carries ONLY DXCC-entity data (country / CQ / ITU zones / continent).
|
// cty.dat carries ONLY DXCC-entity data (country / CQ / ITU zones / continent).
|
||||||
// A QRZ/HamQTH hit is far richer (name, QTH, grid, address, image). When the
|
// A QRZ/HamQTH hit is far richer (name, QTH, grid, address, image). When the
|
||||||
@@ -1883,6 +2066,9 @@ export default function App() {
|
|||||||
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
|
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
|
||||||
}));
|
}));
|
||||||
if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc);
|
if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc);
|
||||||
|
// The DX location is now known (grid set above) — force the world map to
|
||||||
|
// auto-zoom right away, so it doesn't lag behind the resolved QSO.
|
||||||
|
setMapZoomSignal((n) => n + 1);
|
||||||
// Recording: tie it to the resolved callsign. Start once a real (≥3-char)
|
// Recording: tie it to the resolved callsign. Start once a real (≥3-char)
|
||||||
// call resolves — covers the fast CW workflow (type → Enter, no blur). If
|
// call resolves — covers the fast CW workflow (type → Enter, no blur). If
|
||||||
// we're already recording a DIFFERENT call (the operator edited the
|
// we're already recording a DIFFERENT call (the operator edited the
|
||||||
@@ -1933,8 +2119,14 @@ export default function App() {
|
|||||||
// 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) →
|
// A callsign appeared (someone answered the CQ, or a spot was clicked) →
|
||||||
// stop auto-calling so we don't key over the contact.
|
// stop auto-calling so we don't key over the contact. If a CQ was actually
|
||||||
if (v.trim() !== '') stopAutoCall();
|
// in flight, abort the current transmission too so it stops IMMEDIATELY
|
||||||
|
// rather than finishing the buffered call. (autoCallMacroRef flips to -1 on
|
||||||
|
// the first keystroke, so we only abort once.)
|
||||||
|
if (v.trim() !== '') {
|
||||||
|
if (autoCallMacroRef.current !== -1) WinkeyerStop().catch(() => {});
|
||||||
|
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
|
||||||
@@ -2048,6 +2240,9 @@ export default function App() {
|
|||||||
{ type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' },
|
{ type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' },
|
||||||
{ type: 'item', label: cwEnabled ? '✓ CW decoder (RX audio)' : 'CW decoder (RX audio)', action: 'tools.cwdecoder' },
|
{ type: 'item', label: cwEnabled ? '✓ CW decoder (RX audio)' : 'CW decoder (RX audio)', action: 'tools.cwdecoder' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
|
{ type: 'item', label: netEnabled ? '✓ NET Control' : 'NET Control', action: 'tools.net' },
|
||||||
|
{ type: 'item', label: 'Alert management…', action: 'tools.alerts' },
|
||||||
|
{ type: 'separator' },
|
||||||
// Maintenance — bumped here while we only have one entry. Will move
|
// Maintenance — bumped here while we only have one entry. Will move
|
||||||
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
|
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
|
||||||
{ type: 'item', label: ctyRefreshing ? 'Refreshing cty.dat…' : 'Refresh cty.dat', action: 'tools.refreshCty', disabled: ctyRefreshing },
|
{ type: 'item', label: ctyRefreshing ? 'Refreshing cty.dat…' : 'Refresh cty.dat', action: 'tools.refreshCty', disabled: ctyRefreshing },
|
||||||
@@ -2056,7 +2251,7 @@ export default function App() {
|
|||||||
{ name: 'help', label: 'Help', items: [
|
{ name: 'help', label: 'Help', items: [
|
||||||
{ type: 'item', label: 'About OpsLog', action: 'help.about' },
|
{ type: 'item', label: 'About OpsLog', action: 'help.about' },
|
||||||
]},
|
]},
|
||||||
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled]);
|
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled]);
|
||||||
|
|
||||||
function handleMenu(action: string) {
|
function handleMenu(action: string) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@@ -2074,6 +2269,8 @@ export default function App() {
|
|||||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||||
case 'tools.cwdecoder': toggleCwDecoder(); break;
|
case 'tools.cwdecoder': toggleCwDecoder(); break;
|
||||||
|
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
|
||||||
|
case 'tools.alerts': setAlertsOpen(true); 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;
|
case 'help.about': setShowAbout(true); break;
|
||||||
@@ -2197,10 +2394,16 @@ export default function App() {
|
|||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{recording && RECORDABLE_MODES.has(mode.toUpperCase()) && (
|
{recording && RECORDABLE_MODES.has(mode.toUpperCase()) && (
|
||||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-red-600 whitespace-nowrap pointer-events-none">
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={resetRecordingClock}
|
||||||
|
title="Click to restart the recording from 0 — drops everything captured so far (incl. pre-roll) so the file holds only your exchange"
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-red-600 whitespace-nowrap cursor-pointer rounded px-1 hover:bg-red-50"
|
||||||
|
>
|
||||||
<span className="size-2 rounded-full bg-red-600 animate-pulse" />
|
<span className="size-2 rounded-full bg-red-600 animate-pulse" />
|
||||||
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
|
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
|
||||||
</span>
|
</button>
|
||||||
)}
|
)}
|
||||||
<Input
|
<Input
|
||||||
ref={callsignRef}
|
ref={callsignRef}
|
||||||
@@ -2588,8 +2791,9 @@ export default function App() {
|
|||||||
{([
|
{([
|
||||||
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' },
|
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' },
|
||||||
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' },
|
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' },
|
||||||
|
{ k: 'new-mode' as SpotStatusKey, label: 'NEW MODE', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
|
||||||
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
|
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
|
||||||
{ k: 'worked' as SpotStatusKey, label: 'WORKED', cls: 'bg-muted text-muted-foreground border-border' },
|
// (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
|
||||||
]).map((s) => {
|
]).map((s) => {
|
||||||
const on = clusterStatusFilter.has(s.k);
|
const on = clusterStatusFilter.has(s.k);
|
||||||
return (
|
return (
|
||||||
@@ -2662,6 +2866,7 @@ export default function App() {
|
|||||||
toLabel={callsign}
|
toLabel={callsign}
|
||||||
beamAzimuths={showBeamOnMap ? beamHeadings : []}
|
beamAzimuths={showBeamOnMap ? beamHeadings : []}
|
||||||
boomAzimuth={showBeamOnMap && ubPattern && ubPattern !== 'normal' ? boomHeading : null}
|
boomAzimuth={showBeamOnMap && ubPattern && ubPattern !== 'normal' ? boomHeading : null}
|
||||||
|
zoomSignal={mapZoomSignal}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'map2':
|
case 'map2':
|
||||||
@@ -2686,13 +2891,35 @@ export default function App() {
|
|||||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||||
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
|
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
|
||||||
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} />
|
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'flex':
|
case 'flex':
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
|
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
|
||||||
<FlexPanel />
|
<FlexPanel onCWSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'recent':
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
<RecentQSOsGrid
|
||||||
|
rows={qsosWithAwards as any}
|
||||||
|
total={total}
|
||||||
|
awardCols={awardCols}
|
||||||
|
onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||||
|
onUpdateFromCty={bulkUpdateFromCty}
|
||||||
|
onUpdateFromQRZ={bulkUpdateFromQRZ}
|
||||||
|
onUpdateFromClublog={bulkUpdateFromClublog}
|
||||||
|
onSendTo={bulkSendTo}
|
||||||
|
onSendRecording={bulkSendRecording}
|
||||||
|
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||||
|
onBulkEdit={openBulkEdit}
|
||||||
|
onExportSelected={exportSelectedADIF}
|
||||||
|
onExportFiltered={exportFilteredADIF}
|
||||||
|
onDelete={(ids) => setDeletingIds(ids)}
|
||||||
|
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2750,7 +2977,7 @@ export default function App() {
|
|||||||
<div className="flex items-center gap-1.5 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
|
<div className="flex items-center gap-1.5 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
|
||||||
<Satellite className="size-3.5 shrink-0" />
|
<Satellite className="size-3.5 shrink-0" />
|
||||||
<span className="truncate" title={toast}>{toast}</span>
|
<span className="truncate" title={toast}>{toast}</span>
|
||||||
<button className="shrink-0 text-emerald-600 hover:text-emerald-800" onClick={() => setToast('')}><X className="size-3" /></button>
|
<button className="shrink-0 text-emerald-600 hover:text-emerald-800" onClick={dismissToast}><X className="size-3" /></button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2913,6 +3140,21 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
<Compass className="size-4" />
|
<Compass className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
{agEnabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { const v = !showAntGenius; setShowAntGenius(v); writeUiPref('opslog.showAntGenius', v ? '1' : '0'); }}
|
||||||
|
title={showAntGenius ? 'Antenna Genius — shown · click to hide' : 'Antenna Genius · click to show'}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||||
|
showAntGenius ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
|
||||||
|
: 'border-border text-muted-foreground hover:bg-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Antenna className="size-4" />
|
||||||
|
{showAntGenius && agStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-emerald-500" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{chatAvailable && (
|
{chatAvailable && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -3144,10 +3386,15 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: wide Name + QTH + Grid across the full width. */}
|
{/* Row 2: Name fixed to the Band/Mode/Country column width (300px) so
|
||||||
|
its right edge lines up with that column below; QTH grows to fill. */}
|
||||||
<div className="flex gap-2 items-end">
|
<div className="flex gap-2 items-end">
|
||||||
{nameBlock}
|
<div className="flex flex-col w-[300px] shrink-0"><Label className="mb-1 h-3.5">Name</Label>
|
||||||
{qthBlock}
|
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col flex-1 min-w-[70px]"><Label className="mb-1 h-3.5">QTH</Label>
|
||||||
|
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
|
||||||
|
</div>
|
||||||
{gridBlock}
|
{gridBlock}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3206,13 +3453,18 @@ export default function App() {
|
|||||||
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
||||||
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
||||||
otherwise it shows the QRZ profile photo. */}
|
otherwise it shows the QRZ profile photo. */}
|
||||||
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath))) && (
|
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled)) && (
|
||||||
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
|
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
|
||||||
{chatShown && (
|
{chatShown && (
|
||||||
<div className="w-[280px] shrink-0 min-h-0">
|
// relative + absolute inner: the chat takes the row height (set by the
|
||||||
|
// entry strip) WITHOUT its message list growing the row, like the
|
||||||
|
// Stats panel. The list scrolls inside this fixed height.
|
||||||
|
<div className="w-[280px] shrink-0 min-h-0 relative">
|
||||||
|
<div className="absolute inset-0 flex flex-col min-h-0">
|
||||||
<ChatPanel msgs={chatMsgs} online={chatOnline} myCall={station.callsign}
|
<ChatPanel msgs={chatMsgs} online={chatOnline} myCall={station.callsign}
|
||||||
onSend={chatSend} onClose={() => setChatOpen(false)} />
|
onSend={chatSend} onClose={() => setChatOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
|
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
|
||||||
rotator is configured or a DX bearing exists. */}
|
rotator is configured or a DX bearing exists. */}
|
||||||
@@ -3231,6 +3483,15 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{showAntGenius && agEnabled && (
|
||||||
|
<div className="w-[230px] shrink-0 min-h-0">
|
||||||
|
<AntGeniusPanel
|
||||||
|
status={agStatus}
|
||||||
|
onActivate={agActivate}
|
||||||
|
onClose={() => { setShowAntGenius(false); writeUiPref('opslog.showAntGenius', '0'); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{dvkEnabled && (
|
{dvkEnabled && (
|
||||||
<div className="w-[264px] shrink-0 min-h-0">
|
<div className="w-[264px] shrink-0 min-h-0">
|
||||||
<DvkPanel
|
<DvkPanel
|
||||||
@@ -3298,7 +3559,7 @@ export default function App() {
|
|||||||
|
|
||||||
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
|
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
|
||||||
{cwOn && (
|
{cwOn && (
|
||||||
<div className="ml-2.5 mb-1 w-[45%] flex items-center gap-2 rounded-md border border-emerald-300/70 bg-emerald-50/60 px-2 py-1 text-xs">
|
<div className="ml-2.5 mt-1.5 -mb-1 w-[45%] flex items-center gap-2 rounded-md border border-emerald-300/70 bg-emerald-50/60 px-2 py-1.5 text-xs">
|
||||||
<Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-emerald-600' : 'text-muted-foreground')} />
|
<Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-emerald-600' : 'text-muted-foreground')} />
|
||||||
{/* Input-level meter — if this stays flat with a strong signal, the RX
|
{/* Input-level meter — if this stays flat with a strong signal, the RX
|
||||||
audio device is wrong/silent rather than a decode problem. */}
|
audio device is wrong/silent rather than a decode problem. */}
|
||||||
@@ -3319,16 +3580,16 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
{/* Left-aligned single line, no scrollbar; auto-scrolled to the newest
|
{/* Left-aligned single line, no scrollbar; auto-scrolled to the newest
|
||||||
text (see cwScrollRef effect) so the latest stays in view. */}
|
text (see cwScrollRef effect) so the latest stays in view. */}
|
||||||
<div ref={cwScrollRef} className="flex-1 min-w-0 overflow-hidden font-mono leading-5">
|
<div ref={cwScrollRef} className="flex-1 min-w-0 overflow-hidden font-mono leading-none flex items-center">
|
||||||
{cwText.trim() === '' ? (
|
{cwText.trim() === '' ? (
|
||||||
<span className="text-muted-foreground italic">listening…</span>
|
<span className="text-muted-foreground italic">listening…</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="inline-flex whitespace-nowrap">
|
<div className="inline-flex items-center whitespace-nowrap">
|
||||||
{cwText.trim().split(/\s+/).map((tok, i) => (
|
{cwText.trim().split(/\s+/).map((tok, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
type="button"
|
type="button"
|
||||||
className="mr-1 shrink-0 rounded px-1 hover:bg-emerald-200/70"
|
className="mr-1 shrink-0 rounded px-1 leading-none hover:bg-emerald-200/70"
|
||||||
title="Use as callsign"
|
title="Use as callsign"
|
||||||
onClick={() => onCallsignInput(tok, { force: true })}
|
onClick={() => onCallsignInput(tok, { force: true })}
|
||||||
>
|
>
|
||||||
@@ -3364,7 +3625,23 @@ export default function App() {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="awards">Awards</TabsTrigger>
|
<TabsTrigger value="awards">Awards</TabsTrigger>
|
||||||
<TabsTrigger value="bandmap">Band Map</TabsTrigger>
|
<TabsTrigger value="bandmap">Band Map</TabsTrigger>
|
||||||
|
{netEnabled && (
|
||||||
|
<TabsTrigger value="net" className="gap-1.5">
|
||||||
|
Net
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
aria-label="Close Net"
|
||||||
|
title="Close"
|
||||||
|
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
|
||||||
|
onPointerDown={(e) => { e.stopPropagation(); }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setNetEnabled(false); setActiveTab((t) => (t === 'net' ? 'recent' : t)); }}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
|
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
|
||||||
|
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>}
|
||||||
{/* Not a tab — QRZ blocks embedding, so this opens the call's
|
{/* Not a tab — QRZ blocks embedding, so this opens the call's
|
||||||
QRZ.com page in the system browser. Styled like a trigger. */}
|
QRZ.com page in the system browser. Styled like a trigger. */}
|
||||||
<button
|
<button
|
||||||
@@ -3469,6 +3746,7 @@ export default function App() {
|
|||||||
onBulkEdit={openBulkEdit}
|
onBulkEdit={openBulkEdit}
|
||||||
onExportSelected={exportSelectedADIF}
|
onExportSelected={exportSelectedADIF}
|
||||||
onExportFiltered={exportFilteredADIF}
|
onExportFiltered={exportFilteredADIF}
|
||||||
|
onDelete={(ids) => setDeletingIds(ids)}
|
||||||
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
||||||
/>
|
/>
|
||||||
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
|
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
|
||||||
@@ -3644,7 +3922,7 @@ export default function App() {
|
|||||||
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||||
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
|
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
|
||||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} />
|
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Opened on demand from Tools → QSL Manager; closable via the
|
{/* Opened on demand from Tools → QSL Manager; closable via the
|
||||||
@@ -3674,13 +3952,27 @@ export default function App() {
|
|||||||
backend is a FlexRadio. */}
|
backend is a FlexRadio. */}
|
||||||
{catState.backend === 'flex' && (
|
{catState.backend === 'flex' && (
|
||||||
<TabsContent value="flex" className="flex-1 min-h-0 p-0">
|
<TabsContent value="flex" className="flex-1 min-h-0 p-0">
|
||||||
<FlexPanel />
|
<FlexPanel onCWSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }} />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Icom CI-V receive-DSP control panel — only when the CAT backend
|
||||||
|
is an Icom. */}
|
||||||
|
{catState.backend === 'icom' && (
|
||||||
|
<TabsContent value="icom" className="flex-1 min-h-0 p-0">
|
||||||
|
<IcomPanel />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Band Map: several bands shown side-by-side (panadapter-style
|
{/* Band Map: several bands shown side-by-side (panadapter-style
|
||||||
strips). Pick bands with the chips; each strip is clickable to
|
strips). Pick bands with the chips; each strip is clickable to
|
||||||
tune the rig. */}
|
tune the rig. */}
|
||||||
|
{netEnabled && (
|
||||||
|
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||||
|
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
<TabsContent value="bandmap" className="mt-0 flex flex-col min-h-0 flex-1">
|
<TabsContent value="bandmap" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||||
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-border/60 shrink-0 flex-wrap">
|
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-border/60 shrink-0 flex-wrap">
|
||||||
<span className="text-xs text-muted-foreground mr-1">Bands:</span>
|
<span className="text-xs text-muted-foreground mr-1">Bands:</span>
|
||||||
@@ -3843,6 +4135,10 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{alertsOpen && (
|
||||||
|
<AlertsModal onClose={() => setAlertsOpen(false)} bands={bands} modes={modes} countries={countries} />
|
||||||
|
)}
|
||||||
|
|
||||||
<AutoEQSL
|
<AutoEQSL
|
||||||
onSent={(call) => showToast(`OpsLog QSL sent to ${call}`)}
|
onSent={(call) => showToast(`OpsLog QSL sent to ${call}`)}
|
||||||
onError={(msg) => showToast(msg)}
|
onError={(msg) => showToast(msg)}
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Bell, Plus, Trash2, Volume2, Mail, Eye, X, Search } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
ListAlertRules, SaveAlertRule, DeleteAlertRule, GetAlertEmailTo, SetAlertEmailTo,
|
||||||
|
} from '@/../wailsjs/go/main/App';
|
||||||
|
import { alerts } from '@/../wailsjs/go/models';
|
||||||
|
|
||||||
|
type Rule = alerts.Rule;
|
||||||
|
|
||||||
|
const CONTINENTS = ['AF', 'AN', 'AS', 'EU', 'NA', 'OC', 'SA'];
|
||||||
|
|
||||||
|
function emptyRule(): Rule {
|
||||||
|
return alerts.Rule.createFrom({
|
||||||
|
id: '', name: 'New alert', enabled: true,
|
||||||
|
calls: [], countries: [], continents: [], bands: [], modes: [],
|
||||||
|
spotter_call: '', spotter_continents: [], spotter_countries: [],
|
||||||
|
sound: true, visual: true, email: false, again_after_min: 0, skip_worked: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// MultiCheck — a scrollable checkbox list for a set of options with an optional
|
||||||
|
// search box (used for the long country list). Empty selection = ALL.
|
||||||
|
function MultiCheck({ options, selected, onToggle, searchable, height = 'h-52' }: {
|
||||||
|
options: string[]; selected: string[]; onToggle: (v: string) => void; searchable?: boolean; height?: string;
|
||||||
|
}) {
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const shown = useMemo(
|
||||||
|
() => (q ? options.filter((o) => o.toLowerCase().includes(q.toLowerCase())) : options),
|
||||||
|
[options, q],
|
||||||
|
);
|
||||||
|
const sel = new Set((selected ?? []).map((s) => s.toLowerCase()));
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border">
|
||||||
|
{searchable && (
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border/60">
|
||||||
|
<Search className="size-3 text-muted-foreground" />
|
||||||
|
<input className="flex-1 bg-transparent text-xs outline-none" placeholder="Filter…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={cn('overflow-y-auto p-1', height)}>
|
||||||
|
{shown.map((o) => (
|
||||||
|
<label key={o} className="flex items-center gap-2 text-xs px-1.5 py-0.5 rounded hover:bg-accent/40 cursor-pointer">
|
||||||
|
<Checkbox checked={sel.has(o.toLowerCase())} onCheckedChange={() => onToggle(o)} />
|
||||||
|
<span className="truncate">{o}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{shown.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-3 text-center">no match</div>}
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-1 border-t border-border/60 text-[10px] text-muted-foreground">
|
||||||
|
{(selected?.length ?? 0) === 0 ? 'none selected = ALL' : `${selected!.length} selected`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||||
|
onClose: () => void; bands: string[]; modes: string[]; countries: string[];
|
||||||
|
}) {
|
||||||
|
const [rules, setRules] = useState<Rule[]>([]);
|
||||||
|
const [draft, setDraft] = useState<Rule | null>(null);
|
||||||
|
const [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select)
|
||||||
|
const [emailTo, setEmailTo] = useState('');
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try { setRules(((await ListAlertRules()) ?? []) as Rule[]); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { refresh(); GetAlertEmailTo().then((v) => setEmailTo(v || '')).catch(() => {}); }, [refresh]);
|
||||||
|
|
||||||
|
const patch = (p: Partial<Rule>) => setDraft((d) => (d ? alerts.Rule.createFrom({ ...d, ...p }) : d));
|
||||||
|
const toggleIn = (key: keyof Rule, v: string) => setDraft((d) => {
|
||||||
|
if (!d) return d;
|
||||||
|
const cur = ((d as any)[key] as string[]) ?? [];
|
||||||
|
const has = cur.some((x) => x.toLowerCase() === v.toLowerCase());
|
||||||
|
const next = has ? cur.filter((x) => x.toLowerCase() !== v.toLowerCase()) : [...cur, v];
|
||||||
|
return alerts.Rule.createFrom({ ...d, [key]: next });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!draft) return;
|
||||||
|
if (!draft.name.trim()) { setErr('Give the rule a name'); return; }
|
||||||
|
try { const saved = await SaveAlertRule(draft); await refresh(); setDraft(saved as Rule); setErr(''); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function del() {
|
||||||
|
if (!draft) return;
|
||||||
|
if (!draft.id) { setDraft(null); return; }
|
||||||
|
if (!window.confirm(`Delete alert "${draft.name}"?`)) return;
|
||||||
|
try { await DeleteAlertRule(draft.id); setDraft(null); await refresh(); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2"><Bell className="size-4 text-primary" /> Alert management</DialogTitle>
|
||||||
|
<DialogDescription>Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex gap-3 min-h-0 px-5" style={{ height: '60vh' }}>
|
||||||
|
{/* Rule list */}
|
||||||
|
<div className="w-56 shrink-0 flex flex-col border border-border rounded-md">
|
||||||
|
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border/60">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex-1">Rules</span>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { setDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-1">
|
||||||
|
{rules.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-4 text-center">No rules yet — click +</div>}
|
||||||
|
{rules.map((r) => (
|
||||||
|
<button key={r.id} onClick={() => { setDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
|
||||||
|
className={cn('w-full text-left px-2 py-1.5 rounded text-xs flex items-center gap-1.5',
|
||||||
|
draft?.id === r.id ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60')}>
|
||||||
|
<span className={cn('size-1.5 rounded-full shrink-0', r.enabled ? 'bg-emerald-500' : 'bg-muted-foreground/40')} />
|
||||||
|
<span className="truncate flex-1">{r.name}</span>
|
||||||
|
{r.sound && <Volume2 className="size-3 text-muted-foreground shrink-0" />}
|
||||||
|
{r.email && <Mail className="size-3 text-muted-foreground shrink-0" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-1.5 border-t border-border/60">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">Alert e-mail to</Label>
|
||||||
|
<Input className="h-7 text-xs mt-0.5" placeholder="[email protected]" value={emailTo}
|
||||||
|
onChange={(e) => setEmailTo(e.target.value)}
|
||||||
|
onBlur={() => SetAlertEmailTo(emailTo).catch(() => {})} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor */}
|
||||||
|
<div className="flex-1 min-w-0 border border-border rounded-md flex flex-col">
|
||||||
|
{!draft ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">Select or create a rule.</div>
|
||||||
|
) : (
|
||||||
|
<Tabs value={tab} onValueChange={setTab} className="flex flex-col flex-1 min-h-0">
|
||||||
|
<div className="flex items-center gap-2 px-2 pt-2">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="def">Definition</TabsTrigger>
|
||||||
|
<TabsTrigger value="call">Call / DXCC</TabsTrigger>
|
||||||
|
<TabsTrigger value="bm">Band / Mode</TabsTrigger>
|
||||||
|
<TabsTrigger value="orig">Origin</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-3">
|
||||||
|
<TabsContent value="def" className="mt-0 space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Rule name</Label>
|
||||||
|
<Input value={draft.name} onChange={(e) => patch({ name: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={draft.enabled} onCheckedChange={(c) => patch({ enabled: !!c })} /> Alert enabled
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-xs w-40 shrink-0">Alert again after (min)</Label>
|
||||||
|
<Input type="number" className="h-8 w-24" value={draft.again_after_min}
|
||||||
|
onChange={(e) => patch({ again_after_min: parseInt(e.target.value) || 0 })} />
|
||||||
|
<span className="text-[11px] text-muted-foreground">0 = once/session · -1 = always</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
|
<Label className="text-xs font-semibold">Actions</Label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.visual} onCheckedChange={(c) => patch({ visual: !!c })} /><Eye className="size-3.5" /> Visual</label>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.sound} onCheckedChange={(c) => patch({ sound: !!c })} /><Volume2 className="size-3.5" /> Sound</label>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.email} onCheckedChange={(c) => patch({ email: !!c })} /><Mail className="size-3.5" /> E-mail</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
|
||||||
|
<Checkbox checked={draft.skip_worked} onCheckedChange={(c) => patch({ skip_worked: !!c })} /> Skip calls already worked (same band + mode)
|
||||||
|
</label>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="call" className="mt-0 grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Callsigns (one per line, wildcards: IW3*, */P)</Label>
|
||||||
|
<textarea className="w-full h-52 rounded-md border border-border bg-background p-2 text-xs font-mono resize-none"
|
||||||
|
placeholder={'DL1ABC\nIW3*\n*/P'}
|
||||||
|
value={(draft.calls ?? []).join('\n')}
|
||||||
|
onChange={(e) => patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Countries (DXCC)</Label>
|
||||||
|
<MultiCheck options={countries} selected={draft.countries ?? []} onToggle={(v) => toggleIn('countries', v)} searchable />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label className="text-xs">Continents</Label>
|
||||||
|
<div className="flex gap-3 flex-wrap">
|
||||||
|
{CONTINENTS.map((c) => (
|
||||||
|
<label key={c} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||||
|
<Checkbox checked={(draft.continents ?? []).includes(c)} onCheckedChange={() => toggleIn('continents', c)} /> {c}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="bm" className="mt-0 grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Bands</Label>
|
||||||
|
<MultiCheck options={bands} selected={draft.bands ?? []} onToggle={(v) => toggleIn('bands', v)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Modes</Label>
|
||||||
|
<MultiCheck options={modes} selected={draft.modes ?? []} onToggle={(v) => toggleIn('modes', v)} />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="orig" className="mt-0 grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Spotter callsign (wildcard)</Label>
|
||||||
|
<Input className="font-mono" placeholder="e.g. F* or DL1ABC" value={draft.spotter_call ?? ''}
|
||||||
|
onChange={(e) => patch({ spotter_call: e.target.value })} />
|
||||||
|
<Label className="text-xs mt-2 block">Spotter continents</Label>
|
||||||
|
<div className="flex gap-3 flex-wrap">
|
||||||
|
{CONTINENTS.map((c) => (
|
||||||
|
<label key={c} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||||
|
<Checkbox checked={(draft.spotter_continents ?? []).includes(c)} onCheckedChange={() => toggleIn('spotter_continents', c)} /> {c}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Spotter countries</Label>
|
||||||
|
<MultiCheck options={countries} selected={draft.spotter_countries ?? []} onToggle={(v) => toggleIn('spotter_countries', v)} searchable />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor actions */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/60">
|
||||||
|
{err && <span className="text-[11px] text-rose-600 flex-1 truncate">{err}</span>}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button variant="ghost" size="sm" className="text-rose-700" onClick={del}><Trash2 className="size-3.5" /> Delete</Button>
|
||||||
|
<Button size="sm" onClick={save}>Save rule</Button>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end px-5 pb-4">
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose}><X className="size-3.5" /> Close</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Antenna, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type AGAntenna = { index: number; name: string };
|
||||||
|
export type AGStatus = {
|
||||||
|
connected: boolean; host?: string; last_error?: string;
|
||||||
|
port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean;
|
||||||
|
antennas: AGAntenna[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format an antenna name: first letter uppercase, the rest lowercase
|
||||||
|
// (e.g. "DX COMMANDER" → "Dx commander").
|
||||||
|
function pretty(name: string): string {
|
||||||
|
const t = name.trim();
|
||||||
|
if (!t) return t;
|
||||||
|
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
|
||||||
|
// match the app's light theme with soft gradients + glows. Each antenna row has
|
||||||
|
// a port-A button (left) and port-B button (right). Colours: green = selected on
|
||||||
|
// port A, blue = selected on port B, red (pulsing) = that port is transmitting.
|
||||||
|
// Clicking an already-selected port deselects it (port → None).
|
||||||
|
export function AntGeniusPanel({ status, onActivate, onClose }: {
|
||||||
|
status: AGStatus;
|
||||||
|
onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const list = status.antennas ?? [];
|
||||||
|
|
||||||
|
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
|
||||||
|
const letter = port === 1 ? 'A' : 'B';
|
||||||
|
const cls = tx
|
||||||
|
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
||||||
|
: active
|
||||||
|
? (port === 1
|
||||||
|
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
||||||
|
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
||||||
|
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onActivate(port, active ? 0 : index)}
|
||||||
|
title={active ? `Port ${letter} — click to deselect` : `Select on port ${letter}`}
|
||||||
|
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||||
|
<Antenna className={cn('size-4', status.connected ? 'text-emerald-600 drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
|
||||||
|
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-rose-500')} />
|
||||||
|
<span className={status.connected ? 'text-emerald-600' : 'text-rose-500'}>{status.connected ? 'online' : 'offline'}</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title="Close">
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
|
||||||
|
{!status.connected ? (
|
||||||
|
<div className="text-center py-6 text-xs space-y-2">
|
||||||
|
<div className="text-muted-foreground italic animate-pulse">Connecting…</div>
|
||||||
|
{status.last_error && <div className="text-rose-500 font-mono text-[10px] break-words px-2">{status.last_error}</div>}
|
||||||
|
</div>
|
||||||
|
) : list.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground italic text-center py-6 text-xs">No antennas configured.</div>
|
||||||
|
) : list.map((a) => {
|
||||||
|
const aActive = status.port_a === a.index;
|
||||||
|
const bActive = status.port_b === a.index;
|
||||||
|
const aTx = aActive && !!status.tx_a;
|
||||||
|
const bTx = bActive && !!status.tx_b;
|
||||||
|
const nameCls = (aTx || bTx)
|
||||||
|
? 'bg-gradient-to-r from-red-500 to-rose-600 text-white border-red-400/40 shadow-[0_0_11px_rgba(244,63,94,0.35)]'
|
||||||
|
: aActive
|
||||||
|
? 'bg-gradient-to-r from-emerald-500 to-emerald-600 text-white border-emerald-400/40 shadow-[0_0_11px_rgba(16,185,129,0.3)]'
|
||||||
|
: bActive
|
||||||
|
? 'bg-gradient-to-r from-sky-500 to-sky-600 text-white border-sky-400/40 shadow-[0_0_11px_rgba(14,165,233,0.3)]'
|
||||||
|
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
||||||
|
return (
|
||||||
|
<div key={a.index} className="flex items-center gap-1.5">
|
||||||
|
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} />
|
||||||
|
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
|
||||||
|
{pretty(a.name)}
|
||||||
|
</div>
|
||||||
|
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,10 +36,22 @@ export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
|
|||||||
<MessageSquare className="size-4 text-sky-600" />
|
<MessageSquare className="size-4 text-sky-600" />
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">Chat</span>
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">Chat</span>
|
||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
<Users className="size-3.5 text-muted-foreground" />
|
{/* Online count — hover to see who's connected. */}
|
||||||
<span className="text-[11px] text-muted-foreground" title={online.map((o) => o.operator).join(', ')}>
|
<div className="relative group">
|
||||||
{online.length}
|
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground cursor-default">
|
||||||
|
<Users className="size-3.5" />{online.length}
|
||||||
</span>
|
</span>
|
||||||
|
{online.length > 0 && (
|
||||||
|
<div className="hidden group-hover:block absolute right-0 top-5 z-20 min-w-[130px] rounded-md border border-border bg-popover shadow-lg p-1.5">
|
||||||
|
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mb-1">Online</div>
|
||||||
|
{online.map((o) => (
|
||||||
|
<div key={o.operator} className="font-mono text-[11px] whitespace-nowrap">
|
||||||
|
{o.operator}{o.station && o.station.toUpperCase() !== o.operator.toUpperCase() ? <span className="text-muted-foreground"> · {o.station}</span> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground" title="Close">
|
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground" title="Close">
|
||||||
<X className="size-3.5" />
|
<X className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||||
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
|
||||||
@@ -121,6 +121,7 @@ function statusBadge(s: SpotStatusEntry | undefined): { text: string; fg: string
|
|||||||
switch (s?.status) {
|
switch (s?.status) {
|
||||||
case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' };
|
case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' };
|
||||||
case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' };
|
case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' };
|
||||||
|
case 'new-mode': return { text: 'NEW MODE', fg: '#854d0e', bg: '#fef08a' };
|
||||||
case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' };
|
case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' };
|
||||||
default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null;
|
default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null;
|
||||||
}
|
}
|
||||||
@@ -159,6 +160,7 @@ const COL_CATALOG: ColEntry[] = [
|
|||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
if (s?.status === 'new') return 'NEW DXCC';
|
if (s?.status === 'new') return 'NEW DXCC';
|
||||||
if (s?.status === 'new-band') return 'NEW BAND';
|
if (s?.status === 'new-band') return 'NEW BAND';
|
||||||
|
if (s?.status === 'new-mode') return 'NEW MODE';
|
||||||
if (s?.status === 'new-slot') return 'NEW SLOT';
|
if (s?.status === 'new-slot') return 'NEW SLOT';
|
||||||
return s?.worked_call ? 'WKD CALL' : '';
|
return s?.worked_call ? 'WKD CALL' : '';
|
||||||
},
|
},
|
||||||
@@ -212,12 +214,22 @@ const COL_CATALOG: ColEntry[] = [
|
|||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
||||||
// NEW SLOT (mode not yet worked on this band) → fill the cell.
|
// Fill the mode cell: teal = NEW MODE (mode never worked on this entity),
|
||||||
cellStyle: (p: any) => (statusFor(p)?.status === 'new-slot'
|
// yellow = NEW SLOT (this band+mode combo new, but the mode was worked elsewhere).
|
||||||
? { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 }
|
cellStyle: (p: any) => {
|
||||||
: undefined),
|
const st = statusFor(p)?.status;
|
||||||
|
// Both NEW MODE and NEW SLOT highlight the mode cell (same yellow); the
|
||||||
|
// Status badge text tells them apart.
|
||||||
|
if (st === 'new-mode' || st === 'new-slot') return { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 };
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>,
|
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>,
|
||||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-slot' ? 'NEW SLOT (mode not yet worked on this band)' : undefined),
|
tooltipValueGetter: (p: any) => {
|
||||||
|
const st = statusFor(p)?.status;
|
||||||
|
if (st === 'new-mode') return 'NEW MODE (this mode never worked on this entity)';
|
||||||
|
if (st === 'new-slot') return 'NEW SLOT (this band+mode not yet worked)';
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Pfx', colId: 'pfx',
|
group: 'Spot', label: 'Pfx', colId: 'pfx',
|
||||||
@@ -327,6 +339,14 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
// change below.
|
// change below.
|
||||||
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
|
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
|
||||||
|
|
||||||
|
// Spot statuses arrive asynchronously (~after the rows render). The Call/Band/
|
||||||
|
// Mode cellStyles depend on them but their cell VALUE doesn't change, so ag-grid
|
||||||
|
// won't re-render those cells on its own — force a refresh so e.g. a worked call
|
||||||
|
// turns blue once its status loads.
|
||||||
|
useEffect(() => {
|
||||||
|
gridRef.current?.api?.refreshCells({ force: true });
|
||||||
|
}, [spotStatus]);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
function onGridReady(e: GridReadyEvent) {
|
||||||
const local = loadLocal(COL_STATE_KEY);
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
|
|||||||
@@ -35,8 +35,11 @@ export interface DetailsState {
|
|||||||
sat_name: string;
|
sat_name: string;
|
||||||
sat_mode: string;
|
sat_mode: string;
|
||||||
contest_id: string;
|
contest_id: string;
|
||||||
srx?: number;
|
// Contest exchanges as free text — most are serials (001) but some are
|
||||||
stx?: number;
|
// alphanumeric (e.g. a section/zone like "ON4"). Stored to ADIF SRX/STX when
|
||||||
|
// purely numeric, else to SRX_STRING/STX_STRING (handled in App on save).
|
||||||
|
srx_string?: string;
|
||||||
|
stx_string?: string;
|
||||||
email: string;
|
email: string;
|
||||||
// Award references for the contacted station (set via the Awards tab picker).
|
// Award references for the contacted station (set via the Awards tab picker).
|
||||||
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
|
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
|
||||||
@@ -214,7 +217,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
|
||||||
{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() !== ''} />
|
||||||
@@ -371,10 +374,10 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
<Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} />
|
<Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="SRX">
|
<Field label="SRX">
|
||||||
<Input type="number" value={details.srx ?? ''} onChange={(e) => onChange({ srx: numOrUndef(e.target.value) })} />
|
<Input value={details.srx_string ?? ''} placeholder="rcvd exchange" onChange={(e) => onChange({ srx_string: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="STX">
|
<Field label="STX">
|
||||||
<Input type="number" value={details.stx ?? ''} onChange={(e) => onChange({ stx: numOrUndef(e.target.value) })} />
|
<Input value={details.stx_string ?? ''} placeholder="sent exchange" onChange={(e) => onChange({ stx_string: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Contacted email" span={3}>
|
<Field label="Contacted email" span={3}>
|
||||||
<Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} />
|
<Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} />
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Radio, Zap, Power, AudioLines, Flame, Gauge } from 'lucide-react';
|
import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
||||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||||
FlexMox, FlexAmpOperate,
|
FlexMox, FlexAmpOperate,
|
||||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel,
|
GetPGXLStatus, PGXLSetFanMode,
|
||||||
|
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit,
|
||||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||||
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
||||||
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter,
|
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -18,7 +19,9 @@ type FlexState = {
|
|||||||
proc_enable: boolean; proc_level: number;
|
proc_enable: boolean; proc_level: number;
|
||||||
mon: boolean; mon_level: number; mic_level: number;
|
mon: boolean; mon_level: number; mic_level: number;
|
||||||
atu_status?: string; atu_memories: boolean;
|
atu_status?: string; atu_memories: boolean;
|
||||||
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number;
|
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
|
||||||
|
rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[];
|
||||||
|
split: boolean; rx_freq_hz?: number; tx_freq_hz?: number;
|
||||||
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
|
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
|
||||||
@@ -33,7 +36,7 @@ const ZERO: FlexState = {
|
|||||||
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
|
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
|
||||||
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
|
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
|
||||||
mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
|
mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
|
||||||
rx_avail: false, agc_threshold: 0, audio_level: 0,
|
rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false,
|
||||||
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
|
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
|
||||||
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
|
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
|
||||||
apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
|
apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
|
||||||
@@ -109,8 +112,11 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
|
|||||||
}[accent];
|
}[accent];
|
||||||
return (
|
return (
|
||||||
<button type="button" onClick={onClick} disabled={disabled}
|
<button type="button" onClick={onClick} disabled={disabled}
|
||||||
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors disabled:opacity-30',
|
className={cn(
|
||||||
on ? onCls : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
// min width (not fixed) so a longer label like STONE keeps symmetric
|
||||||
|
// padding instead of overflowing; short labels (NB/NR/APF) stay aligned.
|
||||||
|
'min-w-[3.5rem] shrink-0 px-2.5 py-1 rounded-md text-[11px] font-bold border text-center tracking-wide transition-all disabled:opacity-30',
|
||||||
|
on ? cn(onCls, 'shadow-sm') : 'bg-card text-muted-foreground border-border hover:bg-muted hover:border-muted-foreground/30')}>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -150,14 +156,17 @@ function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, displ
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-[2px] h-2.5 items-stretch">
|
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
|
||||||
|
<div className="flex gap-[2px] h-3 items-stretch rounded-[3px] bg-black/10 p-[2px]">
|
||||||
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
|
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
|
||||||
const on = i < lit;
|
const on = i < lit;
|
||||||
const frac = i / METER_SEGMENTS;
|
const frac = i / METER_SEGMENTS;
|
||||||
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
|
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex-1 rounded-[1.5px] transition-colors duration-100"
|
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
|
||||||
style={on ? { background: col, boxShadow: `0 0 3px ${col}66` } : { background: '#cfc6ad', opacity: 0.45 }} />
|
style={on
|
||||||
|
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
|
||||||
|
: { background: '#cfc6ad', opacity: 0.35 }} />
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -178,7 +187,9 @@ function Card({ icon: Icon, title, accent, children }: { icon: any; title: strin
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FlexPanel() {
|
// onCWSpeed (optional): notified when the operator changes CW speed here, so the
|
||||||
|
// host can keep the WinKeyer (which actually sends the macros) in sync.
|
||||||
|
export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } = {}) {
|
||||||
const [st, setSt] = useState<FlexState>(ZERO);
|
const [st, setSt] = useState<FlexState>(ZERO);
|
||||||
const hold = useRef<Record<string, number>>({});
|
const hold = useRef<Record<string, number>>({});
|
||||||
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
|
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
|
||||||
@@ -212,6 +223,16 @@ export function FlexPanel() {
|
|||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// PowerGenius XL direct connection (fan mode), independent of the Flex link.
|
||||||
|
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string }>({ connected: false });
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 2000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
|
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
|
||||||
hold.current[key] = Date.now() + 900;
|
hold.current[key] = Date.now() + 900;
|
||||||
setSt((p) => ({ ...p, [key]: val }));
|
setSt((p) => ({ ...p, [key]: val }));
|
||||||
@@ -224,7 +245,15 @@ export function FlexPanel() {
|
|||||||
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
|
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
|
||||||
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
|
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
|
||||||
const CW_BW = [100, 200, 300, 400, 500];
|
const CW_BW = [100, 200, 300, 400, 500];
|
||||||
|
const SSB_BW = [1800, 2100, 2400, 2700, 3000, 4000, 6000];
|
||||||
const curBW = Math.max(0, (st.filter_hi || 0) - (st.filter_lo || 0));
|
const curBW = Math.max(0, (st.filter_hi || 0) - (st.filter_lo || 0));
|
||||||
|
// Highlight the preset CLOSEST to the radio's actual filter width. The rig
|
||||||
|
// rarely reports a width that lands exactly on a preset (e.g. 2.7k presets as
|
||||||
|
// 100–2790), so an exact/±50 match would leave nothing lit — "doesn't pick up
|
||||||
|
// the current filter". Snapping to the nearest preset always reflects the rig.
|
||||||
|
const ssbActiveBW = curBW > 0
|
||||||
|
? SSB_BW.reduce((best, bw) => (Math.abs(bw - curBW) < Math.abs(best - curBW) ? bw : best), SSB_BW[0])
|
||||||
|
: -1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full min-h-0 overflow-auto bg-background">
|
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||||
@@ -280,6 +309,21 @@ export function FlexPanel() {
|
|||||||
<Power className="size-4 inline mr-1 -mt-0.5" /> MOX
|
<Power className="size-4 inline mr-1 -mt-0.5" /> MOX
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button" disabled={off}
|
||||||
|
title="Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR."
|
||||||
|
onClick={() => change('split', !st.split, () => FlexSetSplit(!st.split))}
|
||||||
|
className={cn('px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
st.split ? 'bg-sky-600 text-white border-sky-600 shadow-[0_0_12px] shadow-sky-600/50' : 'bg-card text-sky-700 border-sky-400 hover:bg-sky-50')}>
|
||||||
|
SPLIT
|
||||||
|
</button>
|
||||||
|
{st.split && !!st.tx_freq_hz && (
|
||||||
|
<span className="text-[11px] font-mono text-muted-foreground whitespace-nowrap">
|
||||||
|
TX {(st.tx_freq_hz / 1e6).toFixed(3)}
|
||||||
|
{!!st.rx_freq_hz && ` (${(st.tx_freq_hz - st.rx_freq_hz) >= 0 ? '+' : ''}${((st.tx_freq_hz - st.rx_freq_hz) / 1000).toFixed(1)} kHz)`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{!isCW ? (
|
{!isCW ? (
|
||||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
@@ -313,7 +357,7 @@ export function FlexPanel() {
|
|||||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Speed</span>
|
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Speed</span>
|
||||||
<Slider value={st.cw_speed} disabled={off} max={60} accent="#0d9488" onChange={(v) => change('cw_speed', v, () => FlexSetCWSpeed(v))} />
|
<Slider value={st.cw_speed} disabled={off} max={60} accent="#0d9488" onChange={(v) => { change('cw_speed', v, () => FlexSetCWSpeed(v)); onCWSpeed?.(v); }} />
|
||||||
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_speed} wpm</span>
|
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_speed} wpm</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -335,20 +379,46 @@ export function FlexPanel() {
|
|||||||
|
|
||||||
{/* RECEIVE */}
|
{/* RECEIVE */}
|
||||||
<Card icon={AudioLines} title="Receive (active slice)" accent="#0891b2">
|
<Card icon={AudioLines} title="Receive (active slice)" accent="#0891b2">
|
||||||
|
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span>
|
||||||
|
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||||
|
<span className="text-[10px] text-muted-foreground">RX</span>
|
||||||
|
<select disabled={rxOff} value={st.rx_ant ?? ''}
|
||||||
|
onChange={(e) => change('rx_ant', e.target.value, () => FlexSetRXAntenna(e.target.value))}
|
||||||
|
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
|
||||||
|
{(st.ant_list ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
|
||||||
|
</select>
|
||||||
|
<span className="text-[10px] text-muted-foreground">TX</span>
|
||||||
|
<select disabled={rxOff} value={st.tx_ant ?? ''}
|
||||||
|
onChange={(e) => change('tx_ant', e.target.value, () => FlexSetTXAntenna(e.target.value))}
|
||||||
|
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
|
||||||
|
{((st.tx_ant_list?.length ? st.tx_ant_list : st.ant_list) ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
|
||||||
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
|
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
|
||||||
onChange={(v) => change('agc_mode', v, () => FlexSetAGCMode(v))} />
|
onChange={(v) => change('agc_mode', v, () => FlexSetAGCMode(v))} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Thresh</span>
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span>
|
||||||
<Slider value={st.agc_threshold} disabled={rxOff} accent="#64748b" onChange={(v) => change('agc_threshold', v, () => FlexSetAGCThreshold(v))} />
|
<button type="button" disabled={rxOff}
|
||||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
|
title={st.mute ? 'Muted — click to unmute' : 'Mute RX audio'}
|
||||||
|
onClick={() => change('mute', !st.mute, () => FlexSetMute(!st.mute))}
|
||||||
|
className={cn('shrink-0 rounded p-1 transition-colors disabled:opacity-30',
|
||||||
|
st.mute ? 'bg-red-600 text-white' : 'text-muted-foreground hover:bg-muted')}>
|
||||||
|
{st.mute ? <VolumeX className="size-3.5" /> : <Volume2 className="size-3.5" />}
|
||||||
|
</button>
|
||||||
|
<Slider value={st.audio_level} disabled={rxOff || st.mute} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mute ? '—' : st.audio_level}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span>
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC-T</span>
|
||||||
<Slider value={st.audio_level} disabled={rxOff} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} />
|
<Slider value={st.agc_threshold} disabled={rxOff} accent="#64748b" onChange={(v) => change('agc_threshold', v, () => FlexSetAGCThreshold(v))} />
|
||||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.audio_level}</span>
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
|
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
|
||||||
@@ -357,9 +427,12 @@ export function FlexPanel() {
|
|||||||
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
|
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
|
||||||
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
|
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
|
||||||
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
|
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
|
||||||
|
{/* ANF (auto notch) is for carriers in voice — meaningless on a CW tone, so hide it in CW. */}
|
||||||
|
{!isCW && (
|
||||||
<LevelRow label="ANF" on={st.anf} disabled={rxOff} value={st.anf_level} accent="violet" sliderAccent="#7c3aed"
|
<LevelRow label="ANF" on={st.anf} disabled={rxOff} value={st.anf_level} accent="violet" sliderAccent="#7c3aed"
|
||||||
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
|
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
|
||||||
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
|
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isCW && (
|
{isCW && (
|
||||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
@@ -382,6 +455,31 @@ export function FlexPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!isCW && (
|
||||||
|
<div className="border-t border-border/60 pt-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Filter</span>
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
|
{SSB_BW.map((bw) => (
|
||||||
|
<button key={bw} type="button" disabled={rxOff}
|
||||||
|
onClick={() => {
|
||||||
|
const lsb = (st.mode || '').toUpperCase().includes('LSB');
|
||||||
|
let lo: number, hi: number;
|
||||||
|
if (lsb) { const near = (st.filter_hi && st.filter_hi < 0) ? st.filter_hi : -100; hi = near; lo = near - bw; }
|
||||||
|
else { const near = (st.filter_lo && st.filter_lo > 0) ? st.filter_lo : 100; lo = near; hi = near + bw; }
|
||||||
|
setSt((p) => ({ ...p, filter_lo: lo, filter_hi: hi }));
|
||||||
|
FlexSetFilter(lo, hi).catch(() => {});
|
||||||
|
}}
|
||||||
|
className={cn('px-1.5 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
|
||||||
|
bw === ssbActiveBW ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{(bw / 1000).toFixed(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground/70 font-mono">kHz</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -398,12 +496,30 @@ export function FlexPanel() {
|
|||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{st.amp_operate ? 'Amplifier is in line (transmitting through PA).' : 'Amplifier bypassed (standby).'}
|
{st.amp_operate ? 'Amplifier is in line (transmitting through PA).' : 'Amplifier bypassed (standby).'}
|
||||||
</span>
|
</span>
|
||||||
|
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
||||||
|
PowerGenius). The dot shows the direct-connection state; the
|
||||||
|
selector is disabled until connected (hover it for the error). */}
|
||||||
|
{(pg.host || pg.connected) && (
|
||||||
|
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? 'PowerGenius connected' : (pg.last_error || 'PowerGenius offline')}>
|
||||||
|
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-rose-500')} />
|
||||||
|
<span className="text-muted-foreground">Fan</span>
|
||||||
|
<select
|
||||||
|
disabled={!pg.connected}
|
||||||
|
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
|
||||||
|
onChange={(e) => { const v = e.target.value; setPg((s) => ({ ...s, fan_mode: v })); PGXLSetFanMode(v).catch(() => {}); }}
|
||||||
|
className="h-8 rounded-md border border-orange-300 bg-card px-2 text-xs font-semibold text-orange-800 outline-none focus:border-orange-500 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<option value="STANDARD">Standard</option>
|
||||||
|
<option value="CONTEST">Contest</option>
|
||||||
|
<option value="BROADCAST">Broadcast</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{st.amp_fault && st.amp_fault !== 'NONE' && (
|
{st.amp_fault && st.amp_fault !== 'NONE' && (
|
||||||
<span className="px-2 py-1 rounded bg-rose-100 text-rose-800 text-xs font-bold">FAULT: {st.amp_fault}</span>
|
<span className="px-2 py-1 rounded bg-rose-100 text-rose-800 text-xs font-bold">FAULT: {st.amp_fault}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground">Amplifier power / SWR / temperature appear in the Meters panel below (src AMP).</p>
|
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -439,7 +555,7 @@ export function FlexPanel() {
|
|||||||
const cur = [
|
const cur = [
|
||||||
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
|
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
|
||||||
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
|
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
|
||||||
segColor={(fr) => { const sv = fr * 19; return sv < 9 ? '#16a34a' : sv < 14 ? '#f59e0b' : '#dc2626'; }} />
|
segColor={(fr) => { const sv = fr * 19; return sv < 9 ? '#16a34a' : sv < 12.33 ? '#f59e0b' : '#dc2626'; }} />
|
||||||
); })(),
|
); })(),
|
||||||
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
|
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
|
||||||
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
|
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Radio, AudioLines, RefreshCw } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
GetIcomState, IcomRefresh,
|
||||||
|
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
|
||||||
|
IcomSetANF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type IcomState = {
|
||||||
|
available: boolean; model?: string; mode?: string;
|
||||||
|
af_gain: number; rf_gain: number;
|
||||||
|
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean;
|
||||||
|
agc?: string; preamp: number; att: number; filter: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZERO: IcomState = {
|
||||||
|
available: false, af_gain: 0, rf_gain: 0,
|
||||||
|
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false,
|
||||||
|
preamp: 0, att: 0, filter: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
function Slider({ value, onChange, disabled, accent = '#2563eb' }: {
|
||||||
|
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string;
|
||||||
|
}) {
|
||||||
|
const v = Math.max(0, Math.min(100, value));
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="range" min={0} max={100} value={v} disabled={disabled}
|
||||||
|
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||||
|
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
|
||||||
|
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
|
||||||
|
'[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm')}
|
||||||
|
style={{ background: `linear-gradient(to right, ${accent} ${v}%, #d8cfb8 ${v}%)`, borderColor: accent }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Segmented({ value, options, onChange }: {
|
||||||
|
value: string; options: { v: string; l: string }[]; onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
|
||||||
|
{options.map((o) => (
|
||||||
|
<button key={o.v} type="button" onClick={() => onChange(o.v)}
|
||||||
|
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors border-l border-border first:border-l-0',
|
||||||
|
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{o.l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chip({ on, onClick, label }: { on: boolean; onClick: () => void; label: string }) {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick}
|
||||||
|
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||||
|
on ? 'bg-emerald-600 border-emerald-600 text-white' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LevelRow({ label, on, onToggle, value, onLevel }: {
|
||||||
|
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Chip on={on} onClick={onToggle} label={label} />
|
||||||
|
<Slider value={value} disabled={!on} onChange={onLevel} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-3">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-16 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// IcomPanel — receive-DSP control surface for an Icom on the CI-V backend.
|
||||||
|
// Unlike the Flex (which pushes state), the Icom is polled: the cache reflects
|
||||||
|
// the last refresh plus optimistic updates. Front-panel knob changes show after
|
||||||
|
// the next ↻ Refresh.
|
||||||
|
export function IcomPanel() {
|
||||||
|
const [st, setSt] = useState<IcomState>(ZERO);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const load = () => GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {});
|
||||||
|
const refresh = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try { await IcomRefresh(); } catch {}
|
||||||
|
await load();
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
const id = window.setInterval(load, 1500); // cheap cache poll (mode + optimistic state)
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Optimistic local update + fire the command; the cache poll reconciles.
|
||||||
|
const set = (patch: Partial<IcomState>, fn: () => Promise<void>) => {
|
||||||
|
setSt((s) => ({ ...s, ...patch }));
|
||||||
|
fn().catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!st.available) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center">
|
||||||
|
Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto p-3 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-bold">{st.model || 'Icom'}{st.mode ? <span className="ml-2 text-xs font-mono text-muted-foreground">{st.mode}</span> : null}</div>
|
||||||
|
<button type="button" onClick={refresh} disabled={busy}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1 text-xs hover:bg-muted disabled:opacity-40">
|
||||||
|
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card icon={Radio} title="Receive" accent="#2563eb">
|
||||||
|
<Row label="AF">
|
||||||
|
<Slider value={st.af_gain} onChange={(v) => set({ af_gain: v }, () => IcomSetAFGain(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.af_gain}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="RF">
|
||||||
|
<Slider value={st.rf_gain} onChange={(v) => set({ rf_gain: v }, () => IcomSetRFGain(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_gain}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="AGC">
|
||||||
|
<Segmented value={st.agc || ''} options={[{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }]}
|
||||||
|
onChange={(v) => set({ agc: v }, () => IcomSetAGC(v))} />
|
||||||
|
</Row>
|
||||||
|
<Row label="Preamp">
|
||||||
|
<Segmented value={String(st.preamp)} options={[{ v: '0', l: 'OFF' }, { v: '1', l: 'P1' }, { v: '2', l: 'P2' }]}
|
||||||
|
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
|
||||||
|
</Row>
|
||||||
|
<Row label="Att">
|
||||||
|
<Segmented value={String(st.att)} options={[{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }]}
|
||||||
|
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
|
||||||
|
</Row>
|
||||||
|
<Row label="Filter">
|
||||||
|
<Segmented value={String(st.filter)} options={[{ v: '1', l: 'FIL1' }, { v: '2', l: 'FIL2' }, { v: '3', l: 'FIL3' }]}
|
||||||
|
onChange={(v) => set({ filter: parseInt(v) }, () => IcomSetFilter(parseInt(v)))} />
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card icon={AudioLines} title="Noise / Notch" accent="#16a34a">
|
||||||
|
<LevelRow label="NB" on={st.nb} value={st.nb_level}
|
||||||
|
onToggle={() => set({ nb: !st.nb }, () => IcomSetNB(!st.nb))}
|
||||||
|
onLevel={(v) => set({ nb_level: v }, () => IcomSetNBLevel(v))} />
|
||||||
|
<LevelRow label="NR" on={st.nr} value={st.nr_level}
|
||||||
|
onToggle={() => set({ nr: !st.nr }, () => IcomSetNR(!st.nr))}
|
||||||
|
onLevel={(v) => set({ nr_level: v }, () => IcomSetNRLevel(v))} />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Chip label="ANF" on={st.anf} onClick={() => set({ anf: !st.anf }, () => IcomSetANF(!st.anf))} />
|
||||||
|
<span className="text-xs text-muted-foreground">Auto notch filter</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -81,10 +81,11 @@ interface WorldProps {
|
|||||||
beamAzimuths?: number[]; // radiating heading(s) (deg) → draw a beam lobe each
|
beamAzimuths?: number[]; // radiating heading(s) (deg) → draw a beam lobe each
|
||||||
beamWidth?: number; // beamwidth (deg), default 30
|
beamWidth?: number; // beamwidth (deg), default 30
|
||||||
boomAzimuth?: number | null; // mechanical boom (rotor) heading → grey reference line
|
boomAzimuth?: number | null; // mechanical boom (rotor) heading → grey reference line
|
||||||
|
zoomSignal?: number; // bump to force an auto-zoom now (e.g. QRZ lookup finished)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorldMap — great-circle path + beam lobe(s), the "map1" pane.
|
// WorldMap — great-circle path + beam lobe(s), the "map1" pane.
|
||||||
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth }: WorldProps) {
|
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth, zoomSignal }: WorldProps) {
|
||||||
const worldRef = useRef<HTMLDivElement>(null);
|
const worldRef = useRef<HTMLDivElement>(null);
|
||||||
const worldMap = useRef<L.Map | null>(null);
|
const worldMap = useRef<L.Map | null>(null);
|
||||||
const worldOverlay = useRef<L.LayerGroup | null>(null);
|
const worldOverlay = useRef<L.LayerGroup | null>(null);
|
||||||
@@ -139,18 +140,13 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
const from = gridToLatLon(fromGrid);
|
const from = gridToLatLon(fromGrid);
|
||||||
const to = gridToLatLon(toGrid);
|
const to = gridToLatLon(toGrid);
|
||||||
|
|
||||||
if (from && to) {
|
// Station marker + antenna beam/boom are drawn whenever the station grid is
|
||||||
|
// known — independent of any DX. The antenna is always pointed somewhere, so
|
||||||
|
// the beam heading should show even before a callsign is entered.
|
||||||
|
if (from) {
|
||||||
L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' })
|
L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' })
|
||||||
.bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
.bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||||
.addTo(wo);
|
.addTo(wo);
|
||||||
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
|
||||||
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
|
||||||
.addTo(wo);
|
|
||||||
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128);
|
|
||||||
// smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the
|
|
||||||
// line, which makes a smooth arc look angular/bumpy).
|
|
||||||
L.polyline(unwrapLon(pts) as L.LatLngExpression[],
|
|
||||||
{ color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).addTo(wo);
|
|
||||||
|
|
||||||
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
|
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
|
||||||
if (beamAzimuths && beamAzimuths.length) {
|
if (beamAzimuths && beamAzimuths.length) {
|
||||||
@@ -169,21 +165,20 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
for (const az of beamAzimuths) {
|
for (const az of beamAzimuths) {
|
||||||
// Draw the lobe as a FAN of translucent great-circle radials, not a
|
// Draw the lobe as a DENSE fan of translucent great-circle radials, not
|
||||||
// filled polygon: a polygon breaks badly near the poles on Mercator
|
// a filled polygon: a polygon smears badly near the poles on Mercator
|
||||||
// (its edges run off toward ±90° and the fill smears across the map),
|
// (a poleward beam degenerates into a giant triangle), while each radial
|
||||||
// while each radial LINE stays clean. The overlapping lines read as a
|
// LINE stays clean at any azimuth. A small angular step makes the lines
|
||||||
// lobe — solid near the antenna, fanning out toward the front. Works
|
// overlap into a solid-looking lobe (no separate strokes), darker near
|
||||||
// for any azimuth, north/south included.
|
// the antenna and fanning out toward the front.
|
||||||
for (let b = az - half; b <= az + half + 0.001; b += 1.5) {
|
for (let b = az - half; b <= az + half + 0.001; b += 0.5) {
|
||||||
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]);
|
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]);
|
||||||
L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.12, smoothFactor: 0 }).addTo(wo);
|
L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.10, smoothFactor: 0 }).addTo(wo);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
|
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
|
||||||
// Dark casing under the boresight so the bright dashed line stays
|
// Dark casing under the boresight so the bright dashed line stays
|
||||||
// readable on any basemap (esp. dark satellite imagery). Same dashArray
|
// readable on any basemap. Same dashArray so the casing tracks each dash.
|
||||||
// as the red line so the casing tracks each dash — otherwise the wide
|
|
||||||
// casing peeks through the gaps and the line looks bumpy.
|
|
||||||
L.polyline(cl as L.LatLngExpression[], { color: '#000', weight: 4, opacity: 0.4, dashArray: '5 4', smoothFactor: 0 }).addTo(wo);
|
L.polyline(cl as L.LatLngExpression[], { color: '#000', weight: 4, opacity: 0.4, dashArray: '5 4', smoothFactor: 0 }).addTo(wo);
|
||||||
L.polyline(cl as L.LatLngExpression[], { color: '#ff2d2d', weight: 2, opacity: 0.95, dashArray: '5 4', smoothFactor: 0 })
|
L.polyline(cl as L.LatLngExpression[], { color: '#ff2d2d', weight: 2, opacity: 0.95, dashArray: '5 4', smoothFactor: 0 })
|
||||||
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
||||||
@@ -204,20 +199,34 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
L.polyline(unwrapLon(bpts) as L.LatLngExpression[], { color: '#64748b', weight: 1.5, opacity: 0.85, dashArray: '3 4', smoothFactor: 0 })
|
L.polyline(unwrapLon(bpts) as L.LatLngExpression[], { color: '#64748b', weight: 1.5, opacity: 0.85, dashArray: '3 4', smoothFactor: 0 })
|
||||||
.bindTooltip(`Boom ${Math.round(boomAzimuth)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
.bindTooltip(`Boom ${Math.round(boomAzimuth)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DX marker + great-circle arc — only when a DX grid is known (callsign entered).
|
||||||
|
let arcPts: [number, number][] | null = null;
|
||||||
|
if (from && to) {
|
||||||
|
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
||||||
|
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||||
|
.addTo(wo);
|
||||||
|
arcPts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128);
|
||||||
|
// smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the line).
|
||||||
|
L.polyline(unwrapLon(arcPts) as L.LatLngExpression[],
|
||||||
|
{ color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).addTo(wo);
|
||||||
|
}
|
||||||
|
|
||||||
if (autoZoom) {
|
if (autoZoom) {
|
||||||
|
if (from && to && arcPts) {
|
||||||
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
||||||
pts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
arcPts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
||||||
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
||||||
}
|
} else if (to) {
|
||||||
} else if (autoZoom && to) {
|
|
||||||
wm.setView([to.lat, to.lon], 3);
|
wm.setView([to.lat, to.lon], 3);
|
||||||
} else if (autoZoom && from) {
|
} else if (from) {
|
||||||
wm.setView([from.lat, from.lon], 3);
|
wm.setView([from.lat, from.lon], 3);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
setTimeout(() => { wm.invalidateSize(); }, 0);
|
setTimeout(() => { wm.invalidateSize(); }, 0);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom]);
|
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom, zoomSignal]);
|
||||||
|
|
||||||
const path = pathBetween(fromGrid, toGrid);
|
const path = pathBetween(fromGrid, toGrid);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||||
|
type ColDef,
|
||||||
|
} from 'ag-grid-community';
|
||||||
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
|
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||||
|
import type { QSOForm } from '@/types';
|
||||||
|
import {
|
||||||
|
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
|
||||||
|
NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup,
|
||||||
|
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive,
|
||||||
|
} from '@/../wailsjs/go/main/App';
|
||||||
|
import { netctl } from '@/../wailsjs/go/models';
|
||||||
|
|
||||||
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
|
const hamlogTheme = themeQuartz.withParams({
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 12.5,
|
||||||
|
backgroundColor: '#faf6ea',
|
||||||
|
foregroundColor: '#2a2419',
|
||||||
|
headerBackgroundColor: '#e8dfc9',
|
||||||
|
headerTextColor: '#5a4f3a',
|
||||||
|
headerFontWeight: 600,
|
||||||
|
oddRowBackgroundColor: '#f5efe0',
|
||||||
|
rowHoverColor: '#ecdcb4',
|
||||||
|
selectedRowBackgroundColor: '#f0d9a8',
|
||||||
|
borderColor: '#c8b994',
|
||||||
|
rowBorder: { color: '#d8c9a8', width: 1 },
|
||||||
|
columnBorder: { color: '#d8c9a8', width: 1 },
|
||||||
|
cellHorizontalPadding: 10,
|
||||||
|
rowHeight: 30,
|
||||||
|
headerHeight: 32,
|
||||||
|
spacing: 4,
|
||||||
|
accentColor: '#b8410c',
|
||||||
|
iconSize: 12,
|
||||||
|
});
|
||||||
|
|
||||||
|
type Net = netctl.Net;
|
||||||
|
type Station = netctl.Station;
|
||||||
|
|
||||||
|
function fmtTimeOn(s: any): string {
|
||||||
|
if (!s) return '';
|
||||||
|
const d = new Date(s);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onLogged?: () => void;
|
||||||
|
countries?: string[];
|
||||||
|
bands?: string[];
|
||||||
|
modes?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||||
|
const [nets, setNets] = useState<Net[]>([]);
|
||||||
|
const [selId, setSelId] = useState<string>('');
|
||||||
|
const [openId, setOpenId] = useState<string>('');
|
||||||
|
const [roster, setRoster] = useState<Station[]>([]);
|
||||||
|
const [active, setActive] = useState<QSOForm[]>([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
|
||||||
|
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
|
||||||
|
|
||||||
|
// Add/edit-contact dialog.
|
||||||
|
const [contactOpen, setContactOpen] = useState(false);
|
||||||
|
const [contact, setContact] = useState<Station>(emptyStation());
|
||||||
|
const [looking, setLooking] = useState(false);
|
||||||
|
|
||||||
|
const activeGrid = useRef<any>(null);
|
||||||
|
const rosterGrid = useRef<any>(null);
|
||||||
|
|
||||||
|
const isOpen = openId !== '' && openId === selId;
|
||||||
|
|
||||||
|
const refreshNets = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [list, oid] = await Promise.all([NetList(), NetOpenID()]);
|
||||||
|
const arr = (list ?? []) as Net[];
|
||||||
|
setNets(arr);
|
||||||
|
setOpenId(oid ?? '');
|
||||||
|
setSelId((cur) => cur || (oid ?? '') || (arr[0]?.id ?? ''));
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshRoster = useCallback(async (id: string) => {
|
||||||
|
if (!id) { setRoster([]); return; }
|
||||||
|
try { setRoster(((await NetRoster(id)) ?? []) as Station[]); }
|
||||||
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshActive = useCallback(async () => {
|
||||||
|
try { setActive(((await NetActiveList()) ?? []) as unknown as QSOForm[]); }
|
||||||
|
catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { refreshNets(); }, [refreshNets]);
|
||||||
|
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
|
||||||
|
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
|
||||||
|
|
||||||
|
// The roster side hides callsigns currently on the air (they live in the left
|
||||||
|
// grid until logged), mirroring Log4OM's two-list behaviour.
|
||||||
|
const activeCalls = useMemo(
|
||||||
|
() => new Set(active.map((a) => (a.callsign ?? '').toUpperCase())),
|
||||||
|
[active],
|
||||||
|
);
|
||||||
|
const rosterShown = useMemo(
|
||||||
|
() => roster.filter((s) => !activeCalls.has((s.callsign ?? '').toUpperCase())),
|
||||||
|
[roster, activeCalls],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function newNet() {
|
||||||
|
const name = window.prompt('New NET name:');
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
try { const n = await NetCreate(name.trim()); await refreshNets(); setSelId(n.id); }
|
||||||
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function renameNet() {
|
||||||
|
if (!selId) return;
|
||||||
|
const cur = nets.find((n) => n.id === selId);
|
||||||
|
const name = window.prompt('Rename NET:', cur?.name ?? '');
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
try { await NetRename(selId, name.trim()); await refreshNets(); }
|
||||||
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function deleteNet() {
|
||||||
|
if (!selId) return;
|
||||||
|
const cur = nets.find((n) => n.id === selId);
|
||||||
|
if (!window.confirm(`Delete NET "${cur?.name}" and its roster? This cannot be undone.`)) return;
|
||||||
|
try { await NetDelete(selId); setSelId(''); await refreshNets(); }
|
||||||
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function toggleOpen() {
|
||||||
|
try {
|
||||||
|
if (isOpen) {
|
||||||
|
if (active.length > 0 &&
|
||||||
|
!window.confirm(`${active.length} station(s) still on the air will be dropped WITHOUT logging. Close anyway?`)) return;
|
||||||
|
await NetClose(); setOpenId('');
|
||||||
|
} else {
|
||||||
|
await NetOpen(selId); setOpenId(selId); await refreshActive();
|
||||||
|
}
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roster → active (start QSO).
|
||||||
|
async function activate(call: string) {
|
||||||
|
if (!isOpen || !call) return;
|
||||||
|
try { await NetActivate(call); await refreshActive(); }
|
||||||
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
// Active → logged (end QSO, removed from session, written to the logbook).
|
||||||
|
async function deactivate(id?: number) {
|
||||||
|
if (id == null) return;
|
||||||
|
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); }
|
||||||
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit-modal handlers (operate on the in-memory draft, not the DB).
|
||||||
|
async function saveDraft(q: QSOForm) {
|
||||||
|
try { await NetUpdateActive(q as any); setEditingDraft(null); await refreshActive(); }
|
||||||
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function discardDraft(id: number) {
|
||||||
|
try { await NetDiscardActive(id); setEditingDraft(null); await refreshActive(); }
|
||||||
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add-contact dialog.
|
||||||
|
function openAddContact() { setContact(emptyStation()); setContactOpen(true); }
|
||||||
|
async function lookupContact() {
|
||||||
|
const call = (contact.callsign ?? '').trim();
|
||||||
|
if (!call) return;
|
||||||
|
setLooking(true);
|
||||||
|
try {
|
||||||
|
const r = await NetLookup(call);
|
||||||
|
setContact((c) => netctl.Station.createFrom({
|
||||||
|
...c, callsign: (r.callsign || call).toUpperCase(),
|
||||||
|
name: r.name || c.name, qth: r.qth || c.qth, country: r.country || c.country,
|
||||||
|
dxcc: r.dxcc || c.dxcc, itu: r.itu || c.itu, cq: r.cq || c.cq,
|
||||||
|
}));
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
finally { setLooking(false); }
|
||||||
|
}
|
||||||
|
async function saveContact() {
|
||||||
|
const call = (contact.callsign ?? '').trim().toUpperCase();
|
||||||
|
if (!call || !selId) return;
|
||||||
|
try {
|
||||||
|
await NetRosterUpsert(selId, netctl.Station.createFrom({ ...contact, callsign: call }));
|
||||||
|
setContactOpen(false);
|
||||||
|
await refreshRoster(selId);
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function removeSelectedRoster() {
|
||||||
|
const sel = (rosterGrid.current?.api?.getSelectedRows() ?? []) as Station[];
|
||||||
|
if (sel.length === 0) return;
|
||||||
|
if (!window.confirm(`Remove ${sel.length} station(s) from this NET's roster?`)) return;
|
||||||
|
try {
|
||||||
|
for (const s of sel) await NetRosterRemove(selId, s.callsign);
|
||||||
|
await refreshRoster(selId);
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCols = useMemo<ColDef<QSOForm>[]>(() => [
|
||||||
|
{ headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||||
|
{ headerName: 'Name', field: 'name', flex: 1 },
|
||||||
|
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||||
|
{ headerName: 'Time on', valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' },
|
||||||
|
{ headerName: 'Band', field: 'band', width: 70, cellClass: 'font-mono' },
|
||||||
|
{ headerName: 'Mode', field: 'mode', width: 70, cellClass: 'font-mono' },
|
||||||
|
{ headerName: 'RST S', field: 'rst_sent', width: 70, cellClass: 'font-mono' },
|
||||||
|
{ headerName: 'RST R', field: 'rst_rcvd', width: 70, cellClass: 'font-mono' },
|
||||||
|
{ headerName: 'Comment', field: 'comment', flex: 1.5 },
|
||||||
|
], []);
|
||||||
|
|
||||||
|
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
||||||
|
{ headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||||
|
{ headerName: 'Name', field: 'name', flex: 1 },
|
||||||
|
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||||
|
{ headerName: 'Country', field: 'country', width: 130 },
|
||||||
|
], []);
|
||||||
|
|
||||||
|
const defaultColDef = useMemo<ColDef>(() => ({ sortable: true, resizable: true, suppressMovable: false }), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-0 flex-1">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<Button variant="outline" size="sm" className="h-8" onClick={newNet}>
|
||||||
|
<Plus className="size-3.5" /> New NET
|
||||||
|
</Button>
|
||||||
|
<select
|
||||||
|
className="h-8 rounded-md border border-input bg-background px-2 text-sm min-w-[180px]"
|
||||||
|
value={selId}
|
||||||
|
disabled={isOpen}
|
||||||
|
onChange={(e) => setSelId(e.target.value)}
|
||||||
|
title={isOpen ? 'Close the NET to switch' : 'Select a NET'}
|
||||||
|
>
|
||||||
|
<option value="">— select a NET —</option>
|
||||||
|
{nets.map((n) => <option key={n.id} value={n.id}>{n.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<Button variant={isOpen ? 'destructive' : 'default'} size="sm" className="h-8" disabled={!selId} onClick={toggleOpen}>
|
||||||
|
<Radio className="size-3.5" /> {isOpen ? 'Close NET' : 'Open NET'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8" disabled={!selId || isOpen} onClick={renameNet}>Rename</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 text-rose-700" disabled={!selId || isOpen} onClick={deleteNet}>
|
||||||
|
<Trash2 className="size-3.5" /> Delete
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
{isOpen && <Badge className="bg-emerald-600 text-white tracking-wider">NET OPEN</Badge>}
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
On air: <strong className="text-foreground">{active.length}</strong> ·
|
||||||
|
Roster: <strong className="text-foreground">{roster.length}</strong>
|
||||||
|
</span>
|
||||||
|
{error && <Badge variant="destructive" className="max-w-[280px] truncate" title={error}>{error}</Badge>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
{/* ACTIVE USERS (left) */}
|
||||||
|
<div className="flex flex-col min-h-0 flex-1 border-r border-border/60">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-600 text-white text-[11px] font-semibold uppercase tracking-wider">
|
||||||
|
On air — active QSOs
|
||||||
|
<span className="ml-auto font-normal normal-case opacity-90">double-click → edit all fields · "Log & end" to save</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||||
|
<div style={{ position: 'absolute', inset: 0 }}>
|
||||||
|
<AgGridReact<QSOForm>
|
||||||
|
ref={activeGrid}
|
||||||
|
theme={hamlogTheme}
|
||||||
|
rowData={active}
|
||||||
|
columnDefs={activeCols}
|
||||||
|
defaultColDef={defaultColDef}
|
||||||
|
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
|
||||||
|
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
|
||||||
|
animateRows={false}
|
||||||
|
getRowId={(p) => String((p.data as any).id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isOpen && active.length > 0 && (
|
||||||
|
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
||||||
|
onClick={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as QSOForm; if (r) deactivate(r.id as number); }}>
|
||||||
|
<MinusCircle className="size-3.5" /> Log & end selected
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* NET USERS / roster (right) */}
|
||||||
|
<div className="flex flex-col min-h-0 w-[40%] max-w-[560px]">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-emerald-700 text-white text-[11px] font-semibold uppercase tracking-wider">
|
||||||
|
NET users — roster
|
||||||
|
<span className="ml-auto font-normal normal-case opacity-90">double-click → put on air</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||||
|
<div style={{ position: 'absolute', inset: 0 }}>
|
||||||
|
<AgGridReact<Station>
|
||||||
|
ref={rosterGrid}
|
||||||
|
theme={hamlogTheme}
|
||||||
|
rowData={rosterShown}
|
||||||
|
columnDefs={rosterCols}
|
||||||
|
defaultColDef={defaultColDef}
|
||||||
|
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||||
|
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
|
||||||
|
animateRows={false}
|
||||||
|
getRowId={(p) => String((p.data as any).callsign)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" disabled={!selId} onClick={openAddContact}>
|
||||||
|
<UserPlus className="size-3.5" /> Add contact
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px] text-rose-700" disabled={!selId} onClick={removeSelectedRoster}>
|
||||||
|
<MinusCircle className="size-3.5" /> Remove
|
||||||
|
</Button>
|
||||||
|
{isOpen && (
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto"
|
||||||
|
onClick={() => { const r = rosterGrid.current?.api?.getSelectedRows?.()?.[0] as Station; if (r) activate(r.callsign); }}>
|
||||||
|
<PlusCircle className="size-3.5" /> Put selected on air
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full-QSO edit modal for the selected on-air draft. Save writes back to
|
||||||
|
the in-memory draft (NetUpdateActive); Delete cancels it (no log). */}
|
||||||
|
{editingDraft && (
|
||||||
|
<QSOEditModal
|
||||||
|
qso={editingDraft}
|
||||||
|
onSave={saveDraft}
|
||||||
|
onDelete={discardDraft}
|
||||||
|
onClose={() => setEditingDraft(null)}
|
||||||
|
countries={countries}
|
||||||
|
bands={bands}
|
||||||
|
modes={modes}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add / edit contact dialog */}
|
||||||
|
<Dialog open={contactOpen} onOpenChange={setContactOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add contact to NET</DialogTitle>
|
||||||
|
<DialogDescription>Saved in this NET's roster (reused next time you open it).</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-3 px-5 py-2">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-[11px]">Callsign</Label>
|
||||||
|
<Input className="font-mono uppercase" value={contact.callsign ?? ''}
|
||||||
|
onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, callsign: e.target.value.toUpperCase() }))}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') lookupContact(); }} />
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={lookupContact} disabled={looking || !(contact.callsign ?? '').trim()}>
|
||||||
|
<Search className="size-3.5" /> {looking ? '…' : 'Search'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[11px]">Name</Label>
|
||||||
|
<Input value={contact.name ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, name: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[11px]">QTH</Label>
|
||||||
|
<Input value={contact.qth ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, qth: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[11px]">Country</Label>
|
||||||
|
<Input value={contact.country ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, country: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setContactOpen(false)}>Cancel</Button>
|
||||||
|
<Button size="sm" onClick={saveContact} disabled={!(contact.callsign ?? '').trim()}>
|
||||||
|
<PlusCircle className="size-3.5" /> Save in NET
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App';
|
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
@@ -60,6 +61,14 @@ const QSL_STATUSES = [
|
|||||||
{ v: 'I', label: 'Ignore' },
|
{ v: 'I', label: 'Ignore' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// QSL routing methods for the paper-QSL "Via" dropdown (was free text).
|
||||||
|
const QSL_VIA_OPTIONS = [
|
||||||
|
{ v: '_', label: '— leave —' },
|
||||||
|
{ v: 'Bureau', label: 'Bureau' },
|
||||||
|
{ v: 'Direct', label: 'Direct' },
|
||||||
|
{ v: 'Electronic', label: 'Electronic' },
|
||||||
|
];
|
||||||
|
|
||||||
type LogQSO = {
|
type LogQSO = {
|
||||||
id: number; qso_date: string; callsign: string; band: string; mode: string; country?: string;
|
id: number; qso_date: string; callsign: string; band: string; mode: string; country?: string;
|
||||||
qsl_sent?: string; qsl_rcvd?: string; qsl_via?: string; qsl_sent_date?: string; qsl_rcvd_date?: string;
|
qsl_sent?: string; qsl_rcvd?: string; qsl_via?: string; qsl_sent_date?: string; qsl_rcvd_date?: string;
|
||||||
@@ -127,13 +136,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
const [paperCall, setPaperCall] = useState('');
|
const [paperCall, setPaperCall] = useState('');
|
||||||
const [paperRows, setPaperRows] = useState<LogQSO[]>([]);
|
const [paperRows, setPaperRows] = useState<LogQSO[]>([]);
|
||||||
const [paperSel, setPaperSel] = useState<Set<number>>(new Set());
|
const [paperSel, setPaperSel] = useState<Set<number>>(new Set());
|
||||||
|
const [paperSelAllSig, setPaperSelAllSig] = useState(0); // bump → grid selects all
|
||||||
const [paperBusy, setPaperBusy] = useState(false);
|
const [paperBusy, setPaperBusy] = useState(false);
|
||||||
const [paperMsg, setPaperMsg] = useState('');
|
const [paperMsg, setPaperMsg] = useState('');
|
||||||
const [qslRcvd, setQslRcvd] = useState('Y');
|
const [qslRcvd, setQslRcvd] = useState('N');
|
||||||
const [qslSent, setQslSent] = useState('_');
|
const [qslSent, setQslSent] = useState('_');
|
||||||
const [qslRcvdDate, setQslRcvdDate] = useState('');
|
const [qslRcvdDate, setQslRcvdDate] = useState('');
|
||||||
const [qslSentDate, setQslSentDate] = useState('');
|
const [qslSentDate, setQslSentDate] = useState('');
|
||||||
const [qslVia, setQslVia] = useState('');
|
const [qslVia, setQslVia] = useState('_');
|
||||||
|
const [qslNotes, setQslNotes] = useState('');
|
||||||
|
const [qslComment, setQslComment] = useState('');
|
||||||
|
|
||||||
const searchPaper = useCallback(async () => {
|
const searchPaper = useCallback(async () => {
|
||||||
const c = paperCall.trim().toUpperCase();
|
const c = paperCall.trim().toUpperCase();
|
||||||
@@ -144,14 +156,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
const list = (r ?? []) as LogQSO[];
|
const list = (r ?? []) as LogQSO[];
|
||||||
setPaperRows(list);
|
setPaperRows(list);
|
||||||
setPaperSel(new Set(list.map((x) => x.id)));
|
setPaperSel(new Set(list.map((x) => x.id)));
|
||||||
|
setPaperSelAllSig((n) => n + 1); // tell the grid to select every row
|
||||||
|
|
||||||
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); setPaperRows([]); }
|
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); setPaperRows([]); }
|
||||||
finally { setPaperBusy(false); }
|
finally { setPaperBusy(false); }
|
||||||
}, [paperCall]);
|
}, [paperCall]);
|
||||||
|
|
||||||
function togglePaper(id: number) {
|
|
||||||
setPaperSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
|
||||||
}
|
|
||||||
const paperAllSel = paperRows.length > 0 && paperSel.size === paperRows.length;
|
|
||||||
|
|
||||||
async function applyPaper() {
|
async function applyPaper() {
|
||||||
const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id);
|
const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id);
|
||||||
@@ -164,7 +174,9 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
rcvd_status: qslRcvd === '_' ? '' : qslRcvd,
|
rcvd_status: qslRcvd === '_' ? '' : qslRcvd,
|
||||||
sent_date: ymd(qslSentDate),
|
sent_date: ymd(qslSentDate),
|
||||||
rcvd_date: ymd(qslRcvdDate),
|
rcvd_date: ymd(qslRcvdDate),
|
||||||
via: qslVia,
|
via: qslVia === '_' ? '' : qslVia,
|
||||||
|
notes: qslNotes,
|
||||||
|
comment: qslComment,
|
||||||
} as any);
|
} as any);
|
||||||
setPaperMsg(`${n} QSO updated.`);
|
setPaperMsg(`${n} QSO updated.`);
|
||||||
await searchPaper();
|
await searchPaper();
|
||||||
@@ -172,11 +184,11 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
finally { setPaperBusy(false); }
|
finally { setPaperBusy(false); }
|
||||||
}
|
}
|
||||||
const [sent, setSent] = useState('R');
|
const [sent, setSent] = useState('R');
|
||||||
const [rows, setRows] = useState<UploadRow[]>([]);
|
// Full QSO rows (so the upload view uses the same rich grid as Recent QSOs).
|
||||||
// Selection lives in the (virtualized) ag-grid — it handles 25k rows smoothly.
|
const [rows, setRows] = useState<any[]>([]);
|
||||||
const gridRef = useRef<AgGridReact<UploadRow>>(null);
|
|
||||||
const [selectedCount, setSelectedCount] = useState(0);
|
const [selectedCount, setSelectedCount] = useState(0);
|
||||||
const selectAllNext = useRef(false); // selectAll once after the next data load
|
const [uploadSelIds, setUploadSelIds] = useState<number[]>([]); // selected QSO ids → upload
|
||||||
|
const [uploadSelAllSig, setUploadSelAllSig] = useState(0); // bump → grid selects all
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [addNotFound, setAddNotFound] = useState(false);
|
const [addNotFound, setAddNotFound] = useState(false);
|
||||||
@@ -210,15 +222,9 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
||||||
|
|
||||||
// Grid selection → just track the count; ids are read from the grid at upload.
|
// Grid selection → just track the count; ids are read from the grid at upload.
|
||||||
function onUploadSelChanged() {
|
function onUploadRowSelected(ids: number[]) {
|
||||||
setSelectedCount(gridRef.current?.api?.getSelectedNodes()?.length ?? 0);
|
setUploadSelIds(ids);
|
||||||
}
|
setSelectedCount(ids.length);
|
||||||
// After "Select required" loads new rows, select them all (the old default).
|
|
||||||
function onUploadRowsLoaded() {
|
|
||||||
if (selectAllNext.current) {
|
|
||||||
selectAllNext.current = false;
|
|
||||||
gridRef.current?.api?.selectAll();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
||||||
@@ -236,10 +242,11 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
|
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
|
||||||
const list = (r ?? []) as UploadRow[];
|
const list = (r ?? []) as any[];
|
||||||
selectAllNext.current = true; // pre-select everything once the grid renders
|
|
||||||
setRows(list);
|
setRows(list);
|
||||||
|
setUploadSelIds(list.map((x) => x.id));
|
||||||
setSelectedCount(list.length);
|
setSelectedCount(list.length);
|
||||||
|
setUploadSelAllSig((n) => n + 1); // pre-select everything once the grid renders
|
||||||
setViewMode('upload');
|
setViewMode('upload');
|
||||||
setShowLog(false);
|
setShowLog(false);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -252,7 +259,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
}, [service, sent]);
|
}, [service, sent]);
|
||||||
|
|
||||||
async function upload() {
|
async function upload() {
|
||||||
const ids = ((gridRef.current?.api?.getSelectedRows() as UploadRow[] | undefined) ?? []).map((r) => r.id);
|
const ids = uploadSelIds;
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
|
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
|
||||||
try { await UploadQSOsManual(service, ids); }
|
try { await UploadQSOsManual(service, ids); }
|
||||||
@@ -283,7 +290,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
|
||||||
<Select value={service} onValueChange={setService}>
|
<Select value={service} onValueChange={setService}>
|
||||||
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
<SelectContent>{[...SERVICES].sort((a, b) => a.label.localeCompare(b.label)).map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{uploadCall && (
|
{uploadCall && (
|
||||||
@@ -387,32 +394,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
paperRows.length === 0 ? (
|
paperRows.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground py-10 text-center">Search a callsign to list its QSOs, then set QSL status below.</div>
|
<div className="text-sm text-muted-foreground py-10 text-center">Search a callsign to list its QSOs, then set QSL status below.</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-xs border-collapse">
|
// Same grid as Recent QSOs (full columns + column picker). Selection
|
||||||
<thead className="sticky top-0 bg-card">
|
// drives which QSOs the apply-form below updates; a search selects all.
|
||||||
<tr className="text-left text-muted-foreground border-b border-border">
|
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
||||||
<th className="py-1.5 px-2 w-8"><Checkbox checked={paperAllSel} onCheckedChange={() => setPaperSel(paperAllSel ? new Set() : new Set(paperRows.map((r) => r.id)))} /></th>
|
<RecentQSOsGrid
|
||||||
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
|
rows={paperRows as any}
|
||||||
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
|
total={paperRows.length}
|
||||||
<th className="py-1.5 px-2">QSL Sent</th><th className="py-1.5 px-2">QSL Rcvd</th><th className="py-1.5 px-2">Via</th>
|
selectAllSignal={paperSelAllSig}
|
||||||
</tr>
|
onRowSelected={(ids) => setPaperSel(new Set(ids))}
|
||||||
</thead>
|
/>
|
||||||
<tbody>
|
</div>
|
||||||
{paperRows.map((r) => (
|
|
||||||
<tr key={r.id}
|
|
||||||
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', paperSel.has(r.id) && 'bg-primary/5')}
|
|
||||||
onClick={() => togglePaper(r.id)}>
|
|
||||||
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}><Checkbox checked={paperSel.has(r.id)} onCheckedChange={() => togglePaper(r.id)} /></td>
|
|
||||||
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
|
|
||||||
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
|
|
||||||
<td className="py-1 px-2">{r.band}</td>
|
|
||||||
<td className="py-1 px-2">{r.mode}</td>
|
|
||||||
<td className="py-1 px-2 font-mono">{r.qsl_sent || '—'}{r.qsl_sent_date ? ` ${fmtQslDate(r.qsl_sent_date)}` : ''}</td>
|
|
||||||
<td className="py-1 px-2 font-mono">{r.qsl_rcvd || '—'}{r.qsl_rcvd_date ? ` ${fmtQslDate(r.qsl_rcvd_date)}` : ''}</td>
|
|
||||||
<td className="py-1 px-2 text-muted-foreground truncate max-w-[160px]">{r.qsl_via}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)
|
)
|
||||||
) : service === 'pota' ? (
|
) : service === 'pota' ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -509,19 +500,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then “Select required”.</div>
|
<div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then “Select required”.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full w-full">
|
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
||||||
<AgGridReact<UploadRow>
|
<RecentQSOsGrid
|
||||||
ref={gridRef}
|
rows={rows as any}
|
||||||
theme={qslTheme}
|
total={rows.length}
|
||||||
rowData={rows}
|
selectAllSignal={uploadSelAllSig}
|
||||||
columnDefs={UPLOAD_COLS}
|
onRowSelected={onUploadRowSelected}
|
||||||
defaultColDef={{ sortable: true, resizable: true, filter: true }}
|
|
||||||
rowSelection={{ mode: 'multiRow', checkboxes: true, headerCheckbox: true }}
|
|
||||||
onSelectionChanged={onUploadSelChanged}
|
|
||||||
onRowDataUpdated={onUploadRowsLoaded}
|
|
||||||
animateRows={false}
|
|
||||||
suppressCellFocus
|
|
||||||
getRowId={(p) => String((p.data as any).id)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -552,7 +536,18 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Via</label>
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Via</label>
|
||||||
<Input className="h-8 w-40" value={qslVia} onChange={(e) => setQslVia(e.target.value)} placeholder="BUREAU / DIRECT / manager" />
|
<Select value={qslVia} onValueChange={setQslVia}>
|
||||||
|
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{o.label}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Notes</label>
|
||||||
|
<Input className="h-8 w-40" value={qslNotes} onChange={(e) => setQslNotes(e.target.value)} placeholder="e.g. paid 3€" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Comment</label>
|
||||||
|
<Input className="h-8 w-40" value={qslComment} onChange={(e) => setQslComment(e.target.value)} placeholder="comment" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{paperMsg && <span className="text-[11px] text-muted-foreground self-center">{paperMsg}</span>}
|
{paperMsg && <span className="text-[11px] text-muted-foreground self-center">{paperMsg}</span>}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine } from 'lucide-react';
|
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ type Props = {
|
|||||||
onBulkEdit?: (ids: number[]) => void;
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
|
onDelete?: (ids: number[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
||||||
@@ -27,20 +28,19 @@ const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
|||||||
|
|
||||||
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
||||||
// menu is an Enterprise feature, so this is a plain floating menu driven by
|
// menu is an Enterprise feature, so this is a plain floating menu driven by
|
||||||
// onCellContextMenu. Closes on any outside click, scroll or Escape.
|
// onCellContextMenu. Stays open until the user clicks outside, presses Escape,
|
||||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered }: Props) {
|
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
||||||
|
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
||||||
|
// which used to dismiss the menu the instant it appeared.)
|
||||||
|
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete }: Props) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
const close = () => onClose();
|
const close = () => onClose();
|
||||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
window.addEventListener('mousedown', close);
|
window.addEventListener('mousedown', close);
|
||||||
window.addEventListener('scroll', close, true);
|
|
||||||
window.addEventListener('resize', close);
|
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('mousedown', close);
|
window.removeEventListener('mousedown', close);
|
||||||
window.removeEventListener('scroll', close, true);
|
|
||||||
window.removeEventListener('resize', close);
|
|
||||||
window.removeEventListener('keydown', onKey);
|
window.removeEventListener('keydown', onKey);
|
||||||
};
|
};
|
||||||
}, [menu, onClose]);
|
}, [menu, onClose]);
|
||||||
@@ -160,6 +160,19 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onDelete && (
|
||||||
|
<>
|
||||||
|
<div className="my-1 border-t border-border" />
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-rose-700 hover:bg-rose-50"
|
||||||
|
onClick={() => { onDelete(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
<span>Delete {n} QSO{n > 1 ? 's' : ''}…</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,6 +215,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]);
|
}, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]);
|
||||||
|
|
||||||
|
// Contest exchange typed as free text → numeric SRX/STX when all-digits, else
|
||||||
|
// the SRX_STRING/STX_STRING field. Mirrors the entry strip (F5) so the field
|
||||||
|
// accepts letters (sections/zones), not just numbers.
|
||||||
|
function setExchange(which: 'srx' | 'stx', raw: string) {
|
||||||
|
const t = raw.trim();
|
||||||
|
const num = /^\d+$/.test(t) ? parseInt(t, 10) : undefined;
|
||||||
|
setDraft((d) => ({ ...d, [which]: num, [`${which}_string`]: num != null ? '' : t } as any));
|
||||||
|
}
|
||||||
function set<K extends keyof QSO>(key: K, value: QSO[K]) {
|
function set<K extends keyof QSO>(key: K, value: QSO[K]) {
|
||||||
setDraft((d) => ({ ...d, [key]: value }));
|
setDraft((d) => ({ ...d, [key]: value }));
|
||||||
}
|
}
|
||||||
@@ -561,7 +569,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{CONFIRMATIONS.map((c) => (
|
{CONFIRMATIONS.map((c) => (
|
||||||
<tr key={c.key} className={cn('text-xs', c.key === confSel && 'bg-accent/40')}>
|
<tr key={c.key} className="text-xs">
|
||||||
<td className="font-medium pr-2 py-0.5">{c.label}</td>
|
<td className="font-medium pr-2 py-0.5">{c.label}</td>
|
||||||
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
|
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
|
||||||
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
||||||
@@ -578,10 +586,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<TabsContent value="contest" className="mt-0">
|
<TabsContent value="contest" className="mt-0">
|
||||||
<div className="grid grid-cols-6 gap-3">
|
<div className="grid grid-cols-6 gap-3">
|
||||||
<F label="Contest ID" span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F>
|
<F label="Contest ID" span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F>
|
||||||
<F label="SRX"><Input type="number" value={draft.srx ?? ''} onChange={(e) => set('srx', intOrUndef(e.target.value) as any)} /></F>
|
<F label="SRX" span={2}><Input value={draft.srx_string || (draft.srx ?? '')} placeholder="rcvd exchange" onChange={(e) => setExchange('srx', e.target.value)} /></F>
|
||||||
<F label="STX"><Input type="number" value={draft.stx ?? ''} onChange={(e) => set('stx', intOrUndef(e.target.value) as any)} /></F>
|
<F label="STX" span={2}><Input value={draft.stx_string || (draft.stx ?? '')} placeholder="sent exchange" onChange={(e) => setExchange('stx', e.target.value)} /></F>
|
||||||
<F label="SRX string" span={3}><Input value={draft.srx_string ?? ''} onChange={(e) => set('srx_string', e.target.value)} /></F>
|
|
||||||
<F label="STX string" span={3}><Input value={draft.stx_string ?? ''} onChange={(e) => set('stx_string', e.target.value)} /></F>
|
|
||||||
<F label="Check"><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
|
<F label="Check"><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
|
||||||
<F label="Precedence"><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F>
|
<F label="Precedence"><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F>
|
||||||
<F label="ARRL section"><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F>
|
<F label="ARRL section"><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F>
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ const hamlogTheme = themeQuartz.withParams({
|
|||||||
type Props = {
|
type Props = {
|
||||||
rows: QSOForm[];
|
rows: QSOForm[];
|
||||||
total: number;
|
total: number;
|
||||||
|
// Bump this number to programmatically select every row (e.g. after a search
|
||||||
|
// in the QSL Manager, where the default is "all selected").
|
||||||
|
selectAllSignal?: number;
|
||||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||||
onRowSelected?: (ids: number[]) => void;
|
onRowSelected?: (ids: number[]) => void;
|
||||||
onUpdateFromCty?: (ids: number[]) => void;
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
@@ -56,6 +59,7 @@ type Props = {
|
|||||||
onBulkEdit?: (ids: number[]) => void;
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
|
onDelete?: (ids: number[]) => void;
|
||||||
// One column per defined award; the cell shows the reference this QSO counts
|
// One column per defined award; the cell shows the reference this QSO counts
|
||||||
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
||||||
awardCols?: { code: string; name: string }[];
|
awardCols?: { code: string; name: string }[];
|
||||||
@@ -156,6 +160,8 @@ export const COL_CATALOG: ColEntry[] = [
|
|||||||
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
|
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
|
||||||
{ group: 'QSL', label: 'OpsLog QSL', colId: 'opslog_qsl_card_sent', headerName: 'OpsLog QSL', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
{ group: 'QSL', label: 'OpsLog QSL', colId: 'opslog_qsl_card_sent', headerName: 'OpsLog QSL', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||||
|
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
||||||
|
{ group: 'QSL', label: 'Recording sent', colId: 'opslog_recording_sent', headerName: 'Rec sent', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||||
|
|
||||||
// ── Uploads (online logbooks) ──
|
// ── Uploads (online logbooks) ──
|
||||||
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
||||||
@@ -222,7 +228,7 @@ export const GROUP_ORDER = [
|
|||||||
'Contest', 'Propagation', 'My station', 'Misc',
|
'Contest', 'Propagation', 'My station', 'Misc',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, awardCols }: Props) {
|
export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete, awardCols }: Props) {
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
@@ -248,7 +254,14 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
|||||||
// Compute initial column defs: all columns defined, but those not marked
|
// Compute initial column defs: all columns defined, but those not marked
|
||||||
// defaultVisible start hidden. The user's saved state (loaded onGridReady)
|
// defaultVisible start hidden. The user's saved state (loaded onGridReady)
|
||||||
// overrides this so a previously toggled column wins.
|
// overrides this so a previously toggled column wins.
|
||||||
|
// While AG Grid rebuilds columns (award columns load async), it fires column
|
||||||
|
// events that would otherwise trigger a save of the DEFAULT visibility before
|
||||||
|
// we re-apply the user's saved state — clobbering it. This flag suppresses the
|
||||||
|
// auto-save during that window. Set in the memo (runs at render, before the
|
||||||
|
// column events) and cleared by the re-apply effect below.
|
||||||
|
const restoringRef = useRef(true);
|
||||||
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
||||||
|
restoringRef.current = true;
|
||||||
const base = COL_CATALOG.map((c) => {
|
const base = COL_CATALOG.map((c) => {
|
||||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||||
return { ...rest, hide: !defaultVisible };
|
return { ...rest, hide: !defaultVisible };
|
||||||
@@ -284,6 +297,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
|
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||||
const state = gridRef.current?.api?.getColumnState();
|
const state = gridRef.current?.api?.getColumnState();
|
||||||
if (state) saveState(COL_STATE_KEY, state);
|
if (state) saveState(COL_STATE_KEY, state);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -295,9 +309,11 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
|||||||
// every rebuild so the user's choices win. No-op before the grid is ready.
|
// every rebuild so the user's choices win. No-op before the grid is ready.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = gridRef.current?.api;
|
const api = gridRef.current?.api;
|
||||||
if (!api) return;
|
|
||||||
const local = loadLocal(COL_STATE_KEY);
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
if (local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
|
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
||||||
|
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
}, [awardCols]);
|
}, [awardCols]);
|
||||||
|
|
||||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
||||||
@@ -307,6 +323,11 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
|||||||
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
||||||
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
||||||
}
|
}
|
||||||
|
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectAllSignal === undefined) return;
|
||||||
|
gridRef.current?.api?.selectAll();
|
||||||
|
}, [selectAllSignal]);
|
||||||
|
|
||||||
// ── Column picker (visibility) ──
|
// ── Column picker (visibility) ──
|
||||||
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
|
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
|
||||||
@@ -395,6 +416,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
|||||||
onBulkEdit={onBulkEdit}
|
onBulkEdit={onBulkEdit}
|
||||||
onExportSelected={onExportSelected}
|
onExportSelected={onExportSelected}
|
||||||
onExportFiltered={onExportFiltered}
|
onExportFiltered={onExportFiltered}
|
||||||
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
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,
|
||||||
|
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||||
|
GetPGXLSettings, SavePGXLSettings,
|
||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||||
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||||
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
||||||
@@ -36,6 +38,7 @@ import {
|
|||||||
TestLoTWUpload, ListTQSLStationLocations,
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
ComputeStationInfo,
|
ComputeStationInfo,
|
||||||
GetUIPref, SetUIPref,
|
GetUIPref, SetUIPref,
|
||||||
|
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
@@ -130,8 +133,10 @@ const emptyProfile = (): Profile => ({
|
|||||||
is_active: false,
|
is_active: false,
|
||||||
sort_order: 0,
|
sort_order: 0,
|
||||||
db: { backend: '', host: '', port: 3306, user: '', password: '', database: '' },
|
db: { backend: '', host: '', port: 3306, user: '', password: '', database: '' },
|
||||||
created_at: '' as any,
|
// Server-managed timestamps — send null (NOT "") so Go's time.Time unmarshal
|
||||||
updated_at: '' as any,
|
// leaves the zero value instead of failing to parse an empty RFC3339 string.
|
||||||
|
created_at: null as any,
|
||||||
|
updated_at: null as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -170,13 +175,29 @@ type SectionId =
|
|||||||
| 'rotator'
|
| 'rotator'
|
||||||
| 'winkeyer'
|
| 'winkeyer'
|
||||||
| 'antenna'
|
| 'antenna'
|
||||||
|
| 'antgenius'
|
||||||
|
| 'pgxl'
|
||||||
|
| 'flex'
|
||||||
| 'audio';
|
| 'audio';
|
||||||
|
|
||||||
type TreeNode =
|
type TreeNode =
|
||||||
| { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] }
|
| { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] }
|
||||||
| { kind: 'item'; label: string; id: SectionId; disabled?: boolean };
|
| { kind: 'item'; label: string; id: SectionId; disabled?: boolean };
|
||||||
|
|
||||||
const TREE: TreeNode[] = [
|
// buildTree returns the settings sidebar. The FlexRadio item only appears when
|
||||||
|
// the active CAT backend is a Flex (per-band antenna config is Flex-specific).
|
||||||
|
function buildTree(flexAvailable: boolean): TreeNode[] {
|
||||||
|
const hardware: TreeNode[] = [
|
||||||
|
{ kind: 'item', label: 'CAT interface', id: 'cat' },
|
||||||
|
{ kind: 'item', label: 'PstRotator', id: 'rotator' },
|
||||||
|
{ kind: 'item', label: 'CW Keyer', id: 'winkeyer' },
|
||||||
|
{ kind: 'item', label: 'UltraBeam', id: 'antenna' },
|
||||||
|
{ kind: 'item', label: 'Antenna Genius', id: 'antgenius' },
|
||||||
|
{ kind: 'item', label: 'Power Genius', id: 'pgxl' },
|
||||||
|
...(flexAvailable ? [{ kind: 'item', label: 'FlexRadio', id: 'flex' } as TreeNode] : []),
|
||||||
|
{ kind: 'item', label: 'Audio devices', id: 'audio' },
|
||||||
|
];
|
||||||
|
return [
|
||||||
{
|
{
|
||||||
kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [
|
kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [
|
||||||
{ kind: 'item', label: 'Station Information', id: 'station' },
|
{ kind: 'item', label: 'Station Information', id: 'station' },
|
||||||
@@ -202,15 +223,10 @@ const TREE: TreeNode[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [
|
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: hardware,
|
||||||
{ kind: 'item', label: 'CAT interface', id: 'cat' },
|
|
||||||
{ kind: 'item', label: 'Rotator', id: 'rotator' },
|
|
||||||
{ kind: 'item', label: 'CW Keyer', id: 'winkeyer' },
|
|
||||||
{ kind: 'item', label: 'Antenna', id: 'antenna' },
|
|
||||||
{ kind: 'item', label: 'Audio devices', id: 'audio' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// Map section id → friendly name (used in breadcrumb / placeholders).
|
// Map section id → friendly name (used in breadcrumb / placeholders).
|
||||||
const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
||||||
@@ -229,9 +245,12 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
|||||||
udp: 'UDP integrations',
|
udp: 'UDP integrations',
|
||||||
awards: 'Awards',
|
awards: 'Awards',
|
||||||
cat: 'CAT interface',
|
cat: 'CAT interface',
|
||||||
rotator: 'Rotator',
|
rotator: 'PstRotator',
|
||||||
winkeyer: 'CW Keyer',
|
winkeyer: 'CW Keyer',
|
||||||
antenna: 'Antenna',
|
antenna: 'UltraBeam',
|
||||||
|
antgenius: 'Antenna Genius',
|
||||||
|
pgxl: 'Power Genius',
|
||||||
|
flex: 'FlexRadio',
|
||||||
audio: 'Audio devices',
|
audio: 'Audio devices',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -240,12 +259,13 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
|||||||
interface TreeProps {
|
interface TreeProps {
|
||||||
selected: SectionId;
|
selected: SectionId;
|
||||||
onSelect: (id: SectionId) => void;
|
onSelect: (id: SectionId) => void;
|
||||||
|
flexAvailable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Tree({ selected, onSelect }: TreeProps) {
|
function Tree({ selected, onSelect, flexAvailable }: TreeProps) {
|
||||||
return (
|
return (
|
||||||
<nav className="text-sm">
|
<nav className="text-sm">
|
||||||
{TREE.map((node, i) => (
|
{buildTree(!!flexAvailable).map((node, i) => (
|
||||||
<TreeNodeView key={i} node={node} depth={0} selected={selected} onSelect={onSelect} />
|
<TreeNodeView key={i} node={node} depth={0} selected={selected} onSelect={onSelect} />
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -477,14 +497,16 @@ const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
|
|||||||
{ value: 'map2', label: 'Map — locator (street)' },
|
{ value: 'map2', label: 'Map — locator (street)' },
|
||||||
{ value: 'cluster', label: 'Cluster spots' },
|
{ value: 'cluster', label: 'Cluster spots' },
|
||||||
{ value: 'worked', label: 'Worked before' },
|
{ value: 'worked', label: 'Worked before' },
|
||||||
|
{ value: 'recent', label: 'Recent QSOs' },
|
||||||
];
|
];
|
||||||
function MainViewPanes({ onChanged, flexAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean }) {
|
function MainViewPanes({ onChanged, flexAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean }) {
|
||||||
const [left, setLeft] = useState('map1');
|
const [left, setLeft] = useState('map1');
|
||||||
const [right, setRight] = useState('map2');
|
const [right, setRight] = useState('map2');
|
||||||
// FlexRadio is only offered when the CAT backend is a Flex.
|
// FlexRadio is only offered when the CAT backend is a Flex. Sorted A→Z.
|
||||||
const options = flexAvailable
|
const options = (flexAvailable
|
||||||
? [...MAIN_PANE_OPTIONS, { value: 'flex', label: 'FlexRadio controls' }]
|
? [...MAIN_PANE_OPTIONS, { value: 'flex', label: 'FlexRadio controls' }]
|
||||||
: MAIN_PANE_OPTIONS;
|
: [...MAIN_PANE_OPTIONS]
|
||||||
|
).sort((a, b) => a.label.localeCompare(b.label));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const valid = (v: string) => v === 'flex' || MAIN_PANE_OPTIONS.some((o) => o.value === v);
|
const valid = (v: string) => v === 'flex' || MAIN_PANE_OPTIONS.some((o) => o.value === v);
|
||||||
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
|
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
|
||||||
@@ -575,6 +597,90 @@ function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FlexBandAntennasPanel — pick the RX/TX antenna per band. Applied automatically
|
||||||
|
// when the band changes (frequency change / spot click). Antennas come live from
|
||||||
|
// the connected FlexRadio; bands come from Lists → Bands.
|
||||||
|
function FlexBandAntennasPanel({ bands }: { bands: string[] }) {
|
||||||
|
const [rxList, setRxList] = useState<string[]>([]);
|
||||||
|
const [txList, setTxList] = useState<string[]>([]);
|
||||||
|
const [map, setMap] = useState<Record<string, { rx: string; tx: string }>>({});
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
useEffect(() => {
|
||||||
|
GetFlexState().then((s: any) => {
|
||||||
|
setRxList((s?.ant_list ?? []) as string[]);
|
||||||
|
setTxList(((s?.tx_ant_list?.length ? s.tx_ant_list : s?.ant_list) ?? []) as string[]);
|
||||||
|
}).catch(() => {});
|
||||||
|
GetFlexBandAntennas().then((m: any) => setMap(m ?? {})).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
const set = (band: string, side: 'rx' | 'tx', v: string) => {
|
||||||
|
const key = band.toUpperCase();
|
||||||
|
setMap((m) => {
|
||||||
|
const cur = m[key] ?? { rx: '', tx: '' };
|
||||||
|
const next = { ...m, [key]: { ...cur, [side]: v } };
|
||||||
|
SaveFlexBandAntennas(next as any)
|
||||||
|
.then(() => { setMsg('Saved'); window.setTimeout(() => setMsg(''), 1200); })
|
||||||
|
.catch((e: any) => setMsg(String(e?.message ?? e)));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-foreground">FlexRadio — per-band antennas</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Choose the RX and TX antenna for each band. They're applied automatically when the band
|
||||||
|
changes (frequency change or clicking a spot).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{rxList.length === 0 && (
|
||||||
|
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-md px-3 py-2 max-w-xl">
|
||||||
|
No antennas reported yet — make sure the FlexRadio is connected (CAT interface), then reopen this panel.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bands.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">No bands configured — add them in Lists → Bands.</p>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-border overflow-hidden max-w-xl">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/40 text-muted-foreground text-xs">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-3 py-1.5 font-semibold">Band</th>
|
||||||
|
<th className="text-left px-3 py-1.5 font-semibold">RX antenna</th>
|
||||||
|
<th className="text-left px-3 py-1.5 font-semibold">TX antenna</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{bands.map((b) => {
|
||||||
|
const e = map[b.toUpperCase()] ?? { rx: '', tx: '' };
|
||||||
|
return (
|
||||||
|
<tr key={b} className="border-t border-border/50">
|
||||||
|
<td className="px-3 py-1.5 font-mono font-semibold">{b}</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<select value={e.rx ?? ''} onChange={(ev) => set(b, 'rx', ev.target.value)}
|
||||||
|
className="h-8 w-32 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
|
||||||
|
<option value="">— none —</option>
|
||||||
|
{rxList.map((a) => <option key={a} value={a}>{a}</option>)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<select value={e.tx ?? ''} onChange={(ev) => set(b, 'tx', ev.target.value)}
|
||||||
|
className="h-8 w-32 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
|
||||||
|
<option value="">— none —</option>
|
||||||
|
{txList.map((a) => <option key={a} value={a}>{a}</option>)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msg && <span className="text-[11px] text-muted-foreground">{msg}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable }: Props) {
|
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable }: Props) {
|
||||||
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
|
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -610,7 +716,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
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, flex_host: '', flex_port: 4992, flex_spots: false, poll_ms: 250, delay_ms: 0,
|
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false,
|
||||||
|
icom_port: '', icom_baud: 115200, icom_addr: 0x98, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
||||||
digital_default: 'FT8',
|
digital_default: 'FT8',
|
||||||
});
|
});
|
||||||
const [rotator, setRotator] = useState<RotatorSettings>({
|
const [rotator, setRotator] = useState<RotatorSettings>({
|
||||||
@@ -626,6 +733,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [ubTesting, setUbTesting] = useState(false);
|
const [ubTesting, setUbTesting] = useState(false);
|
||||||
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
|
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
|
||||||
|
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string }>({ enabled: false, host: '' });
|
||||||
|
|
||||||
|
// PowerGenius XL (4O3A) amp fan-control settings.
|
||||||
|
const [pgxl, setPgxl] = useState<{ enabled: boolean; host: string; port: number }>({ enabled: false, host: '', port: 9008 });
|
||||||
|
|
||||||
// WinKeyer CW keyer settings + macro editor.
|
// WinKeyer CW keyer settings + macro editor.
|
||||||
type WKMac = { label: string; text: string };
|
type WKMac = { label: string; text: string };
|
||||||
type WKSettings = {
|
type WKSettings = {
|
||||||
@@ -883,6 +996,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
setCatCfg(c);
|
setCatCfg(c);
|
||||||
setRotator(r);
|
setRotator(r);
|
||||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
|
try { setPgxl(await GetPGXLSettings() as any); } catch {}
|
||||||
setBackupCfg(b as any);
|
setBackupCfg(b as any);
|
||||||
setQslDefaults(qd as any);
|
setQslDefaults(qd as any);
|
||||||
setExtSvc(es as any);
|
setExtSvc(es as any);
|
||||||
@@ -922,6 +1037,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
try { setCatCfg(await GetCATSettings() as any); } catch {}
|
try { setCatCfg(await GetCATSettings() as any); } catch {}
|
||||||
try { setRotator(await GetRotatorSettings() as any); } catch {}
|
try { setRotator(await GetRotatorSettings() as any); } catch {}
|
||||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
|
try { setPgxl(await GetPGXLSettings() as any); } catch {}
|
||||||
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
||||||
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
||||||
try { setExtSvc(await GetExternalServices() as any); } catch {}
|
try { setExtSvc(await GetExternalServices() as any); } catch {}
|
||||||
@@ -1089,6 +1206,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
await SaveCATSettings(catCfg as any);
|
await SaveCATSettings(catCfg as any);
|
||||||
await SaveRotatorSettings(rotator as any);
|
await SaveRotatorSettings(rotator as any);
|
||||||
await SaveUltrabeamSettings(ultrabeam as any);
|
await SaveUltrabeamSettings(ultrabeam as any);
|
||||||
|
await SaveAntGeniusSettings(antgenius as any);
|
||||||
|
await SavePGXLSettings(pgxl as any);
|
||||||
await SaveWinkeyerSettings(wk as any);
|
await SaveWinkeyerSettings(wk as any);
|
||||||
await SaveAudioSettings(audioCfg as any);
|
await SaveAudioSettings(audioCfg as any);
|
||||||
await SaveEmailSettings(emailCfg as any);
|
await SaveEmailSettings(emailCfg as any);
|
||||||
@@ -1774,6 +1893,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="omnirig">OmniRig (any rig, Windows COM)</SelectItem>
|
<SelectItem value="omnirig">OmniRig (any rig, Windows COM)</SelectItem>
|
||||||
<SelectItem value="flex">FlexRadio / SmartSDR (native)</SelectItem>
|
<SelectItem value="flex">FlexRadio / SmartSDR (native)</SelectItem>
|
||||||
|
<SelectItem value="icom">Icom CI-V (USB serial)</SelectItem>
|
||||||
|
<SelectItem value="tci">TCI (Expert Electronics / SunSDR)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1810,7 +1931,61 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{catCfg.backend === 'omnirig' && (
|
{catCfg.backend === 'icom' && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Icom CI-V port</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={catCfg.icom_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, icom_port: v }))}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select COM port" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||||
|
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button type="button" variant="outline" size="sm"
|
||||||
|
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>↻</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Baud rate</Label>
|
||||||
|
<Select value={String(catCfg.icom_baud || 115200)} onValueChange={(v) => setCatCfg((s) => ({ ...s, icom_baud: parseInt(v) || 115200 }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>CI-V address (hex)</Label>
|
||||||
|
<Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')}
|
||||||
|
onChange={(e) => { const n = parseInt(e.target.value.replace(/[^0-9a-fA-F]/g, ''), 16); setCatCfg((s) => ({ ...s, icom_addr: (n >= 0 && n <= 0xFF) ? n : s.icom_addr })); }} />
|
||||||
|
<p className="text-xs text-muted-foreground">IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{catCfg.backend === 'tci' && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>TCI host</Label>
|
||||||
|
<Input placeholder="127.0.0.1" value={catCfg.tci_host ?? ''}
|
||||||
|
onChange={(e) => setCatCfg((s) => ({ ...s, tci_host: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Port</Label>
|
||||||
|
<Input type="number" value={catCfg.tci_port || 40001}
|
||||||
|
onChange={(e) => setCatCfg((s) => ({ ...s, tci_port: parseInt(e.target.value) || 40001 }))} />
|
||||||
|
</div>
|
||||||
|
<p className="col-span-2 text-xs text-muted-foreground">
|
||||||
|
Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.
|
||||||
|
</p>
|
||||||
|
<label className="col-span-2 flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={!!catCfg.tci_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, tci_spots: !!c }))} />
|
||||||
|
Show cluster spots on the panorama <span className="text-xs text-muted-foreground">(spots from OpsLog's DX cluster appear on the SDR panadapter)</span>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(catCfg.backend === 'omnirig' || catCfg.backend === 'icom') && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Poll interval (ms)</Label>
|
<Label>Poll interval (ms)</Label>
|
||||||
@@ -1907,7 +2082,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Antenna (Ultrabeam)"
|
title="Antenna (Ultrabeam)"
|
||||||
hint="OpsLog talks to the Ultrabeam controller over TCP — typically via an RS232↔Ethernet adapter. Enter its IP address and port; the pattern direction (Normal / 180° / Bidirectional) is then controlled from the status bar."
|
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
@@ -1969,9 +2143,68 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
{ubTest.msg}
|
{ubTest.msg}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-muted-foreground">
|
</div>
|
||||||
Once enabled, the antenna's direction control (Normal / 180° / Bidirectional) appears in the bottom status bar, next to CAT and Rotator. Changing the direction re-tunes the elements at the current frequency.
|
</>
|
||||||
</p>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AntGeniusPanelSettings() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
title="Antenna Genius (4O3A)"
|
||||||
|
hint="OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B)."
|
||||||
|
/>
|
||||||
|
<div className="space-y-4 max-w-xl">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={antgenius.enabled} onCheckedChange={(c) => setAntgenius((s) => ({ ...s, enabled: !!c }))} />
|
||||||
|
Enable Antenna Genius control
|
||||||
|
</label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Host / IP</Label>
|
||||||
|
<Input
|
||||||
|
value={antgenius.host ?? ''}
|
||||||
|
onChange={(e) => setAntgenius((s) => ({ ...s, host: e.target.value }))}
|
||||||
|
placeholder="192.168.1.60"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PGXLPanelSettings() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
title="Power Genius XL"
|
||||||
|
/>
|
||||||
|
<div className="space-y-4 max-w-xl">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={pgxl.enabled} onCheckedChange={(c) => setPgxl((s) => ({ ...s, enabled: !!c }))} />
|
||||||
|
Enable PowerGenius fan control
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>Host / IP</Label>
|
||||||
|
<Input
|
||||||
|
value={pgxl.host ?? ''}
|
||||||
|
onChange={(e) => setPgxl((s) => ({ ...s, host: e.target.value }))}
|
||||||
|
placeholder="192.168.1.70"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>TCP port</Label>
|
||||||
|
<Input
|
||||||
|
type="number" min={1} max={65535}
|
||||||
|
value={pgxl.port}
|
||||||
|
onChange={(e) => setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9008 }))}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -2055,7 +2288,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="CW Keyer"
|
title="CW Keyer"
|
||||||
hint="Drive a K1EL WinKeyer (WK1/2/3) over a serial port to send Morse from OpsLog. Enable it, pick the COM port and keying parameters, then connect from the keyer panel (Tools → WinKeyer CW keyer)."
|
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-4 max-w-2xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
@@ -3732,6 +3964,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
rotator: RotatorPanel,
|
rotator: RotatorPanel,
|
||||||
winkeyer: WinkeyerPanel,
|
winkeyer: WinkeyerPanel,
|
||||||
antenna: UltrabeamPanel,
|
antenna: UltrabeamPanel,
|
||||||
|
antgenius: AntGeniusPanelSettings,
|
||||||
|
pgxl: PGXLPanelSettings,
|
||||||
|
flex: () => <FlexBandAntennasPanel bands={lists.bands ?? []} />,
|
||||||
audio: AudioPanel,
|
audio: AudioPanel,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3749,7 +3984,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<div className="grid grid-cols-[320px_1fr] min-h-0 overflow-hidden">
|
<div className="grid grid-cols-[320px_1fr] min-h-0 overflow-hidden">
|
||||||
{/* Left sidebar tree */}
|
{/* Left sidebar tree */}
|
||||||
<aside className="border-r border-border bg-muted/30 overflow-y-auto p-2">
|
<aside className="border-r border-border bg-muted/30 overflow-y-auto p-2">
|
||||||
<Tree selected={selected} onSelect={setSelected} />
|
<Tree selected={selected} onSelect={setSelected} flexAvailable={flexAvailable} />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Right content pane */}
|
{/* Right content pane */}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||||
@@ -53,6 +53,7 @@ type Props = {
|
|||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
|
onDelete?: (ids: number[]) => void;
|
||||||
// One column per defined award (cell = the reference this QSO counts for).
|
// One column per defined award (cell = the reference this QSO counts for).
|
||||||
awardCols?: { code: string; name: string }[];
|
awardCols?: { code: string; name: string }[];
|
||||||
};
|
};
|
||||||
@@ -67,7 +68,7 @@ function fmtDate(s: any): string {
|
|||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, awardCols }: Props) {
|
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onDelete, awardCols }: Props) {
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
@@ -95,7 +96,12 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
const count = wb?.count ?? 0;
|
const count = wb?.count ?? 0;
|
||||||
const entries = wb?.entries ?? [];
|
const entries = wb?.entries ?? [];
|
||||||
|
|
||||||
|
// Suppress auto-save while AG Grid rebuilds columns (award columns load async),
|
||||||
|
// so the default visibility doesn't clobber the user's saved state. See the
|
||||||
|
// matching note in RecentQSOsGrid.
|
||||||
|
const restoringRef = useRef(true);
|
||||||
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => {
|
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => {
|
||||||
|
restoringRef.current = true;
|
||||||
const base = COL_CATALOG.map((c) => {
|
const base = COL_CATALOG.map((c) => {
|
||||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||||
return { ...rest, hide: !defaultVisible };
|
return { ...rest, hide: !defaultVisible };
|
||||||
@@ -126,10 +132,21 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
|
if (restoringRef.current) return; // ignore events fired by a column rebuild
|
||||||
const state = gridRef.current?.api?.getColumnState();
|
const state = gridRef.current?.api?.getColumnState();
|
||||||
if (state) saveState(COL_STATE_KEY, state);
|
if (state) saveState(COL_STATE_KEY, state);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Re-apply the saved column state after the award columns load (they rebuild
|
||||||
|
// the column set), so the user's visibility choices win over the defaults.
|
||||||
|
useEffect(() => {
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
|
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
|
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [awardCols]);
|
||||||
|
|
||||||
function isColVisible(colId: string): boolean {
|
function isColVisible(colId: string): boolean {
|
||||||
const col = gridRef.current?.api?.getColumn(colId);
|
const col = gridRef.current?.api?.getColumn(colId);
|
||||||
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
||||||
@@ -253,6 +270,7 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
onSendTo={onSendTo}
|
onSendTo={onSendTo}
|
||||||
onSendRecording={onSendRecording}
|
onSendRecording={onSendRecording}
|
||||||
onSendEQSL={onSendEQSL}
|
onSendEQSL={onSendEQSL}
|
||||||
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{count > entries.length && (
|
{count > entries.length && (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// 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).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.12';
|
export const APP_VERSION = '0.16.2';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+111
-1
@@ -5,15 +5,19 @@ import {qso} from '../models';
|
|||||||
import {main} from '../models';
|
import {main} from '../models';
|
||||||
import {cat} from '../models';
|
import {cat} from '../models';
|
||||||
import {profile} from '../models';
|
import {profile} from '../models';
|
||||||
|
import {antgenius} from '../models';
|
||||||
import {award} from '../models';
|
import {award} from '../models';
|
||||||
import {awardref} from '../models';
|
import {awardref} from '../models';
|
||||||
import {cluster} from '../models';
|
import {cluster} from '../models';
|
||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
|
import {powergenius} from '../models';
|
||||||
import {winkeyer} from '../models';
|
import {winkeyer} from '../models';
|
||||||
|
import {alerts} from '../models';
|
||||||
import {audio} from '../models';
|
import {audio} from '../models';
|
||||||
import {operating} from '../models';
|
import {operating} from '../models';
|
||||||
import {udp} from '../models';
|
import {udp} from '../models';
|
||||||
import {lookup} from '../models';
|
import {lookup} from '../models';
|
||||||
|
import {netctl} from '../models';
|
||||||
|
|
||||||
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
||||||
|
|
||||||
@@ -23,6 +27,10 @@ export function ActivateProfile(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
||||||
|
|
||||||
|
export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
|
export function AntGeniusDeselect(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
||||||
@@ -81,6 +89,8 @@ export function DXCCForCountry(arg1:string):Promise<number>;
|
|||||||
|
|
||||||
export function DXCCName(arg1:number):Promise<string>;
|
export function DXCCName(arg1:number):Promise<string>;
|
||||||
|
|
||||||
|
export function DeleteAlertRule(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function DeleteAllQSO():Promise<number>;
|
export function DeleteAllQSO():Promise<number>;
|
||||||
|
|
||||||
export function DeleteAwardReference(arg1:string,arg2:string):Promise<void>;
|
export function DeleteAwardReference(arg1:string,arg2:string):Promise<void>;
|
||||||
@@ -123,7 +133,7 @@ export function ExportAwards():Promise<string>;
|
|||||||
|
|
||||||
export function FilterFields():Promise<Array<string>>;
|
export function FilterFields():Promise<Array<string>>;
|
||||||
|
|
||||||
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>;
|
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
export function FlexATUBypass():Promise<void>;
|
export function FlexATUBypass():Promise<void>;
|
||||||
|
|
||||||
@@ -131,6 +141,8 @@ export function FlexATUStart():Promise<void>;
|
|||||||
|
|
||||||
export function FlexAmpOperate(arg1:boolean):Promise<void>;
|
export function FlexAmpOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexMox(arg1:boolean):Promise<void>;
|
export function FlexMox(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetAGCMode(arg1:string):Promise<void>;
|
export function FlexSetAGCMode(arg1:string):Promise<void>;
|
||||||
@@ -159,12 +171,16 @@ export function FlexSetCWSidetone(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetCWSpeed(arg1:number):Promise<void>;
|
export function FlexSetCWSpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetMic(arg1:number):Promise<void>;
|
export function FlexSetMic(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetMon(arg1:boolean):Promise<void>;
|
export function FlexSetMon(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetMonLevel(arg1:number):Promise<void>;
|
export function FlexSetMonLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetMute(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetNB(arg1:boolean):Promise<void>;
|
export function FlexSetNB(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetNBLevel(arg1:number):Promise<void>;
|
export function FlexSetNBLevel(arg1:number):Promise<void>;
|
||||||
@@ -179,8 +195,14 @@ export function FlexSetProcessor(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
|
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetRXAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetSplit(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetTXAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetTunePower(arg1:number):Promise<void>;
|
export function FlexSetTunePower(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetVox(arg1:boolean):Promise<void>;
|
export function FlexSetVox(arg1:boolean):Promise<void>;
|
||||||
@@ -193,6 +215,12 @@ export function FlexTune(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function GetActiveProfile():Promise<profile.Profile>;
|
export function GetActiveProfile():Promise<profile.Profile>;
|
||||||
|
|
||||||
|
export function GetAlertEmailTo():Promise<string>;
|
||||||
|
|
||||||
|
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
|
||||||
|
|
||||||
|
export function GetAntGeniusStatus():Promise<antgenius.Status>;
|
||||||
|
|
||||||
export function GetAudioSettings():Promise<main.AudioSettings>;
|
export function GetAudioSettings():Promise<main.AudioSettings>;
|
||||||
|
|
||||||
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
|
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
|
||||||
@@ -243,8 +271,12 @@ export function GetEmailSettings():Promise<main.EmailSettings>;
|
|||||||
|
|
||||||
export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
||||||
|
|
||||||
|
export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
|
||||||
|
|
||||||
export function GetFlexState():Promise<cat.FlexTXState>;
|
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||||
|
|
||||||
|
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||||
|
|
||||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||||
|
|
||||||
export function GetLiveStatusEnabled():Promise<boolean>;
|
export function GetLiveStatusEnabled():Promise<boolean>;
|
||||||
@@ -259,6 +291,10 @@ export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
|||||||
|
|
||||||
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
||||||
|
|
||||||
|
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
||||||
|
|
||||||
|
export function GetPGXLStatus():Promise<powergenius.Status>;
|
||||||
|
|
||||||
export function GetPOTAToken():Promise<string>;
|
export function GetPOTAToken():Promise<string>;
|
||||||
|
|
||||||
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||||
@@ -289,6 +325,30 @@ export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
|||||||
|
|
||||||
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
||||||
|
|
||||||
|
export function IcomRefresh():Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetAFGain(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetAGC(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetANF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetAtt(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetFilter(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetNB(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetNBLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetNR(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetNRLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetPreamp(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetRFGain(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
|
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
|
||||||
|
|
||||||
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
||||||
@@ -299,6 +359,8 @@ export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunch
|
|||||||
|
|
||||||
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
||||||
|
|
||||||
|
export function ListAlertRules():Promise<Array<alerts.Rule>>;
|
||||||
|
|
||||||
export function ListAudioInputDevices():Promise<Array<audio.Device>>;
|
export function ListAudioInputDevices():Promise<Array<audio.Device>>;
|
||||||
|
|
||||||
export function ListAudioOutputDevices():Promise<Array<audio.Device>>;
|
export function ListAudioOutputDevices():Promise<Array<audio.Device>>;
|
||||||
@@ -329,6 +391,40 @@ export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
|||||||
|
|
||||||
export function MoveDatabase(arg1:string):Promise<void>;
|
export function MoveDatabase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function NetActivate(arg1:string):Promise<qso.QSO>;
|
||||||
|
|
||||||
|
export function NetActiveList():Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
|
export function NetClose():Promise<void>;
|
||||||
|
|
||||||
|
export function NetCreate(arg1:string):Promise<netctl.Net>;
|
||||||
|
|
||||||
|
export function NetDeactivate(arg1:number):Promise<number>;
|
||||||
|
|
||||||
|
export function NetDelete(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function NetDiscardActive(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function NetList():Promise<Array<netctl.Net>>;
|
||||||
|
|
||||||
|
export function NetLookup(arg1:string):Promise<netctl.Station>;
|
||||||
|
|
||||||
|
export function NetOpen(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function NetOpenID():Promise<string>;
|
||||||
|
|
||||||
|
export function NetRename(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function NetRoster(arg1:string):Promise<Array<netctl.Station>>;
|
||||||
|
|
||||||
|
export function NetRosterRemove(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function NetRosterUpsert(arg1:string,arg2:netctl.Station):Promise<void>;
|
||||||
|
|
||||||
|
export function NetSetDefaults(arg1:string,arg2:string,arg3:string,arg4:string):Promise<void>;
|
||||||
|
|
||||||
|
export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
|
||||||
|
|
||||||
export function OpenADIFFile():Promise<string>;
|
export function OpenADIFFile():Promise<string>;
|
||||||
|
|
||||||
export function OpenDatabase(arg1:string):Promise<void>;
|
export function OpenDatabase(arg1:string):Promise<void>;
|
||||||
@@ -337,6 +433,8 @@ export function OpenExternalURL(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>;
|
export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>;
|
||||||
|
|
||||||
|
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function PickAudioFolder():Promise<string>;
|
export function PickAudioFolder():Promise<string>;
|
||||||
|
|
||||||
export function PickBackupFolder():Promise<string>;
|
export function PickBackupFolder():Promise<string>;
|
||||||
@@ -385,6 +483,8 @@ export function QSOAudioBegin():Promise<boolean>;
|
|||||||
|
|
||||||
export function QSOAudioCancel():Promise<void>;
|
export function QSOAudioCancel():Promise<void>;
|
||||||
|
|
||||||
|
export function QSOAudioResetClock():Promise<boolean>;
|
||||||
|
|
||||||
export function QSOAudioRestart():Promise<boolean>;
|
export function QSOAudioRestart():Promise<boolean>;
|
||||||
|
|
||||||
export function QuitApp():Promise<void>;
|
export function QuitApp():Promise<void>;
|
||||||
@@ -419,6 +519,10 @@ export function RunBackupNow():Promise<string>;
|
|||||||
|
|
||||||
export function SaveADIFFile():Promise<string>;
|
export function SaveADIFFile():Promise<string>;
|
||||||
|
|
||||||
|
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
|
||||||
|
|
||||||
|
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>;
|
export function SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>;
|
||||||
@@ -437,6 +541,8 @@ export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
|
|||||||
|
|
||||||
export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>;
|
export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Promise<void>;
|
||||||
|
|
||||||
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
||||||
@@ -447,6 +553,8 @@ export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.A
|
|||||||
|
|
||||||
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
|
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
|
||||||
|
|
||||||
|
export function SavePGXLSettings(arg1:main.PGXLSettings):Promise<void>;
|
||||||
|
|
||||||
export function SavePOTAToken(arg1:string):Promise<void>;
|
export function SavePOTAToken(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||||
@@ -475,6 +583,8 @@ export function SendEQSL(arg1:number,arg2:number,arg3:string):Promise<void>;
|
|||||||
|
|
||||||
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetCATFrequency(arg1:number):Promise<void>;
|
export function SetCATFrequency(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetCATMode(arg1:string):Promise<void>;
|
export function SetCATMode(arg1:string):Promise<void>;
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ export function AddQSO(arg1) {
|
|||||||
return window['go']['main']['App']['AddQSO'](arg1);
|
return window['go']['main']['App']['AddQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AntGeniusActivate(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AntGeniusDeselect(arg1) {
|
||||||
|
return window['go']['main']['App']['AntGeniusDeselect'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function ApplyAwardPreset(arg1, arg2) {
|
export function ApplyAwardPreset(arg1, arg2) {
|
||||||
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
|
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -134,6 +142,10 @@ export function DXCCName(arg1) {
|
|||||||
return window['go']['main']['App']['DXCCName'](arg1);
|
return window['go']['main']['App']['DXCCName'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DeleteAlertRule(arg1) {
|
||||||
|
return window['go']['main']['App']['DeleteAlertRule'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function DeleteAllQSO() {
|
export function DeleteAllQSO() {
|
||||||
return window['go']['main']['App']['DeleteAllQSO']();
|
return window['go']['main']['App']['DeleteAllQSO']();
|
||||||
}
|
}
|
||||||
@@ -234,6 +246,10 @@ export function FlexAmpOperate(arg1) {
|
|||||||
return window['go']['main']['App']['FlexAmpOperate'](arg1);
|
return window['go']['main']['App']['FlexAmpOperate'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexApplyBandAntenna(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexMox(arg1) {
|
export function FlexMox(arg1) {
|
||||||
return window['go']['main']['App']['FlexMox'](arg1);
|
return window['go']['main']['App']['FlexMox'](arg1);
|
||||||
}
|
}
|
||||||
@@ -290,6 +306,10 @@ export function FlexSetCWSpeed(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetCWSpeed'](arg1);
|
return window['go']['main']['App']['FlexSetCWSpeed'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetFilter(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetMic(arg1) {
|
export function FlexSetMic(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetMic'](arg1);
|
return window['go']['main']['App']['FlexSetMic'](arg1);
|
||||||
}
|
}
|
||||||
@@ -302,6 +322,10 @@ export function FlexSetMonLevel(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetMonLevel'](arg1);
|
return window['go']['main']['App']['FlexSetMonLevel'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetMute(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetMute'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetNB(arg1) {
|
export function FlexSetNB(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetNB'](arg1);
|
return window['go']['main']['App']['FlexSetNB'](arg1);
|
||||||
}
|
}
|
||||||
@@ -330,10 +354,22 @@ export function FlexSetProcessorLevel(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
|
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetRXAntenna(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetSidetoneLevel(arg1) {
|
export function FlexSetSidetoneLevel(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
|
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetSplit(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetSplit'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetTXAntenna(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetTunePower(arg1) {
|
export function FlexSetTunePower(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetTunePower'](arg1);
|
return window['go']['main']['App']['FlexSetTunePower'](arg1);
|
||||||
}
|
}
|
||||||
@@ -358,6 +394,18 @@ export function GetActiveProfile() {
|
|||||||
return window['go']['main']['App']['GetActiveProfile']();
|
return window['go']['main']['App']['GetActiveProfile']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAlertEmailTo() {
|
||||||
|
return window['go']['main']['App']['GetAlertEmailTo']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetAntGeniusSettings() {
|
||||||
|
return window['go']['main']['App']['GetAntGeniusSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetAntGeniusStatus() {
|
||||||
|
return window['go']['main']['App']['GetAntGeniusStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAudioSettings() {
|
export function GetAudioSettings() {
|
||||||
return window['go']['main']['App']['GetAudioSettings']();
|
return window['go']['main']['App']['GetAudioSettings']();
|
||||||
}
|
}
|
||||||
@@ -458,10 +506,18 @@ export function GetExternalServices() {
|
|||||||
return window['go']['main']['App']['GetExternalServices']();
|
return window['go']['main']['App']['GetExternalServices']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetFlexBandAntennas() {
|
||||||
|
return window['go']['main']['App']['GetFlexBandAntennas']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetFlexState() {
|
export function GetFlexState() {
|
||||||
return window['go']['main']['App']['GetFlexState']();
|
return window['go']['main']['App']['GetFlexState']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetIcomState() {
|
||||||
|
return window['go']['main']['App']['GetIcomState']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetListsSettings() {
|
export function GetListsSettings() {
|
||||||
return window['go']['main']['App']['GetListsSettings']();
|
return window['go']['main']['App']['GetListsSettings']();
|
||||||
}
|
}
|
||||||
@@ -490,6 +546,14 @@ export function GetOnlineOperators() {
|
|||||||
return window['go']['main']['App']['GetOnlineOperators']();
|
return window['go']['main']['App']['GetOnlineOperators']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetPGXLSettings() {
|
||||||
|
return window['go']['main']['App']['GetPGXLSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetPGXLStatus() {
|
||||||
|
return window['go']['main']['App']['GetPGXLStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetPOTAToken() {
|
export function GetPOTAToken() {
|
||||||
return window['go']['main']['App']['GetPOTAToken']();
|
return window['go']['main']['App']['GetPOTAToken']();
|
||||||
}
|
}
|
||||||
@@ -550,6 +614,54 @@ export function HasBuiltinReferences(arg1) {
|
|||||||
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
|
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IcomRefresh() {
|
||||||
|
return window['go']['main']['App']['IcomRefresh']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetAFGain(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetAFGain'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetAGC(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetAGC'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetANF(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetANF'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetAtt(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetAtt'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetFilter(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetFilter'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetNB(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetNB'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetNBLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetNBLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetNR(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetNR'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetNRLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetNRLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetPreamp(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetPreamp'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetRFGain(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetRFGain'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function ImportADIF(arg1, arg2, arg3, arg4) {
|
export function ImportADIF(arg1, arg2, arg3, arg4) {
|
||||||
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4);
|
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
@@ -570,6 +682,10 @@ export function LaunchAutostartPrograms() {
|
|||||||
return window['go']['main']['App']['LaunchAutostartPrograms']();
|
return window['go']['main']['App']['LaunchAutostartPrograms']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListAlertRules() {
|
||||||
|
return window['go']['main']['App']['ListAlertRules']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ListAudioInputDevices() {
|
export function ListAudioInputDevices() {
|
||||||
return window['go']['main']['App']['ListAudioInputDevices']();
|
return window['go']['main']['App']['ListAudioInputDevices']();
|
||||||
}
|
}
|
||||||
@@ -630,6 +746,74 @@ export function MoveDatabase(arg1) {
|
|||||||
return window['go']['main']['App']['MoveDatabase'](arg1);
|
return window['go']['main']['App']['MoveDatabase'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function NetActivate(arg1) {
|
||||||
|
return window['go']['main']['App']['NetActivate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetActiveList() {
|
||||||
|
return window['go']['main']['App']['NetActiveList']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetClose() {
|
||||||
|
return window['go']['main']['App']['NetClose']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetCreate(arg1) {
|
||||||
|
return window['go']['main']['App']['NetCreate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetDeactivate(arg1) {
|
||||||
|
return window['go']['main']['App']['NetDeactivate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetDelete(arg1) {
|
||||||
|
return window['go']['main']['App']['NetDelete'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetDiscardActive(arg1) {
|
||||||
|
return window['go']['main']['App']['NetDiscardActive'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetList() {
|
||||||
|
return window['go']['main']['App']['NetList']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetLookup(arg1) {
|
||||||
|
return window['go']['main']['App']['NetLookup'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetOpen(arg1) {
|
||||||
|
return window['go']['main']['App']['NetOpen'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetOpenID() {
|
||||||
|
return window['go']['main']['App']['NetOpenID']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetRename(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['NetRename'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetRoster(arg1) {
|
||||||
|
return window['go']['main']['App']['NetRoster'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetRosterRemove(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['NetRosterRemove'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetRosterUpsert(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['NetRosterUpsert'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetSetDefaults(arg1, arg2, arg3, arg4) {
|
||||||
|
return window['go']['main']['App']['NetSetDefaults'](arg1, arg2, arg3, arg4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetUpdateActive(arg1) {
|
||||||
|
return window['go']['main']['App']['NetUpdateActive'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function OpenADIFFile() {
|
export function OpenADIFFile() {
|
||||||
return window['go']['main']['App']['OpenADIFFile']();
|
return window['go']['main']['App']['OpenADIFFile']();
|
||||||
}
|
}
|
||||||
@@ -646,6 +830,10 @@ export function OperatingDefaultForBand(arg1) {
|
|||||||
return window['go']['main']['App']['OperatingDefaultForBand'](arg1);
|
return window['go']['main']['App']['OperatingDefaultForBand'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PGXLSetFanMode(arg1) {
|
||||||
|
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function PickAudioFolder() {
|
export function PickAudioFolder() {
|
||||||
return window['go']['main']['App']['PickAudioFolder']();
|
return window['go']['main']['App']['PickAudioFolder']();
|
||||||
}
|
}
|
||||||
@@ -742,6 +930,10 @@ export function QSOAudioCancel() {
|
|||||||
return window['go']['main']['App']['QSOAudioCancel']();
|
return window['go']['main']['App']['QSOAudioCancel']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function QSOAudioResetClock() {
|
||||||
|
return window['go']['main']['App']['QSOAudioResetClock']();
|
||||||
|
}
|
||||||
|
|
||||||
export function QSOAudioRestart() {
|
export function QSOAudioRestart() {
|
||||||
return window['go']['main']['App']['QSOAudioRestart']();
|
return window['go']['main']['App']['QSOAudioRestart']();
|
||||||
}
|
}
|
||||||
@@ -810,6 +1002,14 @@ export function SaveADIFFile() {
|
|||||||
return window['go']['main']['App']['SaveADIFFile']();
|
return window['go']['main']['App']['SaveADIFFile']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveAlertRule(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveAlertRule'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveAntGeniusSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveAntGeniusSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveAudioSettings(arg1) {
|
export function SaveAudioSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveAudioSettings'](arg1);
|
return window['go']['main']['App']['SaveAudioSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -846,6 +1046,10 @@ export function SaveExternalServices(arg1) {
|
|||||||
return window['go']['main']['App']['SaveExternalServices'](arg1);
|
return window['go']['main']['App']['SaveExternalServices'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveFlexBandAntennas(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveFlexBandAntennas'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveListsSettings(arg1) {
|
export function SaveListsSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveListsSettings'](arg1);
|
return window['go']['main']['App']['SaveListsSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -866,6 +1070,10 @@ export function SaveOperatingStation(arg1) {
|
|||||||
return window['go']['main']['App']['SaveOperatingStation'](arg1);
|
return window['go']['main']['App']['SaveOperatingStation'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SavePGXLSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SavePGXLSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SavePOTAToken(arg1) {
|
export function SavePOTAToken(arg1) {
|
||||||
return window['go']['main']['App']['SavePOTAToken'](arg1);
|
return window['go']['main']['App']['SavePOTAToken'](arg1);
|
||||||
}
|
}
|
||||||
@@ -922,6 +1130,10 @@ export function SendQSORecordingEmail(arg1) {
|
|||||||
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetAlertEmailTo(arg1) {
|
||||||
|
return window['go']['main']['App']['SetAlertEmailTo'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetCATFrequency(arg1) {
|
export function SetCATFrequency(arg1) {
|
||||||
return window['go']['main']['App']['SetCATFrequency'](arg1);
|
return window['go']['main']['App']['SetCATFrequency'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
+312
-24
@@ -65,6 +65,116 @@ export namespace adif {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace alerts {
|
||||||
|
|
||||||
|
export class Rule {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
calls?: string[];
|
||||||
|
countries?: string[];
|
||||||
|
continents?: string[];
|
||||||
|
bands?: string[];
|
||||||
|
modes?: string[];
|
||||||
|
spotter_call?: string;
|
||||||
|
spotter_continents?: string[];
|
||||||
|
spotter_countries?: string[];
|
||||||
|
sound: boolean;
|
||||||
|
visual: boolean;
|
||||||
|
email: boolean;
|
||||||
|
again_after_min: number;
|
||||||
|
skip_worked: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Rule(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.calls = source["calls"];
|
||||||
|
this.countries = source["countries"];
|
||||||
|
this.continents = source["continents"];
|
||||||
|
this.bands = source["bands"];
|
||||||
|
this.modes = source["modes"];
|
||||||
|
this.spotter_call = source["spotter_call"];
|
||||||
|
this.spotter_continents = source["spotter_continents"];
|
||||||
|
this.spotter_countries = source["spotter_countries"];
|
||||||
|
this.sound = source["sound"];
|
||||||
|
this.visual = source["visual"];
|
||||||
|
this.email = source["email"];
|
||||||
|
this.again_after_min = source["again_after_min"];
|
||||||
|
this.skip_worked = source["skip_worked"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace antgenius {
|
||||||
|
|
||||||
|
export class Antenna {
|
||||||
|
index: number;
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Antenna(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.index = source["index"];
|
||||||
|
this.name = source["name"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Status {
|
||||||
|
connected: boolean;
|
||||||
|
host?: string;
|
||||||
|
last_error?: string;
|
||||||
|
port_a: number;
|
||||||
|
port_b: number;
|
||||||
|
tx_a: boolean;
|
||||||
|
tx_b: boolean;
|
||||||
|
antennas: Antenna[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Status(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.connected = source["connected"];
|
||||||
|
this.host = source["host"];
|
||||||
|
this.last_error = source["last_error"];
|
||||||
|
this.port_a = source["port_a"];
|
||||||
|
this.port_b = source["port_b"];
|
||||||
|
this.tx_a = source["tx_a"];
|
||||||
|
this.tx_b = source["tx_b"];
|
||||||
|
this.antennas = this.convertValues(source["antennas"], Antenna);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 namespace audio {
|
export namespace audio {
|
||||||
|
|
||||||
export class Device {
|
export class Device {
|
||||||
@@ -449,9 +559,17 @@ export namespace cat {
|
|||||||
atu_status?: string;
|
atu_status?: string;
|
||||||
atu_memories: boolean;
|
atu_memories: boolean;
|
||||||
rx_avail: boolean;
|
rx_avail: boolean;
|
||||||
|
split: boolean;
|
||||||
|
rx_freq_hz?: number;
|
||||||
|
tx_freq_hz?: number;
|
||||||
agc_mode?: string;
|
agc_mode?: string;
|
||||||
agc_threshold: number;
|
agc_threshold: number;
|
||||||
audio_level: number;
|
audio_level: number;
|
||||||
|
mute: boolean;
|
||||||
|
rx_ant?: string;
|
||||||
|
tx_ant?: string;
|
||||||
|
ant_list?: string[];
|
||||||
|
tx_ant_list?: string[];
|
||||||
nb: boolean;
|
nb: boolean;
|
||||||
nb_level: number;
|
nb_level: number;
|
||||||
nr: boolean;
|
nr: boolean;
|
||||||
@@ -497,9 +615,17 @@ export namespace cat {
|
|||||||
this.atu_status = source["atu_status"];
|
this.atu_status = source["atu_status"];
|
||||||
this.atu_memories = source["atu_memories"];
|
this.atu_memories = source["atu_memories"];
|
||||||
this.rx_avail = source["rx_avail"];
|
this.rx_avail = source["rx_avail"];
|
||||||
|
this.split = source["split"];
|
||||||
|
this.rx_freq_hz = source["rx_freq_hz"];
|
||||||
|
this.tx_freq_hz = source["tx_freq_hz"];
|
||||||
this.agc_mode = source["agc_mode"];
|
this.agc_mode = source["agc_mode"];
|
||||||
this.agc_threshold = source["agc_threshold"];
|
this.agc_threshold = source["agc_threshold"];
|
||||||
this.audio_level = source["audio_level"];
|
this.audio_level = source["audio_level"];
|
||||||
|
this.mute = source["mute"];
|
||||||
|
this.rx_ant = source["rx_ant"];
|
||||||
|
this.tx_ant = source["tx_ant"];
|
||||||
|
this.ant_list = source["ant_list"];
|
||||||
|
this.tx_ant_list = source["tx_ant_list"];
|
||||||
this.nb = source["nb"];
|
this.nb = source["nb"];
|
||||||
this.nb_level = source["nb_level"];
|
this.nb_level = source["nb_level"];
|
||||||
this.nr = source["nr"];
|
this.nr = source["nr"];
|
||||||
@@ -541,6 +667,44 @@ export namespace cat {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class IcomTXState {
|
||||||
|
available: boolean;
|
||||||
|
model?: string;
|
||||||
|
mode?: string;
|
||||||
|
af_gain: number;
|
||||||
|
rf_gain: number;
|
||||||
|
nb: boolean;
|
||||||
|
nb_level: number;
|
||||||
|
nr: boolean;
|
||||||
|
nr_level: number;
|
||||||
|
anf: boolean;
|
||||||
|
agc?: string;
|
||||||
|
preamp: number;
|
||||||
|
att: number;
|
||||||
|
filter: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new IcomTXState(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.available = source["available"];
|
||||||
|
this.model = source["model"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.af_gain = source["af_gain"];
|
||||||
|
this.rf_gain = source["rf_gain"];
|
||||||
|
this.nb = source["nb"];
|
||||||
|
this.nb_level = source["nb_level"];
|
||||||
|
this.nr = source["nr"];
|
||||||
|
this.nr_level = source["nr_level"];
|
||||||
|
this.anf = source["anf"];
|
||||||
|
this.agc = source["agc"];
|
||||||
|
this.preamp = source["preamp"];
|
||||||
|
this.att = source["att"];
|
||||||
|
this.filter = source["filter"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class RigState {
|
export class RigState {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
@@ -857,6 +1021,20 @@ export namespace lookup {
|
|||||||
|
|
||||||
export namespace main {
|
export namespace main {
|
||||||
|
|
||||||
|
export class AntGeniusSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
host: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AntGeniusSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.host = source["host"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class AudioSettings {
|
export class AudioSettings {
|
||||||
from_radio: string;
|
from_radio: string;
|
||||||
to_radio: string;
|
to_radio: string;
|
||||||
@@ -1042,6 +1220,12 @@ export namespace main {
|
|||||||
flex_host: string;
|
flex_host: string;
|
||||||
flex_port: number;
|
flex_port: number;
|
||||||
flex_spots: boolean;
|
flex_spots: boolean;
|
||||||
|
icom_port: string;
|
||||||
|
icom_baud: number;
|
||||||
|
icom_addr: number;
|
||||||
|
tci_host: string;
|
||||||
|
tci_port: number;
|
||||||
|
tci_spots: boolean;
|
||||||
poll_ms: number;
|
poll_ms: number;
|
||||||
delay_ms: number;
|
delay_ms: number;
|
||||||
digital_default: string;
|
digital_default: string;
|
||||||
@@ -1058,6 +1242,12 @@ export namespace main {
|
|||||||
this.flex_host = source["flex_host"];
|
this.flex_host = source["flex_host"];
|
||||||
this.flex_port = source["flex_port"];
|
this.flex_port = source["flex_port"];
|
||||||
this.flex_spots = source["flex_spots"];
|
this.flex_spots = source["flex_spots"];
|
||||||
|
this.icom_port = source["icom_port"];
|
||||||
|
this.icom_baud = source["icom_baud"];
|
||||||
|
this.icom_addr = source["icom_addr"];
|
||||||
|
this.tci_host = source["tci_host"];
|
||||||
|
this.tci_port = source["tci_port"];
|
||||||
|
this.tci_spots = source["tci_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"];
|
||||||
@@ -1352,6 +1542,22 @@ export namespace main {
|
|||||||
this.database = source["database"];
|
this.database = source["database"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class PGXLSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new PGXLSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.host = source["host"];
|
||||||
|
this.port = source["port"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class POTAUnmatched {
|
export class POTAUnmatched {
|
||||||
activator: string;
|
activator: string;
|
||||||
date: string;
|
date: string;
|
||||||
@@ -1425,6 +1631,8 @@ export namespace main {
|
|||||||
sent_date: string;
|
sent_date: string;
|
||||||
rcvd_date: string;
|
rcvd_date: string;
|
||||||
via: string;
|
via: string;
|
||||||
|
notes: string;
|
||||||
|
comment: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new QSLBulkUpdate(source);
|
return new QSLBulkUpdate(source);
|
||||||
@@ -1437,6 +1645,8 @@ export namespace main {
|
|||||||
this.sent_date = source["sent_date"];
|
this.sent_date = source["sent_date"];
|
||||||
this.rcvd_date = source["rcvd_date"];
|
this.rcvd_date = source["rcvd_date"];
|
||||||
this.via = source["via"];
|
this.via = source["via"];
|
||||||
|
this.notes = source["notes"];
|
||||||
|
this.comment = source["comment"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class QSLDefaults {
|
export class QSLDefaults {
|
||||||
@@ -1870,6 +2080,81 @@ export namespace main {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace netctl {
|
||||||
|
|
||||||
|
export class Station {
|
||||||
|
callsign: string;
|
||||||
|
name?: string;
|
||||||
|
qth?: string;
|
||||||
|
country?: string;
|
||||||
|
dxcc?: number;
|
||||||
|
itu?: number;
|
||||||
|
cq?: number;
|
||||||
|
groups?: string;
|
||||||
|
sig?: string;
|
||||||
|
sig_info?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Station(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.callsign = source["callsign"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.qth = source["qth"];
|
||||||
|
this.country = source["country"];
|
||||||
|
this.dxcc = source["dxcc"];
|
||||||
|
this.itu = source["itu"];
|
||||||
|
this.cq = source["cq"];
|
||||||
|
this.groups = source["groups"];
|
||||||
|
this.sig = source["sig"];
|
||||||
|
this.sig_info = source["sig_info"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Net {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
default_rst_sent?: string;
|
||||||
|
default_rst_rcvd?: string;
|
||||||
|
default_comment?: string;
|
||||||
|
stations?: Station[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Net(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.default_rst_sent = source["default_rst_sent"];
|
||||||
|
this.default_rst_rcvd = source["default_rst_rcvd"];
|
||||||
|
this.default_comment = source["default_comment"];
|
||||||
|
this.stations = this.convertValues(source["stations"], Station);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 namespace operating {
|
export namespace operating {
|
||||||
|
|
||||||
export class AntennaBand {
|
export class AntennaBand {
|
||||||
@@ -1988,6 +2273,33 @@ export namespace operating {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace powergenius {
|
||||||
|
|
||||||
|
export class Status {
|
||||||
|
connected: boolean;
|
||||||
|
host?: string;
|
||||||
|
last_error?: string;
|
||||||
|
state?: string;
|
||||||
|
fan_mode?: string;
|
||||||
|
temperature: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Status(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.connected = source["connected"];
|
||||||
|
this.host = source["host"];
|
||||||
|
this.last_error = source["last_error"];
|
||||||
|
this.state = source["state"];
|
||||||
|
this.fan_mode = source["fan_mode"];
|
||||||
|
this.temperature = source["temperature"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace profile {
|
export namespace profile {
|
||||||
|
|
||||||
export class ProfileDB {
|
export class ProfileDB {
|
||||||
@@ -2656,30 +2968,6 @@ export namespace qso {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class UploadRow {
|
|
||||||
id: number;
|
|
||||||
qso_date: string;
|
|
||||||
callsign: string;
|
|
||||||
band: string;
|
|
||||||
mode: string;
|
|
||||||
country: string;
|
|
||||||
status: string;
|
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
|
||||||
return new UploadRow(source);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
|
||||||
this.id = source["id"];
|
|
||||||
this.qso_date = source["qso_date"];
|
|
||||||
this.callsign = source["callsign"];
|
|
||||||
this.band = source["band"];
|
|
||||||
this.mode = source["mode"];
|
|
||||||
this.country = source["country"];
|
|
||||||
this.status = source["status"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export class WorkedBefore {
|
export class WorkedBefore {
|
||||||
callsign: string;
|
callsign: string;
|
||||||
count: number;
|
count: number;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ require (
|
|||||||
github.com/braheezy/shine-mp3 v0.1.0
|
github.com/braheezy/shine-mp3 v0.1.0
|
||||||
github.com/go-ole/go-ole v1.3.0
|
github.com/go-ole/go-ole v1.3.0
|
||||||
github.com/go-sql-driver/mysql v1.10.0
|
github.com/go-sql-driver/mysql v1.10.0
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/moutend/go-wca v0.3.0
|
github.com/moutend/go-wca v0.3.0
|
||||||
github.com/wailsapp/wails/v2 v2.11.0
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
github.com/wneessen/go-mail v0.7.3
|
github.com/wneessen/go-mail v0.7.3
|
||||||
@@ -22,7 +23,6 @@ require (
|
|||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
|
||||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||||
github.com/labstack/gommon v0.4.2 // indirect
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
// Package alerts evaluates incoming DX-cluster spots against user-defined rules
|
||||||
|
// and reports which rules fire, so the app can notify the operator (sound /
|
||||||
|
// visual / e-mail) when a wanted station is spotted — like Log4OM's Alert
|
||||||
|
// Management. Rules are persisted as JSON (global, machine-local).
|
||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rule is one alert definition. Every filter dimension is optional: an empty
|
||||||
|
// list/string matches ANY value, and the dimensions are ANDed together, so a
|
||||||
|
// rule with Countries=[France] and Bands=[20m] fires only for French stations
|
||||||
|
// on 20m. Calls / SpotterCall accept wildcards (IW3*, */P).
|
||||||
|
type Rule struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
|
||||||
|
// DX filters.
|
||||||
|
Calls []string `json:"calls,omitempty"` // wildcard patterns on the DX call
|
||||||
|
Countries []string `json:"countries,omitempty"` // DXCC entity names
|
||||||
|
Continents []string `json:"continents,omitempty"` // AF/AN/AS/EU/NA/OC/SA
|
||||||
|
|
||||||
|
// Band / mode filters.
|
||||||
|
Bands []string `json:"bands,omitempty"`
|
||||||
|
Modes []string `json:"modes,omitempty"`
|
||||||
|
|
||||||
|
// Spotter (origin) filters.
|
||||||
|
SpotterCall string `json:"spotter_call,omitempty"` // wildcard
|
||||||
|
SpotterContinents []string `json:"spotter_continents,omitempty"`
|
||||||
|
SpotterCountries []string `json:"spotter_countries,omitempty"`
|
||||||
|
|
||||||
|
// Actions.
|
||||||
|
Sound bool `json:"sound"`
|
||||||
|
Visual bool `json:"visual"`
|
||||||
|
Email bool `json:"email"`
|
||||||
|
|
||||||
|
// Throttling: minutes to wait before re-alerting the same callsign. 0 = only
|
||||||
|
// once per session, -1 = always, >0 = that many minutes.
|
||||||
|
AgainAfterMin int `json:"again_after_min"`
|
||||||
|
|
||||||
|
// Skip a spot whose DX call is already worked on this band+mode.
|
||||||
|
SkipWorked bool `json:"skip_worked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spot is the subset of a cluster spot the engine matches against.
|
||||||
|
type Spot struct {
|
||||||
|
DXCall string
|
||||||
|
Band string
|
||||||
|
Mode string // inferred (see InferMode)
|
||||||
|
Country string // DXCC entity name
|
||||||
|
Continent string
|
||||||
|
Spotter string
|
||||||
|
SpotterCountry string
|
||||||
|
SpotterContinent string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store persists the rule set as a JSON file.
|
||||||
|
type Store struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
path string
|
||||||
|
rules []Rule
|
||||||
|
// last fired time per (ruleID|call), for the AgainAfter throttle.
|
||||||
|
lastFired map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open loads the store (empty when the file is absent).
|
||||||
|
func Open(path string) (*Store, error) {
|
||||||
|
s := &Store{path: path, lastFired: map[string]time.Time{}}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(b) > 0 {
|
||||||
|
_ = json.Unmarshal(b, &s.rules) // corrupt file → start empty
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) save() error {
|
||||||
|
b, err := json.MarshalIndent(s.rules, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := s.path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, s.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns a copy of all rules.
|
||||||
|
func (s *Store) List() []Rule {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := make([]Rule, len(s.rules))
|
||||||
|
copy(out, s.rules)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func newID() string { return strconv.FormatInt(time.Now().UnixNano(), 36) }
|
||||||
|
|
||||||
|
// Save upserts a rule (creating an id when empty) and returns it.
|
||||||
|
func (s *Store) Save(r Rule) (Rule, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if strings.TrimSpace(r.ID) == "" {
|
||||||
|
r.ID = newID()
|
||||||
|
s.rules = append(s.rules, r)
|
||||||
|
} else {
|
||||||
|
found := false
|
||||||
|
for i := range s.rules {
|
||||||
|
if s.rules[i].ID == r.ID {
|
||||||
|
s.rules[i] = r
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
s.rules = append(s.rules, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r, s.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a rule by id.
|
||||||
|
func (s *Store) Delete(id string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for i := range s.rules {
|
||||||
|
if s.rules[i].ID == id {
|
||||||
|
s.rules = append(s.rules[:i], s.rules[i+1:]...)
|
||||||
|
return s.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match is a rule that fired for a spot (returned by Evaluate).
|
||||||
|
type Match struct {
|
||||||
|
Rule Rule
|
||||||
|
Spot Spot
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate returns every enabled rule that matches the spot AND isn't currently
|
||||||
|
// throttled. It records the fire time for matched rules so the AgainAfter window
|
||||||
|
// is honoured. workedFn (may be nil) reports whether the DX call is already
|
||||||
|
// worked on this band+mode — used by rules with SkipWorked.
|
||||||
|
func (s *Store) Evaluate(sp Spot, now time.Time, workedFn func(call, band, mode string) bool) []Match {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
var out []Match
|
||||||
|
for _, r := range s.rules {
|
||||||
|
if !r.Enabled || !ruleMatches(r, sp) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.SkipWorked && workedFn != nil && workedFn(sp.DXCall, sp.Band, sp.Mode) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := r.ID + "|" + strings.ToUpper(sp.DXCall)
|
||||||
|
if r.AgainAfterMin >= 0 {
|
||||||
|
last, seen := s.lastFired[key]
|
||||||
|
if seen {
|
||||||
|
if r.AgainAfterMin == 0 {
|
||||||
|
continue // once per session
|
||||||
|
}
|
||||||
|
if now.Sub(last) < time.Duration(r.AgainAfterMin)*time.Minute {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.lastFired[key] = now
|
||||||
|
out = append(out, Match{Rule: r, Spot: sp})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ruleMatches reports whether every specified filter dimension matches the spot.
|
||||||
|
func ruleMatches(r Rule, sp Spot) bool {
|
||||||
|
if len(r.Calls) > 0 && !anyWildcard(r.Calls, sp.DXCall) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(r.Countries) > 0 && !containsFold(r.Countries, sp.Country) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(r.Continents) > 0 && !containsFold(r.Continents, sp.Continent) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(r.Bands) > 0 && !containsFold(r.Bands, sp.Band) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(r.Modes) > 0 && !containsFold(r.Modes, sp.Mode) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(r.SpotterCall) != "" && !matchWildcard(r.SpotterCall, sp.Spotter) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(r.SpotterContinents) > 0 && !containsFold(r.SpotterContinents, sp.SpotterContinent) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(r.SpotterCountries) > 0 && !containsFold(r.SpotterCountries, sp.SpotterCountry) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsFold(list []string, v string) bool {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, x := range list {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(x), v) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func anyWildcard(patterns []string, v string) bool {
|
||||||
|
for _, p := range patterns {
|
||||||
|
if matchWildcard(p, v) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchWildcard does a case-insensitive full-string match where '*' matches any
|
||||||
|
// run of characters and '?' matches one (e.g. "IW3*", "*/P", "F?BPO").
|
||||||
|
func matchWildcard(pattern, v string) bool {
|
||||||
|
pattern = strings.TrimSpace(pattern)
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if pattern == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("(?i)^")
|
||||||
|
for _, r := range pattern {
|
||||||
|
switch r {
|
||||||
|
case '*':
|
||||||
|
b.WriteString(".*")
|
||||||
|
case '?':
|
||||||
|
b.WriteString(".")
|
||||||
|
default:
|
||||||
|
b.WriteString(regexp.QuoteMeta(string(r)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString("$")
|
||||||
|
re, err := regexp.Compile(b.String())
|
||||||
|
if err != nil {
|
||||||
|
return strings.EqualFold(pattern, v)
|
||||||
|
}
|
||||||
|
return re.MatchString(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InferMode guesses a spot's mode from its comment and frequency. Cluster spots
|
||||||
|
// don't carry a mode field, so we read common tags (FT8/FT4/CW/RTTY/…) then fall
|
||||||
|
// back to the digital watering holes and the band-plan CW/phone split.
|
||||||
|
func InferMode(comment string, freqHz int64) string {
|
||||||
|
c := strings.ToUpper(comment)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(c, "FT8"):
|
||||||
|
return "FT8"
|
||||||
|
case strings.Contains(c, "FT4"):
|
||||||
|
return "FT4"
|
||||||
|
case strings.Contains(c, "RTTY"):
|
||||||
|
return "RTTY"
|
||||||
|
case strings.Contains(c, "PSK"):
|
||||||
|
return "PSK"
|
||||||
|
case strings.Contains(c, "JS8"):
|
||||||
|
return "JS8"
|
||||||
|
case strings.Contains(c, "CW"):
|
||||||
|
return "CW"
|
||||||
|
case strings.Contains(c, "SSB") || strings.Contains(c, "USB") || strings.Contains(c, "LSB") || strings.Contains(c, "PH"):
|
||||||
|
return "SSB"
|
||||||
|
}
|
||||||
|
khz := float64(freqHz) / 1000
|
||||||
|
// FT8 watering holes (…074) and FT4 (…080/…140) as a fallback.
|
||||||
|
for _, f := range []float64{1840, 3573, 7074, 10136, 14074, 18100, 21074, 24915, 28074, 50313} {
|
||||||
|
if khz >= f-1 && khz <= f+3 {
|
||||||
|
return "FT8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Band-plan CW segments (bottom of each band).
|
||||||
|
switch {
|
||||||
|
case khz >= 1810 && khz <= 1840,
|
||||||
|
khz >= 3500 && khz <= 3570,
|
||||||
|
khz >= 7000 && khz <= 7040,
|
||||||
|
khz >= 10100 && khz <= 10130,
|
||||||
|
khz >= 14000 && khz <= 14070,
|
||||||
|
khz >= 18068 && khz <= 18095,
|
||||||
|
khz >= 21000 && khz <= 21070,
|
||||||
|
khz >= 24890 && khz <= 24910,
|
||||||
|
khz >= 28000 && khz <= 28070:
|
||||||
|
return "CW"
|
||||||
|
}
|
||||||
|
return "SSB"
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
// Package antgenius drives a 4O3A Antenna Genius switch over its v4 TCP/IP
|
||||||
|
// text API (default port 9007). On connect the device sends a banner line
|
||||||
|
// (e.g. "V4.1.16 AG"); commands are "C<seq>|<command>\r" and the device replies
|
||||||
|
// with "R<seq>|<hex>|<message>" (hex "0" = success) plus asynchronous
|
||||||
|
// "S<0>|<message>" status pushes once you subscribe with "sub port/antenna".
|
||||||
|
//
|
||||||
|
// (The older "GSCP" binary-ish framing documented at gscp.arula.rs is only used
|
||||||
|
// by pre-v4 firmware and is NOT what v4 speaks.)
|
||||||
|
package antgenius
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPort = 9007
|
||||||
|
dialTimeout = 5 * time.Second
|
||||||
|
writeTimeout = 3 * time.Second
|
||||||
|
readIdleTimeout = 12 * time.Second // no data for this long → assume the link is dead
|
||||||
|
keepaliveEvery = 3 * time.Second // periodic "port get" refreshes state + keeps the link alive
|
||||||
|
reconnectDelay = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Antenna is one configured antenna (index + name as stored on the device).
|
||||||
|
type Antenna struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is the snapshot the UI renders.
|
||||||
|
type Status struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
PortA int `json:"port_a"` // active antenna index on port A (0 = none)
|
||||||
|
PortB int `json:"port_b"` // active antenna index on port B
|
||||||
|
TxA bool `json:"tx_a"` // port A is transmitting
|
||||||
|
TxB bool `json:"tx_b"` // port B is transmitting
|
||||||
|
Antennas []Antenna `json:"antennas"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
host string
|
||||||
|
port int
|
||||||
|
|
||||||
|
mu sync.Mutex // guards conn + writes
|
||||||
|
conn net.Conn
|
||||||
|
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
status Status
|
||||||
|
antennas map[int]string // index → name (rebuilt into status.Antennas)
|
||||||
|
|
||||||
|
stop chan struct{}
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(host string, port int) *Client {
|
||||||
|
if port <= 0 || port > 65535 {
|
||||||
|
port = defaultPort
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
antennas: map[int]string{},
|
||||||
|
status: Status{Host: host},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Start() error {
|
||||||
|
c.running = true
|
||||||
|
go c.runLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Stop() {
|
||||||
|
if !c.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.running = false
|
||||||
|
close(c.stop)
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetStatus() Status {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
return c.status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) setStatus(fn func(*Status)) {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
fn(&c.status)
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate selects antenna on a port (1 = A, 2 = B). antenna 0 deselects (sets
|
||||||
|
// the port to "None"). We set both RX and TX antennas and force manual mode so
|
||||||
|
// the choice sticks regardless of the device's auto band-following.
|
||||||
|
func (c *Client) Activate(port, antenna int) error {
|
||||||
|
if port != 1 && port != 2 {
|
||||||
|
return fmt.Errorf("antgenius: invalid port %d (1=A, 2=B)", port)
|
||||||
|
}
|
||||||
|
if antenna < 0 {
|
||||||
|
return fmt.Errorf("antgenius: invalid antenna %d", antenna)
|
||||||
|
}
|
||||||
|
// Set only rxant (like the reference ShackMaster client): the AG mirrors it
|
||||||
|
// to the TX antenna automatically. Forcing txant too can be rejected on the
|
||||||
|
// 8x2 (an antenna can't be TX on both ports at once), which broke port-B
|
||||||
|
// selection and deselection.
|
||||||
|
if err := c.send(fmt.Sprintf("port set %d rxant=%d", port, antenna)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Ask for the new port state so the snapshot reflects it promptly (the
|
||||||
|
// subscription also pushes it, but this makes the change deterministic).
|
||||||
|
_ = c.send(fmt.Sprintf("port get %d", port))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) runLoop() {
|
||||||
|
for {
|
||||||
|
if !c.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn, err := net.DialTimeout("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)), dialTimeout)
|
||||||
|
if err != nil {
|
||||||
|
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() })
|
||||||
|
if c.sleep(reconnectDelay) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.conn = conn
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = ""; s.Host = c.host })
|
||||||
|
|
||||||
|
// Subscribe to live updates and pull the initial state. Command set and
|
||||||
|
// order mirror a known-working Node-RED v4 client (WA9WUD).
|
||||||
|
_ = c.send("antenna list")
|
||||||
|
_ = c.send("sub port all")
|
||||||
|
_ = c.send("port get 1")
|
||||||
|
_ = c.send("port get 2")
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go c.keepalive(conn, done)
|
||||||
|
err = c.readLoop(conn) // blocks until the link errors
|
||||||
|
close(done)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.conn == conn {
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
conn.Close()
|
||||||
|
c.setStatus(func(s *Status) {
|
||||||
|
s.Connected = false
|
||||||
|
if err != nil {
|
||||||
|
s.LastError = "read: " + err.Error()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if c.sleep(reconnectDelay) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepalive periodically re-reads a port so an idle-but-dead link is detected
|
||||||
|
// (the read loop's idle timeout fires if these stop producing replies).
|
||||||
|
func (c *Client) keepalive(conn net.Conn, done chan struct{}) {
|
||||||
|
t := time.NewTicker(keepaliveEvery)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-c.stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
_ = c.send("port get 1")
|
||||||
|
_ = c.send("port get 2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) readLoop(conn net.Conn) error {
|
||||||
|
r := bufio.NewReader(conn)
|
||||||
|
var sb strings.Builder
|
||||||
|
for {
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(readIdleTimeout))
|
||||||
|
b, err := r.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if b == '\r' || b == '\n' {
|
||||||
|
if sb.Len() > 0 {
|
||||||
|
c.handleLine(sb.String())
|
||||||
|
sb.Reset()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sb.WriteByte(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// send writes a "C<seq>|<command>\r" line to the device.
|
||||||
|
func (c *Client) send(command string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn == nil {
|
||||||
|
return fmt.Errorf("antgenius: not connected")
|
||||||
|
}
|
||||||
|
_ = c.conn.SetWriteDeadline(time.Now().Add(writeTimeout))
|
||||||
|
// The device only accepts the constant "C1|" sequence prefix for every
|
||||||
|
// command (using incrementing sequence numbers makes it drop the link);
|
||||||
|
// commands are LF-terminated.
|
||||||
|
_, err := fmt.Fprintf(c.conn, "C1|%s\n", command)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLine parses one response/status/banner line and updates the snapshot.
|
||||||
|
func (c *Client) handleLine(line string) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Banner like "V4.1.16 AG" — just confirms the link is up.
|
||||||
|
if line[0] == 'V' && strings.Contains(line, "AG") {
|
||||||
|
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = "" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// R<seq>|<hex>|<message> or S<seq>|<message>
|
||||||
|
var msg string
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "R"):
|
||||||
|
p := strings.SplitN(line, "|", 3)
|
||||||
|
if len(p) == 3 {
|
||||||
|
msg = p[2]
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(line, "S"):
|
||||||
|
p := strings.SplitN(line, "|", 2)
|
||||||
|
if len(p) == 2 {
|
||||||
|
msg = p[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg = strings.TrimSpace(msg)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(msg, "antenna "):
|
||||||
|
c.parseAntenna(msg)
|
||||||
|
case strings.HasPrefix(msg, "port "):
|
||||||
|
c.parsePort(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAntenna handles "antenna <id> name=<name> tx=.. rx=.. inband=..".
|
||||||
|
// The name may contain spaces, so it's extracted up to the " tx=" field.
|
||||||
|
func (c *Client) parseAntenna(msg string) {
|
||||||
|
fields := strings.Fields(msg)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.Atoi(fields[1])
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := ""
|
||||||
|
if i := strings.Index(msg, "name="); i >= 0 {
|
||||||
|
name = msg[i+len("name="):]
|
||||||
|
if j := strings.Index(name, " tx="); j >= 0 {
|
||||||
|
name = name[:j]
|
||||||
|
}
|
||||||
|
// The device stores spaces as underscores in names.
|
||||||
|
name = strings.TrimSpace(strings.ReplaceAll(name, "_", " "))
|
||||||
|
}
|
||||||
|
c.statusMu.Lock()
|
||||||
|
if name != "" && !isPlaceholderName(name) {
|
||||||
|
c.antennas[id] = name
|
||||||
|
} else {
|
||||||
|
delete(c.antennas, id) // unconfigured slot ("Antenna 4", etc.) → not shown
|
||||||
|
}
|
||||||
|
c.status.Antennas = sortedAntennas(c.antennas)
|
||||||
|
c.status.Connected = true
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePort handles "port <id> ... rxant=<n> txant=<n> ...". The active antenna
|
||||||
|
// shown is the TX antenna, falling back to the RX antenna when TX is none.
|
||||||
|
func (c *Client) parsePort(msg string) {
|
||||||
|
fields := strings.Fields(msg)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.Atoi(fields[1])
|
||||||
|
if err != nil || (id != 1 && id != 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tx := kvInt(msg, "txant")
|
||||||
|
rx := kvInt(msg, "rxant")
|
||||||
|
active := tx
|
||||||
|
if active == 0 {
|
||||||
|
active = rx
|
||||||
|
}
|
||||||
|
txOn := kvInt(msg, "tx") != 0 // the standalone "tx=0|1" transmit flag
|
||||||
|
c.setStatus(func(s *Status) {
|
||||||
|
s.Connected = true
|
||||||
|
if id == 1 {
|
||||||
|
s.PortA, s.TxA = active, txOn
|
||||||
|
} else {
|
||||||
|
s.PortB, s.TxB = active, txOn
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) sleep(d time.Duration) (stopped bool) {
|
||||||
|
select {
|
||||||
|
case <-c.stop:
|
||||||
|
return true
|
||||||
|
case <-time.After(d):
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// kvInt extracts the integer value of a "key=<int>" token from a space-
|
||||||
|
// separated string (returns 0 if absent).
|
||||||
|
func kvInt(s, key string) int {
|
||||||
|
i := strings.Index(s, key+"=")
|
||||||
|
if i < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
v := s[i+len(key)+1:]
|
||||||
|
if sp := strings.IndexByte(v, ' '); sp >= 0 {
|
||||||
|
v = v[:sp]
|
||||||
|
}
|
||||||
|
n, _ := strconv.Atoi(strings.TrimSpace(v))
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPlaceholderName reports whether name is an unconfigured-slot default like
|
||||||
|
// "Antenna 4" / "antenna_5" (after underscores become spaces): the word
|
||||||
|
// "antenna" followed by a number, which the UI shouldn't list.
|
||||||
|
func isPlaceholderName(name string) bool {
|
||||||
|
f := strings.Fields(strings.ToLower(name))
|
||||||
|
if len(f) != 2 || f[0] != "antenna" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := strconv.Atoi(f[1])
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedAntennas(m map[int]string) []Antenna {
|
||||||
|
out := make([]Antenna, 0, len(m))
|
||||||
|
for idx, name := range m {
|
||||||
|
out = append(out, Antenna{Index: idx, Name: name})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index })
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package antgenius
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestHandleAntennaList(t *testing.T) {
|
||||||
|
c := New("x", 9007)
|
||||||
|
// Names may contain spaces — must be captured up to " tx=".
|
||||||
|
c.handleLine("R3|0|antenna 1 name=UB VL2.3 tx=ffff rx=ffff inband=0000")
|
||||||
|
c.handleLine("R3|0|antenna 2 name=DX Commander tx=00ff rx=00ff inband=0000")
|
||||||
|
st := c.GetStatus()
|
||||||
|
if len(st.Antennas) != 2 {
|
||||||
|
t.Fatalf("got %d antennas, want 2: %+v", len(st.Antennas), st.Antennas)
|
||||||
|
}
|
||||||
|
if st.Antennas[0].Index != 1 || st.Antennas[0].Name != "UB VL2.3" {
|
||||||
|
t.Errorf("antenna 1 = %+v", st.Antennas[0])
|
||||||
|
}
|
||||||
|
if st.Antennas[1].Name != "DX Commander" {
|
||||||
|
t.Errorf("antenna 2 name = %q", st.Antennas[1].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntennaUnderscoreAndPlaceholder(t *testing.T) {
|
||||||
|
c := New("x", 9007)
|
||||||
|
c.handleLine("R3|0|antenna 1 name=Hex_Beam tx=ffff rx=ffff inband=0000") // underscore → space
|
||||||
|
c.handleLine("R3|0|antenna 4 name=Antenna_4 tx=0000 rx=0000 inband=0000") // default → filtered
|
||||||
|
c.handleLine("R3|0|antenna 5 name=antenna 5 tx=0000 rx=0000 inband=0000") // default → filtered
|
||||||
|
st := c.GetStatus()
|
||||||
|
if len(st.Antennas) != 1 || st.Antennas[0].Name != "Hex Beam" {
|
||||||
|
t.Fatalf("want only [Hex Beam], got %+v", st.Antennas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePortStatus(t *testing.T) {
|
||||||
|
c := New("x", 9007)
|
||||||
|
// Async push after "sub port all": active antenna is txant (fallback rxant).
|
||||||
|
c.handleLine("S0|port 1 source=MANUAL band=6 rxant=2 txant=2 inband=0 inhibit=0")
|
||||||
|
c.handleLine("S0|port 2 source=AUTO band=0 rxant=0 txant=0 inband=0 inhibit=0")
|
||||||
|
st := c.GetStatus()
|
||||||
|
if st.PortA != 2 {
|
||||||
|
t.Errorf("PortA = %d, want 2", st.PortA)
|
||||||
|
}
|
||||||
|
if st.PortB != 0 {
|
||||||
|
t.Errorf("PortB = %d, want 0 (none)", st.PortB)
|
||||||
|
}
|
||||||
|
// A "port get" reply (R-line) must parse the same way.
|
||||||
|
c.handleLine("R15|0|port 2 source=MANUAL band=3 rxant=5 txant=5 inband=0 inhibit=0")
|
||||||
|
if st = c.GetStatus(); st.PortB != 5 {
|
||||||
|
t.Errorf("PortB after port get = %d, want 5", st.PortB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPortTxFallbackToRx(t *testing.T) {
|
||||||
|
c := New("x", 9007)
|
||||||
|
c.handleLine("S0|port 1 source=MANUAL band=6 rxant=3 txant=0 inband=0 inhibit=0")
|
||||||
|
if st := c.GetStatus(); st.PortA != 3 {
|
||||||
|
t.Errorf("PortA = %d, want 3 (rx fallback when tx=0)", st.PortA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKvInt(t *testing.T) {
|
||||||
|
s := "port 1 source=MANUAL band=6 rxant=2 txant=7 inhibit=0"
|
||||||
|
if v := kvInt(s, "txant"); v != 7 {
|
||||||
|
t.Errorf("txant = %d, want 7", v)
|
||||||
|
}
|
||||||
|
if v := kvInt(s, "rxant"); v != 2 {
|
||||||
|
t.Errorf("rxant = %d, want 2", v)
|
||||||
|
}
|
||||||
|
if v := kvInt(s, "missing"); v != 0 {
|
||||||
|
t.Errorf("missing = %d, want 0", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime/debug"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -19,6 +20,7 @@ var (
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
file *os.File
|
file *os.File
|
||||||
path string
|
path string
|
||||||
|
crashFile *os.File // kept open so the runtime can write a crash traceback to it
|
||||||
)
|
)
|
||||||
|
|
||||||
// Init opens (creates) the log file in dataDir. On rotation we truncate
|
// Init opens (creates) the log file in dataDir. On rotation we truncate
|
||||||
@@ -57,6 +59,17 @@ func Init(dataDir string) (string, error) {
|
|||||||
file = f
|
file = f
|
||||||
path = logPath
|
path = logPath
|
||||||
|
|
||||||
|
// Capture a full traceback on a FATAL crash (a Go panic that escapes our
|
||||||
|
// recover()s, or a runtime-fatal error like a concurrent map write, or a
|
||||||
|
// Windows access violation routed through the Go signal handler) into a
|
||||||
|
// dedicated file the runtime writes directly — so otherwise-silent process
|
||||||
|
// deaths leave a stack we can read.
|
||||||
|
if cf, cerr := os.OpenFile(filepath.Join(dataDir, "opslog-crash.log"),
|
||||||
|
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644); cerr == nil {
|
||||||
|
crashFile = cf
|
||||||
|
_ = debug.SetCrashOutput(cf, debug.CrashOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect log.Print* and the standard logger to the file too, so
|
// Redirect log.Print* and the standard logger to the file too, so
|
||||||
// any third-party output stays consistent.
|
// any third-party output stays consistent.
|
||||||
log.SetOutput(io.MultiWriter(file, os.Stderr))
|
log.SetOutput(io.MultiWriter(file, os.Stderr))
|
||||||
|
|||||||
@@ -32,6 +32,20 @@ func writeMP3(path string, pcm []byte) error {
|
|||||||
for i, v := range mono32 {
|
for i, v := range mono32 {
|
||||||
stereo[2*i], stereo[2*i+1] = v, v
|
stereo[2*i], stereo[2*i+1] = v, v
|
||||||
}
|
}
|
||||||
|
// Shine's Write() reads a WHOLE frame (samplesPerPass × channels = 1152 × 2 =
|
||||||
|
// 2304 interleaved samples for MPEG-1) per pass via unsafe pointer arithmetic,
|
||||||
|
// regardless of how short the trailing chunk is. If the buffer isn't an exact
|
||||||
|
// multiple of a frame, the final pass reads past the slice and the process
|
||||||
|
// dies with an access violation (0xc0000005) inside windowFilterSubband.
|
||||||
|
// Pad with trailing silence to a whole number of frames so no partial pass
|
||||||
|
// exists. (~36 ms of silence at most — inaudible.)
|
||||||
|
const frameInterleaved = 1152 * 2 // samplesPerPass(MPEG-1) × 2 channels
|
||||||
|
if rem := len(stereo) % frameInterleaved; rem != 0 {
|
||||||
|
stereo = append(stereo, make([]int16, frameInterleaved-rem)...)
|
||||||
|
}
|
||||||
|
if len(stereo) == 0 {
|
||||||
|
return nil // nothing to encode (empty recording)
|
||||||
|
}
|
||||||
enc := mp3.NewEncoder(mp3Rate, 2)
|
enc := mp3.NewEncoder(mp3Rate, 2)
|
||||||
return enc.Write(f, stereo)
|
return enc.Write(f, stereo)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,6 +239,22 @@ func (r *Recorder) RestartQSO() {
|
|||||||
r.active = true
|
r.active = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetQSOClock restarts the active accumulation from ZERO — discarding
|
||||||
|
// everything captured so far INCLUDING the pre-roll. Unlike RestartQSO (which
|
||||||
|
// re-seeds from the pre-roll ring), this keeps nothing: the saved file will
|
||||||
|
// contain only audio from this moment onward. Used when the contact you entered
|
||||||
|
// was already in a long QSO and you want to record just your own exchange.
|
||||||
|
// No-op if not running; if no take is active it begins one (empty).
|
||||||
|
func (r *Recorder) ResetQSOClock() {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if !r.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.acc = nil
|
||||||
|
r.active = true
|
||||||
|
}
|
||||||
|
|
||||||
// TakeQSO snapshots the accumulated recording as raw 16 kHz mono PCM bytes and
|
// TakeQSO snapshots the accumulated recording as raw 16 kHz mono PCM bytes and
|
||||||
// stops accumulating — fast, no encoding. The next BeginQSO can safely start a
|
// stops accumulating — fast, no encoding. The next BeginQSO can safely start a
|
||||||
// new take immediately. Pair with WritePCM to encode/write off the hot path so
|
// new take immediately. Pair with WritePCM to encode/write off the hot path so
|
||||||
|
|||||||
@@ -248,9 +248,17 @@ type FlexTXState struct {
|
|||||||
ATUMemories bool `json:"atu_memories"`
|
ATUMemories bool `json:"atu_memories"`
|
||||||
// Active RX slice DSP controls.
|
// Active RX slice DSP controls.
|
||||||
RXAvail bool `json:"rx_avail"` // an RX slice exists
|
RXAvail bool `json:"rx_avail"` // an RX slice exists
|
||||||
|
Split bool `json:"split"` // RX/TX on separate slices
|
||||||
|
RXFreqHz int64 `json:"rx_freq_hz,omitempty"` // RX slice freq when split
|
||||||
|
TXFreqHz int64 `json:"tx_freq_hz,omitempty"` // TX slice freq when split
|
||||||
AGCMode string `json:"agc_mode,omitempty"`
|
AGCMode string `json:"agc_mode,omitempty"`
|
||||||
AGCThreshold int `json:"agc_threshold"`
|
AGCThreshold int `json:"agc_threshold"`
|
||||||
AudioLevel int `json:"audio_level"`
|
AudioLevel int `json:"audio_level"`
|
||||||
|
Mute bool `json:"mute"`
|
||||||
|
RXAnt string `json:"rx_ant,omitempty"` // selected RX antenna
|
||||||
|
TXAnt string `json:"tx_ant,omitempty"` // selected TX antenna
|
||||||
|
AntList []string `json:"ant_list,omitempty"` // antennas selectable for RX
|
||||||
|
TXAntList []string `json:"tx_ant_list,omitempty"` // antennas selectable for TX
|
||||||
NB bool `json:"nb"`
|
NB bool `json:"nb"`
|
||||||
NBLevel int `json:"nb_level"`
|
NBLevel int `json:"nb_level"`
|
||||||
NR bool `json:"nr"`
|
NR bool `json:"nr"`
|
||||||
@@ -312,6 +320,10 @@ type FlexController interface {
|
|||||||
SetAGCMode(string) error
|
SetAGCMode(string) error
|
||||||
SetAGCThreshold(int) error
|
SetAGCThreshold(int) error
|
||||||
SetAudioLevel(int) error
|
SetAudioLevel(int) error
|
||||||
|
SetMute(bool) error
|
||||||
|
SetRXAntenna(string) error
|
||||||
|
SetTXAntenna(string) error
|
||||||
|
SetSplit(bool) error
|
||||||
SetNB(bool) error
|
SetNB(bool) error
|
||||||
SetNBLevel(int) error
|
SetNBLevel(int) error
|
||||||
SetNR(bool) error
|
SetNR(bool) error
|
||||||
@@ -327,6 +339,7 @@ type FlexController interface {
|
|||||||
SetCWSidetone(bool) error
|
SetCWSidetone(bool) error
|
||||||
SetSidetoneLevel(int) error
|
SetSidetoneLevel(int) error
|
||||||
SetCWFilter(int) error
|
SetCWFilter(int) error
|
||||||
|
SetFilter(lo, hi int) error
|
||||||
// External amplifier (PowerGenius XL) operate/standby.
|
// External amplifier (PowerGenius XL) operate/standby.
|
||||||
SetAmpOperate(bool) error
|
SetAmpOperate(bool) error
|
||||||
}
|
}
|
||||||
@@ -355,6 +368,70 @@ func (m *Manager) FlexDo(fn func(FlexController) error) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IcomTXState is the Icom receive-DSP state surfaced to the dedicated Icom
|
||||||
|
// control tab. Levels are 0-100 (scaled from the rig's 0-255). Unlike Flex,
|
||||||
|
// the Icom doesn't push changes, so these reflect the last RefreshIcom() read
|
||||||
|
// plus the optimistic updates each setter applies.
|
||||||
|
type IcomTXState struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Mode string `json:"mode,omitempty"`
|
||||||
|
AFGain int `json:"af_gain"`
|
||||||
|
RFGain int `json:"rf_gain"`
|
||||||
|
NB bool `json:"nb"`
|
||||||
|
NBLevel int `json:"nb_level"`
|
||||||
|
NR bool `json:"nr"`
|
||||||
|
NRLevel int `json:"nr_level"`
|
||||||
|
ANF bool `json:"anf"`
|
||||||
|
AGC string `json:"agc,omitempty"` // FAST | MID | SLOW
|
||||||
|
Preamp int `json:"preamp"` // 0=off, 1=P.AMP1, 2=P.AMP2
|
||||||
|
Att int `json:"att"` // dB attenuation, 0=off
|
||||||
|
Filter int `json:"filter"` // 1 | 2 | 3 (FIL1/2/3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IcomController is an OPTIONAL backend capability (the Icom CI-V backend): the
|
||||||
|
// receive-DSP controls shown on the Icom tab. IcomState() is mutex-guarded in
|
||||||
|
// the backend so it's safe off the CAT goroutine; setters dispatch via IcomDo.
|
||||||
|
type IcomController interface {
|
||||||
|
IcomState() IcomTXState
|
||||||
|
RefreshIcom() error // re-read all DSP state from the rig
|
||||||
|
SetAFGain(int) error
|
||||||
|
SetRFGain(int) error
|
||||||
|
SetNB(bool) error
|
||||||
|
SetNBLevel(int) error
|
||||||
|
SetNR(bool) error
|
||||||
|
SetNRLevel(int) error
|
||||||
|
SetANF(bool) error
|
||||||
|
SetAGC(string) error
|
||||||
|
SetPreamp(int) error
|
||||||
|
SetAtt(int) error
|
||||||
|
SetIcomFilter(int) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// IcomState returns the current Icom DSP state, or (zero, false) when the active
|
||||||
|
// backend isn't an Icom. Safe to call from any goroutine.
|
||||||
|
func (m *Manager) IcomState() (IcomTXState, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
b := m.backend
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if ic, ok := b.(IcomController); ok {
|
||||||
|
return ic.IcomState(), true
|
||||||
|
}
|
||||||
|
return IcomTXState{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IcomDo dispatches an Icom control onto the CAT goroutine. Errors if the
|
||||||
|
// active backend isn't an Icom.
|
||||||
|
func (m *Manager) IcomDo(fn func(IcomController) error) error {
|
||||||
|
return m.exec(func(b Backend) error {
|
||||||
|
ic, ok := b.(IcomController)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("active CAT backend is not an Icom")
|
||||||
|
}
|
||||||
|
return fn(ic)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 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 {
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
// Package civ implements the Icom CI-V protocol independently of the transport
|
||||||
|
// carrying it. The exact same frames travel over a USB/serial port (local
|
||||||
|
// control) and, wrapped in Icom's UDP "serial" stream, over the network
|
||||||
|
// (remote control). Keeping the wire format in one place means the USB backend
|
||||||
|
// (icomserial) and a future network backend (icomnet) share all of it — only
|
||||||
|
// the transport differs.
|
||||||
|
//
|
||||||
|
// Frame layout: FE FE <to> <from> <cmd> [sub] [data…] FD
|
||||||
|
package civ
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Protocol bytes.
|
||||||
|
const (
|
||||||
|
Pre = 0xFE // preamble (sent twice at the start of every frame)
|
||||||
|
End = 0xFD // end-of-message
|
||||||
|
OK = 0xFB // rig acknowledged a set command
|
||||||
|
NG = 0xFA // rig rejected a set command
|
||||||
|
|
||||||
|
// AddrController is the conventional address software uses for itself.
|
||||||
|
AddrController = 0xE0
|
||||||
|
)
|
||||||
|
|
||||||
|
// Commands (the few Phase-1 control needs; more get added with the panel).
|
||||||
|
const (
|
||||||
|
CmdTransceiveFreq = 0x00 // unsolicited freq update (dial turned)
|
||||||
|
CmdTransceiveMode = 0x01 // unsolicited mode update
|
||||||
|
CmdReadFreq = 0x03
|
||||||
|
CmdReadMode = 0x04
|
||||||
|
CmdSetFreq = 0x05
|
||||||
|
CmdSetMode = 0x06
|
||||||
|
CmdPTT = 0x1C // sub 0x00 = PTT
|
||||||
|
CmdExtra = 0x1A // sub 0x06 = data mode on modern Icoms
|
||||||
|
CmdReadID = 0x19 // sub 0x00 = rig's own CI-V address (identifies model)
|
||||||
|
|
||||||
|
CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off)
|
||||||
|
CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255)
|
||||||
|
CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte)
|
||||||
|
|
||||||
|
SubDataMode = 0x06
|
||||||
|
SubPTT = 0x00
|
||||||
|
|
||||||
|
// CmdLevel sub-commands.
|
||||||
|
SubLevelAF = 0x01 // AF (volume)
|
||||||
|
SubLevelRF = 0x02 // RF gain
|
||||||
|
SubLevelNR = 0x06 // noise-reduction depth
|
||||||
|
SubLevelNB = 0x12 // noise-blanker depth
|
||||||
|
|
||||||
|
// CmdSwitch sub-commands.
|
||||||
|
SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2
|
||||||
|
SubSwAGC = 0x12 // 1=FAST, 2=MID, 3=SLOW
|
||||||
|
SubSwNB = 0x22 // noise blanker on/off
|
||||||
|
SubSwNR = 0x40 // noise reduction on/off
|
||||||
|
SubSwANF = 0x41 // auto-notch on/off
|
||||||
|
)
|
||||||
|
|
||||||
|
// Icom mode codes (used by CmdReadMode / CmdSetMode).
|
||||||
|
const (
|
||||||
|
ModeLSB = 0x00
|
||||||
|
ModeUSB = 0x01
|
||||||
|
ModeAM = 0x02
|
||||||
|
ModeCW = 0x03
|
||||||
|
ModeRTTY = 0x04
|
||||||
|
ModeFM = 0x05
|
||||||
|
ModeCWR = 0x07
|
||||||
|
ModeRTTYR = 0x08
|
||||||
|
)
|
||||||
|
|
||||||
|
// Frame builds a complete CI-V frame (preamble … end) for payload, which is the
|
||||||
|
// command byte followed by any sub-command/data bytes.
|
||||||
|
func Frame(to, from byte, payload ...byte) []byte {
|
||||||
|
f := make([]byte, 0, len(payload)+5)
|
||||||
|
f = append(f, Pre, Pre, to, from)
|
||||||
|
f = append(f, payload...)
|
||||||
|
f = append(f, End)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// FreqToBCD encodes a frequency in Hz as the 5 little-endian BCD bytes Icom
|
||||||
|
// expects (10 digits, 2 per byte, least-significant byte first).
|
||||||
|
func FreqToBCD(hz int64) []byte {
|
||||||
|
if hz < 0 {
|
||||||
|
hz = 0
|
||||||
|
}
|
||||||
|
b := make([]byte, 5)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
lo := hz % 10
|
||||||
|
hz /= 10
|
||||||
|
hi := hz % 10
|
||||||
|
hz /= 10
|
||||||
|
b[i] = byte(lo) | byte(hi)<<4
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// BCDToFreq decodes Icom little-endian BCD frequency bytes back to Hz.
|
||||||
|
func BCDToFreq(b []byte) int64 {
|
||||||
|
var hz int64
|
||||||
|
mult := int64(1)
|
||||||
|
for i := 0; i < len(b) && i < 5; i++ {
|
||||||
|
hz += int64(b[i]&0x0F) * mult
|
||||||
|
mult *= 10
|
||||||
|
hz += int64(b[i]>>4) * mult
|
||||||
|
mult *= 10
|
||||||
|
}
|
||||||
|
return hz
|
||||||
|
}
|
||||||
|
|
||||||
|
// LevelToBCD encodes a 0-255 level as the 2 big-endian BCD bytes Icom's
|
||||||
|
// CmdLevel commands use (e.g. 128 → 0x01 0x28, 255 → 0x02 0x55).
|
||||||
|
func LevelToBCD(v int) []byte {
|
||||||
|
if v < 0 {
|
||||||
|
v = 0
|
||||||
|
}
|
||||||
|
if v > 255 {
|
||||||
|
v = 255
|
||||||
|
}
|
||||||
|
return []byte{byte(v / 100), byte(((v/10)%10)<<4 | v%10)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BCDToLevel decodes the 2 BCD bytes of a CmdLevel response back to 0-255.
|
||||||
|
func BCDToLevel(b []byte) int {
|
||||||
|
if len(b) < 2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int(b[0])*100 + int(b[1]>>4)*10 + int(b[1]&0x0F)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ByteToBCD / BCDToByte handle a single packed-BCD byte (used by the
|
||||||
|
// attenuator, where the value is dB: 0x00, 0x06, 0x12, 0x18…).
|
||||||
|
func ByteToBCD(v int) byte {
|
||||||
|
if v < 0 {
|
||||||
|
v = 0
|
||||||
|
}
|
||||||
|
if v > 99 {
|
||||||
|
v = 99
|
||||||
|
}
|
||||||
|
return byte((v/10)<<4 | v%10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BCDToByte(b byte) int { return int(b>>4)*10 + int(b&0x0F) }
|
||||||
|
|
||||||
|
// ModeToADIF maps an Icom mode byte (plus the data-mode flag) to an ADIF mode
|
||||||
|
// string. Data mode on USB/LSB is surfaced as "DATA" so the app can substitute
|
||||||
|
// the user's preferred digital mode (FT8/RTTY/…), matching the OmniRig backend.
|
||||||
|
func ModeToADIF(m byte, data bool) string {
|
||||||
|
switch m {
|
||||||
|
case ModeCW, ModeCWR:
|
||||||
|
return "CW"
|
||||||
|
case ModeRTTY, ModeRTTYR:
|
||||||
|
return "RTTY"
|
||||||
|
case ModeAM:
|
||||||
|
return "AM"
|
||||||
|
case ModeFM:
|
||||||
|
return "FM"
|
||||||
|
case ModeLSB, ModeUSB:
|
||||||
|
if data {
|
||||||
|
return "DATA"
|
||||||
|
}
|
||||||
|
return "SSB"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelName maps a rig's default CI-V address (from CmdReadID) to a readable
|
||||||
|
// model. Unknown addresses fall back to a hex label.
|
||||||
|
func ModelName(addr byte) string {
|
||||||
|
switch addr {
|
||||||
|
case 0x94:
|
||||||
|
return "IC-7300"
|
||||||
|
case 0x98:
|
||||||
|
return "IC-7610"
|
||||||
|
case 0xA2:
|
||||||
|
return "IC-9700"
|
||||||
|
case 0xA4:
|
||||||
|
return "IC-705"
|
||||||
|
case 0x88:
|
||||||
|
return "IC-7700"
|
||||||
|
case 0x80:
|
||||||
|
return "IC-7800"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Icom (0x%02X)", addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decoded is one parsed CI-V frame. Data is everything after the command byte
|
||||||
|
// (so it still carries the sub-command for multi-byte commands like 1A 06).
|
||||||
|
type Decoded struct {
|
||||||
|
To byte
|
||||||
|
From byte
|
||||||
|
Cmd byte
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan extracts every complete frame from buf and reports how many leading
|
||||||
|
// bytes the caller may now discard. A trailing partial frame (or a lone
|
||||||
|
// preamble byte) is left unconsumed so it can be completed by the next read.
|
||||||
|
func Scan(buf []byte) (frames []Decoded, consumed int) {
|
||||||
|
pos := 0
|
||||||
|
for {
|
||||||
|
p := indexPreamble(buf, pos)
|
||||||
|
if p < 0 {
|
||||||
|
// No further preamble. Keep a trailing FE (possible start of the
|
||||||
|
// next preamble); otherwise everything seen is consumable.
|
||||||
|
if len(buf) > 0 && buf[len(buf)-1] == Pre {
|
||||||
|
return frames, len(buf) - 1
|
||||||
|
}
|
||||||
|
return frames, len(buf)
|
||||||
|
}
|
||||||
|
start := p + 2
|
||||||
|
for start < len(buf) && buf[start] == Pre { // tolerate padding FEs
|
||||||
|
start++
|
||||||
|
}
|
||||||
|
end := bytes.IndexByte(buf[start:], End)
|
||||||
|
if end < 0 {
|
||||||
|
return frames, p // incomplete frame — keep from its preamble
|
||||||
|
}
|
||||||
|
end += start
|
||||||
|
if body := buf[start:end]; len(body) >= 3 {
|
||||||
|
frames = append(frames, Decoded{
|
||||||
|
To: body[0],
|
||||||
|
From: body[1],
|
||||||
|
Cmd: body[2],
|
||||||
|
Data: append([]byte(nil), body[3:]...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pos = end + 1
|
||||||
|
consumed = pos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexPreamble returns the index of the next FE FE pair at or after from.
|
||||||
|
func indexPreamble(buf []byte, from int) int {
|
||||||
|
for i := from; i+1 < len(buf); i++ {
|
||||||
|
if buf[i] == Pre && buf[i+1] == Pre {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package civ
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFreqBCDRoundTrip(t *testing.T) {
|
||||||
|
cases := []int64{0, 1, 7074000, 14250000, 28074000, 50313000, 144174000, 1296000000}
|
||||||
|
for _, hz := range cases {
|
||||||
|
b := FreqToBCD(hz)
|
||||||
|
if len(b) != 5 {
|
||||||
|
t.Fatalf("FreqToBCD(%d) len=%d, want 5", hz, len(b))
|
||||||
|
}
|
||||||
|
if got := BCDToFreq(b); got != hz {
|
||||||
|
t.Errorf("round trip %d → % X → %d", hz, b, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreqBCDKnownEncoding(t *testing.T) {
|
||||||
|
// 14.250.000 Hz → little-endian BCD 00 00 25 14 00.
|
||||||
|
want := []byte{0x00, 0x00, 0x25, 0x14, 0x00}
|
||||||
|
if got := FreqToBCD(14250000); !bytes.Equal(got, want) {
|
||||||
|
t.Errorf("FreqToBCD(14250000) = % X, want % X", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFrame(t *testing.T) {
|
||||||
|
// Read-frequency request to a 7610 (0x98) from the controller (0xE0).
|
||||||
|
got := Frame(0x98, AddrController, CmdReadFreq)
|
||||||
|
want := []byte{0xFE, 0xFE, 0x98, 0xE0, 0x03, 0xFD}
|
||||||
|
if !bytes.Equal(got, want) {
|
||||||
|
t.Errorf("Frame = % X, want % X", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanSingleFreqResponse(t *testing.T) {
|
||||||
|
// Rig (0x98) → controller (0xE0): freq read response for 14.250 MHz.
|
||||||
|
in := Frame(AddrController, 0x98, CmdReadFreq, 0x00, 0x00, 0x25, 0x14, 0x00)
|
||||||
|
frames, consumed := Scan(in)
|
||||||
|
if consumed != len(in) {
|
||||||
|
t.Fatalf("consumed=%d, want %d", consumed, len(in))
|
||||||
|
}
|
||||||
|
if len(frames) != 1 {
|
||||||
|
t.Fatalf("got %d frames, want 1", len(frames))
|
||||||
|
}
|
||||||
|
f := frames[0]
|
||||||
|
if f.From != 0x98 || f.To != AddrController || f.Cmd != CmdReadFreq {
|
||||||
|
t.Errorf("addrs/cmd wrong: %+v", f)
|
||||||
|
}
|
||||||
|
if hz := BCDToFreq(f.Data); hz != 14250000 {
|
||||||
|
t.Errorf("decoded freq %d, want 14250000", hz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanSkipsEchoAndKeepsPartial(t *testing.T) {
|
||||||
|
echo := Frame(0x98, AddrController, CmdReadFreq) // our outgoing (echoed back)
|
||||||
|
resp := Frame(AddrController, 0x98, CmdReadMode, ModeCW, 0x01) // a real response
|
||||||
|
buf := append(append([]byte{}, echo...), resp...)
|
||||||
|
buf = append(buf, 0xFE, 0xFE, 0x98) // a partial third frame (no FD yet)
|
||||||
|
|
||||||
|
frames, consumed := Scan(buf)
|
||||||
|
if len(frames) != 2 {
|
||||||
|
t.Fatalf("got %d frames, want 2", len(frames))
|
||||||
|
}
|
||||||
|
// The partial frame must be left unconsumed so the next read can finish it.
|
||||||
|
if consumed != len(echo)+len(resp) {
|
||||||
|
t.Errorf("consumed=%d, want %d (partial frame retained)", consumed, len(echo)+len(resp))
|
||||||
|
}
|
||||||
|
if frames[1].Cmd != CmdReadMode || len(frames[1].Data) < 1 || frames[1].Data[0] != ModeCW {
|
||||||
|
t.Errorf("second frame wrong: %+v", frames[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModeToADIF(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
m byte
|
||||||
|
data bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{ModeUSB, false, "SSB"},
|
||||||
|
{ModeLSB, false, "SSB"},
|
||||||
|
{ModeUSB, true, "DATA"},
|
||||||
|
{ModeCW, false, "CW"},
|
||||||
|
{ModeCWR, false, "CW"},
|
||||||
|
{ModeRTTY, false, "RTTY"},
|
||||||
|
{ModeAM, false, "AM"},
|
||||||
|
{ModeFM, false, "FM"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := ModeToADIF(c.m, c.data); got != c.want {
|
||||||
|
t.Errorf("ModeToADIF(0x%02X, %v) = %q, want %q", c.m, c.data, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLevelBCDRoundTrip(t *testing.T) {
|
||||||
|
for _, v := range []int{0, 1, 50, 99, 100, 128, 200, 255} {
|
||||||
|
b := LevelToBCD(v)
|
||||||
|
if len(b) != 2 {
|
||||||
|
t.Fatalf("LevelToBCD(%d) len=%d", v, len(b))
|
||||||
|
}
|
||||||
|
if got := BCDToLevel(b); got != v {
|
||||||
|
t.Errorf("level round trip %d → % X → %d", v, b, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Known encodings from the Icom CI-V reference.
|
||||||
|
if got := LevelToBCD(128); !bytes.Equal(got, []byte{0x01, 0x28}) {
|
||||||
|
t.Errorf("LevelToBCD(128) = % X, want 01 28", got)
|
||||||
|
}
|
||||||
|
if got := LevelToBCD(255); !bytes.Equal(got, []byte{0x02, 0x55}) {
|
||||||
|
t.Errorf("LevelToBCD(255) = % X, want 02 55", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestByteBCDRoundTrip(t *testing.T) {
|
||||||
|
for _, v := range []int{0, 6, 12, 18, 21} {
|
||||||
|
if got := BCDToByte(ByteToBCD(v)); got != v {
|
||||||
|
t.Errorf("byte BCD round trip %d → %d", v, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModelName(t *testing.T) {
|
||||||
|
if got := ModelName(0x98); got != "IC-7610" {
|
||||||
|
t.Errorf("ModelName(0x98) = %q, want IC-7610", got)
|
||||||
|
}
|
||||||
|
if got := ModelName(0x12); got != "Icom (0x12)" {
|
||||||
|
t.Errorf("ModelName(0x12) = %q, want fallback", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+146
-2
@@ -54,6 +54,7 @@ type Flex struct {
|
|||||||
spotsEnabled bool // push cluster spots + manage the panadapter overlay
|
spotsEnabled bool // push cluster spots + manage the panadapter overlay
|
||||||
spotIdx map[int]bool // panadapter spot indices currently known to the radio
|
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
|
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||||
|
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
||||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||||
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ type flexSlice struct {
|
|||||||
agcMode string // off | slow | med | fast
|
agcMode string // off | slow | med | fast
|
||||||
agcThreshold int // 0-100
|
agcThreshold int // 0-100
|
||||||
audioLevel int // 0-100 (RX volume)
|
audioLevel int // 0-100 (RX volume)
|
||||||
|
mute bool // RX audio muted
|
||||||
nb bool // noise blanker
|
nb bool // noise blanker
|
||||||
nbLevel int
|
nbLevel int
|
||||||
nr bool // noise reduction
|
nr bool // noise reduction
|
||||||
@@ -83,6 +85,10 @@ type flexSlice struct {
|
|||||||
apfLevel int
|
apfLevel int
|
||||||
filterLo int // slice filter low cut (Hz)
|
filterLo int // slice filter low cut (Hz)
|
||||||
filterHi int // slice filter high cut (Hz)
|
filterHi int // slice filter high cut (Hz)
|
||||||
|
rxAnt string // selected RX antenna (e.g. ANT1, ANT2, RX_A)
|
||||||
|
txAnt string // selected TX antenna
|
||||||
|
antList []string // antennas valid for this slice (RX side)
|
||||||
|
txAntList []string // antennas valid for TX
|
||||||
}
|
}
|
||||||
|
|
||||||
// flexTX mirrors the radio's transmit/ATU/interlock objects (the SmartSDR-style
|
// flexTX mirrors the radio's transmit/ATU/interlock objects (the SmartSDR-style
|
||||||
@@ -143,7 +149,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
|||||||
return &Flex{
|
return &Flex{
|
||||||
host: strings.TrimSpace(host), port: port,
|
host: strings.TrimSpace(host), port: port,
|
||||||
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
||||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{},
|
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, pendingSplit: map[int]bool{},
|
||||||
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||||
}
|
}
|
||||||
@@ -309,7 +315,18 @@ func (f *Flex) reader(conn net.Conn) {
|
|||||||
f.spotIdx[idx] = true
|
f.spotIdx[idx] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A successful "slice create" for split returns the new slice's index;
|
||||||
|
// make that slice the transmitter so RX/TX are on separate slices.
|
||||||
|
splitSeq := f.pendingSplit[seq]
|
||||||
|
if splitSeq {
|
||||||
|
delete(f.pendingSplit, seq)
|
||||||
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
|
if splitSeq && ok && len(parts) >= 3 {
|
||||||
|
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||||
|
f.send(fmt.Sprintf("slice s %d tx=1", idx))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Connection ended.
|
// Connection ended.
|
||||||
@@ -339,7 +356,10 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
// Transmit object — RF/tune power, VOX, speech processor, monitor, mic,
|
// Transmit object — RF/tune power, VOX, speech processor, monitor, mic,
|
||||||
// tune carrier. Field names per the SmartSDR API (logged so the exact set
|
// tune carrier. Field names per the SmartSDR API (logged so the exact set
|
||||||
// is auditable against a real radio).
|
// is auditable against a real radio).
|
||||||
if len(fields) >= 1 && fields[0] == "transmit" {
|
// "transmit band <N> band_name=… rfpower=…" lines are PER-BAND power
|
||||||
|
// presets, not the current TX state — ignore them, otherwise the last
|
||||||
|
// band's rfpower (e.g. 630m=100) clobbers the real current value.
|
||||||
|
if len(fields) >= 1 && fields[0] == "transmit" && !(len(fields) >= 2 && fields[1] == "band") {
|
||||||
if !f.txRawLogged {
|
if !f.txRawLogged {
|
||||||
f.txRawLogged = true
|
f.txRawLogged = true
|
||||||
debugLog.Printf("Flex: FIRST transmit status: %s", payload)
|
debugLog.Printf("Flex: FIRST transmit status: %s", payload)
|
||||||
@@ -667,6 +687,16 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
s.agcThreshold = atoiDefault(val, s.agcThreshold)
|
s.agcThreshold = atoiDefault(val, s.agcThreshold)
|
||||||
case "audio_level":
|
case "audio_level":
|
||||||
s.audioLevel = atoiDefault(val, s.audioLevel)
|
s.audioLevel = atoiDefault(val, s.audioLevel)
|
||||||
|
case "audio_mute", "mute":
|
||||||
|
s.mute = val == "1"
|
||||||
|
case "rxant":
|
||||||
|
s.rxAnt = val
|
||||||
|
case "txant":
|
||||||
|
s.txAnt = val
|
||||||
|
case "ant_list":
|
||||||
|
s.antList = splitCSV(val)
|
||||||
|
case "tx_ant_list":
|
||||||
|
s.txAntList = splitCSV(val)
|
||||||
case "nb":
|
case "nb":
|
||||||
s.nb = val == "1"
|
s.nb = val == "1"
|
||||||
case "nb_level":
|
case "nb_level":
|
||||||
@@ -954,6 +984,18 @@ func splitKV(kv string) (key, val string, ok bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// atoiDefault parses an int (or a float like "20.0", truncated), else def.
|
// atoiDefault parses an int (or a float like "20.0", truncated), else def.
|
||||||
|
// splitCSV splits a comma-separated antenna list (e.g. "ANT1,ANT2,RX_A") into a
|
||||||
|
// trimmed slice, dropping empties.
|
||||||
|
func splitCSV(s string) []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, p := range strings.Split(s, ",") {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func atoiDefault(s string, def int) int {
|
func atoiDefault(s string, def int) int {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
if n, err := strconv.Atoi(s); err == nil {
|
if n, err := strconv.Atoi(s); err == nil {
|
||||||
@@ -1020,12 +1062,18 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
CWSidetone: f.tx.cwSidetone,
|
CWSidetone: f.tx.cwSidetone,
|
||||||
CWMonLevel: f.tx.cwMonLevel,
|
CWMonLevel: f.tx.cwMonLevel,
|
||||||
}
|
}
|
||||||
|
if rx, tx := f.pickSlicesLocked(); rx != nil && tx != nil && rx != tx && rx.freqHz != tx.freqHz {
|
||||||
|
st.Split = true
|
||||||
|
st.RXFreqHz = rx.freqHz
|
||||||
|
st.TXFreqHz = tx.freqHz
|
||||||
|
}
|
||||||
if _, rx := f.rxSliceLocked(); rx != nil {
|
if _, rx := f.rxSliceLocked(); rx != nil {
|
||||||
st.RXAvail = true
|
st.RXAvail = true
|
||||||
st.Mode = strings.ToUpper(rx.mode)
|
st.Mode = strings.ToUpper(rx.mode)
|
||||||
st.AGCMode = rx.agcMode
|
st.AGCMode = rx.agcMode
|
||||||
st.AGCThreshold = rx.agcThreshold
|
st.AGCThreshold = rx.agcThreshold
|
||||||
st.AudioLevel = rx.audioLevel
|
st.AudioLevel = rx.audioLevel
|
||||||
|
st.Mute = rx.mute
|
||||||
st.NB = rx.nb
|
st.NB = rx.nb
|
||||||
st.NBLevel = rx.nbLevel
|
st.NBLevel = rx.nbLevel
|
||||||
st.NR = rx.nr
|
st.NR = rx.nr
|
||||||
@@ -1036,6 +1084,13 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
st.APFLevel = rx.apfLevel
|
st.APFLevel = rx.apfLevel
|
||||||
st.FilterLo = rx.filterLo
|
st.FilterLo = rx.filterLo
|
||||||
st.FilterHi = rx.filterHi
|
st.FilterHi = rx.filterHi
|
||||||
|
st.RXAnt = rx.rxAnt
|
||||||
|
st.TXAnt = rx.txAnt
|
||||||
|
st.AntList = rx.antList
|
||||||
|
st.TXAntList = rx.txAntList
|
||||||
|
if len(st.TXAntList) == 0 {
|
||||||
|
st.TXAntList = rx.antList // many configs share one antenna list
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if f.amp.handle != "" {
|
if f.amp.handle != "" {
|
||||||
st.AmpAvailable = true
|
st.AmpAvailable = true
|
||||||
@@ -1073,6 +1128,8 @@ func (f *Flex) sendSlice(param string, val any) error {
|
|||||||
rx.agcThreshold = toInt(val)
|
rx.agcThreshold = toInt(val)
|
||||||
case "audio_level":
|
case "audio_level":
|
||||||
rx.audioLevel = toInt(val)
|
rx.audioLevel = toInt(val)
|
||||||
|
case "audio_mute", "mute":
|
||||||
|
rx.mute = fmt.Sprint(val) == "1"
|
||||||
case "nb":
|
case "nb":
|
||||||
rx.nb = val == "1"
|
rx.nb = val == "1"
|
||||||
case "nb_level":
|
case "nb_level":
|
||||||
@@ -1089,6 +1146,10 @@ func (f *Flex) sendSlice(param string, val any) error {
|
|||||||
rx.apf = val == "1"
|
rx.apf = val == "1"
|
||||||
case "apf_level":
|
case "apf_level":
|
||||||
rx.apfLevel = toInt(val)
|
rx.apfLevel = toInt(val)
|
||||||
|
case "rxant":
|
||||||
|
rx.rxAnt = fmt.Sprint(val)
|
||||||
|
case "txant":
|
||||||
|
rx.txAnt = fmt.Sprint(val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
@@ -1123,6 +1184,68 @@ func (f *Flex) SetAGCMode(m string) error {
|
|||||||
}
|
}
|
||||||
func (f *Flex) SetAGCThreshold(l int) error { return f.sendSlice("agc_threshold", clampLevel(l)) }
|
func (f *Flex) SetAGCThreshold(l int) error { return f.sendSlice("agc_threshold", clampLevel(l)) }
|
||||||
func (f *Flex) SetAudioLevel(l int) error { return f.sendSlice("audio_level", clampLevel(l)) }
|
func (f *Flex) SetAudioLevel(l int) error { return f.sendSlice("audio_level", clampLevel(l)) }
|
||||||
|
func (f *Flex) SetMute(on bool) error { return f.sendSlice("audio_mute", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetRXAntenna(a string) error { return f.sendSlice("rxant", a) }
|
||||||
|
func (f *Flex) SetTXAntenna(a string) error { return f.sendSlice("txant", a) }
|
||||||
|
|
||||||
|
// SetSplit toggles 2-slice split like SmartSDR's SPLIT button. ON creates a TX
|
||||||
|
// slice at RX freq + offset (+1 kHz on CW, +5 kHz otherwise) and makes it the
|
||||||
|
// transmitter; OFF removes the extra TX slice and returns to simplex (RX slice
|
||||||
|
// transmits again).
|
||||||
|
func (f *Flex) SetSplit(on bool) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
if f.conn == nil {
|
||||||
|
f.mu.Unlock()
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
rx, tx := f.pickSlicesLocked()
|
||||||
|
rxIdx, txIdx := -1, -1
|
||||||
|
for i, s := range f.slices {
|
||||||
|
if s == rx {
|
||||||
|
rxIdx = i
|
||||||
|
}
|
||||||
|
if s == tx {
|
||||||
|
txIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alreadySplit := rx != nil && tx != nil && rx != tx
|
||||||
|
var rxFreq int64
|
||||||
|
var rxMode, rxAnt string
|
||||||
|
if rx != nil {
|
||||||
|
rxFreq, rxMode, rxAnt = rx.freqHz, rx.mode, rx.rxAnt
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
if on {
|
||||||
|
if alreadySplit || rx == nil {
|
||||||
|
return nil // already split (or no slice)
|
||||||
|
}
|
||||||
|
offset := int64(5000)
|
||||||
|
if strings.HasPrefix(strings.ToUpper(rxMode), "CW") {
|
||||||
|
offset = 1000
|
||||||
|
}
|
||||||
|
cmd := fmt.Sprintf("slice create freq=%.6f mode=%s", float64(rxFreq+offset)/1e6, rxMode)
|
||||||
|
if rxAnt != "" {
|
||||||
|
cmd += " ant=" + rxAnt
|
||||||
|
}
|
||||||
|
if seq := f.send(cmd); seq > 0 {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.pendingSplit[seq] = true // mark the new slice TX once its index returns
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !alreadySplit {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if txIdx >= 0 {
|
||||||
|
f.send(fmt.Sprintf("slice remove %d", txIdx))
|
||||||
|
}
|
||||||
|
if rxIdx >= 0 {
|
||||||
|
f.send(fmt.Sprintf("slice s %d tx=1", rxIdx))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (f *Flex) SetNB(on bool) error { return f.sendSlice("nb", boolFlex(on)) }
|
func (f *Flex) SetNB(on bool) error { return f.sendSlice("nb", boolFlex(on)) }
|
||||||
func (f *Flex) SetNBLevel(l int) error { return f.sendSlice("nb_level", clampLevel(l)) }
|
func (f *Flex) SetNBLevel(l int) error { return f.sendSlice("nb_level", clampLevel(l)) }
|
||||||
func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on)) }
|
func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on)) }
|
||||||
@@ -1220,6 +1343,27 @@ func (f *Flex) SetCWFilter(bw int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetFilter sets the active RX slice's passband to an explicit low/high cut (Hz,
|
||||||
|
// audio offsets; negative for LSB). Used by the SSB width presets, where the
|
||||||
|
// frontend keeps the carrier-side edge and extends the far edge.
|
||||||
|
func (f *Flex) SetFilter(lo, hi int) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
idx, rx := f.rxSliceLocked()
|
||||||
|
connected := f.conn != nil
|
||||||
|
if rx != nil {
|
||||||
|
rx.filterLo, rx.filterHi = lo, hi
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
if rx == nil || idx < 0 {
|
||||||
|
return fmt.Errorf("flex: no receive slice")
|
||||||
|
}
|
||||||
|
f.send(fmt.Sprintf("filt %d %d %d", idx, lo, hi))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// boolWord renders a Flex on/off boolean as the word form some commands want.
|
// boolWord renders a Flex on/off boolean as the word form some commands want.
|
||||||
func boolWord(on bool) string {
|
func boolWord(on bool) string {
|
||||||
if on {
|
if on {
|
||||||
|
|||||||
@@ -0,0 +1,557 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/cat/civ"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IcomSerial controls an Icom transceiver over its USB/serial CI-V port (local
|
||||||
|
// control). It speaks the shared civ protocol, so when the network backend
|
||||||
|
// (icomnet) is added it will reuse the same encode/decode — only the transport
|
||||||
|
// changes. Implements Backend; all methods run on the Manager's CAT goroutine,
|
||||||
|
// so the port is accessed single-threaded (no locking needed).
|
||||||
|
type IcomSerial struct {
|
||||||
|
portName string
|
||||||
|
baud int
|
||||||
|
rigAddr byte // rig's CI-V address (IC-7610 default 0x98)
|
||||||
|
digital string // mode to command for DATA (FT8/RTTY/…)
|
||||||
|
|
||||||
|
port serial.Port
|
||||||
|
rx []byte // accumulated bytes awaiting a complete frame
|
||||||
|
model string
|
||||||
|
|
||||||
|
curFreq int64 // last frequency read (for sideband choice)
|
||||||
|
curModeByte byte // last raw Icom mode byte (for filter re-send)
|
||||||
|
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
|
||||||
|
lastSetFreqAt time.Time
|
||||||
|
|
||||||
|
// dsp caches the receive-DSP state for the Icom control tab. Read off the
|
||||||
|
// CAT goroutine via IcomState(), written on the CAT goroutine (RefreshIcom
|
||||||
|
// / setters) — hence the mutex.
|
||||||
|
dspMu sync.Mutex
|
||||||
|
dsp IcomTXState
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
icomReadTimeout = 350 * time.Millisecond // wait for a poll response
|
||||||
|
icomCmdTimeout = 400 * time.Millisecond // wait for a set ack (FB/FA)
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewIcomSerial builds an (unconnected) Icom serial backend. baud defaults to
|
||||||
|
// 115200, rig address to the IC-7610's 0x98 when out of range.
|
||||||
|
func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *IcomSerial {
|
||||||
|
if baud <= 0 {
|
||||||
|
baud = 115200
|
||||||
|
}
|
||||||
|
if civAddr <= 0 || civAddr > 0xFF {
|
||||||
|
civAddr = 0x98 // IC-7610
|
||||||
|
}
|
||||||
|
if digitalDefault == "" {
|
||||||
|
digitalDefault = "FT8"
|
||||||
|
}
|
||||||
|
return &IcomSerial{
|
||||||
|
portName: portName,
|
||||||
|
baud: baud,
|
||||||
|
rigAddr: byte(civAddr),
|
||||||
|
digital: strings.ToUpper(digitalDefault),
|
||||||
|
model: "Icom",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) Name() string { return "icom" }
|
||||||
|
|
||||||
|
func (b *IcomSerial) Connect() error {
|
||||||
|
if b.portName == "" {
|
||||||
|
return fmt.Errorf("no serial port configured")
|
||||||
|
}
|
||||||
|
port, err := serial.Open(b.portName, &serial.Mode{BaudRate: b.baud})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open %s @ %d baud: %w", b.portName, b.baud, err)
|
||||||
|
}
|
||||||
|
// Short read timeout so recv() polls in a tight loop without blocking the
|
||||||
|
// CAT goroutine when the rig is silent.
|
||||||
|
_ = port.SetReadTimeout(60 * time.Millisecond)
|
||||||
|
b.port = port
|
||||||
|
b.rx = b.rx[:0]
|
||||||
|
b.model = civ.ModelName(b.rigAddr)
|
||||||
|
|
||||||
|
// Best-effort model identification: ask the rig for its own CI-V address.
|
||||||
|
if err := b.write(civ.CmdReadID, civ.SubPTT); err == nil {
|
||||||
|
if f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
||||||
|
return d.Cmd == civ.CmdReadID && len(d.Data) >= 2 && d.Data[0] == 0x00
|
||||||
|
}); err == nil {
|
||||||
|
b.model = civ.ModelName(f.Data[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.readDSP() // best-effort initial snapshot for the control tab
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) Disconnect() {
|
||||||
|
if b.port != nil {
|
||||||
|
_ = b.port.Close()
|
||||||
|
b.port = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadState polls the rig for frequency and mode. A failed frequency read is
|
||||||
|
// treated as "lost the rig" so the Manager reconnects.
|
||||||
|
func (b *IcomSerial) ReadState() (RigState, error) {
|
||||||
|
if b.port == nil {
|
||||||
|
return RigState{}, fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
s := RigState{Backend: b.Name(), Connected: true, Rig: b.model}
|
||||||
|
|
||||||
|
hz, err := b.readFreq()
|
||||||
|
if err != nil {
|
||||||
|
return RigState{}, err
|
||||||
|
}
|
||||||
|
s.FreqHz = hz
|
||||||
|
b.curFreq = hz
|
||||||
|
|
||||||
|
if m, ok := b.readMode(); ok {
|
||||||
|
b.curModeByte = m
|
||||||
|
data := b.readDataMode() // best-effort; ignored on failure
|
||||||
|
s.Mode = civ.ModeToADIF(m, data)
|
||||||
|
if s.Mode == "DATA" {
|
||||||
|
s.Mode = b.digital
|
||||||
|
}
|
||||||
|
b.dspMu.Lock()
|
||||||
|
b.dsp.Mode = s.Mode
|
||||||
|
b.dspMu.Unlock()
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetFrequency(hz int64) error {
|
||||||
|
if hz <= 0 {
|
||||||
|
return fmt.Errorf("invalid frequency")
|
||||||
|
}
|
||||||
|
b.lastSetFreq, b.lastSetFreqAt = hz, time.Now()
|
||||||
|
return b.exec(append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetMode(mode string) error {
|
||||||
|
code, data, err := b.modeCode(mode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Set the base mode (keeping the rig's current filter by sending only the
|
||||||
|
// mode byte), then set the data-mode flag for digital modes.
|
||||||
|
if err := b.exec(civ.CmdSetMode, code); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dataByte := byte(0)
|
||||||
|
if data {
|
||||||
|
dataByte = 1
|
||||||
|
}
|
||||||
|
// Filter 0x01 (FIL1) is the conventional default for the data-mode set.
|
||||||
|
_ = b.exec(civ.CmdExtra, civ.SubDataMode, dataByte, 0x01)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetPTT(on bool) error {
|
||||||
|
state := byte(0)
|
||||||
|
if on {
|
||||||
|
state = 1
|
||||||
|
}
|
||||||
|
return b.exec(civ.CmdPTT, civ.SubPTT, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (b *IcomSerial) write(payload ...byte) error {
|
||||||
|
_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, payload...))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// recv reads from the port until a frame from the rig satisfies match or the
|
||||||
|
// timeout elapses. Frames that are our own echo (from == controller) or don't
|
||||||
|
// match are discarded.
|
||||||
|
func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (civ.Decoded, error) {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
tmp := make([]byte, 256)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
n, err := b.port.Read(tmp)
|
||||||
|
if err != nil {
|
||||||
|
return civ.Decoded{}, err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.rx = append(b.rx, tmp[:n]...)
|
||||||
|
frames, consumed := civ.Scan(b.rx)
|
||||||
|
if consumed > 0 {
|
||||||
|
b.rx = append(b.rx[:0], b.rx[consumed:]...)
|
||||||
|
}
|
||||||
|
for _, f := range frames {
|
||||||
|
if f.From != b.rigAddr {
|
||||||
|
continue // skip echo of our own commands
|
||||||
|
}
|
||||||
|
if match(f) {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return civ.Decoded{}, fmt.Errorf("icom: timeout waiting for response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// exec sends a set command and waits for the rig's OK (FB) / NG (FA) ack.
|
||||||
|
func (b *IcomSerial) exec(payload ...byte) error {
|
||||||
|
if err := b.write(payload...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := b.recv(icomCmdTimeout, func(d civ.Decoded) bool {
|
||||||
|
return d.Cmd == civ.OK || d.Cmd == civ.NG
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if f.Cmd == civ.NG {
|
||||||
|
return fmt.Errorf("icom: rig rejected command 0x%02X", payload[0])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) readFreq() (int64, error) {
|
||||||
|
if err := b.write(civ.CmdReadFreq); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
||||||
|
return d.Cmd == civ.CmdReadFreq || d.Cmd == civ.CmdTransceiveFreq
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return civ.BCDToFreq(f.Data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) readMode() (byte, bool) {
|
||||||
|
if err := b.write(civ.CmdReadMode); err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
||||||
|
return (d.Cmd == civ.CmdReadMode || d.Cmd == civ.CmdTransceiveMode) && len(d.Data) >= 1
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return f.Data[0], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) readDataMode() bool {
|
||||||
|
if err := b.write(civ.CmdExtra, civ.SubDataMode); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
||||||
|
return d.Cmd == civ.CmdExtra && len(d.Data) >= 2 && d.Data[0] == civ.SubDataMode
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return f.Data[1] != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// modeCode maps an ADIF mode to an Icom mode byte plus whether the data-mode
|
||||||
|
// flag should be set. SSB sideband follows the usual convention (LSB below
|
||||||
|
// 10 MHz, USB above); the frequency just commanded is preferred over the last
|
||||||
|
// poll so a clicked spot (freq then mode) picks the right sideband immediately.
|
||||||
|
func (b *IcomSerial) modeCode(mode string) (code byte, data bool, err error) {
|
||||||
|
freq := b.curFreq
|
||||||
|
if b.lastSetFreq > 0 && time.Since(b.lastSetFreqAt) < 5*time.Second {
|
||||||
|
freq = b.lastSetFreq
|
||||||
|
}
|
||||||
|
usb := byte(civ.ModeUSB)
|
||||||
|
if freq > 0 && freq < 10_000_000 {
|
||||||
|
usb = civ.ModeLSB
|
||||||
|
}
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
|
case "CW":
|
||||||
|
return civ.ModeCW, false, nil
|
||||||
|
case "SSB":
|
||||||
|
return usb, false, nil
|
||||||
|
case "AM":
|
||||||
|
return civ.ModeAM, false, nil
|
||||||
|
case "FM":
|
||||||
|
return civ.ModeFM, false, nil
|
||||||
|
case "RTTY", "FSK":
|
||||||
|
return civ.ModeRTTY, false, nil
|
||||||
|
case "FT8", "FT4", "PSK31", "MFSK", "JS8", "JT65", "JT9", "OLIVIA", "DATA", "DIGITALVOICE":
|
||||||
|
// Digital data modes ride on USB with the data flag set (FT8 etc.).
|
||||||
|
return civ.ModeUSB, true, nil
|
||||||
|
}
|
||||||
|
return 0, false, fmt.Errorf("icom: unsupported mode %q", mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IcomController: receive-DSP controls for the Icom tab ───────────────────
|
||||||
|
|
||||||
|
func (b *IcomSerial) IcomState() IcomTXState {
|
||||||
|
b.dspMu.Lock()
|
||||||
|
defer b.dspMu.Unlock()
|
||||||
|
return b.dsp
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshIcom re-reads the whole DSP snapshot from the rig. Runs on the CAT
|
||||||
|
// goroutine (dispatched via IcomDo).
|
||||||
|
func (b *IcomSerial) RefreshIcom() error {
|
||||||
|
if b.port == nil {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
b.readDSP()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readDSP polls every DSP value once and replaces the cache. Best-effort: a
|
||||||
|
// value the rig doesn't answer keeps its previous cached value rather than
|
||||||
|
// stalling (each read has a short timeout).
|
||||||
|
func (b *IcomSerial) readDSP() {
|
||||||
|
st := IcomTXState{Available: true, Model: b.model}
|
||||||
|
b.dspMu.Lock()
|
||||||
|
st.Mode = b.dsp.Mode // preserve mode (set by ReadState)
|
||||||
|
b.dspMu.Unlock()
|
||||||
|
|
||||||
|
if v, ok := b.readLevel(civ.SubLevelAF); ok {
|
||||||
|
st.AFGain = from255(v)
|
||||||
|
}
|
||||||
|
if v, ok := b.readLevel(civ.SubLevelRF); ok {
|
||||||
|
st.RFGain = from255(v)
|
||||||
|
}
|
||||||
|
if v, ok := b.readLevel(civ.SubLevelNR); ok {
|
||||||
|
st.NRLevel = from255(v)
|
||||||
|
}
|
||||||
|
if v, ok := b.readLevel(civ.SubLevelNB); ok {
|
||||||
|
st.NBLevel = from255(v)
|
||||||
|
}
|
||||||
|
if v, ok := b.readSwitch(civ.SubSwNB); ok {
|
||||||
|
st.NB = v != 0
|
||||||
|
}
|
||||||
|
if v, ok := b.readSwitch(civ.SubSwNR); ok {
|
||||||
|
st.NR = v != 0
|
||||||
|
}
|
||||||
|
if v, ok := b.readSwitch(civ.SubSwANF); ok {
|
||||||
|
st.ANF = v != 0
|
||||||
|
}
|
||||||
|
if v, ok := b.readSwitch(civ.SubSwAGC); ok {
|
||||||
|
st.AGC = agcName(v)
|
||||||
|
}
|
||||||
|
if v, ok := b.readSwitch(civ.SubSwPreamp); ok {
|
||||||
|
st.Preamp = int(v)
|
||||||
|
}
|
||||||
|
if v, ok := b.readAtt(); ok {
|
||||||
|
st.Att = v
|
||||||
|
}
|
||||||
|
if _, f, ok := b.readModeFilter(); ok {
|
||||||
|
st.Filter = int(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.dspMu.Lock()
|
||||||
|
b.dsp = st
|
||||||
|
b.dspMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
const icomDSPTimeout = 150 * time.Millisecond // shorter: unsupported reads mustn't stall the poll
|
||||||
|
|
||||||
|
func (b *IcomSerial) readLevel(sub byte) (int, bool) {
|
||||||
|
if err := b.write(civ.CmdLevel, sub); err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
||||||
|
return d.Cmd == civ.CmdLevel && len(d.Data) >= 3 && d.Data[0] == sub
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return civ.BCDToLevel(f.Data[1:3]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) readSwitch(sub byte) (byte, bool) {
|
||||||
|
if err := b.write(civ.CmdSwitch, sub); err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
||||||
|
return d.Cmd == civ.CmdSwitch && len(d.Data) >= 2 && d.Data[0] == sub
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return f.Data[1], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) readAtt() (int, bool) {
|
||||||
|
if err := b.write(civ.CmdAtt); err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
||||||
|
return d.Cmd == civ.CmdAtt && len(d.Data) >= 1
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return civ.BCDToByte(f.Data[0]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) readModeFilter() (mode, filter byte, ok bool) {
|
||||||
|
if err := b.write(civ.CmdReadMode); err != nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
||||||
|
return d.Cmd == civ.CmdReadMode && len(d.Data) >= 2
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return f.Data[0], f.Data[1], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetAFGain(p int) error {
|
||||||
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelAF}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.AFGain = clampPct(p) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetRFGain(p int) error {
|
||||||
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelRF}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.RFGain = clampPct(p) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetNB(on bool) error {
|
||||||
|
if err := b.exec(civ.CmdSwitch, civ.SubSwNB, boolByte(on)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.NB = on })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetNBLevel(p int) error {
|
||||||
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelNB}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.NBLevel = clampPct(p) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetNR(on bool) error {
|
||||||
|
if err := b.exec(civ.CmdSwitch, civ.SubSwNR, boolByte(on)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.NR = on })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetNRLevel(p int) error {
|
||||||
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelNR}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.NRLevel = clampPct(p) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetANF(on bool) error {
|
||||||
|
if err := b.exec(civ.CmdSwitch, civ.SubSwANF, boolByte(on)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.ANF = on })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetAGC(name string) error {
|
||||||
|
v := agcValue(name)
|
||||||
|
if v == 0 {
|
||||||
|
return fmt.Errorf("icom: invalid AGC %q", name)
|
||||||
|
}
|
||||||
|
if err := b.exec(civ.CmdSwitch, civ.SubSwAGC, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.AGC = strings.ToUpper(name) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetPreamp(n int) error {
|
||||||
|
if n < 0 || n > 2 {
|
||||||
|
return fmt.Errorf("icom: invalid preamp %d", n)
|
||||||
|
}
|
||||||
|
if err := b.exec(civ.CmdSwitch, civ.SubSwPreamp, byte(n)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.Preamp = n })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetAtt(db int) error {
|
||||||
|
if err := b.exec(civ.CmdAtt, civ.ByteToBCD(db)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.Att = db })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetIcomFilter(n int) error {
|
||||||
|
if n < 1 || n > 3 {
|
||||||
|
return fmt.Errorf("icom: invalid filter %d", n)
|
||||||
|
}
|
||||||
|
if b.curModeByte == 0 {
|
||||||
|
// Need the current mode to re-send with the chosen filter.
|
||||||
|
if m, _, ok := b.readModeFilter(); ok {
|
||||||
|
b.curModeByte = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := b.exec(civ.CmdSetMode, b.curModeByte, byte(n)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.Filter = n })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) setCache(fn func(*IcomTXState)) {
|
||||||
|
b.dspMu.Lock()
|
||||||
|
fn(&b.dsp)
|
||||||
|
b.dspMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── small helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func to255(p int) int { return clampPct(p) * 255 / 100 }
|
||||||
|
func from255(v int) int { return (v*100 + 127) / 255 }
|
||||||
|
func clampPct(p int) int { return min(100, max(0, p)) }
|
||||||
|
|
||||||
|
func boolByte(on bool) byte {
|
||||||
|
if on {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func agcName(v byte) string {
|
||||||
|
switch v {
|
||||||
|
case 1:
|
||||||
|
return "FAST"
|
||||||
|
case 2:
|
||||||
|
return "MID"
|
||||||
|
case 3:
|
||||||
|
return "SLOW"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func agcValue(name string) byte {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(name)) {
|
||||||
|
case "FAST":
|
||||||
|
return 1
|
||||||
|
case "MID":
|
||||||
|
return 2
|
||||||
|
case "SLOW":
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TCI is a native backend for Expert Electronics' TCI protocol (SunSDR2/MB1/
|
||||||
|
// ColibriNANO via ExpertSDR2/EESDR, and TCI-compatible apps). TCI is a text
|
||||||
|
// protocol over a WebSocket: the server streams state ("vfo:0,0,14100000;",
|
||||||
|
// "modulation:0,cw;", "trx:0,true;") and accepts the same commands to control
|
||||||
|
// the rig. We keep the pushed state cached so ReadState is instant, like Flex.
|
||||||
|
//
|
||||||
|
// Pure Go (gorilla/websocket, no CGO). Default port 40001.
|
||||||
|
type TCI struct {
|
||||||
|
host string
|
||||||
|
port int
|
||||||
|
|
||||||
|
digitalDefault string // surfaced when the rig reports a digital mode (FT8/…)
|
||||||
|
spotsEnabled bool // mirror cluster spots onto the TCI panorama
|
||||||
|
|
||||||
|
mu sync.Mutex // guards conn + writes + state
|
||||||
|
conn *websocket.Conn
|
||||||
|
ready bool
|
||||||
|
|
||||||
|
// Cached state pushed by the radio.
|
||||||
|
device string
|
||||||
|
freqA int64 // VFO A (RX) frequency, Hz (vfo:0,0)
|
||||||
|
freqB int64 // VFO B (TX in split), Hz (vfo:0,1)
|
||||||
|
mode string
|
||||||
|
split bool
|
||||||
|
tx bool
|
||||||
|
|
||||||
|
lastSig string // last logged state signature (log only on change)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tciDefaultPort = 40001
|
||||||
|
|
||||||
|
// NewTCI builds a TCI backend for the given host/port. digitalDefault is the
|
||||||
|
// mode surfaced when the radio reports a generic digital modulation; spots turns
|
||||||
|
// on mirroring OpsLog's cluster spots onto the TCI panorama.
|
||||||
|
func NewTCI(host string, port int, digitalDefault string, spots bool) *TCI {
|
||||||
|
if port <= 0 || port > 65535 {
|
||||||
|
port = tciDefaultPort
|
||||||
|
}
|
||||||
|
return &TCI{host: strings.TrimSpace(host), port: port, digitalDefault: strings.TrimSpace(digitalDefault), spotsEnabled: spots}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TCI) Name() string { return "tci" }
|
||||||
|
|
||||||
|
// Connect opens the WebSocket and starts the reader goroutine. The reader keeps
|
||||||
|
// our cached state current from the radio's push messages.
|
||||||
|
func (t *TCI) Connect() error {
|
||||||
|
t.mu.Lock()
|
||||||
|
already := t.conn != nil
|
||||||
|
host, port := t.host, t.port
|
||||||
|
t.mu.Unlock()
|
||||||
|
if already {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
return fmt.Errorf("tci: no host configured")
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("ws://%s", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||||
|
dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
|
||||||
|
conn, _, err := dialer.Dial(url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tci: connect %s: %w", url, err)
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
t.conn = conn
|
||||||
|
t.ready = false
|
||||||
|
t.mu.Unlock()
|
||||||
|
debugLog.Printf("TCI: connected to %s", url)
|
||||||
|
go t.reader(conn)
|
||||||
|
if t.spotsEnabled {
|
||||||
|
_ = t.send("spot_clear;") // drop any leftover spots from a previous session
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendSpot mirrors a cluster spot onto the TCI panorama (implements Spotter).
|
||||||
|
// The radio replaces a spot that has the same callsign, so re-spotting updates
|
||||||
|
// it in place. No-op when spot mirroring is disabled.
|
||||||
|
func (t *TCI) SendSpot(s SpotInfo) error {
|
||||||
|
if !t.spotsEnabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
call := strings.TrimSpace(s.Callsign)
|
||||||
|
if call == "" || s.FreqHz <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
color := strings.TrimSpace(s.Color)
|
||||||
|
if color == "" {
|
||||||
|
color = "#FFFFA500" // opaque orange default
|
||||||
|
}
|
||||||
|
color = "0x" + strings.TrimPrefix(color, "#")
|
||||||
|
mode := strings.ToLower(strings.TrimSpace(s.Mode))
|
||||||
|
// Commas/semicolons would break TCI's comma-separated argument parsing.
|
||||||
|
text := strings.NewReplacer(",", " ", ";", " ").Replace(s.Comment)
|
||||||
|
return t.send(fmt.Sprintf("spot:%s,%s,%d,%s,%s;", call, mode, s.FreqHz, color, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect closes the WebSocket; the reader goroutine then exits.
|
||||||
|
func (t *TCI) Disconnect() {
|
||||||
|
t.mu.Lock()
|
||||||
|
c := t.conn
|
||||||
|
t.conn = nil
|
||||||
|
t.ready = false
|
||||||
|
t.mu.Unlock()
|
||||||
|
if c != nil {
|
||||||
|
_ = c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||||
|
_ = c.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadState returns the cached state pushed by the radio.
|
||||||
|
func (t *TCI) ReadState() (RigState, error) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
if t.conn == nil {
|
||||||
|
return RigState{}, fmt.Errorf("tci: not connected")
|
||||||
|
}
|
||||||
|
st := RigState{Connected: t.ready, Rig: t.device}
|
||||||
|
if !t.ready {
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
// ADIF convention: FreqHz is the TX freq. In split, TX is VFO B.
|
||||||
|
if t.split && t.freqB > 0 {
|
||||||
|
st.FreqHz = t.freqB
|
||||||
|
st.RxFreqHz = t.freqA
|
||||||
|
st.Split = true
|
||||||
|
} else {
|
||||||
|
st.FreqHz = t.freqA
|
||||||
|
}
|
||||||
|
st.Mode = tciModeToADIF(t.mode, t.digitalDefault)
|
||||||
|
if st.FreqHz > 0 {
|
||||||
|
st.Band = BandFromHz(st.FreqHz)
|
||||||
|
}
|
||||||
|
sig := fmt.Sprintf("%d/%d/%v/%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
|
||||||
|
if sig != t.lastSig {
|
||||||
|
t.lastSig = sig
|
||||||
|
debugLog.Printf("TCI: state tx=%d rx=%d split=%v mode=%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
|
||||||
|
}
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFrequency tunes VFO A (the main/RX VFO).
|
||||||
|
func (t *TCI) SetFrequency(hz int64) error {
|
||||||
|
return t.send(fmt.Sprintf("vfo:0,0,%d;", hz))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMode maps an ADIF mode to a TCI modulation and sets it. USB vs LSB is
|
||||||
|
// chosen from the current VFO-A frequency (< 10 MHz → LSB).
|
||||||
|
func (t *TCI) SetMode(mode string) error {
|
||||||
|
t.mu.Lock()
|
||||||
|
freq := t.freqA
|
||||||
|
t.mu.Unlock()
|
||||||
|
m := adifToTCIMode(mode, freq)
|
||||||
|
if m == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return t.send(fmt.Sprintf("modulation:0,%s;", m))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPTT keys or unkeys the transmitter (VFO 0).
|
||||||
|
func (t *TCI) SetPTT(on bool) error {
|
||||||
|
return t.send(fmt.Sprintf("trx:0,%t;", on))
|
||||||
|
}
|
||||||
|
|
||||||
|
// send writes a command to the WebSocket (one writer at a time).
|
||||||
|
func (t *TCI) send(cmd string) error {
|
||||||
|
t.mu.Lock()
|
||||||
|
c := t.conn
|
||||||
|
t.mu.Unlock()
|
||||||
|
if c == nil {
|
||||||
|
return fmt.Errorf("tci: not connected")
|
||||||
|
}
|
||||||
|
_ = c.SetWriteDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
if err := c.WriteMessage(websocket.TextMessage, []byte(cmd)); err != nil {
|
||||||
|
debugLog.Printf("TCI: send %q failed: %v", cmd, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
debugLog.Printf("TCI: → %s", cmd)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reader drains push messages and keeps the cached state current until the
|
||||||
|
// connection closes.
|
||||||
|
func (t *TCI) reader(conn *websocket.Conn) {
|
||||||
|
for {
|
||||||
|
_, data, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// A frame may carry several ";"-terminated commands.
|
||||||
|
for _, cmd := range strings.Split(string(data), ";") {
|
||||||
|
t.handle(strings.TrimSpace(cmd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
if t.conn == conn {
|
||||||
|
t.conn = nil
|
||||||
|
t.ready = false
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
debugLog.Printf("TCI: reader ended")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle parses one "command:args" message and updates the cache.
|
||||||
|
func (t *TCI) handle(msg string) {
|
||||||
|
if msg == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name, args := msg, ""
|
||||||
|
if i := strings.IndexByte(msg, ':'); i >= 0 {
|
||||||
|
name, args = msg[:i], msg[i+1:]
|
||||||
|
}
|
||||||
|
f := strings.Split(args, ",")
|
||||||
|
get := func(i int) string {
|
||||||
|
if i < len(f) {
|
||||||
|
return strings.TrimSpace(f[i])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
switch strings.ToLower(name) {
|
||||||
|
case "device":
|
||||||
|
t.device = strings.TrimSpace(args)
|
||||||
|
case "ready", "start":
|
||||||
|
t.ready = true
|
||||||
|
case "stop":
|
||||||
|
t.ready = false
|
||||||
|
case "vfo":
|
||||||
|
// vfo:<rx>,<channel>,<freq>
|
||||||
|
if get(0) == "0" {
|
||||||
|
hz, _ := strconv.ParseInt(get(2), 10, 64)
|
||||||
|
if hz > 0 {
|
||||||
|
t.ready = true // receiving live state → treat as ready even without an explicit "ready;"
|
||||||
|
switch get(1) {
|
||||||
|
case "0":
|
||||||
|
t.freqA = hz
|
||||||
|
case "1":
|
||||||
|
t.freqB = hz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "modulation":
|
||||||
|
if get(0) == "0" {
|
||||||
|
t.mode = strings.ToLower(get(1))
|
||||||
|
}
|
||||||
|
case "split_enable":
|
||||||
|
if get(0) == "0" {
|
||||||
|
t.split = get(1) == "true"
|
||||||
|
}
|
||||||
|
case "trx":
|
||||||
|
if get(0) == "0" {
|
||||||
|
t.tx = get(1) == "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tciModeToADIF converts a TCI modulation to an ADIF mode. Generic digital
|
||||||
|
// modulations surface the operator's chosen digital default (FT8/FT4/RTTY…).
|
||||||
|
func tciModeToADIF(m, digitalDefault string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(m)) {
|
||||||
|
case "usb", "lsb", "dsb":
|
||||||
|
return "SSB"
|
||||||
|
case "cw":
|
||||||
|
return "CW"
|
||||||
|
case "am", "sam":
|
||||||
|
return "AM"
|
||||||
|
case "nfm", "wfm", "fm":
|
||||||
|
return "FM"
|
||||||
|
case "digu", "digl":
|
||||||
|
if digitalDefault != "" {
|
||||||
|
return strings.ToUpper(digitalDefault)
|
||||||
|
}
|
||||||
|
return "DATA"
|
||||||
|
case "drm":
|
||||||
|
return "DIGITALVOICE"
|
||||||
|
case "":
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return strings.ToUpper(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adifToTCIMode maps an ADIF mode to a TCI modulation. USB/LSB is chosen from
|
||||||
|
// the frequency (< 10 MHz → LSB) as usual. Digital modes → digu.
|
||||||
|
func adifToTCIMode(mode string, freqHz int64) string {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
|
case "SSB", "USB", "LSB":
|
||||||
|
if freqHz > 0 && freqHz < 10_000_000 {
|
||||||
|
return "lsb"
|
||||||
|
}
|
||||||
|
return "usb"
|
||||||
|
case "CW", "CWR", "CW-R":
|
||||||
|
return "cw"
|
||||||
|
case "AM":
|
||||||
|
return "am"
|
||||||
|
case "FM", "NFM":
|
||||||
|
return "nfm"
|
||||||
|
case "RTTY":
|
||||||
|
return "digl"
|
||||||
|
case "":
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
// FT8/FT4/PSK/DATA/JT… → upper-sideband digital.
|
||||||
|
return "digu"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,10 @@ import (
|
|||||||
// subscription used elsewhere for callsign data — they're different keys.
|
// subscription used elsewhere for callsign data — they're different keys.
|
||||||
const qrzAPIURL = "https://logbook.qrz.com/api"
|
const qrzAPIURL = "https://logbook.qrz.com/api"
|
||||||
|
|
||||||
|
// LogSink receives diagnostics such as raw QRZ responses. Set to applog.Printf
|
||||||
|
// at startup; defaults to a no-op so the package is usable without wiring.
|
||||||
|
var LogSink = func(string, ...any) {}
|
||||||
|
|
||||||
// UploadQRZ pushes one ADIF record to the QRZ.com logbook identified by
|
// UploadQRZ pushes one ADIF record to the QRZ.com logbook identified by
|
||||||
// apiKey. It returns OK when the QSO is inserted or already present
|
// apiKey. It returns OK when the QSO is inserted or already present
|
||||||
// (QRZ reports a duplicate as a FAIL with a "duplicate" reason, which we
|
// (QRZ reports a duplicate as a FAIL with a "duplicate" reason, which we
|
||||||
@@ -67,6 +71,7 @@ func UploadQRZ(ctx context.Context, client *http.Client, apiKey, adifRecord stri
|
|||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return UploadResult{}, fmt.Errorf("qrz: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
return UploadResult{}, fmt.Errorf("qrz: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
}
|
}
|
||||||
|
LogSink("qrz: INSERT raw response: %s", strings.TrimSpace(string(body)))
|
||||||
|
|
||||||
return parseQRZResponse(string(body))
|
return parseQRZResponse(string(body))
|
||||||
}
|
}
|
||||||
@@ -171,6 +176,7 @@ func TestQRZ(ctx context.Context, client *http.Client, apiKey string) (string, e
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||||
|
LogSink("qrz: STATUS raw response: %s", strings.TrimSpace(string(body)))
|
||||||
vals, err := url.ParseQuery(strings.TrimSpace(string(body)))
|
vals, err := url.ParseQuery(strings.TrimSpace(string(body)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("qrz: bad response: %w", err)
|
return "", fmt.Errorf("qrz: bad response: %w", err)
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
// Package netctl persists "NET" definitions and their station rosters for the
|
||||||
|
// NET Control feature (managing a directed net / round-table on a frequency).
|
||||||
|
//
|
||||||
|
// A NET is a named net (e.g. "French QSO", "QSO des Brasses") with a roster of
|
||||||
|
// stations that habitually check in. The roster grows over time as you add new
|
||||||
|
// callsigns. Storage is a single JSON file in the data dir — global/shared
|
||||||
|
// across all logbooks (a net like "French QSO" is reused whatever logbook is
|
||||||
|
// open). The QSOs themselves are logged into the active logbook by the caller;
|
||||||
|
// this package only owns the net definitions + rosters, not the live session.
|
||||||
|
package netctl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Station is one roster entry: a station registered in a net.
|
||||||
|
type Station struct {
|
||||||
|
Callsign string `json:"callsign"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
QTH string `json:"qth,omitempty"`
|
||||||
|
Country string `json:"country,omitempty"`
|
||||||
|
DXCC int `json:"dxcc,omitempty"`
|
||||||
|
ITU int `json:"itu,omitempty"`
|
||||||
|
CQ int `json:"cq,omitempty"`
|
||||||
|
Groups string `json:"groups,omitempty"`
|
||||||
|
SIG string `json:"sig,omitempty"`
|
||||||
|
SIGInfo string `json:"sig_info,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Net is a named net with default report values and a station roster.
|
||||||
|
type Net struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DefaultRSTSent string `json:"default_rst_sent,omitempty"`
|
||||||
|
DefaultRSTRcvd string `json:"default_rst_rcvd,omitempty"`
|
||||||
|
DefaultComment string `json:"default_comment,omitempty"`
|
||||||
|
Stations []Station `json:"stations,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store is the persistent collection of nets, backed by a JSON file.
|
||||||
|
type Store struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
path string
|
||||||
|
nets []Net
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open loads the store from path (creating an empty one if the file is absent).
|
||||||
|
func Open(path string) (*Store, error) {
|
||||||
|
s := &Store{path: path}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(b) > 0 {
|
||||||
|
if err := json.Unmarshal(b, &s.nets); err != nil {
|
||||||
|
// Corrupt file: start empty rather than failing the whole app.
|
||||||
|
s.nets = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// save writes the current state to disk. Caller must hold s.mu.
|
||||||
|
func (s *Store) save() error {
|
||||||
|
b, err := json.MarshalIndent(s.nets, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := s.path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, s.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newID() string { return strconv.FormatInt(time.Now().UnixNano(), 36) }
|
||||||
|
|
||||||
|
// List returns a copy of all nets (with rosters), ordered by name.
|
||||||
|
func (s *Store) List() []Net {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := make([]Net, len(s.nets))
|
||||||
|
copy(out, s.nets)
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// find returns the index of the net with id, or -1. Caller must hold s.mu.
|
||||||
|
func (s *Store) find(id string) int {
|
||||||
|
for i := range s.nets {
|
||||||
|
if s.nets[i].ID == id {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create adds a new net with default reports of 59/59 and returns it.
|
||||||
|
func (s *Store) Create(name string) (Net, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return Net{}, fmt.Errorf("net name required")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for i := range s.nets {
|
||||||
|
if strings.EqualFold(s.nets[i].Name, name) {
|
||||||
|
return Net{}, fmt.Errorf("a net named %q already exists", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n := Net{ID: newID(), Name: name, DefaultRSTSent: "59", DefaultRSTRcvd: "59"}
|
||||||
|
s.nets = append(s.nets, n)
|
||||||
|
return n, s.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename changes a net's name.
|
||||||
|
func (s *Store) Rename(id, name string) error {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("net name required")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
i := s.find(id)
|
||||||
|
if i < 0 {
|
||||||
|
return fmt.Errorf("net not found")
|
||||||
|
}
|
||||||
|
s.nets[i].Name = name
|
||||||
|
return s.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDefaults updates the per-net default report/comment values.
|
||||||
|
func (s *Store) SetDefaults(id, rstSent, rstRcvd, comment string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
i := s.find(id)
|
||||||
|
if i < 0 {
|
||||||
|
return fmt.Errorf("net not found")
|
||||||
|
}
|
||||||
|
s.nets[i].DefaultRSTSent = rstSent
|
||||||
|
s.nets[i].DefaultRSTRcvd = rstRcvd
|
||||||
|
s.nets[i].DefaultComment = comment
|
||||||
|
return s.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a net and its roster.
|
||||||
|
func (s *Store) Delete(id string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
i := s.find(id)
|
||||||
|
if i < 0 {
|
||||||
|
return fmt.Errorf("net not found")
|
||||||
|
}
|
||||||
|
s.nets = append(s.nets[:i], s.nets[i+1:]...)
|
||||||
|
return s.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a copy of one net by id.
|
||||||
|
func (s *Store) Get(id string) (Net, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
i := s.find(id)
|
||||||
|
if i < 0 {
|
||||||
|
return Net{}, false
|
||||||
|
}
|
||||||
|
return s.nets[i], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roster returns a net's stations, sorted by callsign.
|
||||||
|
func (s *Store) Roster(id string) ([]Station, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
i := s.find(id)
|
||||||
|
if i < 0 {
|
||||||
|
return nil, fmt.Errorf("net not found")
|
||||||
|
}
|
||||||
|
out := make([]Station, len(s.nets[i].Stations))
|
||||||
|
copy(out, s.nets[i].Stations)
|
||||||
|
sort.Slice(out, func(a, b int) bool { return out[a].Callsign < out[b].Callsign })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RosterUpsert adds st to the net's roster, or updates it if the callsign is
|
||||||
|
// already present (matched case-insensitively; the callsign is stored upper).
|
||||||
|
func (s *Store) RosterUpsert(id string, st Station) error {
|
||||||
|
st.Callsign = strings.ToUpper(strings.TrimSpace(st.Callsign))
|
||||||
|
if st.Callsign == "" {
|
||||||
|
return fmt.Errorf("callsign required")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
i := s.find(id)
|
||||||
|
if i < 0 {
|
||||||
|
return fmt.Errorf("net not found")
|
||||||
|
}
|
||||||
|
for j := range s.nets[i].Stations {
|
||||||
|
if strings.EqualFold(s.nets[i].Stations[j].Callsign, st.Callsign) {
|
||||||
|
s.nets[i].Stations[j] = st
|
||||||
|
return s.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.nets[i].Stations = append(s.nets[i].Stations, st)
|
||||||
|
return s.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RosterRemove deletes a callsign from a net's roster.
|
||||||
|
func (s *Store) RosterRemove(id, callsign string) error {
|
||||||
|
callsign = strings.ToUpper(strings.TrimSpace(callsign))
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
i := s.find(id)
|
||||||
|
if i < 0 {
|
||||||
|
return fmt.Errorf("net not found")
|
||||||
|
}
|
||||||
|
for j := range s.nets[i].Stations {
|
||||||
|
if strings.EqualFold(s.nets[i].Stations[j].Callsign, callsign) {
|
||||||
|
s.nets[i].Stations = append(s.nets[i].Stations[:j], s.nets[i].Stations[j+1:]...)
|
||||||
|
return s.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil // not present → nothing to do
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
// Package powergenius drives a 4O3A PowerGenius XL amplifier over its TCP text
|
||||||
|
// API (same "Genius Series" line protocol as the Antenna Genius). OpsLog reads
|
||||||
|
// the amp's operate state via the FlexRadio amplifier object, but the fan mode
|
||||||
|
// is a PGXL-only setting only reachable on the amp's own control port — hence
|
||||||
|
// this small direct client. Commands are "C<id>|<cmd>\n"; replies are
|
||||||
|
// "R<id>|0|<k=v …>" and asynchronous "S0|<k=v …>".
|
||||||
|
package powergenius
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPort = 9008
|
||||||
|
dialTimeout = 5 * time.Second
|
||||||
|
ioTimeout = 3 * time.Second
|
||||||
|
pollEvery = 1500 * time.Millisecond
|
||||||
|
reconnectDelay = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status is the snapshot the UI renders (only the bits OpsLog needs).
|
||||||
|
type Status struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
State string `json:"state,omitempty"` // IDLE / TRANSMIT_A …
|
||||||
|
FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
host string
|
||||||
|
port int
|
||||||
|
|
||||||
|
mu sync.Mutex // serialises command send/recv on the connection
|
||||||
|
conn net.Conn
|
||||||
|
reader *bufio.Reader
|
||||||
|
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
status Status
|
||||||
|
// Optimistic fan mode kept until the amp's status poll confirms it (or it
|
||||||
|
// ages out) — otherwise a stale poll right after a change reverts the UI.
|
||||||
|
fanPending string
|
||||||
|
fanPendingAt time.Time
|
||||||
|
|
||||||
|
cmdID atomic.Int64
|
||||||
|
stop chan struct{}
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(host string, port int) *Client {
|
||||||
|
if port <= 0 || port > 65535 {
|
||||||
|
port = defaultPort
|
||||||
|
}
|
||||||
|
return &Client{host: host, port: port, stop: make(chan struct{}), status: Status{Host: host}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Start() error {
|
||||||
|
c.running = true
|
||||||
|
go c.pollLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Stop() {
|
||||||
|
if !c.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.running = false
|
||||||
|
close(c.stop)
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
c.reader = nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetStatus() Status {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
return c.status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) setStatus(fn func(*Status)) {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
fn(&c.status)
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFanMode sets the amplifier fan mode (STANDARD | CONTEST | BROADCAST).
|
||||||
|
func (c *Client) SetFanMode(mode string) error {
|
||||||
|
m := strings.ToUpper(strings.TrimSpace(mode))
|
||||||
|
switch m {
|
||||||
|
case "STANDARD", "CONTEST", "BROADCAST":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("powergenius: invalid fan mode %q", mode)
|
||||||
|
}
|
||||||
|
if _, err := c.command("setup fanmode=" + m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.status.FanMode = m // optimistic
|
||||||
|
c.fanPending, c.fanPendingAt = m, time.Now()
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOperate puts the amp in OPERATE (1) or STANDBY (0).
|
||||||
|
func (c *Client) SetOperate(on bool) error {
|
||||||
|
v := "0"
|
||||||
|
if on {
|
||||||
|
v = "1"
|
||||||
|
}
|
||||||
|
_, err := c.command("operate=" + v)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) pollLoop() {
|
||||||
|
t := time.NewTicker(pollEvery)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if err := c.ensureConnected(); err != nil {
|
||||||
|
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := c.command("status"); err != nil {
|
||||||
|
c.dropConn()
|
||||||
|
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = err.Error() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ensureConnected() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
conn, err := net.DialTimeout("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)), dialTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.conn = conn
|
||||||
|
c.reader = bufio.NewReader(conn)
|
||||||
|
// Discard the version banner the device sends on connect.
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||||
|
_, _ = c.reader.ReadString('\n')
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) dropConn() {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
c.reader = nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// command sends "C<id>|<cmd>\n" and parses the single-line reply into status.
|
||||||
|
func (c *Client) command(cmd string) (string, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn == nil || c.reader == nil {
|
||||||
|
return "", fmt.Errorf("powergenius: not connected")
|
||||||
|
}
|
||||||
|
id := c.cmdID.Add(1)
|
||||||
|
_ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout))
|
||||||
|
if _, err := fmt.Fprintf(c.conn, "C%d|%s\n", id, cmd); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||||
|
line, err := c.reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
c.parse(line)
|
||||||
|
return line, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse handles "R<id>|0|<k=v …>" and "S0|<k=v …>" status lines.
|
||||||
|
func (c *Client) parse(resp string) {
|
||||||
|
var data string
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(resp, "R"):
|
||||||
|
p := strings.SplitN(resp, "|", 3)
|
||||||
|
if len(p) < 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = p[2]
|
||||||
|
case strings.HasPrefix(resp, "S"):
|
||||||
|
p := strings.SplitN(resp, "|", 2)
|
||||||
|
if len(p) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = p[1]
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.status.Connected = true
|
||||||
|
c.status.LastError = ""
|
||||||
|
for _, pair := range strings.Fields(data) {
|
||||||
|
kv := strings.SplitN(pair, "=", 2)
|
||||||
|
if len(kv) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch kv[0] {
|
||||||
|
case "state":
|
||||||
|
c.status.State = kv[1]
|
||||||
|
case "fanmode":
|
||||||
|
dev := strings.ToUpper(kv[1])
|
||||||
|
// Honour a recent optimistic change until the amp confirms it.
|
||||||
|
if c.fanPending != "" && time.Since(c.fanPendingAt) < 3*time.Second && dev != c.fanPending {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
c.fanPending = ""
|
||||||
|
c.status.FanMode = dev
|
||||||
|
case "temp":
|
||||||
|
c.status.Temperature, _ = strconv.ParseFloat(kv[1], 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
@@ -497,6 +497,31 @@ var uploadStatusCols = map[string]bool{
|
|||||||
|
|
||||||
// ListForUpload returns QSOs whose per-service sent-status column equals
|
// ListForUpload returns QSOs whose per-service sent-status column equals
|
||||||
// value ("" matches blank/NULL). Used by the QSL Manager's "Select required".
|
// value ("" matches blank/NULL). Used by the QSL Manager's "Select required".
|
||||||
|
// ListForUploadFull is like ListForUpload but returns FULL QSO rows so the UI
|
||||||
|
// can show the same rich, column-pickable table as Recent QSOs. column is an
|
||||||
|
// upload-status column (validated); value is the status to match ("" = not yet
|
||||||
|
// uploaded).
|
||||||
|
func (r *Repo) ListForUploadFull(ctx context.Context, column, value string) ([]QSO, error) {
|
||||||
|
if !uploadStatusCols[column] {
|
||||||
|
return nil, fmt.Errorf("invalid upload column %q", column)
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT `+selectCols+` FROM qso WHERE COALESCE(`+column+`,'') = ? ORDER BY qso_date DESC, id DESC`, value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list for upload: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make([]QSO, 0, 64)
|
||||||
|
for rows.Next() {
|
||||||
|
q, err := scanQSO(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, q)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]UploadRow, error) {
|
func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]UploadRow, error) {
|
||||||
if !uploadStatusCols[column] {
|
if !uploadStatusCols[column] {
|
||||||
return nil, fmt.Errorf("invalid upload column %q", column)
|
return nil, fmt.Errorf("invalid upload column %q", column)
|
||||||
@@ -1622,6 +1647,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
|
|||||||
type EntitySlot struct {
|
type EntitySlot struct {
|
||||||
Country string
|
Country string
|
||||||
Bands map[string]struct{} // bands worked, any mode
|
Bands map[string]struct{} // bands worked, any mode
|
||||||
|
Modes map[string]struct{} // modes worked, any band
|
||||||
Slots map[string]map[string]struct{} // band → modes worked
|
Slots map[string]map[string]struct{} // band → modes worked
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1667,11 +1693,13 @@ func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, store
|
|||||||
e = &EntitySlot{
|
e = &EntitySlot{
|
||||||
Country: country,
|
Country: country,
|
||||||
Bands: make(map[string]struct{}),
|
Bands: make(map[string]struct{}),
|
||||||
|
Modes: make(map[string]struct{}),
|
||||||
Slots: make(map[string]map[string]struct{}),
|
Slots: make(map[string]map[string]struct{}),
|
||||||
}
|
}
|
||||||
out[key] = e
|
out[key] = e
|
||||||
}
|
}
|
||||||
e.Bands[band] = struct{}{}
|
e.Bands[band] = struct{}{}
|
||||||
|
e.Modes[mode] = struct{}{}
|
||||||
bandSlots, ok := e.Slots[band]
|
bandSlots, ok := e.Slots[band]
|
||||||
if !ok {
|
if !ok {
|
||||||
bandSlots = make(map[string]struct{})
|
bandSlots = make(map[string]struct{})
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ type Client struct {
|
|||||||
running bool
|
running bool
|
||||||
seqNum byte
|
seqNum byte
|
||||||
seqMu sync.Mutex
|
seqMu sync.Mutex
|
||||||
|
|
||||||
|
// Optimistic pattern direction kept until the antenna's status poll reports
|
||||||
|
// it (or it ages out) — the motors take a second or two, and a stale poll in
|
||||||
|
// between would otherwise snap the UI back to the old direction.
|
||||||
|
pendingDir int
|
||||||
|
pendingDirAt time.Time
|
||||||
|
pendingDirSet bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
@@ -199,6 +206,14 @@ func (c *Client) pollLoop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
|
// Keep a just-commanded direction until the antenna reports it.
|
||||||
|
if c.pendingDirSet {
|
||||||
|
if time.Since(c.pendingDirAt) > 4*time.Second || status.Direction == c.pendingDir {
|
||||||
|
c.pendingDirSet = false
|
||||||
|
} else {
|
||||||
|
status.Direction = c.pendingDir
|
||||||
|
}
|
||||||
|
}
|
||||||
c.lastStatus = status
|
c.lastStatus = status
|
||||||
c.statusMu.Unlock()
|
c.statusMu.Unlock()
|
||||||
|
|
||||||
@@ -449,6 +464,14 @@ func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err := c.sendCommand(CMD_FREQ, data)
|
_, err := c.sendCommand(CMD_FREQ, data)
|
||||||
|
if err == nil {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
|
||||||
|
if c.lastStatus != nil {
|
||||||
|
c.lastStatus.Direction = direction // reflect immediately
|
||||||
|
}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.12"
|
appVersion = "0.16.2"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user