feat: added FlexRadio support (meters & basic functions)
This commit is contained in:
@@ -6315,6 +6315,196 @@ func (a *App) SetCATMode(mode string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── FlexRadio control tab (Phase 1: SmartSDR-style transmit controls) ──
|
||||||
|
// These are no-ops / errors unless the active CAT backend is a FlexRadio.
|
||||||
|
|
||||||
|
// GetFlexState returns the radio's transmit/ATU state for the FlexRadio tab.
|
||||||
|
// Available=false when the active backend isn't a connected Flex.
|
||||||
|
func (a *App) GetFlexState() cat.FlexTXState {
|
||||||
|
if a.cat == nil {
|
||||||
|
return cat.FlexTXState{}
|
||||||
|
}
|
||||||
|
st, _ := a.cat.FlexState()
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexSetPower(p int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRFPower(p) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexSetTunePower(p int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTunePower(p) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexTune(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTune(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexSetVox(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetVOX(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexSetVoxLevel(l int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetVOXLevel(l) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexSetVoxDelay(ms int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetVOXDelay(ms) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexAmpOperate(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAmpOperate(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexSetProcessor(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetProcessor(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexSetProcessorLevel(l int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetProcessorLevel(l) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexSetMon(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMon(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexSetMonLevel(l int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMonLevel(l) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexSetMic(l int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMic(l) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexMox(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.SetPTT(on) // MOX = manual transmit (xmit 1/0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexATUStart() error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.ATUStart() })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexATUBypass() error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.ATUBypass() })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexSetATUMemories(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetATUMemories(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// RX slice DSP controls (target the active receive slice).
|
||||||
|
|
||||||
|
func (a *App) FlexSetAGCMode(m string) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAGCMode(m) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexSetAGCThreshold(l int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAGCThreshold(l) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexSetAudioLevel(l int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAudioLevel(l) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexSetNB(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNB(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexSetNBLevel(l int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNBLevel(l) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexSetNR(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNR(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexSetNRLevel(l int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNRLevel(l) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexSetANF(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetANF(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexSetANFLevel(l int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetANFLevel(l) })
|
||||||
|
}
|
||||||
|
|
||||||
// SwitchCATRig hot-swaps the active OmniRig slot (Rig1 ↔ Rig2) without
|
// SwitchCATRig hot-swaps the active OmniRig slot (Rig1 ↔ Rig2) without
|
||||||
// requiring a trip through the full Settings panel. Persists the choice
|
// requiring a trip through the full Settings panel. Persists the choice
|
||||||
// so it survives restart.
|
// so it survives restart.
|
||||||
|
|||||||
+19
-2
@@ -51,6 +51,7 @@ import { FirstRunModal } from '@/components/FirstRunModal';
|
|||||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||||
import { BandMap } from '@/components/BandMap';
|
import { BandMap } from '@/components/BandMap';
|
||||||
import { WorldMap, LocatorMap } from '@/components/MainMap';
|
import { WorldMap, LocatorMap } from '@/components/MainMap';
|
||||||
|
import { FlexPanel } from '@/components/FlexPanel';
|
||||||
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
||||||
import { AwardsPanel } from '@/components/AwardsPanel';
|
import { AwardsPanel } from '@/components/AwardsPanel';
|
||||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
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
|
// map ("map1"), the locator street map ("map2"), the cluster grid or the
|
||||||
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
|
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
|
||||||
// so it's loaded async on mount and re-read on profile:changed below.
|
// 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 [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
|
||||||
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
|
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
|
||||||
const loadMainPanes = useCallback(async () => {
|
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([
|
const [l, r] = await Promise.all([
|
||||||
GetUIPref('mainPaneLeft').catch(() => ''),
|
GetUIPref('mainPaneLeft').catch(() => ''),
|
||||||
GetUIPref('mainPaneRight').catch(() => ''),
|
GetUIPref('mainPaneRight').catch(() => ''),
|
||||||
@@ -2514,6 +2515,12 @@ export default function App() {
|
|||||||
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} />
|
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} />
|
||||||
</div>
|
</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>
|
||||||
<TabsTrigger value="awards">Awards</TabsTrigger>
|
<TabsTrigger value="awards">Awards</TabsTrigger>
|
||||||
<TabsTrigger value="bandmap">Band Map</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
|
{/* Not a tab — QRZ blocks embedding, so this opens the call's
|
||||||
QRZ.com page in the system browser. Styled like a trigger. */}
|
QRZ.com page in the system browser. Styled like a trigger. */}
|
||||||
<button
|
<button
|
||||||
@@ -3396,6 +3404,14 @@ export default function App() {
|
|||||||
<AwardsPanel onEditQSO={openEdit} />
|
<AwardsPanel onEditQSO={openEdit} />
|
||||||
</TabsContent>
|
</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
|
{/* Band Map: several bands shown side-by-side (panadapter-style
|
||||||
strips). Pick bands with the chips; each strip is clickable to
|
strips). Pick bands with the chips; each strip is clickable to
|
||||||
tune the rig. */}
|
tune the rig. */}
|
||||||
@@ -3550,6 +3566,7 @@ export default function App() {
|
|||||||
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
|
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
|
||||||
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); }}
|
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); }}
|
||||||
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
|
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
|
||||||
|
flexAvailable={catState.backend === 'flex'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -138,6 +138,7 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
onMainPaneChanged?: (side: 'left' | 'right', value: string) => void; // live Main-view layout update
|
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
|
// 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: 'cluster', label: 'Cluster spots' },
|
||||||
{ value: 'worked', label: 'Worked before' },
|
{ 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 [left, setLeft] = useState('map1');
|
||||||
const [right, setRight] = useState('map2');
|
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(() => {
|
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(() => '')])
|
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
|
||||||
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
|
.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)}>
|
<Select value={left} onValueChange={(v) => pick('left', v)}>
|
||||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</label>
|
</label>
|
||||||
@@ -491,7 +496,7 @@ function MainViewPanes({ onChanged }: { onChanged?: (side: 'left' | 'right', val
|
|||||||
<Select value={right} onValueChange={(v) => pick('right', v)}>
|
<Select value={right} onValueChange={(v) => pick('right', v)}>
|
||||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</label>
|
</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 [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -3347,7 +3352,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</label>
|
</label>
|
||||||
<TelemetryToggle />
|
<TelemetryToggle />
|
||||||
|
|
||||||
<MainViewPanes onChanged={onMainPaneChanged} />
|
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} />
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||||
<h4 className="text-sm font-semibold text-foreground">Password encryption</h4>
|
<h4 className="text-sm font-semibold text-foreground">Password encryption</h4>
|
||||||
|
|||||||
Vendored
+52
@@ -115,6 +115,56 @@ export function FilterFields():Promise<Array<string>>;
|
|||||||
|
|
||||||
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>;
|
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 GetActiveProfile():Promise<profile.Profile>;
|
||||||
|
|
||||||
export function GetAudioSettings():Promise<main.AudioSettings>;
|
export function GetAudioSettings():Promise<main.AudioSettings>;
|
||||||
@@ -163,6 +213,8 @@ export function GetEmailSettings():Promise<main.EmailSettings>;
|
|||||||
|
|
||||||
export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
||||||
|
|
||||||
|
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||||
|
|
||||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||||
|
|
||||||
export function GetLogFilePath():Promise<string>;
|
export function GetLogFilePath():Promise<string>;
|
||||||
|
|||||||
@@ -202,6 +202,106 @@ export function FindQSOsForUpload(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['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() {
|
export function GetActiveProfile() {
|
||||||
return window['go']['main']['App']['GetActiveProfile']();
|
return window['go']['main']['App']['GetActiveProfile']();
|
||||||
}
|
}
|
||||||
@@ -298,6 +398,10 @@ export function GetExternalServices() {
|
|||||||
return window['go']['main']['App']['GetExternalServices']();
|
return window['go']['main']['App']['GetExternalServices']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetFlexState() {
|
||||||
|
return window['go']['main']['App']['GetFlexState']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetListsSettings() {
|
export function GetListsSettings() {
|
||||||
return window['go']['main']['App']['GetListsSettings']();
|
return window['go']['main']['App']['GetListsSettings']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,6 +385,30 @@ export namespace awardref {
|
|||||||
|
|
||||||
export namespace cat {
|
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 {
|
export class FlexRadio {
|
||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: number;
|
||||||
@@ -407,6 +431,96 @@ export namespace cat {
|
|||||||
this.callsign = source["callsign"];
|
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 {
|
export class RigState {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
|
|||||||
@@ -226,6 +226,115 @@ func (m *Manager) SendSpot(s SpotInfo) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FlexTXState is the FlexRadio transmit/ATU state surfaced to the dedicated
|
||||||
|
// FlexRadio control tab. Levels are 0-100. (Phase 1: controls + state pushed by
|
||||||
|
// the radio over TCP; live meters arrive over a separate UDP stream later.)
|
||||||
|
type FlexTXState struct {
|
||||||
|
Available bool `json:"available"` // backend is Flex and handshaked
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
RFPower int `json:"rf_power"`
|
||||||
|
TunePower int `json:"tune_power"`
|
||||||
|
Tune bool `json:"tune"` // tune carrier active
|
||||||
|
Transmitting bool `json:"transmitting"` // interlock state = TRANSMITTING
|
||||||
|
VoxEnable bool `json:"vox_enable"`
|
||||||
|
VoxLevel int `json:"vox_level"`
|
||||||
|
VoxDelay int `json:"vox_delay"`
|
||||||
|
ProcEnable bool `json:"proc_enable"`
|
||||||
|
ProcLevel int `json:"proc_level"`
|
||||||
|
Mon bool `json:"mon"`
|
||||||
|
MonLevel int `json:"mon_level"`
|
||||||
|
MicLevel int `json:"mic_level"`
|
||||||
|
ATUStatus string `json:"atu_status,omitempty"`
|
||||||
|
ATUMemories bool `json:"atu_memories"`
|
||||||
|
// Active RX slice DSP controls.
|
||||||
|
RXAvail bool `json:"rx_avail"` // an RX slice exists
|
||||||
|
AGCMode string `json:"agc_mode,omitempty"`
|
||||||
|
AGCThreshold int `json:"agc_threshold"`
|
||||||
|
AudioLevel int `json:"audio_level"`
|
||||||
|
NB bool `json:"nb"`
|
||||||
|
NBLevel int `json:"nb_level"`
|
||||||
|
NR bool `json:"nr"`
|
||||||
|
NRLevel int `json:"nr_level"`
|
||||||
|
ANF bool `json:"anf"`
|
||||||
|
ANFLevel int `json:"anf_level"`
|
||||||
|
// External amplifier (PowerGenius XL).
|
||||||
|
AmpAvailable bool `json:"amp_available"`
|
||||||
|
AmpModel string `json:"amp_model,omitempty"`
|
||||||
|
AmpOperate bool `json:"amp_operate"`
|
||||||
|
AmpFault string `json:"amp_fault,omitempty"`
|
||||||
|
// Live meters streamed over UDP (S-meter, PWR, SWR, temp, voltage…).
|
||||||
|
Meters []FlexMeter `json:"meters,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexMeter is one live meter value (already scaled to real units).
|
||||||
|
type FlexMeter struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
|
||||||
|
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
|
||||||
|
Unit string `json:"unit,omitempty"`
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
Lo float64 `json:"lo"`
|
||||||
|
Hi float64 `json:"hi"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexController is an OPTIONAL backend capability (the FlexRadio backend): the
|
||||||
|
// SmartSDR-style transmit controls. Backends that don't implement it are skipped
|
||||||
|
// by the FlexRadio tab. FlexState() is mutex-guarded in the backend so it's safe
|
||||||
|
// to read off the CAT goroutine; the setters are dispatched onto it via FlexDo.
|
||||||
|
type FlexController interface {
|
||||||
|
FlexState() FlexTXState
|
||||||
|
SetRFPower(int) error
|
||||||
|
SetTunePower(int) error
|
||||||
|
SetTune(bool) error
|
||||||
|
SetVOX(bool) error
|
||||||
|
SetVOXLevel(int) error
|
||||||
|
SetVOXDelay(int) error
|
||||||
|
SetProcessor(bool) error
|
||||||
|
SetProcessorLevel(int) error
|
||||||
|
SetMon(bool) error
|
||||||
|
SetMonLevel(int) error
|
||||||
|
SetMic(int) error
|
||||||
|
ATUStart() error
|
||||||
|
ATUBypass() error
|
||||||
|
SetATUMemories(bool) error
|
||||||
|
// RX slice DSP controls (target the active receive slice).
|
||||||
|
SetAGCMode(string) error
|
||||||
|
SetAGCThreshold(int) error
|
||||||
|
SetAudioLevel(int) error
|
||||||
|
SetNB(bool) error
|
||||||
|
SetNBLevel(int) error
|
||||||
|
SetNR(bool) error
|
||||||
|
SetNRLevel(int) error
|
||||||
|
SetANF(bool) error
|
||||||
|
SetANFLevel(int) error
|
||||||
|
// External amplifier (PowerGenius XL) operate/standby.
|
||||||
|
SetAmpOperate(bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexState returns the current FlexRadio transmit state, or (zero, false) when
|
||||||
|
// the active backend isn't a Flex. Safe to call from any goroutine.
|
||||||
|
func (m *Manager) FlexState() (FlexTXState, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
b := m.backend
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if fc, ok := b.(FlexController); ok {
|
||||||
|
return fc.FlexState(), true
|
||||||
|
}
|
||||||
|
return FlexTXState{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexDo dispatches a FlexRadio control onto the CAT goroutine. Errors if the
|
||||||
|
// active backend isn't a Flex.
|
||||||
|
func (m *Manager) FlexDo(fn func(FlexController) error) error {
|
||||||
|
return m.exec(func(b Backend) error {
|
||||||
|
fc, ok := b.(FlexController)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("active CAT backend is not a FlexRadio")
|
||||||
|
}
|
||||||
|
return fn(fc)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// exec marshals a backend operation onto the CAT goroutine. Returns the
|
// exec marshals a backend operation onto the CAT goroutine. Returns the
|
||||||
// operation's error or a "busy"/"not running" error if dispatch failed.
|
// operation's error or a "busy"/"not running" error if dispatch failed.
|
||||||
func (m *Manager) exec(fn func(Backend) error) error {
|
func (m *Manager) exec(fn func(Backend) error) error {
|
||||||
|
|||||||
+776
-5
@@ -4,6 +4,7 @@ package cat
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
@@ -33,12 +34,27 @@ type Flex struct {
|
|||||||
gotHandle bool
|
gotHandle bool
|
||||||
|
|
||||||
slices map[int]*flexSlice
|
slices map[int]*flexSlice
|
||||||
|
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
|
||||||
|
amp flexAmp // external amplifier (PowerGenius XL) state
|
||||||
|
txSetAt map[string]time.Time // status field → when WE last set it (ignore the radio's lagging echo briefly)
|
||||||
lastStateSig string // last logged derived-state signature (log only on change)
|
lastStateSig string // last logged derived-state signature (log only on change)
|
||||||
|
|
||||||
|
// Live meters streamed over UDP (VITA-49). meterMeta is the definitions
|
||||||
|
// pushed over TCP; meterVal the latest scaled values keyed by meter id.
|
||||||
|
udpConn *net.UDPConn
|
||||||
|
meterMeta map[int]meterInfo
|
||||||
|
meterVal map[int]float64
|
||||||
|
meterSub map[int]bool // ids we've already sent "sub meter <id>" for
|
||||||
|
meterLogAt time.Time // throttle for value logging
|
||||||
|
vitaSeen int // count of UDP datagrams (first few logged for diag)
|
||||||
|
meterRawLogged bool // log the first raw meter-definition status once
|
||||||
|
txRawLogged bool // log the first raw transmit status once (field-name audit)
|
||||||
|
|
||||||
spotsEnabled bool // push cluster spots + manage the panadapter overlay
|
spotsEnabled bool // push cluster spots + manage the panadapter overlay
|
||||||
spotIdx map[int]bool // panadapter spot indices currently known to the radio
|
spotIdx map[int]bool // panadapter spot indices currently known to the radio
|
||||||
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||||
|
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
||||||
|
|
||||||
// OnSpotClick is called (off the reader goroutine's hot path) when the user
|
// OnSpotClick is called (off the reader goroutine's hot path) when the user
|
||||||
// clicks one of our spots on the panadapter, with the spot's callsign and
|
// clicks one of our spots on the panadapter, with the spot's callsign and
|
||||||
@@ -52,6 +68,54 @@ type flexSlice struct {
|
|||||||
active bool
|
active bool
|
||||||
tx bool
|
tx bool
|
||||||
inUse bool
|
inUse bool
|
||||||
|
// RX DSP controls (SmartSDR slice object).
|
||||||
|
agcMode string // off | slow | med | fast
|
||||||
|
agcThreshold int // 0-100
|
||||||
|
audioLevel int // 0-100 (RX volume)
|
||||||
|
nb bool // noise blanker
|
||||||
|
nbLevel int
|
||||||
|
nr bool // noise reduction
|
||||||
|
nrLevel int
|
||||||
|
anf bool // auto notch filter
|
||||||
|
anfLevel int
|
||||||
|
}
|
||||||
|
|
||||||
|
// flexTX mirrors the radio's transmit/ATU/interlock objects (the SmartSDR-style
|
||||||
|
// controls). Populated from status pushes in handleStatus; read by FlexState().
|
||||||
|
type flexTX struct {
|
||||||
|
rfPower int
|
||||||
|
tunePower int
|
||||||
|
tune bool
|
||||||
|
transmitting bool // interlock state == TRANSMITTING
|
||||||
|
voxEnable bool
|
||||||
|
voxLevel int
|
||||||
|
voxDelay int
|
||||||
|
procEnable bool
|
||||||
|
procLevel int
|
||||||
|
mon bool
|
||||||
|
monLevel int
|
||||||
|
micLevel int
|
||||||
|
atuStatus string
|
||||||
|
atuMemories bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// flexAmp mirrors the external amplifier object (PowerGenius XL). handle is the
|
||||||
|
// hex id used to address SET commands; operate=true means OPERATE (vs STANDBY).
|
||||||
|
type flexAmp struct {
|
||||||
|
handle string
|
||||||
|
model string
|
||||||
|
operate bool
|
||||||
|
fault string
|
||||||
|
}
|
||||||
|
|
||||||
|
// meterInfo is a meter definition pushed by the radio over TCP. unit drives the
|
||||||
|
// raw-int16 → real-value scaling; src/name identify what it measures.
|
||||||
|
type meterInfo struct {
|
||||||
|
src string // SLC (slice), TX-, COD, RAD, AMP…
|
||||||
|
name string // FWDPWR, SWR, LEVEL, PATEMP, +13.8B…
|
||||||
|
unit string // dbm, dbfs, swr, volts, degc, watts…
|
||||||
|
lo float64
|
||||||
|
hi float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// flexTriggerRe matches the radio's "spot <index> triggered" notification, sent
|
// flexTriggerRe matches the radio's "spot <index> triggered" notification, sent
|
||||||
@@ -69,6 +133,8 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
|||||||
host: strings.TrimSpace(host), port: port,
|
host: strings.TrimSpace(host), port: port,
|
||||||
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
||||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{},
|
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{},
|
||||||
|
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||||
|
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,16 +162,21 @@ func (f *Flex) Connect() error {
|
|||||||
f.conn = conn
|
f.conn = conn
|
||||||
f.gotHandle = false
|
f.gotHandle = false
|
||||||
f.slices = map[int]*flexSlice{}
|
f.slices = map[int]*flexSlice{}
|
||||||
|
f.meterVal = map[int]float64{}
|
||||||
|
f.meterSub = map[int]bool{}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
debugLog.Printf("Flex: connected to %s:%d", host, port)
|
debugLog.Printf("Flex: connected to %s:%d", host, port)
|
||||||
|
|
||||||
go f.reader(conn)
|
go f.reader(conn)
|
||||||
// Identify ourselves in SmartSDR's client list, then stream slice + transmit
|
// Identify ourselves in SmartSDR's client list, then stream slice + transmit
|
||||||
// (TX/split) status. Command names per the SmartSDR TCP/IP API docs.
|
// (TX/split) status. Command names per the SmartSDR TCP/IP API docs.
|
||||||
f.send("client program=OpsLog")
|
f.send("client program OpsLog")
|
||||||
f.send("sub slice all")
|
f.send("sub slice all") // slice/receiver: freq, mode, AGC, NB/NR/ANF, audio…
|
||||||
f.send("sub transmit all")
|
f.send("sub tx all") // transmit: rfpower, tunepower, vox, processor, mon, mic
|
||||||
f.send("sub radio all")
|
f.send("sub atu all") // antenna-tuner status + memories
|
||||||
|
f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby
|
||||||
|
f.send("sub radio all") // radio-wide incl. interlock (TX/RX state)
|
||||||
|
f.startMeters(conn) // open the UDP VITA-49 stream for live meters
|
||||||
if f.spotsEnabled {
|
if f.spotsEnabled {
|
||||||
// Subscribe so the radio pushes existing spots (we learn their indices),
|
// Subscribe so the radio pushes existing spots (we learn their indices),
|
||||||
// then wipe the panadapter so stale spots from a previous session or
|
// then wipe the panadapter so stale spots from a previous session or
|
||||||
@@ -119,9 +190,14 @@ func (f *Flex) Connect() error {
|
|||||||
func (f *Flex) Disconnect() {
|
func (f *Flex) Disconnect() {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
c := f.conn
|
c := f.conn
|
||||||
|
uc := f.udpConn
|
||||||
f.conn = nil
|
f.conn = nil
|
||||||
|
f.udpConn = nil
|
||||||
f.gotHandle = false
|
f.gotHandle = false
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
|
if uc != nil {
|
||||||
|
_ = uc.Close() // unblocks udpReader
|
||||||
|
}
|
||||||
if c != nil {
|
if c != nil {
|
||||||
_ = c.Close()
|
_ = c.Close()
|
||||||
debugLog.Printf("Flex: disconnected")
|
debugLog.Printf("Flex: disconnected")
|
||||||
@@ -136,6 +212,9 @@ func (f *Flex) send(cmd string) int {
|
|||||||
c := f.conn
|
c := f.conn
|
||||||
f.seq++
|
f.seq++
|
||||||
seq := f.seq
|
seq := f.seq
|
||||||
|
if f.sentCmds != nil {
|
||||||
|
f.sentCmds[seq] = cmd
|
||||||
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return 0
|
return 0
|
||||||
@@ -196,8 +275,12 @@ func (f *Flex) reader(conn net.Conn) {
|
|||||||
}
|
}
|
||||||
seq, _ := strconv.Atoi(parts[0])
|
seq, _ := strconv.Atoi(parts[0])
|
||||||
ok := parts[1] == "0" || parts[1] == "00000000"
|
ok := parts[1] == "0" || parts[1] == "00000000"
|
||||||
|
f.mu.Lock()
|
||||||
|
cmdText := f.sentCmds[seq]
|
||||||
|
delete(f.sentCmds, seq)
|
||||||
|
f.mu.Unlock()
|
||||||
if !ok {
|
if !ok {
|
||||||
debugLog.Printf("Flex: cmd error %s", line)
|
debugLog.Printf("Flex: cmd error R%d code=%s cmd=%q", seq, parts[1], cmdText)
|
||||||
}
|
}
|
||||||
// A successful "spot add" returns the new spot's index in the message;
|
// A successful "spot add" returns the new spot's index in the message;
|
||||||
// pair it with the callsign we stashed under this seq.
|
// pair it with the callsign we stashed under this seq.
|
||||||
@@ -239,8 +322,190 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Transmit object — RF/tune power, VOX, speech processor, monitor, mic,
|
||||||
|
// tune carrier. Field names per the SmartSDR API (logged so the exact set
|
||||||
|
// is auditable against a real radio).
|
||||||
if len(fields) >= 1 && fields[0] == "transmit" {
|
if len(fields) >= 1 && fields[0] == "transmit" {
|
||||||
|
if !f.txRawLogged {
|
||||||
|
f.txRawLogged = true
|
||||||
|
debugLog.Printf("Flex: FIRST transmit status: %s", payload)
|
||||||
|
}
|
||||||
debugLog.Printf("Flex: status %s", payload)
|
debugLog.Printf("Flex: status %s", payload)
|
||||||
|
f.mu.Lock()
|
||||||
|
for _, kv := range fields[1:] {
|
||||||
|
key, val, ok := splitKV(kv)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Ignore the radio's echo of a field we just set ourselves (it
|
||||||
|
// often re-pushes the OLD value for ~1s, which snapped the slider
|
||||||
|
// back). Our optimistic value stands until the guard expires.
|
||||||
|
if t, ok := f.txSetAt[key]; ok && time.Since(t) < 1200*time.Millisecond {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "rfpower":
|
||||||
|
f.tx.rfPower = atoiDefault(val, f.tx.rfPower)
|
||||||
|
case "tunepower":
|
||||||
|
f.tx.tunePower = atoiDefault(val, f.tx.tunePower)
|
||||||
|
case "tune":
|
||||||
|
f.tx.tune = val == "1"
|
||||||
|
case "vox_enable":
|
||||||
|
f.tx.voxEnable = val == "1"
|
||||||
|
case "vox_level":
|
||||||
|
f.tx.voxLevel = atoiDefault(val, f.tx.voxLevel)
|
||||||
|
case "vox_delay":
|
||||||
|
f.tx.voxDelay = atoiDefault(val, f.tx.voxDelay)
|
||||||
|
case "speech_processor_enable":
|
||||||
|
f.tx.procEnable = val == "1"
|
||||||
|
case "speech_processor_level":
|
||||||
|
f.tx.procLevel = atoiDefault(val, f.tx.procLevel)
|
||||||
|
case "mon", "sb_monitor":
|
||||||
|
f.tx.mon = val == "1"
|
||||||
|
case "mon_gain_sb":
|
||||||
|
f.tx.monLevel = atoiDefault(val, f.tx.monLevel)
|
||||||
|
case "mic_level", "miclevel":
|
||||||
|
f.tx.micLevel = atoiDefault(val, f.tx.micLevel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
// ATU object — auto-tuner status + memories.
|
||||||
|
if len(fields) >= 1 && fields[0] == "atu" {
|
||||||
|
debugLog.Printf("Flex: status %s", payload)
|
||||||
|
f.mu.Lock()
|
||||||
|
for _, kv := range fields[1:] {
|
||||||
|
key, val, ok := splitKV(kv)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "status":
|
||||||
|
f.tx.atuStatus = val
|
||||||
|
case "memories_enabled":
|
||||||
|
f.tx.atuMemories = val == "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
// Interlock object — transmit state (RECEIVE / TRANSMITTING / …).
|
||||||
|
if len(fields) >= 1 && fields[0] == "interlock" {
|
||||||
|
f.mu.Lock()
|
||||||
|
for _, kv := range fields[1:] {
|
||||||
|
if key, val, ok := splitKV(kv); ok && key == "state" {
|
||||||
|
f.tx.transmitting = strings.EqualFold(val, "TRANSMITTING")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
// Amplifier object — "amplifier <handle> model=… operate=… …" (PowerGenius
|
||||||
|
// XL). The handle (hex) addresses the operate/standby SET command.
|
||||||
|
if len(fields) >= 2 && fields[0] == "amplifier" {
|
||||||
|
debugLog.Printf("Flex: status %s", payload)
|
||||||
|
f.mu.Lock()
|
||||||
|
if strings.HasPrefix(fields[1], "0x") {
|
||||||
|
f.amp.handle = fields[1]
|
||||||
|
}
|
||||||
|
removed := false
|
||||||
|
for _, kv := range fields[2:] {
|
||||||
|
if kv == "removed" || kv == "in_use=0" {
|
||||||
|
removed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, val, ok := splitKV(kv)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "handle":
|
||||||
|
f.amp.handle = val
|
||||||
|
case "model":
|
||||||
|
f.amp.model = val
|
||||||
|
case "operate":
|
||||||
|
f.amp.operate = val == "1" || strings.EqualFold(val, "OPERATE")
|
||||||
|
case "mode":
|
||||||
|
f.amp.operate = strings.EqualFold(val, "OPERATE")
|
||||||
|
case "fault":
|
||||||
|
f.amp.fault = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if removed {
|
||||||
|
f.amp = flexAmp{}
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
// Meter definitions — "meter <num>.src=… <num>.nam=… <num>.unit=… …".
|
||||||
|
// The unit scales the UDP values, the name labels them; subscribe to each
|
||||||
|
// new id so the radio streams it.
|
||||||
|
if len(fields) >= 2 && fields[0] == "meter" {
|
||||||
|
if !f.meterRawLogged {
|
||||||
|
f.meterRawLogged = true
|
||||||
|
debugLog.Printf("Flex: meter status raw: %s", payload)
|
||||||
|
}
|
||||||
|
var newIDs []int
|
||||||
|
f.mu.Lock()
|
||||||
|
for _, tok := range fields[1:] {
|
||||||
|
// One meter per token; its fields are '#'-separated:
|
||||||
|
// "<n>.src=…#<n>.num=…#<n>.nam=…#<n>.low=…#<n>.hi=…#<n>.unit=…".
|
||||||
|
num := -1
|
||||||
|
var mi meterInfo
|
||||||
|
for _, sub := range strings.Split(tok, "#") {
|
||||||
|
key, val, ok := splitKV(sub)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dot := strings.IndexByte(key, '.')
|
||||||
|
if dot <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(key[:dot])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
num = n
|
||||||
|
switch key[dot+1:] {
|
||||||
|
case "src":
|
||||||
|
mi.src = val
|
||||||
|
case "nam":
|
||||||
|
mi.name = val
|
||||||
|
case "unit", "units":
|
||||||
|
mi.unit = val
|
||||||
|
case "low", "lo":
|
||||||
|
mi.lo = parseFloatDefault(val, mi.lo)
|
||||||
|
case "hi":
|
||||||
|
mi.hi = parseFloatDefault(val, mi.hi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if num < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
old, seen := f.meterMeta[num]
|
||||||
|
if !seen {
|
||||||
|
newIDs = append(newIDs, num)
|
||||||
|
}
|
||||||
|
if mi.src != "" {
|
||||||
|
old.src = mi.src
|
||||||
|
}
|
||||||
|
if mi.name != "" {
|
||||||
|
old.name = mi.name
|
||||||
|
}
|
||||||
|
if mi.unit != "" {
|
||||||
|
old.unit = mi.unit
|
||||||
|
}
|
||||||
|
if mi.lo != 0 {
|
||||||
|
old.lo = mi.lo
|
||||||
|
}
|
||||||
|
if mi.hi != 0 {
|
||||||
|
old.hi = mi.hi
|
||||||
|
}
|
||||||
|
f.meterMeta[num] = old
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
for _, id := range newIDs {
|
||||||
|
mi := f.meterMeta[id]
|
||||||
|
debugLog.Printf("Flex: meter def #%d %s/%s unit=%s → sub", id, mi.src, mi.name, mi.unit)
|
||||||
|
f.subscribeMeter(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Spot status: "spot <index> …". Track the index so we can clear the
|
// Spot status: "spot <index> …". Track the index so we can clear the
|
||||||
// panadapter, and log it verbatim — a click on a panadapter spot pushes a
|
// panadapter, and log it verbatim — a click on a panadapter spot pushes a
|
||||||
@@ -299,6 +564,24 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
s.tx = val == "1"
|
s.tx = val == "1"
|
||||||
case "in_use":
|
case "in_use":
|
||||||
s.inUse = val == "1"
|
s.inUse = val == "1"
|
||||||
|
case "agc_mode":
|
||||||
|
s.agcMode = val
|
||||||
|
case "agc_threshold":
|
||||||
|
s.agcThreshold = atoiDefault(val, s.agcThreshold)
|
||||||
|
case "audio_level":
|
||||||
|
s.audioLevel = atoiDefault(val, s.audioLevel)
|
||||||
|
case "nb":
|
||||||
|
s.nb = val == "1"
|
||||||
|
case "nb_level":
|
||||||
|
s.nbLevel = atoiDefault(val, s.nbLevel)
|
||||||
|
case "nr":
|
||||||
|
s.nr = val == "1"
|
||||||
|
case "nr_level":
|
||||||
|
s.nrLevel = atoiDefault(val, s.nrLevel)
|
||||||
|
case "anf":
|
||||||
|
s.anf = val == "1"
|
||||||
|
case "anf_level":
|
||||||
|
s.anfLevel = atoiDefault(val, s.anfLevel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
@@ -547,6 +830,494 @@ func (f *Flex) SetPTT(on bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// splitKV splits a "key=value" token. ok is false when there's no '='.
|
||||||
|
func splitKV(kv string) (key, val string, ok bool) {
|
||||||
|
eq := strings.IndexByte(kv, '=')
|
||||||
|
if eq <= 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return kv[:eq], kv[eq+1:], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// atoiDefault parses an int (or a float like "20.0", truncated), else def.
|
||||||
|
func atoiDefault(s string, def int) int {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if n, err := strconv.Atoi(s); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
if fl, err := strconv.ParseFloat(s, 64); err == nil {
|
||||||
|
return int(fl)
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampLevel(v int) int {
|
||||||
|
if v < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if v > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// rxSliceLocked returns the active RX slice and its index (-1 when none), using
|
||||||
|
// the same RX-selection rule as ReadState. Caller holds f.mu.
|
||||||
|
func (f *Flex) rxSliceLocked() (int, *flexSlice) {
|
||||||
|
rx, _ := f.pickSlicesLocked()
|
||||||
|
if rx == nil {
|
||||||
|
return -1, nil
|
||||||
|
}
|
||||||
|
for i, s := range f.slices {
|
||||||
|
if s == rx {
|
||||||
|
return i, rx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1, rx
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexState returns a snapshot of the radio's transmit/ATU state plus the active
|
||||||
|
// RX slice's DSP controls, for the FlexRadio control tab. Available is true once
|
||||||
|
// the handshake has completed.
|
||||||
|
func (f *Flex) FlexState() FlexTXState {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
st := FlexTXState{
|
||||||
|
Available: f.gotHandle && f.conn != nil,
|
||||||
|
Model: f.model,
|
||||||
|
RFPower: f.tx.rfPower,
|
||||||
|
TunePower: f.tx.tunePower,
|
||||||
|
Tune: f.tx.tune,
|
||||||
|
Transmitting: f.tx.transmitting,
|
||||||
|
VoxEnable: f.tx.voxEnable,
|
||||||
|
VoxLevel: f.tx.voxLevel,
|
||||||
|
VoxDelay: f.tx.voxDelay,
|
||||||
|
ProcEnable: f.tx.procEnable,
|
||||||
|
ProcLevel: f.tx.procLevel,
|
||||||
|
Mon: f.tx.mon,
|
||||||
|
MonLevel: f.tx.monLevel,
|
||||||
|
MicLevel: f.tx.micLevel,
|
||||||
|
ATUStatus: f.tx.atuStatus,
|
||||||
|
ATUMemories: f.tx.atuMemories,
|
||||||
|
}
|
||||||
|
if _, rx := f.rxSliceLocked(); rx != nil {
|
||||||
|
st.RXAvail = true
|
||||||
|
st.AGCMode = rx.agcMode
|
||||||
|
st.AGCThreshold = rx.agcThreshold
|
||||||
|
st.AudioLevel = rx.audioLevel
|
||||||
|
st.NB = rx.nb
|
||||||
|
st.NBLevel = rx.nbLevel
|
||||||
|
st.NR = rx.nr
|
||||||
|
st.NRLevel = rx.nrLevel
|
||||||
|
st.ANF = rx.anf
|
||||||
|
st.ANFLevel = rx.anfLevel
|
||||||
|
}
|
||||||
|
if f.amp.handle != "" {
|
||||||
|
st.AmpAvailable = true
|
||||||
|
st.AmpModel = f.amp.model
|
||||||
|
st.AmpOperate = f.amp.operate
|
||||||
|
st.AmpFault = f.amp.fault
|
||||||
|
}
|
||||||
|
if len(f.meterVal) > 0 {
|
||||||
|
ids := make([]int, 0, len(f.meterVal))
|
||||||
|
for id := range f.meterVal {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
sort.Ints(ids) // stable order so the UI doesn't reshuffle each poll
|
||||||
|
for _, id := range ids {
|
||||||
|
mi := f.meterMeta[id]
|
||||||
|
st.Meters = append(st.Meters, FlexMeter{ID: id, Src: mi.src, Name: mi.name, Unit: mi.unit, Value: f.meterVal[id], Lo: mi.lo, Hi: mi.hi})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendSlice sends a "slice s <rxIdx> <param>=<val>" to the active RX slice, and
|
||||||
|
// optimistically updates our cached slice state — the radio doesn't reliably
|
||||||
|
// echo every field back to the client that changed it (e.g. agc_mode), so
|
||||||
|
// without this the UI would snap back to the stale value.
|
||||||
|
func (f *Flex) sendSlice(param string, val any) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
idx, rx := f.rxSliceLocked()
|
||||||
|
connected := f.conn != nil
|
||||||
|
if rx != nil {
|
||||||
|
switch param {
|
||||||
|
case "agc_mode":
|
||||||
|
rx.agcMode = fmt.Sprint(val)
|
||||||
|
case "agc_threshold":
|
||||||
|
rx.agcThreshold = toInt(val)
|
||||||
|
case "audio_level":
|
||||||
|
rx.audioLevel = toInt(val)
|
||||||
|
case "nb":
|
||||||
|
rx.nb = val == "1"
|
||||||
|
case "nb_level":
|
||||||
|
rx.nbLevel = toInt(val)
|
||||||
|
case "nr":
|
||||||
|
rx.nr = val == "1"
|
||||||
|
case "nr_level":
|
||||||
|
rx.nrLevel = toInt(val)
|
||||||
|
case "anf":
|
||||||
|
rx.anf = val == "1"
|
||||||
|
case "anf_level":
|
||||||
|
rx.anfLevel = toInt(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
if rx == nil || idx < 0 {
|
||||||
|
return fmt.Errorf("flex: no receive slice")
|
||||||
|
}
|
||||||
|
f.send(fmt.Sprintf("slice s %d %s=%v", idx, param, val))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toInt coerces an int or numeric string to int (for the optimistic cache).
|
||||||
|
func toInt(v any) int {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case int:
|
||||||
|
return t
|
||||||
|
case string:
|
||||||
|
return atoiDefault(t, 0)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) SetAGCMode(m string) error {
|
||||||
|
switch m {
|
||||||
|
case "off", "slow", "med", "fast":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("flex: invalid agc mode %q", m)
|
||||||
|
}
|
||||||
|
return f.sendSlice("agc_mode", m)
|
||||||
|
}
|
||||||
|
func (f *Flex) SetAGCThreshold(l int) error { return f.sendSlice("agc_threshold", clampLevel(l)) }
|
||||||
|
func (f *Flex) SetAudioLevel(l int) error { return f.sendSlice("audio_level", clampLevel(l)) }
|
||||||
|
func (f *Flex) SetNB(on bool) error { return f.sendSlice("nb", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetNBLevel(l int) error { return f.sendSlice("nb_level", clampLevel(l)) }
|
||||||
|
func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
|
||||||
|
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
|
||||||
|
|
||||||
|
// connected reports whether the TCP link is up (commands are no-ops otherwise).
|
||||||
|
func (f *Flex) connected() bool {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.conn != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FlexController controls (SmartSDR transmit object). ---
|
||||||
|
//
|
||||||
|
// txSet sends a command AND optimistically updates our cached transmit state.
|
||||||
|
// The radio doesn't reliably echo a changed field back to the client that set
|
||||||
|
// it, so without the optimistic update the UI would snap back to the stale
|
||||||
|
// cached value (a real echo, e.g. a change from SmartSDR, still overrides it).
|
||||||
|
// txSet sends a command, optimistically updates our cached transmit state, and
|
||||||
|
// records `field` (the STATUS field name) so the radio's lagging echo of the old
|
||||||
|
// value is ignored for a moment (see handleStatus) — otherwise the slider snaps
|
||||||
|
// back. `field` may be "" for non-guarded commands.
|
||||||
|
func (f *Flex) txSet(cmd, field string, apply func(*flexTX)) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
connected := f.conn != nil
|
||||||
|
if connected && apply != nil {
|
||||||
|
apply(&f.tx)
|
||||||
|
if field != "" {
|
||||||
|
f.txSetAt[field] = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
f.send(cmd)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) SetRFPower(p int) error {
|
||||||
|
p = clampLevel(p)
|
||||||
|
return f.txSet(fmt.Sprintf("transmit set rfpower=%d", p), "rfpower", func(t *flexTX) { t.rfPower = p })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) SetTunePower(p int) error {
|
||||||
|
p = clampLevel(p)
|
||||||
|
return f.txSet(fmt.Sprintf("transmit set tunepower=%d", p), "tunepower", func(t *flexTX) { t.tunePower = p })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) SetTune(on bool) error {
|
||||||
|
cmd := "transmit tune off"
|
||||||
|
if on {
|
||||||
|
cmd = "transmit tune on"
|
||||||
|
}
|
||||||
|
return f.txSet(cmd, "tune", func(t *flexTX) { t.tune = on })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) SetVOX(on bool) error {
|
||||||
|
return f.txSet("transmit set vox_enable="+boolFlex(on), "vox_enable", func(t *flexTX) { t.voxEnable = on })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) SetVOXLevel(l int) error {
|
||||||
|
l = clampLevel(l)
|
||||||
|
return f.txSet(fmt.Sprintf("transmit set vox_level=%d", l), "vox_level", func(t *flexTX) { t.voxLevel = l })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVOXDelay sets the VOX hang time (0-100, a percentage scale in SmartSDR).
|
||||||
|
func (f *Flex) SetVOXDelay(l int) error {
|
||||||
|
l = clampLevel(l)
|
||||||
|
return f.txSet(fmt.Sprintf("transmit set vox_delay=%d", l), "vox_delay", func(t *flexTX) { t.voxDelay = l })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) SetProcessor(on bool) error {
|
||||||
|
return f.txSet("transmit set speech_processor_enable="+boolFlex(on), "speech_processor_enable", func(t *flexTX) { t.procEnable = on })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetProcessorLevel sets the speech-processor preset: 0=NOR, 1=DX, 2=DX+ (NOT a
|
||||||
|
// 0-100 level — per the SmartSDR transmit API).
|
||||||
|
func (f *Flex) SetProcessorLevel(l int) error {
|
||||||
|
if l < 0 {
|
||||||
|
l = 0
|
||||||
|
}
|
||||||
|
if l > 2 {
|
||||||
|
l = 2
|
||||||
|
}
|
||||||
|
return f.txSet(fmt.Sprintf("transmit set speech_processor_level=%d", l), "speech_processor_level", func(t *flexTX) { t.procLevel = l })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) SetMon(on bool) error {
|
||||||
|
return f.txSet("transmit set mon="+boolFlex(on), "mon", func(t *flexTX) { t.mon = on })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) SetMonLevel(l int) error {
|
||||||
|
l = clampLevel(l)
|
||||||
|
return f.txSet(fmt.Sprintf("transmit set mon_gain_sb=%d", l), "mon_gain_sb", func(t *flexTX) { t.monLevel = l })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMic sets the mic gain. The SET token is "miclevel" (one word) even though
|
||||||
|
// the radio reports it back as "mic_level" in the transmit status.
|
||||||
|
func (f *Flex) SetMic(l int) error {
|
||||||
|
l = clampLevel(l)
|
||||||
|
return f.txSet(fmt.Sprintf("transmit set miclevel=%d", l), "mic_level", func(t *flexTX) { t.micLevel = l })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) ATUStart() error {
|
||||||
|
if !f.connected() {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
f.send("atu start")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) ATUBypass() error {
|
||||||
|
if !f.connected() {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
f.send("atu bypass")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) SetATUMemories(on bool) error {
|
||||||
|
return f.txSet("atu set memories_enabled="+boolFlex(on), "", func(t *flexTX) { t.atuMemories = on })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAmpOperate switches the external amplifier between OPERATE (on=true) and
|
||||||
|
// STANDBY. Needs the amplifier handle learned from its status push.
|
||||||
|
func (f *Flex) SetAmpOperate(on bool) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
handle := f.amp.handle
|
||||||
|
connected := f.conn != nil
|
||||||
|
if handle != "" {
|
||||||
|
f.amp.operate = on // optimistic (radio may not echo to us)
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
if handle == "" {
|
||||||
|
return fmt.Errorf("flex: no amplifier detected")
|
||||||
|
}
|
||||||
|
f.send(fmt.Sprintf("amplifier set %s operate=%s", handle, boolFlex(on)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolFlex(b bool) string {
|
||||||
|
if b {
|
||||||
|
return "1"
|
||||||
|
}
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Live meters over UDP (VITA-49) ---
|
||||||
|
|
||||||
|
// flexMeterClass is the VITA-49 packet class code FlexRadio uses for meter
|
||||||
|
// extension packets. The payload is 32-bit words: upper 16 bits = meter id,
|
||||||
|
// lower 16 bits = signed value (scaled per the meter's unit).
|
||||||
|
const flexMeterClass = 0x8002
|
||||||
|
|
||||||
|
// startMeters opens a UDP socket for the radio's VITA-49 realtime stream (sent
|
||||||
|
// from the radio's :4991), tells the radio which local port to stream to, and
|
||||||
|
// starts the reader. The socket is DIALED to radio:4991 and we send a "punch"
|
||||||
|
// datagram + periodic keepalives so Windows Firewall accepts the inbound stream
|
||||||
|
// (an unsolicited inbound UDP to our ephemeral port would otherwise be dropped).
|
||||||
|
func (f *Flex) startMeters(conn net.Conn) {
|
||||||
|
raddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(f.host, "4991"))
|
||||||
|
if err != nil {
|
||||||
|
debugLog.Printf("Flex: meters resolve %s:4991: %v", f.host, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Unconnected socket: accept the stream from ANY source (the radio's source
|
||||||
|
// port can change across NAT), while we still punch/keepalive toward :4991.
|
||||||
|
uc, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
||||||
|
if err != nil {
|
||||||
|
debugLog.Printf("Flex: meters UDP listen failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
port := uc.LocalAddr().(*net.UDPAddr).Port
|
||||||
|
f.mu.Lock()
|
||||||
|
f.udpConn = uc
|
||||||
|
f.vitaSeen = 0
|
||||||
|
f.mu.Unlock()
|
||||||
|
f.send(fmt.Sprintf("client udpport %d", port)) // route VITA-49 to our port
|
||||||
|
f.send("sub meter all") // stream all meter values
|
||||||
|
_, _ = uc.WriteToUDP([]byte{0}, raddr) // firewall/NAT punch
|
||||||
|
debugLog.Printf("Flex: meters UDP local=:%d punch→%s", port, raddr)
|
||||||
|
go f.udpReader(uc)
|
||||||
|
go f.udpKeepalive(uc, raddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) udpReader(uc *net.UDPConn) {
|
||||||
|
buf := make([]byte, 16*1024)
|
||||||
|
for {
|
||||||
|
n, src, err := uc.ReadFromUDP(buf)
|
||||||
|
if err != nil {
|
||||||
|
return // socket closed on disconnect
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.vitaSeen++
|
||||||
|
seen := f.vitaSeen
|
||||||
|
f.mu.Unlock()
|
||||||
|
if seen <= 3 {
|
||||||
|
debugLog.Printf("Flex: UDP datagram #%d %d bytes from %s", seen, n, src)
|
||||||
|
}
|
||||||
|
f.parseVita(buf[:n], seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// udpKeepalive keeps the firewall/NAT mapping open by pinging the radio's :4991.
|
||||||
|
func (f *Flex) udpKeepalive(uc *net.UDPConn, raddr *net.UDPAddr) {
|
||||||
|
t := time.NewTicker(10 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
f.mu.Lock()
|
||||||
|
cur := f.udpConn
|
||||||
|
f.mu.Unlock()
|
||||||
|
if cur != uc {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := uc.WriteToUDP([]byte{0}, raddr); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVita decodes a VITA-49 datagram and, if it's a meter packet, updates the
|
||||||
|
// cached meter values. Header flags are honoured so the payload offset is right.
|
||||||
|
func (f *Flex) parseVita(p []byte, seen int) {
|
||||||
|
if len(p) < 4 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w0 := binary.BigEndian.Uint32(p[0:4])
|
||||||
|
off := 4
|
||||||
|
pktType := (w0 >> 28) & 0xF
|
||||||
|
hasClass := (w0>>27)&0x1 == 1
|
||||||
|
tsi := (w0 >> 22) & 0x3
|
||||||
|
tsf := (w0 >> 20) & 0x3
|
||||||
|
if pktType == 0x1 || pktType == 0x3 { // packet types carrying a Stream ID
|
||||||
|
off += 4
|
||||||
|
}
|
||||||
|
var packetClass uint16
|
||||||
|
if hasClass {
|
||||||
|
if off+8 > len(p) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
packetClass = uint16(binary.BigEndian.Uint32(p[off+4 : off+8]))
|
||||||
|
off += 8
|
||||||
|
}
|
||||||
|
if tsi != 0 {
|
||||||
|
off += 4
|
||||||
|
}
|
||||||
|
if tsf != 0 {
|
||||||
|
off += 8
|
||||||
|
}
|
||||||
|
// Diagnostics: log the first few datagrams's parsed header so we can confirm
|
||||||
|
// the class code (in case 0x8002 / offsets differ on a real radio).
|
||||||
|
if seen <= 3 {
|
||||||
|
debugLog.Printf("Flex: VITA #%d len=%d type=%d class=0x%04x off=%d", seen, len(p), pktType, packetClass, off)
|
||||||
|
}
|
||||||
|
if packetClass != flexMeterClass || off > len(p) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload := p[off:]
|
||||||
|
f.mu.Lock()
|
||||||
|
for i := 0; i+4 <= len(payload); i += 4 {
|
||||||
|
id := int(binary.BigEndian.Uint16(payload[i : i+2]))
|
||||||
|
raw := int16(binary.BigEndian.Uint16(payload[i+2 : i+4]))
|
||||||
|
f.meterVal[id] = scaleMeter(raw, f.meterMeta[id].unit)
|
||||||
|
}
|
||||||
|
if time.Since(f.meterLogAt) > 5*time.Second { // throttled dump to validate names
|
||||||
|
f.meterLogAt = time.Now()
|
||||||
|
var b strings.Builder
|
||||||
|
for id, v := range f.meterVal {
|
||||||
|
mi := f.meterMeta[id]
|
||||||
|
fmt.Fprintf(&b, "%s=%.1f%s ", nonEmpty(mi.name, strconv.Itoa(id)), v, mi.unit)
|
||||||
|
}
|
||||||
|
debugLog.Printf("Flex: meters %s", strings.TrimSpace(b.String()))
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// scaleMeter converts the raw int16 to its real value per the meter's unit.
|
||||||
|
func scaleMeter(raw int16, unit string) float64 {
|
||||||
|
switch strings.ToUpper(unit) {
|
||||||
|
case "DB", "DBM", "DBFS":
|
||||||
|
return float64(raw) / 128.0
|
||||||
|
case "VOLTS", "AMPS":
|
||||||
|
return float64(raw) / 256.0
|
||||||
|
case "DEGC", "DEGF", "TEMPC", "TEMPF":
|
||||||
|
return float64(raw) / 64.0
|
||||||
|
case "SWR":
|
||||||
|
return float64(raw) / 128.0 // raw 128 = SWR 1.0 at idle
|
||||||
|
default:
|
||||||
|
return float64(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribeMeter asks the radio to stream a meter's values (once per id).
|
||||||
|
func (f *Flex) subscribeMeter(id int) {
|
||||||
|
f.mu.Lock()
|
||||||
|
if f.meterSub[id] || f.conn == nil {
|
||||||
|
f.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.meterSub[id] = true
|
||||||
|
f.mu.Unlock()
|
||||||
|
f.send(fmt.Sprintf("sub meter %d", id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func nonEmpty(s, def string) string {
|
||||||
|
if s == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFloatDefault(s string, def float64) float64 {
|
||||||
|
if v, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
// flexModeToADIF maps a Flex slice mode to a generic ADIF mode.
|
// flexModeToADIF maps a Flex slice mode to a generic ADIF mode.
|
||||||
func flexModeToADIF(m string) string {
|
func flexModeToADIF(m string) string {
|
||||||
switch strings.ToUpper(strings.TrimSpace(m)) {
|
switch strings.ToUpper(strings.TrimSpace(m)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user