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:
2026-08-26 22:07:24 +02:00
11 changed files with 471 additions and 149 deletions
+1 -1
View File
@@ -1 +1 @@
f9b41e192918fa2511f68cd1b361fcd3
704fe1bf370b669665df0606fae8a69d
+4 -35
View File
@@ -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>
+72
View File
@@ -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>
);
}
+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>
);
}
+192 -95
View File
@@ -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>
+4
View File
@@ -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"];
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ type Flex struct {
meterRawLogged bool // log the first raw meter-definition status once
txRawLogged bool // log the first raw transmit status once (field-name audit)
spotsEnabled bool // push cluster spots + manage the panadapter overlay
spotsEnabled bool // push cluster spots + manage the panadapter overlay
// foreignSpotSeen counts what probeForeignSpot has already reported, so a
// skimmer posting all evening cannot turn the log into its own transcript.
foreignSpotSeen int
+3 -3
View File
@@ -98,11 +98,11 @@ type Kenwood struct {
// Panel state — the K3/K4 control panel, see kenwood_panel.go. Read on the
// same serialised link as everything else, on a slow beat for the settings
// and every poll for the meters.
panel KenwoodTXState
panel KenwoodTXState
// The icon/status word, for working out which bit says "ATU in line" — see
// probeIcons. Kept so only CHANGES are logged.
lastIcons string
iconProbes int
lastIcons string
iconProbes int
panelCycle int
panelLoaded bool
metersLogged int
+16
View File
@@ -304,6 +304,22 @@ func (t *TCI) ReadState() (RigState, error) {
} else {
st.FreqHz = t.freqA
}
// The transmit meters are asked for, not pushed: TX_POWER and TX_SWR are
// read-only commands the radio answers when asked, and asking is only worth
// anything while it is keyed. Fired and forgotten from here — the answers
// arrive on the reader like everything else — and only while transmitting,
// so a receiving station pays nothing for a meter nobody is watching.
// Keyed by PTT **or** by TUNE. A tune carrier is exactly when the meters
// matter most — it is the carrier an operator is watching an SWR on — and
// asking only on t.tx left them at zero for the whole tune, because the
// radio reports tuning as its own state and not as a transmission.
if t.tx || t.panel.st.Tuning {
tx := t
go func() {
_ = tx.send("tx_power;")
_ = tx.send("tx_swr;")
}()
}
st.Mode = tciModeToADIF(t.mode, t.digitalDefault)
if st.FreqHz > 0 {
st.Band = BandFromHz(st.FreqHz)
+80 -7
View File
@@ -68,6 +68,17 @@ type TCIPanelState struct {
// several times a second while receiving.
SMeter int `json:"smeter"`
// TXPowerW and TXSWR are the transmit meters. READ-ONLY in TCI, and only
// answered while transmitting — asked for on every poll of a keyed radio,
// see ReadState.
//
// There is no temperature in this protocol. The command list has TX_POWER
// and TX_SWR and nothing thermal at all, so a temperature reading here would
// have to be invented, and an invented temperature on a transmitter is the
// kind of number somebody trusts.
TXPowerW float64 `json:"tx_power_w"`
TXSWR float64 `json:"tx_swr"`
// Modulations is what this radio will accept, straight from its own
// announcement, so the mode buttons are the radio's and not a guess.
Modulations []string `json:"modulations,omitempty"`
@@ -77,6 +88,8 @@ type TCIPanelState struct {
// arrives alongside.
type tciPanel struct {
st TCIPanelState
// logged counts what has been written per message type — see handlePanel.
logged map[string]int
}
// handlePanel takes the messages the console cares about.
@@ -95,6 +108,28 @@ func (t *TCI) handlePanel(name string, get func(int) string, args string) bool {
yes := func(s string) bool { return strings.EqualFold(strings.TrimSpace(s), "true") }
p := &t.panel.st
// Mute and squelch are LOGGED as they change, because a report from a real
// radio says pressing MUTE lights the squelch and nothing here can explain
// it. What the radio actually announces after the command settles whether
// this is our reading or its doing, and no amount of reasoning will.
switch name {
case "mute", "sql_enable", "sql_level", "tx_power", "tx_swr", "tune":
// Logged on arrival so an ANSWER can be told from a SILENCE: the log
// showed the transmit meters being asked for and nothing coming back,
// which on its own proves nothing — a reply that arrived and failed to
// parse leaves exactly the same trace as one that never came.
//
// Capped per message type. The meters are asked for four times a second
// while transmitting, and a diagnostic that fills an evening's log is
// one that gets switched off instead of read.
if t.panel.logged == nil {
t.panel.logged = map[string]int{}
}
if n := t.panel.logged[name]; n < 20 {
t.panel.logged[name] = n + 1
debugLog.Printf("TCI: %s:%s", name, args)
}
}
switch name {
case "protocol":
p.Protocol = strings.TrimSpace(args)
@@ -120,7 +155,15 @@ func (t *TCI) handlePanel(name string, get func(int) string, args string) bool {
p.Volume = n
}
case "mute":
p.Mute = yes(get(1))
// Both shapes. This radio reports "mute:0,false" and the reference shows
// "mute:true" elsewhere — reading only one of them left the button
// showing the opposite of the truth, which is worse than showing
// nothing.
if get(1) != "" {
p.Mute = yes(get(1))
} else {
p.Mute = yes(get(0))
}
case "agc_mode":
if forRX0() {
p.AGC = strings.ToLower(strings.TrimSpace(get(1)))
@@ -178,6 +221,14 @@ func (t *TCI) handlePanel(name string, get func(int) string, args string) bool {
if forRX0() {
p.Lock = yes(get(1))
}
case "tx_power":
if v, err := strconv.ParseFloat(strings.TrimSpace(get(0)), 64); err == nil {
p.TXPowerW = v
}
case "tx_swr":
if v, err := strconv.ParseFloat(strings.TrimSpace(get(0)), 64); err == nil {
p.TXSWR = v
}
case "rx_smeter":
if n, ok := num(get(1)); ok && forRX0() {
p.SMeter = n
@@ -228,12 +279,22 @@ func (t *TCI) TCIPanel() TCIPanelState {
// later.
// 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.
func (t *TCI) SetTuneDrive(v int) error { return t.send(fmt.Sprintf("tune_drive:%d;", clampTCIPct(v))) }
// 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:0,%d;", clampTCIPct(v)))
}
// 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))) }
// SetVolume sets the receive volume in dB. TCI's scale is negative — 0 is full
@@ -248,8 +309,9 @@ func (t *TCI) SetVolume(db int) error {
return t.send(fmt.Sprintf("volume:%d;", db))
}
// SetMute mutes or unmutes the receiver.
func (t *TCI) SetMute(on bool) error { return t.send(fmt.Sprintf("mute:%t;", on)) }
// SetMute mutes or unmutes the receiver. Indexed — the radio reports
// "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.
func (t *TCI) SetAGC(mode string) error {
@@ -295,7 +357,18 @@ func (t *TCI) SetLock(on bool) error { return t.send(fmt.Sprintf("lock:0,%t;", o
//
// It TRANSMITS, at tune_drive rather than at drive — which is the setting to
// check before pressing it, and why the panel shows the two side by side.
func (t *TCI) SetTune(on bool) error { return t.send(fmt.Sprintf("tune:0,%t;", on)) }
//
// The state is recorded HERE rather than waited for. This radio does not echo
// "tune:0,true", so the panel had no way of knowing a tune was running: the
// button stayed on TUNE and every further press sent another START, which is
// why it could not be switched off again. Whatever the radio says afterwards
// still wins — it simply never says anything.
func (t *TCI) SetTune(on bool) error {
t.mu.Lock()
t.panel.st.Tuning = on
t.mu.Unlock()
return t.send(fmt.Sprintf("tune:0,%t;", on))
}
func clampTCIPct(v int) int {
if v < 0 {
+7 -7
View File
@@ -51,13 +51,13 @@ type YaesuTXState struct {
// NarrowSupported says the rig answered NA at all. A button that reports a
// state the radio never gave, and does nothing when pressed, is worse than
// an absent one: it looks like a fault in the radio.
NarrowSupported bool `json:"narrow_supported"`
MicGain int `json:"mic_gain"` // 0-100
AFGain int `json:"af_gain"` // 0-100
RFGain int `json:"rf_gain"` // 0-100
Squelch int `json:"squelch"` // 0-100
AGC string `json:"agc,omitempty"`
Preamp int `json:"preamp"` // 0=IPO, 1=AMP1, 2=AMP2
NarrowSupported bool `json:"narrow_supported"`
MicGain int `json:"mic_gain"` // 0-100
AFGain int `json:"af_gain"` // 0-100
RFGain int `json:"rf_gain"` // 0-100
Squelch int `json:"squelch"` // 0-100
AGC string `json:"agc,omitempty"`
Preamp int `json:"preamp"` // 0=IPO, 1=AMP1, 2=AMP2
// Antenna is the selected jack, 1-3, or 0 when the rig has no AN command —
// an FT-891 or FT-991A has a single socket and answers nothing. 0 is what
// tells the panel to draw no selector at all rather than a dead one.