diff --git a/app.go b/app.go index aa93760..02f757d 100644 --- a/app.go +++ b/app.go @@ -6315,6 +6315,196 @@ func (a *App) SetCATMode(mode string) error { 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 // requiring a trip through the full Settings panel. Persists the choice // so it survives restart. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0d3690c..ce145a2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -51,6 +51,7 @@ import { FirstRunModal } from '@/components/FirstRunModal'; import { QSOEditModal } from '@/components/QSOEditModal'; import { BandMap } from '@/components/BandMap'; import { WorldMap, LocatorMap } from '@/components/MainMap'; +import { FlexPanel } from '@/components/FlexPanel'; import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder'; import { AwardsPanel } from '@/components/AwardsPanel'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; @@ -664,11 +665,11 @@ export default function App() { // map ("map1"), the locator street map ("map2"), the cluster grid or the // worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed), // so it's loaded async on mount and re-read on profile:changed below. - type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked'; + type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex'; const [mainPaneLeft, setMainPaneLeft] = useState('map1'); const [mainPaneRight, setMainPaneRight] = useState('map2'); const loadMainPanes = useCallback(async () => { - const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked'; + const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex'; const [l, r] = await Promise.all([ GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => ''), @@ -2514,6 +2515,12 @@ export default function App() { onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} /> ); + case 'flex': + return ( +
+ +
+ ); } }; @@ -3093,6 +3100,7 @@ export default function App() { Awards Band Map + {catState.backend === 'flex' && FlexRadio} {/* Not a tab — QRZ blocks embedding, so this opens the call's QRZ.com page in the system browser. Styled like a trigger. */} + ))} + + ); +} + +// 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 ( + + ); +} + +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 ( +
+ + + {value} +
+ ); +} + +// 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 ( +
+
+ {label} + + {display !== undefined ? display : ( + <>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}{unit} + )} + +
+
+ {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 ( +
+ ); + })} +
+ {extra &&
{extra}
} +
+ ); +} + +function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) { + return ( +
+
+ + {title} +
+
{children}
+
+ ); +} + +export function FlexPanel() { + const [st, setSt] = useState(ZERO); + const hold = useRef>({}); + + 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) => { + 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 ( +
+
+ {/* Header strip */} +
+ +
+ {st.model || 'FlexRadio'} + SmartSDR remote control +
+
+ + + {!st.available ? 'OFFLINE' : st.transmitting ? 'TX' : 'RX'} + +
+ + {off && ( +
+ Waiting for the FlexRadio… (set CAT to FlexRadio and connect) +
+ )} + + {/* TX + RX columns */} +
+ {/* TRANSMIT */} + +
+ RF Power + change('rf_power', v, () => FlexSetPower(v))} /> + {st.rf_power} +
+
+ Tune Pwr + change('tune_power', v, () => FlexSetTunePower(v))} /> + {st.tune_power} +
+
+ + +
+ +
+
+ change('proc_enable', !st.proc_enable, () => FlexSetProcessor(!st.proc_enable))} /> + change('proc_level', parseInt(v, 10), () => FlexSetProcessorLevel(parseInt(v, 10)))} /> + +
+ change('vox_enable', !st.vox_enable, () => FlexSetVox(!st.vox_enable))} + onLevel={(v) => change('vox_level', v, () => FlexSetVoxLevel(v))} /> +
+ VOX Dly + change('vox_delay', v, () => FlexSetVoxDelay(v))} /> + {st.vox_delay} +
+ change('mon', !st.mon, () => FlexSetMon(!st.mon))} + onLevel={(v) => change('mon_level', v, () => FlexSetMonLevel(v))} /> +
+ MIC + change('mic_level', v, () => FlexSetMic(v))} /> + {st.mic_level} +
+
+
+ + {/* RECEIVE */} + +
+ AGC + change('agc_mode', v, () => FlexSetAGCMode(v))} /> +
+
+ Thresh + change('agc_threshold', v, () => FlexSetAGCThreshold(v))} /> + {st.agc_threshold} +
+
+ AF + change('audio_level', v, () => FlexSetAudioLevel(v))} /> + {st.audio_level} +
+
+ change('nb', !st.nb, () => FlexSetNB(!st.nb))} + onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} /> + change('nr', !st.nr, () => FlexSetNR(!st.nr))} + onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} /> + change('anf', !st.anf, () => FlexSetANF(!st.anf))} + onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} /> +
+
+
+ + {/* ATU */} + +
+ + + change('atu_memories', !st.atu_memories, () => FlexSetATUMemories(!st.atu_memories))} /> +
+ {st.atu_status && ( + {st.atu_status.replace(/_/g, ' ')} + )} +
+ + + {/* External amplifier (PowerGenius XL) — only when detected. */} + {st.amp_available && ( + +
+ + + {st.amp_operate ? 'Amplifier is in line (transmitting through PA).' : 'Amplifier bypassed (standby).'} + +
+ {st.amp_fault && st.amp_fault !== 'NONE' && ( + FAULT: {st.amp_fault} + )} +
+

Amplifier power / SWR / temperature appear in the Meters panel below (src AMP).

+ + )} + + {/* Live meters (UDP VITA-49 stream) */} + + {(() => { + const meters = st.meters || []; + if (off || meters.length === 0) { + return

No meters yet — waiting for the radio's UDP stream…

; + } + 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 ( + { const sv = fr * 19; return sv < 9 ? '#16a34a' : sv < 14 ? '#f59e0b' : '#dc2626'; }} /> + ); })(), + fwd && , + swr && , + alc && , + temp && , + volts && , + ].filter(Boolean); + return ( + <> +
{cur}
+ {amp.length > 0 && ( +
+
AMPLIFIER
+
+ {amp.map((m) => { + if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) { + return ; + } + return ; + })} +
+
+ )} + + ); + })()} +
+
+
+ ); +} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 8b34752..aeca188 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -138,6 +138,7 @@ interface Props { onClose: () => void; onSaved: () => void; onMainPaneChanged?: (side: 'left' | 'right', value: string) => void; // live Main-view layout update + flexAvailable?: boolean; // CAT backend is FlexRadio → offer it as a Main pane } // Pretty little card showing what OpsLog will stamp on each QSO based on @@ -457,11 +458,15 @@ const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [ { value: 'cluster', label: 'Cluster spots' }, { value: 'worked', label: 'Worked before' }, ]; -function MainViewPanes({ onChanged }: { onChanged?: (side: 'left' | 'right', value: string) => void }) { +function MainViewPanes({ onChanged, flexAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean }) { const [left, setLeft] = useState('map1'); const [right, setRight] = useState('map2'); + // FlexRadio is only offered when the CAT backend is a Flex. + const options = flexAvailable + ? [...MAIN_PANE_OPTIONS, { value: 'flex', label: 'FlexRadio controls' }] + : MAIN_PANE_OPTIONS; useEffect(() => { - const valid = (v: string) => MAIN_PANE_OPTIONS.some((o) => o.value === v); + const valid = (v: string) => v === 'flex' || MAIN_PANE_OPTIONS.some((o) => o.value === v); Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')]) .then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); }); }, []); @@ -482,7 +487,7 @@ function MainViewPanes({ onChanged }: { onChanged?: (side: 'left' | 'right', val @@ -491,7 +496,7 @@ function MainViewPanes({ onChanged }: { onChanged?: (side: 'left' | 'right', val @@ -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((initialSection as SectionId) || 'station'); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -3347,7 +3352,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan - +

Password encryption

diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 71ccde1..0f9814a 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -115,6 +115,56 @@ export function FilterFields():Promise>; export function FindQSOsForUpload(arg1:string,arg2:string):Promise>; +export function FlexATUBypass():Promise; + +export function FlexATUStart():Promise; + +export function FlexAmpOperate(arg1:boolean):Promise; + +export function FlexMox(arg1:boolean):Promise; + +export function FlexSetAGCMode(arg1:string):Promise; + +export function FlexSetAGCThreshold(arg1:number):Promise; + +export function FlexSetANF(arg1:boolean):Promise; + +export function FlexSetANFLevel(arg1:number):Promise; + +export function FlexSetATUMemories(arg1:boolean):Promise; + +export function FlexSetAudioLevel(arg1:number):Promise; + +export function FlexSetMic(arg1:number):Promise; + +export function FlexSetMon(arg1:boolean):Promise; + +export function FlexSetMonLevel(arg1:number):Promise; + +export function FlexSetNB(arg1:boolean):Promise; + +export function FlexSetNBLevel(arg1:number):Promise; + +export function FlexSetNR(arg1:boolean):Promise; + +export function FlexSetNRLevel(arg1:number):Promise; + +export function FlexSetPower(arg1:number):Promise; + +export function FlexSetProcessor(arg1:boolean):Promise; + +export function FlexSetProcessorLevel(arg1:number):Promise; + +export function FlexSetTunePower(arg1:number):Promise; + +export function FlexSetVox(arg1:boolean):Promise; + +export function FlexSetVoxDelay(arg1:number):Promise; + +export function FlexSetVoxLevel(arg1:number):Promise; + +export function FlexTune(arg1:boolean):Promise; + export function GetActiveProfile():Promise; export function GetAudioSettings():Promise; @@ -163,6 +213,8 @@ export function GetEmailSettings():Promise; export function GetExternalServices():Promise; +export function GetFlexState():Promise; + export function GetListsSettings():Promise; export function GetLogFilePath():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 53857ef..8bd976f 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -202,6 +202,106 @@ export function FindQSOsForUpload(arg1, arg2) { return window['go']['main']['App']['FindQSOsForUpload'](arg1, arg2); } +export function FlexATUBypass() { + return window['go']['main']['App']['FlexATUBypass'](); +} + +export function FlexATUStart() { + return window['go']['main']['App']['FlexATUStart'](); +} + +export function FlexAmpOperate(arg1) { + return window['go']['main']['App']['FlexAmpOperate'](arg1); +} + +export function FlexMox(arg1) { + return window['go']['main']['App']['FlexMox'](arg1); +} + +export function FlexSetAGCMode(arg1) { + return window['go']['main']['App']['FlexSetAGCMode'](arg1); +} + +export function FlexSetAGCThreshold(arg1) { + return window['go']['main']['App']['FlexSetAGCThreshold'](arg1); +} + +export function FlexSetANF(arg1) { + return window['go']['main']['App']['FlexSetANF'](arg1); +} + +export function FlexSetANFLevel(arg1) { + return window['go']['main']['App']['FlexSetANFLevel'](arg1); +} + +export function FlexSetATUMemories(arg1) { + return window['go']['main']['App']['FlexSetATUMemories'](arg1); +} + +export function FlexSetAudioLevel(arg1) { + return window['go']['main']['App']['FlexSetAudioLevel'](arg1); +} + +export function FlexSetMic(arg1) { + return window['go']['main']['App']['FlexSetMic'](arg1); +} + +export function FlexSetMon(arg1) { + return window['go']['main']['App']['FlexSetMon'](arg1); +} + +export function FlexSetMonLevel(arg1) { + return window['go']['main']['App']['FlexSetMonLevel'](arg1); +} + +export function FlexSetNB(arg1) { + return window['go']['main']['App']['FlexSetNB'](arg1); +} + +export function FlexSetNBLevel(arg1) { + return window['go']['main']['App']['FlexSetNBLevel'](arg1); +} + +export function FlexSetNR(arg1) { + return window['go']['main']['App']['FlexSetNR'](arg1); +} + +export function FlexSetNRLevel(arg1) { + return window['go']['main']['App']['FlexSetNRLevel'](arg1); +} + +export function FlexSetPower(arg1) { + return window['go']['main']['App']['FlexSetPower'](arg1); +} + +export function FlexSetProcessor(arg1) { + return window['go']['main']['App']['FlexSetProcessor'](arg1); +} + +export function FlexSetProcessorLevel(arg1) { + return window['go']['main']['App']['FlexSetProcessorLevel'](arg1); +} + +export function FlexSetTunePower(arg1) { + return window['go']['main']['App']['FlexSetTunePower'](arg1); +} + +export function FlexSetVox(arg1) { + return window['go']['main']['App']['FlexSetVox'](arg1); +} + +export function FlexSetVoxDelay(arg1) { + return window['go']['main']['App']['FlexSetVoxDelay'](arg1); +} + +export function FlexSetVoxLevel(arg1) { + return window['go']['main']['App']['FlexSetVoxLevel'](arg1); +} + +export function FlexTune(arg1) { + return window['go']['main']['App']['FlexTune'](arg1); +} + export function GetActiveProfile() { return window['go']['main']['App']['GetActiveProfile'](); } @@ -298,6 +398,10 @@ export function GetExternalServices() { return window['go']['main']['App']['GetExternalServices'](); } +export function GetFlexState() { + return window['go']['main']['App']['GetFlexState'](); +} + export function GetListsSettings() { return window['go']['main']['App']['GetListsSettings'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index b5d0021..af5bc00 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -385,6 +385,30 @@ export namespace awardref { export namespace cat { + export class FlexMeter { + id: number; + src?: string; + name?: string; + unit?: string; + value: number; + lo: number; + hi: number; + + static createFrom(source: any = {}) { + return new FlexMeter(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.src = source["src"]; + this.name = source["name"]; + this.unit = source["unit"]; + this.value = source["value"]; + this.lo = source["lo"]; + this.hi = source["hi"]; + } + } export class FlexRadio { ip: string; port: number; @@ -407,6 +431,96 @@ export namespace cat { this.callsign = source["callsign"]; } } + export class FlexTXState { + available: boolean; + model?: string; + rf_power: number; + tune_power: number; + tune: boolean; + transmitting: boolean; + vox_enable: boolean; + vox_level: number; + vox_delay: number; + proc_enable: boolean; + proc_level: number; + mon: boolean; + mon_level: number; + mic_level: number; + atu_status?: string; + atu_memories: boolean; + rx_avail: boolean; + agc_mode?: string; + agc_threshold: number; + audio_level: number; + nb: boolean; + nb_level: number; + nr: boolean; + nr_level: number; + anf: boolean; + anf_level: number; + amp_available: boolean; + amp_model?: string; + amp_operate: boolean; + amp_fault?: string; + meters?: FlexMeter[]; + + static createFrom(source: any = {}) { + return new FlexTXState(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.available = source["available"]; + this.model = source["model"]; + this.rf_power = source["rf_power"]; + this.tune_power = source["tune_power"]; + this.tune = source["tune"]; + this.transmitting = source["transmitting"]; + this.vox_enable = source["vox_enable"]; + this.vox_level = source["vox_level"]; + this.vox_delay = source["vox_delay"]; + this.proc_enable = source["proc_enable"]; + this.proc_level = source["proc_level"]; + this.mon = source["mon"]; + this.mon_level = source["mon_level"]; + this.mic_level = source["mic_level"]; + this.atu_status = source["atu_status"]; + this.atu_memories = source["atu_memories"]; + this.rx_avail = source["rx_avail"]; + this.agc_mode = source["agc_mode"]; + this.agc_threshold = source["agc_threshold"]; + this.audio_level = source["audio_level"]; + this.nb = source["nb"]; + this.nb_level = source["nb_level"]; + this.nr = source["nr"]; + this.nr_level = source["nr_level"]; + this.anf = source["anf"]; + this.anf_level = source["anf_level"]; + this.amp_available = source["amp_available"]; + this.amp_model = source["amp_model"]; + this.amp_operate = source["amp_operate"]; + this.amp_fault = source["amp_fault"]; + this.meters = this.convertValues(source["meters"], FlexMeter); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } export class RigState { enabled: boolean; connected: boolean; diff --git a/internal/cat/cat.go b/internal/cat/cat.go index 647ed2d..6121b6c 100644 --- a/internal/cat/cat.go +++ b/internal/cat/cat.go @@ -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 // operation's error or a "busy"/"not running" error if dispatch failed. func (m *Manager) exec(fn func(Backend) error) error { diff --git a/internal/cat/flex.go b/internal/cat/flex.go index f09ddaa..6ab51c8 100644 --- a/internal/cat/flex.go +++ b/internal/cat/flex.go @@ -4,6 +4,7 @@ package cat import ( "bufio" + "encoding/binary" "fmt" "math" "net" @@ -33,12 +34,27 @@ type Flex struct { gotHandle bool 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) + // 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 " 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 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 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 error names the command // 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 @@ -52,6 +68,54 @@ type flexSlice struct { active bool tx 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 triggered" notification, sent @@ -69,6 +133,8 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex { host: strings.TrimSpace(host), port: port, slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled, 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.gotHandle = false f.slices = map[int]*flexSlice{} + f.meterVal = map[int]float64{} + f.meterSub = map[int]bool{} f.mu.Unlock() debugLog.Printf("Flex: connected to %s:%d", host, port) go f.reader(conn) // Identify ourselves in SmartSDR's client list, then stream slice + transmit // (TX/split) status. Command names per the SmartSDR TCP/IP API docs. - f.send("client program=OpsLog") - f.send("sub slice all") - f.send("sub transmit all") - f.send("sub radio all") + f.send("client program OpsLog") + f.send("sub slice all") // slice/receiver: freq, mode, AGC, NB/NR/ANF, audio… + f.send("sub tx all") // transmit: rfpower, tunepower, vox, processor, mon, mic + 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 { // Subscribe so the radio pushes existing spots (we learn their indices), // 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() { f.mu.Lock() c := f.conn + uc := f.udpConn f.conn = nil + f.udpConn = nil f.gotHandle = false f.mu.Unlock() + if uc != nil { + _ = uc.Close() // unblocks udpReader + } if c != nil { _ = c.Close() debugLog.Printf("Flex: disconnected") @@ -136,6 +212,9 @@ func (f *Flex) send(cmd string) int { c := f.conn f.seq++ seq := f.seq + if f.sentCmds != nil { + f.sentCmds[seq] = cmd + } f.mu.Unlock() if c == nil { return 0 @@ -196,8 +275,12 @@ func (f *Flex) reader(conn net.Conn) { } seq, _ := strconv.Atoi(parts[0]) ok := parts[1] == "0" || parts[1] == "00000000" + f.mu.Lock() + cmdText := f.sentCmds[seq] + delete(f.sentCmds, seq) + f.mu.Unlock() 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; // 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 !f.txRawLogged { + f.txRawLogged = true + debugLog.Printf("Flex: FIRST transmit 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 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 .src=… .nam=… .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: + // ".src=…#.num=…#.nam=…#.low=…#.hi=…#.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 …". Track the index so we can clear the // 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" case "in_use": 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() @@ -547,6 +830,494 @@ func (f *Flex) SetPTT(on bool) error { 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 =" 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. func flexModeToADIF(m string) string { switch strings.ToUpper(strings.TrimSpace(m)) {