Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd13921322 | ||
|
|
46e3619a38 | ||
|
|
dcf006905c | ||
|
|
7cf2dfeaf9 | ||
|
|
69229964f4 | ||
|
|
09848adddc |
@@ -7760,9 +7760,24 @@ type ConfirmationItem struct {
|
||||
Country string `json:"country"`
|
||||
NewDXCC bool `json:"new_dxcc"`
|
||||
NewBand bool `json:"new_band"`
|
||||
NewMode bool `json:"new_mode"` // new mode CLASS (Phone/CW/Digital) for the entity
|
||||
NewSlot bool `json:"new_slot"`
|
||||
}
|
||||
|
||||
// GetSlotStats returns the worked/confirmed slot + DXCC tallies for the QSL
|
||||
// Manager. Slots are counted by mode CLASS (Phone/CW/Digital); the raw-mode
|
||||
// granularity (RTTY ≠ FT8) is kept only for the cluster/matrix "new slot" flag.
|
||||
func (a *App) GetSlotStats() qso.SlotStats {
|
||||
if a.qso == nil {
|
||||
return qso.SlotStats{}
|
||||
}
|
||||
st, err := a.qso.GetSlotStats(a.ctx)
|
||||
if err != nil {
|
||||
return qso.SlotStats{}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// DownloadConfirmations pulls confirmed QSOs from a service and updates the
|
||||
// matching local QSOs' received status. LoTW only for now (the canonical
|
||||
// confirmation system); runs in the background emitting the same
|
||||
@@ -7887,12 +7902,17 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
Country: q.Country,
|
||||
}
|
||||
if dxccNum != 0 {
|
||||
// Flags use mode CLASS (Phone/CW/Digital), not raw mode: a DIGI
|
||||
// confirmation is "new mode/slot" only if no digital mode was
|
||||
// confirmed there before (RTTY and FT8 are the same class here).
|
||||
it.NewDXCC = !sets.DXCC[dxccNum]
|
||||
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
|
||||
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)]
|
||||
it.NewMode = !sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)]
|
||||
it.NewSlot = !sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)]
|
||||
sets.DXCC[dxccNum] = true
|
||||
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
|
||||
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true
|
||||
sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)] = true
|
||||
sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)] = true
|
||||
}
|
||||
items = append(items, it)
|
||||
return nil
|
||||
@@ -8047,12 +8067,17 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
Band: q.Band, Mode: q.Mode, Country: q.Country,
|
||||
}
|
||||
if dxccNum != 0 {
|
||||
// Flags use mode CLASS (Phone/CW/Digital), not raw mode: a DIGI
|
||||
// confirmation is "new mode/slot" only if no digital mode was
|
||||
// confirmed there before (RTTY and FT8 are the same class here).
|
||||
it.NewDXCC = !sets.DXCC[dxccNum]
|
||||
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
|
||||
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)]
|
||||
it.NewMode = !sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)]
|
||||
it.NewSlot = !sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)]
|
||||
sets.DXCC[dxccNum] = true
|
||||
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
|
||||
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true
|
||||
sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)] = true
|
||||
sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)] = true
|
||||
}
|
||||
items = append(items, it)
|
||||
return nil
|
||||
@@ -8163,12 +8188,17 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
Band: q.Band, Mode: q.Mode, Country: q.Country,
|
||||
}
|
||||
if dxccNum != 0 {
|
||||
// Flags use mode CLASS (Phone/CW/Digital), not raw mode: a DIGI
|
||||
// confirmation is "new mode/slot" only if no digital mode was
|
||||
// confirmed there before (RTTY and FT8 are the same class here).
|
||||
it.NewDXCC = !sets.DXCC[dxccNum]
|
||||
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
|
||||
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)]
|
||||
it.NewMode = !sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)]
|
||||
it.NewSlot = !sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)]
|
||||
sets.DXCC[dxccNum] = true
|
||||
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
|
||||
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true
|
||||
sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)] = true
|
||||
sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)] = true
|
||||
}
|
||||
items = append(items, it)
|
||||
return nil
|
||||
@@ -10747,6 +10777,14 @@ type motorAntenna interface {
|
||||
Retract() error
|
||||
LastSetKHz() int
|
||||
Status() motorStatus
|
||||
// Elements returns the per-element lengths (mm) when the antenna exposes them
|
||||
// (Ultrabeam), or nil when it doesn't (SteppIR tunes its elements from the
|
||||
// frequency and has no individual-element command).
|
||||
Elements() []int
|
||||
SetElement(num, lengthMm int) error
|
||||
// ReadElements queries the controller for the current element lengths on demand
|
||||
// (Ultrabeam CMD_READ_BANDS); SteppIR returns an error.
|
||||
ReadElements() ([]int, error)
|
||||
}
|
||||
|
||||
// ubAdapter / steppirAdapter wrap each concrete client to the shared interface,
|
||||
@@ -10760,6 +10798,14 @@ func (a ubAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d)
|
||||
func (a ubAdapter) SetDirection(d int) error { return a.c.SetDirection(d) }
|
||||
func (a ubAdapter) Retract() error { return a.c.Retract() }
|
||||
func (a ubAdapter) LastSetKHz() int { return a.c.LastSetKHz() }
|
||||
func (a ubAdapter) SetElement(n, mm int) error { return a.c.ModifyElement(n, mm) }
|
||||
func (a ubAdapter) ReadElements() ([]int, error) { return a.c.ReadElements() }
|
||||
func (a ubAdapter) Elements() []int {
|
||||
if st, err := a.c.GetStatus(); err == nil && st != nil {
|
||||
return st.ElementLengths
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (a ubAdapter) Status() motorStatus {
|
||||
st, err := a.c.GetStatus()
|
||||
if err != nil || st == nil {
|
||||
@@ -10776,6 +10822,13 @@ func (a steppirAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k
|
||||
func (a steppirAdapter) SetDirection(d int) error { return a.c.SetDirection(d) }
|
||||
func (a steppirAdapter) Retract() error { return a.c.Retract() }
|
||||
func (a steppirAdapter) LastSetKHz() int { return a.c.LastSetKHz() }
|
||||
func (a steppirAdapter) Elements() []int { return nil } // SteppIR: no per-element control
|
||||
func (a steppirAdapter) SetElement(_, _ int) error {
|
||||
return fmt.Errorf("individual element control is not available on the SteppIR")
|
||||
}
|
||||
func (a steppirAdapter) ReadElements() ([]int, error) {
|
||||
return nil, fmt.Errorf("individual element control is not available on the SteppIR")
|
||||
}
|
||||
func (a steppirAdapter) Status() motorStatus {
|
||||
st, err := a.c.GetStatus()
|
||||
if err != nil || st == nil {
|
||||
@@ -11062,19 +11115,22 @@ func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, stop <-chan struc
|
||||
// direction control). Enabled mirrors the setting; the rest comes from the
|
||||
// device's most recent status poll.
|
||||
type UltrabeamStatusInfo struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Connected bool `json:"connected"`
|
||||
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bidirectional
|
||||
Frequency int `json:"frequency"` // KHz
|
||||
Band int `json:"band"`
|
||||
Moving bool `json:"moving"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Type string `json:"type"` // "ultrabeam" | "steppir"
|
||||
Connected bool `json:"connected"`
|
||||
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bidirectional
|
||||
Frequency int `json:"frequency"` // KHz
|
||||
Band int `json:"band"`
|
||||
Moving bool `json:"moving"`
|
||||
Elements []int `json:"elements"` // per-element lengths (mm); empty when unsupported
|
||||
}
|
||||
|
||||
// GetUltrabeamStatus returns the antenna's current state for the UI poll.
|
||||
func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo {
|
||||
out := UltrabeamStatusInfo{}
|
||||
out := UltrabeamStatusInfo{Elements: []int{}}
|
||||
s, _ := a.GetUltrabeamSettings()
|
||||
out.Enabled = s.Enabled
|
||||
out.Type = s.Type
|
||||
if a.motorAnt == nil {
|
||||
return out
|
||||
}
|
||||
@@ -11084,9 +11140,31 @@ func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo {
|
||||
out.Frequency = st.Frequency
|
||||
out.Band = st.Band
|
||||
out.Moving = st.Moving
|
||||
if el := a.motorAnt.Elements(); el != nil {
|
||||
out.Elements = el
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MotorSetElement sets one element's length (mm) on an Ultrabeam. Returns an
|
||||
// error on the SteppIR, which has no per-element command.
|
||||
func (a *App) MotorSetElement(num, lengthMm int) error {
|
||||
if a.motorAnt == nil {
|
||||
return fmt.Errorf("antenna not connected — enable it in Settings → Antenna")
|
||||
}
|
||||
a.noteMotorMoveCommanded()
|
||||
return a.motorAnt.SetElement(num, lengthMm)
|
||||
}
|
||||
|
||||
// MotorReadElements queries the controller for the current element lengths (mm),
|
||||
// so the operator adjusts from the real values instead of blind. Ultrabeam only.
|
||||
func (a *App) MotorReadElements() ([]int, error) {
|
||||
if a.motorAnt == nil {
|
||||
return nil, fmt.Errorf("antenna not connected — enable it in Settings → Antenna")
|
||||
}
|
||||
return a.motorAnt.ReadElements()
|
||||
}
|
||||
|
||||
// SetUltrabeamDirection switches the antenna pattern: 0=normal, 1=180°,
|
||||
// 2=bidirectional (re-issues the current frequency with the new direction).
|
||||
func (a *App) SetUltrabeamDirection(direction int) error {
|
||||
|
||||
+19
-14
@@ -2866,12 +2866,12 @@ export default function App() {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col flex-[0.55] min-w-[70px]"><Label className="mb-1 h-3.5">{t('field.qth')}</Label>
|
||||
<div className="flex flex-col flex-1 min-w-[130px]"><Label className="mb-1 h-3.5">{t('field.qth')}</Label>
|
||||
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
|
||||
</div>
|
||||
);
|
||||
const gridBlock = (
|
||||
<div className="flex flex-col w-28 shrink-0"><Label className="mb-1 h-3.5">{t('field.grid')}</Label>
|
||||
<div className="flex flex-col w-[76px] shrink-0"><Label className="mb-1 h-3.5">{t('field.grid')}</Label>
|
||||
<Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
|
||||
</div>
|
||||
);
|
||||
@@ -2885,8 +2885,8 @@ export default function App() {
|
||||
: d < 30 ? 'border-warning text-warning bg-warning/15'
|
||||
: 'border-danger text-danger bg-danger/15';
|
||||
return (
|
||||
<div className="self-end shrink-0" title={t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })}>
|
||||
<span className={cn('inline-flex items-center rounded-md border px-1.5 py-1.5 text-[10px] font-extrabold tracking-wide leading-none', cls)}>
|
||||
<div className="shrink-0" title={t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })}>
|
||||
<span className={cn('inline-flex items-center rounded-md border px-1.5 py-1 text-[9px] font-extrabold tracking-wide leading-none', cls)}>
|
||||
LoTW
|
||||
</span>
|
||||
</div>
|
||||
@@ -2901,7 +2901,7 @@ export default function App() {
|
||||
// worked-before check doesn't include these, and see what's waiting.
|
||||
const offlinePendingCount = offlineStatus.pending ?? 0;
|
||||
const offlineBlock = offlinePendingCount === 0 ? null : (
|
||||
<div className="relative self-end mb-0.5 shrink-0">
|
||||
<div className="relative shrink-0">
|
||||
<button type="button" onClick={() => { setPendingOpen((o) => !o); GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {}); }}
|
||||
title={t('offline.tip', { n: offlinePendingCount })}
|
||||
className="relative inline-flex h-8 items-center gap-1.5 rounded-full border border-danger bg-danger/15 px-2.5 text-danger transition-colors hover:bg-danger/25">
|
||||
@@ -2954,7 +2954,7 @@ export default function App() {
|
||||
const hasAlerts = recentAlerts.length > 0;
|
||||
// Only show the bell when there are pending alerts — hidden otherwise.
|
||||
const alertLedBlock = !hasAlerts ? null : (
|
||||
<div className="relative self-end mb-0.5 shrink-0">
|
||||
<div className="relative shrink-0">
|
||||
<button type="button" onClick={() => setAlertsPanelOpen((o) => !o)}
|
||||
title={hasAlerts ? t('alert.pending', { n: recentAlerts.length }) : t('alert.noneShort')}
|
||||
className={cn('relative inline-flex size-8 items-center justify-center rounded-full border transition-colors',
|
||||
@@ -3109,19 +3109,19 @@ export default function App() {
|
||||
const logButtons = (
|
||||
<div className="flex flex-col">
|
||||
<Label className="mb-1 h-3.5"> </Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-1.5">
|
||||
{clusterServerStatuses.some((s) => s.state === 'connected') && (
|
||||
<Button type="button" variant="outline" onClick={() => setShowSpotModal(true)} className="h-8" title="Send a DX spot to the master cluster">
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => setShowSpotModal(true)} className="h-8 px-2.5 text-xs" title="Send a DX spot to the master cluster">
|
||||
<Satellite className="size-3.5" />
|
||||
{t('btn.spot')}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={() => { resetEntry(); callsignRef.current?.focus(); }} className="h-8"
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => { resetEntry(); callsignRef.current?.focus(); }} className="h-8 px-2.5 text-xs"
|
||||
title="Clear the QSO entry (always — unlike Esc which may be reserved for the CW keyer)">
|
||||
<Eraser className="size-3.5" />
|
||||
{t('btn.clear')}
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving} className="h-8">
|
||||
<Button size="sm" onClick={save} disabled={saving} className="h-8 px-3 text-xs">
|
||||
<Send className="size-3.5" />
|
||||
{saving ? '…' : t('btn.logQso')}
|
||||
</Button>
|
||||
@@ -3910,9 +3910,6 @@ export default function App() {
|
||||
</div>
|
||||
{qthBlock}
|
||||
{gridBlock}
|
||||
{lotwBlock}
|
||||
{offlineBlock}
|
||||
{alertLedBlock}
|
||||
</div>
|
||||
|
||||
{/* Row 3: tight left detail column (Band/Mode/Country) and
|
||||
@@ -3929,11 +3926,19 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: TX freq, RX freq, RX band — plus the action buttons. */}
|
||||
{/* Bottom: TX freq, RX freq, RX band — then the alert LED + LoTW badge
|
||||
(vertically centred on the input row), then the action buttons. */}
|
||||
<div className="flex gap-2 items-end">
|
||||
{freqBlock}
|
||||
{rxFreqBlock}
|
||||
{bandRxBlock}
|
||||
{(alertLedBlock || lotwBlock || offlineBlock) && (
|
||||
<div className="flex items-center gap-2 h-9 self-end">
|
||||
{alertLedBlock}
|
||||
{lotwBlock}
|
||||
{offlineBlock}
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto">{logButtons}</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
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, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
@@ -32,7 +32,7 @@ const UPLOAD_COLS: ColDef<UploadRow>[] = [
|
||||
|
||||
type Confirmation = {
|
||||
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
||||
new_dxcc: boolean; new_band: boolean; new_slot: boolean;
|
||||
new_dxcc: boolean; new_band: boolean; new_mode: boolean; new_slot: boolean;
|
||||
};
|
||||
|
||||
const SERVICES = [
|
||||
@@ -208,18 +208,27 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
|
||||
|
||||
// Worked/confirmed tallies. Slots are counted by mode CLASS (PH/CW/DIGI) — the
|
||||
// raw-mode granularity (RTTY ≠ FT8) stays only in the cluster/matrix new-slot flag.
|
||||
const [stats, setStats] = useState<{ slots_worked: number; slots_confirmed: number; dxcc_worked: number; dxcc_confirmed: number }>(
|
||||
{ slots_worked: 0, slots_confirmed: 0, dxcc_worked: 0, dxcc_confirmed: 0 });
|
||||
const refreshCounts = useCallback(() => { GetSlotStats().then((c: any) => setStats(c)).catch(() => {}); }, []);
|
||||
useEffect(() => { refreshCounts(); }, [refreshCounts]);
|
||||
|
||||
useEffect(() => {
|
||||
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
|
||||
const offDone = EventsOn('qslmgr:done', (d: any) => {
|
||||
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} —`]);
|
||||
setBusy(false);
|
||||
refreshCounts(); // a download may have added confirmations
|
||||
});
|
||||
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
|
||||
setConfirmations((list ?? []) as Confirmation[]);
|
||||
setViewMode('confirmations');
|
||||
refreshCounts();
|
||||
});
|
||||
return () => { offLog(); offDone(); offConf(); };
|
||||
}, []);
|
||||
}, [refreshCounts]);
|
||||
|
||||
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
||||
|
||||
@@ -231,9 +240,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
|
||||
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
||||
switch (confFilter) {
|
||||
case 'new': return c.new_dxcc || c.new_band || c.new_slot;
|
||||
case 'new': return c.new_dxcc || c.new_band || c.new_mode || c.new_slot;
|
||||
case 'dxcc': return c.new_dxcc;
|
||||
case 'band': return c.new_band;
|
||||
case 'mode': return c.new_mode;
|
||||
case 'slot': return c.new_slot;
|
||||
default: return true;
|
||||
}
|
||||
@@ -353,6 +363,21 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{/* Worked / confirmed tallies. Slots by mode class (PH/CW/DIGI). */}
|
||||
<div className="self-center flex items-center gap-3 text-[11px] font-mono" title={t('qslm.countsTip')}>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground uppercase text-[9px] tracking-wider">{t('qslm.slots')}</span>
|
||||
<span className="font-semibold">{stats.slots_worked}</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="text-success font-semibold">{stats.slots_confirmed}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground uppercase text-[9px] tracking-wider">DXCC</span>
|
||||
<span className="font-semibold">{stats.dxcc_worked}</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="text-success font-semibold">{stats.dxcc_confirmed}</span>
|
||||
</span>
|
||||
</div>
|
||||
{service === 'pota' && potaRes && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('qslm.potaSummaryShort', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched })}{potaRes.skipped_other_call > 0 ? t('qslm.potaOtherCall', { n: potaRes.skipped_other_call }) : ''} / {potaRes.fetched}
|
||||
@@ -371,6 +396,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
<SelectItem value="new">{t('qslm.filterNew')}</SelectItem>
|
||||
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
|
||||
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
|
||||
<SelectItem value="mode">{t('qslm.filterNewMode')}</SelectItem>
|
||||
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -491,6 +517,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
<td className="py-1 px-2">
|
||||
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-danger-muted text-danger-muted-foreground border border-danger-border">{t('qslm.newDxcc')}</span>
|
||||
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border">{t('qslm.newBand')}</span>
|
||||
: c.new_mode ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-info-muted text-info-muted-foreground border border-info-border">{t('qslm.newMode')}</span>
|
||||
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-caution-muted text-caution-muted-foreground border border-caution-border">{t('qslm.newSlot')}</span>
|
||||
: <span className="text-muted-foreground/50">—</span>}
|
||||
</td>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -11,6 +11,7 @@ import { RotorCompass } from '@/components/RotorCompass';
|
||||
import {
|
||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
||||
@@ -91,6 +92,126 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
||||
);
|
||||
}
|
||||
|
||||
type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[] };
|
||||
|
||||
// MotorAntennaWidget controls a motorized antenna (Ultrabeam / SteppIR) from the
|
||||
// Station Control tab: pattern (Normal / 180° / Bi), Retract, and — Ultrabeam
|
||||
// only — per-element length adjustment. Heading/state is polled by the panel.
|
||||
const ELEMENT_STEP = 2; // the physical console adjusts 2 mm per press
|
||||
|
||||
// elementName maps an element index to a ham-radio name: 0 = reflector,
|
||||
// 1 = driven element, then Director 1, 2, 3…
|
||||
function elementName(i: number, t: (k: string, v?: any) => string): string {
|
||||
if (i === 0) return t('station.reflector');
|
||||
if (i === 1) return t('station.driven');
|
||||
return `${t('station.director')} ${i - 1}`;
|
||||
}
|
||||
|
||||
function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () => void; t: (k: string, v?: any) => string }) {
|
||||
const [err, setErr] = useState('');
|
||||
const [lengths, setLengths] = useState<number[]>([]); // current element lengths (mm), from ReadElements
|
||||
const [reading, setReading] = useState(false);
|
||||
const [busyEl, setBusyEl] = useState<number | null>(null);
|
||||
const run = (p: Promise<any>) => p.then(refetch).catch((e) => setErr(String(e?.message ?? e)));
|
||||
const isUB = ant.type !== 'steppir';
|
||||
|
||||
const readLengths = useCallback(async () => {
|
||||
setReading(true); setErr('');
|
||||
try { setLengths(((await MotorReadElements()) ?? []) as number[]); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
finally { setReading(false); }
|
||||
}, []);
|
||||
// Read the current lengths once when the Ultrabeam widget mounts/connects, so
|
||||
// +/- starts from the real values rather than blind.
|
||||
useEffect(() => { if (isUB && ant.connected) readLengths(); }, [isUB, ant.connected, readLengths]);
|
||||
|
||||
// Nudge one element by ±2 mm from its current known length.
|
||||
const nudge = async (i: number, delta: number) => {
|
||||
const cur = lengths[i] ?? 0;
|
||||
const next = Math.max(0, cur + delta);
|
||||
setBusyEl(i); setErr('');
|
||||
setLengths((ls) => { const c = [...ls]; c[i] = next; return c; }); // optimistic
|
||||
try { await MotorSetElement(i, next); refetch(); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
finally { setBusyEl(null); }
|
||||
};
|
||||
const dirs: [number, string][] = [[0, 'N'], [1, '180°'], [2, t('station.bi')]];
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<AntennaIcon className="size-4 text-primary" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{isUB ? 'Ultrabeam' : 'SteppIR'}</div>
|
||||
{ant.frequency > 0 && <div className="text-[10px] text-muted-foreground font-mono">{(ant.frequency / 1000).toFixed(3)} MHz</div>}
|
||||
</div>
|
||||
{ant.moving && <span className="ml-auto text-[10px] font-semibold text-warning animate-pulse">{t('station.moving')}</span>}
|
||||
<span className={cn('size-2 rounded-full shrink-0', ant.moving ? '' : 'ml-auto', ant.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||
title={ant.connected ? t('station.online') : t('station.offline')} />
|
||||
</div>
|
||||
<div className="p-3 space-y-3">
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">{t('station.pattern')}</div>
|
||||
<div className="flex gap-1">
|
||||
{dirs.map(([d, lbl]) => (
|
||||
<button key={d} type="button" disabled={!ant.connected}
|
||||
onClick={() => run(SetUltrabeamDirection(d))}
|
||||
className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors disabled:opacity-40',
|
||||
ant.direction === d ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted')}>
|
||||
{lbl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" disabled={!ant.connected}
|
||||
onClick={() => run(UltrabeamRetract())}
|
||||
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1.5 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
||||
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
|
||||
</button>
|
||||
|
||||
{isUB && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{t('station.elements')}</span>
|
||||
<button type="button" onClick={readLengths} disabled={!ant.connected || reading}
|
||||
className="text-[10px] text-primary hover:underline inline-flex items-center gap-1 disabled:opacity-40" title={t('station.readLengths')}>
|
||||
<RefreshCw className={cn('size-3', reading && 'animate-spin')} /> {t('station.read')}
|
||||
</button>
|
||||
</div>
|
||||
{lengths.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{/* Hide 0-length elements — an Ultrabeam reports 6 slots but a
|
||||
3-element beam only uses the first few. */}
|
||||
{lengths.map((mm, i) => ({ mm, i })).filter((e) => e.mm > 0).map(({ mm, i }) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
|
||||
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||
onClick={() => nudge(i, -ELEMENT_STEP)}
|
||||
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
||||
<Minus className="size-3.5" />
|
||||
</button>
|
||||
<span className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums">
|
||||
{busyEl === i ? <Loader2 className="size-3.5 animate-spin inline" /> : `${mm} mm`}
|
||||
</span>
|
||||
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||
onClick={() => nudge(i, ELEMENT_STEP)}
|
||||
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground mt-1">{t('station.elementsHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
|
||||
const { t } = useI18n();
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
@@ -98,11 +219,13 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
const [editing, setEditing] = useState<Device | null>(null); // device being added/edited
|
||||
const [busy, setBusy] = useState<Record<string, boolean>>({}); // per-relay in-flight
|
||||
const [rot, setRot] = useState<Heading>({ enabled: false, ok: false, azimuth: 0 });
|
||||
const [ant, setAnt] = useState<AntStatus>({ enabled: false, type: '', connected: false, direction: 0, frequency: 0, moving: false, elements: [] });
|
||||
// Widget order (rotator + device ids), drag-and-drop reorderable, persisted.
|
||||
const [order, setOrder] = useState<string[]>(() => {
|
||||
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
|
||||
});
|
||||
const dragId = useRef<string | null>(null);
|
||||
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
||||
|
||||
const loadDevices = useCallback(async () => {
|
||||
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
||||
@@ -117,13 +240,16 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
const pollRot = useCallback(async () => {
|
||||
try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ }
|
||||
}, []);
|
||||
const pollAnt = useCallback(async () => {
|
||||
try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadDevices(); }, [loadDevices]);
|
||||
useEffect(() => {
|
||||
poll(); pollRot();
|
||||
const id = window.setInterval(() => { poll(); pollRot(); }, 3000);
|
||||
poll(); pollRot(); pollAnt();
|
||||
const id = window.setInterval(() => { poll(); pollRot(); pollAnt(); }, 3000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [poll, pollRot, devices.length]);
|
||||
}, [poll, pollRot, pollAnt, devices.length]);
|
||||
|
||||
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
|
||||
// Reorder so `dragged` lands just before `target`.
|
||||
@@ -217,21 +343,43 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
if (rot.enabled) {
|
||||
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||
}
|
||||
if (ant.enabled) {
|
||||
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
||||
}
|
||||
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
||||
|
||||
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
||||
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
|
||||
const widgetIds = ordered.map((w) => w.id);
|
||||
|
||||
const noDevices = devices.length === 0 && !rot.enabled;
|
||||
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled;
|
||||
|
||||
// Column count controls the layout: with 4 widgets, choosing "2" lays them out
|
||||
// 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to
|
||||
// fill however many columns are available.
|
||||
const gridCols: Record<string, string> = {
|
||||
auto: 'sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4',
|
||||
'1': 'grid-cols-1', '2': 'grid-cols-2', '3': 'grid-cols-3', '4': 'grid-cols-4',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-auto p-4">
|
||||
<div className="flex items-center justify-between mb-3 max-w-4xl">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
|
||||
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
|
||||
{(['auto', '2', '3', '4'] as const).map((c) => (
|
||||
<button key={c} type="button"
|
||||
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
|
||||
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||
{c === 'auto' ? t('station.colsAuto') : c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
|
||||
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{noDevices && !editing && (
|
||||
@@ -240,7 +388,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 max-w-4xl md:grid-cols-2 items-start">
|
||||
<div className={cn('grid gap-3 items-start', gridCols[cols] ?? gridCols.auto)}>
|
||||
{ordered.map((w) => (
|
||||
<div key={w.id} draggable
|
||||
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||||
@@ -253,7 +401,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
</div>
|
||||
|
||||
{!noDevices && (
|
||||
<p className="text-[11px] text-muted-foreground mt-2 max-w-4xl">{t('station.dragHint')}</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-2">{t('station.dragHint')}</p>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+6
@@ -382,6 +382,8 @@ export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
||||
|
||||
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||
|
||||
export function GetSlotStats():Promise<qso.SlotStats>;
|
||||
|
||||
export function GetSolarData():Promise<solar.Data>;
|
||||
|
||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||
@@ -538,6 +540,10 @@ export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||
|
||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||
|
||||
export function MotorReadElements():Promise<Array<number>>;
|
||||
|
||||
export function MotorSetElement(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function MoveDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function NetActivate(arg1:string):Promise<qso.QSO>;
|
||||
|
||||
@@ -722,6 +722,10 @@ export function GetSecretStatus() {
|
||||
return window['go']['main']['App']['GetSecretStatus']();
|
||||
}
|
||||
|
||||
export function GetSlotStats() {
|
||||
return window['go']['main']['App']['GetSlotStats']();
|
||||
}
|
||||
|
||||
export function GetSolarData() {
|
||||
return window['go']['main']['App']['GetSolarData']();
|
||||
}
|
||||
@@ -1034,6 +1038,14 @@ export function LookupCallsign(arg1) {
|
||||
return window['go']['main']['App']['LookupCallsign'](arg1);
|
||||
}
|
||||
|
||||
export function MotorReadElements() {
|
||||
return window['go']['main']['App']['MotorReadElements']();
|
||||
}
|
||||
|
||||
export function MotorSetElement(arg1, arg2) {
|
||||
return window['go']['main']['App']['MotorSetElement'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function MoveDatabase(arg1) {
|
||||
return window['go']['main']['App']['MoveDatabase'](arg1);
|
||||
}
|
||||
|
||||
@@ -2580,11 +2580,13 @@ export namespace main {
|
||||
}
|
||||
export class UltrabeamStatusInfo {
|
||||
enabled: boolean;
|
||||
type: string;
|
||||
connected: boolean;
|
||||
direction: number;
|
||||
frequency: number;
|
||||
band: number;
|
||||
moving: boolean;
|
||||
elements: number[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new UltrabeamStatusInfo(source);
|
||||
@@ -2593,11 +2595,13 @@ export namespace main {
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.type = source["type"];
|
||||
this.connected = source["connected"];
|
||||
this.direction = source["direction"];
|
||||
this.frequency = source["frequency"];
|
||||
this.band = source["band"];
|
||||
this.moving = source["moving"];
|
||||
this.elements = source["elements"];
|
||||
}
|
||||
}
|
||||
export class UpdateInfo {
|
||||
@@ -3655,6 +3659,24 @@ export namespace qso {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class SlotStats {
|
||||
slots_worked: number;
|
||||
slots_confirmed: number;
|
||||
dxcc_worked: number;
|
||||
dxcc_confirmed: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SlotStats(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.slots_worked = source["slots_worked"];
|
||||
this.slots_confirmed = source["slots_confirmed"];
|
||||
this.dxcc_worked = source["dxcc_worked"];
|
||||
this.dxcc_confirmed = source["dxcc_confirmed"];
|
||||
}
|
||||
}
|
||||
export class Stats {
|
||||
total: number;
|
||||
unique_calls: number;
|
||||
|
||||
+67
-4
@@ -1958,18 +1958,80 @@ func closestRef(refs []matchRef, when time.Time, window time.Duration) (int64, b
|
||||
// ConfirmedSets captures which DXCC / band / slot combinations are already
|
||||
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be
|
||||
// flagged as a NEW DXCC / NEW BAND / NEW SLOT.
|
||||
// ConfirmedSets captures confirmed combinations for the QSL Manager's NEW flags.
|
||||
// Modes are grouped into CLASSES (Phone/CW/Digital) — a digital confirmation is a
|
||||
// "new mode/slot" only if no digital mode was confirmed there before (RTTY and FT8
|
||||
// are the same DIGI class). Raw-mode granularity lives only in the cluster/matrix.
|
||||
type ConfirmedSets struct {
|
||||
DXCC map[int]bool // dxcc entity confirmed
|
||||
Band map[string]bool // "dxcc|band"
|
||||
Slot map[string]bool // "dxcc|band|mode"
|
||||
Mode map[string]bool // "dxcc|class"
|
||||
Slot map[string]bool // "dxcc|band|class"
|
||||
}
|
||||
|
||||
// SlotKey / BandKey build the composite keys used in ConfirmedSets.
|
||||
// Key builders for ConfirmedSets. Band is mode-agnostic; Mode/Slot use the class.
|
||||
func BandKey(dxcc int, band string) string { return fmt.Sprintf("%d|%s", dxcc, strings.ToLower(band)) }
|
||||
func ModeClassKey(dxcc int, mode string) string {
|
||||
return fmt.Sprintf("%d|%s", dxcc, modeClass(mode))
|
||||
}
|
||||
func SlotClassKey(dxcc int, band, mode string) string {
|
||||
return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), modeClass(mode))
|
||||
}
|
||||
|
||||
// SlotKey is the RAW-mode slot key, kept for the cluster/matrix new-slot flag.
|
||||
func SlotKey(dxcc int, band, mode string) string {
|
||||
return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), strings.ToUpper(mode))
|
||||
}
|
||||
|
||||
// SlotStats is the worked/confirmed tally for the QSL Manager, counting slots by
|
||||
// mode CLASS (Phone / CW / Digital) — NOT raw mode. Raw-mode granularity (RTTY ≠
|
||||
// FT8) is kept only for the cluster/matrix "new slot" flag; the totals here match
|
||||
// how slots are conventionally counted (a band in a class, not in each digital
|
||||
// sub-mode).
|
||||
type SlotStats struct {
|
||||
SlotsWorked int `json:"slots_worked"` // distinct DXCC × band × class, all QSOs
|
||||
SlotsConfirmed int `json:"slots_confirmed"` // distinct DXCC × band × class, confirmed
|
||||
DXCCWorked int `json:"dxcc_worked"` // distinct DXCC entities worked
|
||||
DXCCConfirmed int `json:"dxcc_confirmed"` // distinct DXCC entities confirmed
|
||||
}
|
||||
|
||||
// GetSlotStats computes the worked/confirmed slot and DXCC tallies in one pass.
|
||||
// "Confirmed" = LoTW or paper QSL received (the award-valid sources).
|
||||
func (r *Repo) GetSlotStats(ctx context.Context) (SlotStats, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT COALESCE(dxcc,0), LOWER(COALESCE(band,'')), UPPER(COALESCE(mode,'')),
|
||||
CASE WHEN lotw_rcvd='Y' OR qsl_rcvd='Y' THEN 1 ELSE 0 END
|
||||
FROM qso`)
|
||||
if err != nil {
|
||||
return SlotStats{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
dxccW, slotW := map[int]bool{}, map[string]bool{}
|
||||
dxccC, slotC := map[int]bool{}, map[string]bool{}
|
||||
for rows.Next() {
|
||||
var dxcc, conf int
|
||||
var band, mode string
|
||||
if err := rows.Scan(&dxcc, &band, &mode, &conf); err != nil {
|
||||
return SlotStats{}, err
|
||||
}
|
||||
if dxcc == 0 {
|
||||
continue
|
||||
}
|
||||
key := SlotClassKey(dxcc, band, mode)
|
||||
dxccW[dxcc] = true
|
||||
if band != "" {
|
||||
slotW[key] = true
|
||||
}
|
||||
if conf == 1 {
|
||||
dxccC[dxcc] = true
|
||||
if band != "" {
|
||||
slotC[key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return SlotStats{SlotsWorked: len(slotW), SlotsConfirmed: len(slotC), DXCCWorked: len(dxccW), DXCCConfirmed: len(dxccC)}, rows.Err()
|
||||
}
|
||||
|
||||
// confirmedCols whitelists the received-status columns ConfirmedSlots may
|
||||
// OR together (guards the dynamic SQL).
|
||||
var confirmedCols = map[string]bool{
|
||||
@@ -1985,7 +2047,7 @@ var confirmedCols = map[string]bool{
|
||||
// {lotw_rcvd, qsl_rcvd} (the award-valid sources), QRZ uses
|
||||
// {qrzcom_qso_download_status}.
|
||||
func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets, error) {
|
||||
sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Slot: map[string]bool{}}
|
||||
sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Mode: map[string]bool{}, Slot: map[string]bool{}}
|
||||
var conds []string
|
||||
for _, c := range cols {
|
||||
if confirmedCols[c] {
|
||||
@@ -2014,7 +2076,8 @@ func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets
|
||||
}
|
||||
sets.DXCC[dxcc] = true
|
||||
sets.Band[BandKey(dxcc, band)] = true
|
||||
sets.Slot[SlotKey(dxcc, band, mode)] = true
|
||||
sets.Mode[ModeClassKey(dxcc, mode)] = true
|
||||
sets.Slot[SlotClassKey(dxcc, band, mode)] = true
|
||||
}
|
||||
return sets, rows.Err()
|
||||
}
|
||||
|
||||
@@ -470,6 +470,27 @@ func (c *Client) queryProgress() ([]int, error) {
|
||||
return []int{total, current}, nil
|
||||
}
|
||||
|
||||
// ReadElements reads the current per-element lengths for the active band
|
||||
// (CMD_READ_BANDS). The controller is write-only for ModifyElement, so this is
|
||||
// the only way to see the current lengths — needed so the operator isn't
|
||||
// adjusting blind. The reply payload layout is not documented in the code, so we
|
||||
// LOG it verbatim (once) and parse a best guess: element lengths as 16-bit
|
||||
// little-endian values, matching how ModifyElement WRITES a length. Confirm the
|
||||
// format from the logged bytes on real hardware, then tighten the parse.
|
||||
func (c *Client) ReadElements() ([]int, error) {
|
||||
payload, err := c.sendCommand(CMD_READ_BANDS, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("Ultrabeam: READ_BANDS payload (% X) — %d bytes", payload, len(payload))
|
||||
// Best-guess parse: consecutive 16-bit LE values = element lengths in mm.
|
||||
out := make([]int, 0, len(payload)/2)
|
||||
for i := 0; i+1 < len(payload); i += 2 {
|
||||
out = append(out, int(payload[i])|int(payload[i+1])<<8)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetFrequency changes frequency and optional direction (command 3)
|
||||
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
||||
// Trace WHO asked for the change — the caller's function + line — so an
|
||||
|
||||
Reference in New Issue
Block a user