diff --git a/app_tci_panel.go b/app_tci_panel.go new file mode 100644 index 0000000..7ea7f66 --- /dev/null +++ b/app_tci_panel.go @@ -0,0 +1,118 @@ +package main + +// The TCI control console — bindings. +// +// Thin on purpose: the state is a snapshot the radio pushed and the setters are +// one command each. Everything interesting is in internal/cat/tci_panel.go. + +import ( + "fmt" + + "hamlog/internal/cat" +) + +// GetTCIPanel returns the console state. Connected=false when the active CAT +// backend is not a TCI radio, so the frontend has one thing to look at rather +// than an error to distinguish from a disconnected radio. +func (a *App) GetTCIPanel() cat.TCIPanelState { + if a.cat == nil { + return cat.TCIPanelState{} + } + st, _ := a.cat.TCIPanelState() + return st +} + +// tciDo is the shape every setter below takes. +func (a *App) tciDo(fn func(cat.TCIPanelController) error) error { + if a.cat == nil { + return fmt.Errorf("CAT not initialized") + } + return a.cat.TCIPanelDo(fn) +} + +// SetTCIDrive sets the transmit drive (0-100). +func (a *App) SetTCIDrive(v int) error { + return a.tciDo(func(t cat.TCIPanelController) error { return t.SetDrive(v) }) +} + +// SetTCITuneDrive sets the drive TUNE uses (0-100). +func (a *App) SetTCITuneDrive(v int) error { + return a.tciDo(func(t cat.TCIPanelController) error { return t.SetTuneDrive(v) }) +} + +// SetTCIMicLevel sets the microphone gain (0-100). +func (a *App) SetTCIMicLevel(v int) error { + return a.tciDo(func(t cat.TCIPanelController) error { return t.SetMicLevel(v) }) +} + +// SetTCIVolume sets the receive volume in dB (0 down to -60). +func (a *App) SetTCIVolume(db int) error { + return a.tciDo(func(t cat.TCIPanelController) error { return t.SetVolume(db) }) +} + +// SetTCIMute mutes or unmutes the receiver. +func (a *App) SetTCIMute(on bool) error { + return a.tciDo(func(t cat.TCIPanelController) error { return t.SetMute(on) }) +} + +// SetTCIAGC picks the AGC speed: off, long, slow, med, fast. +func (a *App) SetTCIAGC(mode string) error { + return a.tciDo(func(t cat.TCIPanelController) error { return t.SetAGC(mode) }) +} + +// SetTCISquelch turns the squelch on or off. +func (a *App) SetTCISquelch(on bool) error { + return a.tciDo(func(t cat.TCIPanelController) error { return t.SetSquelch(on) }) +} + +// SetTCISquelchLevel sets the squelch threshold in dBm. +func (a *App) SetTCISquelchLevel(v int) error { + return a.tciDo(func(t cat.TCIPanelController) error { return t.SetSquelchLevel(v) }) +} + +// SetTCINB, SetTCINR, SetTCIANF and SetTCIAPF switch the receive processing. +func (a *App) SetTCINB(on bool) error { + return a.tciDo(func(t cat.TCIPanelController) error { return t.SetNB(on) }) +} +func (a *App) SetTCINR(on bool) error { + return a.tciDo(func(t cat.TCIPanelController) error { return t.SetNR(on) }) +} +func (a *App) SetTCIANF(on bool) error { + return a.tciDo(func(t cat.TCIPanelController) error { return t.SetANF(on) }) +} +func (a *App) SetTCIAPF(on bool) error { + return a.tciDo(func(t cat.TCIPanelController) error { return t.SetAPF(on) }) +} + +// SetTCIFilter sets the passband edges in Hz. +func (a *App) SetTCIFilter(lo, hi int) error { + return a.tciDo(func(t cat.TCIPanelController) error { return t.SetFilter(lo, hi) }) +} + +// SetTCIRIT / SetTCIXIT switch the offsets on; the Offset calls move them. +func (a *App) SetTCIRIT(on bool) error { + return a.tciDo(func(t cat.TCIPanelController) error { return t.SetRIT(on) }) +} +func (a *App) SetTCIXIT(on bool) error { + return a.tciDo(func(t cat.TCIPanelController) error { return t.SetXIT(on) }) +} +func (a *App) SetTCIRITOffset(hz int) error { + return a.tciDo(func(t cat.TCIPanelController) error { return t.SetRITOffset(hz) }) +} +func (a *App) SetTCIXITOffset(hz int) error { + return a.tciDo(func(t cat.TCIPanelController) error { return t.SetXITOffset(hz) }) +} + +// SetTCILock locks the radio's VFO knob. +func (a *App) SetTCILock(on bool) error { + return a.tciDo(func(t cat.TCIPanelController) error { return t.SetLock(on) }) +} + +// SetTCITune starts or stops the tune carrier. +// +// IT TRANSMITS, and at tune_drive rather than at drive — which is why the panel +// shows those two numbers next to the button rather than hiding one of them in +// a menu. +func (a *App) SetTCITune(on bool) error { + return a.tciDo(func(t cat.TCIPanelController) error { return t.SetTune(on) }) +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bad6168..6b03aad 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -78,6 +78,7 @@ import { WorldMap, LocatorMap } from '@/components/MainMap'; import { FlexPanel } from '@/components/FlexPanel'; import { IcomPanel } from '@/components/IcomPanel'; import { YaesuPanel } from '@/components/YaesuPanel'; +import { TCIPanel } from '@/components/TCIPanel'; import { ElecraftPanel } from '@/components/ElecraftPanel'; import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel'; import { MotorAntennaWidget, type AntStatus } from '@/components/MotorAntennaWidget'; @@ -1920,7 +1921,7 @@ export default function App() { // so it's loaded async on mount and re-read on profile:changed below. // 'none' is only ever stored for the third and fourth panes: the first two are // the Main view, and a layout with no panes at all is not a layout. - type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'elecraft' | 'netcontrol' | 'decodes' | 'none'; + type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'elecraft' | 'tci' | 'netcontrol' | 'decodes' | 'none'; const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now const [mainPaneLeft, setMainPaneLeft] = useState('map1'); const [mainPaneRight, setMainPaneRight] = useState('map2'); @@ -1931,7 +1932,7 @@ export default function App() { // quarter-width map is unreadable. const [mainLayout4, setMainLayout4] = useState<'cols' | 'quad'>('quad'); const loadMainPanes = useCallback(async () => { - const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'yaesu' || v === 'elecraft' || v === 'netcontrol' || v === 'decodes'; + const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'yaesu' || v === 'elecraft' || v === 'tci' || v === 'netcontrol' || v === 'decodes'; const [l, r, p3, p4, lay] = await Promise.all([ GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => ''), @@ -6174,6 +6175,12 @@ export default function App() { { setRstSent(r); rstUserEditedRef.current = true; }} /> ); + case 'tci': + return ( +
+ { setRstSent(r); rstUserEditedRef.current = true; }} /> +
+ ); case 'icom': return (
@@ -7452,6 +7459,7 @@ export default function App() { {catState.backend === 'icom' && Icom Console} {catState.backend === 'yaesu' && Yaesu Console} {(catState.backend === 'elecraft' || catState.backend === 'kenwood') && {t('k3.console')}} + {catState.backend === 'tci' && {t('tcip.console')}} {statsTabOpen && ( {t('stats.tab')} @@ -8140,6 +8148,15 @@ export default function App() { )} + {/* The SunSDR console. Everything on it is state the radio pushes + over TCI unasked, so unlike the serial consoles it costs nothing + to keep open. */} + {catState.backend === 'tci' && ( + + { setRstSent(r); rstUserEditedRef.current = true; }} /> + + )} + {catState.backend === 'icom' && ( { setRstSent(r); rstUserEditedRef.current = true; }} /> @@ -8538,6 +8555,8 @@ export default function App() { flexAvailable={catState.backend === 'flex'} icomAvailable={catState.backend === 'icom'} yaesuAvailable={catState.backend === 'yaesu'} + elecraftAvailable={catState.backend === 'elecraft' || catState.backend === 'kenwood'} + tciAvailable={catState.backend === 'tci'} /> )} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index ac19417..8552475 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -175,6 +175,8 @@ interface Props { flexAvailable?: boolean; // CAT backend is FlexRadio → offer it as a Main pane icomAvailable?: boolean; // CAT backend is Icom → offer the Icom console as a Main pane yaesuAvailable?: boolean; // CAT backend is Yaesu → offer the Yaesu console as a Main pane + elecraftAvailable?: boolean; // CAT backend is Elecraft/Kenwood → the K3/K4 console + tciAvailable?: boolean; // CAT backend is TCI → the SunSDR console // Opens a QSO in the editor. Settings is not where a log is edited — but the // RDA comparison lists contacts whose district is in dispute, and a list of // things to fix that cannot be acted on is a list to write down and look up @@ -1085,7 +1087,7 @@ function RelayAutoPanel() { // profile-prefixed). Self-contained so it owns its async-loaded state. const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol', 'decodes']; const PANE_NONE = 'none'; -function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable }: { onChanged?: (side: 'left' | 'right' | 'p3' | 'p4' | 'layout', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean; yaesuAvailable?: boolean }) { +function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable, elecraftAvailable, tciAvailable }: { onChanged?: (side: 'left' | 'right' | 'p3' | 'p4' | 'layout', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean; yaesuAvailable?: boolean; elecraftAvailable?: boolean; tciAvailable?: boolean }) { const { t } = useI18n(); const [panes, setPanes] = useState>({ left: 'map1', right: 'map2', p3: PANE_NONE, p4: PANE_NONE }); const [layout, setLayout] = useState('quad'); @@ -1095,11 +1097,16 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable ...(flexAvailable ? ['flex'] : []), ...(icomAvailable ? ['icom'] : []), ...(yaesuAvailable ? ['yaesu'] : []), + // The Elecraft console could be docked from the start — App has always had + // the pane — but it was never offered here, so the only way to reach it was + // the tab. Listed with the others now. + ...(elecraftAvailable ? ['elecraft'] : []), + ...(tciAvailable ? ['tci'] : []), ].map((value) => ({ value, label: t(`settings.pane.${value}`) })) .sort((a, b) => a.label.localeCompare(b.label)); const KEYS: Record = { left: 'mainPaneLeft', right: 'mainPaneRight', p3: 'mainPane3', p4: 'mainPane4' }; useEffect(() => { - const valid = (v: string) => v === 'flex' || v === 'icom' || v === 'yaesu' || MAIN_PANE_VALUES.includes(v); + const valid = (v: string) => v === 'flex' || v === 'icom' || v === 'yaesu' || v === 'elecraft' || v === 'tci' || MAIN_PANE_VALUES.includes(v); Promise.all([ ...Object.values(KEYS).map((k) => GetUIPref(k).catch(() => '')), GetUIPref('mainPaneLayout').catch(() => ''), @@ -1553,7 +1560,7 @@ function brandOfBackend(backend: string, kenwoodLink?: string): { brand: string; // memo() cuts that off. It only works if the props hold still, which is why // App passes callbacks that do not change identity on every render — see the // useCallback wrappers there. -function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable, yaesuAvailable, onEditQSO }: Props) { +function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable, yaesuAvailable, elecraftAvailable, tciAvailable, onEditQSO }: Props) { const { t } = useI18n(); const [selected, setSelected] = useState((initialSection as SectionId) || 'station'); const [loading, setLoading] = useState(true); @@ -7115,7 +7122,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged - +

{t('gen.pwEnc')}

diff --git a/frontend/src/components/TCIPanel.tsx b/frontend/src/components/TCIPanel.tsx new file mode 100644 index 0000000..065eeef --- /dev/null +++ b/frontend/src/components/TCIPanel.tsx @@ -0,0 +1,314 @@ +import { useEffect, useRef, useState } from 'react'; +import { Radio, Activity, AudioLines, SlidersHorizontal, Mic } from 'lucide-react'; +import { + GetTCIPanel, GetCATState, + SetTCIDrive, SetTCITuneDrive, SetTCIMicLevel, SetTCIVolume, SetTCIMute, + SetTCIAGC, SetTCISquelch, SetTCISquelchLevel, + SetTCINB, SetTCINR, SetTCIANF, SetTCIAPF, SetTCIFilter, + SetTCIRIT, SetTCIXIT, SetTCIRITOffset, SetTCIXITOffset, SetTCILock, SetTCITune, +} from '../../wailsjs/go/main/App'; +import { cn } from '@/lib/utils'; +import { useI18n } from '@/lib/i18n'; +import { sMeterRST } from '@/lib/rst'; +import { MeterBar } from '@/components/MeterBar'; +import { WheelRange } from '@/components/WheelRange'; + +type TCIState = { + connected: boolean; device?: string; protocol?: string; + drive: number; tune_drive: number; mic_level: number; tx_enabled: boolean; tx: boolean; tuning: boolean; + volume: number; mute: boolean; agc?: string; squelch_on: boolean; squelch: number; + nb: boolean; nr: boolean; anf: boolean; apf: boolean; + filter_lo: number; filter_hi: number; + rit: boolean; rit_offset: number; xit: boolean; xit_offset: number; lock: boolean; split: boolean; + smeter: number; modulations?: string[]; +}; + +const ZERO: TCIState = { + connected: false, drive: 0, tune_drive: 0, mic_level: 0, tx_enabled: false, tx: false, tuning: false, + volume: 0, mute: false, squelch_on: false, squelch: 0, + nb: false, nr: false, anf: false, apf: false, filter_lo: 0, filter_hi: 0, + rit: false, rit_offset: 0, xit: false, xit_offset: 0, lock: false, split: false, smeter: 0, +}; + +// Passbands worth a button, as edges relative to the carrier. TCI takes the two +// edges rather than a width, which is more than a console needs: an operator +// picks "CW" or "SSB", not a pair of numbers. +const FILTERS: { label: string; lo: number; hi: number }[] = [ + { label: '250', lo: 300, hi: 550 }, + { label: '500', lo: 300, hi: 800 }, + { label: '1.0k', lo: 200, hi: 1200 }, + { label: '1.8k', lo: 100, hi: 1900 }, + { label: '2.4k', lo: 100, hi: 2500 }, + { label: '2.8k', lo: 100, hi: 2900 }, + { label: '3.5k', lo: 100, hi: 3600 }, +]; + +// dBm → S units. TCI reports a real signal level rather than a meter position, +// which is the useful way round: S9 is -73 dBm by the IARU definition and every +// S unit below it is 6 dB, so this is arithmetic rather than a calibration +// table — nothing here is provisional the way the K3's meter reading is. +function sParts(dbm: number): { s: number; over: number; label: string } { + if (dbm === 0) return { s: 0, over: 0, label: '—' }; + if (dbm >= -73) { + const over = Math.round((dbm + 73) / 10) * 10; + return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' }; + } + const s = Math.max(0, Math.min(9, Math.round(9 + (dbm + 73) / 6))); + return { s, over: 0, label: `S${s}` }; +} + +// The meter bar wants 0-100; -127 dBm is the bottom of the scale and -13 dBm +// (S9+60) the top. +function sBar(dbm: number): number { + if (dbm === 0) return 0; + return Math.max(0, Math.min(100, ((dbm + 127) / 114) * 100)); +} + +function sSegColor(frac: number): string { + return frac > 0.75 ? '#dc2626' : frac > 0.55 ? '#f59e0b' : '#16a34a'; +} + +function Card({ icon: Icon, title, children }: { icon: any; title: string; children: React.ReactNode }) { + return ( +
+
+ + {title} +
+
{children}
+
+ ); +} + +// One button shape for every on/off control, as on the other consoles. +function Toggle({ label, on, off, onClick, title }: { + label: string; on: boolean; off: boolean; onClick: () => void; title?: string; +}) { + return ( + + ); +} + +function Row({ label, value, children }: { label: string; value: string; children: React.ReactNode }) { + return ( +
+
+ {label} + {value} +
+ {children} +
+ ); +} + +export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void } = {}) { + const { t } = useI18n(); + const [st, setSt] = useState(ZERO); + const [freqHz, setFreqHz] = useState(0); + const [mode, setMode] = useState(''); + const [err, setErr] = useState(''); + + // A slider the operator is dragging must not be dragged back by the poll. + // The radio confirms every change by announcing it, but that answer takes a + // round trip — long enough for a drag to stutter against its own echo. + const holdRef = useRef>({}); + const hold = (key: string, reported: number) => { + const h = holdRef.current[key]; + return h && Date.now() < h.until ? h.v : reported; + }; + const setHold = (key: string, v: number) => { + holdRef.current[key] = { v, until: Date.now() + 900 }; + }; + + useEffect(() => { + let alive = true; + const tick = async () => { + try { + const p: any = await GetTCIPanel(); + if (!alive) return; + setSt(p as TCIState); + const cs: any = await GetCATState(); + if (!alive) return; + setFreqHz(Number(cs?.rx_freq_hz) || Number(cs?.freq_hz) || 0); + setMode(String(cs?.mode || '')); + } catch (e: any) { + if (alive) setErr(String(e?.message ?? e)); + } + }; + tick(); + const id = window.setInterval(tick, 400); + return () => { alive = false; window.clearInterval(id); }; + }, []); + + const off = !st.connected; + const call = (fn: () => Promise) => { fn().catch((e: any) => setErr(String(e?.message ?? e))); }; + + const drive = hold('drive', st.drive); + const tuneDrive = hold('tune_drive', st.tune_drive); + const mic = hold('mic', st.mic_level); + const vol = hold('vol', st.volume); + const sql = hold('sql', st.squelch); + const s = sParts(st.smeter); + + return ( +
+ {/* Capped and centred, like the Elecraft, Yaesu, Icom and Flex consoles. + Stretched across a wide window a console puts each slider a hand's + width from its own label and stops reading as one instrument. */} +
+ {/* VFO + identity */} +
+
+
+ + {st.device || 'SunSDR'} {st.protocol ? `· ${st.protocol}` : ''} + +
+
+ {freqHz > 0 ? (freqHz / 1e6).toFixed(6) : '—'} +
+
+
+ {st.tx && TX} + {st.split && SPLIT} + call(() => SetTCILock(!st.lock))} /> +
+
+ + {off &&
{t('tcip.waiting')}
} + {!!err &&
{err}
} + + {/* Meters — one for now: TCI reports the receive level and does not + publish a transmit power reading, so a PWR bar here would be an + empty promise. */} + + { + if (st.tx || !onReportRST) return; + onReportRST(sMeterRST(s.s, s.over, mode)); + }} + title={t('tcip.sMeterHint')} /> + + + {/* Transmit */} + +
+ + { setHold('drive', v); call(() => SetTCIDrive(v)); }} /> + + + { setHold('tune_drive', v); call(() => SetTCITuneDrive(v)); }} /> + +
+
+ {/* TUNE transmits, and at the tune drive rather than the main one — + which is why both numbers are above the button rather than one of + them being in a menu somewhere. */} + + {!st.tx_enabled && !off && ( + {t('tcip.txDisabled')} + )} +
+ + { setHold('mic', v); call(() => SetTCIMicLevel(v)); }} /> + +
+ + {/* Receive */} + +
+ {/* TCI's volume is dB and NEGATIVE — 0 is full, -60 inaudible. Shown + as the radio's own number rather than converted to a percentage, + so it matches the figure in ExpertSDR3's window. */} + + { setHold('vol', v); call(() => SetTCIVolume(v)); }} /> + + + { setHold('sql', v); call(() => SetTCISquelchLevel(v)); }} /> + +
+
+ call(() => SetTCINB(!st.nb))} /> + call(() => SetTCINR(!st.nr))} /> + call(() => SetTCIANF(!st.anf))} /> + call(() => SetTCIAPF(!st.apf))} /> + call(() => SetTCISquelch(!st.squelch_on))} /> + call(() => SetTCIMute(!st.mute))} /> +
+
+ {t('tcip.agc')} +
+ {['off', 'long', 'slow', 'med', 'fast'].map((m) => ( + call(() => SetTCIAGC(m))} /> + ))} +
+
+
+
+ {t('tcip.filter')} + {st.filter_lo}–{st.filter_hi} Hz +
+
+ {FILTERS.map((f) => ( + call(() => SetTCIFilter(f.lo, f.hi))} /> + ))} +
+
+
+ + {/* RIT / XIT */} + +
+ {([ + { key: 'rit', on: st.rit, offset: st.rit_offset, toggle: SetTCIRIT, set: SetTCIRITOffset }, + { key: 'xit', on: st.xit, offset: st.xit_offset, toggle: SetTCIXIT, set: SetTCIXITOffset }, + ] as const).map((r) => ( +
+ call(() => r.toggle(!r.on))} /> + + {r.offset > 0 ? `+${r.offset}` : r.offset} Hz + + {/* ±10 and ±100, and a zero. The radio's own knob does the rest; + a console that tries to replace it needs a knob, not more + buttons. */} + {[-100, -10, 10, 100].map((d) => ( + + ))} + +
+ ))} +
+
+
+
+ ); +} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 307d330..5e973ab 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -109,7 +109,7 @@ const en: Dict = { 'settings.pane.map1': 'Map — great-circle + beam', 'settings.pane.map2': 'Map — locator (street)', 'settings.pane.cluster': 'Cluster spots', 'settings.pane.worked': 'Worked before', 'settings.pane.recent': 'Recent QSOs', 'settings.pane.netcontrol': 'Net control', 'settings.pane.decodes': 'FT decodes', - 'settings.pane.flex': 'Flex Console', 'settings.pane.icom': 'Icom Console', 'settings.pane.yaesu': 'Yaesu Console', 'yaesu.notConnected': 'Yaesu CAT not connected', 'yaesu.meters': 'Meters', 'yaesu.bandMode': 'Band & mode', 'yaesu.receive': 'Receive', 'yaesu.noiseFilter': 'Noise & filter', 'yaesu.transmit': 'Transmit', 'yaesu.refresh': 'Refresh', 'yaesu.narrowHint': 'Narrow IF filter (NAR) — tightens the receive bandwidth. Only shown when the radio answers the command.', 'yaesu.tuneHint': 'Start an antenna-tuner cycle', 'yaesu.sToRst': 'Click to fill the RST sent', 'yaesu.sidebandHint': 'Click to select this mode; click again to switch sideband (U/L)', 'yaesu.splitUpHint': 'Transmit this far above the receive frequency, and turn split on', 'yaesu.txOn': 'TX', 'yaesu.cw': 'CW', 'yaesu.breakInHint': 'Break-in: the rig switches to receive between characters', 'yaesu.zinHint': 'Zero-in: retune so the station you hear lands on your CW pitch', + 'settings.pane.flex': 'Flex Console', 'tcip.console': "SunSDR Console", 'settings.pane.tci': "SunSDR Console", 'settings.pane.elecraft': "Elecraft Console", 'tcip.waiting': "Waiting for the radio — TCI is not connected.", 'tcip.meters': "Meters", 'tcip.transmit': "Transmit", 'tcip.receive': "Receive", 'tcip.drive': "Drive", 'tcip.tuneDrive': "Tune drive", 'tcip.mic': "Mic gain", 'tcip.volume': "Volume", 'tcip.squelch': "Squelch", 'tcip.agc': "AGC", 'tcip.filter': "Filter", 'tcip.mute': "MUTE", 'tcip.lock': "LOCK", 'tcip.off': "off", 'tcip.tune': "TUNE", 'tcip.tuning': "TUNING", 'tcip.txDisabled': "The radio is not allowing transmit", 'tcip.sMeterHint': "Click to put this report in the entry form. The radio sends a real signal level in dBm, so the S units are arithmetic rather than a calibration guess.", 'settings.pane.icom': 'Icom Console', 'settings.pane.yaesu': 'Yaesu Console', 'yaesu.notConnected': 'Yaesu CAT not connected', 'yaesu.meters': 'Meters', 'yaesu.bandMode': 'Band & mode', 'yaesu.receive': 'Receive', 'yaesu.noiseFilter': 'Noise & filter', 'yaesu.transmit': 'Transmit', 'yaesu.refresh': 'Refresh', 'yaesu.narrowHint': 'Narrow IF filter (NAR) — tightens the receive bandwidth. Only shown when the radio answers the command.', 'yaesu.tuneHint': 'Start an antenna-tuner cycle', 'yaesu.sToRst': 'Click to fill the RST sent', 'yaesu.sidebandHint': 'Click to select this mode; click again to switch sideband (U/L)', 'yaesu.splitUpHint': 'Transmit this far above the receive frequency, and turn split on', 'yaesu.txOn': 'TX', 'yaesu.cw': 'CW', 'yaesu.breakInHint': 'Break-in: the rig switches to receive between characters', 'yaesu.zinHint': 'Zero-in: retune so the station you hear lands on your CW pitch', 'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.light-cool': 'Cool light', 'theme.light-sage': 'Sage light', 'theme.light-nordic': 'Nordic light', 'theme.sahara': 'Sahara', 'theme.dim-slate': 'Dim slate', 'theme.dark-warm': 'Warm dark', 'theme.dark-graphite': 'Graphite dark', 'theme.dark-indigo': 'Indigo', 'theme.dark-teal': 'Ocean', 'theme.dark-plum': 'Plum', 'theme.high-contrast': 'High contrast', @@ -596,7 +596,7 @@ const fr: Dict = { 'settings.pane.map1': 'Carte — orthodromie + faisceau', 'settings.pane.map2': 'Carte — locator (rue)', 'settings.pane.cluster': 'Spots cluster', 'settings.pane.worked': 'Déjà contactés', 'settings.pane.recent': 'QSO récents', 'settings.pane.netcontrol': 'Gestion de net', 'settings.pane.decodes': 'Decodes FT', - 'settings.pane.flex': 'Flex Console', 'settings.pane.icom': 'Icom Console', 'settings.pane.yaesu': 'Yaesu Console', 'yaesu.notConnected': 'CAT Yaesu non connecté', 'yaesu.meters': 'Mesures', 'yaesu.bandMode': 'Bande et mode', 'yaesu.receive': 'Réception', 'yaesu.noiseFilter': 'Bruit et filtre', 'yaesu.transmit': 'Émission', 'yaesu.refresh': 'Actualiser', 'yaesu.narrowHint': "Filtre FI étroit (NAR) — resserre la bande passante de réception. N'apparaît que si la radio répond à la commande.", 'yaesu.tuneHint': "Lancer un cycle d'accord d'antenne", 'yaesu.sToRst': 'Cliquer pour remplir le RST envoyé', 'yaesu.sidebandHint': 'Cliquer pour choisir ce mode ; recliquer pour changer de bande latérale (U/L)', 'yaesu.splitUpHint': "Émettre à cette distance au-dessus de la fréquence de réception, et activer le split", 'yaesu.txOn': 'TX', 'yaesu.cw': 'CW', 'yaesu.breakInHint': "Break-in : la radio repasse en réception entre les caractères", 'yaesu.zinHint': "Zéro-in : réaccorde pour que la station entendue tombe sur votre note CW", + 'settings.pane.flex': 'Flex Console', 'tcip.console': "Console SunSDR", 'settings.pane.tci': "Console SunSDR", 'settings.pane.elecraft': "Console Elecraft", 'tcip.waiting': "En attente de la radio — TCI n'est pas connecté.", 'tcip.meters': "Mesures", 'tcip.transmit': "Émission", 'tcip.receive': "Réception", 'tcip.drive': "Puissance", 'tcip.tuneDrive': "Puissance d'accord", 'tcip.mic': "Gain micro", 'tcip.volume': "Volume", 'tcip.squelch': "Squelch", 'tcip.agc': "AGC", 'tcip.filter': "Filtre", 'tcip.mute': "MUET", 'tcip.lock': "VERR", 'tcip.off': "désactivé", 'tcip.tune': "ACCORD", 'tcip.tuning': "EN ACCORD", 'tcip.txDisabled': "La radio n'autorise pas l'émission", 'tcip.sMeterHint': "Cliquer pour reporter dans la saisie. La radio envoie un vrai niveau en dBm, donc les points S sont un calcul et non une estimation d'étalonnage.", 'settings.pane.icom': 'Icom Console', 'settings.pane.yaesu': 'Yaesu Console', 'yaesu.notConnected': 'CAT Yaesu non connecté', 'yaesu.meters': 'Mesures', 'yaesu.bandMode': 'Bande et mode', 'yaesu.receive': 'Réception', 'yaesu.noiseFilter': 'Bruit et filtre', 'yaesu.transmit': 'Émission', 'yaesu.refresh': 'Actualiser', 'yaesu.narrowHint': "Filtre FI étroit (NAR) — resserre la bande passante de réception. N'apparaît que si la radio répond à la commande.", 'yaesu.tuneHint': "Lancer un cycle d'accord d'antenne", 'yaesu.sToRst': 'Cliquer pour remplir le RST envoyé', 'yaesu.sidebandHint': 'Cliquer pour choisir ce mode ; recliquer pour changer de bande latérale (U/L)', 'yaesu.splitUpHint': "Émettre à cette distance au-dessus de la fréquence de réception, et activer le split", 'yaesu.txOn': 'TX', 'yaesu.cw': 'CW', 'yaesu.breakInHint': "Break-in : la radio repasse en réception entre les caractères", 'yaesu.zinHint': "Zéro-in : réaccorde pour que la station entendue tombe sur votre note CW", 'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.light-cool': 'Clair froid', 'theme.light-sage': 'Clair sauge', 'theme.light-nordic': 'Clair nordique', 'theme.sahara': 'Sahara', 'theme.dim-slate': 'Ardoise tamisé', 'theme.dark-warm': 'Sombre chaud', 'theme.dark-graphite': 'Sombre graphite', 'theme.dark-indigo': 'Indigo', 'theme.dark-teal': 'Océan', 'theme.dark-plum': 'Prune', 'theme.high-contrast': 'Contraste élevé', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 53270d1..002e12c 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -577,6 +577,8 @@ export function GetStationSettings():Promise; export function GetStationStatus():Promise>; +export function GetTCIPanel():Promise; + export function GetTelemetryEnabled():Promise; export function GetTrackedAwards():Promise>; @@ -1159,6 +1161,44 @@ export function SetSpotMax(arg1:number):Promise; export function SetSpotTTLMinutes(arg1:number):Promise; +export function SetTCIAGC(arg1:string):Promise; + +export function SetTCIANF(arg1:boolean):Promise; + +export function SetTCIAPF(arg1:boolean):Promise; + +export function SetTCIDrive(arg1:number):Promise; + +export function SetTCIFilter(arg1:number,arg2:number):Promise; + +export function SetTCILock(arg1:boolean):Promise; + +export function SetTCIMicLevel(arg1:number):Promise; + +export function SetTCIMute(arg1:boolean):Promise; + +export function SetTCINB(arg1:boolean):Promise; + +export function SetTCINR(arg1:boolean):Promise; + +export function SetTCIRIT(arg1:boolean):Promise; + +export function SetTCIRITOffset(arg1:number):Promise; + +export function SetTCISquelch(arg1:boolean):Promise; + +export function SetTCISquelchLevel(arg1:number):Promise; + +export function SetTCITune(arg1:boolean):Promise; + +export function SetTCITuneDrive(arg1:number):Promise; + +export function SetTCIVolume(arg1:number):Promise; + +export function SetTCIXIT(arg1:boolean):Promise; + +export function SetTCIXITOffset(arg1:number):Promise; + export function SetTelemetryEnabled(arg1:boolean):Promise; export function SetUIPref(arg1:string,arg2:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index e59afe8..d174a27 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -1094,6 +1094,10 @@ export function GetStationStatus() { return window['go']['main']['App']['GetStationStatus'](); } +export function GetTCIPanel() { + return window['go']['main']['App']['GetTCIPanel'](); +} + export function GetTelemetryEnabled() { return window['go']['main']['App']['GetTelemetryEnabled'](); } @@ -2258,6 +2262,82 @@ export function SetSpotTTLMinutes(arg1) { return window['go']['main']['App']['SetSpotTTLMinutes'](arg1); } +export function SetTCIAGC(arg1) { + return window['go']['main']['App']['SetTCIAGC'](arg1); +} + +export function SetTCIANF(arg1) { + return window['go']['main']['App']['SetTCIANF'](arg1); +} + +export function SetTCIAPF(arg1) { + return window['go']['main']['App']['SetTCIAPF'](arg1); +} + +export function SetTCIDrive(arg1) { + return window['go']['main']['App']['SetTCIDrive'](arg1); +} + +export function SetTCIFilter(arg1, arg2) { + return window['go']['main']['App']['SetTCIFilter'](arg1, arg2); +} + +export function SetTCILock(arg1) { + return window['go']['main']['App']['SetTCILock'](arg1); +} + +export function SetTCIMicLevel(arg1) { + return window['go']['main']['App']['SetTCIMicLevel'](arg1); +} + +export function SetTCIMute(arg1) { + return window['go']['main']['App']['SetTCIMute'](arg1); +} + +export function SetTCINB(arg1) { + return window['go']['main']['App']['SetTCINB'](arg1); +} + +export function SetTCINR(arg1) { + return window['go']['main']['App']['SetTCINR'](arg1); +} + +export function SetTCIRIT(arg1) { + return window['go']['main']['App']['SetTCIRIT'](arg1); +} + +export function SetTCIRITOffset(arg1) { + return window['go']['main']['App']['SetTCIRITOffset'](arg1); +} + +export function SetTCISquelch(arg1) { + return window['go']['main']['App']['SetTCISquelch'](arg1); +} + +export function SetTCISquelchLevel(arg1) { + return window['go']['main']['App']['SetTCISquelchLevel'](arg1); +} + +export function SetTCITune(arg1) { + return window['go']['main']['App']['SetTCITune'](arg1); +} + +export function SetTCITuneDrive(arg1) { + return window['go']['main']['App']['SetTCITuneDrive'](arg1); +} + +export function SetTCIVolume(arg1) { + return window['go']['main']['App']['SetTCIVolume'](arg1); +} + +export function SetTCIXIT(arg1) { + return window['go']['main']['App']['SetTCIXIT'](arg1); +} + +export function SetTCIXITOffset(arg1) { + return window['go']['main']['App']['SetTCIXITOffset'](arg1); +} + export function SetTelemetryEnabled(arg1) { return window['go']['main']['App']['SetTelemetryEnabled'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 5c162da..1a113e8 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1210,6 +1210,72 @@ export namespace cat { this.fixed = source["fixed"]; } } + export class TCIPanelState { + connected: boolean; + device?: string; + protocol?: string; + drive: number; + tune_drive: number; + mic_level: number; + tx_enabled: boolean; + tx: boolean; + tuning: boolean; + volume: number; + mute: boolean; + agc?: string; + squelch_on: boolean; + squelch: number; + nb: boolean; + nr: boolean; + anf: boolean; + apf: boolean; + filter_lo: number; + filter_hi: number; + rit: boolean; + rit_offset: number; + xit: boolean; + xit_offset: number; + lock: boolean; + split: boolean; + smeter: number; + modulations?: string[]; + + static createFrom(source: any = {}) { + return new TCIPanelState(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.connected = source["connected"]; + this.device = source["device"]; + this.protocol = source["protocol"]; + this.drive = source["drive"]; + this.tune_drive = source["tune_drive"]; + this.mic_level = source["mic_level"]; + this.tx_enabled = source["tx_enabled"]; + this.tx = source["tx"]; + this.tuning = source["tuning"]; + this.volume = source["volume"]; + this.mute = source["mute"]; + this.agc = source["agc"]; + this.squelch_on = source["squelch_on"]; + this.squelch = source["squelch"]; + this.nb = source["nb"]; + this.nr = source["nr"]; + this.anf = source["anf"]; + this.apf = source["apf"]; + this.filter_lo = source["filter_lo"]; + this.filter_hi = source["filter_hi"]; + this.rit = source["rit"]; + this.rit_offset = source["rit_offset"]; + this.xit = source["xit"]; + this.xit_offset = source["xit_offset"]; + this.lock = source["lock"]; + this.split = source["split"]; + this.smeter = source["smeter"]; + this.modulations = source["modulations"]; + } + } export class YaesuTXState { available: boolean; model?: string; diff --git a/internal/cat/tci.go b/internal/cat/tci.go index b0b4351..24968e4 100644 --- a/internal/cat/tci.go +++ b/internal/cat/tci.go @@ -34,6 +34,10 @@ type TCI struct { OnSpotClick func(callsign string, freqHz int64) unhandledSeen map[string]bool // log each unknown TCI message type once + // panel is the control-console state — everything the radio announces about + // itself that is not frequency or mode. See tci_panel.go. + panel tciPanel + // audio holds the receive-audio stream — see tci_audio.go. TCI carries it // on this same WebSocket, which is what lets a SunSDR record and decode // without a virtual audio cable in the way. @@ -457,7 +461,20 @@ func (t *TCI) handle(msg string) { } t.mu.Lock() defer t.mu.Unlock() - switch strings.ToLower(name) { + lower := strings.ToLower(name) + // The console's own messages first. Most of them were being logged once as + // unhandled and thrown away — the radio has been announcing its drive, its + // filters and its noise blanker since the first connection. + if t.handlePanel(lower, get, args) { + // Still falls through for the few the rig state also needs (split, tune), + // which is why this does not return. + switch lower { + case "split_enable", "trx", "modulation", "vfo": + default: + return + } + } + switch lower { case "device": t.device = strings.TrimSpace(args) // The radio ANNOUNCES its audio format at connect — @@ -524,7 +541,7 @@ func (t *TCI) handle(msg string) { t.txAllowed, t.txAllowedKnown = allowed, true } default: - lname := strings.ToLower(name) + lname := lower // A click on one of our panorama spots comes back as // CLICKED_ON_SPOT:, (legacy) // RX_CLICKED_ON_SPOT:,,, diff --git a/internal/cat/tci_manager.go b/internal/cat/tci_manager.go index 2bf561c..7897fdb 100644 --- a/internal/cat/tci_manager.go +++ b/internal/cat/tci_manager.go @@ -37,3 +37,62 @@ func (m *Manager) TCIAudioDo(fn func(TCIAudioController) error) error { return fn(tc) }) } + +// TCIPanelController is the control console of a TCI radio — everything the +// panel reads and everything it sets. +// +// Listed one by one rather than accepted as *TCI, for the same reason the audio +// controller is: the manager hands out capabilities, not backends, and a +// station on OmniRig asking for the TCI console gets a sentence instead of a +// crash. +type TCIPanelController interface { + TCIPanel() TCIPanelState + SetDrive(v int) error + SetTuneDrive(v int) error + SetMicLevel(v int) error + SetVolume(db int) error + SetMute(on bool) error + SetAGC(mode string) error + SetSquelch(on bool) error + SetSquelchLevel(v int) error + SetNB(on bool) error + SetNR(on bool) error + SetANF(on bool) error + SetAPF(on bool) error + SetFilter(lo, hi int) error + SetRIT(on bool) error + SetXIT(on bool) error + SetRITOffset(hz int) error + SetXITOffset(hz int) error + SetLock(on bool) error + SetTune(on bool) error +} + +// TCIPanelState returns the console snapshot, or (zero, false) when the active +// backend is not a TCI radio. +// +// Read WITHOUT going through the CAT goroutine: the state is a cached copy of +// what the radio pushed, guarded by its own lock, and the panel polls it several +// times a second. Queueing that behind whatever the poll loop is doing would put +// the console's smoothness at the mercy of a rig command's timeout. +func (m *Manager) TCIPanelState() (TCIPanelState, bool) { + m.mu.RLock() + b := m.backend + m.mu.RUnlock() + if tc, ok := b.(TCIPanelController); ok { + return tc.TCIPanel(), true + } + return TCIPanelState{}, false +} + +// TCIPanelDo dispatches one console command onto the CAT goroutine, where every +// other write to the radio goes. +func (m *Manager) TCIPanelDo(fn func(TCIPanelController) error) error { + return m.exec(func(b Backend) error { + tc, ok := b.(TCIPanelController) + if !ok { + return fmt.Errorf("the active CAT backend is not a TCI radio") + } + return fn(tc) + }) +} diff --git a/internal/cat/tci_panel.go b/internal/cat/tci_panel.go new file mode 100644 index 0000000..959eeb7 --- /dev/null +++ b/internal/cat/tci_panel.go @@ -0,0 +1,308 @@ +//go:build windows + +package cat + +// The TCI control panel: what the radio already tells us, gathered up. +// +// This is the cheapest panel in OpsLog, and the reason is worth saying. A K3 is +// asked — every value on its console costs a command and a reply on a serial +// line, which is why that panel reads its settings in a rotation and its meters +// only while it is on screen. TCI PUSHES: the radio announces its drive, its +// volume, its filters, its noise blanker and everything else when a client +// connects, and again whenever any of them changes, whoever changed it. There +// is nothing to poll. +// +// So this file is mostly a place to PUT what was already arriving and being +// logged as "(unhandled once)". The setters are the same names sent back the +// other way, which is how TCI works throughout: one vocabulary, both directions. + +import ( + "fmt" + "strconv" + "strings" +) + +// TCIPanelState is the whole console in one snapshot, polled by the frontend. +// +// Values the radio has not mentioned keep their zero, which is why the +// "Known" flags exist for the ones where zero is a real setting: a squelch at 0 +// and a squelch never reported are different, and a panel that cannot tell them +// apart draws a control that lies until the operator touches it. +type TCIPanelState struct { + Connected bool `json:"connected"` + Device string `json:"device,omitempty"` // what the radio calls itself + Protocol string `json:"protocol,omitempty"` // "ExpertSDR3,1.5" + + // Transmit. + Drive int `json:"drive"` // 0-100 + TuneDrive int `json:"tune_drive"` // 0-100, used by TUNE + MicLevel int `json:"mic_level"` // 0-100 + TXEnabled bool `json:"tx_enabled"` // the radio's own permission (tx_enable) + TX bool `json:"tx"` + Tuning bool `json:"tuning"` + + // Receive. + Volume int `json:"volume"` // dB, negative — TCI's own scale + Mute bool `json:"mute"` + AGC string `json:"agc,omitempty"` // off/long/slow/med/fast + SquelchOn bool `json:"squelch_on"` + Squelch int `json:"squelch"` // dBm threshold + NB bool `json:"nb"` + NR bool `json:"nr"` + ANF bool `json:"anf"` + APF bool `json:"apf"` + + // Filter edges in Hz, relative to the carrier (TCI's own convention). + FilterLo int `json:"filter_lo"` + FilterHi int `json:"filter_hi"` + + // Tuning aids. + RIT bool `json:"rit"` + RITOffset int `json:"rit_offset"` + XIT bool `json:"xit"` + XITOffset int `json:"xit_offset"` + Lock bool `json:"lock"` + Split bool `json:"split"` + + // SMeter is the last reported signal level in dBm — the radio pushes it + // several times a second while receiving. + SMeter int `json:"smeter"` + + // Modulations is what this radio will accept, straight from its own + // announcement, so the mode buttons are the radio's and not a guess. + Modulations []string `json:"modulations,omitempty"` +} + +// tciPanel is the backing state. Guarded by TCI.mu with everything else it +// arrives alongside. +type tciPanel struct { + st TCIPanelState +} + +// handlePanel takes the messages the console cares about. +// +// Returns false when the message is none of its business, so the caller can go +// on to its own cases and to the unknown-message log. Called with t.mu held. +func (t *TCI) handlePanel(name string, get func(int) string, args string) bool { + // Most of these are per-receiver ("sql_level:0,20"), and OpsLog follows + // receiver 0 throughout. A message for another receiver is accepted as + // handled and dropped: it is understood, it is simply not ours. + forRX0 := func() bool { return get(0) == "0" || get(0) == "" } + num := func(s string) (int, bool) { + n, err := strconv.Atoi(strings.TrimSpace(s)) + return n, err == nil + } + yes := func(s string) bool { return strings.EqualFold(strings.TrimSpace(s), "true") } + + p := &t.panel.st + switch name { + case "protocol": + p.Protocol = strings.TrimSpace(args) + case "drive": + if n, ok := num(get(1)); ok && forRX0() { + p.Drive = n + } else if n, ok := num(get(0)); ok && get(1) == "" { + // Some firmware sends "drive:85" with no receiver index. + p.Drive = n + } + case "tune_drive": + if n, ok := num(get(1)); ok && forRX0() { + p.TuneDrive = n + } else if n, ok := num(get(0)); ok && get(1) == "" { + p.TuneDrive = n + } + case "mic_level": + if n, ok := num(get(0)); ok { + p.MicLevel = n + } + case "volume": + if n, ok := num(get(0)); ok { + p.Volume = n + } + case "mute": + p.Mute = yes(get(1)) + case "agc_mode": + if forRX0() { + p.AGC = strings.ToLower(strings.TrimSpace(get(1))) + } + case "sql_enable": + if forRX0() { + p.SquelchOn = yes(get(1)) + } + case "sql_level": + if n, ok := num(get(1)); ok && forRX0() { + p.Squelch = n + } + case "rx_nb_enable": + if forRX0() { + p.NB = yes(get(1)) + } + case "rx_nr_enable": + if forRX0() { + p.NR = yes(get(1)) + } + case "rx_anf_enable": + if forRX0() { + p.ANF = yes(get(1)) + } + case "rx_apf_enable": + if forRX0() { + p.APF = yes(get(1)) + } + case "rx_filter_band": + if forRX0() { + if lo, ok := num(get(1)); ok { + p.FilterLo = lo + } + if hi, ok := num(get(2)); ok { + p.FilterHi = hi + } + } + case "rit_enable": + if forRX0() { + p.RIT = yes(get(1)) + } + case "xit_enable": + if forRX0() { + p.XIT = yes(get(1)) + } + case "rit_offset": + if n, ok := num(get(1)); ok && forRX0() { + p.RITOffset = n + } + case "xit_offset": + if n, ok := num(get(1)); ok && forRX0() { + p.XITOffset = n + } + case "lock": + if forRX0() { + p.Lock = yes(get(1)) + } + case "rx_smeter": + if n, ok := num(get(1)); ok && forRX0() { + p.SMeter = n + } + case "tune": + if forRX0() { + p.Tuning = yes(get(1)) + } + case "modulations_list": + p.Modulations = splitAndTrim(args) + default: + return false + } + return true +} + +// splitAndTrim turns "usb,lsb,cw" into a slice, upper-cased for display. +func splitAndTrim(s string) []string { + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if v := strings.ToUpper(strings.TrimSpace(p)); v != "" { + out = append(out, v) + } + } + return out +} + +// TCIPanel returns the console snapshot. +func (t *TCI) TCIPanel() TCIPanelState { + t.mu.Lock() + defer t.mu.Unlock() + st := t.panel.st + st.Connected = t.conn != nil + st.Device = t.device + st.TX = t.tx + st.Split = t.split + st.TXEnabled = t.txAllowed || !t.txAllowedKnown + return st +} + +// ── Setters ─────────────────────────────────────────────────────────────── +// +// Every one of them is a SET in the same vocabulary the radio reports in, and +// none of them updates the cached state: the radio answers with the new value, +// and taking its word rather than our own is what keeps the panel honest when a +// setting is refused, clamped, or changed from the radio's own window a second +// later. + +// SetDrive sets the transmit drive, 0-100. +func (t *TCI) SetDrive(v int) error { return t.send(fmt.Sprintf("drive:%d;", clampTCIPct(v))) } + +// SetTuneDrive sets the drive used by TUNE, 0-100. +func (t *TCI) SetTuneDrive(v int) error { return t.send(fmt.Sprintf("tune_drive:%d;", clampTCIPct(v))) } + +// SetMicLevel sets the microphone gain, 0-100. +func (t *TCI) SetMicLevel(v int) error { return t.send(fmt.Sprintf("mic_level:%d;", clampTCIPct(v))) } + +// SetVolume sets the receive volume in dB. TCI's scale is negative — 0 is full +// and -60 is inaudible — so this is NOT clamped to a percentage. +func (t *TCI) SetVolume(db int) error { + if db > 0 { + db = 0 + } + if db < -60 { + db = -60 + } + return t.send(fmt.Sprintf("volume:%d;", db)) +} + +// SetMute mutes or unmutes the receiver. +func (t *TCI) SetMute(on bool) error { return t.send(fmt.Sprintf("mute:%t;", on)) } + +// SetAGC picks the AGC speed: off, long, slow, med, fast. +func (t *TCI) SetAGC(mode string) error { + m := strings.ToLower(strings.TrimSpace(mode)) + switch m { + case "off", "long", "slow", "med", "fast": + default: + return fmt.Errorf("unknown AGC mode %q", mode) + } + return t.send(fmt.Sprintf("agc_mode:0,%s;", m)) +} + +// SetSquelch turns the squelch on or off. +func (t *TCI) SetSquelch(on bool) error { return t.send(fmt.Sprintf("sql_enable:0,%t;", on)) } + +// SetSquelchLevel sets the threshold in dBm. +func (t *TCI) SetSquelchLevel(v int) error { return t.send(fmt.Sprintf("sql_level:0,%d;", v)) } + +// SetNB, SetNR, SetANF, SetAPF switch the receive processing. +func (t *TCI) SetNB(on bool) error { return t.send(fmt.Sprintf("rx_nb_enable:0,%t;", on)) } +func (t *TCI) SetNR(on bool) error { return t.send(fmt.Sprintf("rx_nr_enable:0,%t;", on)) } +func (t *TCI) SetANF(on bool) error { return t.send(fmt.Sprintf("rx_anf_enable:0,%t;", on)) } +func (t *TCI) SetAPF(on bool) error { return t.send(fmt.Sprintf("rx_apf_enable:0,%t;", on)) } + +// SetFilter sets the passband edges in Hz. +func (t *TCI) SetFilter(lo, hi int) error { + if lo > hi { + lo, hi = hi, lo + } + return t.send(fmt.Sprintf("rx_filter_band:0,%d,%d;", lo, hi)) +} + +// SetRIT / SetXIT switch the offsets on, SetRITOffset / SetXITOffset move them. +func (t *TCI) SetRIT(on bool) error { return t.send(fmt.Sprintf("rit_enable:0,%t;", on)) } +func (t *TCI) SetXIT(on bool) error { return t.send(fmt.Sprintf("xit_enable:0,%t;", on)) } +func (t *TCI) SetRITOffset(hz int) error { return t.send(fmt.Sprintf("rit_offset:0,%d;", hz)) } +func (t *TCI) SetXITOffset(hz int) error { return t.send(fmt.Sprintf("xit_offset:0,%d;", hz)) } + +// SetLock locks the VFO knob on the radio. +func (t *TCI) SetLock(on bool) error { return t.send(fmt.Sprintf("lock:0,%t;", on)) } + +// SetTune starts or stops the tune carrier. +// +// It TRANSMITS, at tune_drive rather than at drive — which is the setting to +// check before pressing it, and why the panel shows the two side by side. +func (t *TCI) SetTune(on bool) error { return t.send(fmt.Sprintf("tune:0,%t;", on)) } + +func clampTCIPct(v int) int { + if v < 0 { + return 0 + } + if v > 100 { + return 100 + } + return v +}