Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28b6f04ea4 | ||
|
|
e6744c1d0a | ||
|
|
5152366717 | ||
|
|
e8691a43ba | ||
|
|
b83a4f4455 | ||
|
|
9d7091b1b8 | ||
|
|
f650183936 |
@@ -2090,6 +2090,11 @@ func (a *App) applyStationDefaults(q *qso.QSO, includeIdentity bool) {
|
|||||||
if q.MyPOTARef == "" {
|
if q.MyPOTARef == "" {
|
||||||
q.MyPOTARef = p.MyPOTARef
|
q.MyPOTARef = p.MyPOTARef
|
||||||
}
|
}
|
||||||
|
// MY_NAME = the operator's personal name (profile OpName, e.g. "Greg") — stamped
|
||||||
|
// like the other My* fields so every QSO carries it, not just those edited by hand.
|
||||||
|
if q.MyName == "" {
|
||||||
|
q.MyName = p.OpName
|
||||||
|
}
|
||||||
// Per-band rig/antenna from Operating conditions (the antenna ticked as
|
// Per-band rig/antenna from Operating conditions (the antenna ticked as
|
||||||
// DEFAULT for this band) — the same auto-fill the entry strip does, applied
|
// DEFAULT for this band) — the same auto-fill the entry strip does, applied
|
||||||
// here so imported QSOs get MY_RIG / MY_ANTENNA from the band defaults.
|
// here so imported QSOs get MY_RIG / MY_ANTENNA from the band defaults.
|
||||||
@@ -4651,6 +4656,10 @@ var bulkFieldColumns = map[string]string{
|
|||||||
"my_antenna": "my_antenna",
|
"my_antenna": "my_antenna",
|
||||||
"my_sig": "my_sig",
|
"my_sig": "my_sig",
|
||||||
"my_sig_info": "my_sig_info",
|
"my_sig_info": "my_sig_info",
|
||||||
|
"my_name": "my_name",
|
||||||
|
"my_arrl_sect": "my_arrl_sect",
|
||||||
|
"my_darc_dok": "my_darc_dok",
|
||||||
|
"my_vucc_grids": "my_vucc_grids",
|
||||||
// Contest
|
// Contest
|
||||||
"contest_id": "contest_id",
|
"contest_id": "contest_id",
|
||||||
"srx_string": "srx_string",
|
"srx_string": "srx_string",
|
||||||
@@ -8878,6 +8887,12 @@ func (a *App) consumeUDPEvents() {
|
|||||||
"source": ev.Source,
|
"source": ev.Source,
|
||||||
"adif": ev.LoggedADIF,
|
"adif": ev.LoggedADIF,
|
||||||
})
|
})
|
||||||
|
case ev.ClearCall:
|
||||||
|
applog.Printf("udp: emit udp:clear_call (DX Call cleared in the digital app)\n")
|
||||||
|
wruntime.EventsEmit(a.ctx, "udp:clear_call", map[string]any{
|
||||||
|
"service": string(ev.Service),
|
||||||
|
"source": ev.Source,
|
||||||
|
})
|
||||||
case ev.DXCall != "" && ev.Service == udp.ServiceRemoteCall:
|
case ev.DXCall != "" && ev.Service == udp.ServiceRemoteCall:
|
||||||
applog.Printf("udp: emit udp:remote_call %q\n", ev.DXCall)
|
applog.Printf("udp: emit udp:remote_call %q\n", ev.DXCall)
|
||||||
wruntime.EventsEmit(a.ctx, "udp:remote_call", ev.DXCall)
|
wruntime.EventsEmit(a.ctx, "udp:remote_call", ev.DXCall)
|
||||||
|
|||||||
+48
-11
@@ -608,7 +608,16 @@ export default function App() {
|
|||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
|
const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
|
||||||
const [matchCount, setMatchCount] = useState<number | null>(null);
|
const [matchCount, setMatchCount] = useState<number | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState('recent');
|
// The selected tab is remembered across restarts. Only the always-present tabs
|
||||||
|
// are restored: the conditional ones (flex/icom/contest/net/stats/qsl) depend on
|
||||||
|
// a feature or CAT backend that isn't known this early, and restoring one that
|
||||||
|
// isn't there would show a blank pane.
|
||||||
|
const ALWAYS_TABS = ['main', 'recent', 'cluster', 'worked', 'awards', 'bandmap'];
|
||||||
|
const [activeTab, setActiveTab] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('opslog.activeTab') || '';
|
||||||
|
return ALWAYS_TABS.includes(saved) ? saved : 'recent';
|
||||||
|
});
|
||||||
|
useEffect(() => { writeUiPref('opslog.activeTab', activeTab); }, [activeTab]);
|
||||||
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
|
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
|
||||||
const [qslTabOpen, setQslTabOpen] = useState(false);
|
const [qslTabOpen, setQslTabOpen] = useState(false);
|
||||||
const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
|
const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
|
||||||
@@ -909,8 +918,15 @@ export default function App() {
|
|||||||
// Ring buffer — only keep the last N spots; cluster firehose can be heavy.
|
// Ring buffer — only keep the last N spots; cluster firehose can be heavy.
|
||||||
const [spots, setSpots] = useState<ClusterSpot[]>([]);
|
const [spots, setSpots] = useState<ClusterSpot[]>([]);
|
||||||
const SPOTS_CAP = 1000;
|
const SPOTS_CAP = 1000;
|
||||||
const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>('');
|
// Cluster filter selections persist across restarts (writeUiPref → localStorage
|
||||||
const [clusterGroup, setClusterGroup] = useState(true);
|
// + DB, so they also travel with a copied data/ folder). Loaders read the cache
|
||||||
|
// synchronously at first render; a single effect below writes them back.
|
||||||
|
const lsBool = (k: string, d: boolean) => { const v = localStorage.getItem(k); return v === null ? d : v === '1'; };
|
||||||
|
const lsSet = <T,>(k: string): Set<T> => { try { const a = JSON.parse(localStorage.getItem(k) || '[]'); return new Set(Array.isArray(a) ? a : []); } catch { return new Set<T>(); } };
|
||||||
|
const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>(() => {
|
||||||
|
const v = localStorage.getItem('opslog.clusterFilterSource'); const n = v ? parseInt(v, 10) : NaN; return Number.isFinite(n) ? n : '';
|
||||||
|
});
|
||||||
|
const [clusterGroup, setClusterGroup] = useState(() => lsBool('opslog.clusterGroup', true));
|
||||||
const [clusterCmd, setClusterCmd] = useState('');
|
const [clusterCmd, setClusterCmd] = useState('');
|
||||||
// Cluster console: the raw traffic. Spots are parsed out of the stream into the
|
// Cluster console: the raw traffic. Spots are parsed out of the stream into the
|
||||||
// grid; everything else (SH/DX output, WHO, MOTD, errors) used to be discarded,
|
// grid; everything else (SH/DX output, WHO, MOTD, errors) used to be discarded,
|
||||||
@@ -938,22 +954,37 @@ export default function App() {
|
|||||||
if (atBottom) el.scrollTop = el.scrollHeight;
|
if (atBottom) el.scrollTop = el.scrollHeight;
|
||||||
}, [clusterLines]);
|
}, [clusterLines]);
|
||||||
// Multi-band filter: empty set = all bands. The user toggles chips.
|
// Multi-band filter: empty set = all bands. The user toggles chips.
|
||||||
const [clusterBands, setClusterBands] = useState<Set<string>>(new Set());
|
const [clusterBands, setClusterBands] = useState<Set<string>>(() => lsSet<string>('opslog.clusterBands'));
|
||||||
// Lock-to-entry: when on, the band filter follows the entry's current
|
// Lock-to-entry: when on, the band filter follows the entry's current
|
||||||
// band and the mode filter follows the entry's current mode.
|
// band and the mode filter follows the entry's current mode.
|
||||||
const [clusterLockBand, setClusterLockBand] = useState(false);
|
const [clusterLockBand, setClusterLockBand] = useState(() => lsBool('opslog.clusterLockBand', false));
|
||||||
const [clusterLockMode, setClusterLockMode] = useState(false);
|
const [clusterLockMode, setClusterLockMode] = useState(() => lsBool('opslog.clusterLockMode', 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-mode' | '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>>(() => lsSet<SpotStatusKey>('opslog.clusterStatusFilter'));
|
||||||
// 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).
|
||||||
type SpotModeCat = 'SSB' | 'CW' | 'DATA';
|
type SpotModeCat = 'SSB' | 'CW' | 'DATA';
|
||||||
const [clusterModeFilter, setClusterModeFilter] = useState<Set<SpotModeCat>>(new Set());
|
const [clusterModeFilter, setClusterModeFilter] = useState<Set<SpotModeCat>>(() => lsSet<SpotModeCat>('opslog.clusterModeFilter'));
|
||||||
const [clusterSearch, setClusterSearch] = useState('');
|
const [clusterSearch, setClusterSearch] = useState(() => localStorage.getItem('opslog.clusterSearch') || '');
|
||||||
// Hide spots already worked (exact call worked, or this band+mode slot done).
|
// Hide spots already worked (exact call worked, or this band+mode slot done).
|
||||||
const [clusterHideWorked, setClusterHideWorked] = useState(false);
|
const [clusterHideWorked, setClusterHideWorked] = useState(() => lsBool('opslog.clusterHideWorked', false));
|
||||||
|
|
||||||
|
// Persist every cluster filter selection whenever it changes, so it is still
|
||||||
|
// set after a close/reopen.
|
||||||
|
useEffect(() => {
|
||||||
|
writeUiPref('opslog.clusterFilterSource', clusterFilterSource === '' ? '' : String(clusterFilterSource));
|
||||||
|
writeUiPref('opslog.clusterGroup', clusterGroup ? '1' : '0');
|
||||||
|
writeUiPref('opslog.clusterBands', JSON.stringify([...clusterBands]));
|
||||||
|
writeUiPref('opslog.clusterLockBand', clusterLockBand ? '1' : '0');
|
||||||
|
writeUiPref('opslog.clusterLockMode', clusterLockMode ? '1' : '0');
|
||||||
|
writeUiPref('opslog.clusterStatusFilter', JSON.stringify([...clusterStatusFilter]));
|
||||||
|
writeUiPref('opslog.clusterModeFilter', JSON.stringify([...clusterModeFilter]));
|
||||||
|
writeUiPref('opslog.clusterSearch', clusterSearch);
|
||||||
|
writeUiPref('opslog.clusterHideWorked', clusterHideWorked ? '1' : '0');
|
||||||
|
}, [clusterFilterSource, clusterGroup, clusterBands, clusterLockBand, clusterLockMode,
|
||||||
|
clusterStatusFilter, clusterModeFilter, clusterSearch, clusterHideWorked]);
|
||||||
// Bands shown side-by-side in the Band Map tab (portable).
|
// Bands shown side-by-side in the Band Map tab (portable).
|
||||||
const [bandMapBands, setBandMapBands] = useState<string[]>(() => {
|
const [bandMapBands, setBandMapBands] = useState<string[]>(() => {
|
||||||
try { const v = JSON.parse(localStorage.getItem('opslog.bandMapBands') || '[]'); return Array.isArray(v) ? v : []; }
|
try { const v = JSON.parse(localStorage.getItem('opslog.bandMapBands') || '[]'); return Array.isArray(v) ? v : []; }
|
||||||
@@ -1756,6 +1787,12 @@ export default function App() {
|
|||||||
const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
|
const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
|
||||||
if (applyUdpCall(raw, true)) restartRecordingForNewTarget(String(raw ?? '')); // explicit remote pick
|
if (applyUdpCall(raw, true)) restartRecordingForNewTarget(String(raw ?? '')); // explicit remote pick
|
||||||
});
|
});
|
||||||
|
// The DX Call was cleared in WSJT-X / JTDX / MSHV → clear our entry to match.
|
||||||
|
// Only when something is actually in the entry, so an idle digital app doesn't
|
||||||
|
// wipe a call being typed by hand.
|
||||||
|
const unsubClear = EventsOn('udp:clear_call', () => {
|
||||||
|
if (callsignRef.current?.value?.trim() || callsign.trim()) resetEntry();
|
||||||
|
});
|
||||||
// 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.
|
// An explicit click always wins over whatever call is currently in the field.
|
||||||
@@ -1780,7 +1817,7 @@ export default function App() {
|
|||||||
else setError('UDP auto-log: ' + msg);
|
else setError('UDP auto-log: ' + msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => { unsubDX?.(); unsubRC?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); };
|
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
|
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
|
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
|
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_name', label: 'bulk.fMyName', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_arrl_sect', label: 'bulk.fMyArrlSect', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_darc_dok', label: 'bulk.fMyDarcDok', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_vucc_grids', label: 'bulk.fMyVuccGrids', group: 'My station', kind: 'text', upper: true },
|
||||||
// Contest
|
// Contest
|
||||||
{ id: 'contest_id', label: 'bulk.fContestId', group: 'Contest', kind: 'text', upper: true },
|
{ id: 'contest_id', label: 'bulk.fContestId', group: 'Contest', kind: 'text', upper: true },
|
||||||
{ id: 'srx_string', label: 'bulk.fSrxString', group: 'Contest', kind: 'text' },
|
{ id: 'srx_string', label: 'bulk.fSrxString', group: 'Contest', kind: 'text' },
|
||||||
|
|||||||
@@ -203,6 +203,13 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
{ group: 'My station', label: t('rqg.c.my_zip'), colId: 'my_postal_code', headerName: t('rqg.h.my_zip'), field: 'my_postal_code' as any, width: 80 },
|
{ group: 'My station', label: t('rqg.c.my_zip'), colId: 'my_postal_code', headerName: t('rqg.h.my_zip'), field: 'my_postal_code' as any, width: 80 },
|
||||||
{ group: 'My station', label: t('rqg.c.my_rig'), colId: 'my_rig', headerName: t('rqg.c.my_rig'), field: 'my_rig' as any, width: 130 },
|
{ group: 'My station', label: t('rqg.c.my_rig'), colId: 'my_rig', headerName: t('rqg.c.my_rig'), field: 'my_rig' as any, width: 130 },
|
||||||
{ group: 'My station', label: t('rqg.c.my_antenna'), colId: 'my_antenna', headerName: t('rqg.h.my_antenna'), field: 'my_antenna' as any, width: 130 },
|
{ group: 'My station', label: t('rqg.c.my_antenna'), colId: 'my_antenna', headerName: t('rqg.h.my_antenna'), field: 'my_antenna' as any, width: 130 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_name'), colId: 'my_name', headerName: t('rqg.c.my_name'), field: 'my_name' as any, width: 120 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_wwff'), colId: 'my_wwff_ref', headerName: t('rqg.c.my_wwff'), field: 'my_wwff_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_sig'), colId: 'my_sig', headerName: t('rqg.c.my_sig'), field: 'my_sig' as any, width: 90 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_sig_info'), colId: 'my_sig_info', headerName: t('rqg.c.my_sig_info'), field: 'my_sig_info' as any, width: 120 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_arrl_sect'), colId: 'my_arrl_sect', headerName: t('rqg.c.my_arrl_sect'), field: 'my_arrl_sect' as any, width: 90, cellClass: 'font-mono' },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_darc_dok'), colId: 'my_darc_dok', headerName: t('rqg.c.my_darc_dok'), field: 'my_darc_dok' as any, width: 90, cellClass: 'font-mono' },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_vucc_grids'),colId: 'my_vucc_grids', headerName: t('rqg.c.my_vucc_grids'), field: 'my_vucc_grids' as any, width: 130, cellClass: 'font-mono' },
|
||||||
|
|
||||||
// ── Misc ──
|
// ── Misc ──
|
||||||
{ group: 'Misc', label: t('rqg.c.comment'), colId: 'comment', headerName: t('rqg.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
|
{ group: 'Misc', label: t('rqg.c.comment'), colId: 'comment', headerName: t('rqg.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
|
||||||
@@ -262,6 +269,30 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
// auto-save during that window. Set in the memo (runs at render, before 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.
|
// column events) and cleared by the re-apply effect below.
|
||||||
const restoringRef = useRef(true);
|
const restoringRef = useRef(true);
|
||||||
|
|
||||||
|
// Award-column visibility is stored as an explicit set of award CODES, NOT via
|
||||||
|
// AG Grid's column-state round-trip. Those columns load asynchronously and
|
||||||
|
// race the state restore, and AG Grid preserves an existing column's visibility
|
||||||
|
// across a columnDefs rebuild instead of re-reading `hide` — which is exactly
|
||||||
|
// why previously-shown award columns kept vanishing on reopen. A dedicated set
|
||||||
|
// is deterministic: it drives `hide` directly and is re-enforced below.
|
||||||
|
const AWARD_SHOWN_KEY = 'hamlog.awardColsShown';
|
||||||
|
const [awardShown, setAwardShown] = useState<Set<string>>(() => new Set((loadLocal(AWARD_SHOWN_KEY) ?? []).map((s) => String(s).toUpperCase())));
|
||||||
|
useEffect(() => {
|
||||||
|
// Fresh machine: hydrate the set from the portable DB copy, then seed the cache.
|
||||||
|
if (loadLocal(AWARD_SHOWN_KEY)) return;
|
||||||
|
loadRemote(AWARD_SHOWN_KEY).then((remote) => {
|
||||||
|
if (remote && remote.length) {
|
||||||
|
seedLocal(AWARD_SHOWN_KEY, remote);
|
||||||
|
setAwardShown(new Set(remote.map((s: any) => String(s).toUpperCase())));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
const persistAwardShown = useCallback((next: Set<string>) => {
|
||||||
|
setAwardShown(next);
|
||||||
|
saveState(AWARD_SHOWN_KEY, [...next]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
||||||
restoringRef.current = true;
|
restoringRef.current = true;
|
||||||
const base = COL_CATALOG.map((c) => {
|
const base = COL_CATALOG.map((c) => {
|
||||||
@@ -274,16 +305,26 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
||||||
width: 110,
|
width: 110,
|
||||||
cellClass: 'text-[11px]',
|
cellClass: 'text-[11px]',
|
||||||
// Hidden by DEFAULT (award columns are opt-in). Without this, AG Grid shows
|
// Visibility comes from the persisted award-code set, so a column the user
|
||||||
// them whenever the saved column state doesn't cover them — a newly-added
|
// showed reappears on reopen and one they didn't stays hidden.
|
||||||
// award, an empty cache, or a rebuild that runs before the saved state is
|
hide: !awardShown.has(a.code.toUpperCase()),
|
||||||
// re-applied — which is why "all award columns" kept reappearing. The user's
|
|
||||||
// saved state (a column they explicitly showed) still overrides this.
|
|
||||||
hide: true,
|
|
||||||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||||
}));
|
}));
|
||||||
return [...base, ...awards];
|
return [...base, ...awards];
|
||||||
}, [awardCols, COL_CATALOG, t]);
|
}, [awardCols, COL_CATALOG, t, awardShown]);
|
||||||
|
|
||||||
|
// Enforce award-column visibility via the API after the columns (re)appear —
|
||||||
|
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
|
||||||
|
// so we set it explicitly whenever the award set or the loaded columns change.
|
||||||
|
useEffect(() => {
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (!api || !awardCols?.length) return;
|
||||||
|
for (const a of awardCols) {
|
||||||
|
const want = awardShown.has(a.code.toUpperCase());
|
||||||
|
const col = api.getColumn(`award_${a.code}`);
|
||||||
|
if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want);
|
||||||
|
}
|
||||||
|
}, [awardCols, awardShown]);
|
||||||
|
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: true,
|
sortable: true,
|
||||||
@@ -342,10 +383,22 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
// state for "which columns are visible" — AG Grid's column state is the
|
// state for "which columns are visible" — AG Grid's column state is the
|
||||||
// source of truth, and saveColumnState persists it.
|
// source of truth, and saveColumnState persists it.
|
||||||
function isColVisible(colId: string): boolean {
|
function isColVisible(colId: string): boolean {
|
||||||
|
// Award columns: the persisted set is the source of truth (see awardShown).
|
||||||
|
if (colId.startsWith('award_')) return awardShown.has(colId.slice('award_'.length).toUpperCase());
|
||||||
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;
|
||||||
}
|
}
|
||||||
function setColVisible(colId: string, visible: boolean) {
|
function setColVisible(colId: string, visible: boolean) {
|
||||||
|
// Award columns are driven by the persisted code set — update it and let the
|
||||||
|
// enforce effect apply visibility. This is what makes them stick across reopen.
|
||||||
|
if (colId.startsWith('award_')) {
|
||||||
|
const code = colId.slice('award_'.length).toUpperCase();
|
||||||
|
const next = new Set(awardShown);
|
||||||
|
if (visible) next.add(code); else next.delete(code);
|
||||||
|
persistAwardShown(next);
|
||||||
|
gridRef.current?.api?.setColumnsVisible([colId], visible);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const api = gridRef.current?.api;
|
const api = gridRef.current?.api;
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
api.setColumnsVisible([colId], visible);
|
api.setColumnsVisible([colId], visible);
|
||||||
@@ -373,6 +426,9 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
api.setColumnsVisible(visible, true);
|
api.setColumnsVisible(visible, true);
|
||||||
api.setColumnsVisible(hidden, false);
|
api.setColumnsVisible(hidden, false);
|
||||||
saveColumnState();
|
saveColumnState();
|
||||||
|
// Award columns are opt-in: reset hides them all.
|
||||||
|
persistAwardShown(new Set());
|
||||||
|
(awardCols ?? []).forEach((a) => api.setColumnsVisible([`award_${a.code}`], false));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -469,8 +525,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('rqg.grpAwards')}</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('rqg.grpAwards')}</span>
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>{t('rqg.all')}</button>
|
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { persistAwardShown(new Set(awardCols.map((a) => a.code.toUpperCase()))); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], true)); }}>{t('rqg.all')}</button>
|
||||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>{t('rqg.none')}</button>
|
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { persistAwardShown(new Set()); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], false)); }}>{t('rqg.none')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -26,6 +26,12 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
||||||
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||||
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
||||||
|
// Cluster filter selections — restored on reopen.
|
||||||
|
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
||||||
|
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||||
|
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||||
|
'opslog.activeTab', // last selected tab
|
||||||
|
'hamlog.awardColsShown', // which award columns are shown in the QSO grid
|
||||||
];
|
];
|
||||||
|
|
||||||
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ type Event struct {
|
|||||||
DecodeFreqHz int64 // RF frequency (dial + audio offset)
|
DecodeFreqHz int64 // RF frequency (dial + audio offset)
|
||||||
DecodeSNR int // reported SNR (dB)
|
DecodeSNR int // reported SNR (dB)
|
||||||
DecodeCQ bool // the decode was a CQ
|
DecodeCQ bool // the decode was a CQ
|
||||||
|
|
||||||
|
// ClearCall is set when a WSJT/JTDX/MSHV Status message reports an EMPTY DX
|
||||||
|
// Call after previously reporting one — i.e. the operator cleared the call in
|
||||||
|
// the digital app. OpsLog clears its entry to match.
|
||||||
|
ClearCall bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is a single inbound UDP listener.
|
// Server is a single inbound UDP listener.
|
||||||
@@ -64,7 +69,8 @@ type Server struct {
|
|||||||
stopped bool
|
stopped bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|
||||||
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets
|
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets
|
||||||
|
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
|
||||||
}
|
}
|
||||||
|
|
||||||
func newServer(cfg Config, out chan<- Event) *Server {
|
func newServer(cfg Config, out chan<- Event) *Server {
|
||||||
@@ -213,6 +219,17 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
ev.Mode = w.Mode
|
ev.Mode = w.Mode
|
||||||
ev.FreqHz = w.FreqHz
|
ev.FreqHz = w.FreqHz
|
||||||
ev.LoggedADIF = w.LoggedADIF
|
ev.LoggedADIF = w.LoggedADIF
|
||||||
|
// A Status with an empty DX Call, right after one that had a call, means the
|
||||||
|
// operator cleared it in WSJT-X / JTDX / MSHV. Fire ONE clear (tracked per
|
||||||
|
// server) — an idle app sends empty Status every second, and we must not
|
||||||
|
// re-clear (which would fight a manual entry) on each of those.
|
||||||
|
s.mu.Lock()
|
||||||
|
prev := s.lastDX
|
||||||
|
s.lastDX = w.DXCall
|
||||||
|
s.mu.Unlock()
|
||||||
|
if w.DXCall == "" && prev != "" {
|
||||||
|
ev.ClearCall = true
|
||||||
|
}
|
||||||
case ServiceADIF:
|
case ServiceADIF:
|
||||||
// JTAlert / GridTracker forward a text ADIF record after a QSO is
|
// JTAlert / GridTracker forward a text ADIF record after a QSO is
|
||||||
// logged. Guard against keep-alive / non-ADIF chatter on the socket:
|
// logged. Guard against keep-alive / non-ADIF chatter on the socket:
|
||||||
@@ -268,8 +285,9 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Empty events are useless; skip.
|
// Empty events are useless; skip — EXCEPT a clear signal, which is meant to be
|
||||||
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" {
|
// empty (the DX Call was cleared in the digital app).
|
||||||
|
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" && !ev.ClearCall {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -724,6 +724,10 @@ var bulkEditableCols = map[string]bool{
|
|||||||
"my_antenna": true,
|
"my_antenna": true,
|
||||||
"my_sig": true,
|
"my_sig": true,
|
||||||
"my_sig_info": true,
|
"my_sig_info": true,
|
||||||
|
"my_name": true,
|
||||||
|
"my_arrl_sect": true,
|
||||||
|
"my_darc_dok": true,
|
||||||
|
"my_vucc_grids": true,
|
||||||
// Contest — the exchange/label fields that are constant across a run.
|
// Contest — the exchange/label fields that are constant across a run.
|
||||||
// (srx/stx serial numbers stay excluded: they are per-QSO.)
|
// (srx/stx serial numbers stay excluded: they are per-QSO.)
|
||||||
"contest_id": true,
|
"contest_id": true,
|
||||||
|
|||||||
+33
-23
@@ -74,6 +74,12 @@ type Client struct {
|
|||||||
connMu sync.Mutex
|
connMu sync.Mutex
|
||||||
conn io.ReadWriteCloser
|
conn io.ReadWriteCloser
|
||||||
|
|
||||||
|
// ioMu serialises EVERY exchange on the shared connection — a status query
|
||||||
|
// (write "?A" then read 11 bytes) and a command write must never interleave,
|
||||||
|
// or their bytes mix on the wire and both frames are corrupted. The status
|
||||||
|
// poll runs on one goroutine, tuning on another, so this is essential.
|
||||||
|
ioMu sync.Mutex
|
||||||
|
|
||||||
statusMu sync.RWMutex
|
statusMu sync.RWMutex
|
||||||
lastStatus *Status
|
lastStatus *Status
|
||||||
lastSetKHz int
|
lastSetKHz int
|
||||||
@@ -85,11 +91,6 @@ type Client struct {
|
|||||||
pendingDirAt time.Time
|
pendingDirAt time.Time
|
||||||
pendingDirSet bool
|
pendingDirSet bool
|
||||||
|
|
||||||
// After a Home/Retract the controller drops out of AUTOTRACK and ignores
|
|
||||||
// frequency sets until it is turned back ON. Set on Retract, cleared by
|
|
||||||
// re-enabling on the next SetFrequency.
|
|
||||||
needAutotrack bool
|
|
||||||
|
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
running bool
|
running bool
|
||||||
}
|
}
|
||||||
@@ -231,6 +232,8 @@ func (c *Client) queryStatus() (*Status, error) {
|
|||||||
if conn == nil {
|
if conn == nil {
|
||||||
return nil, fmt.Errorf("steppir: not connected")
|
return nil, fmt.Errorf("steppir: not connected")
|
||||||
}
|
}
|
||||||
|
c.ioMu.Lock()
|
||||||
|
defer c.ioMu.Unlock()
|
||||||
setDeadline(conn, 3*time.Second)
|
setDeadline(conn, 3*time.Second)
|
||||||
if _, err := conn.Write([]byte("?A\r")); err != nil {
|
if _, err := conn.Write([]byte("?A\r")); err != nil {
|
||||||
return nil, fmt.Errorf("write status cmd: %w", err)
|
return nil, fmt.Errorf("write status cmd: %w", err)
|
||||||
@@ -250,10 +253,14 @@ func parseStatus(b []byte) (*Status, error) {
|
|||||||
freqHz := int(int32(binary.BigEndian.Uint32(b[2:6]))) * 10
|
freqHz := int(int32(binary.BigEndian.Uint32(b[2:6]))) * 10
|
||||||
active := b[6]
|
active := b[6]
|
||||||
dir := decodeDir(b[7])
|
dir := decodeDir(b[7])
|
||||||
// active==0xFF means "command just received" (not motion); the 0x01 bit is
|
// active-motors byte: one bit per element that is currently moving.
|
||||||
// documented as always set. Treat anything else non-zero as motors busy.
|
// 0x04 driver · 0x08 DIR1 · 0x10 reflector · 0x20 DIR2 (mask 0x3C)
|
||||||
|
// Bit 0 (0x01) is documented as always set — not a motor. 0xFF means the
|
||||||
|
// controller just received a command, not motion. So "moving" is precisely
|
||||||
|
// "any real motor bit set", ignoring the always-on bit and the ack value.
|
||||||
|
const motorBits = 0x3C
|
||||||
moving := 0
|
moving := 0
|
||||||
if active != 0xFF && (active & ^byte(0x01)) != 0 {
|
if active != 0xFF && active&motorBits != 0 {
|
||||||
moving = 1
|
moving = 1
|
||||||
}
|
}
|
||||||
return &Status{Frequency: freqHz / 1000, Direction: dir, MotorsMoving: moving}, nil
|
return &Status{Frequency: freqHz / 1000, Direction: dir, MotorsMoving: moving}, nil
|
||||||
@@ -299,6 +306,9 @@ func (c *Client) writeCmd(pkt []byte) error {
|
|||||||
if conn == nil {
|
if conn == nil {
|
||||||
return fmt.Errorf("steppir: not connected")
|
return fmt.Errorf("steppir: not connected")
|
||||||
}
|
}
|
||||||
|
c.ioMu.Lock()
|
||||||
|
defer c.ioMu.Unlock()
|
||||||
|
log.Printf("steppir: → % X", pkt)
|
||||||
setDeadline(conn, 3*time.Second)
|
setDeadline(conn, 3*time.Second)
|
||||||
if _, err := conn.Write(pkt); err != nil {
|
if _, err := conn.Write(pkt); err != nil {
|
||||||
c.closeConn()
|
c.closeConn()
|
||||||
@@ -309,16 +319,19 @@ func (c *Client) writeCmd(pkt []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetFrequency tunes the elements to freqKhz with the given direction. If a prior
|
// SetFrequency tunes the elements to freqKhz with the given direction.
|
||||||
// Retract dropped AUTOTRACK, re-enable it first — otherwise the set is ignored.
|
//
|
||||||
|
// AUTOTRACK is (re-)enabled first, EVERY time: the controller ignores frequency
|
||||||
|
// sets unless it is in AUTOTRACK mode ("when not in AUTOTRACK only CALIBRATE and
|
||||||
|
// RETRACT work"), and it can be out of AUTOTRACK at power-on, after a Home, or if
|
||||||
|
// switched off on the front panel. Sending the 'R' command each tune is cheap and
|
||||||
|
// makes tuning work regardless of the controller's current mode — which is what
|
||||||
|
// was silently failing before.
|
||||||
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
||||||
if c.needAutotrack {
|
if err := c.writeCmd(buildSet(freqKhz*1000, direction, 'R')); err != nil { // AUTOTRACK ON
|
||||||
if err := c.writeCmd(buildSet(freqKhz*1000, direction, 'R')); err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
c.needAutotrack = false
|
|
||||||
}
|
}
|
||||||
if err := c.writeCmd(buildSet(freqKhz*1000, direction, '1')); err != nil {
|
if err := c.writeCmd(buildSet(freqKhz*1000, direction, '1')); err != nil { // set freq + dir
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
@@ -343,8 +356,9 @@ func (c *Client) SetDirection(direction int) error {
|
|||||||
return c.SetFrequency(khz, direction)
|
return c.SetFrequency(khz, direction)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retract homes the elements into the hubs (storage). This leaves AUTOTRACK off,
|
// Retract homes the elements into the hubs (storage). This drops the controller
|
||||||
// so the next SetFrequency re-enables it.
|
// out of AUTOTRACK, but that is handled transparently: the next SetFrequency
|
||||||
|
// re-issues AUTOTRACK ON before tuning.
|
||||||
func (c *Client) Retract() error {
|
func (c *Client) Retract() error {
|
||||||
// A valid frequency must accompany the command; reuse the last one.
|
// A valid frequency must accompany the command; reuse the last one.
|
||||||
khz := c.LastSetKHz()
|
khz := c.LastSetKHz()
|
||||||
@@ -355,9 +369,5 @@ func (c *Client) Retract() error {
|
|||||||
khz = 14000 // any in-range value; the controller just homes
|
khz = 14000 // any in-range value; the controller just homes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := c.writeCmd(buildSet(khz*1000, DirNormal, 'S')); err != nil {
|
return c.writeCmd(buildSet(khz*1000, DirNormal, 'S'))
|
||||||
return err
|
|
||||||
}
|
|
||||||
c.needAutotrack = true
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,10 +50,32 @@ func TestBuildSetDirectionAndCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A REAL status frame captured off an SDA controller (F4BPO's, 2026-07-15):
|
||||||
|
// 40 41 00 4C C5 84 00 87 30 38 0D — 50.313 MHz, idle, bidirectional, interface
|
||||||
|
// version "08". Using the actual device output (rather than hand-built bytes)
|
||||||
|
// pins parseStatus to real hardware, and independently confirms the ÷10 wire
|
||||||
|
// scale: 0x4CC584 × 10 = 50 313 000 Hz, a genuine 6 m frequency.
|
||||||
|
func TestParseStatusRealFrame(t *testing.T) {
|
||||||
|
frame := []byte{0x40, 0x41, 0x00, 0x4C, 0xC5, 0x84, 0x00, 0x87, 0x30, 0x38, 0x0D}
|
||||||
|
st, err := parseStatus(frame)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.Frequency != 50313 {
|
||||||
|
t.Errorf("freq = %d kHz, want 50313 (50.313 MHz)", st.Frequency)
|
||||||
|
}
|
||||||
|
if st.Direction != DirBi { // 0x87 & 0xE0 = 0x80 = bidirectional
|
||||||
|
t.Errorf("direction = %d, want %d (bidirectional)", st.Direction, DirBi)
|
||||||
|
}
|
||||||
|
if st.MotorsMoving != 0 {
|
||||||
|
t.Errorf("moving = %d, want 0 (motors byte 0x00)", st.MotorsMoving)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// parseStatus decodes what the controller sends back — the inverse of buildSet's
|
// parseStatus decodes what the controller sends back — the inverse of buildSet's
|
||||||
// frequency field, plus the direction nibble.
|
// frequency field, plus the direction nibble and the motors-busy byte.
|
||||||
func TestParseStatus(t *testing.T) {
|
func TestParseStatus(t *testing.T) {
|
||||||
frame := []byte{0x00, 0x00, 0x00, 0x15, 0x79, 0xA8, 0x01, 0x40, '1', '2', 0x0D}
|
frame := []byte{0x40, 0x41, 0x00, 0x15, 0x79, 0xA8, 0x01, 0x40, '0', '8', 0x0D}
|
||||||
st, err := parseStatus(frame)
|
st, err := parseStatus(frame)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
Reference in New Issue
Block a user