diff --git a/frontend/src/components/IcomPanel.tsx b/frontend/src/components/IcomPanel.tsx index 500b392..bdcd6f4 100644 --- a/frontend/src/components/IcomPanel.tsx +++ b/frontend/src/components/IcomPanel.tsx @@ -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
{body}
; } -// 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(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 ( -
- -
- - - {hz > 0 ? '+' : hz < 0 ? '−' : ''}{Math.abs(hz)} Hz - - -
- -
- ); -} - // 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 set({ rit_on: !st.rit_on }, () => IcomSetRITOn(!st.rit_on))} - onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} /> + onSet={setRit} /> set({ xit_on: !st.xit_on }, () => IcomSetXITOn(!st.xit_on))} - onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} /> + onSet={setRit} />

{t('icmp.ritHint')}

diff --git a/frontend/src/components/ShiftRow.tsx b/frontend/src/components/ShiftRow.tsx new file mode 100644 index 0000000..f135322 --- /dev/null +++ b/frontend/src/components/ShiftRow.tsx @@ -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(null); + const [editing, setEditing] = useState(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 ( +
+ +
+ + {editing !== null ? ( + 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" + /> + ) : ( + + )} + +
+ +
+ ); +} diff --git a/frontend/src/components/TCIPanel.tsx b/frontend/src/components/TCIPanel.tsx index 065eeef..ebddcf2 100644 --- a/frontend/src/components/TCIPanel.tsx +++ b/frontend/src/components/TCIPanel.tsx @@ -12,6 +12,7 @@ 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'; type TCIState = { connected: boolean; device?: string; protocol?: string; @@ -113,16 +114,27 @@ 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>({}); - 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. + const holdRef = useRef>({}); + const [, forceRender] = useState(0); + const hold = (key: string, reported: T): T => { 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) => { - holdRef.current[key] = { v, until: Date.now() + 900 }; + const setHold = (key: string, v: any) => { + holdRef.current[key] = { v, until: Date.now() + 1200 }; + forceRender((n) => n + 1); }; useEffect(() => { @@ -146,6 +158,7 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void }, []); const off = !st.connected; + const call = (fn: () => Promise) => { fn().catch((e: any) => setErr(String(e?.message ?? e))); }; 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 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); + SetTCIRITOffset(v).catch(() => {}); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + return (
{/* Capped and centred, like the Elecraft, Yaesu, Icom and Flex consoles. @@ -240,25 +277,28 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void { setHold('vol', v); call(() => SetTCIVolume(v)); }} /> - - + { setHold('sql', v); call(() => SetTCISquelchLevel(v)); }} />
- call(() => SetTCINB(!st.nb))} /> - call(() => SetTCINR(!st.nr))} /> - call(() => SetTCIANF(!st.anf))} /> - call(() => SetTCIAPF(!st.apf))} /> - call(() => SetTCISquelch(!st.squelch_on))} /> - call(() => SetTCIMute(!st.mute))} /> + { setHold('nb', !nb); call(() => SetTCINB(!nb)); }} /> + { setHold('nr', !nr); call(() => SetTCINR(!nr)); }} /> + { setHold('anf', !anf); call(() => SetTCIANF(!anf)); }} /> + { setHold('apf', !apf); call(() => SetTCIAPF(!apf)); }} /> + { setHold('sql_on', !sqlOn); call(() => SetTCISquelch(!sqlOn)); }} /> + { setHold('mute', !muted); call(() => SetTCIMute(!muted)); }} />
{t('tcip.agc')} -
- {['off', 'long', 'slow', 'med', 'fast'].map((m) => ( - 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. */} +
+ {['off', 'slow', 'med', 'fast'].map((m) => ( + { setHold('agc', m); call(() => SetTCIAGC(m)); }} /> ))}
@@ -277,35 +317,17 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
- {/* 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. */}
- {([ - { 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) => ( -
- call(() => r.toggle(!r.on))} /> - - {r.offset > 0 ? `+${r.offset}` : r.offset} Hz - - {/* ±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) => ( - - ))} - -
- ))} + { setHold('rit', !rit); call(() => SetTCIRIT(!rit)); }} + onSet={(v) => { setHold('rit_hz', v); call(() => SetTCIRITOffset(v)); }} /> + { setHold('xit', !xit); call(() => SetTCIXIT(!xit)); }} + onSet={(v) => { setHold('xit_hz', v); call(() => SetTCIXITOffset(v)); }} />
diff --git a/internal/cat/tci_panel.go b/internal/cat/tci_panel.go index 959eeb7..8e318a9 100644 --- a/internal/cat/tci_panel.go +++ b/internal/cat/tci_panel.go @@ -228,12 +228,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 +258,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 {