6 Commits
10 changed files with 432 additions and 50 deletions
+85 -7
View File
@@ -7760,9 +7760,24 @@ type ConfirmationItem struct {
Country string `json:"country"` Country string `json:"country"`
NewDXCC bool `json:"new_dxcc"` NewDXCC bool `json:"new_dxcc"`
NewBand bool `json:"new_band"` NewBand bool `json:"new_band"`
NewMode bool `json:"new_mode"` // new mode CLASS (Phone/CW/Digital) for the entity
NewSlot bool `json:"new_slot"` 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 // DownloadConfirmations pulls confirmed QSOs from a service and updates the
// matching local QSOs' received status. LoTW only for now (the canonical // matching local QSOs' received status. LoTW only for now (the canonical
// confirmation system); runs in the background emitting the same // 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, Country: q.Country,
} }
if dxccNum != 0 { 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.NewDXCC = !sets.DXCC[dxccNum]
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)] 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.DXCC[dxccNum] = true
sets.Band[qso.BandKey(dxccNum, q.Band)] = 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) items = append(items, it)
return nil 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, Band: q.Band, Mode: q.Mode, Country: q.Country,
} }
if dxccNum != 0 { 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.NewDXCC = !sets.DXCC[dxccNum]
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)] 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.DXCC[dxccNum] = true
sets.Band[qso.BandKey(dxccNum, q.Band)] = 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) items = append(items, it)
return nil 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, Band: q.Band, Mode: q.Mode, Country: q.Country,
} }
if dxccNum != 0 { 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.NewDXCC = !sets.DXCC[dxccNum]
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)] 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.DXCC[dxccNum] = true
sets.Band[qso.BandKey(dxccNum, q.Band)] = 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) items = append(items, it)
return nil return nil
@@ -10747,6 +10777,14 @@ type motorAntenna interface {
Retract() error Retract() error
LastSetKHz() int LastSetKHz() int
Status() motorStatus 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, // 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) SetDirection(d int) error { return a.c.SetDirection(d) }
func (a ubAdapter) Retract() error { return a.c.Retract() } func (a ubAdapter) Retract() error { return a.c.Retract() }
func (a ubAdapter) LastSetKHz() int { return a.c.LastSetKHz() } 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 { func (a ubAdapter) Status() motorStatus {
st, err := a.c.GetStatus() st, err := a.c.GetStatus()
if err != nil || st == nil { 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) SetDirection(d int) error { return a.c.SetDirection(d) }
func (a steppirAdapter) Retract() error { return a.c.Retract() } func (a steppirAdapter) Retract() error { return a.c.Retract() }
func (a steppirAdapter) LastSetKHz() int { return a.c.LastSetKHz() } 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 { func (a steppirAdapter) Status() motorStatus {
st, err := a.c.GetStatus() st, err := a.c.GetStatus()
if err != nil || st == nil { if err != nil || st == nil {
@@ -11063,18 +11116,21 @@ func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, stop <-chan struc
// device's most recent status poll. // device's most recent status poll.
type UltrabeamStatusInfo struct { type UltrabeamStatusInfo struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Type string `json:"type"` // "ultrabeam" | "steppir"
Connected bool `json:"connected"` Connected bool `json:"connected"`
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bidirectional Direction int `json:"direction"` // 0=normal, 1=180°, 2=bidirectional
Frequency int `json:"frequency"` // KHz Frequency int `json:"frequency"` // KHz
Band int `json:"band"` Band int `json:"band"`
Moving bool `json:"moving"` 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. // GetUltrabeamStatus returns the antenna's current state for the UI poll.
func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo { func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo {
out := UltrabeamStatusInfo{} out := UltrabeamStatusInfo{Elements: []int{}}
s, _ := a.GetUltrabeamSettings() s, _ := a.GetUltrabeamSettings()
out.Enabled = s.Enabled out.Enabled = s.Enabled
out.Type = s.Type
if a.motorAnt == nil { if a.motorAnt == nil {
return out return out
} }
@@ -11084,9 +11140,31 @@ func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo {
out.Frequency = st.Frequency out.Frequency = st.Frequency
out.Band = st.Band out.Band = st.Band
out.Moving = st.Moving out.Moving = st.Moving
if el := a.motorAnt.Elements(); el != nil {
out.Elements = el
}
return out 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°, // SetUltrabeamDirection switches the antenna pattern: 0=normal, 1=180°,
// 2=bidirectional (re-issues the current frequency with the new direction). // 2=bidirectional (re-issues the current frequency with the new direction).
func (a *App) SetUltrabeamDirection(direction int) error { func (a *App) SetUltrabeamDirection(direction int) error {
+19 -14
View File
@@ -2866,12 +2866,12 @@ export default function App() {
</div> </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'); }} /> <Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
</div> </div>
); );
const gridBlock = ( 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'); }} /> <Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
</div> </div>
); );
@@ -2885,8 +2885,8 @@ export default function App() {
: d < 30 ? 'border-warning text-warning bg-warning/15' : d < 30 ? 'border-warning text-warning bg-warning/15'
: 'border-danger text-danger bg-danger/15'; : 'border-danger text-danger bg-danger/15';
return ( return (
<div className="self-end shrink-0" title={t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })}> <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.5 text-[10px] font-extrabold tracking-wide leading-none', cls)}> <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 LoTW
</span> </span>
</div> </div>
@@ -2901,7 +2901,7 @@ export default function App() {
// worked-before check doesn't include these, and see what's waiting. // worked-before check doesn't include these, and see what's waiting.
const offlinePendingCount = offlineStatus.pending ?? 0; const offlinePendingCount = offlineStatus.pending ?? 0;
const offlineBlock = offlinePendingCount === 0 ? null : ( 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(() => {}); }} <button type="button" onClick={() => { setPendingOpen((o) => !o); GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {}); }}
title={t('offline.tip', { n: offlinePendingCount })} 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"> 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; const hasAlerts = recentAlerts.length > 0;
// Only show the bell when there are pending alerts — hidden otherwise. // Only show the bell when there are pending alerts — hidden otherwise.
const alertLedBlock = !hasAlerts ? null : ( 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)} <button type="button" onClick={() => setAlertsPanelOpen((o) => !o)}
title={hasAlerts ? t('alert.pending', { n: recentAlerts.length }) : t('alert.noneShort')} 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', 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 = ( const logButtons = (
<div className="flex flex-col"> <div className="flex flex-col">
<Label className="mb-1 h-3.5">&nbsp;</Label> <Label className="mb-1 h-3.5">&nbsp;</Label>
<div className="flex gap-2"> <div className="flex gap-1.5">
{clusterServerStatuses.some((s) => s.state === 'connected') && ( {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" /> <Satellite className="size-3.5" />
{t('btn.spot')} {t('btn.spot')}
</Button> </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)"> title="Clear the QSO entry (always — unlike Esc which may be reserved for the CW keyer)">
<Eraser className="size-3.5" /> <Eraser className="size-3.5" />
{t('btn.clear')} {t('btn.clear')}
</Button> </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" /> <Send className="size-3.5" />
{saving ? '…' : t('btn.logQso')} {saving ? '…' : t('btn.logQso')}
</Button> </Button>
@@ -3910,9 +3910,6 @@ export default function App() {
</div> </div>
{qthBlock} {qthBlock}
{gridBlock} {gridBlock}
{lotwBlock}
{offlineBlock}
{alertLedBlock}
</div> </div>
{/* Row 3: tight left detail column (Band/Mode/Country) and {/* Row 3: tight left detail column (Band/Mode/Country) and
@@ -3929,11 +3926,19 @@ export default function App() {
</div> </div>
</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"> <div className="flex gap-2 items-end">
{freqBlock} {freqBlock}
{rxFreqBlock} {rxFreqBlock}
{bandRxBlock} {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 className="ml-auto">{logButtons}</div>
</div> </div>
</> </>
+31 -4
View File
@@ -8,7 +8,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App'; import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime'; import { EventsOn } from '../../wailsjs/runtime/runtime';
@@ -32,7 +32,7 @@ const UPLOAD_COLS: ColDef<UploadRow>[] = [
type Confirmation = { type Confirmation = {
callsign: string; qso_date: string; band: string; mode: string; country: string; 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 = [ const SERVICES = [
@@ -208,18 +208,27 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload'); 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(() => { useEffect(() => {
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line])); const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
const offDone = EventsOn('qslmgr:done', (d: any) => { const offDone = EventsOn('qslmgr:done', (d: any) => {
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0}`]); setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0}`]);
setBusy(false); setBusy(false);
refreshCounts(); // a download may have added confirmations
}); });
const offConf = EventsOn('qslmgr:confirmations', (list: any) => { const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
setConfirmations((list ?? []) as Confirmation[]); setConfirmations((list ?? []) as Confirmation[]);
setViewMode('confirmations'); setViewMode('confirmations');
refreshCounts();
}); });
return () => { offLog(); offDone(); offConf(); }; return () => { offLog(); offDone(); offConf(); };
}, []); }, [refreshCounts]);
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]); 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) => { const shownConfs = useMemo(() => confirmations.filter((c) => {
switch (confFilter) { 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 'dxcc': return c.new_dxcc;
case 'band': return c.new_band; case 'band': return c.new_band;
case 'mode': return c.new_mode;
case 'slot': return c.new_slot; case 'slot': return c.new_slot;
default: return true; default: return true;
} }
@@ -353,6 +363,21 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
</> </>
)} )}
<div className="flex-1" /> <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 && ( {service === 'pota' && potaRes && (
<span className="text-xs text-muted-foreground"> <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} {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="new">{t('qslm.filterNew')}</SelectItem>
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem> <SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem> <SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
<SelectItem value="mode">{t('qslm.filterNewMode')}</SelectItem>
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem> <SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -491,6 +517,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
<td className="py-1 px-2"> <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_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_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> : 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>} : <span className="text-muted-foreground/50"></span>}
</td> </td>
+156 -8
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; 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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -11,6 +11,7 @@ import { RotorCompass } from '@/components/RotorCompass';
import { import {
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay, GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
GetRotatorHeading, RotatorGoTo, RotatorStop, GetRotatorHeading, RotatorGoTo, RotatorStop,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null }; 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) { export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
const { t } = useI18n(); const { t } = useI18n();
const [devices, setDevices] = useState<Device[]>([]); 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 [editing, setEditing] = useState<Device | null>(null); // device being added/edited
const [busy, setBusy] = useState<Record<string, boolean>>({}); // per-relay in-flight const [busy, setBusy] = useState<Record<string, boolean>>({}); // per-relay in-flight
const [rot, setRot] = useState<Heading>({ enabled: false, ok: false, azimuth: 0 }); 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. // Widget order (rotator + device ids), drag-and-drop reorderable, persisted.
const [order, setOrder] = useState<string[]>(() => { const [order, setOrder] = useState<string[]>(() => {
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
}); });
const dragId = useRef<string | null>(null); const dragId = useRef<string | null>(null);
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
const loadDevices = useCallback(async () => { const loadDevices = useCallback(async () => {
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ } 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 () => { const pollRot = useCallback(async () => {
try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ } try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ }
}, []); }, []);
const pollAnt = useCallback(async () => {
try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ }
}, []);
useEffect(() => { loadDevices(); }, [loadDevices]); useEffect(() => { loadDevices(); }, [loadDevices]);
useEffect(() => { useEffect(() => {
poll(); pollRot(); poll(); pollRot(); pollAnt();
const id = window.setInterval(() => { poll(); pollRot(); }, 3000); const id = window.setInterval(() => { poll(); pollRot(); pollAnt(); }, 3000);
return () => window.clearInterval(id); 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)); }; const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
// Reorder so `dragged` lands just before `target`. // Reorder so `dragged` lands just before `target`.
@@ -217,22 +343,44 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
if (rot.enabled) { if (rot.enabled) {
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> }); 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) }); 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 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 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 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 ( return (
<div className="flex-1 min-h-0 overflow-auto p-4"> <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> <h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
<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())}> <Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')} <Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
</Button> </Button>
</div> </div>
</div>
{noDevices && !editing && ( {noDevices && !editing && (
<div className="max-w-4xl rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground"> <div className="max-w-4xl rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
@@ -240,7 +388,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
</div> </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) => ( {ordered.map((w) => (
<div key={w.id} draggable <div key={w.id} draggable
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }} onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
@@ -253,7 +401,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
</div> </div>
{!noDevices && ( {!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 && ( {editing && (
File diff suppressed because one or more lines are too long
+6
View File
@@ -382,6 +382,8 @@ export function GetRotatorSettings():Promise<main.RotatorSettings>;
export function GetSecretStatus():Promise<main.SecretStatus>; export function GetSecretStatus():Promise<main.SecretStatus>;
export function GetSlotStats():Promise<qso.SlotStats>;
export function GetSolarData():Promise<solar.Data>; export function GetSolarData():Promise<solar.Data>;
export function GetStartupStatus():Promise<main.StartupStatus>; 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 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 MoveDatabase(arg1:string):Promise<void>;
export function NetActivate(arg1:string):Promise<qso.QSO>; export function NetActivate(arg1:string):Promise<qso.QSO>;
+12
View File
@@ -722,6 +722,10 @@ export function GetSecretStatus() {
return window['go']['main']['App']['GetSecretStatus'](); return window['go']['main']['App']['GetSecretStatus']();
} }
export function GetSlotStats() {
return window['go']['main']['App']['GetSlotStats']();
}
export function GetSolarData() { export function GetSolarData() {
return window['go']['main']['App']['GetSolarData'](); return window['go']['main']['App']['GetSolarData']();
} }
@@ -1034,6 +1038,14 @@ export function LookupCallsign(arg1) {
return window['go']['main']['App']['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) { export function MoveDatabase(arg1) {
return window['go']['main']['App']['MoveDatabase'](arg1); return window['go']['main']['App']['MoveDatabase'](arg1);
} }
+22
View File
@@ -2580,11 +2580,13 @@ export namespace main {
} }
export class UltrabeamStatusInfo { export class UltrabeamStatusInfo {
enabled: boolean; enabled: boolean;
type: string;
connected: boolean; connected: boolean;
direction: number; direction: number;
frequency: number; frequency: number;
band: number; band: number;
moving: boolean; moving: boolean;
elements: number[];
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new UltrabeamStatusInfo(source); return new UltrabeamStatusInfo(source);
@@ -2593,11 +2595,13 @@ export namespace main {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"]; this.enabled = source["enabled"];
this.type = source["type"];
this.connected = source["connected"]; this.connected = source["connected"];
this.direction = source["direction"]; this.direction = source["direction"];
this.frequency = source["frequency"]; this.frequency = source["frequency"];
this.band = source["band"]; this.band = source["band"];
this.moving = source["moving"]; this.moving = source["moving"];
this.elements = source["elements"];
} }
} }
export class UpdateInfo { export class UpdateInfo {
@@ -3655,6 +3659,24 @@ export namespace qso {
return a; 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 { export class Stats {
total: number; total: number;
unique_calls: number; unique_calls: number;
+67 -4
View File
@@ -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 // ConfirmedSets captures which DXCC / band / slot combinations are already
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be // confirmed (by any QSL system), so a freshly-downloaded confirmation can be
// flagged as a NEW DXCC / NEW BAND / NEW SLOT. // 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 { type ConfirmedSets struct {
DXCC map[int]bool // dxcc entity confirmed DXCC map[int]bool // dxcc entity confirmed
Band map[string]bool // "dxcc|band" 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 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 { func SlotKey(dxcc int, band, mode string) string {
return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), strings.ToUpper(mode)) 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 // confirmedCols whitelists the received-status columns ConfirmedSlots may
// OR together (guards the dynamic SQL). // OR together (guards the dynamic SQL).
var confirmedCols = map[string]bool{ var confirmedCols = map[string]bool{
@@ -1985,7 +2047,7 @@ var confirmedCols = map[string]bool{
// {lotw_rcvd, qsl_rcvd} (the award-valid sources), QRZ uses // {lotw_rcvd, qsl_rcvd} (the award-valid sources), QRZ uses
// {qrzcom_qso_download_status}. // {qrzcom_qso_download_status}.
func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets, error) { 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 var conds []string
for _, c := range cols { for _, c := range cols {
if confirmedCols[c] { if confirmedCols[c] {
@@ -2014,7 +2076,8 @@ func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets
} }
sets.DXCC[dxcc] = true sets.DXCC[dxcc] = true
sets.Band[BandKey(dxcc, band)] = 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() return sets, rows.Err()
} }
+21
View File
@@ -470,6 +470,27 @@ func (c *Client) queryProgress() ([]int, error) {
return []int{total, current}, nil 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) // SetFrequency changes frequency and optional direction (command 3)
func (c *Client) SetFrequency(freqKhz int, direction int) error { func (c *Client) SetFrequency(freqKhz int, direction int) error {
// Trace WHO asked for the change — the caller's function + line — so an // Trace WHO asked for the change — the caller's function + line — so an