7 Commits
10 changed files with 228 additions and 56 deletions
+15
View File
@@ -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
View File
@@ -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' },
+65 -9
View File
@@ -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
+6
View File
@@ -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:
+20 -2
View File
@@ -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.
@@ -65,6 +70,7 @@ type Server struct {
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 {
+4
View File
@@ -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,
+32 -22
View File
@@ -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 { // set freq + dir
}
if err := c.writeCmd(buildSet(freqKhz*1000, direction, '1')); err != nil {
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
} }
+24 -2
View File
@@ -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)