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:
2026-08-26 19:15:48 +02:00
parent 4c2638a7e5
commit fc79be7c05
4 changed files with 180 additions and 87 deletions
+91
View File
@@ -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>
);
}