diff --git a/app.go b/app.go index aaeb50c..49854d5 100644 --- a/app.go +++ b/app.go @@ -14887,3 +14887,94 @@ func (a *App) reloadCATShare(s CATSettings) { } a.catShare = srv } + +// ── Yaesu control panel bindings ────────────────────────────────────────── +// +// One thin binding per control, mirroring the Icom set. Each dispatches onto the +// CAT goroutine, so a click never races the poll loop for the serial port. + +func (a *App) GetYaesuState() cat.YaesuTXState { + if a.cat == nil { + return cat.YaesuTXState{} + } + st, _ := a.cat.YaesuState() + return st +} + +func (a *App) RefreshYaesuPanel() error { + if a.cat == nil { + return fmt.Errorf("CAT not initialized") + } + return a.cat.YaesuDo(func(y cat.YaesuController) error { return y.RefreshYaesu() }) +} + +func (a *App) SetYaesuPower(w int) error { + return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuPower(w) }) +} + +func (a *App) SetYaesuMicGain(p int) error { + return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuMicGain(p) }) +} + +func (a *App) SetYaesuAFGain(p int) error { + return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuAFGain(p) }) +} + +func (a *App) SetYaesuRFGain(p int) error { + return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuRFGain(p) }) +} + +func (a *App) SetYaesuSquelch(p int) error { + return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuSquelch(p) }) +} + +func (a *App) SetYaesuAGC(mode string) error { + return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuAGC(mode) }) +} + +func (a *App) SetYaesuPreamp(n int) error { + return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuPreamp(n) }) +} + +func (a *App) SetYaesuAtt(db int) error { + return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuAtt(db) }) +} + +func (a *App) SetYaesuNB(on bool) error { + return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuNB(on) }) +} + +func (a *App) SetYaesuNR(on bool) error { + return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuNR(on) }) +} + +func (a *App) SetYaesuNRLevel(n int) error { + return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuNRLevel(n) }) +} + +func (a *App) SetYaesuNarrow(on bool) error { + return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuNarrow(on) }) +} + +func (a *App) SetYaesuVOX(on bool) error { + return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuVOX(on) }) +} + +func (a *App) SetYaesuSplit(on bool) error { + return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuSplit(on) }) +} + +func (a *App) SetYaesuBand(band string) error { + return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuBand(band) }) +} + +func (a *App) TuneYaesuATU() error { + return a.yaesuDo(func(y cat.YaesuController) error { return y.TuneYaesuATU() }) +} + +func (a *App) yaesuDo(fn func(cat.YaesuController) error) error { + if a.cat == nil { + return fmt.Errorf("CAT not initialized") + } + return a.cat.YaesuDo(fn) +} diff --git a/changelog.json b/changelog.json index e9ac827..b88786e 100644 --- a/changelog.json +++ b/changelog.json @@ -3,6 +3,7 @@ "version": "0.21.9", "date": "2026-07-28", "en": [ + "Yaesu control panel: with the native Yaesu backend, a console pane gives S/PO/SWR meters, band and mode buttons, AF/RF/squelch, AGC, IPO-AMP1-AMP2, ATT, NB, DNR, narrow filter, power, mic gain, VOX, split and ATU tune. Enable it in Settings → Main view panes.", "Native Xiegu CAT (G90, X6100, X6200, X5105): a new backend talks CI-V straight to the radio — frequency, mode, split and PTT. Pick \"Xiegu (native CI-V)\" in Settings → CAT. Untested on a radio so far, feedback welcome.", "CAT sharing: OpsLog can now serve its rig connection to other programs (Settings → CAT → Share CAT). WSJT-X, JTDX, MSHV or Log4OM connect with rig model \"Hamlib NET rigctl\" at 127.0.0.1:4532 — needed because a native CAT backend holds the radio serial port on its own. Works with every backend, and with both Hamlib command dialects (JTDX names the VFO, MSHV does not).", "Native Yaesu CAT: a new backend talks to an FTDX10/FTDX101 directly over its serial port, without OmniRig — frequency, mode, VFO A/B, split and PTT. Pick \"Yaesu (native CAT)\" in Settings → CAT. First release, feedback welcome.", @@ -15,6 +16,7 @@ "CQ and ITU zones you correct by hand in your profile stay corrected. They are derived from cty.dat, which gives the zones of the whole entity — in a country spanning several, the automatic value is wrong and it came back at every restart. They now only fill in when empty, and recompute when the callsign or grid changes." ], "fr": [ + "Panneau de contrôle Yaesu : avec le backend Yaesu natif, un volet console offre les mesures S/PO/ROS, les boutons de bande et de mode, AF/RF/squelch, AGC, IPO-AMP1-AMP2, ATT, NB, DNR, filtre étroit, puissance, gain micro, VOX, split et accord d'antenne. À activer dans Réglages → Volets de la vue principale.", "CAT Xiegu natif (G90, X6100, X6200, X5105) : un nouveau backend dialogue en CI-V directement avec la radio — fréquence, mode, split et PTT. À choisir dans Réglages → CAT sous « Xiegu (CI-V natif) ». Pas encore testé sur une radio, retours bienvenus.", "Partage du CAT : OpsLog peut désormais servir sa liaison radio aux autres logiciels (Réglages → CAT → Partager le CAT). WSJT-X, JTDX, MSHV ou Log4OM s'y connectent avec le modèle « Hamlib NET rigctl » sur 127.0.0.1:4532 — nécessaire car un backend CAT natif occupe seul le port série. Fonctionne avec tous les backends, et avec les deux dialectes Hamlib (JTDX nomme le VFO, MSHV non).", "CAT Yaesu natif : un nouveau backend dialogue directement avec un FTDX10/FTDX101 par son port série, sans OmniRig — fréquence, mode, VFO A/B, split et PTT. À choisir dans Réglages → CAT sous « Yaesu (CAT natif) ». Première version, retours bienvenus.", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 955488f..18909bb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -73,6 +73,7 @@ import { BandMap } from '@/components/BandMap'; import { WorldMap, LocatorMap } from '@/components/MainMap'; import { FlexPanel } from '@/components/FlexPanel'; import { IcomPanel } from '@/components/IcomPanel'; +import { YaesuPanel } from '@/components/YaesuPanel'; import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel'; import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel'; import { ScpPanel, type ScpResult } from '@/components/ScpPanel'; @@ -1319,12 +1320,12 @@ export default function App() { // map ("map1"), the locator street map ("map2"), the cluster grid or the // worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed), // so it's loaded async on mount and re-read on profile:changed below. - type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'netcontrol'; + type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'netcontrol'; const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now const [mainPaneLeft, setMainPaneLeft] = useState('map1'); const [mainPaneRight, setMainPaneRight] = useState('map2'); const loadMainPanes = useCallback(async () => { - const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'netcontrol'; + const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'yaesu' || v === 'netcontrol'; const [l, r] = await Promise.all([ GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => ''), @@ -4279,6 +4280,12 @@ export default function App() { onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} /> ); + case 'yaesu': + return ( +
+ { setRstSent(r); rstUserEditedRef.current = true; }} /> +
+ ); case 'icom': return (
@@ -6074,6 +6081,7 @@ export default function App() { onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }} flexAvailable={catState.backend === 'flex'} icomAvailable={catState.backend === 'icom'} + yaesuAvailable={catState.backend === 'yaesu'} /> )} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 07d1c92..0e99083 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -156,6 +156,7 @@ interface Props { onMainPaneChanged?: (side: 'left' | 'right', value: string) => void; // live Main-view layout update flexAvailable?: boolean; // CAT backend is FlexRadio → offer it as a Main pane icomAvailable?: boolean; // CAT backend is Icom → offer the Icom console as a Main pane + yaesuAvailable?: boolean; // CAT backend is Yaesu → offer the Yaesu console as a Main pane } // Pretty little card showing what OpsLog will stamp on each QSO based on @@ -817,7 +818,7 @@ function RelayAutoPanel() { // cluster grid or the worked-before grid. Per-profile (stored via SetUIPref, // which is profile-prefixed). Self-contained so it owns its async-loaded state. const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol']; -function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean }) { +function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean; yaesuAvailable?: boolean }) { const { t } = useI18n(); const [left, setLeft] = useState('map1'); const [right, setRight] = useState('map2'); @@ -826,10 +827,11 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged? ...MAIN_PANE_VALUES, ...(flexAvailable ? ['flex'] : []), ...(icomAvailable ? ['icom'] : []), + ...(yaesuAvailable ? ['yaesu'] : []), ].map((value) => ({ value, label: t(`settings.pane.${value}`) })) .sort((a, b) => a.label.localeCompare(b.label)); useEffect(() => { - const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_VALUES.includes(v); + const valid = (v: string) => v === 'flex' || v === 'icom' || v === 'yaesu' || MAIN_PANE_VALUES.includes(v); Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')]) .then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); }); }, []); @@ -1024,7 +1026,7 @@ const ICOM_MODELS: { name: string; addr: number }[] = [ { name: 'IC-9700', addr: 0xA2 }, ]; -export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable }: Props) { +export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable, yaesuAvailable }: Props) { const { t } = useI18n(); const [selected, setSelected] = useState((initialSection as SectionId) || 'station'); const [loading, setLoading] = useState(true); @@ -2664,11 +2666,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan

)} - {catCfg.backend === 'flex' && ( -

- {t('cat.flexHint')} -

- )}
); @@ -5133,7 +5130,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan - +

{t('gen.pwEnc')}

diff --git a/frontend/src/components/YaesuPanel.tsx b/frontend/src/components/YaesuPanel.tsx new file mode 100644 index 0000000..35d5f0c --- /dev/null +++ b/frontend/src/components/YaesuPanel.tsx @@ -0,0 +1,367 @@ +import { useEffect, useRef, useState } from 'react'; +import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna } from 'lucide-react'; +import { + GetYaesuState, RefreshYaesuPanel, + SetYaesuPower, SetYaesuMicGain, SetYaesuAFGain, SetYaesuRFGain, SetYaesuSquelch, + SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel, + SetYaesuNarrow, SetYaesuVOX, SetYaesuSplit, SetYaesuBand, TuneYaesuATU, + GetCATState, SetCATMode, +} from '../../wailsjs/go/main/App'; +import { cn } from '@/lib/utils'; +import { useI18n } from '@/lib/i18n'; +import { sMeterRST } from '@/lib/rst'; + +type YaesuState = { + available: boolean; model?: string; mode?: string; + transmitting: boolean; split: boolean; + s_meter: number; power_meter: number; swr_meter: number; + rf_power: number; mic_gain: number; af_gain: number; rf_gain: number; squelch: number; + agc?: string; preamp: number; att: number; + nb: boolean; nr: boolean; nr_level: number; narrow: boolean; vox: boolean; +}; + +const ZERO: YaesuState = { + available: false, transmitting: false, split: false, + s_meter: 0, power_meter: 0, swr_meter: 0, + rf_power: 0, mic_gain: 0, af_gain: 0, rf_gain: 0, squelch: 0, + preamp: 0, att: 0, nb: false, nr: false, nr_level: 0, narrow: false, vox: false, +}; + +// Band buttons use the rig's OWN band memory (CAT "BS"), not a frequency we +// choose: pressing 20 m lands where the operator last was on 20 m, which is what +// the radio's own band keys do. That is why these are band names, not Hz. +const BANDS = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m']; + +const MODES = ['SSB', 'CW', 'RTTY', 'FT8', 'AM', 'FM']; + +// The FTDX10/FTDX101 preamp is a three-way front-end selector, not an on/off: +// IPO bypasses the preamp entirely (best on a quiet, high-signal band), AMP1 and +// AMP2 add gain. Presenting it as a toggle would hide the middle position. +const PREAMPS = [{ v: '0', l: 'IPO' }, { v: '1', l: 'AMP1' }, { v: '2', l: 'AMP2' }]; +const AGCS = [{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }, { v: 'AUTO', l: 'AUTO' }]; + +function fmtVFO(hz?: number): string { + if (!hz || hz <= 0) return '––.–––.––'; + const mhz = Math.floor(hz / 1_000_000); + const khz = Math.floor((hz % 1_000_000) / 1000); + const h2 = Math.floor((hz % 1000) / 10); + return `${mhz}.${String(khz).padStart(3, '0')}.${String(h2).padStart(2, '0')}`; +} + +function bandOfHz(hz?: number): string { + if (!hz || hz <= 0) return ''; + const mhz = hz / 1_000_000; + const bands: [string, number, number][] = [ + ['160m', 1.8, 2.0], ['80m', 3.5, 4.0], ['60m', 5.25, 5.45], ['40m', 7.0, 7.3], + ['30m', 10.1, 10.15], ['20m', 14.0, 14.35], ['17m', 18.068, 18.168], + ['15m', 21.0, 21.45], ['12m', 24.89, 24.99], ['10m', 28.0, 29.7], ['6m', 50.0, 54.0], + ]; + for (const [name, lo, hi] of bands) if (mhz >= lo && mhz <= hi) return name; + return ''; +} + +function modeMatches(btn: string, cur?: string): boolean { + if (!cur) return false; + if (btn === 'SSB') return cur === 'SSB' || cur === 'USB' || cur === 'LSB'; + if (btn === 'FT8') return cur === 'FT8' || cur === 'FT4' || cur === 'DATA'; + return btn === cur; +} + + +// Split the 0-100 S-meter reading into S units + dB over S9. +// +// The FTDX10 answers SM0 on a 0-255 scale and its manual does not say where S9 +// falls; the front panel puts it at roughly half travel, which is the 50 used +// here. That figure is a HYPOTHESIS — if reports come out consistently one S +// unit off on a radio, this is the number to correct, not the RST helper. +function sParts(v: number): { s: number; over: number; label: string } { + if (v >= 50) { + const over = Math.max(0, Math.round((v - 50) * 60 / 50)); + return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' }; + } + const s = Math.max(0, Math.min(9, Math.round(v / 5.5))); + return { s, over: 0, label: `S${s}` }; +} + +function Slider({ value, onChange, disabled, accent = '#2563eb' }: { + value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; +}) { + const v = Math.max(0, Math.min(100, value)); + const ref = useRef(null); + // React's onWheel is passive, so preventDefault is ignored there — attach a + // native non-passive listener, and read live values through refs so the + // handler never closes over a stale value. + const valRef = useRef(value); valRef.current = value; + const cbRef = useRef(onChange); cbRef.current = onChange; + const disRef = useRef(disabled); disRef.current = disabled; + useEffect(() => { + const el = ref.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + if (disRef.current) return; + e.preventDefault(); + const nv = Math.max(0, Math.min(100, valRef.current + (e.deltaY < 0 ? 1 : -1))); + if (nv !== valRef.current) cbRef.current(nv); + }; + el.addEventListener('wheel', onWheel, { passive: false }); + return () => el.removeEventListener('wheel', onWheel); + }, []); + return ( + onChange(parseInt(e.target.value, 10))} + className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default', + '[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full', + '[&::-webkit-slider-thumb]:bg-card [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm')} + style={{ background: `linear-gradient(to right, ${accent} ${v}%, var(--muted))`, borderColor: accent }} + /> + ); +} + +function Segmented({ value, options, onChange }: { + value: string; options: { v: string; l: string }[]; onChange: (v: string) => void; +}) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ); +} + +function Chip({ on, onClick, label, title }: { on: boolean; onClick: () => void; label: string; title?: string }) { + return ( + + ); +} + +function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) { + return ( +
+
+ + {title} +
+
{children}
+
+ ); +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +function Meter({ label, value, accent, onClick, title }: { + label: string; value: number; accent: string; onClick?: () => void; title?: string; +}) { + const v = Math.max(0, Math.min(100, value)); + const body = ( + <> + {label} +
+
+
+ {v} + + ); + if (onClick) { + return ; + } + return
{body}
; +} + +export function YaesuPanel({ onReportRST }: { onReportRST?: (rst: string) => void }) { + const { t } = useI18n(); + const [st, setSt] = useState(ZERO); + const [freqHz, setFreqHz] = useState(0); + const [err, setErr] = useState(''); + + // Optimistic local values for the sliders. Without them a drag fights the + // poll: the rig's older reading arrives mid-gesture and yanks the thumb back. + const [local, setLocal] = useState>({}); + const localAtRef = useRef(0); + + useEffect(() => { + let alive = true; + const tick = async () => { + try { + const s = (await GetYaesuState()) as YaesuState; + const c = await GetCATState(); + if (!alive) return; + setSt(s); + setFreqHz((c as any)?.freq_hz ?? 0); + // Drop the optimistic overlay once the rig has had time to answer with + // the new value — 1.2 s covers the slow-beat settings read. + if (Date.now() - localAtRef.current > 1200) setLocal({}); + setErr(''); + } catch (e: any) { + if (alive) setErr(String(e?.message ?? e)); + } + }; + tick(); + const id = window.setInterval(tick, 400); + return () => { alive = false; window.clearInterval(id); }; + }, []); + + const view = { ...st, ...local }; + + // Every setter follows the same shape: show the value at once, remember when, + // and let the poll take over. A rejected command surfaces as an error rather + // than as a control that silently springs back. + function push(key: K, value: YaesuState[K], fn: () => Promise) { + setLocal((l) => ({ ...l, [key]: value })); + localAtRef.current = Date.now(); + fn().catch((e) => setErr(String(e?.message ?? e))); + } + + const band = bandOfHz(freqHz); + + if (!st.available) { + return ( +
+
+ +

{t('yaesu.notConnected')}

+ {err &&

{err}

} +
+
+ ); + } + + return ( +
+ {/* VFO + status */} +
+
+
{st.model || 'Yaesu'}
+
{fmtVFO(freqHz)}
+
+
+ {st.transmitting && ( + TX + )} + push('split', !view.split, () => SetYaesuSplit(!view.split))} label="SPLIT" /> + +
+
+ + {err &&

{err}

} + + {/* Meters */} + + { const sp = sParts(view.s_meter); onReportRST(sMeterRST(sp.s, sp.over, view.mode)); } : undefined} + title={t('yaesu.sToRst')} /> + + + + + {/* Bands + modes */} + +
+ {BANDS.map((b) => ( + + ))} +
+
+ {MODES.map((m) => ( + + ))} +
+
+ + {/* Receive */} + + + push('af_gain', v, () => SetYaesuAFGain(v))} /> + {view.af_gain} + + + push('rf_gain', v, () => SetYaesuRFGain(v))} /> + {view.rf_gain} + + + push('squelch', v, () => SetYaesuSquelch(v))} /> + {view.squelch} + + + push('agc', v, () => SetYaesuAGC(v))} /> + + + push('preamp', parseInt(v, 10), () => SetYaesuPreamp(parseInt(v, 10)))} /> + 0} onClick={() => push('att', view.att > 0 ? 0 : 12, () => SetYaesuAtt(view.att > 0 ? 0 : 12))} label="ATT" /> + + + + {/* Noise + filter */} + +
+ push('nb', !view.nb, () => SetYaesuNB(!view.nb))} label="NB" /> + push('nr', !view.nr, () => SetYaesuNR(!view.nr))} label="DNR" /> + push('narrow', !view.narrow, () => SetYaesuNarrow(!view.narrow))} label="NAR" /> +
+ + {/* 1-15 on the rig, shown as-is rather than rescaled to a percentage: + the radio's own display counts 1-15, and matching it is what makes + the panel readable next to the front panel. */} + { const v = parseInt(e.target.value, 10); push('nr_level', v, () => SetYaesuNRLevel(v)); }} + className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 accent-primary" /> + {view.nr_level || 1} + +
+ + {/* Transmit */} + + + {/* Watts, not a percentage: the rig reports and takes watts, and a + percentage would be a second unit to reconcile every time. */} + { const v = parseInt(e.target.value, 10); push('rf_power', v, () => SetYaesuPower(v)); }} + className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer accent-primary" /> + {view.rf_power}W + + + push('mic_gain', v, () => SetYaesuMicGain(v))} /> + {view.mic_gain} + + + push('vox', !view.vox, () => SetYaesuVOX(!view.vox))} label="VOX" /> + + + +
+ ); +} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 04f0f42..d58bab3 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -106,7 +106,7 @@ const en: Dict = { 'settings.pane.map1': 'Map — great-circle + beam', 'settings.pane.map2': 'Map — locator (street)', 'settings.pane.cluster': 'Cluster spots', 'settings.pane.worked': 'Worked before', 'settings.pane.recent': 'Recent QSOs', 'settings.pane.netcontrol': 'Net control', - 'settings.pane.flex': 'FlexRadio controls', 'settings.pane.icom': 'Icom console', + 'settings.pane.flex': 'FlexRadio controls', 'settings.pane.icom': 'Icom console', 'settings.pane.yaesu': 'Yaesu console', 'yaesu.notConnected': 'Yaesu CAT not connected', 'yaesu.meters': 'Meters', 'yaesu.bandMode': 'Band & mode', 'yaesu.receive': 'Receive', 'yaesu.noiseFilter': 'Noise & filter', 'yaesu.transmit': 'Transmit', 'yaesu.refresh': 'Refresh', 'yaesu.tuneHint': 'Start an antenna-tuner cycle', 'yaesu.sToRst': 'Click to fill the RST sent', 'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.light-cool': 'Cool light', 'theme.light-sage': 'Sage light', 'theme.dim-slate': 'Dim slate', 'theme.dark-warm': 'Warm dark', 'theme.dark-graphite': 'Graphite dark', 'theme.high-contrast': 'High contrast', @@ -287,7 +287,6 @@ const en: Dict = { 'cat.tciHost': 'TCI host', 'cat.tciHint': 'Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.', 'cat.tciSpots': 'Show cluster spots on the panorama', 'cat.tciSpotsHint': "(spots from OpsLog's DX cluster appear on the SDR panadapter)", 'cat.pollMs': 'Poll interval (ms)', 'cat.delayMs': 'CAT delay (ms)', 'cat.digitalDefault': 'Default digital mode (when rig reports DIG)', 'cat.modeBeforeFreq': 'Set mode before frequency', 'cat.modeBeforeFreqHint': '(older rigs that drop the mode after a band change)', 'cat.omnirigHint': 'Configure your rig (COM port, baud rate, model) in OmniRig\'s own settings GUI first. OpsLog will read whichever Rig slot you select here. Set CAT delay above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu). OmniRig only reports generic "DIG" for digital modes — Default digital mode is the specific mode OpsLog will surface (and log).', - 'cat.flexHint': 'Native SmartSDR API — no OmniRig needed. Frequency, mode and split are read in real time from the radio (no polling, no second-click mode bug). Use Detect radios or enter the IP. Default digital mode is what OpsLog logs when the slice is in a digital mode (DIGU/DIGL).', 'cat.rotatorOk': "Packet sent — antenna should swing to 0° (north). If it didn't, check PstRotator host/port and that PstRotator's UDP listener is enabled.", 'cat.ubOk': 'Connected — the antenna responded with a status frame.', // External services (repeated labels) @@ -519,7 +518,7 @@ const fr: Dict = { 'settings.pane.map1': 'Carte — orthodromie + faisceau', 'settings.pane.map2': 'Carte — locator (rue)', 'settings.pane.cluster': 'Spots cluster', 'settings.pane.worked': 'Déjà contactés', 'settings.pane.recent': 'QSO récents', 'settings.pane.netcontrol': 'Gestion de net', - 'settings.pane.flex': 'Contrôles FlexRadio', 'settings.pane.icom': 'Console Icom', + 'settings.pane.flex': 'Contrôles FlexRadio', 'settings.pane.icom': 'Console Icom', 'settings.pane.yaesu': 'Console Yaesu', 'yaesu.notConnected': 'CAT Yaesu non connecté', 'yaesu.meters': 'Mesures', 'yaesu.bandMode': 'Bande et mode', 'yaesu.receive': 'Réception', 'yaesu.noiseFilter': 'Bruit et filtre', 'yaesu.transmit': 'Émission', 'yaesu.refresh': 'Actualiser', 'yaesu.tuneHint': "Lancer un cycle d'accord d'antenne", 'yaesu.sToRst': 'Cliquer pour remplir le RST envoyé', 'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.light-cool': 'Clair froid', 'theme.light-sage': 'Clair sauge', 'theme.dim-slate': 'Ardoise tamisé', 'theme.dark-warm': 'Sombre chaud', 'theme.dark-graphite': 'Sombre graphite', 'theme.high-contrast': 'Contraste élevé', @@ -689,7 +688,6 @@ const fr: Dict = { 'cat.tciHost': 'Hôte TCI', 'cat.tciHint': 'Active le serveur TCI dans ExpertSDR2/EESDR (Options → TCI). Port par défaut 40001. Utilise 127.0.0.1 si OpsLog tourne sur le même PC.', 'cat.tciSpots': 'Afficher les spots cluster sur le panorama', 'cat.tciSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur le panadapter SDR)", 'cat.pollMs': 'Intervalle de poll (ms)', 'cat.delayMs': 'Délai CAT (ms)', 'cat.digitalDefault': 'Mode numérique par défaut (quand le poste indique DIG)', 'cat.modeBeforeFreq': 'Régler le mode avant la fréquence', 'cat.modeBeforeFreqHint': '(anciens postes qui perdent le mode après un changement de bande)', 'cat.omnirigHint': "Configure d'abord ton poste (port COM, débit, modèle) dans l'interface de réglages d'OmniRig. OpsLog lira le slot Rig que tu choisis ici. Mets le délai CAT au-dessus de 0 si ton poste perd des commandes envoyées coup sur coup (certains anciens Kenwood/Yaesu). OmniRig ne rapporte qu'un « DIG » générique pour les modes numériques — le mode numérique par défaut est le mode précis qu'OpsLog affichera (et loggera).", - 'cat.flexHint': "API SmartSDR native — pas besoin d'OmniRig. Fréquence, mode et split sont lus en temps réel depuis la radio (sans poll, sans le bug du mode au 2ᵉ clic). Utilise Détecter les radios ou saisis l'IP. Le mode numérique par défaut est ce qu'OpsLog logge quand la slice est en mode numérique (DIGU/DIGL).", 'cat.rotatorOk': "Paquet envoyé — l'antenne devrait tourner vers 0° (nord). Sinon, vérifie l'hôte/port PstRotator et que l'écouteur UDP de PstRotator est activé.", 'cat.ubOk': "Connecté — l'antenne a répondu avec une trame de statut.", 'es.autoUpload': 'Envoi automatique à chaque nouveau QSO', 'es.uploadTiming': "Moment de l'envoi", 'es.immediate': 'Immédiat', 'es.delayed': 'Différé (1–2 min, permet de corriger les erreurs)', 'es.onClose': "À la fermeture (par lot)", 'es.testConn': 'Tester la connexion', 'es.testing': 'Test…', 'es.password': 'Mot de passe', 'es.showPass': 'Afficher le mot de passe', 'es.hidePass': 'Masquer le mot de passe', 'es.apiKey': 'Clé API', 'es.cloudlogUrl': "Adresse de l'instance", 'es.cloudlogUrlPh': 'https://log.exemple.fr', 'es.cloudlogStationId': 'ID de station', 'es.cloudlogKeyPh': 'clé API lecture/écriture', 'es.cloudlogHint': "Cloudlog et Wavelog sont auto-hébergés : indiquez l'adresse de VOTRE instance (une IP convient en réseau local). La clé API se crée dans Compte → API Keys et doit être en lecture/écriture ; l'ID de station est le numéro de l'emplacement sous lequel les QSO sont classés (page Station Locations). Les doublons sont refusés par le serveur, renvoyer un QSO est donc sans risque.", 'es.forceCall': "Forcer l'indicatif de station", 'es.accountEmail': 'E-mail du compte', 'es.logbookCall': 'Indicatif du carnet', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index f2385a3..e93212c 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -499,6 +499,8 @@ export function GetWinkeyerSettings():Promise; export function GetWinkeyerStatus():Promise; +export function GetYaesuState():Promise; + export function HasBuiltinReferences(arg1:string):Promise; export function IcomRefresh():Promise; @@ -755,6 +757,8 @@ export function RefreshCtyDat():Promise; export function RefreshSolar():Promise; +export function RefreshYaesuPanel():Promise; + export function ReloadUDPIntegrations():Promise>; export function RemovePassphrase(arg1:string):Promise; @@ -911,6 +915,36 @@ export function SetUltrabeamDirection(arg1:number):Promise; export function SetWinkeyerTrace(arg1:boolean):Promise; +export function SetYaesuAFGain(arg1:number):Promise; + +export function SetYaesuAGC(arg1:string):Promise; + +export function SetYaesuAtt(arg1:number):Promise; + +export function SetYaesuBand(arg1:string):Promise; + +export function SetYaesuMicGain(arg1:number):Promise; + +export function SetYaesuNB(arg1:boolean):Promise; + +export function SetYaesuNR(arg1:boolean):Promise; + +export function SetYaesuNRLevel(arg1:number):Promise; + +export function SetYaesuNarrow(arg1:boolean):Promise; + +export function SetYaesuPower(arg1:number):Promise; + +export function SetYaesuPreamp(arg1:number):Promise; + +export function SetYaesuRFGain(arg1:number):Promise; + +export function SetYaesuSplit(arg1:boolean):Promise; + +export function SetYaesuSquelch(arg1:number):Promise; + +export function SetYaesuVOX(arg1:boolean):Promise; + export function StartCWDecoder():Promise; export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise; @@ -947,6 +981,8 @@ export function TestStationDevice(arg1:main.StationDevice):Promise; +export function TuneYaesuATU():Promise; + export function TunerGeniusActivate(arg1:number):Promise; export function TunerGeniusAutotune():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 41562fd..fdc3153 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -946,6 +946,10 @@ export function GetWinkeyerStatus() { return window['go']['main']['App']['GetWinkeyerStatus'](); } +export function GetYaesuState() { + return window['go']['main']['App']['GetYaesuState'](); +} + export function HasBuiltinReferences(arg1) { return window['go']['main']['App']['HasBuiltinReferences'](arg1); } @@ -1458,6 +1462,10 @@ export function RefreshSolar() { return window['go']['main']['App']['RefreshSolar'](); } +export function RefreshYaesuPanel() { + return window['go']['main']['App']['RefreshYaesuPanel'](); +} + export function ReloadUDPIntegrations() { return window['go']['main']['App']['ReloadUDPIntegrations'](); } @@ -1770,6 +1778,66 @@ export function SetWinkeyerTrace(arg1) { return window['go']['main']['App']['SetWinkeyerTrace'](arg1); } +export function SetYaesuAFGain(arg1) { + return window['go']['main']['App']['SetYaesuAFGain'](arg1); +} + +export function SetYaesuAGC(arg1) { + return window['go']['main']['App']['SetYaesuAGC'](arg1); +} + +export function SetYaesuAtt(arg1) { + return window['go']['main']['App']['SetYaesuAtt'](arg1); +} + +export function SetYaesuBand(arg1) { + return window['go']['main']['App']['SetYaesuBand'](arg1); +} + +export function SetYaesuMicGain(arg1) { + return window['go']['main']['App']['SetYaesuMicGain'](arg1); +} + +export function SetYaesuNB(arg1) { + return window['go']['main']['App']['SetYaesuNB'](arg1); +} + +export function SetYaesuNR(arg1) { + return window['go']['main']['App']['SetYaesuNR'](arg1); +} + +export function SetYaesuNRLevel(arg1) { + return window['go']['main']['App']['SetYaesuNRLevel'](arg1); +} + +export function SetYaesuNarrow(arg1) { + return window['go']['main']['App']['SetYaesuNarrow'](arg1); +} + +export function SetYaesuPower(arg1) { + return window['go']['main']['App']['SetYaesuPower'](arg1); +} + +export function SetYaesuPreamp(arg1) { + return window['go']['main']['App']['SetYaesuPreamp'](arg1); +} + +export function SetYaesuRFGain(arg1) { + return window['go']['main']['App']['SetYaesuRFGain'](arg1); +} + +export function SetYaesuSplit(arg1) { + return window['go']['main']['App']['SetYaesuSplit'](arg1); +} + +export function SetYaesuSquelch(arg1) { + return window['go']['main']['App']['SetYaesuSquelch'](arg1); +} + +export function SetYaesuVOX(arg1) { + return window['go']['main']['App']['SetYaesuVOX'](arg1); +} + export function StartCWDecoder() { return window['go']['main']['App']['StartCWDecoder'](); } @@ -1842,6 +1910,10 @@ export function TestUltrabeam(arg1) { return window['go']['main']['App']['TestUltrabeam'](arg1); } +export function TuneYaesuATU() { + return window['go']['main']['App']['TuneYaesuATU'](); +} + export function TunerGeniusActivate(arg1) { return window['go']['main']['App']['TunerGeniusActivate'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 5b5df2b..dc902e3 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1074,6 +1074,58 @@ export namespace cat { this.fixed = source["fixed"]; } } + export class YaesuTXState { + available: boolean; + model?: string; + mode?: string; + transmitting: boolean; + split: boolean; + s_meter: number; + power_meter: number; + swr_meter: number; + rf_power: number; + mic_gain: number; + af_gain: number; + rf_gain: number; + squelch: number; + agc?: string; + preamp: number; + att: number; + nb: boolean; + nr: boolean; + nr_level: number; + narrow: boolean; + vox: boolean; + + static createFrom(source: any = {}) { + return new YaesuTXState(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.available = source["available"]; + this.model = source["model"]; + this.mode = source["mode"]; + this.transmitting = source["transmitting"]; + this.split = source["split"]; + this.s_meter = source["s_meter"]; + this.power_meter = source["power_meter"]; + this.swr_meter = source["swr_meter"]; + this.rf_power = source["rf_power"]; + this.mic_gain = source["mic_gain"]; + this.af_gain = source["af_gain"]; + this.rf_gain = source["rf_gain"]; + this.squelch = source["squelch"]; + this.agc = source["agc"]; + this.preamp = source["preamp"]; + this.att = source["att"]; + this.nb = source["nb"]; + this.nr = source["nr"]; + this.nr_level = source["nr_level"]; + this.narrow = source["narrow"]; + this.vox = source["vox"]; + } + } } diff --git a/internal/cat/cat.go b/internal/cat/cat.go index efc56d4..c6df0b8 100644 --- a/internal/cat/cat.go +++ b/internal/cat/cat.go @@ -845,3 +845,27 @@ func BandFromHz(hz int64) string { } return "" } + +// YaesuState returns the panel snapshot, or (zero, false) when the active +// backend isn't a Yaesu — same shape as IcomState. +func (m *Manager) YaesuState() (YaesuTXState, bool) { + m.mu.RLock() + b := m.backend + m.mu.RUnlock() + if yc, ok := b.(YaesuController); ok { + return yc.YaesuState(), true + } + return YaesuTXState{}, false +} + +// YaesuDo dispatches a Yaesu control onto the CAT goroutine, so a panel click +// and the poll loop never share the serial port at the same instant. +func (m *Manager) YaesuDo(fn func(YaesuController) error) error { + return m.exec(func(b Backend) error { + yc, ok := b.(YaesuController) + if !ok { + return fmt.Errorf("active CAT backend is not a Yaesu") + } + return fn(yc) + }) +} diff --git a/internal/cat/yaesu.go b/internal/cat/yaesu.go index 2345469..f209b6c 100644 --- a/internal/cat/yaesu.go +++ b/internal/cat/yaesu.go @@ -95,6 +95,11 @@ type Yaesu struct { curFreq int64 curVFO string // "A" or "B" + + // Control-panel state and its slow-beat counter — see yaesu_panel.go. + panel YaesuTXState + panelCycle int + panelLoaded bool } func NewYaesu(portName string, baud int, digital string) *Yaesu { @@ -216,6 +221,7 @@ func (y *Yaesu) ReadState() (RigState, error) { debugLog.Printf("yaesu: unknown mode reply %q", r) } } + y.readPanel(s.Mode, s.Split) return s, nil } diff --git a/internal/cat/yaesu_panel.go b/internal/cat/yaesu_panel.go new file mode 100644 index 0000000..c41f4e8 --- /dev/null +++ b/internal/cat/yaesu_panel.go @@ -0,0 +1,404 @@ +package cat + +// The Yaesu control panel: meters and the settings an operator reaches for +// mid-QSO. Split out from yaesu.go, which stays the small fast path the whole +// application depends on — a panel read that hangs must never delay the +// frequency display. +// +// Reads are STAGGERED. Meters change constantly and are polled every cycle; +// settings (power, gains, AGC, filters) only change when someone turns a knob, +// so they are refreshed every 8th cycle and immediately after any set. At the +// default 250 ms cycle that is a two-second worst case for a knob turned on the +// radio, against a serial link that would otherwise carry twenty queries a +// second and starve the frequency poll it shares. +// +// Verified on: FTDX10. Commands come from its CAT reference; a control a model +// does not implement simply never answers, and askNum keeps the previous value +// rather than showing a zero that reads as "it reset itself". + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// YaesuTXState is the panel snapshot handed to the frontend. +type YaesuTXState struct { + Available bool `json:"available"` + Model string `json:"model,omitempty"` + Mode string `json:"mode,omitempty"` + + Transmitting bool `json:"transmitting"` + Split bool `json:"split"` + SMeter int `json:"s_meter"` // 0-100 (raw 0-255) + PowerMeter int `json:"power_meter"` // 0-100, TX only + SWRMeter int `json:"swr_meter"` // 0-100, TX only + + RFPower int `json:"rf_power"` // watts + 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 + Att int `json:"att"` // 0=off, else dB + NB bool `json:"nb"` + NR bool `json:"nr"` + NRLevel int `json:"nr_level"` // 1-15 + Narrow bool `json:"narrow"` // NAR filter + VOX bool `json:"vox"` +} + +// YaesuController is the typed escape the Manager exposes for the panel, in the +// same shape as FlexController and IcomController. +type YaesuController interface { + YaesuState() YaesuTXState + RefreshYaesu() error + SetYaesuPower(int) error + SetYaesuMicGain(int) error + SetYaesuAFGain(int) error + SetYaesuRFGain(int) error + SetYaesuSquelch(int) error + SetYaesuAGC(string) error + SetYaesuPreamp(int) error + SetYaesuAtt(int) error + SetYaesuNB(bool) error + SetYaesuNR(bool) error + SetYaesuNRLevel(int) error + SetYaesuNarrow(bool) error + SetYaesuVOX(bool) error + SetYaesuSplit(bool) error + SetYaesuBand(string) error + TuneYaesuATU() error +} + +func (y *Yaesu) YaesuState() YaesuTXState { + y.mu.Lock() + defer y.mu.Unlock() + st := y.panel + st.Available = y.port != nil + st.Model = y.model + return st +} + +// readPanel refreshes the meters, and the settings on the slower beat. Called +// from ReadState with the mutex HELD, so it shares the same serialised link. +func (y *Yaesu) readPanel(mode string, split bool) { + y.panel.Mode = mode + y.panel.Split = split + + // TX state first: which meters mean anything depends on it, and a power + // reading shown while receiving is how a panel lies. + if r, err := y.ask("TX;"); err == nil && len(r) >= 3 { + y.panel.Transmitting = r[2] != '0' + } + if v, ok := y.askNum("SM0;", "SM0", 3); ok { + y.panel.SMeter = scale255(v) + } + if y.panel.Transmitting { + if v, ok := y.askNum("RM4;", "RM4", 3); ok { + y.panel.PowerMeter = scale255(v) + } + if v, ok := y.askNum("RM5;", "RM5", 3); ok { + y.panel.SWRMeter = scale255(v) + } + } else { + // Zeroed rather than frozen: a stale SWR bar from the last transmission + // reads as a live measurement. + y.panel.PowerMeter, y.panel.SWRMeter = 0, 0 + } + + y.panelCycle++ + if y.panelLoaded && y.panelCycle < 8 { + return + } + y.panelCycle = 0 + y.panelLoaded = true + y.readPanelSettings() +} + +// readPanelSettings re-reads everything a knob can change. Separate so a set can +// force it without waiting for the slow beat. +func (y *Yaesu) readPanelSettings() { + if v, ok := y.askNum("PC;", "PC", 3); ok { + y.panel.RFPower = v // watts, not a 0-255 scale + } + if v, ok := y.askNum("MG;", "MG", 3); ok { + y.panel.MicGain = scale255(v) + } + if v, ok := y.askNum("AG0;", "AG0", 3); ok { + y.panel.AFGain = scale255(v) + } + if v, ok := y.askNum("RG0;", "RG0", 3); ok { + y.panel.RFGain = scale255(v) + } + if v, ok := y.askNum("SQ0;", "SQ0", 3); ok { + y.panel.Squelch = scale255(v) + } + if v, ok := y.askNum("GT0;", "GT0", 1); ok { + y.panel.AGC = yaesuAGCName(v) + } + if v, ok := y.askNum("PA0;", "PA0", 1); ok { + y.panel.Preamp = v + } + if v, ok := y.askNum("RA0;", "RA0", 1); ok { + y.panel.Att = yaesuAttDB(v) + } + if v, ok := y.askNum("NB0;", "NB0", 1); ok { + y.panel.NB = v != 0 + } + if v, ok := y.askNum("NR0;", "NR0", 1); ok { + y.panel.NR = v != 0 + } + if v, ok := y.askNum("RL0;", "RL0", 2); ok { + y.panel.NRLevel = v + } + if v, ok := y.askNum("NA0;", "NA0", 1); ok { + y.panel.Narrow = v != 0 + } + if v, ok := y.askNum("VX;", "VX", 1); ok { + y.panel.VOX = v != 0 + } +} + +// askNum sends a query and reads a fixed-width decimal field out of the reply. +// ok=false when the rig does not answer, or answers something else — a control +// this model lacks then keeps its previous value instead of dropping to zero, +// which would look like a setting that reset itself. +func (y *Yaesu) askNum(cmd, prefix string, digits int) (int, bool) { + r, err := y.ask(cmd) + if err != nil { + return 0, false + } + if !strings.HasPrefix(r, prefix) || len(r) < len(prefix)+digits { + debugLog.Printf("yaesu: unexpected reply %q to %q", r, cmd) + return 0, false + } + n, err := strconv.Atoi(r[len(prefix) : len(prefix)+digits]) + if err != nil { + return 0, false + } + return n, true +} + +// setAndRefresh writes a command then re-reads the settings, so the panel shows +// what the RIG ended up with rather than what we asked for — the two differ +// whenever a value is out of range or the current mode forbids the control. +func (y *Yaesu) setAndRefresh(cmd string) error { + y.mu.Lock() + defer y.mu.Unlock() + if y.port == nil { + return fmt.Errorf("yaesu: not connected") + } + if err := y.write(cmd); err != nil { + return err + } + time.Sleep(30 * time.Millisecond) // let the rig apply it before reading back + y.readPanelSettings() + return nil +} + +func (y *Yaesu) RefreshYaesu() error { + y.mu.Lock() + defer y.mu.Unlock() + if y.port == nil { + return fmt.Errorf("yaesu: not connected") + } + y.readPanelSettings() + return nil +} + +func (y *Yaesu) SetYaesuPower(w int) error { + return y.setAndRefresh(fmt.Sprintf("PC%03d;", clampInt(w, 5, 100))) +} + +func (y *Yaesu) SetYaesuMicGain(p int) error { + return y.setAndRefresh(fmt.Sprintf("MG%03d;", from100(p))) +} + +func (y *Yaesu) SetYaesuAFGain(p int) error { + return y.setAndRefresh(fmt.Sprintf("AG0%03d;", from100(p))) +} + +func (y *Yaesu) SetYaesuRFGain(p int) error { + return y.setAndRefresh(fmt.Sprintf("RG0%03d;", from100(p))) +} + +func (y *Yaesu) SetYaesuSquelch(p int) error { + return y.setAndRefresh(fmt.Sprintf("SQ0%03d;", from100(p))) +} + +func (y *Yaesu) SetYaesuAGC(name string) error { + return y.setAndRefresh(fmt.Sprintf("GT0%d;", yaesuAGCCode(name))) +} + +func (y *Yaesu) SetYaesuPreamp(n int) error { + return y.setAndRefresh(fmt.Sprintf("PA0%d;", clampInt(n, 0, 2))) +} + +func (y *Yaesu) SetYaesuAtt(db int) error { + return y.setAndRefresh(fmt.Sprintf("RA0%d;", yaesuAttCode(db))) +} + +func (y *Yaesu) SetYaesuNB(on bool) error { + return y.setAndRefresh(fmt.Sprintf("NB0%d;", boolDigit(on))) +} + +func (y *Yaesu) SetYaesuNR(on bool) error { + return y.setAndRefresh(fmt.Sprintf("NR0%d;", boolDigit(on))) +} + +func (y *Yaesu) SetYaesuNRLevel(n int) error { + return y.setAndRefresh(fmt.Sprintf("RL0%02d;", clampInt(n, 1, 15))) +} + +func (y *Yaesu) SetYaesuNarrow(on bool) error { + return y.setAndRefresh(fmt.Sprintf("NA0%d;", boolDigit(on))) +} + +func (y *Yaesu) SetYaesuVOX(on bool) error { + return y.setAndRefresh(fmt.Sprintf("VX%d;", boolDigit(on))) +} + +// SetYaesuSplit uses whichever command this rig answered at connect. Sending the +// other one would be silently ignored, and the operator would get a split button +// that does nothing. +func (y *Yaesu) SetYaesuSplit(on bool) error { + y.mu.Lock() + cmd := y.splitCmd + y.mu.Unlock() + if cmd == "" { + return fmt.Errorf("yaesu: this rig answered neither ST; nor FT; — split cannot be set") + } + return y.setAndRefresh(fmt.Sprintf("%s%d;", cmd, boolDigit(on))) +} + +// SetYaesuBand switches band with BS, which lands the rig on ITS OWN last-used +// frequency for that band — the radio's band memory, not a frequency we choose. +func (y *Yaesu) SetYaesuBand(band string) error { + code, ok := yaesuBandCode(band) + if !ok { + return fmt.Errorf("yaesu: no band code for %q", band) + } + return y.setAndRefresh(fmt.Sprintf("BS%02d;", code)) +} + +// TuneYaesuATU starts a tuning cycle. Deliberately not followed by a settings +// read: the rig is transmitting into the tuner for several seconds and answers +// little, so the reads would just time out one after another. +func (y *Yaesu) TuneYaesuATU() error { + y.mu.Lock() + defer y.mu.Unlock() + if y.port == nil { + return fmt.Errorf("yaesu: not connected") + } + return y.write("AC002;") +} + +// ── small mappings ──────────────────────────────────────────────────────── + +func scale255(v int) int { + if v <= 0 { + return 0 + } + if v >= 255 { + return 100 + } + return v * 100 / 255 +} + +func from100(p int) int { return clampInt(p, 0, 100) * 255 / 100 } + +func clampInt(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func boolDigit(b bool) int { + if b { + return 1 + } + return 0 +} + +func yaesuAGCName(code int) string { + switch code { + case 0: + return "OFF" + case 1: + return "FAST" + case 2: + return "MID" + case 3: + return "SLOW" + case 4: + return "AUTO" + } + return "" +} + +func yaesuAGCCode(name string) int { + switch strings.ToUpper(strings.TrimSpace(name)) { + case "OFF": + return 0 + case "FAST": + return 1 + case "MID", "MEDIUM": + return 2 + case "SLOW": + return 3 + } + return 4 // AUTO — the safe default for anything unrecognised +} + +// The FTDX10's attenuator is a single 12 dB step (RA00/RA01). Reporting the dB +// rather than the raw code lets the panel label the button honestly, and leaves +// room for models with more steps. +func yaesuAttDB(code int) int { + if code == 0 { + return 0 + } + return 12 +} + +func yaesuAttCode(db int) int { + if db <= 0 { + return 0 + } + return 1 +} + +// yaesuBandCode maps an ADIF band to the BS command's band number. +func yaesuBandCode(band string) (int, bool) { + switch strings.ToLower(strings.TrimSpace(band)) { + case "160m": + return 0, true + case "80m": + return 1, true + case "60m": + return 2, true + case "40m": + return 3, true + case "30m": + return 4, true + case "20m": + return 5, true + case "17m": + return 6, true + case "15m": + return 7, true + case "12m": + return 8, true + case "10m": + return 9, true + case "6m": + return 10, true + } + return 0, false +}