merge: the SunSDR control console, and the audio device fixes with it

TCI pushes everything about the radio unasked, so the console is mostly a
place to put what was already arriving and being logged once as unhandled.
Drive, tune drive, mic gain, TUNE, volume, mute, squelch, NB/NR/ANF/APF,
AGC, passband, RIT and XIT, VFO lock, and an S-meter in real dBm.

Merged with two corrections to the audio page the console's own work
turned up: the radio was being offered as a recording microphone, and the
RX monitor was enabled for a source it cannot play.
This commit is contained in:
2026-08-26 19:04:13 +02:00
11 changed files with 1057 additions and 16 deletions
+118
View File
@@ -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) })
}
+21 -2
View File
@@ -78,6 +78,7 @@ import { WorldMap, LocatorMap } from '@/components/MainMap';
import { FlexPanel } from '@/components/FlexPanel'; import { FlexPanel } from '@/components/FlexPanel';
import { IcomPanel } from '@/components/IcomPanel'; import { IcomPanel } from '@/components/IcomPanel';
import { YaesuPanel } from '@/components/YaesuPanel'; import { YaesuPanel } from '@/components/YaesuPanel';
import { TCIPanel } from '@/components/TCIPanel';
import { ElecraftPanel } from '@/components/ElecraftPanel'; import { ElecraftPanel } from '@/components/ElecraftPanel';
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel'; import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
import { MotorAntennaWidget, type AntStatus } from '@/components/MotorAntennaWidget'; 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. // 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 // '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. // 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 [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1'); const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2'); const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
@@ -1931,7 +1932,7 @@ export default function App() {
// quarter-width map is unreadable. // quarter-width map is unreadable.
const [mainLayout4, setMainLayout4] = useState<'cols' | 'quad'>('quad'); const [mainLayout4, setMainLayout4] = useState<'cols' | 'quad'>('quad');
const loadMainPanes = useCallback(async () => { 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([ const [l, r, p3, p4, lay] = await Promise.all([
GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneLeft').catch(() => ''),
GetUIPref('mainPaneRight').catch(() => ''), GetUIPref('mainPaneRight').catch(() => ''),
@@ -6174,6 +6175,12 @@ export default function App() {
<ElecraftPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} /> <ElecraftPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
</div> </div>
); );
case 'tci':
return (
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
<TCIPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
</div>
);
case 'icom': case 'icom':
return ( return (
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border"> <div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
@@ -7452,6 +7459,7 @@ export default function App() {
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom Console</TabsTrigger>} {catState.backend === 'icom' && <TabsTrigger value="icom">Icom Console</TabsTrigger>}
{catState.backend === 'yaesu' && <TabsTrigger value="yaesu">Yaesu Console</TabsTrigger>} {catState.backend === 'yaesu' && <TabsTrigger value="yaesu">Yaesu Console</TabsTrigger>}
{(catState.backend === 'elecraft' || catState.backend === 'kenwood') && <TabsTrigger value="elecraft">{t('k3.console')}</TabsTrigger>} {(catState.backend === 'elecraft' || catState.backend === 'kenwood') && <TabsTrigger value="elecraft">{t('k3.console')}</TabsTrigger>}
{catState.backend === 'tci' && <TabsTrigger value="tci">{t('tcip.console')}</TabsTrigger>}
{statsTabOpen && ( {statsTabOpen && (
<TabsTrigger value="stats" className="gap-1.5"> <TabsTrigger value="stats" className="gap-1.5">
{t('stats.tab')} {t('stats.tab')}
@@ -8140,6 +8148,15 @@ export default function App() {
</TabsContent> </TabsContent>
)} )}
{/* 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' && (
<TabsContent value="tci" className="flex-1 min-h-0 p-0">
<TCIPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
</TabsContent>
)}
{catState.backend === 'icom' && ( {catState.backend === 'icom' && (
<TabsContent value="icom" className="flex-1 min-h-0 p-0"> <TabsContent value="icom" className="flex-1 min-h-0 p-0">
<IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} /> <IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
@@ -8538,6 +8555,8 @@ export default function App() {
flexAvailable={catState.backend === 'flex'} flexAvailable={catState.backend === 'flex'}
icomAvailable={catState.backend === 'icom'} icomAvailable={catState.backend === 'icom'}
yaesuAvailable={catState.backend === 'yaesu'} yaesuAvailable={catState.backend === 'yaesu'}
elecraftAvailable={catState.backend === 'elecraft' || catState.backend === 'kenwood'}
tciAvailable={catState.backend === 'tci'}
/> />
)} )}
+28 -8
View File
@@ -175,6 +175,8 @@ interface Props {
flexAvailable?: boolean; // CAT backend is FlexRadio → offer it as a Main pane 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 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 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 // 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 // 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 // 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. // profile-prefixed). Self-contained so it owns its async-loaded state.
const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol', 'decodes']; const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol', 'decodes'];
const PANE_NONE = 'none'; 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 { t } = useI18n();
const [panes, setPanes] = useState<Record<string, string>>({ left: 'map1', right: 'map2', p3: PANE_NONE, p4: PANE_NONE }); const [panes, setPanes] = useState<Record<string, string>>({ left: 'map1', right: 'map2', p3: PANE_NONE, p4: PANE_NONE });
const [layout, setLayout] = useState('quad'); const [layout, setLayout] = useState('quad');
@@ -1095,11 +1097,16 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable
...(flexAvailable ? ['flex'] : []), ...(flexAvailable ? ['flex'] : []),
...(icomAvailable ? ['icom'] : []), ...(icomAvailable ? ['icom'] : []),
...(yaesuAvailable ? ['yaesu'] : []), ...(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}`) })) ].map((value) => ({ value, label: t(`settings.pane.${value}`) }))
.sort((a, b) => a.label.localeCompare(b.label)); .sort((a, b) => a.label.localeCompare(b.label));
const KEYS: Record<string, string> = { left: 'mainPaneLeft', right: 'mainPaneRight', p3: 'mainPane3', p4: 'mainPane4' }; const KEYS: Record<string, string> = { left: 'mainPaneLeft', right: 'mainPaneRight', p3: 'mainPane3', p4: 'mainPane4' };
useEffect(() => { 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([ Promise.all([
...Object.values(KEYS).map((k) => GetUIPref(k).catch(() => '')), ...Object.values(KEYS).map((k) => GetUIPref(k).catch(() => '')),
GetUIPref('mainPaneLayout').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 // 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 // App passes callbacks that do not change identity on every render — see the
// useCallback wrappers there. // 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 { t } = useI18n();
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station'); const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -6766,6 +6773,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
xiegu: t('cat.optXiegu'), xiegu: t('cat.optXiegu'),
} as Record<string, string>)[catCfg.backend] ?? ''; } as Record<string, string>)[catCfg.backend] ?? '';
// The radio-over-network entry, which ListAudioInputDevices and
// ListAudioOutputDevices add when the CAT link can carry audio. It is a
// real choice for two of the four fields and nonsense for the other two.
const NET_DEVICE = 'net:radio';
const soundCardsOnly = (devs: AudioDev[]) => devs.filter((d) => d.id !== NET_DEVICE);
const fromRadioIsNetwork = audioCfg.from_radio === NET_DEVICE;
const deviceSelect = ( const deviceSelect = (
field: keyof AudioSettings, field: keyof AudioSettings,
devices: AudioDev[], devices: AudioDev[],
@@ -6800,10 +6814,16 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
{deviceSelect('from_radio', audioInputs, t('aud.phFromRadio'))} {deviceSelect('from_radio', audioInputs, t('aud.phFromRadio'))}
<Label className="text-sm">{t('aud.toRadio')}</Label> <Label className="text-sm">{t('aud.toRadio')}</Label>
{deviceSelect('to_radio', audioOutputs, t('aud.phToRadio'))} {deviceSelect('to_radio', audioOutputs, t('aud.phToRadio'))}
{/* The radio is offered for the two fields it can BE where the
received audio comes from, and where the voice keyer sends
its messages and taken out of the other two. It is not a
microphone: choosing it here would record the station you are
listening to instead of your own voice. And it is not a pair of
speakers: the audio going that way is transmit audio. */}
<Label className="text-sm">{t('aud.recMic')}</Label> <Label className="text-sm">{t('aud.recMic')}</Label>
{deviceSelect('recording_device', audioInputs, t('aud.phRecMic'))} {deviceSelect('recording_device', soundCardsOnly(audioInputs), t('aud.phRecMic'))}
<Label className="text-sm">{t('aud.listening')}</Label> <Label className="text-sm">{t('aud.listening')}</Label>
{deviceSelect('listening_device', audioOutputs, t('aud.phListening'))} {deviceSelect('listening_device', soundCardsOnly(audioOutputs), t('aud.phListening'))}
</div> </div>
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
<strong>{t('aud.fromRadioShort')}</strong> {t('aud.explainFrom')}{' '} <strong>{t('aud.fromRadioShort')}</strong> {t('aud.explainFrom')}{' '}
@@ -6821,8 +6841,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
size="sm" size="sm"
className="h-8" className="h-8"
onClick={toggleMonitor} onClick={toggleMonitor}
disabled={!monitorOn && !audioCfg.from_radio} disabled={!monitorOn && (!audioCfg.from_radio || fromRadioIsNetwork)}
title={t('aud.monitorTitle')} title={fromRadioIsNetwork ? t('aud.monitorNoTci') : t('aud.monitorTitle')}
> >
{monitorOn ? t('aud.stopListening') : t('aud.listenRadio')} {monitorOn ? t('aud.stopListening') : t('aud.listenRadio')}
</Button> </Button>
@@ -7115,7 +7135,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</label> </label>
<TelemetryToggle /> <TelemetryToggle />
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} icomAvailable={icomAvailable} yaesuAvailable={yaesuAvailable} /> <MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} icomAvailable={icomAvailable} yaesuAvailable={yaesuAvailable} elecraftAvailable={elecraftAvailable} tciAvailable={tciAvailable} />
<div className="border-t border-border/60 pt-4 space-y-2"> <div className="border-t border-border/60 pt-4 space-y-2">
<h4 className="text-sm font-semibold text-foreground">{t('gen.pwEnc')}</h4> <h4 className="text-sm font-semibold text-foreground">{t('gen.pwEnc')}</h4>
+314
View File
@@ -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 (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Icon className="size-4 text-primary" />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
</div>
<div className="p-3 space-y-3">{children}</div>
</div>
);
}
// 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 (
<button type="button" disabled={off} onClick={onClick} title={title}
className={cn('rounded-lg border-2 px-2 py-1.5 text-xs font-bold transition-all disabled:opacity-30',
on ? 'bg-primary text-primary-foreground border-primary shadow-[0_0_10px] shadow-primary/40'
: 'bg-card text-muted-foreground border-border hover:bg-muted')}>
{label}
</button>
);
}
function Row({ label, value, children }: { label: string; value: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
<div className="flex items-baseline justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
<span className="text-xs font-mono tabular-nums">{value}</span>
</div>
{children}
</div>
);
}
export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void } = {}) {
const { t } = useI18n();
const [st, setSt] = useState<TCIState>(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<Record<string, { v: number; until: number }>>({});
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<any>) => { 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 (
<div className="h-full min-h-0 overflow-auto bg-background">
{/* 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. */}
<div className="max-w-5xl mx-auto p-3 space-y-3">
{/* VFO + identity */}
<div className="rounded-xl border border-border bg-card shadow-sm px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
<div>
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
<Radio className="size-3.5" />
{st.device || 'SunSDR'} {st.protocol ? `· ${st.protocol}` : ''}
<span className={cn('size-2 rounded-full', off ? 'bg-muted-foreground/40' : 'bg-success')} />
</div>
<div className="text-2xl font-mono tabular-nums font-bold">
{freqHz > 0 ? (freqHz / 1e6).toFixed(6) : '—'}
</div>
</div>
<div className="flex items-center gap-2">
{st.tx && <span className="rounded-md bg-danger px-2 py-1 text-[11px] font-bold text-danger-foreground">TX</span>}
{st.split && <span className="rounded-md border border-border px-2 py-1 text-[11px] font-bold">SPLIT</span>}
<Toggle label={t('tcip.lock')} on={st.lock} off={off} onClick={() => call(() => SetTCILock(!st.lock))} />
</div>
</div>
{off && <div className="text-xs text-muted-foreground px-1">{t('tcip.waiting')}</div>}
{!!err && <div className="text-[11px] text-danger px-1">{err}</div>}
{/* 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. */}
<Card icon={Activity} title={t('tcip.meters')}>
<MeterBar label="S-METER" value={st.tx ? 0 : sBar(st.smeter)} lo={0} hi={100}
accent="#16a34a" segColor={sSegColor}
display={st.tx ? '—' : `${s.label} ${st.smeter} dBm`}
onClick={() => {
if (st.tx || !onReportRST) return;
onReportRST(sMeterRST(s.s, s.over, mode));
}}
title={t('tcip.sMeterHint')} />
</Card>
{/* Transmit */}
<Card icon={SlidersHorizontal} title={t('tcip.transmit')}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Row label={t('tcip.drive')} value={`${drive}%`}>
<WheelRange min={0} max={100} disabled={off} value={drive}
onChange={(v) => { setHold('drive', v); call(() => SetTCIDrive(v)); }} />
</Row>
<Row label={t('tcip.tuneDrive')} value={`${tuneDrive}%`}>
<WheelRange min={0} max={100} disabled={off} value={tuneDrive} accent="#f59e0b"
onChange={(v) => { setHold('tune_drive', v); call(() => SetTCITuneDrive(v)); }} />
</Row>
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* 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. */}
<button type="button" disabled={off || !st.tx_enabled}
onClick={() => call(() => SetTCITune(!st.tuning))}
className={cn('rounded-lg border-2 px-4 py-2 text-sm font-extrabold tracking-wide transition-all disabled:opacity-30',
st.tuning ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50'
: 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{st.tuning ? t('tcip.tuning') : t('tcip.tune')}
</button>
{!st.tx_enabled && !off && (
<span className="text-[11px] text-muted-foreground">{t('tcip.txDisabled')}</span>
)}
</div>
<Row label={t('tcip.mic')} value={`${mic}%`}>
<WheelRange min={0} max={100} disabled={off} value={mic} accent="#a855f7"
onChange={(v) => { setHold('mic', v); call(() => SetTCIMicLevel(v)); }} />
</Row>
</Card>
{/* Receive */}
<Card icon={AudioLines} title={t('tcip.receive')}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* 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. */}
<Row label={t('tcip.volume')} value={`${vol} dB`}>
<WheelRange min={-60} max={0} disabled={off} value={vol}
onChange={(v) => { setHold('vol', v); call(() => SetTCIVolume(v)); }} />
</Row>
<Row label={t('tcip.squelch')} value={st.squelch_on ? `${sql} dBm` : t('tcip.off')}>
<WheelRange min={-140} max={0} disabled={off || !st.squelch_on} value={sql}
onChange={(v) => { setHold('sql', v); call(() => SetTCISquelchLevel(v)); }} />
</Row>
</div>
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2">
<Toggle label="NB" on={st.nb} off={off} onClick={() => call(() => SetTCINB(!st.nb))} />
<Toggle label="NR" on={st.nr} off={off} onClick={() => call(() => SetTCINR(!st.nr))} />
<Toggle label="ANF" on={st.anf} off={off} onClick={() => call(() => SetTCIANF(!st.anf))} />
<Toggle label="APF" on={st.apf} off={off} onClick={() => call(() => SetTCIAPF(!st.apf))} />
<Toggle label="SQL" on={st.squelch_on} off={off} onClick={() => call(() => SetTCISquelch(!st.squelch_on))} />
<Toggle label={t('tcip.mute')} on={st.mute} off={off} onClick={() => call(() => SetTCIMute(!st.mute))} />
</div>
<div className="space-y-1">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{t('tcip.agc')}</span>
<div className="grid grid-cols-5 gap-2">
{['off', 'long', 'slow', 'med', 'fast'].map((m) => (
<Toggle key={m} label={m.toUpperCase()} on={(st.agc || '') === m} off={off}
onClick={() => call(() => SetTCIAGC(m))} />
))}
</div>
</div>
<div className="space-y-1">
<div className="flex items-baseline justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{t('tcip.filter')}</span>
<span className="text-xs font-mono tabular-nums">{st.filter_lo}{st.filter_hi} Hz</span>
</div>
<div className="grid grid-cols-4 sm:grid-cols-7 gap-2">
{FILTERS.map((f) => (
<Toggle key={f.label} label={f.label}
on={st.filter_lo === f.lo && st.filter_hi === f.hi} off={off}
onClick={() => call(() => SetTCIFilter(f.lo, f.hi))} />
))}
</div>
</div>
</Card>
{/* RIT / XIT */}
<Card icon={Mic} title="RIT / XIT">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{([
{ 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) => (
<div key={r.key} className="flex items-center gap-2">
<Toggle label={r.key.toUpperCase()} on={r.on} off={off} onClick={() => call(() => r.toggle(!r.on))} />
<span className="text-xs font-mono tabular-nums w-16 text-center">
{r.offset > 0 ? `+${r.offset}` : r.offset} Hz
</span>
{/* ±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) => (
<button key={d} type="button" disabled={off || !r.on}
onClick={() => call(() => r.set(r.offset + d))}
className="rounded-md border border-border bg-card px-1.5 py-1 text-[10px] font-mono hover:bg-muted disabled:opacity-30">
{d > 0 ? `+${d}` : d}
</button>
))}
<button type="button" disabled={off || !r.on}
onClick={() => call(() => r.set(0))}
className="rounded-md border border-border bg-card px-1.5 py-1 text-[10px] font-bold hover:bg-muted disabled:opacity-30">
0
</button>
</div>
))}
</div>
</Card>
</div>
</div>
);
}
+4 -4
View File
@@ -109,7 +109,7 @@ const en: Dict = {
'settings.pane.map1': 'Map — great-circle + beam', 'settings.pane.map2': 'Map — locator (street)', '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.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.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.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.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', 'theme.dark-graphite': 'Graphite dark', 'theme.dark-indigo': 'Indigo', 'theme.dark-teal': 'Ocean', 'theme.dark-plum': 'Plum', 'theme.high-contrast': 'High contrast',
@@ -494,7 +494,7 @@ const en: Dict = {
'aud.noneDefault': '— none / system default —', 'aud.defaultTag': '(default)', 'aud.noneDefault': '— none / system default —', 'aud.defaultTag': '(default)',
'aud.fromRadioShort': 'From Radio', 'aud.toRadioShort': 'To Radio', 'aud.explainFrom': '= what you receive (used by the QSO recorder).', 'aud.explainTo': '= where voice-keyer messages are transmitted.', 'aud.fromRadioShort': 'From Radio', 'aud.toRadioShort': 'To Radio', 'aud.explainFrom': '= what you receive (used by the QSO recorder).', 'aud.explainTo': '= where voice-keyer messages are transmitted.',
'aud.monitorTitle': "Hear the rig's RX audio (From Radio) through your Listening device", 'aud.listenRadio': '▶ Listen to radio', 'aud.stopListening': '■ Stop listening', 'aud.monitorTitle': "Hear the rig's RX audio (From Radio) through your Listening device", 'aud.listenRadio': '▶ Listen to radio', 'aud.stopListening': '■ Stop listening',
'aud.monitorOn': 'RX monitor running — From Radio → Listening device.', 'aud.monitorHint': 'Live-monitor the rig here (USB codec now; network audio later).', 'aud.monitorOn': 'RX monitor running — From Radio → Listening device.', 'aud.monitorNoTci': 'Listening here is not available with the radio as the source: the TCI stream feeds the QSO recorder, not the monitor. Listen on the radio itself.', 'aud.monitorHint': 'Live-monitor the rig here (USB codec now; network audio later).',
'aud.txTitle': 'Key PTT and pipe your live mic into the rig (To Radio device)', 'aud.talkRadio': '🎙 Talk to radio (TX)', 'aud.stopTalk': '■ Stop talking (TX)', 'aud.txTitle': 'Key PTT and pipe your live mic into the rig (To Radio device)', 'aud.talkRadio': '🎙 Talk to radio (TX)', 'aud.stopTalk': '■ Stop talking (TX)',
'aud.txOn': 'TRANSMITTING — mic → To Radio, PTT keyed. Click to stop.', 'aud.txHint': 'Live mic → rig with PTT (USB now; network TX later).', 'aud.txOn': 'TRANSMITTING — mic → To Radio, PTT keyed. Click to stop.', 'aud.txHint': 'Live mic → rig with PTT (USB now; network TX later).',
'aud.recorder': 'QSO recorder', 'aud.recordEvery': 'Record every QSO to an audio file (From Radio + your mic)', 'aud.recFolder': 'Recordings folder', 'aud.browse': 'Browse…', 'aud.recorder': 'QSO recorder', 'aud.recordEvery': 'Record every QSO to an audio file (From Radio + your mic)', 'aud.recFolder': 'Recordings folder', 'aud.browse': 'Browse…',
@@ -596,7 +596,7 @@ const fr: Dict = {
'settings.pane.map1': 'Carte — orthodromie + faisceau', 'settings.pane.map2': 'Carte — locator (rue)', '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.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.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.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.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é', 'theme.dark-graphite': 'Sombre graphite', 'theme.dark-indigo': 'Indigo', 'theme.dark-teal': 'Océan', 'theme.dark-plum': 'Prune', 'theme.high-contrast': 'Contraste élevé',
@@ -962,7 +962,7 @@ const fr: Dict = {
'aud.noneDefault': '— aucun / défaut système —', 'aud.defaultTag': '(défaut)', 'aud.noneDefault': '— aucun / défaut système —', 'aud.defaultTag': '(défaut)',
'aud.fromRadioShort': 'Depuis la radio', 'aud.toRadioShort': 'Vers la radio', 'aud.explainFrom': "= ce que vous recevez (utilisé par l'enregistreur de QSO).", 'aud.explainTo': '= où sont émis les messages du manipulateur vocal.', 'aud.fromRadioShort': 'Depuis la radio', 'aud.toRadioShort': 'Vers la radio', 'aud.explainFrom': "= ce que vous recevez (utilisé par l'enregistreur de QSO).", 'aud.explainTo': '= où sont émis les messages du manipulateur vocal.',
'aud.monitorTitle': "Écouter l'audio RX du poste (Depuis la radio) sur votre périphérique d'écoute", 'aud.listenRadio': '▶ Écouter la radio', 'aud.stopListening': "■ Arrêter l'écoute", 'aud.monitorTitle': "Écouter l'audio RX du poste (Depuis la radio) sur votre périphérique d'écoute", 'aud.listenRadio': '▶ Écouter la radio', 'aud.stopListening': "■ Arrêter l'écoute",
'aud.monitorOn': "Écoute RX active — Depuis la radio → périphérique d'écoute.", 'aud.monitorHint': "Écoute directe du poste (codec USB pour l'instant ; audio réseau plus tard).", 'aud.monitorOn': "Écoute RX active — Depuis la radio → périphérique d'écoute.", 'aud.monitorNoTci': "L'écoute ici n'est pas disponible avec la radio comme source : le flux TCI alimente l'enregistreur de QSO, pas l'écoute. Écoute sur la radio elle-même.", 'aud.monitorHint': "Écoute directe du poste (codec USB pour l'instant ; audio réseau plus tard).",
'aud.txTitle': 'Activer le PTT et envoyer votre micro vers le poste (périphérique « Vers la radio »)', 'aud.talkRadio': '🎙 Parler à la radio (TX)', 'aud.stopTalk': '■ Arrêter de parler (TX)', 'aud.txTitle': 'Activer le PTT et envoyer votre micro vers le poste (périphérique « Vers la radio »)', 'aud.talkRadio': '🎙 Parler à la radio (TX)', 'aud.stopTalk': '■ Arrêter de parler (TX)',
'aud.txOn': 'ÉMISSION — micro → Vers la radio, PTT activé. Cliquez pour arrêter.', 'aud.txHint': "Micro direct → poste avec PTT (USB pour l'instant ; TX réseau plus tard).", 'aud.txOn': 'ÉMISSION — micro → Vers la radio, PTT activé. Cliquez pour arrêter.', 'aud.txHint': "Micro direct → poste avec PTT (USB pour l'instant ; TX réseau plus tard).",
'aud.recorder': 'Enregistreur de QSO', 'aud.recordEvery': 'Enregistrer chaque QSO dans un fichier audio (Depuis la radio + votre micro)', 'aud.recFolder': 'Dossier des enregistrements', 'aud.browse': 'Parcourir…', 'aud.recorder': 'Enregistreur de QSO', 'aud.recordEvery': 'Enregistrer chaque QSO dans un fichier audio (Depuis la radio + votre micro)', 'aud.recFolder': 'Dossier des enregistrements', 'aud.browse': 'Parcourir…',
+40
View File
@@ -577,6 +577,8 @@ export function GetStationSettings():Promise<main.StationSettings>;
export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>; export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
export function GetTCIPanel():Promise<cat.TCIPanelState>;
export function GetTelemetryEnabled():Promise<boolean>; export function GetTelemetryEnabled():Promise<boolean>;
export function GetTrackedAwards():Promise<Array<string>>; export function GetTrackedAwards():Promise<Array<string>>;
@@ -1159,6 +1161,44 @@ export function SetSpotMax(arg1:number):Promise<void>;
export function SetSpotTTLMinutes(arg1:number):Promise<void>; export function SetSpotTTLMinutes(arg1:number):Promise<void>;
export function SetTCIAGC(arg1:string):Promise<void>;
export function SetTCIANF(arg1:boolean):Promise<void>;
export function SetTCIAPF(arg1:boolean):Promise<void>;
export function SetTCIDrive(arg1:number):Promise<void>;
export function SetTCIFilter(arg1:number,arg2:number):Promise<void>;
export function SetTCILock(arg1:boolean):Promise<void>;
export function SetTCIMicLevel(arg1:number):Promise<void>;
export function SetTCIMute(arg1:boolean):Promise<void>;
export function SetTCINB(arg1:boolean):Promise<void>;
export function SetTCINR(arg1:boolean):Promise<void>;
export function SetTCIRIT(arg1:boolean):Promise<void>;
export function SetTCIRITOffset(arg1:number):Promise<void>;
export function SetTCISquelch(arg1:boolean):Promise<void>;
export function SetTCISquelchLevel(arg1:number):Promise<void>;
export function SetTCITune(arg1:boolean):Promise<void>;
export function SetTCITuneDrive(arg1:number):Promise<void>;
export function SetTCIVolume(arg1:number):Promise<void>;
export function SetTCIXIT(arg1:boolean):Promise<void>;
export function SetTCIXITOffset(arg1:number):Promise<void>;
export function SetTelemetryEnabled(arg1:boolean):Promise<void>; export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
export function SetUIPref(arg1:string,arg2:string):Promise<void>; export function SetUIPref(arg1:string,arg2:string):Promise<void>;
+80
View File
@@ -1094,6 +1094,10 @@ export function GetStationStatus() {
return window['go']['main']['App']['GetStationStatus'](); return window['go']['main']['App']['GetStationStatus']();
} }
export function GetTCIPanel() {
return window['go']['main']['App']['GetTCIPanel']();
}
export function GetTelemetryEnabled() { export function GetTelemetryEnabled() {
return window['go']['main']['App']['GetTelemetryEnabled'](); return window['go']['main']['App']['GetTelemetryEnabled']();
} }
@@ -2258,6 +2262,82 @@ export function SetSpotTTLMinutes(arg1) {
return window['go']['main']['App']['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) { export function SetTelemetryEnabled(arg1) {
return window['go']['main']['App']['SetTelemetryEnabled'](arg1); return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
} }
+66
View File
@@ -1210,6 +1210,72 @@ export namespace cat {
this.fixed = source["fixed"]; 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 { export class YaesuTXState {
available: boolean; available: boolean;
model?: string; model?: string;
+19 -2
View File
@@ -34,6 +34,10 @@ type TCI struct {
OnSpotClick func(callsign string, freqHz int64) OnSpotClick func(callsign string, freqHz int64)
unhandledSeen map[string]bool // log each unknown TCI message type once 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 // 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 // on this same WebSocket, which is what lets a SunSDR record and decode
// without a virtual audio cable in the way. // without a virtual audio cable in the way.
@@ -457,7 +461,20 @@ func (t *TCI) handle(msg string) {
} }
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() 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": case "device":
t.device = strings.TrimSpace(args) t.device = strings.TrimSpace(args)
// The radio ANNOUNCES its audio format at connect — // The radio ANNOUNCES its audio format at connect —
@@ -524,7 +541,7 @@ func (t *TCI) handle(msg string) {
t.txAllowed, t.txAllowedKnown = allowed, true t.txAllowed, t.txAllowedKnown = allowed, true
} }
default: default:
lname := strings.ToLower(name) lname := lower
// A click on one of our panorama spots comes back as // A click on one of our panorama spots comes back as
// CLICKED_ON_SPOT:<call>,<hz> (legacy) // CLICKED_ON_SPOT:<call>,<hz> (legacy)
// RX_CLICKED_ON_SPOT:<rx>,<ch>,<call>,<hz> // RX_CLICKED_ON_SPOT:<rx>,<ch>,<call>,<hz>
+59
View File
@@ -37,3 +37,62 @@ func (m *Manager) TCIAudioDo(fn func(TCIAudioController) error) error {
return fn(tc) 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)
})
}
+308
View File
@@ -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
}