From fc79be7c05e0c5d0a70ad7c00496ae7b5680f21a Mon Sep 17 00:00:00 2001 From: rouggy Date: Wed, 26 Aug 2026 19:15:48 +0200 Subject: [PATCH 1/8] fix(tci): the drive commands need the TRX index, and the console holds its own clicks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/components/IcomPanel.tsx | 39 +-------- frontend/src/components/ShiftRow.tsx | 91 ++++++++++++++++++++ frontend/src/components/TCIPanel.tsx | 116 +++++++++++++++----------- internal/cat/tci_panel.go | 21 +++-- 4 files changed, 180 insertions(+), 87 deletions(-) create mode 100644 frontend/src/components/ShiftRow.tsx 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 { From fbe01bf19dcc83e2816841b3d44e5024093f6b56 Mon Sep 17 00:00:00 2001 From: rouggy Date: Wed, 26 Aug 2026 20:54:28 +0200 Subject: [PATCH 2/8] fix(tci): wide level rows, honest filter labels, and mute read both ways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things reported from the radio, all of them mine. THE LEVELS. Two half-width sliders side by side left each of them a couple of centimetres long, which is not enough to set 15% with. One level per row now, full width, with the value typed in or stepped with ± — and in the radio's own units, so the volume is dB and the squelch a dBm threshold, matching what ExpertSDR3's own window shows. THE FILTERS. The button said 250 and the radio was set to 300-550: 250 Hz wide, but sitting where no CW note is. The edges are computed from the width now, and a narrow filter is CENTRED ON THE CW NOTE — a 250 Hz filter from 100 to 350 would put the note outside its own passband. A button also lights on the WIDTH the radio reports rather than on an exact pair of edges, so moving one edge on the radio no longer darkens every button. MUTE. Read from one shape only, while this radio reports the other ('mute:0,false'), so the button showed the opposite of the truth. Both are accepted now. None of this should have reached main before somebody had a radio in front of it. --- frontend/package.json.md5 | 2 +- frontend/src/components/LevelRow.tsx | 72 +++++++++++++++++++ frontend/src/components/TCIPanel.tsx | 100 +++++++++++++++------------ internal/cat/tci_panel.go | 10 ++- 4 files changed, 138 insertions(+), 46 deletions(-) create mode 100644 frontend/src/components/LevelRow.tsx diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 693b40b..b826f3b 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -f9b41e192918fa2511f68cd1b361fcd3 \ No newline at end of file +704fe1bf370b669665df0606fae8a69d \ No newline at end of file diff --git a/frontend/src/components/LevelRow.tsx b/frontend/src/components/LevelRow.tsx new file mode 100644 index 0000000..8e10ec2 --- /dev/null +++ b/frontend/src/components/LevelRow.tsx @@ -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(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 ( +
+ + {label} + + +
+ + {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-14 rounded border border-border bg-background px-1 text-right text-xs font-mono tabular-nums outline-none" + /> + ) : ( + + )} + +
+
+ ); +} diff --git a/frontend/src/components/TCIPanel.tsx b/frontend/src/components/TCIPanel.tsx index ebddcf2..103b191 100644 --- a/frontend/src/components/TCIPanel.tsx +++ b/frontend/src/components/TCIPanel.tsx @@ -13,6 +13,7 @@ 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; @@ -31,18 +32,31 @@ const ZERO: TCIState = { rit: false, rit_offset: 0, xit: false, xit_offset: 0, lock: false, split: false, smeter: 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. TCI wants the two EDGES, not a width, so the +// edges are computed from the width and the mode — and the button says the +// width, which had better be the width you get. +// +// It did not: "250" set 300-550, which is 250 Hz wide but sitting where a CW +// note is not, and the row underneath said "300-550 Hz" while the button said +// 250. Now a narrow filter is CENTRED ON THE CW NOTE and a wide one starts at +// the bottom of the voice band, which is what each is for. +const CW_PITCH = 700; // ExpertSDR3's default sidetone, and where its CW filters sit +const WIDTHS = [250, 500, 1000, 1800, 2400, 2800, 3500]; + +function widthLabel(w: number): string { + return w >= 1000 ? `${(w / 1000).toFixed(1)}k` : String(w); +} + +// edgesFor turns a width into the pair TCI wants. Narrow filters are centred on +// the CW note: a 250 Hz filter from 100 to 350 would put the note outside its +// own passband. +function edgesFor(w: number, mode: string): { lo: number; hi: number } { + const cw = /CW/i.test(mode); + if (cw || w <= 1000) { + return { lo: Math.max(0, CW_PITCH - Math.round(w / 2)), hi: CW_PITCH + Math.round(w / 2) }; + } + return { lo: 100, hi: 100 + 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 @@ -236,16 +250,13 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void {/* Transmit */} -
- - { setHold('drive', v); call(() => SetTCIDrive(v)); }} /> - - - { setHold('tune_drive', v); call(() => SetTCITuneDrive(v)); }} /> - -
+ {/* 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. */} + { setHold('drive', v); call(() => SetTCIDrive(v)); }} /> + { setHold('tune_drive', v); call(() => SetTCITuneDrive(v)); }} />
{/* 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 @@ -261,27 +272,20 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void {t('tcip.txDisabled')} )}
- - { setHold('mic', v); call(() => SetTCIMicLevel(v)); }} /> - + { setHold('mic', v); call(() => SetTCIMicLevel(v)); }} />
{/* Receive */} -
- {/* 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. */} - - { setHold('vol', v); call(() => SetTCIVolume(v)); }} /> - - - { setHold('sql', v); call(() => SetTCISquelchLevel(v)); }} /> - -
+ {/* 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. */} + { setHold('vol', v); call(() => SetTCIVolume(v)); }} /> + { setHold('sql', v); call(() => SetTCISquelchLevel(v)); }} />
{ setHold('nb', !nb); call(() => SetTCINB(!nb)); }} /> { setHold('nr', !nr); call(() => SetTCINR(!nr)); }} /> @@ -308,11 +312,19 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void {st.filter_lo}–{st.filter_hi} Hz
- {FILTERS.map((f) => ( - call(() => SetTCIFilter(f.lo, f.hi))} /> - ))} + {WIDTHS.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 ( + call(() => SetTCIFilter(e.lo, e.hi))} /> + ); + })}
diff --git a/internal/cat/tci_panel.go b/internal/cat/tci_panel.go index 8e318a9..bbe8fca 100644 --- a/internal/cat/tci_panel.go +++ b/internal/cat/tci_panel.go @@ -120,7 +120,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))) From dad71e9e4f493b9575c748fd6a1a8c61fdcceb0a Mon Sep 17 00:00:00 2001 From: rouggy Date: Wed, 26 Aug 2026 20:59:31 +0200 Subject: [PATCH 3/8] fix(tci): a filter button does what it says, and mute is put under a log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 250 now means 0-250. It meant 575-825: the width was right and it was centred on the CW note, on the reasoning that a CW filter should contain the note. That reasoning may be right for a radio and it is still wrong here, because 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. The two edges are editable underneath for anything else, which is what TCI takes anyway. And MUTE still lights the squelch on a real radio. Nothing in this code can do that — the button sends mute and only mute, and the two are separate state — so the radio's own announcements are logged as they arrive. What it says after the command will settle whether this is our reading or its doing; no more reasoning from here will. --- frontend/src/components/TCIPanel.tsx | 29 +++++++++++++++++++--------- internal/cat/tci_panel.go | 8 ++++++++ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/TCIPanel.tsx b/frontend/src/components/TCIPanel.tsx index 103b191..03c8d4d 100644 --- a/frontend/src/components/TCIPanel.tsx +++ b/frontend/src/components/TCIPanel.tsx @@ -47,15 +47,16 @@ function widthLabel(w: number): string { return w >= 1000 ? `${(w / 1000).toFixed(1)}k` : String(w); } -// edgesFor turns a width into the pair TCI wants. Narrow filters are centred on -// the CW note: a 250 Hz filter from 100 to 350 would put the note outside its -// own passband. -function edgesFor(w: number, mode: string): { lo: number; hi: number } { - const cw = /CW/i.test(mode); - if (cw || w <= 1000) { - return { lo: Math.max(0, CW_PITCH - Math.round(w / 2)), hi: CW_PITCH + Math.round(w / 2) }; - } - return { lo: 100, hi: 100 + 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, @@ -311,6 +312,16 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void {t('tcip.filter')} {st.filter_lo}–{st.filter_hi} Hz +
+ call(() => SetTCIFilter(v, st.filter_hi))} /> +
+
+ call(() => SetTCIFilter(st.filter_lo, v))} /> +
{WIDTHS.map((w) => { const e = edgesFor(w, mode); diff --git a/internal/cat/tci_panel.go b/internal/cat/tci_panel.go index bbe8fca..e8fd39d 100644 --- a/internal/cat/tci_panel.go +++ b/internal/cat/tci_panel.go @@ -95,6 +95,14 @@ 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": + debugLog.Printf("TCI: %s:%s", name, args) + } switch name { case "protocol": p.Protocol = strings.TrimSpace(args) From 01d0b8f22bf080c014ef0d7af782fc93e33fd772 Mon Sep 17 00:00:00 2001 From: rouggy Date: Wed, 26 Aug 2026 21:04:00 +0200 Subject: [PATCH 4/8] =?UTF-8?q?fix(tci):=20the=20squelch=20is=20the=20radi?= =?UTF-8?q?o's=20refusal,=20not=20our=20bug=20=E2=80=94=20and=20the=20pane?= =?UTF-8?q?l=20now=20shows=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The log settles it, and it was not mute. Mute works: '→ mute:0,true;' and the radio answers 'mute:0,true'. What happens is that the squelch REFUSES to go off — we send 'sql_enable:0,false;', the radio echoes false, and eighty milliseconds later announces 'sql_enable:0,true' again. Six times in the log, always the same shape. The SQL lamp coming back on right after a MUTE click is that revert landing, not the mute doing it. So the optimistic hold is fixed rather than the phantom. It was a fixed 1.2 s, which is wrong in both directions: too short for a setting the radio never echoes at all (AGC), and too long for one it refuses, where it showed the operator a lie for a second. The requested value now stands while the radio says nothing about that setting, and the instant the radio reports any change for it, its word replaces ours. A refusal is therefore visible immediately, and an unechoed setting still sticks. Also from the same session: filters per MODE — the narrow end in CW, the voice widths in SSB, a middle set for the digital modes — because 250 Hz in SSB and 2.8 kHz in CW are buttons nobody presses. And APF is shown only in CW: it rings a single tone out of the noise, which is a CW tool and nothing else. --- frontend/src/components/TCIPanel.tsx | 99 +++++++++++++++++++--------- 1 file changed, 67 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/TCIPanel.tsx b/frontend/src/components/TCIPanel.tsx index 03c8d4d..0425751 100644 --- a/frontend/src/components/TCIPanel.tsx +++ b/frontend/src/components/TCIPanel.tsx @@ -32,16 +32,27 @@ const ZERO: TCIState = { rit: false, rit_offset: 0, xit: false, xit_offset: 0, lock: false, split: false, smeter: 0, }; -// The widths worth a button. TCI wants the two EDGES, not a width, so the -// edges are computed from the width and the mode — and the button says the -// width, which had better be the width you get. +// 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. // -// It did not: "250" set 300-550, which is 250 Hz wide but sitting where a CW -// note is not, and the row underneath said "300-550 Hz" while the button said -// 250. Now a narrow filter is CENTRED ON THE CW NOTE and a wide one starts at -// the bottom of the voice band, which is what each is for. -const CW_PITCH = 700; // ExpertSDR3's default sidetone, and where its CW filters sit -const WIDTHS = [250, 500, 1000, 1800, 2400, 2800, 3500]; +// 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); @@ -141,14 +152,33 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void // 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>({}); + // 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>({}); const [, forceRender] = useState(0); const hold = (key: string, reported: T): T => { const h = holdRef.current[key]; - return h && Date.now() < h.until ? (h.v as T) : 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: any) => { - holdRef.current[key] = { v, until: Date.now() + 1200 }; + const setHold = (key: string, v: any, reported: any) => { + holdRef.current[key] = { v, reported }; forceRender((n) => n + 1); }; @@ -200,7 +230,7 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void if (r.off || !r.on) return; e.preventDefault(); const v = r.hz + (e.key === 'ArrowRight' ? 10 : -10); - setHold('rit_hz', v); + setHold('rit_hz', v, ritRef.current.hz); SetTCIRITOffset(v).catch(() => {}); }; window.addEventListener('keydown', onKey); @@ -255,9 +285,9 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void left each of them a couple of centimetres long — small enough that setting 15% took aim. */} { setHold('drive', v); call(() => SetTCIDrive(v)); }} /> + onSet={(v) => { setHold('drive', v, st.drive); call(() => SetTCIDrive(v)); }} /> { setHold('tune_drive', v); call(() => SetTCITuneDrive(v)); }} /> + onSet={(v) => { setHold('tune_drive', v, st.tune_drive); call(() => SetTCITuneDrive(v)); }} />
{/* 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 @@ -274,7 +304,7 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void )}
{ setHold('mic', v); call(() => SetTCIMicLevel(v)); }} /> + onSet={(v) => { setHold('mic', v, st.mic_level); call(() => SetTCIMicLevel(v)); }} /> {/* Receive */} @@ -283,17 +313,22 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void squelch as a dBm threshold — so the numbers match the ones in ExpertSDR3's window rather than being percentages of something. */} { setHold('vol', v); call(() => SetTCIVolume(v)); }} /> + onSet={(v) => { setHold('vol', v, st.volume); call(() => SetTCIVolume(v)); }} /> { setHold('sql', v); call(() => SetTCISquelchLevel(v)); }} /> -
- { 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)); }} /> + onSet={(v) => { setHold('sql', v, st.squelch); call(() => SetTCISquelchLevel(v)); }} /> +
+ { setHold('nb', !nb, st.nb); call(() => SetTCINB(!nb)); }} /> + { setHold('nr', !nr, st.nr); call(() => SetTCINR(!nr)); }} /> + { 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) && ( + { setHold('apf', !apf, st.apf); call(() => SetTCIAPF(!apf)); }} /> + )} + { setHold('sql_on', !sqlOn, st.squelch_on); call(() => SetTCISquelch(!sqlOn)); }} /> + { setHold('mute', !muted, st.mute); call(() => SetTCIMute(!muted)); }} />
{t('tcip.agc')} @@ -303,7 +338,7 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
{['off', 'slow', 'med', 'fast'].map((m) => ( { setHold('agc', m); call(() => SetTCIAGC(m)); }} /> + onClick={() => { setHold('agc', m, st.agc || ''); call(() => SetTCIAGC(m)); }} /> ))}
@@ -323,7 +358,7 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void onSet={(v) => call(() => SetTCIFilter(st.filter_lo, v))} />
- {WIDTHS.map((w) => { + {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 @@ -346,11 +381,11 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
{ setHold('rit', !rit); call(() => SetTCIRIT(!rit)); }} - onSet={(v) => { setHold('rit_hz', v); call(() => SetTCIRITOffset(v)); }} /> + onToggle={() => { setHold('rit', !rit, st.rit); call(() => SetTCIRIT(!rit)); }} + onSet={(v) => { setHold('rit_hz', v, st.rit_offset); call(() => SetTCIRITOffset(v)); }} /> { setHold('xit', !xit); call(() => SetTCIXIT(!xit)); }} - onSet={(v) => { setHold('xit_hz', v); call(() => SetTCIXITOffset(v)); }} /> + onToggle={() => { setHold('xit', !xit, st.xit); call(() => SetTCIXIT(!xit)); }} + onSet={(v) => { setHold('xit_hz', v, st.xit_offset); call(() => SetTCIXITOffset(v)); }} />
From 1201b44908d2f93c11508082607b149349873ab3 Mon Sep 17 00:00:00 2001 From: rouggy Date: Wed, 26 Aug 2026 21:14:58 +0200 Subject: [PATCH 5/8] feat(tci): the transmit meters, and no temperature invented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TX_POWER and TX_SWR are read-only commands the radio answers when asked, so they are asked for on each poll of a KEYED radio and not at all otherwise: a receiving station pays nothing for meters nobody is watching. Both appear next to the S-meter only while transmitting, because showing them the rest of the time would show the last thing that happened as if it were now. An SWR of 0 draws as '—' rather than as 1.0. A perfect match on an antenna nobody has measured is the one reading an operator should never be handed. There is no temperature. The protocol's command list has TX_POWER and TX_SWR and nothing thermal at all — so rather than leave the question hanging, it is written down where the next person will look for it. A temperature invented from something else, on a transmitter, is exactly the kind of number somebody would trust. --- frontend/src/components/TCIPanel.tsx | 23 ++++++++++++++++++++--- frontend/wailsjs/go/models.ts | 4 ++++ internal/cat/flex.go | 2 +- internal/cat/kenwood.go | 6 +++--- internal/cat/tci.go | 12 ++++++++++++ internal/cat/tci_panel.go | 19 +++++++++++++++++++ internal/cat/yaesu_panel.go | 14 +++++++------- 7 files changed, 66 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/TCIPanel.tsx b/frontend/src/components/TCIPanel.tsx index 0425751..c93f5b7 100644 --- a/frontend/src/components/TCIPanel.tsx +++ b/frontend/src/components/TCIPanel.tsx @@ -23,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 = { @@ -30,6 +31,7 @@ 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, }; // The widths worth a button, PER MODE — because 250 Hz is useless in SSB and @@ -265,9 +267,12 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void {off &&
{t('tcip.waiting')}
} {!!err &&
{err}
} - {/* 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. */} void onReportRST(sMeterRST(s.s, s.over, mode)); }} title={t('tcip.sMeterHint')} /> + {st.tx && ( +
+ + {/* 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. */} + 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')} /> +
+ )}
{/* Transmit */} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 1a113e8..c310dd1 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -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"]; } } diff --git a/internal/cat/flex.go b/internal/cat/flex.go index e4a76ac..6576a51 100644 --- a/internal/cat/flex.go +++ b/internal/cat/flex.go @@ -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 diff --git a/internal/cat/kenwood.go b/internal/cat/kenwood.go index ea01a5d..d6f7d1b 100644 --- a/internal/cat/kenwood.go +++ b/internal/cat/kenwood.go @@ -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 diff --git a/internal/cat/tci.go b/internal/cat/tci.go index 24968e4..1d99fef 100644 --- a/internal/cat/tci.go +++ b/internal/cat/tci.go @@ -304,6 +304,18 @@ 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. + if t.tx { + 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) diff --git a/internal/cat/tci_panel.go b/internal/cat/tci_panel.go index e8fd39d..ce89db7 100644 --- a/internal/cat/tci_panel.go +++ b/internal/cat/tci_panel.go @@ -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"` @@ -194,6 +205,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 diff --git a/internal/cat/yaesu_panel.go b/internal/cat/yaesu_panel.go index 348821e..82db697 100644 --- a/internal/cat/yaesu_panel.go +++ b/internal/cat/yaesu_panel.go @@ -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. From 980c54a616b61437ccd25faa05e572a58749a2f7 Mon Sep 17 00:00:00 2001 From: rouggy Date: Wed, 26 Aug 2026 21:25:06 +0200 Subject: [PATCH 6/8] fix(tci): TUNE can be switched off again, and the meters watch it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two faults with one cause: this radio does not echo 'tune:0,true'. So the panel never knew a tune was running. The button stayed on TUNE and every further press sent another START — there was no way to stop it from here at all. The state is recorded when the command is sent now; whatever the radio says afterwards still wins, it simply never says anything. And the transmit meters were asked for only while t.tx, which a tune carrier does not set: the radio reports tuning as its own state, not as a transmission. So power and SWR sat at zero for the whole tune — the exact carrier an operator holds a tune for in order to watch an SWR on. They now follow PTT or TUNE, and the S-meter reads '—' under our own carrier either way. --- frontend/src/components/TCIPanel.tsx | 6 +++--- internal/cat/tci.go | 6 +++++- internal/cat/tci_panel.go | 13 ++++++++++++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/TCIPanel.tsx b/frontend/src/components/TCIPanel.tsx index c93f5b7..8dbbd63 100644 --- a/frontend/src/components/TCIPanel.tsx +++ b/frontend/src/components/TCIPanel.tsx @@ -274,15 +274,15 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void There is no temperature: the protocol has no such command, and a made-up figure on a transmitter is the kind somebody trusts. */} - { if (st.tx || !onReportRST) return; onReportRST(sMeterRST(s.s, s.over, mode)); }} title={t('tcip.sMeterHint')} /> - {st.tx && ( + {(st.tx || st.tuning) && (
diff --git a/internal/cat/tci.go b/internal/cat/tci.go index 1d99fef..7a3ab2f 100644 --- a/internal/cat/tci.go +++ b/internal/cat/tci.go @@ -309,7 +309,11 @@ func (t *TCI) ReadState() (RigState, error) { // 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. - if t.tx { + // 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;") diff --git a/internal/cat/tci_panel.go b/internal/cat/tci_panel.go index ce89db7..7e3afb6 100644 --- a/internal/cat/tci_panel.go +++ b/internal/cat/tci_panel.go @@ -341,7 +341,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 { From 130c4e72e05b55f2a174273f5003edd66e2a5faa Mon Sep 17 00:00:00 2001 From: rouggy Date: Wed, 26 Aug 2026 21:28:09 +0200 Subject: [PATCH 7/8] chore(tci): log the transmit meters as they arrive, or fail to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The log shows 'tx_power;' going out four times a second during a tune and nothing coming back — but that proves less than it looks: a reply that arrived and failed to parse leaves exactly the same trace as one that never came. Both are now visible. --- internal/cat/tci_panel.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/cat/tci_panel.go b/internal/cat/tci_panel.go index 7e3afb6..d4f064c 100644 --- a/internal/cat/tci_panel.go +++ b/internal/cat/tci_panel.go @@ -111,7 +111,12 @@ func (t *TCI) handlePanel(name string, get func(int) string, args string) bool { // 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": + case "mute", "sql_enable", "sql_level", "tx_power", "tx_swr", "tune": + // Logged on arrival so an ANSWER can be told from a silence. The + // transmit meters are asked for four times a second while keyed and the + // log showed only the asking, which proves nothing on its own: a reply + // that arrived and failed to parse looks exactly like one that never + // came. debugLog.Printf("TCI: %s:%s", name, args) } switch name { From 27b342a5e7c2ac922b60b42263eb6725b3dbb9c7 Mon Sep 17 00:00:00 2001 From: rouggy Date: Wed, 26 Aug 2026 22:07:11 +0200 Subject: [PATCH 8/8] chore(tci): cap the diagnostic logging per message type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transmit meters are asked for four times a second while keyed, so logging every arrival would fill an evening's log — and a diagnostic that fills a log is one that gets switched off instead of read. Twenty of each is enough to tell an answer from a silence, which is all it is for. --- internal/cat/tci_panel.go | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/internal/cat/tci_panel.go b/internal/cat/tci_panel.go index d4f064c..566d420 100644 --- a/internal/cat/tci_panel.go +++ b/internal/cat/tci_panel.go @@ -88,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. @@ -112,12 +114,21 @@ func (t *TCI) handlePanel(name string, get func(int) string, args string) bool { // 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 - // transmit meters are asked for four times a second while keyed and the - // log showed only the asking, which proves nothing on its own: a reply - // that arrived and failed to parse looks exactly like one that never - // came. - debugLog.Printf("TCI: %s:%s", name, args) + // 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":