Compare commits

...
5 Commits
Author SHA1 Message Date
rouggy e00488fad8 chore: release v0.27.3 2026-08-30 20:19:39 +02:00
rouggy 12c61dc35a feat(dvk): delete a message — recording and label together
A deleted message is a free slot, per its operator; keeping the label made it
look half-deleted.
2026-08-30 20:15:45 +02:00
rouggy 0c0e8b06ba feat(dvk): twelve slots; perf: Settings pauses the heavy streams
The voice keyer grows to F1-F12 — the UI was already data-driven, so the
count constant, the F-key range and the labels are the whole change.

And the preferences stop stuttering on a busy station: every spot batch,
decode batch and CAT snapshot re-rendered the entire App tree behind the
dialog — cluster grid, decodes panel, thousands of nodes — and the pointer
janked over the very panel the operator was trying to use. While Settings is
open the flushes park their batches in the pending refs (bounded) and CAT
repaints at most every two seconds; closing the dialog drains everything.
2026-08-30 20:09:44 +02:00
rouggy 1bd3896ca7 feat(decodes): arrival order within a period, not strongest-first
The panel now mirrors the decoder's own window line for line — the operator
compares the two side by side, and the SNR sort scrambled that
correspondence. The report is still right there in its column.
2026-08-30 19:26:52 +02:00
rouggy 7e6c0b4f7e fix(kpa): stop switching the KPA500 off, and answer its operator instantly
Three faults, one report. The slow poll asks ^TP — the KPA1500's ATU, which
a KPA500 (no ATU) never answers — and ask() dropped the whole connection on
any read timeout: a two-second stall and a reconnect every slow cycle, which
is why buttons lagged and the status read wrong. Worse, every serial reopen
toggled DTR/RTS — and those lines are the KPA500's POWER SWITCH (that is how
the Elecraft utility turns it on), so the amplifier obediently switched off
twenty seconds after its operator pressed nothing but Standby.

Silence is no longer a dead link (write errors still are), ^TP is never asked
again after one silence, the control lines are asserted once and held, and
the baud field becomes a list of the rates these amplifiers actually speak.
2026-08-30 19:14:35 +02:00
12 changed files with 137 additions and 20 deletions
+17 -1
View File
@@ -10239,7 +10239,7 @@ func titleEntity(s string) string {
// the configured "Recording mic", transmit via "To Radio", preview via // the configured "Recording mic", transmit via "To Radio", preview via
// "Listening". // "Listening".
const dvkSlots = 6 const dvkSlots = 12
// DVKMessage is one voice-keyer slot for the UI. // DVKMessage is one voice-keyer slot for the UI.
type DVKMessage struct { type DVKMessage struct {
@@ -10300,6 +10300,22 @@ func (a *App) GetDVKMessages() []DVKMessage {
return out return out
} }
// DVKDelete removes a slot's recording AND its label — a deleted message is a
// free slot, per its operator.
func (a *App) DVKDelete(slot int) error {
if slot < 1 || slot > dvkSlots {
return fmt.Errorf("bad slot")
}
if err := os.Remove(a.dvkPath(slot)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("delete message %d: %w", slot, err)
}
if a.settings != nil {
_ = a.settings.Set(a.ctx, dvkLabelKey(slot), "")
}
applog.Printf("dvk: message F%d deleted (label cleared)", slot)
return nil
}
// SetDVKLabel renames a voice-keyer slot. // SetDVKLabel renames a voice-keyer slot.
func (a *App) SetDVKLabel(slot int, label string) error { func (a *App) SetDVKLabel(slot int, label string) error {
if a.settings == nil { if a.settings == nil {
+18
View File
@@ -1,4 +1,22 @@
[ [
{
"version": "0.27.3",
"date": "",
"en": [
"KPA500: the amplifier no longer switches itself off and commands respond instantly. A command this model does not know (the KPA1500s ATU poll) was tearing the link down every cycle, and each reconnect toggled the serial control lines — which are the KPA500s power switch. The lines are now held steady, silence is not treated as a dead link, and the baud is picked from a list.",
"FT decodes: within a period, decodes are listed in arrival order — mirroring the decoders own window — instead of strongest-first.",
"Voice keyer: twelve message slots (F1F12) instead of six.",
"Preferences open smoothly on a busy station: while the dialog is open, cluster spots, FT decodes and CAT snapshots queue quietly instead of repainting the whole window behind it — everything catches up the moment it closes.",
"Voice keyer: a delete button per message — removes the recording and clears the label."
],
"fr": [
"KPA500 : lampli ne s’éteint plus tout seul et les commandes répondent instantanément. Une commande inconnue de ce modèle (le poll ATU du KPA1500) détruisait le lien à chaque cycle, et chaque reconnexion basculait les lignes de contrôle série — qui sont linterrupteur du KPA500. Les lignes sont désormais tenues stables, le silence nest plus traité comme un lien mort, et le baud se choisit dans une liste.",
"FT decodes : dans une période, les décodages sont listés dans lordre darrivée — comme la fenêtre du décodeur — au lieu du plus fort dabord.",
"Manipulateur vocal : douze messages (F1F12) au lieu de six.",
"Les Préférences restent fluides sur une station chargée : dialogue ouvert, les spots cluster, les décodages FT et les instantanés CAT patientent en file au lieu de repeindre toute la fenêtre derrière — tout se rattrape à la fermeture.",
"Manipulateur vocal : un bouton supprimer par message — efface lenregistrement et le libellé."
]
},
{ {
"version": "0.27.2", "version": "0.27.2",
"date": "", "date": "",
+36 -1
View File
@@ -2228,6 +2228,15 @@ export default function App() {
const [bulkEditIds, setBulkEditIds] = useState<number[]>([]); const [bulkEditIds, setBulkEditIds] = useState<number[]>([]);
const [bulkEditOpen, setBulkEditOpen] = useState(false); const [bulkEditOpen, setBulkEditOpen] = useState(false);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
// While the Settings dialog is open, the spot/decode flushes and the CAT
// snapshot stream are PAUSED (data keeps accumulating in the pending refs).
// Every flush re-renders the whole App tree behind the dialog — cluster
// grid, decodes panel, thousands of nodes — and with a busy cluster plus
// two decoders the pointer visibly stuttered over the preferences.
const showSettingsRef = useRef(false);
useEffect(() => { showSettingsRef.current = showSettings; }, [showSettings]);
const flushSpotsRef = useRef<() => void>(() => {});
const flushDecodesRef = useRef<() => void>(() => {});
// Re-read the "beam on map" toggle when Preferences closes (it's edited there). // Re-read the "beam on map" toggle when Preferences closes (it's edited there).
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]); useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
useEffect(() => { if (!showSettings) setRotorCompact(localStorage.getItem('opslog.rotorCompact') === '1'); }, [showSettings]); useEffect(() => { if (!showSettings) setRotorCompact(localStorage.getItem('opslog.rotorCompact') === '1'); }, [showSettings]);
@@ -3435,7 +3444,15 @@ export default function App() {
// Apply a CAT snapshot to the entry strip (freq/band/mode), unless the user // Apply a CAT snapshot to the entry strip (freq/band/mode), unless the user
// just typed something (freeze window) or locked a field. Shared by the live // just typed something (freeze window) or locked a field. Shared by the live
// cat:state event and the startup poll below. // cat:state event and the startup poll below.
const lastCatWhileSettingsRef = useRef(0);
function applyCatState(s: CATState) { function applyCatState(s: CATState) {
// Behind the Settings dialog nobody reads a frequency four times a second;
// each snapshot re-renders the whole App tree under the pointer.
if (showSettingsRef.current) {
const now = Date.now();
if (now - lastCatWhileSettingsRef.current < 2000) return;
lastCatWhileSettingsRef.current = now;
}
setCatState(s); setCatState(s);
if (!s?.connected) return; if (!s?.connected) return;
// A snapshot arriving during the freeze used to be DROPPED, and that lost the // A snapshot arriving during the freeze used to be DROPPED, and that lost the
@@ -3580,11 +3597,19 @@ export default function App() {
// Commit the staged spots: resolve the status for any slot we don't know yet // Commit the staged spots: resolve the status for any slot we don't know yet
// FIRST, then insert the rows — so they appear with the right badge already // FIRST, then insert the rows — so they appear with the right badge already
// painted instead of flashing plain text then flipping to a pill. // painted instead of flashing plain text then flipping to a pill.
// eslint-disable-next-line prefer-const
const flushPendingSpots = async () => { const flushPendingSpots = async () => {
pendingSpotTimer.current = undefined; pendingSpotTimer.current = undefined;
// Settings open: leave everything queued (bounded) and repaint nothing.
if (showSettingsRef.current) {
const cap = spotsCapRef.current;
if (pendingSpotsRef.current.length > cap) pendingSpotsRef.current = pendingSpotsRef.current.slice(-cap);
return;
}
const batch = pendingSpotsRef.current; const batch = pendingSpotsRef.current;
pendingSpotsRef.current = []; pendingSpotsRef.current = [];
if (batch.length === 0) return; if (batch.length === 0) return;
// (registered below so closing Settings can drain the queue)
// Resolve unknown statuses before the rows go in. // Resolve unknown statuses before the rows go in.
try { try {
const known = spotStatusRef.current; const known = spotStatusRef.current;
@@ -3640,6 +3665,7 @@ export default function App() {
return next.length > cap ? next.slice(0, cap) : next; return next.length > cap ? next.slice(0, cap) : next;
}); });
}; };
flushSpotsRef.current = () => { void flushPendingSpots(); };
const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => { const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
// Stage the spot; a short timer resolves its status then commits it. // Stage the spot; a short timer resolves its status then commits it.
pendingSpotsRef.current.push(sp); pendingSpotsRef.current.push(sp);
@@ -3687,6 +3713,10 @@ export default function App() {
// decodes panel and plain worked in the cluster list two seconds later. // decodes panel and plain worked in the cluster list two seconds later.
const flushDecodes = async () => { const flushDecodes = async () => {
pendingDecodeTimer.current = undefined; pendingDecodeTimer.current = undefined;
if (showSettingsRef.current) {
if (pendingDecodesRef.current.length > 3000) pendingDecodesRef.current = pendingDecodesRef.current.slice(-3000);
return;
}
const batch = pendingDecodesRef.current; const batch = pendingDecodesRef.current;
pendingDecodesRef.current = []; pendingDecodesRef.current = [];
if (batch.length === 0) return; if (batch.length === 0) return;
@@ -3729,6 +3759,7 @@ export default function App() {
return next; return next;
}); });
}; };
flushDecodesRef.current = () => { void flushDecodes(); };
const unsubDecode = EventsOn('udp:decode', (d: DecodeRow) => { const unsubDecode = EventsOn('udp:decode', (d: DecodeRow) => {
pendingDecodesRef.current.push(d); pendingDecodesRef.current.push(d);
@@ -4375,6 +4406,10 @@ export default function App() {
// The stable wrapper the dialog actually receives. // The stable wrapper the dialog actually receives.
const openQSOFromSettings = useCallback((id: number) => openEditRef.current(id), []); const openQSOFromSettings = useCallback((id: number) => openEditRef.current(id), []);
const closeSettings = useCallback(() => { const closeSettings = useCallback(() => {
// Synchronously: the ref effect runs after the next render, and flushing
// through a still-true ref would hit the pause gate again.
showSettingsRef.current = false;
window.setTimeout(() => { flushSpotsRef.current(); flushDecodesRef.current(); }, 50);
setShowSettings(false); setShowSettings(false);
setSettingsSection(undefined); setSettingsSection(undefined);
refreshChaseNew(); refreshChaseNew();
@@ -5203,7 +5238,7 @@ export default function App() {
if (dvkActiveRef.current) { if (dvkActiveRef.current) {
// Voice keyer: plain F1..F6 transmit the message; Ctrl+F1..F5 → tabs. // Voice keyer: plain F1..F6 transmit the message; Ctrl+F1..F5 → tabs.
if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; } if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; }
if (plain && n <= 6) { e.preventDefault(); dvkPlayRef.current(n); return; } if (plain && n <= 12) { e.preventDefault(); dvkPlayRef.current(n); return; }
return; return;
} }
// No keyer: plain F1..F5 switch the detail tab (labels read "F1…"). // No keyer: plain F1..F5 switch the detail tab (labels read "F1…").
+5 -4
View File
@@ -498,10 +498,11 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
// the same way it was grouped. // the same way it was grouped.
tr: trSeconds(g.decodes[0]?.mode ?? g.tx[0]?.mode, g.decodes[0]?.tr_period), tr: trSeconds(g.decodes[0]?.mode ?? g.tx[0]?.mode, g.decodes[0]?.tr_period),
tx: g.tx, tx: g.tx,
// Strongest first inside a period: the eye should land on what is // ARRIVAL order inside a period, per the operator: it mirrors the
// workable, and time within a slot means nothing — they were all // decoder's own window line for line, which makes the two screens
// transmitting simultaneously. // comparable at a glance — the strongest-first sort scrambled that
decodes: g.decodes.sort((x, y) => y.snr - x.snr), // correspondence, and SNR is right there in its column anyway.
decodes: g.decodes,
})); }));
} }
+1 -1
View File
@@ -19,7 +19,7 @@ type Props = {
phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked
}; };
// Operating panel for the Digital Voice Keyer — transmits the recorded F1F6 // Operating panel for the Digital Voice Keyer — transmits the recorded F1F12
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in // voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
// the reserved area. Recording/labeling lives in Settings → Audio. // the reserved area. Recording/labeling lives in Settings → Audio.
export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) { export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) {
+21 -3
View File
@@ -9,7 +9,7 @@ import {
import { import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider, GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
GetListsSettings, SaveListsSettings, GetListsSettings, SaveListsSettings,
GetCATSettings, SaveCATSettings, GetRadios, SaveRadios, SetActiveRadio, ActiveRadioID, DiscoverFlexRadios, GetCATSettings, SaveCATSettings, GetRadios, SaveRadios, SetActiveRadio, ActiveRadioID, DiscoverFlexRadios, DVKDelete,
GetAudioMonitorPref, GetAudioMonitorPref,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile, ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop, GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
@@ -4416,8 +4416,17 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Baud</Label> <Label>Baud</Label>
<Input type="number" min={1200} value={amp.baud} {/* A list, not a free number: the KPA500 report that
onChange={(e) => patchAmp(i, { baud: parseInt(e.target.value) || 115200 })} className="font-mono" /> began this had its operator wondering whether a typed
baud was the whole problem. These are the rates the
supported amplifiers actually speak. */}
<select value={String(amp.baud)}
onChange={(e) => patchAmp(i, { baud: parseInt(e.target.value) })}
className="h-9 w-full px-2 rounded-md border border-border bg-background text-sm font-mono">
{[4800, 9600, 19200, 38400, 57600, 115200].map((b) => (
<option key={b} value={String(b)}>{b}</option>
))}
</select>
</div> </div>
</div> </div>
) : ( ) : (
@@ -7169,6 +7178,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
> >
{dvkStat.playing ? t('aud.stop') : t('aud.play')} {dvkStat.playing ? t('aud.stop') : t('aud.play')}
</Button> </Button>
<Button
type="button"
variant="outline" size="sm" className="h-8 w-9 shrink-0 px-0 text-danger hover:text-danger"
title={t('aud.deleteMsg')}
disabled={!m.has_audio || dvkStat.recording || dvkStat.playing}
onClick={() => DVKDelete(m.slot).then(reloadDvk).catch((err) => setDvkErr(String(err?.message ?? err)))}
>
<Trash2 className="size-3.5" />
</Button>
</div> </div>
); );
})} })}
+4 -4
View File
@@ -449,7 +449,7 @@ const en: Dict = {
'wkp.cwSpeed': 'CW speed (WPM)', 'wkp.faster': 'Faster', 'wkp.slower': 'Slower', 'wkp.cwText': 'CW text', 'wkp.sendOnTypeHint': 'Key each character live as you type (backspace removes un-sent chars)', 'wkp.sendOnType': 'send on type', 'wkp.phLive': 'Type — sent live…', 'wkp.phEnter': 'Type and press Enter to send…', 'wkp.clear': 'Clear', 'wkp.send': 'Send', 'wkp.abort': 'Abort (clear keyer buffer)', 'wkp.stop': 'Stop', 'wkp.cwSpeed': 'CW speed (WPM)', 'wkp.faster': 'Faster', 'wkp.slower': 'Slower', 'wkp.cwText': 'CW text', 'wkp.sendOnTypeHint': 'Key each character live as you type (backspace removes un-sent chars)', 'wkp.sendOnType': 'send on type', 'wkp.phLive': 'Type — sent live…', 'wkp.phEnter': 'Type and press Enter to send…', 'wkp.clear': 'Clear', 'wkp.send': 'Send', 'wkp.abort': 'Abort (clear keyer buffer)', 'wkp.stop': 'Stop',
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL", 'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL",
'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}', 'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}',
'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Repeat a CQ-labelled message on a timer until you stop it or play another slot', 'dvkp.gap': 'Gap', 'dvkp.notPhone': 'The voice keyer only transmits on a phone mode (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message', 'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Repeat a CQ-labelled message on a timer until you stop it or play another slot', 'dvkp.gap': 'Gap', 'dvkp.notPhone': 'The voice keyer only transmits on a phone mode (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1F12.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message',
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band', 'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band',
'ampw.title': 'Amplifier', 'ampw.all': 'All amplifiers', 'ampw.pick': 'Which amplifier the widget shows', 'ampw.title': 'Amplifier', 'ampw.all': 'All amplifiers', 'ampw.pick': 'Which amplifier the widget shows',
'ampw.showHint': 'Amplifier · click to show', 'ampw.hideHint': 'Amplifier — shown · click to hide', 'ampw.showHint': 'Amplifier · click to show', 'ampw.hideHint': 'Amplifier — shown · click to hide',
@@ -535,7 +535,7 @@ const en: Dict = {
'aud.preroll': 'Pre-roll (seconds)', 'aud.format': 'File format', 'aud.wav': 'WAV (lossless, larger)', 'aud.mp3': 'MP3 (compressed, small)', 'aud.preroll': 'Pre-roll (seconds)', 'aud.format': 'File format', 'aud.wav': 'WAV (lossless, larger)', 'aud.mp3': 'MP3 (compressed, small)',
'aud.fromLevel': 'From Radio level', 'aud.txLevel': 'Voice keyer level', 'aud.txLevelHint': 'Level of the recorded messages sent to the radio. Raise it if your voice keyer is much quieter than your microphone; Play previews at this same level. If the radio transmits almost nothing, its modulation source is still the front microphone: on an FTDX10 set MENU → SSB MOD SOURCE to REAR (the USB input).', 'aud.micLevel': 'Mic level', 'aud.qsoPlayLevel': 'QSO playback level', 'aud.levelHint': 'If your voice is louder than the station, lower Mic level.', 'aud.fromLevel': 'From Radio level', 'aud.txLevel': 'Voice keyer level', 'aud.txLevelHint': 'Level of the recorded messages sent to the radio. Raise it if your voice keyer is much quieter than your microphone; Play previews at this same level. If the radio transmits almost nothing, its modulation source is still the front microphone: on an FTDX10 set MENU → SSB MOD SOURCE to REAR (the USB input).', 'aud.micLevel': 'Mic level', 'aud.qsoPlayLevel': 'QSO playback level', 'aud.levelHint': 'If your voice is louder than the station, lower Mic level.',
'aud.autoSend': 'Auto-send the recording to the station by e-mail when I log a QSO', 'aud.autoSend': 'Auto-send the recording to the station by e-mail when I log a QSO',
'aud.dvkTitle': 'Voice keyer messages (F1F6)', 'aud.pttMethod': 'PTT method', 'aud.pttNone': 'None (VOX)', 'aud.pttCat': 'CAT (the radio link)', 'aud.pttRts': 'Serial RTS', 'aud.pttDtr': 'Serial DTR', 'aud.dvkTitle': 'Voice keyer messages (F1F12)', 'aud.deleteMsg': 'Delete this message', 'aud.pttMethod': 'PTT method', 'aud.pttNone': 'None (VOX)', 'aud.pttCat': 'CAT (the radio link)', 'aud.pttRts': 'Serial RTS', 'aud.pttDtr': 'Serial DTR',
'aud.testPtt': 'Test PTT', 'aud.pttPort': 'PTT COM port', 'aud.pickPort': 'Pick a COM port', 'aud.selectPort': '— select —', 'aud.refresh': 'Refresh', 'aud.testPtt': 'Test PTT', 'aud.pttPort': 'PTT COM port', 'aud.pickPort': 'Pick a COM port', 'aud.selectPort': '— select —', 'aud.refresh': 'Refresh',
'aud.msgPlaceholder': 'Message {n} label (CQ, report, 73…)', 'aud.holdRec': '● Hold to rec', 'aud.recordingNow': '● Recording…', 'aud.play': '▶ Play', 'aud.stop': '■ Stop', 'aud.msgPlaceholder': 'Message {n} label (CQ, report, 73…)', 'aud.holdRec': '● Hold to rec', 'aud.recordingNow': '● Recording…', 'aud.play': '▶ Play', 'aud.stop': '■ Stop',
'aud.errPttTest': 'PTT test: ', 'aud.errRecord': 'Record: ', 'aud.errSave': 'Save: ', 'aud.errPlay': 'Play: ', 'aud.errPttTest': 'PTT test: ', 'aud.errRecord': 'Record: ', 'aud.errSave': 'Save: ', 'aud.errPlay': 'Play: ',
@@ -955,7 +955,7 @@ const fr: Dict = {
'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop', 'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop',
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL", 'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL",
'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}', 'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}',
'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Répète un message libellé CQ à intervalle régulier jusqu\'à l\'arrêt ou la lecture d\'un autre slot', 'dvkp.gap': 'Intervalle', 'dvkp.notPhone': 'Le manipulateur vocal n\'émet qu\'en mode phonie (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message', 'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Répète un message libellé CQ à intervalle régulier jusqu\'à l\'arrêt ou la lecture d\'un autre slot', 'dvkp.gap': 'Intervalle', 'dvkp.notPhone': 'Le manipulateur vocal n\'émet qu\'en mode phonie (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1F12.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message',
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour nafficher que la bande courante', 'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour nafficher que la bande courante',
'ampw.title': 'Amplificateur', 'ampw.all': 'Tous les amplis', 'ampw.pick': 'Ampli affiché par le widget', 'ampw.title': 'Amplificateur', 'ampw.all': 'Tous les amplis', 'ampw.pick': 'Ampli affiché par le widget',
'ampw.showHint': 'Amplificateur · cliquer pour afficher', 'ampw.hideHint': 'Amplificateur — affiché · cliquer pour masquer', 'ampw.showHint': 'Amplificateur · cliquer pour afficher', 'ampw.hideHint': 'Amplificateur — affiché · cliquer pour masquer',
@@ -1037,7 +1037,7 @@ const fr: Dict = {
'aud.preroll': 'Pré-enregistrement (secondes)', 'aud.format': 'Format de fichier', 'aud.wav': 'WAV (sans perte, plus volumineux)', 'aud.mp3': 'MP3 (compressé, léger)', 'aud.preroll': 'Pré-enregistrement (secondes)', 'aud.format': 'Format de fichier', 'aud.wav': 'WAV (sans perte, plus volumineux)', 'aud.mp3': 'MP3 (compressé, léger)',
'aud.fromLevel': 'Niveau depuis la radio', 'aud.txLevel': 'Niveau du voice keyer', 'aud.txLevelHint': "Niveau des messages enregistrés envoyés à la radio. Augmentez-le si votre voice keyer est bien plus faible que votre micro ; Lire fait entendre ce même niveau. Si la radio n'émet presque rien, c'est que sa source de modulation est restée le micro de façade : sur un FTDX10, réglez MENU → SSB MOD SOURCE sur REAR (l'entrée USB).", 'aud.micLevel': 'Niveau micro', 'aud.qsoPlayLevel': 'Niveau de relecture QSO', 'aud.levelHint': 'Si votre voix est plus forte que la station, baissez le niveau micro.', 'aud.fromLevel': 'Niveau depuis la radio', 'aud.txLevel': 'Niveau du voice keyer', 'aud.txLevelHint': "Niveau des messages enregistrés envoyés à la radio. Augmentez-le si votre voice keyer est bien plus faible que votre micro ; Lire fait entendre ce même niveau. Si la radio n'émet presque rien, c'est que sa source de modulation est restée le micro de façade : sur un FTDX10, réglez MENU → SSB MOD SOURCE sur REAR (l'entrée USB).", 'aud.micLevel': 'Niveau micro', 'aud.qsoPlayLevel': 'Niveau de relecture QSO', 'aud.levelHint': 'Si votre voix est plus forte que la station, baissez le niveau micro.',
'aud.autoSend': "Envoyer automatiquement l'enregistrement à la station par e-mail lorsque j'enregistre un QSO", 'aud.autoSend': "Envoyer automatiquement l'enregistrement à la station par e-mail lorsque j'enregistre un QSO",
'aud.dvkTitle': 'Messages du manipulateur vocal (F1F6)', 'aud.pttMethod': 'Méthode PTT', 'aud.pttNone': 'Aucune (VOX)', 'aud.pttCat': 'CAT (la liaison radio)', 'aud.pttRts': 'RTS série', 'aud.pttDtr': 'DTR série', 'aud.dvkTitle': 'Messages du manipulateur vocal (F1F12)', 'aud.deleteMsg': 'Supprimer ce message', 'aud.pttMethod': 'Méthode PTT', 'aud.pttNone': 'Aucune (VOX)', 'aud.pttCat': 'CAT (la liaison radio)', 'aud.pttRts': 'RTS série', 'aud.pttDtr': 'DTR série',
'aud.testPtt': 'Tester le PTT', 'aud.pttPort': 'Port COM du PTT', 'aud.pickPort': 'Choisir un port COM', 'aud.selectPort': '— choisir —', 'aud.refresh': 'Actualiser', 'aud.testPtt': 'Tester le PTT', 'aud.pttPort': 'Port COM du PTT', 'aud.pickPort': 'Choisir un port COM', 'aud.selectPort': '— choisir —', 'aud.refresh': 'Actualiser',
'aud.msgPlaceholder': 'Libellé du message {n} (CQ, report, 73…)', 'aud.holdRec': '● Maintenir pour enreg.', 'aud.recordingNow': '● Enregistrement…', 'aud.play': '▶ Lire', 'aud.stop': '■ Arrêter', 'aud.msgPlaceholder': 'Libellé du message {n} (CQ, report, 73…)', 'aud.holdRec': '● Maintenir pour enreg.', 'aud.recordingNow': '● Enregistrement…', 'aud.play': '▶ Lire', 'aud.stop': '■ Arrêter',
'aud.errPttTest': 'Test PTT : ', 'aud.errRecord': 'Enregistrement : ', 'aud.errSave': 'Sauvegarde : ', 'aud.errPlay': 'Lecture : ', 'aud.errPttTest': 'Test PTT : ', 'aud.errRecord': 'Enregistrement : ', 'aud.errSave': 'Sauvegarde : ', 'aud.errPlay': 'Lecture : ',
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.27.2'; export const APP_VERSION = '0.27.3';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+2
View File
@@ -160,6 +160,8 @@ export function CreateDatabase(arg1:string):Promise<void>;
export function DVKCancelRecord():Promise<void>; export function DVKCancelRecord():Promise<void>;
export function DVKDelete(arg1:number):Promise<void>;
export function DVKPlay(arg1:number):Promise<void>; export function DVKPlay(arg1:number):Promise<void>;
export function DVKPreview(arg1:number):Promise<void>; export function DVKPreview(arg1:number):Promise<void>;
+4
View File
@@ -258,6 +258,10 @@ export function DVKCancelRecord() {
return window['go']['main']['App']['DVKCancelRecord'](); return window['go']['main']['App']['DVKCancelRecord']();
} }
export function DVKDelete(arg1) {
return window['go']['main']['App']['DVKDelete'](arg1);
}
export function DVKPlay(arg1) { export function DVKPlay(arg1) {
return window['go']['main']['App']['DVKPlay'](arg1); return window['go']['main']['App']['DVKPlay'](arg1);
} }
+24 -1
View File
@@ -82,6 +82,7 @@ type Client struct {
mu sync.Mutex // serialises the connection: one question at a time mu sync.Mutex // serialises the connection: one question at a time
conn io.ReadWriteCloser conn io.ReadWriteCloser
rd *bufio.Reader rd *bufio.Reader
skipTP bool // ^TP went unanswered once — a KPA500, no ATU; never ask again
statusMu sync.RWMutex statusMu sync.RWMutex
status Status status Status
@@ -183,6 +184,13 @@ func (c *Client) connectLocked() error {
if err != nil { if err != nil {
return fmt.Errorf("cannot open %s: %w", c.cfg.ComPort, err) return fmt.Errorf("cannot open %s: %w", c.cfg.ComPort, err)
} }
// The KPA500 is POWER-CONTROLLED by these lines: the Elecraft utility
// switches the amplifier on by raising them. Held asserted, once, and
// never touched again — reconnect cycles that toggled them were
// switching a KPA500 OFF twenty seconds after its operator pressed
// nothing but Standby.
_ = p.SetDTR(true)
_ = p.SetRTS(true)
_ = p.SetReadTimeout(ioTimeout) _ = p.SetReadTimeout(ioTimeout)
c.conn = p c.conn = p
} }
@@ -214,7 +222,13 @@ func (c *Client) ask(cmd string) (string, error) {
// the frame. // the frame.
line, err := c.rd.ReadString(';') line, err := c.rd.ReadString(';')
if err != nil { if err != nil {
c.dropLocked() // NOT dropped. A command this model simply does not know (^TP is the
// KPA1500's ATU — a KPA500 never answers it) is silence, not a dead
// link, and dropping here tore the connection down on every slow poll
// cycle: two seconds of stalled commands, a reconnect, and a DTR
// toggle the amplifier read as the off switch. Nothing arrived, so
// nothing is left to desynchronise the next exchange. Write errors —
// the genuinely dead link — still drop, above.
return "", fmt.Errorf("no answer to %s: %w", cmd, err) return "", fmt.Errorf("no answer to %s: %w", cmd, err)
} }
return strings.TrimSpace(line), nil return strings.TrimSpace(line), nil
@@ -364,12 +378,21 @@ func (c *Client) pollOnce(n uint64) {
c.statusMu.Unlock() c.statusMu.Unlock()
} }
} }
if c.skipTP {
return
}
if reply, err := c.ask("^TP;"); err == nil { if reply, err := c.ask("^TP;"); err == nil {
if v, err := parseInt(reply, "^TP"); err == nil { if v, err := parseInt(reply, "^TP"); err == nil {
c.statusMu.Lock() c.statusMu.Lock()
c.status.Tuning = v == 1 c.status.Tuning = v == 1
c.statusMu.Unlock() c.statusMu.Unlock()
} }
} else {
// One silence is the model's answer for good: a KPA500 has no ATU and
// will never answer ^TP — asking again every cycle cost a two-second
// stall each time.
c.skipTP = true
applog.Printf("kpa: ^TP unanswered — no ATU on this model, not asking again")
} }
} }
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.27.2" appVersion = "0.27.3"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.