feat(elecraft): a K3/K4 console — power, volume, S-meter, SWR, MOX, tune
The Elecraft backend already existed; what was missing was somewhere to operate the radio from. This adds the panel, on the Kenwood-dialect client the K3 already speaks, in a tab of its own beside the Yaesu and Icom consoles. Scope is the six controls asked for and nothing else. Every extra command added without a radio to test it against is a control that may or may not do what its label says, and a K3 exposes dozens. No K3 was available while writing this, so the two halves are treated differently. The setters are the commands the reference documents unambiguously and whose effect is visible and reversible (PC, AG, TX/RX). The meters are the opposite: their scaling differs by model and firmware, so the raw answers are logged next to the power setting, the panel says the scaling is provisional, and an unmeasured SWR shows as '—' rather than as a perfect 1.0 — a good match on an antenna nobody measured is the reading that costs a radio. ATU tune sends SWT20 (the K3 front-panel tap) and logs exactly what it sent, so a wrong mapping names itself instead of leaving an operator guessing which button OpsLog pressed.
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
// Elecraft K3/K4 panel bindings.
|
||||
//
|
||||
// The controls the operator asked for and nothing else: power, volume, the
|
||||
// S-meter and SWR, MOX, and an ATU tune. A K3 exposes dozens of commands, but a
|
||||
// panel earns its place by covering what is reached for during a QSO — and each
|
||||
// one added without a radio to test it against is a control that may or may not
|
||||
// do what its label says. See internal/cat/kenwood_panel.go.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"hamlog/internal/cat"
|
||||
)
|
||||
|
||||
// GetKenwoodState returns the K3/K4 panel snapshot. Zero value when the active
|
||||
// CAT backend is something else, which is how the UI knows to hide the panel.
|
||||
func (a *App) GetKenwoodState() cat.KenwoodTXState {
|
||||
if a.cat == nil {
|
||||
return cat.KenwoodTXState{}
|
||||
}
|
||||
st, _ := a.cat.KenwoodState()
|
||||
return st
|
||||
}
|
||||
|
||||
func (a *App) SetKenwoodPower(w int) error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodPower(w) })
|
||||
}
|
||||
|
||||
func (a *App) SetKenwoodAFGain(p int) error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodAFGain(p) })
|
||||
}
|
||||
|
||||
// SetKenwoodTX is the panel's MOX.
|
||||
func (a *App) SetKenwoodTX(on bool) error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodTX(on) })
|
||||
}
|
||||
|
||||
func (a *App) TuneKenwoodATU() error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.TuneKenwoodATU() })
|
||||
}
|
||||
|
||||
// RefreshKenwood re-reads the settings on the next poll — for when a knob was
|
||||
// turned on the radio itself.
|
||||
func (a *App) RefreshKenwood() error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.RefreshKenwood() })
|
||||
}
|
||||
|
||||
func (a *App) kenwoodPanelDo(fn func(cat.KenwoodPanelController) error) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("CAT not initialized")
|
||||
}
|
||||
return a.cat.KenwoodPanelDo(fn)
|
||||
}
|
||||
+4
-2
@@ -5,12 +5,14 @@
|
||||
"en": [
|
||||
"Withdrawing deleted QSOs from Club Log is now paced and capped at 25 per deletion. Their delete endpoint is a real-time one, meant for an operator removing a contact they just mis-logged; Club Log watches the rate and blocks the IP of anything that batches through it. Past the cap OpsLog stops and says so — there is no bulk-delete API, and hundreds of removals belong on clublog.org, which has a tool for it.",
|
||||
"Auto-call, withdrawn earlier, is now disarmed where it was remembered: a stored 'enabled' is switched off and written back the first time OpsLog reads it. The running guard already stopped this build from calling anyone, but the stored flag survived — and any build without that guard would key the transmitter for a feature with no switch left to turn it off.",
|
||||
"When TQSL refuses an upload, the log now carries the exact ADIF record it was given and the station location it was told to sign with. 'No QSOs processed' covers several unrelated causes and the temp file is deleted the moment TQSL returns, so the one piece of evidence that mattered was the one nobody could see."
|
||||
"When TQSL refuses an upload, the log now carries the exact ADIF record it was given and the station location it was told to sign with. 'No QSOs processed' covers several unrelated causes and the temp file is deleted the moment TQSL returns, so the one piece of evidence that mattered was the one nobody could see.",
|
||||
"An Elecraft console for the K3/K4: power, volume, S-meter, SWR, MOX and an ATU tune, in a tab of its own when the CAT backend is Elecraft or Kenwood. The meter scaling is marked provisional and the raw readings go to the log — it was written from the K3 Programmer's Reference without a radio to hand, and a wrongly scaled SWR bar reports a good match on a bad antenna."
|
||||
],
|
||||
"fr": [
|
||||
"Le retrait des QSO supprimés chez Club Log est désormais cadencé et limité à 25 par suppression. Leur point d'entrée de suppression est temps réel, prévu pour un opérateur qui retire un contact qu'il vient de mal enregistrer ; Club Log surveille le rythme et bloque l'IP de ce qui passe des lots par là. Au-delà de la limite, OpsLog s'arrête et le dit — il n'existe pas d'API de suppression en masse, et des centaines de retraits se font sur clublog.org, qui a l'outil pour ça.",
|
||||
"L'appel automatique, retiré précédemment, est maintenant désarmé là où il était mémorisé : un « activé » enregistré est éteint et réécrit dès la première lecture par OpsLog. Le garde-fou à l'exécution empêchait déjà cette version d'appeler qui que ce soit, mais l'indicateur enregistré survivait — et toute version sans ce garde-fou passait à l'émission pour une fonction dont il ne reste aucun interrupteur.",
|
||||
"Quand TQSL refuse un envoi, le journal contient désormais l'enregistrement ADIF exact qui lui a été remis et l'emplacement de station demandé pour la signature. « No QSOs processed » recouvre plusieurs causes sans rapport et le fichier temporaire est supprimé dès que TQSL rend la main : la seule pièce à conviction utile était justement invisible."
|
||||
"Quand TQSL refuse un envoi, le journal contient désormais l'enregistrement ADIF exact qui lui a été remis et l'emplacement de station demandé pour la signature. « No QSOs processed » recouvre plusieurs causes sans rapport et le fichier temporaire est supprimé dès que TQSL rend la main : la seule pièce à conviction utile était justement invisible.",
|
||||
"Une console Elecraft pour les K3/K4 : puissance, volume, S-mètre, ROS, MOX et accord de l'ATU, dans un onglet dédié quand le CAT est réglé sur Elecraft ou Kenwood. L'échelle des mesures est signalée comme provisoire et les valeurs brutes partent dans le journal — la console a été écrite d'après le manuel de programmation du K3, sans radio sous la main, et une barre de ROS mal calibrée annonce un bon accord sur une mauvaise antenne."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+18
-2
@@ -78,6 +78,7 @@ import { WorldMap, LocatorMap } from '@/components/MainMap';
|
||||
import { FlexPanel } from '@/components/FlexPanel';
|
||||
import { IcomPanel } from '@/components/IcomPanel';
|
||||
import { YaesuPanel } from '@/components/YaesuPanel';
|
||||
import { ElecraftPanel } from '@/components/ElecraftPanel';
|
||||
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
|
||||
import { MotorAntennaWidget, type AntStatus } from '@/components/MotorAntennaWidget';
|
||||
import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel';
|
||||
@@ -1861,7 +1862,7 @@ export default function App() {
|
||||
// so it's loaded async on mount and re-read on profile:changed below.
|
||||
// 'none' is only ever stored for the third and fourth panes: the first two are
|
||||
// the Main view, and a layout with no panes at all is not a layout.
|
||||
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'netcontrol' | 'decodes' | 'none';
|
||||
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'elecraft' | 'netcontrol' | 'decodes' | 'none';
|
||||
const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now
|
||||
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
|
||||
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
|
||||
@@ -1872,7 +1873,7 @@ export default function App() {
|
||||
// quarter-width map is unreadable.
|
||||
const [mainLayout4, setMainLayout4] = useState<'cols' | 'quad'>('quad');
|
||||
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 === 'yaesu' || v === 'netcontrol' || v === 'decodes';
|
||||
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'yaesu' || v === 'elecraft' || v === 'netcontrol' || v === 'decodes';
|
||||
const [l, r, p3, p4, lay] = await Promise.all([
|
||||
GetUIPref('mainPaneLeft').catch(() => ''),
|
||||
GetUIPref('mainPaneRight').catch(() => ''),
|
||||
@@ -6037,6 +6038,12 @@ export default function App() {
|
||||
<YaesuPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} onKeySpeed={setCWSpeedEverywhere} />
|
||||
</div>
|
||||
);
|
||||
case 'elecraft':
|
||||
return (
|
||||
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
|
||||
<ElecraftPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
||||
</div>
|
||||
);
|
||||
case 'icom':
|
||||
return (
|
||||
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
|
||||
@@ -7303,6 +7310,7 @@ export default function App() {
|
||||
{catState.backend === 'flex' && <TabsTrigger value="flex">Flex Console</TabsTrigger>}
|
||||
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom Console</TabsTrigger>}
|
||||
{catState.backend === 'yaesu' && <TabsTrigger value="yaesu">Yaesu Console</TabsTrigger>}
|
||||
{(catState.backend === 'elecraft' || catState.backend === 'kenwood') && <TabsTrigger value="elecraft">{t('k3.console')}</TabsTrigger>}
|
||||
{statsTabOpen && (
|
||||
<TabsTrigger value="stats" className="gap-1.5">
|
||||
{t('stats.tab')}
|
||||
@@ -7982,6 +7990,14 @@ export default function App() {
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Elecraft K3/K4 console. Shown for the Kenwood backend too: the
|
||||
K3 speaks that dialect and the panel reads whatever answers. */}
|
||||
{(catState.backend === 'elecraft' || catState.backend === 'kenwood') && (
|
||||
<TabsContent value="elecraft" className="flex-1 min-h-0 p-0">
|
||||
<ElecraftPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{catState.backend === 'icom' && (
|
||||
<TabsContent value="icom" className="flex-1 min-h-0 p-0">
|
||||
<IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Radio, Power, Activity, AudioLines, SlidersHorizontal } from 'lucide-react';
|
||||
import {
|
||||
GetKenwoodState, RefreshKenwood, SetKenwoodPower, SetKenwoodAFGain, SetKenwoodTX, TuneKenwoodATU,
|
||||
GetCATState,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sMeterRST } from '@/lib/rst';
|
||||
import { MeterBar } from '@/components/MeterBar';
|
||||
|
||||
type KenwoodState = {
|
||||
available: boolean; model?: string; elecraft: boolean; mode?: string;
|
||||
transmitting: boolean; split: boolean; split_tx_hz?: number;
|
||||
s_meter: number; s_meter_raw: number;
|
||||
power_meter: number; swr: number; swr_raw: number;
|
||||
rf_power: number; af_gain: number;
|
||||
meters_provisional: boolean;
|
||||
};
|
||||
|
||||
const ZERO: KenwoodState = {
|
||||
available: false, elecraft: false, transmitting: false, split: false,
|
||||
s_meter: 0, s_meter_raw: 0, power_meter: 0, swr: 0, swr_raw: 0,
|
||||
rf_power: 0, af_gain: 0, meters_provisional: true,
|
||||
};
|
||||
|
||||
// Raw S-meter → S units. The K3 answers 0-21 across S0…S9+60; S9 is taken at
|
||||
// raw 9 and each step above it as 6 dB. PROVISIONAL, like the rest of the
|
||||
// scaling: the raw value is on screen and in the log, so a real radio settles
|
||||
// it rather than this comment.
|
||||
const S9_RAW = 9;
|
||||
const DB_PER_RAW = 6;
|
||||
function sParts(rawV: number): { s: number; over: number; label: string } {
|
||||
if (rawV >= S9_RAW) {
|
||||
const over = Math.max(0, Math.round((rawV - S9_RAW) * DB_PER_RAW));
|
||||
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
|
||||
}
|
||||
const s = Math.max(0, Math.min(9, rawV));
|
||||
return { s, over: 0, label: `S${s}` };
|
||||
}
|
||||
|
||||
// Segment colour, printed the way a radio's own meter is: green up to S9, amber
|
||||
// through the S9+ range, red once the signal would be reported as 59+20 or more.
|
||||
function sSegColor(frac: number) {
|
||||
if (frac > 0.78) return '#dc2626';
|
||||
if (frac > 0.55) return '#f59e0b';
|
||||
return '#16a34a';
|
||||
}
|
||||
|
||||
export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) => void }) {
|
||||
const { t } = useI18n();
|
||||
const [st, setSt] = useState<KenwoodState>(ZERO);
|
||||
const [freqHz, setFreqHz] = useState(0);
|
||||
const [err, setErr] = useState('');
|
||||
// Optimistic overlay: a slider must follow the finger, not the poll. Dropped
|
||||
// once the radio has had time to answer with the value it actually took.
|
||||
const [local, setLocal] = useState<{ rf_power?: number; af_gain?: number }>({});
|
||||
const localAtRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const s = (await GetKenwoodState()) as KenwoodState;
|
||||
const c = (await GetCATState()) as any;
|
||||
if (!alive) return;
|
||||
setSt(s);
|
||||
setFreqHz(c?.split && c?.freq_rx_hz > 0 ? c.freq_rx_hz : (c?.freq_hz ?? 0));
|
||||
if (Date.now() - localAtRef.current > 1200) setLocal({});
|
||||
setErr('');
|
||||
} catch (e: any) {
|
||||
if (alive) setErr(String(e?.message ?? e));
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 500);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
const view = { ...st, ...local };
|
||||
const off = !st.available;
|
||||
|
||||
const put = (patch: typeof local, run: () => Promise<any>) => {
|
||||
setLocal((p) => ({ ...p, ...patch }));
|
||||
localAtRef.current = Date.now();
|
||||
run().catch((e) => setErr(String(e?.message ?? e)));
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-2 h-full min-h-0 bg-card overflow-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
|
||||
<Radio className="size-4 text-primary shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{st.elecraft ? 'Elecraft' : 'Kenwood'}
|
||||
</span>
|
||||
<span className="text-xs font-mono text-foreground/80">{st.model}</span>
|
||||
<span className={cn('size-2 rounded-full', off ? 'bg-muted-foreground/40' : 'bg-success')} />
|
||||
<div className="flex-1" />
|
||||
<span className="font-mono text-sm tabular-nums">{freqHz > 0 ? (freqHz / 1e6).toFixed(6) : '—'}</span>
|
||||
<button type="button" className="text-[11px] text-muted-foreground hover:text-foreground"
|
||||
onClick={() => RefreshKenwood().catch(() => {})} title={t('k3.refreshHint')}>
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{off && <div className="px-3 text-xs text-muted-foreground">{t('k3.waiting')}</div>}
|
||||
{!!err && <div className="px-3 text-[11px] text-danger">{err}</div>}
|
||||
|
||||
{/* Meters */}
|
||||
<div className="grid grid-cols-3 gap-2 px-3">
|
||||
<MeterBar label="S-METER" value={view.transmitting ? 0 : view.s_meter} lo={0} hi={100}
|
||||
accent="#16a34a" segColor={sSegColor}
|
||||
display={view.transmitting ? '—' : sParts(view.s_meter_raw).label}
|
||||
onClick={() => {
|
||||
if (view.transmitting || !onReportRST) return;
|
||||
const sp = sParts(view.s_meter_raw);
|
||||
onReportRST(sMeterRST(sp.s, sp.over, view.mode));
|
||||
}}
|
||||
title={t('k3.sMeterHint', { raw: String(view.s_meter_raw) })} />
|
||||
<MeterBar label="PWR" value={view.transmitting ? view.power_meter : 0} lo={0} hi={100} accent="#0ea5e9" />
|
||||
{/* 0 means "not measured", and it must not render as a perfect 1.0:
|
||||
a match that looks ideal on an antenna nobody has measured is the one
|
||||
reading that can cost a radio. */}
|
||||
<MeterBar label="SWR" value={view.transmitting && view.swr > 0 ? view.swr : 1} lo={1} hi={4}
|
||||
accent="#f59e0b"
|
||||
display={view.transmitting && view.swr > 0 ? view.swr.toFixed(1) : '—'} />
|
||||
</div>
|
||||
|
||||
{view.meters_provisional && (
|
||||
<p className="px-3 text-[10px] text-muted-foreground">{t('k3.provisional')}</p>
|
||||
)}
|
||||
|
||||
{/* MOX + TUNE */}
|
||||
<div className="flex items-center gap-2 px-3">
|
||||
<button type="button" disabled={off}
|
||||
onClick={() => SetKenwoodTX(!view.transmitting).catch((e) => setErr(String(e?.message ?? e)))}
|
||||
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
view.transmitting
|
||||
? 'bg-danger text-danger-foreground border-danger shadow-[0_0_14px] shadow-danger/50'
|
||||
: 'bg-card text-danger border-danger hover:bg-danger-muted')}>
|
||||
<Power className="size-4 inline mr-1 -mt-0.5" /> MOX
|
||||
</button>
|
||||
<button type="button" disabled={off}
|
||||
onClick={() => TuneKenwoodATU().catch((e) => setErr(String(e?.message ?? e)))}
|
||||
title={t('k3.tuneHint')}
|
||||
className="flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 border-warning text-warning bg-card hover:bg-warning-muted transition-all disabled:opacity-30">
|
||||
<Activity className="size-4 inline mr-1 -mt-0.5" /> TUNE
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Power + volume */}
|
||||
<div className="px-3 pb-3 space-y-2">
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.power')}</span>
|
||||
<input type="range" min={0} max={110} step={1} disabled={off}
|
||||
value={view.rf_power ?? 0}
|
||||
onChange={(e) => put({ rf_power: Number(e.target.value) }, () => SetKenwoodPower(Number(e.target.value)))}
|
||||
className="flex-1 accent-primary" />
|
||||
<span className="w-12 text-right font-mono tabular-nums">{view.rf_power ?? 0} W</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<span className="w-16 shrink-0 text-muted-foreground flex items-center gap-1">
|
||||
<AudioLines className="size-3.5" /> {t('k3.volume')}
|
||||
</span>
|
||||
<input type="range" min={0} max={100} step={1} disabled={off}
|
||||
value={view.af_gain ?? 0}
|
||||
onChange={(e) => put({ af_gain: Number(e.target.value) }, () => SetKenwoodAFGain(Number(e.target.value)))}
|
||||
className="flex-1 accent-primary" />
|
||||
<span className="w-12 text-right font-mono tabular-nums">{view.af_gain ?? 0}</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -426,7 +426,7 @@ const en: Dict = {
|
||||
'tgp.online': 'online', 'tgp.offline': 'offline', 'tgp.close': 'Close', 'tgp.connecting': 'Connecting…', 'tgp.swr': 'SWR', 'tgp.power': 'Fwd power', 'tgp.tune': 'Tune', 'tgp.tuning': 'Tuning…', 'tgp.tuneHint': 'Start an automatic tuning cycle on the active channel — key the rig into a carrier so the tuner can measure SWR', 'tgp.bypass': 'Bypass', 'tgp.bypassHint': 'Toggle global bypass — route the antenna straight through, tuner out of line', 'tgp.operate': 'Operate', 'tgp.standby': 'Standby', 'tgp.operateHint': 'Toggle Operate / Standby',
|
||||
'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Channel {letter} — active', 'tgp.chSelect': 'Make channel {letter} active', 'tgp.chActiveTag': 'active', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypassed', 'tgp.inLine': 'In line',
|
||||
'flxp.ritHint': 'RIT — shifts your RECEIVE frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.xitHint': 'XIT — shifts your TRANSMIT frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.',
|
||||
'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.rstChaseHint': 'Chase the pile-up: when the CW skimmer marks a report ({m}) on the panadapter, move the TRANSMIT slice there — that is where the DX was listening a second ago. The receive slice never moves. Right-click to change the marker text.', 'flxp.rstChaseMarkerHint': 'The text the skimmer writes for a report — whatever SDC is set to send (599, 5NN…). Several can be given, separated by commas; add the old-report marker to chase those too.', 'flxp.rstChaseOffset': 'off', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay',
|
||||
'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.rstChaseHint': 'Chase the pile-up: when the CW skimmer marks a report ({m}) on the panadapter, move the TRANSMIT slice there — that is where the DX was listening a second ago. The receive slice never moves. Right-click to change the marker text.', 'flxp.rstChaseMarkerHint': 'The text the skimmer writes for a report — whatever SDC is set to send (599, 5NN…). Several can be given, separated by commas; add the old-report marker to chase those too.', 'flxp.rstChaseOffset': 'off', 'k3.console': 'Elecraft Console', 'k3.waiting': 'Waiting for the radio… (set CAT to Elecraft or Kenwood and connect)', 'k3.power': 'Power', 'k3.volume': 'Volume', 'k3.refreshHint': 'Re-read the settings from the radio — for when a knob was turned on the front panel.', 'k3.sMeterHint': 'Click to use this reading as the report sent. Raw value from the rig: {raw}.', 'k3.tuneHint': 'Start an ATU tuning cycle (K3: a tap of the ATU TUNE button). The exact command sent is written to the log.', 'k3.provisional': 'Meter scaling is provisional: it has not yet been confirmed against a real K3, and the raw readings are written to the log so it can be.', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay',
|
||||
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.atuTune': 'TUNE', 'flxp.atuTuneHint': 'Start a tuning cycle on the built-in ATU. The radio keys a carrier itself to measure the match.', 'flxp.atuBypass': 'BYPASS', 'flxp.atuBypassHint': 'Take the ATU out of line (straight through).', 'flxp.atuMem': 'MEM', 'flxp.atuMemHint': 'Reuse the stored tuning solution for this frequency instead of tuning again.', 'flxp.atuIdle': 'not tuned', 'flxp.atuTuning': 'tuning…', 'flxp.atuOk': 'tuned', 'flxp.atuFail': 'TUNE FAILED', 'flxp.atuBypassed': 'bypassed', 'flxp.atuAborted': 'aborted', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.voltage': 'VOLTAGE', 'flxp.paTemp': 'PA TEMP', 'flxp.txFilter': 'TX filter', 'flxp.micProfile': 'Mic profile', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER', 'flxp.outputPower': 'OUTPUT POWER', 'flxp.speOffline': 'SPE offline', 'flxp.acomOffline': 'ACOM offline', 'flxp.ampPick': 'Choose which amplifier this card shows', 'flxp.dspV4Hint': 'SmartSDR v4 DSP (8000/Aurora series)', 'flxp.daxHint': 'DAX as the transmit audio source (SmartSDR transmit-bar DAX button) — for WSJT-X & co', 'flxp.rnnHint': 'RNN — AI noise reduction (on/off)', 'flxp.anftHint': 'ANFT — FFT-based automatic notch filter (on/off)', 'flxp.dspNoise': 'Noise', 'flxp.dspMore': 'Show/hide advanced DSP (WNB, v4 NR/notch)',
|
||||
'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope −50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.bandCurrent': 'The rig is on {b} m', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?',
|
||||
'rst.clickToFill': 'Click to set RST tx from the signal',
|
||||
@@ -898,7 +898,7 @@ const fr: Dict = {
|
||||
'tgp.online': 'en ligne', 'tgp.offline': 'hors ligne', 'tgp.close': 'Fermer', 'tgp.connecting': 'Connexion…', 'tgp.swr': 'ROS', 'tgp.power': 'Puiss. directe', 'tgp.tune': 'Accord', 'tgp.tuning': 'Accord…', 'tgp.tuneHint': "Lancer un cycle d'accord automatique sur le canal actif — passe la radio en porteuse pour que le coupleur mesure le ROS", 'tgp.bypass': 'Bypass', 'tgp.bypassHint': "Basculer le bypass global — antenne en direct, coupleur hors ligne", 'tgp.operate': 'Operate', 'tgp.standby': 'Standby', 'tgp.operateHint': 'Basculer Operate / Standby',
|
||||
'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Canal {letter} — actif', 'tgp.chSelect': 'Activer le canal {letter}', 'tgp.chActiveTag': 'actif', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypass', 'tgp.inLine': 'En ligne',
|
||||
'flxp.ritHint': "RIT — décale uniquement ta fréquence de RÉCEPTION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.xitHint': "XIT — décale uniquement ta fréquence d'ÉMISSION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.",
|
||||
'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.rstChaseHint': "Chasser le pile-up : quand le skimmer CW marque un report ({m}) sur le panadapter, déplacer la slice d'ÉMISSION dessus — c'est là que le DX écoutait il y a une seconde. La slice de réception ne bouge jamais. Clic droit pour changer le texte du marqueur.", 'flxp.rstChaseMarkerHint': "Le texte que le skimmer écrit pour un report — ce que SDC est réglé à envoyer (599, 5NN…). On peut en mettre plusieurs, séparés par des virgules ; ajoute le marqueur des reports anciens pour les chasser aussi.", 'flxp.rstChaseOffset': 'off', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai',
|
||||
'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.rstChaseHint': "Chasser le pile-up : quand le skimmer CW marque un report ({m}) sur le panadapter, déplacer la slice d'ÉMISSION dessus — c'est là que le DX écoutait il y a une seconde. La slice de réception ne bouge jamais. Clic droit pour changer le texte du marqueur.", 'flxp.rstChaseMarkerHint': "Le texte que le skimmer écrit pour un report — ce que SDC est réglé à envoyer (599, 5NN…). On peut en mettre plusieurs, séparés par des virgules ; ajoute le marqueur des reports anciens pour les chasser aussi.", 'flxp.rstChaseOffset': 'off', 'k3.console': 'Console Elecraft', 'k3.waiting': 'En attente de la radio… (règle le CAT sur Elecraft ou Kenwood et connecte)', 'k3.power': 'Puissance', 'k3.volume': 'Volume', 'k3.refreshHint': "Relire les réglages depuis la radio — quand un bouton a été tourné en façade.", 'k3.sMeterHint': 'Cliquer pour utiliser cette lecture comme report envoyé. Valeur brute de la radio : {raw}.', 'k3.tuneHint': "Lancer un cycle d'accord de l'ATU (K3 : appui sur la touche ATU TUNE). La commande exacte envoyée est écrite dans le journal.", 'k3.provisional': "L'échelle des mesures est provisoire : elle n'a pas encore été confirmée sur un vrai K3, et les valeurs brutes sont écrites dans le journal pour qu'elle puisse l'être.", 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai',
|
||||
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.atuTune': 'ACCORD', 'flxp.atuTuneHint': "Lance un cycle d'accord sur le coupleur intégré. La radio émet elle-même une porteuse pour mesurer l'adaptation.", 'flxp.atuBypass': 'BYPASS', 'flxp.atuBypassHint': 'Sort le coupleur de la ligne (passage direct).', 'flxp.atuMem': 'MEM', 'flxp.atuMemHint': "Réutilise l'accord mémorisé pour cette fréquence au lieu de refaire un cycle.", 'flxp.atuIdle': 'non accordé', 'flxp.atuTuning': 'accord en cours…', 'flxp.atuOk': 'accordé', 'flxp.atuFail': 'ÉCHEC ACCORD', 'flxp.atuBypassed': 'contourné', 'flxp.atuAborted': 'interrompu', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.voltage': 'TENSION', 'flxp.paTemp': 'TEMP PA', 'flxp.txFilter': 'Filtre TX', 'flxp.micProfile': 'Profil micro', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR', 'flxp.outputPower': 'PUISSANCE DE SORTIE', 'flxp.speOffline': 'SPE hors ligne', 'flxp.acomOffline': 'ACOM hors ligne', 'flxp.ampPick': 'Choisir quel amplificateur cette carte affiche', 'flxp.dspV4Hint': 'DSP SmartSDR v4 (séries 8000/Aurora)', 'flxp.daxHint': "DAX comme source audio d'émission (bouton DAX du bandeau transmit de SmartSDR) — pour WSJT-X & co", 'flxp.rnnHint': 'RNN — réduction de bruit par IA (on/off)', 'flxp.anftHint': 'ANFT — filtre notch automatique FFT (on/off)', 'flxp.dspNoise': 'Bruit', 'flxp.dspMore': 'Afficher/masquer le DSP avancé (WNB, NR/notch v4)',
|
||||
'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope −50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.bandCurrent': 'Le poste est sur {b} m', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?',
|
||||
'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal',
|
||||
|
||||
Vendored
+12
@@ -487,6 +487,8 @@ export function GetGridScopeSettings():Promise<main.GridScopeSettings>;
|
||||
|
||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||
|
||||
export function GetKenwoodState():Promise<cat.KenwoodTXState>;
|
||||
|
||||
export function GetLinkedAmps():Promise<Array<string>>;
|
||||
|
||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||
@@ -899,6 +901,8 @@ export function RecomputeAwardRefsForCode(arg1:string):Promise<number>;
|
||||
|
||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function RefreshKenwood():Promise<void>;
|
||||
|
||||
export function RefreshSolar():Promise<void>;
|
||||
|
||||
export function RefreshYaesuPanel():Promise<void>;
|
||||
@@ -1099,8 +1103,14 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||
|
||||
export function SetFlexRSTChaseEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetKenwoodAFGain(arg1:number):Promise<void>;
|
||||
|
||||
export function SetKenwoodKeySpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function SetKenwoodPower(arg1:number):Promise<void>;
|
||||
|
||||
export function SetKenwoodTX(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetLinkedAmps(arg1:Array<string>):Promise<void>;
|
||||
|
||||
export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<void>;
|
||||
@@ -1207,6 +1217,8 @@ export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
||||
|
||||
export function TestWebPublishFTP(arg1:webpub.Config):Promise<string>;
|
||||
|
||||
export function TuneKenwoodATU():Promise<void>;
|
||||
|
||||
export function TuneYaesuATU():Promise<void>;
|
||||
|
||||
export function TunerGeniusActivate(arg1:number):Promise<void>;
|
||||
|
||||
@@ -914,6 +914,10 @@ export function GetIcomState() {
|
||||
return window['go']['main']['App']['GetIcomState']();
|
||||
}
|
||||
|
||||
export function GetKenwoodState() {
|
||||
return window['go']['main']['App']['GetKenwoodState']();
|
||||
}
|
||||
|
||||
export function GetLinkedAmps() {
|
||||
return window['go']['main']['App']['GetLinkedAmps']();
|
||||
}
|
||||
@@ -1738,6 +1742,10 @@ export function RefreshCtyDat() {
|
||||
return window['go']['main']['App']['RefreshCtyDat']();
|
||||
}
|
||||
|
||||
export function RefreshKenwood() {
|
||||
return window['go']['main']['App']['RefreshKenwood']();
|
||||
}
|
||||
|
||||
export function RefreshSolar() {
|
||||
return window['go']['main']['App']['RefreshSolar']();
|
||||
}
|
||||
@@ -2138,10 +2146,22 @@ export function SetFlexRSTChaseEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetFlexRSTChaseEnabled'](arg1);
|
||||
}
|
||||
|
||||
export function SetKenwoodAFGain(arg1) {
|
||||
return window['go']['main']['App']['SetKenwoodAFGain'](arg1);
|
||||
}
|
||||
|
||||
export function SetKenwoodKeySpeed(arg1) {
|
||||
return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1);
|
||||
}
|
||||
|
||||
export function SetKenwoodPower(arg1) {
|
||||
return window['go']['main']['App']['SetKenwoodPower'](arg1);
|
||||
}
|
||||
|
||||
export function SetKenwoodTX(arg1) {
|
||||
return window['go']['main']['App']['SetKenwoodTX'](arg1);
|
||||
}
|
||||
|
||||
export function SetLinkedAmps(arg1) {
|
||||
return window['go']['main']['App']['SetLinkedAmps'](arg1);
|
||||
}
|
||||
@@ -2354,6 +2374,10 @@ export function TestWebPublishFTP(arg1) {
|
||||
return window['go']['main']['App']['TestWebPublishFTP'](arg1);
|
||||
}
|
||||
|
||||
export function TuneKenwoodATU() {
|
||||
return window['go']['main']['App']['TuneKenwoodATU']();
|
||||
}
|
||||
|
||||
export function TuneYaesuATU() {
|
||||
return window['go']['main']['App']['TuneYaesuATU']();
|
||||
}
|
||||
|
||||
@@ -1067,6 +1067,46 @@ export namespace cat {
|
||||
this.anti_vox = source["anti_vox"];
|
||||
}
|
||||
}
|
||||
export class KenwoodTXState {
|
||||
available: boolean;
|
||||
model?: string;
|
||||
elecraft: boolean;
|
||||
mode?: string;
|
||||
transmitting: boolean;
|
||||
split: boolean;
|
||||
split_tx_hz: number;
|
||||
s_meter: number;
|
||||
s_meter_raw: number;
|
||||
power_meter: number;
|
||||
swr: number;
|
||||
swr_raw: number;
|
||||
rf_power: number;
|
||||
af_gain: number;
|
||||
meters_provisional: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new KenwoodTXState(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.available = source["available"];
|
||||
this.model = source["model"];
|
||||
this.elecraft = source["elecraft"];
|
||||
this.mode = source["mode"];
|
||||
this.transmitting = source["transmitting"];
|
||||
this.split = source["split"];
|
||||
this.split_tx_hz = source["split_tx_hz"];
|
||||
this.s_meter = source["s_meter"];
|
||||
this.s_meter_raw = source["s_meter_raw"];
|
||||
this.power_meter = source["power_meter"];
|
||||
this.swr = source["swr"];
|
||||
this.swr_raw = source["swr_raw"];
|
||||
this.rf_power = source["rf_power"];
|
||||
this.af_gain = source["af_gain"];
|
||||
this.meters_provisional = source["meters_provisional"];
|
||||
}
|
||||
}
|
||||
export class RigState {
|
||||
enabled: boolean;
|
||||
connected: boolean;
|
||||
|
||||
@@ -990,6 +990,30 @@ type KenwoodController interface {
|
||||
SetKeySpeed(int) error
|
||||
}
|
||||
|
||||
// KenwoodState returns the K3/K4 panel snapshot, or (zero, false) when the
|
||||
// active backend is not a Kenwood-dialect rig.
|
||||
func (m *Manager) KenwoodState() (KenwoodTXState, bool) {
|
||||
m.mu.RLock()
|
||||
b := m.backend
|
||||
m.mu.RUnlock()
|
||||
if kc, ok := b.(KenwoodPanelController); ok {
|
||||
return kc.KenwoodState(), true
|
||||
}
|
||||
return KenwoodTXState{}, false
|
||||
}
|
||||
|
||||
// KenwoodPanelDo dispatches a K3/K4 panel 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) KenwoodPanelDo(fn func(KenwoodPanelController) error) error {
|
||||
return m.exec(func(b Backend) error {
|
||||
kc, ok := b.(KenwoodPanelController)
|
||||
if !ok {
|
||||
return fmt.Errorf("active CAT backend is not a Kenwood/Elecraft")
|
||||
}
|
||||
return fn(kc)
|
||||
})
|
||||
}
|
||||
|
||||
// KenwoodDo dispatches a Kenwood control onto the CAT goroutine.
|
||||
func (m *Manager) KenwoodDo(fn func(KenwoodController) error) error {
|
||||
return m.exec(func(b Backend) error {
|
||||
|
||||
@@ -92,6 +92,16 @@ type Kenwood struct {
|
||||
// Commands this rig answered "?;" to — asked once, then never again.
|
||||
unsupported map[string]bool
|
||||
|
||||
// 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
|
||||
panelCycle int
|
||||
panelLoaded bool
|
||||
metersLogged int
|
||||
powerPeak meterPeak
|
||||
swrPeak meterPeak
|
||||
|
||||
// rx holds bytes read but not yet consumed, ACROSS calls to ask.
|
||||
//
|
||||
// It has to survive: a rig answers faster than we ask, so one Read often
|
||||
@@ -422,6 +432,10 @@ func (k *Kenwood) ReadState() (RigState, error) {
|
||||
k.curRXFreq = s.RxFreqHz
|
||||
}
|
||||
k.lastState = s // cache for the transmit window, where we can't poll
|
||||
// The panel rides on the same poll and the same held mutex: its own reader
|
||||
// would have to take turns on the serial port, and the K3 is slow enough
|
||||
// that two readers taking turns is what makes a dial lag.
|
||||
k.readPanel(s.Mode, s.Split, s.FreqHz)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
package cat
|
||||
|
||||
// Elecraft K3/K4 control panel — power, volume, S-meter, SWR, MOX and ATU tune.
|
||||
//
|
||||
// Built on the Kenwood-dialect backend, because a K3 speaks it: plain ASCII,
|
||||
// every command terminated by ';'. What is Elecraft-specific is the vocabulary
|
||||
// below, taken from the K3 Programmer's Reference; the K4 accepts the same set.
|
||||
//
|
||||
// NO K3 WAS AVAILABLE WHILE WRITING THIS, and that shapes it:
|
||||
//
|
||||
// - The commands that SET something are the ones the reference documents
|
||||
// unambiguously and whose effect is visible and reversible (PC, AG, TX/RX).
|
||||
// - The meters are the opposite: their scaling differs between models and
|
||||
// firmware, and a wrongly scaled SWR bar is worse than none — it reports a
|
||||
// good match on a bad antenna. So the raw answers are LOGGED, for a real
|
||||
// radio to settle, and until then the panel says the scaling is provisional.
|
||||
//
|
||||
// The same discipline as the Yaesu meters, which were guessed wrong twice and
|
||||
// only settled when an FTDX10 keyed a carrier at two known power levels.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// KenwoodTXState is what the panel shows.
|
||||
type KenwoodTXState struct {
|
||||
Available bool `json:"available"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Elecraft bool `json:"elecraft"` // a K3/K4 rather than a Kenwood
|
||||
Mode string `json:"mode,omitempty"`
|
||||
|
||||
Transmitting bool `json:"transmitting"`
|
||||
Split bool `json:"split"`
|
||||
SplitTXHz int64 `json:"split_tx_hz"`
|
||||
|
||||
// SMeter is 0-100 for the bar. SMeterRaw is what the rig actually answered,
|
||||
// kept because the scaling is not yet confirmed on a real K3 and a number
|
||||
// nobody can check is worth less than the reading it came from.
|
||||
SMeter int `json:"s_meter"`
|
||||
SMeterRaw int `json:"s_meter_raw"`
|
||||
// PowerMeter is 0-100 while transmitting. SWR is the ratio; 0 means "not
|
||||
// measured", NOT a perfect match.
|
||||
PowerMeter int `json:"power_meter"`
|
||||
SWR float64 `json:"swr"`
|
||||
SWRRaw int `json:"swr_raw"`
|
||||
|
||||
RFPower int `json:"rf_power"` // watts, the PC setting
|
||||
AFGain int `json:"af_gain"` // 0-100
|
||||
|
||||
// MetersProvisional says the meter scaling has not been confirmed against a
|
||||
// real radio. The panel says so rather than presenting a guess as a
|
||||
// measurement.
|
||||
MetersProvisional bool `json:"meters_provisional"`
|
||||
}
|
||||
|
||||
// KenwoodPanelController is the K3/K4 panel capability. Separate from
|
||||
// KenwoodController (the CW keyer) so a backend can offer one without the other.
|
||||
type KenwoodPanelController interface {
|
||||
KenwoodState() KenwoodTXState
|
||||
RefreshKenwood() error
|
||||
SetKenwoodPower(int) error
|
||||
SetKenwoodAFGain(int) error
|
||||
SetKenwoodTX(bool) error
|
||||
TuneKenwoodATU() error
|
||||
}
|
||||
|
||||
// kenwoodPanelSlowBeat is how many polls pass between full re-reads of the
|
||||
// settings. The meters are read every poll; a power setting is not.
|
||||
const kenwoodPanelSlowBeat = 8
|
||||
|
||||
// KenwoodState returns the panel snapshot.
|
||||
func (k *Kenwood) KenwoodState() KenwoodTXState {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
st := k.panel
|
||||
st.Available = k.port != nil
|
||||
st.Model = k.model
|
||||
st.Elecraft = k.elecraft
|
||||
return st
|
||||
}
|
||||
|
||||
// RefreshKenwood forces the settings to be re-read on the next poll, so a value
|
||||
// changed on the radio's own front panel shows up at once.
|
||||
func (k *Kenwood) RefreshKenwood() error {
|
||||
k.mu.Lock()
|
||||
k.panelCycle = kenwoodPanelSlowBeat
|
||||
k.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// readPanel refreshes the panel. Called from ReadState with the mutex HELD, so
|
||||
// it shares the same serialised link as everything else.
|
||||
func (k *Kenwood) readPanel(mode string, split bool, txHz int64) {
|
||||
k.panel.Mode = mode
|
||||
k.panel.Split = split
|
||||
k.panel.SplitTXHz = 0
|
||||
if split {
|
||||
k.panel.SplitTXHz = txHz
|
||||
}
|
||||
k.panel.Transmitting = k.tx
|
||||
k.panel.MetersProvisional = true
|
||||
|
||||
if k.panel.Transmitting {
|
||||
k.readTXMeters()
|
||||
} else {
|
||||
// Cleared, not frozen: a power bar left standing after the carrier drops
|
||||
// reads as a live transmission.
|
||||
k.panel.PowerMeter = 0
|
||||
k.panel.SWR, k.panel.SWRRaw = 0, 0
|
||||
k.powerPeak, k.swrPeak = meterPeak{}, meterPeak{}
|
||||
// The S-meter only means anything while receiving.
|
||||
if v, ok := k.askNum("SM;", "SM", 4); ok {
|
||||
k.panel.SMeterRaw = v
|
||||
k.panel.SMeter = kenwoodSMeterPercent(v)
|
||||
}
|
||||
}
|
||||
|
||||
k.panelCycle++
|
||||
if k.panelLoaded && k.panelCycle < kenwoodPanelSlowBeat {
|
||||
return
|
||||
}
|
||||
k.panelCycle = 0
|
||||
k.panelLoaded = true
|
||||
k.readPanelSettings()
|
||||
}
|
||||
|
||||
// readPanelSettings re-reads what a knob can change.
|
||||
func (k *Kenwood) readPanelSettings() {
|
||||
// PC is watts on both the K3 and the Kenwoods — a setting, not a scale.
|
||||
if v, ok := k.askNum("PC;", "PC", 3); ok {
|
||||
k.panel.RFPower = v
|
||||
}
|
||||
// AF gain. The K3 answers three digits 000-255; a Kenwood answers AG0nnn,
|
||||
// which is why the plain form is tried first and the addressed one after.
|
||||
if v, ok := k.askNum("AG;", "AG", 3); ok {
|
||||
k.panel.AFGain = scale255(v)
|
||||
} else if v, ok := k.askNum("AG0;", "AG0", 3); ok {
|
||||
k.panel.AFGain = scale255(v)
|
||||
}
|
||||
}
|
||||
|
||||
// kenwoodMeterProbes are the candidate meter commands, asked once per
|
||||
// transmission burst so a real radio can settle what they mean.
|
||||
//
|
||||
// They are NOT interchangeable — BG is a bargraph position, SM during transmit
|
||||
// is something else again — which is exactly why the log records which one
|
||||
// answered and what it said, next to the power SETTING: the meter that tracks a
|
||||
// known carrier at two different power levels is the power meter, and no amount
|
||||
// of reading the reference settles that as well as one transmission does.
|
||||
var kenwoodMeterProbes = []struct {
|
||||
cmd string
|
||||
prefix string
|
||||
digits int
|
||||
}{
|
||||
{"SM;", "SM", 4},
|
||||
{"BG;", "BG", 2},
|
||||
{"SW;", "SW", 4},
|
||||
{"PO;", "PO", 3},
|
||||
}
|
||||
|
||||
// readTXMeters reads the transmit meters.
|
||||
func (k *Kenwood) readTXMeters() {
|
||||
now := time.Now()
|
||||
if v, ok := k.askNum("BG;", "BG", 2); ok {
|
||||
k.panel.PowerMeter = k.powerPeak.update(kenwoodBargraphPercent(v), now)
|
||||
}
|
||||
if v, ok := k.askNum("SW;", "SW", 4); ok {
|
||||
k.panel.SWRRaw = v
|
||||
// Tenths of a ratio, provisionally: 15 → 1.5. Reported as raw as well,
|
||||
// so the log can correct this without anyone having to trust the bar.
|
||||
if v > 0 {
|
||||
k.panel.SWR = float64(k.swrPeak.update(v, now)) / 10
|
||||
}
|
||||
}
|
||||
if k.metersLogged >= 20 {
|
||||
return
|
||||
}
|
||||
k.metersLogged++
|
||||
raw := make([]string, 0, len(kenwoodMeterProbes))
|
||||
for _, p := range kenwoodMeterProbes {
|
||||
if v, ok := k.askNum(p.cmd, p.prefix, p.digits); ok {
|
||||
raw = append(raw, fmt.Sprintf("%s=%d", strings.TrimSuffix(p.cmd, ";"), v))
|
||||
} else {
|
||||
raw = append(raw, strings.TrimSuffix(p.cmd, ";")+"=-")
|
||||
}
|
||||
}
|
||||
debugLog.Printf("kenwood: TX meters at PC=%dW: %s (compare two power settings, and a known SWR)",
|
||||
k.panel.RFPower, strings.Join(raw, " "))
|
||||
}
|
||||
|
||||
// kenwoodSMeterPercent turns the S-meter answer into a bar percentage.
|
||||
//
|
||||
// The K3 reports 0-21 across S0…S9+60, which is not a linear dB scale but is
|
||||
// what its own display shows — and matching the radio's own bar is something an
|
||||
// operator can check at a glance. A Kenwood answers 0-30 on the same command;
|
||||
// both are covered by clamping rather than by guessing which rig is on the
|
||||
// other end.
|
||||
func kenwoodSMeterPercent(v int) int {
|
||||
switch {
|
||||
case v <= 0:
|
||||
return 0
|
||||
case v >= 30:
|
||||
return 100
|
||||
}
|
||||
return v * 100 / 21
|
||||
}
|
||||
|
||||
// kenwoodBargraphPercent scales the K3 bargraph (0-12 segments) to the bar.
|
||||
func kenwoodBargraphPercent(v int) int {
|
||||
switch {
|
||||
case v <= 0:
|
||||
return 0
|
||||
case v >= 12:
|
||||
return 100
|
||||
}
|
||||
return v * 100 / 12
|
||||
}
|
||||
|
||||
// SetKenwoodPower sets the transmit power, in watts.
|
||||
func (k *Kenwood) SetKenwoodPower(w int) error {
|
||||
if w < 0 {
|
||||
w = 0
|
||||
}
|
||||
if w > 200 {
|
||||
w = 200
|
||||
}
|
||||
return k.setPanel(fmt.Sprintf("PC%03d;", w))
|
||||
}
|
||||
|
||||
// SetKenwoodAFGain sets the volume, 0-100, scaled to the rig's 0-255.
|
||||
func (k *Kenwood) SetKenwoodAFGain(p int) error {
|
||||
if p < 0 {
|
||||
p = 0
|
||||
}
|
||||
if p > 100 {
|
||||
p = 100
|
||||
}
|
||||
return k.setPanel(fmt.Sprintf("AG%03d;", p*255/100))
|
||||
}
|
||||
|
||||
// SetKenwoodTX keys or unkeys the transmitter — the panel's MOX.
|
||||
//
|
||||
// Goes through SetPTT rather than writing TX;/RX; here, so the backend's own
|
||||
// idea of transmitting stays true: it suppresses polling while the carrier is
|
||||
// up, and a panel that keyed behind its back would leave it polling a rig that
|
||||
// answers "?;" to everything.
|
||||
func (k *Kenwood) SetKenwoodTX(on bool) error {
|
||||
return k.SetPTT(on)
|
||||
}
|
||||
|
||||
// kenwoodATUTune is the command that starts an ATU tuning cycle on a K3.
|
||||
//
|
||||
// The K3 has no dedicated "tune" command: the reference exposes the front panel
|
||||
// instead, and SWT20 is the tap of the ATU TUNE button. That mapping has NOT
|
||||
// been confirmed on a radio here, which is why the command actually sent is
|
||||
// logged — if it presses something else on a real K3, the log names what was
|
||||
// sent instead of leaving an operator to guess which button OpsLog reached for.
|
||||
const kenwoodATUTune = "SWT20;"
|
||||
|
||||
// TuneKenwoodATU starts an ATU tuning cycle.
|
||||
func (k *Kenwood) TuneKenwoodATU() error {
|
||||
debugLog.Printf("kenwood: ATU tune — sending %q (K3 front-panel tap; report it if another button responded)", kenwoodATUTune)
|
||||
return k.setPanel(kenwoodATUTune)
|
||||
}
|
||||
|
||||
// setPanel writes one command and schedules a settings re-read, so the panel
|
||||
// shows what the radio did rather than what it was asked to do.
|
||||
func (k *Kenwood) setPanel(cmd string) error {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.port == nil {
|
||||
return fmt.Errorf("kenwood: not connected")
|
||||
}
|
||||
if err := k.write(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
k.panelCycle = kenwoodPanelSlowBeat // re-read on the next poll
|
||||
return nil
|
||||
}
|
||||
|
||||
// askNum asks a command and parses its numeric body. ask() already remembers
|
||||
// what this rig answered "?;" to and refuses to ask it again, so an unsupported
|
||||
// meter costs one timeout for the life of the session, not one per poll.
|
||||
func (k *Kenwood) askNum(cmd, prefix string, digits int) (int, bool) {
|
||||
r, err := k.ask(cmd)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
body := strings.TrimSuffix(strings.TrimPrefix(r, prefix), ";")
|
||||
if len(body) < digits {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(body[:digits]))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
Reference in New Issue
Block a user