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:
@@ -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
@@ -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<MainPaneKind>('map1');
|
||||
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('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() {
|
||||
<ElecraftPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
||||
</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':
|
||||
return (
|
||||
<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 === 'yaesu' && <TabsTrigger value="yaesu">Yaesu 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 && (
|
||||
<TabsTrigger value="stats" className="gap-1.5">
|
||||
{t('stats.tab')}
|
||||
@@ -8140,6 +8148,15 @@ export default function App() {
|
||||
</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' && (
|
||||
<TabsContent value="icom" className="flex-1 min-h-0 p-0">
|
||||
<IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { 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'}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<Record<string, string>>({ 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<string, string> = { 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<SectionId>((initialSection as SectionId) || 'station');
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -6766,6 +6773,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
xiegu: t('cat.optXiegu'),
|
||||
} 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 = (
|
||||
field: keyof AudioSettings,
|
||||
devices: AudioDev[],
|
||||
@@ -6800,10 +6814,16 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
{deviceSelect('from_radio', audioInputs, t('aud.phFromRadio'))}
|
||||
<Label className="text-sm">{t('aud.toRadio')}</Label>
|
||||
{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>
|
||||
{deviceSelect('recording_device', audioInputs, t('aud.phRecMic'))}
|
||||
{deviceSelect('recording_device', soundCardsOnly(audioInputs), t('aud.phRecMic'))}
|
||||
<Label className="text-sm">{t('aud.listening')}</Label>
|
||||
{deviceSelect('listening_device', audioOutputs, t('aud.phListening'))}
|
||||
{deviceSelect('listening_device', soundCardsOnly(audioOutputs), t('aud.phListening'))}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<strong>{t('aud.fromRadioShort')}</strong> {t('aud.explainFrom')}{' '}
|
||||
@@ -6821,8 +6841,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={toggleMonitor}
|
||||
disabled={!monitorOn && !audioCfg.from_radio}
|
||||
title={t('aud.monitorTitle')}
|
||||
disabled={!monitorOn && (!audioCfg.from_radio || fromRadioIsNetwork)}
|
||||
title={fromRadioIsNetwork ? t('aud.monitorNoTci') : t('aud.monitorTitle')}
|
||||
>
|
||||
{monitorOn ? t('aud.stopListening') : t('aud.listenRadio')}
|
||||
</Button>
|
||||
@@ -7115,7 +7135,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
</label>
|
||||
<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">
|
||||
<h4 className="text-sm font-semibold text-foreground">{t('gen.pwEnc')}</h4>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
@@ -494,7 +494,7 @@ const en: Dict = {
|
||||
'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.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.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…',
|
||||
@@ -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é',
|
||||
@@ -962,7 +962,7 @@ const fr: Dict = {
|
||||
'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.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.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…',
|
||||
|
||||
Vendored
+40
@@ -577,6 +577,8 @@ export function GetStationSettings():Promise<main.StationSettings>;
|
||||
|
||||
export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
|
||||
|
||||
export function GetTCIPanel():Promise<cat.TCIPanelState>;
|
||||
|
||||
export function GetTelemetryEnabled():Promise<boolean>;
|
||||
|
||||
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 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 SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+19
-2
@@ -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:<call>,<hz> (legacy)
|
||||
// RX_CLICKED_ON_SPOT:<rx>,<ch>,<call>,<hz>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user