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/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/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/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..8dbbd63 100644 --- a/frontend/src/components/TCIPanel.tsx +++ b/frontend/src/components/TCIPanel.tsx @@ -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>({}); - 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>({}); + 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; + 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) => { 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 (
{/* Capped and centred, like the Elecraft, Yaesu, Icom and Flex consoles. @@ -183,32 +267,44 @@ 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. */} - { if (st.tx || !onReportRST) return; onReportRST(sMeterRST(s.s, s.over, mode)); }} title={t('tcip.sMeterHint')} /> + {(st.tx || st.tuning) && ( +
+ + {/* 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 */} -
- - { 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, st.drive); call(() => SetTCIDrive(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 @@ -224,41 +320,42 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void {t('tcip.txDisabled')} )}
- - { setHold('mic', v); call(() => SetTCIMicLevel(v)); }} /> - + { setHold('mic', v, st.mic_level); 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)); }} /> - -
-
- 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))} /> + {/* 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, st.volume); call(() => SetTCIVolume(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')} -
- {['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, st.agc || ''); call(() => SetTCIAGC(m)); }} /> ))}
@@ -267,45 +364,45 @@ 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))} /> +
- {FILTERS.map((f) => ( - 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 ( + call(() => SetTCIFilter(e.lo, e.hi))} /> + ); + })}
- {/* 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, st.rit); call(() => SetTCIRIT(!rit)); }} + onSet={(v) => { setHold('rit_hz', v, st.rit_offset); call(() => SetTCIRITOffset(v)); }} /> + { setHold('xit', !xit, st.xit); call(() => SetTCIXIT(!xit)); }} + onSet={(v) => { setHold('xit_hz', v, st.xit_offset); call(() => SetTCIXITOffset(v)); }} />
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..7a3ab2f 100644 --- a/internal/cat/tci.go +++ b/internal/cat/tci.go @@ -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) diff --git a/internal/cat/tci_panel.go b/internal/cat/tci_panel.go index 959eeb7..566d420 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"` @@ -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 { 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.