feat: added FlexRadio support (meters & basic functions)

This commit is contained in:
2026-06-17 18:29:35 +02:00
parent abdab22010
commit bde1195b34
9 changed files with 1808 additions and 13 deletions
+19 -2
View File
@@ -51,6 +51,7 @@ import { FirstRunModal } from '@/components/FirstRunModal';
import { QSOEditModal } from '@/components/QSOEditModal';
import { BandMap } from '@/components/BandMap';
import { WorldMap, LocatorMap } from '@/components/MainMap';
import { FlexPanel } from '@/components/FlexPanel';
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
import { AwardsPanel } from '@/components/AwardsPanel';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
@@ -664,11 +665,11 @@ export default function App() {
// map ("map1"), the locator street map ("map2"), the cluster grid or the
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
// so it's loaded async on mount and re-read on profile:changed below.
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked';
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex';
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
const loadMainPanes = useCallback(async () => {
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked';
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex';
const [l, r] = await Promise.all([
GetUIPref('mainPaneLeft').catch(() => ''),
GetUIPref('mainPaneRight').catch(() => ''),
@@ -2514,6 +2515,12 @@ export default function App() {
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} />
</div>
);
case 'flex':
return (
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
<FlexPanel />
</div>
);
}
};
@@ -3093,6 +3100,7 @@ export default function App() {
</TabsTrigger>
<TabsTrigger value="awards">Awards</TabsTrigger>
<TabsTrigger value="bandmap">Band Map</TabsTrigger>
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
{/* Not a tab — QRZ blocks embedding, so this opens the call's
QRZ.com page in the system browser. Styled like a trigger. */}
<button
@@ -3396,6 +3404,14 @@ export default function App() {
<AwardsPanel onEditQSO={openEdit} />
</TabsContent>
{/* FlexRadio SmartSDR-style control panel — only present when the CAT
backend is a FlexRadio. */}
{catState.backend === 'flex' && (
<TabsContent value="flex" className="flex-1 min-h-0 p-0">
<FlexPanel />
</TabsContent>
)}
{/* Band Map: several bands shown side-by-side (panadapter-style
strips). Pick bands with the chips; each strip is clickable to
tune the rig. */}
@@ -3550,6 +3566,7 @@ export default function App() {
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); }}
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
flexAvailable={catState.backend === 'flex'}
/>
)}
+433
View File
@@ -0,0 +1,433 @@
import { useEffect, useRef, useState } from 'react';
import { Radio, Zap, Mic2, Settings2, Power, AudioLines, Antenna, Flame, Gauge } from 'lucide-react';
import {
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexATUStart, FlexATUBypass, FlexSetATUMemories, FlexAmpOperate,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
} from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils';
type FlexState = {
available: boolean; model?: string;
rf_power: number; tune_power: number; tune: boolean; transmitting: boolean;
vox_enable: boolean; vox_level: number; vox_delay: number;
proc_enable: boolean; proc_level: number;
mon: boolean; mon_level: number; mic_level: number;
atu_status?: string; atu_memories: boolean;
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number;
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string;
meters?: Meter[];
};
type Meter = { id: number; src?: string; name?: string; unit?: string; value: number; lo: number; hi: number };
const ZERO: FlexState = {
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
rx_avail: false, agc_threshold: 0, audio_level: 0,
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
amp_available: false, amp_operate: false,
};
function Slider({ value, onChange, disabled, accent = '#16a34a', step = 1, max = 100 }: {
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; step?: number; max?: number;
}) {
const v = Math.max(0, Math.min(max, value));
const pct = max > 0 ? (v / max) * 100 : 0;
const ref = useRef<HTMLInputElement>(null);
// Mouse-wheel adjusts the slider. React's onWheel is passive (preventDefault
// is ignored), so attach a non-passive native listener; read live values via
// refs to avoid stale closures.
const valRef = useRef(value); valRef.current = value;
const cbRef = useRef(onChange); cbRef.current = onChange;
const disRef = useRef(disabled); disRef.current = disabled;
const stepRef = useRef(step); stepRef.current = step;
const maxRef = useRef(max); maxRef.current = max;
useEffect(() => {
const el = ref.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (disRef.current) return;
e.preventDefault();
const d = e.deltaY < 0 ? stepRef.current : -stepRef.current;
const nv = Math.max(0, Math.min(maxRef.current, valRef.current + d));
if (nv !== valRef.current) cbRef.current(nv);
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
return (
<input
ref={ref}
type="range" min={0} max={max} value={v} disabled={disabled}
onChange={(e) => onChange(parseInt(e.target.value, 10))}
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
'[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:cursor-pointer')}
style={{ background: `linear-gradient(to right, ${accent} ${pct}%, #d8cfb8 ${pct}%)`, borderColor: accent }}
/>
);
}
// Segmented — radio-style multi-choice (AGC, Processor preset).
function Segmented({ value, options, onChange, disabled }: {
value: string; options: { v: string; l: string }[]; onChange: (v: string) => void; disabled?: boolean;
}) {
return (
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
{options.map((o) => (
<button key={o.v} type="button" disabled={disabled} onClick={() => onChange(o.v)}
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{o.l}
</button>
))}
</div>
);
}
// Chip — a compact on/off pill (NB/NR/ANF/VOX/MON…).
function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber';
}) {
const onCls = {
emerald: 'bg-emerald-600 border-emerald-600 text-white',
violet: 'bg-violet-600 border-violet-600 text-white',
cyan: 'bg-cyan-600 border-cyan-600 text-white',
amber: 'bg-amber-500 border-amber-500 text-white',
}[accent];
return (
<button type="button" onClick={onClick} disabled={disabled}
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors disabled:opacity-30',
on ? onCls : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
{label}
</button>
);
}
function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, sliderAccent }: {
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; sliderAccent?: string;
}) {
return (
<div className="flex items-center gap-2">
<Chip on={on} onClick={onToggle} label={label} disabled={disabled} accent={accent} />
<Slider value={value} disabled={disabled || !on} accent={sliderAccent} onChange={onLevel} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{value}</span>
</div>
);
}
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
// `display` overrides the numeric readout; `segColor` colours segments by their
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
const METER_SEGMENTS = 26;
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor }: {
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
segColor?: (frac: number) => string;
}) {
const span = hi - lo;
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
const lit = Math.round((pct / 100) * METER_SEGMENTS);
return (
<div className="rounded-lg border border-border/70 px-2.5 py-2 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0">
<div className="flex items-baseline justify-between gap-1 mb-1.5">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
<span className="text-sm font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90">
{display !== undefined ? display : (
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
)}
</span>
</div>
<div className="flex gap-[2px] h-2.5 items-stretch">
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
const on = i < lit;
const frac = i / METER_SEGMENTS;
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
return (
<div key={i} className="flex-1 rounded-[1.5px] transition-colors duration-100"
style={on ? { background: col, boxShadow: `0 0 3px ${col}66` } : { background: '#cfc6ad', opacity: 0.45 }} />
);
})}
</div>
{extra && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
</div>
);
}
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
</div>
<div className="p-3 space-y-3">{children}</div>
</div>
);
}
export function FlexPanel() {
const [st, setSt] = useState<FlexState>(ZERO);
const hold = useRef<Record<string, number>>({});
useEffect(() => {
let alive = true;
const tick = async () => {
try {
const s = (await GetFlexState()) as any as FlexState;
if (!alive) return;
setSt((prev) => {
const now = Date.now();
const merged: any = { ...s };
for (const k in hold.current) {
if (hold.current[k] > now) merged[k] = (prev as any)[k];
}
return merged as FlexState;
});
} catch { /* not connected */ }
};
tick();
const id = window.setInterval(tick, 400);
return () => { alive = false; window.clearInterval(id); };
}, []);
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
hold.current[key] = Date.now() + 900;
setSt((p) => ({ ...p, [key]: val }));
send().catch(() => {});
};
const off = !st.available;
const rxOff = off || !st.rx_avail;
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
return (
<div className="h-full min-h-0 overflow-auto bg-background">
<div className="max-w-5xl mx-auto p-3 space-y-3">
{/* Header strip */}
<div className="flex items-center gap-3 rounded-xl px-4 py-3 text-white shadow-sm"
style={{ background: 'linear-gradient(135deg,#1e293b,#0f172a)' }}>
<Radio className="size-6 text-sky-400" />
<div className="flex flex-col leading-tight">
<span className="text-base font-extrabold tracking-tight">{st.model || 'FlexRadio'}</span>
<span className="text-[11px] text-slate-400">SmartSDR remote control</span>
</div>
<div className="flex-1" />
<span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider',
!st.available ? 'bg-slate-700 text-slate-300'
: st.transmitting ? 'bg-rose-500 text-white shadow-[0_0_16px] shadow-rose-500/60' : 'bg-emerald-500 text-white')}>
<span className="size-2 rounded-full bg-current" />
{!st.available ? 'OFFLINE' : st.transmitting ? 'TX' : 'RX'}
</span>
</div>
{off && (
<div className="text-center text-sm text-muted-foreground py-6">
Waiting for the FlexRadio (set CAT to FlexRadio and connect)
</div>
)}
{/* TX + RX columns */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* TRANSMIT */}
<Card icon={Zap} title="Transmit" accent="#dc2626">
<div className="flex items-center gap-3">
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">RF Power</span>
<Slider value={st.rf_power} disabled={off} accent="#dc2626" onChange={(v) => change('rf_power', v, () => FlexSetPower(v))} />
<span className="w-9 text-right text-sm font-mono font-bold tabular-nums">{st.rf_power}</span>
</div>
<div className="flex items-center gap-3">
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">Tune Pwr</span>
<Slider value={st.tune_power} disabled={off} accent="#d97706" onChange={(v) => change('tune_power', v, () => FlexSetTunePower(v))} />
<span className="w-9 text-right text-sm font-mono font-bold tabular-nums">{st.tune_power}</span>
</div>
<div className="flex items-center gap-2 pt-0.5">
<button type="button" disabled={off}
onClick={() => change('tune', !st.tune, () => FlexTune(!st.tune))}
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
st.tune ? 'bg-amber-500 text-white border-amber-500 shadow-[0_0_14px] shadow-amber-500/50' : 'bg-card text-amber-700 border-amber-400 hover:bg-amber-50')}>
TUNE
</button>
<button type="button" disabled={off}
onClick={() => change('transmitting', !st.transmitting, () => FlexMox(!st.transmitting))}
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
st.transmitting ? 'bg-rose-600 text-white border-rose-600 shadow-[0_0_14px] shadow-rose-600/50' : 'bg-card text-rose-700 border-rose-400 hover:bg-rose-50')}>
<Power className="size-4 inline mr-1 -mt-0.5" /> MOX
</button>
</div>
<div className="border-t border-border/60 pt-3 space-y-3">
<div className="flex items-center gap-2">
<Chip on={st.proc_enable} disabled={off} label="PROC" accent="violet"
onClick={() => change('proc_enable', !st.proc_enable, () => FlexSetProcessor(!st.proc_enable))} />
<Segmented value={String(st.proc_level)} options={PROC} disabled={off || !st.proc_enable}
onChange={(v) => change('proc_level', parseInt(v, 10), () => FlexSetProcessorLevel(parseInt(v, 10)))} />
<span className="flex-1" />
</div>
<LevelRow label="VOX" on={st.vox_enable} disabled={off} value={st.vox_level} sliderAccent="#16a34a"
onToggle={() => change('vox_enable', !st.vox_enable, () => FlexSetVox(!st.vox_enable))}
onLevel={(v) => change('vox_level', v, () => FlexSetVoxLevel(v))} />
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground pl-0.5">VOX Dly</span>
<Slider value={st.vox_delay} disabled={off || !st.vox_enable} accent="#16a34a"
onChange={(v) => change('vox_delay', v, () => FlexSetVoxDelay(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.vox_delay}</span>
</div>
<LevelRow label="MON" on={st.mon} disabled={off} value={st.mon_level} accent="cyan" sliderAccent="#0891b2"
onToggle={() => change('mon', !st.mon, () => FlexSetMon(!st.mon))}
onLevel={(v) => change('mon_level', v, () => FlexSetMonLevel(v))} />
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">MIC</span>
<Slider value={st.mic_level} disabled={off} accent="#2563eb" onChange={(v) => change('mic_level', v, () => FlexSetMic(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span>
</div>
</div>
</Card>
{/* RECEIVE */}
<Card icon={AudioLines} title="Receive (active slice)" accent="#0891b2">
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
onChange={(v) => change('agc_mode', v, () => FlexSetAGCMode(v))} />
</div>
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Thresh</span>
<Slider value={st.agc_threshold} disabled={rxOff} accent="#64748b" onChange={(v) => change('agc_threshold', v, () => FlexSetAGCThreshold(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
</div>
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span>
<Slider value={st.audio_level} disabled={rxOff} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.audio_level}</span>
</div>
<div className="border-t border-border/60 pt-3 space-y-3">
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} />
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
<LevelRow label="ANF" on={st.anf} disabled={rxOff} value={st.anf_level} accent="violet" sliderAccent="#7c3aed"
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
</div>
</Card>
</div>
{/* ATU */}
<Card icon={Settings2} title="Antenna Tuner">
<div className="flex items-center gap-2 flex-wrap">
<button type="button" disabled={off} onClick={() => FlexATUStart().catch(() => {})}
className="px-3 py-1.5 rounded-md text-xs font-bold border border-emerald-400 text-emerald-700 hover:bg-emerald-50 disabled:opacity-30">
<Antenna className="size-3.5 inline mr-1 -mt-0.5" /> Tune ATU
</button>
<button type="button" disabled={off} onClick={() => FlexATUBypass().catch(() => {})}
className="px-3 py-1.5 rounded-md text-xs font-bold border border-border text-muted-foreground hover:bg-muted disabled:opacity-30">
Bypass
</button>
<Chip on={st.atu_memories} disabled={off} label="MEM"
onClick={() => change('atu_memories', !st.atu_memories, () => FlexSetATUMemories(!st.atu_memories))} />
<div className="flex-1" />
{st.atu_status && (
<span className="text-xs font-mono text-muted-foreground">{st.atu_status.replace(/_/g, ' ')}</span>
)}
</div>
</Card>
{/* External amplifier (PowerGenius XL) — only when detected. */}
{st.amp_available && (
<Card icon={Flame} title={`Amplifier${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
<div className="flex items-center gap-3">
<button type="button" disabled={off}
onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))}
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
st.amp_operate ? 'bg-orange-600 text-white border-orange-600 shadow-[0_0_14px] shadow-orange-600/50' : 'bg-card text-orange-700 border-orange-400 hover:bg-orange-50')}>
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
</button>
<span className="text-xs text-muted-foreground">
{st.amp_operate ? 'Amplifier is in line (transmitting through PA).' : 'Amplifier bypassed (standby).'}
</span>
<div className="flex-1" />
{st.amp_fault && st.amp_fault !== 'NONE' && (
<span className="px-2 py-1 rounded bg-rose-100 text-rose-800 text-xs font-bold">FAULT: {st.amp_fault}</span>
)}
</div>
<p className="text-[11px] text-muted-foreground">Amplifier power / SWR / temperature appear in the Meters panel below (src AMP).</p>
</Card>
)}
{/* Live meters (UDP VITA-49 stream) */}
<Card icon={Gauge} title="Meters">
{(() => {
const meters = st.meters || [];
if (off || meters.length === 0) {
return <p className="text-[11px] text-muted-foreground text-center py-2">No meters yet waiting for the radio's UDP stream…</p>;
}
const isDbm = (m?: Meter) => !!m && /dbm/i.test(m.unit || '');
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
// Radio meters (exclude the amplifier's, which we show separately).
const radio = (name: string) => meters.find((m) =>
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
const sig = radio('LEVEL') || radio('SIGNAL');
const fwd = radio('FWDPWR');
const swr = radio('SWR');
const alc = radio('ALC');
const temp = radio('PATEMP');
const volts = radio('13.8B') || meters.find((m) => /volts/i.test(m.unit || '') && !(m.src || '').toUpperCase().includes('AMP'));
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
const accentFor = (m: Meter) => /swr/i.test(`${m.unit}${m.name}`) ? '#dc2626'
: /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c'
: /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
// S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit).
const sUnit = (dbm: number) => {
const s = (dbm + 127) / 6; // S0 = -127 dBm
if (s >= 9) {
const over = Math.round(dbm + 73); // dB over S9
return { display: over > 0 ? `S9+${over}` : 'S9', bar: s };
}
return { display: `S${Math.max(0, Math.round(s))}`, bar: Math.max(0, s) };
};
const cur = [
sig && (() => { const s = sUnit(sig.value); return (
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${sig.value.toFixed(1)} dBm`}
segColor={(fr) => { const sv = fr * 19; return sv < 9 ? '#16a34a' : sv < 14 ? '#f59e0b' : '#dc2626'; }} />
); })(),
fwd && <MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
value={isDbm(fwd) ? dbmToW(fwd.value) : fwd.value} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />,
swr && <MeterBar key="w" label="SWR" value={swr.value} unit="" lo={1} hi={3} accent="#d97706" />,
alc && <MeterBar key="a" label="ALC" value={alc.value} unit={alc.unit} lo={alc.lo} hi={alc.hi || 100} accent="#7c3aed" />,
temp && <MeterBar key="t" label="PA TEMP" value={temp.value} unit={temp.unit} lo={temp.lo || 0} hi={temp.hi || 80} accent="#ea580c" />,
volts && <MeterBar key="v" label="VOLTS" value={volts.value} unit={volts.unit} lo={volts.lo || 0} hi={volts.hi || 15} accent="#2563eb" />,
].filter(Boolean);
return (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
{amp.length > 0 && (
<div className="border-t border-border/60 pt-2 mt-1">
<div className="text-[10px] font-bold tracking-wider text-muted-foreground mb-1.5">AMPLIFIER</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{amp.map((m) => {
if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) {
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={dbmToW(m.value)} unit="W" lo={0} hi={1500} accent="#dc2626" extra={`${m.value.toFixed(1)} dBm`} />;
}
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={m.value} unit={m.unit} lo={m.lo} hi={m.hi} accent={accentFor(m)} />;
})}
</div>
</div>
)}
</>
);
})()}
</Card>
</div>
</div>
);
}
+11 -6
View File
@@ -138,6 +138,7 @@ interface Props {
onClose: () => void;
onSaved: () => void;
onMainPaneChanged?: (side: 'left' | 'right', value: string) => void; // live Main-view layout update
flexAvailable?: boolean; // CAT backend is FlexRadio → offer it as a Main pane
}
// Pretty little card showing what OpsLog will stamp on each QSO based on
@@ -457,11 +458,15 @@ const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
{ value: 'cluster', label: 'Cluster spots' },
{ value: 'worked', label: 'Worked before' },
];
function MainViewPanes({ onChanged }: { onChanged?: (side: 'left' | 'right', value: string) => void }) {
function MainViewPanes({ onChanged, flexAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean }) {
const [left, setLeft] = useState('map1');
const [right, setRight] = useState('map2');
// FlexRadio is only offered when the CAT backend is a Flex.
const options = flexAvailable
? [...MAIN_PANE_OPTIONS, { value: 'flex', label: 'FlexRadio controls' }]
: MAIN_PANE_OPTIONS;
useEffect(() => {
const valid = (v: string) => MAIN_PANE_OPTIONS.some((o) => o.value === v);
const valid = (v: string) => v === 'flex' || MAIN_PANE_OPTIONS.some((o) => o.value === v);
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
}, []);
@@ -482,7 +487,7 @@ function MainViewPanes({ onChanged }: { onChanged?: (side: 'left' | 'right', val
<Select value={left} onValueChange={(v) => pick('left', v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{MAIN_PANE_OPTIONS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</label>
@@ -491,7 +496,7 @@ function MainViewPanes({ onChanged }: { onChanged?: (side: 'left' | 'right', val
<Select value={right} onValueChange={(v) => pick('right', v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{MAIN_PANE_OPTIONS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</label>
@@ -550,7 +555,7 @@ function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
);
}
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged }: Props) {
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable }: Props) {
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -3347,7 +3352,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</label>
<TelemetryToggle />
<MainViewPanes onChanged={onMainPaneChanged} />
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} />
<div className="border-t border-border/60 pt-4 space-y-2">
<h4 className="text-sm font-semibold text-foreground">Password encryption</h4>
+52
View File
@@ -115,6 +115,56 @@ export function FilterFields():Promise<Array<string>>;
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>;
export function FlexATUBypass():Promise<void>;
export function FlexATUStart():Promise<void>;
export function FlexAmpOperate(arg1:boolean):Promise<void>;
export function FlexMox(arg1:boolean):Promise<void>;
export function FlexSetAGCMode(arg1:string):Promise<void>;
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
export function FlexSetANF(arg1:boolean):Promise<void>;
export function FlexSetANFLevel(arg1:number):Promise<void>;
export function FlexSetATUMemories(arg1:boolean):Promise<void>;
export function FlexSetAudioLevel(arg1:number):Promise<void>;
export function FlexSetMic(arg1:number):Promise<void>;
export function FlexSetMon(arg1:boolean):Promise<void>;
export function FlexSetMonLevel(arg1:number):Promise<void>;
export function FlexSetNB(arg1:boolean):Promise<void>;
export function FlexSetNBLevel(arg1:number):Promise<void>;
export function FlexSetNR(arg1:boolean):Promise<void>;
export function FlexSetNRLevel(arg1:number):Promise<void>;
export function FlexSetPower(arg1:number):Promise<void>;
export function FlexSetProcessor(arg1:boolean):Promise<void>;
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
export function FlexSetTunePower(arg1:number):Promise<void>;
export function FlexSetVox(arg1:boolean):Promise<void>;
export function FlexSetVoxDelay(arg1:number):Promise<void>;
export function FlexSetVoxLevel(arg1:number):Promise<void>;
export function FlexTune(arg1:boolean):Promise<void>;
export function GetActiveProfile():Promise<profile.Profile>;
export function GetAudioSettings():Promise<main.AudioSettings>;
@@ -163,6 +213,8 @@ export function GetEmailSettings():Promise<main.EmailSettings>;
export function GetExternalServices():Promise<extsvc.ExternalServices>;
export function GetFlexState():Promise<cat.FlexTXState>;
export function GetListsSettings():Promise<main.ListsSettings>;
export function GetLogFilePath():Promise<string>;
+104
View File
@@ -202,6 +202,106 @@ export function FindQSOsForUpload(arg1, arg2) {
return window['go']['main']['App']['FindQSOsForUpload'](arg1, arg2);
}
export function FlexATUBypass() {
return window['go']['main']['App']['FlexATUBypass']();
}
export function FlexATUStart() {
return window['go']['main']['App']['FlexATUStart']();
}
export function FlexAmpOperate(arg1) {
return window['go']['main']['App']['FlexAmpOperate'](arg1);
}
export function FlexMox(arg1) {
return window['go']['main']['App']['FlexMox'](arg1);
}
export function FlexSetAGCMode(arg1) {
return window['go']['main']['App']['FlexSetAGCMode'](arg1);
}
export function FlexSetAGCThreshold(arg1) {
return window['go']['main']['App']['FlexSetAGCThreshold'](arg1);
}
export function FlexSetANF(arg1) {
return window['go']['main']['App']['FlexSetANF'](arg1);
}
export function FlexSetANFLevel(arg1) {
return window['go']['main']['App']['FlexSetANFLevel'](arg1);
}
export function FlexSetATUMemories(arg1) {
return window['go']['main']['App']['FlexSetATUMemories'](arg1);
}
export function FlexSetAudioLevel(arg1) {
return window['go']['main']['App']['FlexSetAudioLevel'](arg1);
}
export function FlexSetMic(arg1) {
return window['go']['main']['App']['FlexSetMic'](arg1);
}
export function FlexSetMon(arg1) {
return window['go']['main']['App']['FlexSetMon'](arg1);
}
export function FlexSetMonLevel(arg1) {
return window['go']['main']['App']['FlexSetMonLevel'](arg1);
}
export function FlexSetNB(arg1) {
return window['go']['main']['App']['FlexSetNB'](arg1);
}
export function FlexSetNBLevel(arg1) {
return window['go']['main']['App']['FlexSetNBLevel'](arg1);
}
export function FlexSetNR(arg1) {
return window['go']['main']['App']['FlexSetNR'](arg1);
}
export function FlexSetNRLevel(arg1) {
return window['go']['main']['App']['FlexSetNRLevel'](arg1);
}
export function FlexSetPower(arg1) {
return window['go']['main']['App']['FlexSetPower'](arg1);
}
export function FlexSetProcessor(arg1) {
return window['go']['main']['App']['FlexSetProcessor'](arg1);
}
export function FlexSetProcessorLevel(arg1) {
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
}
export function FlexSetTunePower(arg1) {
return window['go']['main']['App']['FlexSetTunePower'](arg1);
}
export function FlexSetVox(arg1) {
return window['go']['main']['App']['FlexSetVox'](arg1);
}
export function FlexSetVoxDelay(arg1) {
return window['go']['main']['App']['FlexSetVoxDelay'](arg1);
}
export function FlexSetVoxLevel(arg1) {
return window['go']['main']['App']['FlexSetVoxLevel'](arg1);
}
export function FlexTune(arg1) {
return window['go']['main']['App']['FlexTune'](arg1);
}
export function GetActiveProfile() {
return window['go']['main']['App']['GetActiveProfile']();
}
@@ -298,6 +398,10 @@ export function GetExternalServices() {
return window['go']['main']['App']['GetExternalServices']();
}
export function GetFlexState() {
return window['go']['main']['App']['GetFlexState']();
}
export function GetListsSettings() {
return window['go']['main']['App']['GetListsSettings']();
}
+114
View File
@@ -385,6 +385,30 @@ export namespace awardref {
export namespace cat {
export class FlexMeter {
id: number;
src?: string;
name?: string;
unit?: string;
value: number;
lo: number;
hi: number;
static createFrom(source: any = {}) {
return new FlexMeter(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.src = source["src"];
this.name = source["name"];
this.unit = source["unit"];
this.value = source["value"];
this.lo = source["lo"];
this.hi = source["hi"];
}
}
export class FlexRadio {
ip: string;
port: number;
@@ -407,6 +431,96 @@ export namespace cat {
this.callsign = source["callsign"];
}
}
export class FlexTXState {
available: boolean;
model?: string;
rf_power: number;
tune_power: number;
tune: boolean;
transmitting: boolean;
vox_enable: boolean;
vox_level: number;
vox_delay: number;
proc_enable: boolean;
proc_level: number;
mon: boolean;
mon_level: number;
mic_level: number;
atu_status?: string;
atu_memories: boolean;
rx_avail: boolean;
agc_mode?: string;
agc_threshold: number;
audio_level: number;
nb: boolean;
nb_level: number;
nr: boolean;
nr_level: number;
anf: boolean;
anf_level: number;
amp_available: boolean;
amp_model?: string;
amp_operate: boolean;
amp_fault?: string;
meters?: FlexMeter[];
static createFrom(source: any = {}) {
return new FlexTXState(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.available = source["available"];
this.model = source["model"];
this.rf_power = source["rf_power"];
this.tune_power = source["tune_power"];
this.tune = source["tune"];
this.transmitting = source["transmitting"];
this.vox_enable = source["vox_enable"];
this.vox_level = source["vox_level"];
this.vox_delay = source["vox_delay"];
this.proc_enable = source["proc_enable"];
this.proc_level = source["proc_level"];
this.mon = source["mon"];
this.mon_level = source["mon_level"];
this.mic_level = source["mic_level"];
this.atu_status = source["atu_status"];
this.atu_memories = source["atu_memories"];
this.rx_avail = source["rx_avail"];
this.agc_mode = source["agc_mode"];
this.agc_threshold = source["agc_threshold"];
this.audio_level = source["audio_level"];
this.nb = source["nb"];
this.nb_level = source["nb_level"];
this.nr = source["nr"];
this.nr_level = source["nr_level"];
this.anf = source["anf"];
this.anf_level = source["anf_level"];
this.amp_available = source["amp_available"];
this.amp_model = source["amp_model"];
this.amp_operate = source["amp_operate"];
this.amp_fault = source["amp_fault"];
this.meters = this.convertValues(source["meters"], FlexMeter);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class RigState {
enabled: boolean;
connected: boolean;