feat(tci): a control console for the SunSDR

TCI already carries the frequency, the mode, the meters and now the audio.
It also carries everything else about the radio — and OpsLog was logging
most of it once as '(unhandled once)' and throwing it away. The console is
mostly a place to put what was already arriving.

That makes it the cheapest panel here, and it is worth saying why. A K3
console costs a command and a reply for every value it shows, which is why
it reads its settings in a rotation and its meters only while on screen.
TCI PUSHES: the radio announces its drive, its filters, its noise blanker
and the rest on connect, and again whenever any of them changes —
including when the operator changes them in ExpertSDR3's own window, which
this panel therefore follows without asking anything.

What it drives: drive and tune drive, mic gain, TUNE, volume, mute,
squelch and its threshold, NB, NR, ANF, APF, AGC speed, the passband, RIT
and XIT with their offsets, and the VFO lock. The S-meter is a real dBm
reading, so its S units are arithmetic rather than the calibration guess a
K3's meter needs.

Setters never update the cached state. The radio answers with the new
value, and taking its word is what keeps the panel honest when a setting
is refused, clamped, or changed at the radio a second later — the one
exception being a slider mid-drag, held for 900 ms so it is not dragged
back by its own echo.

Capped width and centred, like the other consoles. Also offered as a
docked pane — and the Elecraft console is offered there too now: App has
always had that pane, Settings simply never listed it.
This commit is contained in:
2026-08-26 18:31:44 +02:00
parent 8b9c835ca1
commit 98c49f6bbc
11 changed files with 1038 additions and 10 deletions
+21 -2
View File
@@ -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'}
/>
)}
+11 -4
View File
@@ -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);
@@ -7115,7 +7122,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>
+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>
);
}
+2 -2
View File
@@ -109,7 +109,7 @@ const en: Dict = {
'settings.pane.map1': 'Map — great-circle + beam', 'settings.pane.map2': 'Map — locator (street)',
'settings.pane.cluster': 'Cluster spots', 'settings.pane.worked': 'Worked before',
'settings.pane.recent': 'Recent QSOs', 'settings.pane.netcontrol': 'Net control', 'settings.pane.decodes': 'FT decodes',
'settings.pane.flex': 'Flex Console', 'settings.pane.icom': 'Icom Console', 'settings.pane.yaesu': 'Yaesu Console', 'yaesu.notConnected': 'Yaesu CAT not connected', 'yaesu.meters': 'Meters', 'yaesu.bandMode': 'Band & mode', 'yaesu.receive': 'Receive', 'yaesu.noiseFilter': 'Noise & filter', 'yaesu.transmit': 'Transmit', 'yaesu.refresh': 'Refresh', 'yaesu.narrowHint': 'Narrow IF filter (NAR) — tightens the receive bandwidth. Only shown when the radio answers the command.', 'yaesu.tuneHint': 'Start an antenna-tuner cycle', 'yaesu.sToRst': 'Click to fill the RST sent', 'yaesu.sidebandHint': 'Click to select this mode; click again to switch sideband (U/L)', 'yaesu.splitUpHint': 'Transmit this far above the receive frequency, and turn split on', 'yaesu.txOn': 'TX', 'yaesu.cw': 'CW', 'yaesu.breakInHint': 'Break-in: the rig switches to receive between characters', 'yaesu.zinHint': 'Zero-in: retune so the station you hear lands on your CW pitch',
'settings.pane.flex': 'Flex Console', 'tcip.console': "SunSDR Console", 'settings.pane.tci': "SunSDR Console", 'settings.pane.elecraft': "Elecraft Console", 'tcip.waiting': "Waiting for the radio — TCI is not connected.", 'tcip.meters': "Meters", 'tcip.transmit': "Transmit", 'tcip.receive': "Receive", 'tcip.drive': "Drive", 'tcip.tuneDrive': "Tune drive", 'tcip.mic': "Mic gain", 'tcip.volume': "Volume", 'tcip.squelch': "Squelch", 'tcip.agc': "AGC", 'tcip.filter': "Filter", 'tcip.mute': "MUTE", 'tcip.lock': "LOCK", 'tcip.off': "off", 'tcip.tune': "TUNE", 'tcip.tuning': "TUNING", 'tcip.txDisabled': "The radio is not allowing transmit", 'tcip.sMeterHint': "Click to put this report in the entry form. The radio sends a real signal level in dBm, so the S units are arithmetic rather than a calibration guess.", 'settings.pane.icom': 'Icom Console', 'settings.pane.yaesu': 'Yaesu Console', 'yaesu.notConnected': 'Yaesu CAT not connected', 'yaesu.meters': 'Meters', 'yaesu.bandMode': 'Band & mode', 'yaesu.receive': 'Receive', 'yaesu.noiseFilter': 'Noise & filter', 'yaesu.transmit': 'Transmit', 'yaesu.refresh': 'Refresh', 'yaesu.narrowHint': 'Narrow IF filter (NAR) — tightens the receive bandwidth. Only shown when the radio answers the command.', 'yaesu.tuneHint': 'Start an antenna-tuner cycle', 'yaesu.sToRst': 'Click to fill the RST sent', 'yaesu.sidebandHint': 'Click to select this mode; click again to switch sideband (U/L)', 'yaesu.splitUpHint': 'Transmit this far above the receive frequency, and turn split on', 'yaesu.txOn': 'TX', 'yaesu.cw': 'CW', 'yaesu.breakInHint': 'Break-in: the rig switches to receive between characters', 'yaesu.zinHint': 'Zero-in: retune so the station you hear lands on your CW pitch',
'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.light-cool': 'Cool light',
'theme.light-sage': 'Sage light', 'theme.light-nordic': 'Nordic light', 'theme.sahara': 'Sahara', 'theme.dim-slate': 'Dim slate', 'theme.dark-warm': 'Warm dark',
'theme.dark-graphite': 'Graphite dark', 'theme.dark-indigo': 'Indigo', 'theme.dark-teal': 'Ocean', 'theme.dark-plum': 'Plum', 'theme.high-contrast': 'High contrast',
@@ -596,7 +596,7 @@ const fr: Dict = {
'settings.pane.map1': 'Carte — orthodromie + faisceau', 'settings.pane.map2': 'Carte — locator (rue)',
'settings.pane.cluster': 'Spots cluster', 'settings.pane.worked': 'Déjà contactés',
'settings.pane.recent': 'QSO récents', 'settings.pane.netcontrol': 'Gestion de net', 'settings.pane.decodes': 'Decodes FT',
'settings.pane.flex': 'Flex Console', 'settings.pane.icom': 'Icom Console', 'settings.pane.yaesu': 'Yaesu Console', 'yaesu.notConnected': 'CAT Yaesu non connecté', 'yaesu.meters': 'Mesures', 'yaesu.bandMode': 'Bande et mode', 'yaesu.receive': 'Réception', 'yaesu.noiseFilter': 'Bruit et filtre', 'yaesu.transmit': 'Émission', 'yaesu.refresh': 'Actualiser', 'yaesu.narrowHint': "Filtre FI étroit (NAR) — resserre la bande passante de réception. N'apparaît que si la radio répond à la commande.", 'yaesu.tuneHint': "Lancer un cycle d'accord d'antenne", 'yaesu.sToRst': 'Cliquer pour remplir le RST envoyé', 'yaesu.sidebandHint': 'Cliquer pour choisir ce mode ; recliquer pour changer de bande latérale (U/L)', 'yaesu.splitUpHint': "Émettre à cette distance au-dessus de la fréquence de réception, et activer le split", 'yaesu.txOn': 'TX', 'yaesu.cw': 'CW', 'yaesu.breakInHint': "Break-in : la radio repasse en réception entre les caractères", 'yaesu.zinHint': "Zéro-in : réaccorde pour que la station entendue tombe sur votre note CW",
'settings.pane.flex': 'Flex Console', 'tcip.console': "Console SunSDR", 'settings.pane.tci': "Console SunSDR", 'settings.pane.elecraft': "Console Elecraft", 'tcip.waiting': "En attente de la radio — TCI n'est pas connecté.", 'tcip.meters': "Mesures", 'tcip.transmit': "Émission", 'tcip.receive': "Réception", 'tcip.drive': "Puissance", 'tcip.tuneDrive': "Puissance d'accord", 'tcip.mic': "Gain micro", 'tcip.volume': "Volume", 'tcip.squelch': "Squelch", 'tcip.agc': "AGC", 'tcip.filter': "Filtre", 'tcip.mute': "MUET", 'tcip.lock': "VERR", 'tcip.off': "désactivé", 'tcip.tune': "ACCORD", 'tcip.tuning': "EN ACCORD", 'tcip.txDisabled': "La radio n'autorise pas l'émission", 'tcip.sMeterHint': "Cliquer pour reporter dans la saisie. La radio envoie un vrai niveau en dBm, donc les points S sont un calcul et non une estimation d'étalonnage.", 'settings.pane.icom': 'Icom Console', 'settings.pane.yaesu': 'Yaesu Console', 'yaesu.notConnected': 'CAT Yaesu non connecté', 'yaesu.meters': 'Mesures', 'yaesu.bandMode': 'Bande et mode', 'yaesu.receive': 'Réception', 'yaesu.noiseFilter': 'Bruit et filtre', 'yaesu.transmit': 'Émission', 'yaesu.refresh': 'Actualiser', 'yaesu.narrowHint': "Filtre FI étroit (NAR) — resserre la bande passante de réception. N'apparaît que si la radio répond à la commande.", 'yaesu.tuneHint': "Lancer un cycle d'accord d'antenne", 'yaesu.sToRst': 'Cliquer pour remplir le RST envoyé', 'yaesu.sidebandHint': 'Cliquer pour choisir ce mode ; recliquer pour changer de bande latérale (U/L)', 'yaesu.splitUpHint': "Émettre à cette distance au-dessus de la fréquence de réception, et activer le split", 'yaesu.txOn': 'TX', 'yaesu.cw': 'CW', 'yaesu.breakInHint': "Break-in : la radio repasse en réception entre les caractères", 'yaesu.zinHint': "Zéro-in : réaccorde pour que la station entendue tombe sur votre note CW",
'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.light-cool': 'Clair froid',
'theme.light-sage': 'Clair sauge', 'theme.light-nordic': 'Clair nordique', 'theme.sahara': 'Sahara', 'theme.dim-slate': 'Ardoise tamisé', 'theme.dark-warm': 'Sombre chaud',
'theme.dark-graphite': 'Sombre graphite', 'theme.dark-indigo': 'Indigo', 'theme.dark-teal': 'Océan', 'theme.dark-plum': 'Prune', 'theme.high-contrast': 'Contraste élevé',
+40
View File
@@ -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>;
+80
View File
@@ -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);
}
+66
View File
@@ -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;