fix(tci): the drive commands need the TRX index, and the console holds its own clicks
Two faults, reported from a real SunSDR. DRIVE AND TUNE DRIVE DID NOTHING, and neither did MUTE. Those commands carry the transceiver index — 'drive:0,15;', not 'drive:15;' — and sent without it the radio ignores them silently: no error, no answer, the power unchanged. The rule was in the radio's own reports all along, which is where it should have been read from: it announces 'drive:0,85' and 'mute:0,false' at connect, while 'mic_level:100' and 'volume:-12' come with no index at all. Sending the shape the radio speaks in is the whole rule, and it is now written down next to the two exceptions. AGC LOOKED STUCK ON SLOW. The panel showed only what the radio reported back, on the principle that the radio is the truth — but ExpertSDR3 does not echo every setting it accepts, so a working button sat unlit. Changes are shown at once and held for a moment now; whatever the radio announces afterwards still wins, so a clamped or refused setting stays honest without every working one looking broken. Also from the same report, and fair: the consoles did not resemble each other. The Icom panel's RIT control is now a shared component both use — chip, signed offset, ± keys, wheel, and TYPING a value straight in, which is the thing a row of ±10/±100 buttons cannot do. Ctrl+←/→ shifts the RIT here as it does there. And LONG is gone from the AGC row: the protocol takes it, but it is a hang time nobody reaches for between overs.
This commit is contained in:
@@ -14,6 +14,8 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { sMeterRST } from '@/lib/rst';
|
import { sMeterRST } from '@/lib/rst';
|
||||||
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
|
import { ShiftRow } from '@/components/ShiftRow';
|
||||||
|
|
||||||
type IcomState = {
|
type IcomState = {
|
||||||
available: boolean; model?: string; mode?: string;
|
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>;
|
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
|
// 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
|
// CI-V 0-255 scale, +60 dB near full scale). Used for both the display label and
|
||||||
// the RST-tx value on click.
|
// 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">
|
<Card icon={SlidersHorizontal} title={t('icmp.clarifiers')} accent="#8b5cf6">
|
||||||
<ShiftRow label="RIT" accent="#8b5cf6" on={st.rit_on} hz={st.rit_hz}
|
<ShiftRow label="RIT" accent="#8b5cf6" on={st.rit_on} hz={st.rit_hz}
|
||||||
onToggle={() => set({ rit_on: !st.rit_on }, () => IcomSetRITOn(!st.rit_on))}
|
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}
|
<ShiftRow label="ΔTX" accent="#f59e0b" on={st.xit_on} hz={st.rit_hz}
|
||||||
onToggle={() => set({ xit_on: !st.xit_on }, () => IcomSetXITOn(!st.xit_on))}
|
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>
|
<p className="text-[11px] text-muted-foreground">{t('icmp.ritHint')}</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
import { sMeterRST } from '@/lib/rst';
|
import { sMeterRST } from '@/lib/rst';
|
||||||
import { MeterBar } from '@/components/MeterBar';
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
import { WheelRange } from '@/components/WheelRange';
|
import { WheelRange } from '@/components/WheelRange';
|
||||||
|
import { ShiftRow } from '@/components/ShiftRow';
|
||||||
|
|
||||||
type TCIState = {
|
type TCIState = {
|
||||||
connected: boolean; device?: string; protocol?: string;
|
connected: boolean; device?: string; protocol?: string;
|
||||||
@@ -113,16 +114,27 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
const [mode, setMode] = useState('');
|
const [mode, setMode] = useState('');
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
// A slider the operator is dragging must not be dragged back by the poll.
|
// OPTIMISTIC, like the Icom console — and for a reason found on a real radio.
|
||||||
// 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.
|
// This panel used to show only what the radio reported back, on the principle
|
||||||
const holdRef = useRef<Record<string, { v: number; until: number }>>({});
|
// that the radio is the truth. But ExpertSDR3 does not echo every setting it
|
||||||
const hold = (key: string, reported: number) => {
|
// 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.
|
||||||
|
const holdRef = useRef<Record<string, { v: any; until: number }>>({});
|
||||||
|
const [, forceRender] = useState(0);
|
||||||
|
const hold = <T,>(key: string, reported: T): T => {
|
||||||
const h = holdRef.current[key];
|
const h = holdRef.current[key];
|
||||||
return h && Date.now() < h.until ? h.v : reported;
|
return h && Date.now() < h.until ? (h.v as T) : reported;
|
||||||
};
|
};
|
||||||
const setHold = (key: string, v: number) => {
|
const setHold = (key: string, v: any) => {
|
||||||
holdRef.current[key] = { v, until: Date.now() + 900 };
|
holdRef.current[key] = { v, until: Date.now() + 1200 };
|
||||||
|
forceRender((n) => n + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -146,6 +158,7 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const off = !st.connected;
|
const off = !st.connected;
|
||||||
|
|
||||||
const call = (fn: () => Promise<any>) => { fn().catch((e: any) => setErr(String(e?.message ?? e))); };
|
const call = (fn: () => Promise<any>) => { fn().catch((e: any) => setErr(String(e?.message ?? e))); };
|
||||||
|
|
||||||
const drive = hold('drive', st.drive);
|
const drive = hold('drive', st.drive);
|
||||||
@@ -153,8 +166,32 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
const mic = hold('mic', st.mic_level);
|
const mic = hold('mic', st.mic_level);
|
||||||
const vol = hold('vol', st.volume);
|
const vol = hold('vol', st.volume);
|
||||||
const sql = hold('sql', st.squelch);
|
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);
|
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);
|
||||||
|
SetTCIRITOffset(v).catch(() => {});
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full min-h-0 overflow-auto bg-background">
|
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||||
{/* Capped and centred, like the Elecraft, Yaesu, Icom and Flex consoles.
|
{/* Capped and centred, like the Elecraft, Yaesu, Icom and Flex consoles.
|
||||||
@@ -240,25 +277,28 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
<WheelRange min={-60} max={0} disabled={off} value={vol}
|
<WheelRange min={-60} max={0} disabled={off} value={vol}
|
||||||
onChange={(v) => { setHold('vol', v); call(() => SetTCIVolume(v)); }} />
|
onChange={(v) => { setHold('vol', v); call(() => SetTCIVolume(v)); }} />
|
||||||
</Row>
|
</Row>
|
||||||
<Row label={t('tcip.squelch')} value={st.squelch_on ? `${sql} dBm` : t('tcip.off')}>
|
<Row label={t('tcip.squelch')} value={sqlOn ? `${sql} dBm` : t('tcip.off')}>
|
||||||
<WheelRange min={-140} max={0} disabled={off || !st.squelch_on} value={sql}
|
<WheelRange min={-140} max={0} disabled={off || !sqlOn} value={sql}
|
||||||
onChange={(v) => { setHold('sql', v); call(() => SetTCISquelchLevel(v)); }} />
|
onChange={(v) => { setHold('sql', v); call(() => SetTCISquelchLevel(v)); }} />
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2">
|
<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="NB" on={nb} off={off} onClick={() => { setHold('nb', !nb); call(() => SetTCINB(!nb)); }} />
|
||||||
<Toggle label="NR" on={st.nr} off={off} onClick={() => call(() => SetTCINR(!st.nr))} />
|
<Toggle label="NR" on={nr} off={off} onClick={() => { setHold('nr', !nr); call(() => SetTCINR(!nr)); }} />
|
||||||
<Toggle label="ANF" on={st.anf} off={off} onClick={() => call(() => SetTCIANF(!st.anf))} />
|
<Toggle label="ANF" on={anf} off={off} onClick={() => { setHold('anf', !anf); call(() => SetTCIANF(!anf)); }} />
|
||||||
<Toggle label="APF" on={st.apf} off={off} onClick={() => call(() => SetTCIAPF(!st.apf))} />
|
<Toggle label="APF" on={apf} off={off} onClick={() => { setHold('apf', !apf); call(() => SetTCIAPF(!apf)); }} />
|
||||||
<Toggle label="SQL" on={st.squelch_on} off={off} onClick={() => call(() => SetTCISquelch(!st.squelch_on))} />
|
<Toggle label="SQL" on={sqlOn} off={off} onClick={() => { setHold('sql_on', !sqlOn); call(() => SetTCISquelch(!sqlOn)); }} />
|
||||||
<Toggle label={t('tcip.mute')} on={st.mute} off={off} onClick={() => call(() => SetTCIMute(!st.mute))} />
|
<Toggle label={t('tcip.mute')} on={muted} off={off} onClick={() => { setHold('mute', !muted); call(() => SetTCIMute(!muted)); }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{t('tcip.agc')}</span>
|
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{t('tcip.agc')}</span>
|
||||||
<div className="grid grid-cols-5 gap-2">
|
{/* LONG is gone. The protocol accepts it, but it is a hang time
|
||||||
{['off', 'long', 'slow', 'med', 'fast'].map((m) => (
|
nobody reaches for between overs, and a fifth button that has to
|
||||||
<Toggle key={m} label={m.toUpperCase()} on={(st.agc || '') === m} off={off}
|
be explained is worse than four that do not. */}
|
||||||
onClick={() => call(() => SetTCIAGC(m))} />
|
<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); call(() => SetTCIAGC(m)); }} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,35 +317,17 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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">
|
<Card icon={Mic} title="RIT / XIT">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{([
|
<ShiftRow label="RIT" on={rit} hz={ritHz} accent="#38bdf8" disabled={off}
|
||||||
{ key: 'rit', on: st.rit, offset: st.rit_offset, toggle: SetTCIRIT, set: SetTCIRITOffset },
|
onToggle={() => { setHold('rit', !rit); call(() => SetTCIRIT(!rit)); }}
|
||||||
{ key: 'xit', on: st.xit, offset: st.xit_offset, toggle: SetTCIXIT, set: SetTCIXITOffset },
|
onSet={(v) => { setHold('rit_hz', v); call(() => SetTCIRITOffset(v)); }} />
|
||||||
] as const).map((r) => (
|
<ShiftRow label="XIT" on={xit} hz={xitHz} accent="#f59e0b" disabled={off}
|
||||||
<div key={r.key} className="flex items-center gap-2">
|
onToggle={() => { setHold('xit', !xit); call(() => SetTCIXIT(!xit)); }}
|
||||||
<Toggle label={r.key.toUpperCase()} on={r.on} off={off} onClick={() => call(() => r.toggle(!r.on))} />
|
onSet={(v) => { setHold('xit_hz', v); call(() => SetTCIXITOffset(v)); }} />
|
||||||
<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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -228,12 +228,22 @@ func (t *TCI) TCIPanel() TCIPanelState {
|
|||||||
// later.
|
// later.
|
||||||
|
|
||||||
// SetDrive sets the transmit drive, 0-100.
|
// SetDrive sets the transmit drive, 0-100.
|
||||||
func (t *TCI) SetDrive(v int) error { return t.send(fmt.Sprintf("drive:%d;", clampTCIPct(v))) }
|
//
|
||||||
|
// THE TRX INDEX IS PART OF THE COMMAND — "drive:0,15;", not "drive:15;". Sent
|
||||||
|
// without it the radio simply ignores it: no error, no answer, the power
|
||||||
|
// unchanged. The rule is the one the radio's own reports follow, and it was
|
||||||
|
// there to read all along: this radio announces "drive:0,85" at connect.
|
||||||
|
func (t *TCI) SetDrive(v int) error { return t.send(fmt.Sprintf("drive:0,%d;", clampTCIPct(v))) }
|
||||||
|
|
||||||
// SetTuneDrive sets the drive used by TUNE, 0-100.
|
// SetTuneDrive sets the drive used by TUNE, 0-100. Indexed, like drive.
|
||||||
func (t *TCI) SetTuneDrive(v int) error { return t.send(fmt.Sprintf("tune_drive:%d;", clampTCIPct(v))) }
|
func (t *TCI) SetTuneDrive(v int) error {
|
||||||
|
return t.send(fmt.Sprintf("tune_drive:0,%d;", clampTCIPct(v)))
|
||||||
|
}
|
||||||
|
|
||||||
// SetMicLevel sets the microphone gain, 0-100.
|
// SetMicLevel sets the microphone gain, 0-100.
|
||||||
|
// Mic gain and volume are the two that are NOT indexed — the radio reports
|
||||||
|
// them as "mic_level:100" and "volume:-12", with no receiver in front. Sending
|
||||||
|
// the shape the radio speaks in is the whole rule here.
|
||||||
func (t *TCI) SetMicLevel(v int) error { return t.send(fmt.Sprintf("mic_level:%d;", clampTCIPct(v))) }
|
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
|
// SetVolume sets the receive volume in dB. TCI's scale is negative — 0 is full
|
||||||
@@ -248,8 +258,9 @@ func (t *TCI) SetVolume(db int) error {
|
|||||||
return t.send(fmt.Sprintf("volume:%d;", db))
|
return t.send(fmt.Sprintf("volume:%d;", db))
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMute mutes or unmutes the receiver.
|
// SetMute mutes or unmutes the receiver. Indexed — the radio reports
|
||||||
func (t *TCI) SetMute(on bool) error { return t.send(fmt.Sprintf("mute:%t;", on)) }
|
// "mute:0,false", and a mute sent without the index goes nowhere.
|
||||||
|
func (t *TCI) SetMute(on bool) error { return t.send(fmt.Sprintf("mute:0,%t;", on)) }
|
||||||
|
|
||||||
// SetAGC picks the AGC speed: off, long, slow, med, fast.
|
// SetAGC picks the AGC speed: off, long, slow, med, fast.
|
||||||
func (t *TCI) SetAGC(mode string) error {
|
func (t *TCI) SetAGC(mode string) error {
|
||||||
|
|||||||
Reference in New Issue
Block a user