merge: the SunSDR console, working this time
Everything here came from one operator with the radio in front of him, and
most of it was mine to fix. The drive commands need the transceiver index
('drive:0,15;', not 'drive:15;') or the radio ignores them without a word.
The console now holds a click the radio does not echo, so a working button
stops looking dead — and lets go the moment the radio contradicts it, so a
REFUSED setting still tells the truth. Filters per mode, APF only in CW,
levels on wide rows with typed values, the RIT control shared with the
Icom console, the transmit meters, and a TUNE that can be switched off
again.
Two findings worth keeping: this radio re-asserts sql_enable eighty
milliseconds after being told to turn the squelch off, and it never
answers TX_POWER or TX_SWR — both are its own doing, and both are visible
in the log rather than argued about.
This commit is contained in:
@@ -1 +1 @@
|
||||
f9b41e192918fa2511f68cd1b361fcd3
|
||||
704fe1bf370b669665df0606fae8a69d
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sMeterRST } from '@/lib/rst';
|
||||
import { MeterBar } from '@/components/MeterBar';
|
||||
import { ShiftRow } from '@/components/ShiftRow';
|
||||
|
||||
type IcomState = {
|
||||
available: boolean; model?: string; mode?: string;
|
||||
@@ -248,39 +250,6 @@ function Meter({ label, value, accent, scale, onClick, title }: { label: string;
|
||||
return <div className="flex items-center gap-2">{body}</div>;
|
||||
}
|
||||
|
||||
// ShiftRow — a RIT / ΔTX offset control: on/off chip + a wheel-adjustable signed
|
||||
// offset (±10 Hz per notch or per ± button) + a clear (0) button.
|
||||
function ShiftRow({ label, on, hz, accent, onToggle, onDelta, onClear }: {
|
||||
label: string; on: boolean; hz: number; accent: string;
|
||||
onToggle: () => void; onDelta: (d: number) => void; onClear: () => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const cb = useRef(onDelta); cb.current = onDelta;
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => { e.preventDefault(); cb.current(e.deltaY < 0 ? 10 : -10); };
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip on={on} onClick={onToggle} label={label} />
|
||||
<div ref={ref} title="Wheel / ± to shift"
|
||||
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none cursor-ns-resize',
|
||||
on ? 'border-border bg-muted/40' : 'border-border/60 bg-muted/20 opacity-60')}>
|
||||
<button type="button" onClick={() => onDelta(-10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground">−</button>
|
||||
<span className="text-sm font-mono font-bold tabular-nums" style={{ color: on ? accent : undefined }}>
|
||||
{hz > 0 ? '+' : hz < 0 ? '−' : ''}{Math.abs(hz)} Hz
|
||||
</span>
|
||||
<button type="button" onClick={() => onDelta(10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground">+</button>
|
||||
</div>
|
||||
<button type="button" onClick={onClear}
|
||||
className="w-8 shrink-0 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">0</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// sParts turns the raw 0-100 S-meter into S-unit + dB-over-S9 (S9 ≈ 47% on the
|
||||
// CI-V 0-255 scale, +60 dB near full scale). Used for both the display label and
|
||||
// the RST-tx value on click.
|
||||
@@ -797,10 +766,10 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
||||
<Card icon={SlidersHorizontal} title={t('icmp.clarifiers')} accent="#8b5cf6">
|
||||
<ShiftRow label="RIT" accent="#8b5cf6" on={st.rit_on} hz={st.rit_hz}
|
||||
onToggle={() => set({ rit_on: !st.rit_on }, () => IcomSetRITOn(!st.rit_on))}
|
||||
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
|
||||
onSet={setRit} />
|
||||
<ShiftRow label="ΔTX" accent="#f59e0b" on={st.xit_on} hz={st.rit_hz}
|
||||
onToggle={() => set({ xit_on: !st.xit_on }, () => IcomSetXITOn(!st.xit_on))}
|
||||
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
|
||||
onSet={setRit} />
|
||||
<p className="text-[11px] text-muted-foreground">{t('icmp.ritHint')}</p>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { WheelRange } from '@/components/WheelRange';
|
||||
|
||||
// LevelRow — a named level with a slider, a value you can type into, and ±.
|
||||
//
|
||||
// Shared, like ShiftRow, and for the same complaint: the consoles each drew
|
||||
// their levels their own way. This is the wide shape — one row per level, the
|
||||
// slider taking the width it needs — rather than two half-width sliders side by
|
||||
// side, which is what "c'est laid et elles sont toutes petites" was about.
|
||||
//
|
||||
// Four ways to move it, so nobody has to learn ours: drag, wheel over the
|
||||
// track, ± for one step, or click the number and type. Typing matters for the
|
||||
// levels TCI reports in real units — a squelch at -95 dBm is a value an
|
||||
// operator knows, not a position to hunt for with a mouse.
|
||||
export function LevelRow({
|
||||
label, value, min = 0, max = 100, step = 1, unit = '', accent, disabled, onSet,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
unit?: string;
|
||||
accent?: string;
|
||||
disabled?: boolean;
|
||||
onSet: (v: number) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const clamp = (v: number) => Math.max(min, Math.min(max, v));
|
||||
const commit = (raw: string) => {
|
||||
setEditing(null);
|
||||
const v = parseInt(raw.replace(/[^0-9+-]/g, ''), 10);
|
||||
if (!Number.isNaN(v)) onSet(clamp(v));
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-24 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<WheelRange
|
||||
min={min} max={max} step={step} value={value} disabled={disabled} accent={accent}
|
||||
onChange={onSet}
|
||||
className="h-2.5 flex-1 [&::-webkit-slider-thumb]:size-4"
|
||||
/>
|
||||
<div className={cn('flex items-center gap-0.5 shrink-0', disabled && 'opacity-40')}>
|
||||
<button type="button" disabled={disabled} onClick={() => onSet(clamp(value - step))}
|
||||
className="px-1.5 text-sm font-bold text-muted-foreground hover:text-foreground disabled:opacity-40">−</button>
|
||||
{editing !== null ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editing}
|
||||
onChange={(e) => setEditing(e.target.value)}
|
||||
onBlur={(e) => commit(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commit((e.target as HTMLInputElement).value);
|
||||
else if (e.key === 'Escape') setEditing(null);
|
||||
}}
|
||||
className="w-14 rounded border border-border bg-background px-1 text-right text-xs font-mono tabular-nums outline-none"
|
||||
/>
|
||||
) : (
|
||||
<button type="button" disabled={disabled} onClick={() => setEditing(String(value))}
|
||||
className="w-14 text-right text-xs font-mono tabular-nums hover:text-primary disabled:cursor-default">
|
||||
{value}{unit}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" disabled={disabled} onClick={() => onSet(clamp(value + step))}
|
||||
className="px-1.5 text-sm font-bold text-muted-foreground hover:text-foreground disabled:opacity-40">+</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ShiftRow — the RIT / XIT offset control, shared by the radio consoles.
|
||||
//
|
||||
// It began inside the Icom panel and is here because the next console needed
|
||||
// exactly it. Consoles that each invent their own way of nudging an offset make
|
||||
// an operator learn the same thing twice, which is the complaint that moved it:
|
||||
// "none of the consoles look alike".
|
||||
//
|
||||
// Three ways to move it, because operators reach for different ones: the ± keys,
|
||||
// the wheel over the number, and TYPING a value straight in. The last one is
|
||||
// what a button row cannot do — 'put me 300 Hz down' is one keystroke sequence,
|
||||
// not thirty clicks.
|
||||
export function ShiftRow({ label, on, hz, accent, disabled, step = 10, onToggle, onSet }: {
|
||||
label: string;
|
||||
on: boolean;
|
||||
hz: number;
|
||||
accent: string;
|
||||
disabled?: boolean;
|
||||
step?: number;
|
||||
onToggle: () => void;
|
||||
onSet: (hz: number) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const cb = useRef(onSet); cb.current = onSet;
|
||||
const cur = useRef({ hz, on, disabled }); cur.current = { hz, on, disabled };
|
||||
|
||||
// Wheel over the row. A native non-passive listener, because React's onWheel
|
||||
// is passive and cannot preventDefault — without that the panel scrolls under
|
||||
// the pointer while the number changes.
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
const c = cur.current;
|
||||
if (c.disabled || !c.on) return;
|
||||
e.preventDefault();
|
||||
cb.current(c.hz + (e.deltaY < 0 ? step : -step));
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, [step]);
|
||||
|
||||
const commit = (raw: string) => {
|
||||
setEditing(null);
|
||||
const v = parseInt(raw.replace(/[^0-9+-]/g, ''), 10);
|
||||
if (!Number.isNaN(v)) onSet(v);
|
||||
};
|
||||
|
||||
const dead = disabled || !on;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={onToggle} disabled={disabled}
|
||||
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors disabled:opacity-30',
|
||||
on ? 'bg-success border-success text-success-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
{label}
|
||||
</button>
|
||||
<div ref={ref} title="Wheel, ± or type"
|
||||
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none',
|
||||
dead ? 'border-border/60 bg-muted/20 opacity-60' : 'border-border bg-muted/40 cursor-ns-resize')}>
|
||||
<button type="button" disabled={dead} onClick={() => onSet(hz - step)}
|
||||
className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground disabled:opacity-40">−</button>
|
||||
{editing !== null ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editing}
|
||||
onChange={(e) => setEditing(e.target.value)}
|
||||
onBlur={(e) => commit(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commit((e.target as HTMLInputElement).value);
|
||||
else if (e.key === 'Escape') setEditing(null);
|
||||
}}
|
||||
className="w-20 bg-transparent text-center text-sm font-mono font-bold tabular-nums outline-none"
|
||||
/>
|
||||
) : (
|
||||
<button type="button" disabled={dead} onClick={() => setEditing(String(hz))}
|
||||
className="text-sm font-mono font-bold tabular-nums disabled:cursor-default"
|
||||
style={{ color: on ? accent : undefined }}>
|
||||
{hz > 0 ? '+' : hz < 0 ? '−' : ''}{Math.abs(hz)} Hz
|
||||
</button>
|
||||
)}
|
||||
<button type="button" disabled={dead} onClick={() => onSet(hz + step)}
|
||||
className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground disabled:opacity-40">+</button>
|
||||
</div>
|
||||
<button type="button" disabled={dead} onClick={() => onSet(0)}
|
||||
className="w-8 shrink-0 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted disabled:opacity-30">0</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { sMeterRST } from '@/lib/rst';
|
||||
import { MeterBar } from '@/components/MeterBar';
|
||||
import { WheelRange } from '@/components/WheelRange';
|
||||
import { ShiftRow } from '@/components/ShiftRow';
|
||||
import { LevelRow } from '@/components/LevelRow';
|
||||
|
||||
type TCIState = {
|
||||
connected: boolean; device?: string; protocol?: string;
|
||||
@@ -21,6 +23,7 @@ type TCIState = {
|
||||
filter_lo: number; filter_hi: number;
|
||||
rit: boolean; rit_offset: number; xit: boolean; xit_offset: number; lock: boolean; split: boolean;
|
||||
smeter: number; modulations?: string[];
|
||||
tx_power_w: number; tx_swr: number;
|
||||
};
|
||||
|
||||
const ZERO: TCIState = {
|
||||
@@ -28,20 +31,46 @@ const ZERO: TCIState = {
|
||||
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,
|
||||
tx_power_w: 0, tx_swr: 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 },
|
||||
];
|
||||
// The widths worth a button, PER MODE — because 250 Hz is useless in SSB and
|
||||
// 2.8 kHz is useless in CW, and a row offering both is a row where half the
|
||||
// buttons are never pressed.
|
||||
//
|
||||
// CW gets the narrow end, where the difference between 250 and 500 is the
|
||||
// difference between one signal and three. Voice gets the range a passband is
|
||||
// actually shaped over. Digital sits between: wide enough for a whole FT8
|
||||
// sub-band, narrow enough for RTTY.
|
||||
const WIDTHS_CW = [100, 250, 400, 500, 700, 1000, 1800];
|
||||
const WIDTHS_SSB = [1800, 2100, 2400, 2700, 2800, 3000, 3500];
|
||||
const WIDTHS_DIGI = [500, 1000, 1800, 2400, 2800, 3000, 3500];
|
||||
|
||||
// widthsFor picks the row from the mode the radio reports.
|
||||
function widthsFor(mode: string): number[] {
|
||||
if (/CW/i.test(mode)) return WIDTHS_CW;
|
||||
if (/SSB|USB|LSB|AM|FM/i.test(mode)) return WIDTHS_SSB;
|
||||
return WIDTHS_DIGI;
|
||||
}
|
||||
|
||||
// isCW says whether the CW-only controls belong on screen at all.
|
||||
function isCW(mode: string): boolean { return /CW/i.test(mode); }
|
||||
|
||||
function widthLabel(w: number): string {
|
||||
return w >= 1000 ? `${(w / 1000).toFixed(1)}k` : String(w);
|
||||
}
|
||||
|
||||
// edgesFor turns a width into the pair TCI wants: 0 to the width, and nothing
|
||||
// clever.
|
||||
//
|
||||
// It centred narrow filters on the CW note first — 250 became 575-825 — on the
|
||||
// reasoning that a CW filter should contain the note. That reasoning may even be
|
||||
// right for a radio, but it is not what the button says, and a button that does
|
||||
// not do what it says is worse than one that does something simple. 250 means
|
||||
// 0-250. The two edges are editable underneath for anything else.
|
||||
function edgesFor(w: number, _mode: string): { lo: number; hi: number } {
|
||||
return { lo: 0, hi: w };
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -113,16 +142,46 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
||||
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) => {
|
||||
// OPTIMISTIC, like the Icom console — and for a reason found on a real radio.
|
||||
//
|
||||
// This panel used to show only what the radio reported back, on the principle
|
||||
// that the radio is the truth. But ExpertSDR3 does not echo every setting it
|
||||
// is given: press MED and the radio changes, says nothing, and the button
|
||||
// stays lit on SLOW. Waiting for an answer that never comes reads as a dead
|
||||
// control.
|
||||
//
|
||||
// So a change is shown at once and held for a moment. Whatever the radio
|
||||
// announces afterwards — the new value, or a refusal that leaves the old one
|
||||
// — wins once the hold expires, which keeps a clamped or rejected setting
|
||||
// honest without making every working one look broken.
|
||||
// Held UNTIL THE RADIO SPEAKS, not for a fixed moment.
|
||||
//
|
||||
// A timeout was wrong in both directions. Too short and a setting the radio
|
||||
// never echoes — AGC is one — snapped back to its old value a second after
|
||||
// the click. Too long and a setting the radio REFUSES looked accepted: a real
|
||||
// log shows this one answering 'sql_enable:0,false' and then, eighty
|
||||
// milliseconds later, 'sql_enable:0,true' — it puts the squelch straight back
|
||||
// on. Holding through that would have shown the operator a lie.
|
||||
//
|
||||
// So the requested value stands while the radio says nothing about it, and
|
||||
// the instant it reports ANY change for that setting, its word replaces ours.
|
||||
const holdRef = useRef<Record<string, { v: any; reported: any }>>({});
|
||||
const [, forceRender] = useState(0);
|
||||
const hold = <T,>(key: string, reported: T): T => {
|
||||
const h = holdRef.current[key];
|
||||
return h && Date.now() < h.until ? h.v : reported;
|
||||
if (!h) return reported;
|
||||
// The radio has said something different from what it was saying when the
|
||||
// click happened — whether that is our value or a refusal, it is now the
|
||||
// truth and the hold is over.
|
||||
if (reported !== h.reported) {
|
||||
delete holdRef.current[key];
|
||||
return reported;
|
||||
}
|
||||
return h.v as T;
|
||||
};
|
||||
const setHold = (key: string, v: number) => {
|
||||
holdRef.current[key] = { v, until: Date.now() + 900 };
|
||||
const setHold = (key: string, v: any, reported: any) => {
|
||||
holdRef.current[key] = { v, reported };
|
||||
forceRender((n) => n + 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -146,6 +205,7 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
||||
}, []);
|
||||
|
||||
const off = !st.connected;
|
||||
|
||||
const call = (fn: () => Promise<any>) => { fn().catch((e: any) => setErr(String(e?.message ?? e))); };
|
||||
|
||||
const drive = hold('drive', st.drive);
|
||||
@@ -153,8 +213,32 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
||||
const mic = hold('mic', st.mic_level);
|
||||
const vol = hold('vol', st.volume);
|
||||
const sql = hold('sql', st.squelch);
|
||||
const agc = hold('agc', st.agc || '');
|
||||
const nb = hold('nb', st.nb), nr = hold('nr', st.nr);
|
||||
const anf = hold('anf', st.anf), apf = hold('apf', st.apf);
|
||||
const sqlOn = hold('sql_on', st.squelch_on), muted = hold('mute', st.mute);
|
||||
const rit = hold('rit', st.rit), xit = hold('xit', st.xit);
|
||||
const ritHz = hold('rit_hz', st.rit_offset), xitHz = hold('xit_hz', st.xit_offset);
|
||||
const s = sParts(st.smeter);
|
||||
|
||||
// Ctrl+Left/Right shifts the RIT by ±10 Hz, the same keys the Icom console
|
||||
// uses. Two consoles for two radios should not need two habits.
|
||||
const ritRef = useRef({ on: false, hz: 0, off: true });
|
||||
ritRef.current = { on: rit, hz: ritHz, off };
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!e.ctrlKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
|
||||
const r = ritRef.current;
|
||||
if (r.off || !r.on) return;
|
||||
e.preventDefault();
|
||||
const v = r.hz + (e.key === 'ArrowRight' ? 10 : -10);
|
||||
setHold('rit_hz', v, ritRef.current.hz);
|
||||
SetTCIRITOffset(v).catch(() => {});
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||
{/* Capped and centred, like the Elecraft, Yaesu, Icom and Flex consoles.
|
||||
@@ -183,32 +267,44 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
||||
{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. */}
|
||||
{/* Meters. The S-meter while receiving, power and SWR while
|
||||
transmitting — the radio answers TX_POWER and TX_SWR only when it is
|
||||
keyed, so showing them the rest of the time would be showing the
|
||||
last thing that happened as if it were now.
|
||||
There is no temperature: the protocol has no such command, and a
|
||||
made-up figure on a transmitter is the kind somebody trusts. */}
|
||||
<Card icon={Activity} title={t('tcip.meters')}>
|
||||
<MeterBar label="S-METER" value={st.tx ? 0 : sBar(st.smeter)} lo={0} hi={100}
|
||||
<MeterBar label="S-METER" value={st.tx || st.tuning ? 0 : sBar(st.smeter)} lo={0} hi={100}
|
||||
accent="#16a34a" segColor={sSegColor}
|
||||
display={st.tx ? '—' : `${s.label} ${st.smeter} dBm`}
|
||||
display={st.tx || st.tuning ? '—' : `${s.label} ${st.smeter} dBm`}
|
||||
onClick={() => {
|
||||
if (st.tx || !onReportRST) return;
|
||||
onReportRST(sMeterRST(s.s, s.over, mode));
|
||||
}}
|
||||
title={t('tcip.sMeterHint')} />
|
||||
{(st.tx || st.tuning) && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<MeterBar label="PWR" value={st.tx_power_w} lo={0} hi={Math.max(10, Math.ceil(st.tx_power_w / 10) * 10)}
|
||||
accent="#0ea5e9" display={`${st.tx_power_w.toFixed(1)} W`} />
|
||||
{/* 0 is "not measured yet", and it must not draw as a perfect
|
||||
match: an SWR of 1.0 on an antenna nobody has measured is the
|
||||
one reading an operator should not be handed. */}
|
||||
<MeterBar label="SWR" value={st.tx_swr > 0 ? Math.min(100, (st.tx_swr - 1) * 50) : 0} lo={0} hi={100}
|
||||
accent="#f59e0b" display={st.tx_swr > 0 ? st.tx_swr.toFixed(1) : '—'}
|
||||
segColor={(f) => (f > 0.5 ? '#dc2626' : f > 0.25 ? '#f59e0b' : '#16a34a')} />
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
{/* One level per ROW, full width. Two half-width sliders side by side
|
||||
left each of them a couple of centimetres long — small enough that
|
||||
setting 15% took aim. */}
|
||||
<LevelRow label={t('tcip.drive')} unit="%" value={drive} disabled={off}
|
||||
onSet={(v) => { setHold('drive', v, st.drive); call(() => SetTCIDrive(v)); }} />
|
||||
<LevelRow label={t('tcip.tuneDrive')} unit="%" value={tuneDrive} disabled={off} accent="#f59e0b"
|
||||
onSet={(v) => { setHold('tune_drive', v, st.tune_drive); call(() => SetTCITuneDrive(v)); }} />
|
||||
<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
|
||||
@@ -224,41 +320,42 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
||||
<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>
|
||||
<LevelRow label={t('tcip.mic')} unit="%" value={mic} disabled={off} accent="#a855f7"
|
||||
onSet={(v) => { setHold('mic', v, st.mic_level); call(() => SetTCIMicLevel(v)); }} />
|
||||
</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))} />
|
||||
{/* Both in the radio's OWN units — volume in dB, negative, and the
|
||||
squelch as a dBm threshold — so the numbers match the ones in
|
||||
ExpertSDR3's window rather than being percentages of something. */}
|
||||
<LevelRow label={t('tcip.volume')} unit=" dB" min={-60} max={0} value={vol} disabled={off}
|
||||
onSet={(v) => { setHold('vol', v, st.volume); call(() => SetTCIVolume(v)); }} />
|
||||
<LevelRow label={t('tcip.squelch')} unit=" dBm" min={-140} max={0} value={sql}
|
||||
disabled={off || !sqlOn} accent="#38bdf8"
|
||||
onSet={(v) => { setHold('sql', v, st.squelch); call(() => SetTCISquelchLevel(v)); }} />
|
||||
<div className={cn('grid gap-2', isCW(mode) ? 'grid-cols-3 sm:grid-cols-6' : 'grid-cols-3 sm:grid-cols-5')}>
|
||||
<Toggle label="NB" on={nb} off={off} onClick={() => { setHold('nb', !nb, st.nb); call(() => SetTCINB(!nb)); }} />
|
||||
<Toggle label="NR" on={nr} off={off} onClick={() => { setHold('nr', !nr, st.nr); call(() => SetTCINR(!nr)); }} />
|
||||
<Toggle label="ANF" on={anf} off={off} onClick={() => { setHold('anf', !anf, st.anf); call(() => SetTCIANF(!anf)); }} />
|
||||
{/* APF is an audio PEAK filter — it rings a single tone out of the
|
||||
noise, which is a CW tool and nothing else. Shown only there:
|
||||
off CW it is not a control, it is a puzzle. */}
|
||||
{isCW(mode) && (
|
||||
<Toggle label="APF" on={apf} off={off} onClick={() => { setHold('apf', !apf, st.apf); call(() => SetTCIAPF(!apf)); }} />
|
||||
)}
|
||||
<Toggle label="SQL" on={sqlOn} off={off} onClick={() => { setHold('sql_on', !sqlOn, st.squelch_on); call(() => SetTCISquelch(!sqlOn)); }} />
|
||||
<Toggle label={t('tcip.mute')} on={muted} off={off} onClick={() => { setHold('mute', !muted, st.mute); call(() => SetTCIMute(!muted)); }} />
|
||||
</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))} />
|
||||
{/* LONG is gone. The protocol accepts it, but it is a hang time
|
||||
nobody reaches for between overs, and a fifth button that has to
|
||||
be explained is worse than four that do not. */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{['off', 'slow', 'med', 'fast'].map((m) => (
|
||||
<Toggle key={m} label={m.toUpperCase()} on={agc === m} off={off}
|
||||
onClick={() => { setHold('agc', m, st.agc || ''); call(() => SetTCIAGC(m)); }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,45 +364,45 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
||||
<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="flex items-center gap-2 pb-1">
|
||||
<LevelRow label="LO" unit=" Hz" min={-5000} max={5000} step={10}
|
||||
value={st.filter_lo} disabled={off}
|
||||
onSet={(v) => call(() => SetTCIFilter(v, st.filter_hi))} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pb-1">
|
||||
<LevelRow label="HI" unit=" Hz" min={-5000} max={5000} step={10}
|
||||
value={st.filter_hi} disabled={off}
|
||||
onSet={(v) => call(() => SetTCIFilter(st.filter_lo, v))} />
|
||||
</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))} />
|
||||
))}
|
||||
{widthsFor(mode).map((w) => {
|
||||
const e = edgesFor(w, mode);
|
||||
// Lit by the WIDTH the radio is actually using, not by an exact
|
||||
// pair of edges: the operator may have moved one edge on the
|
||||
// radio, and a button that only lights on our own numbers would
|
||||
// go dark for a filter that is plainly 500 Hz wide.
|
||||
const on = Math.abs((st.filter_hi - st.filter_lo) - w) <= 50;
|
||||
return (
|
||||
<Toggle key={w} label={widthLabel(w)} on={on} off={off}
|
||||
title={`${e.lo}–${e.hi} Hz`}
|
||||
onClick={() => call(() => SetTCIFilter(e.lo, e.hi))} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* RIT / XIT */}
|
||||
{/* RIT / XIT — the SAME control the Icom console uses, now shared
|
||||
rather than reinvented: a chip, a signed offset you can type into,
|
||||
scroll on, or step with ±, and a zero. Ctrl+←/→ shifts the RIT. */}
|
||||
<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>
|
||||
))}
|
||||
<ShiftRow label="RIT" on={rit} hz={ritHz} accent="#38bdf8" disabled={off}
|
||||
onToggle={() => { setHold('rit', !rit, st.rit); call(() => SetTCIRIT(!rit)); }}
|
||||
onSet={(v) => { setHold('rit_hz', v, st.rit_offset); call(() => SetTCIRITOffset(v)); }} />
|
||||
<ShiftRow label="XIT" on={xit} hz={xitHz} accent="#f59e0b" disabled={off}
|
||||
onToggle={() => { setHold('xit', !xit, st.xit); call(() => SetTCIXIT(!xit)); }}
|
||||
onSet={(v) => { setHold('xit_hz', v, st.xit_offset); call(() => SetTCIXITOffset(v)); }} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1238,6 +1238,8 @@ export namespace cat {
|
||||
lock: boolean;
|
||||
split: boolean;
|
||||
smeter: number;
|
||||
tx_power_w: number;
|
||||
tx_swr: number;
|
||||
modulations?: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -1273,6 +1275,8 @@ export namespace cat {
|
||||
this.lock = source["lock"];
|
||||
this.split = source["split"];
|
||||
this.smeter = source["smeter"];
|
||||
this.tx_power_w = source["tx_power_w"];
|
||||
this.tx_swr = source["tx_swr"];
|
||||
this.modulations = source["modulations"];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user