Compare commits

...
8 Commits
Author SHA1 Message Date
rouggy f27faa81a6 chore: release v0.23.4 2026-08-04 19:41:06 +02:00
rouggy 69e6c20b97 docs(changelog): merge the two QSL Manager entries into one for 0.23.4 2026-08-04 19:39:52 +02:00
rouggy d93c40ebb3 fix(qsl): clearer QRZ download summary (confirmed vs total)
QRZ hands back the whole logbook; a confirmation download only acts on the
QSOs QRZ marks confirmed. The old "(of 7950 returned)" read as if QRZ had
returned only a fraction of a 26k-QSO logbook. Now it states "N of M are
confirmed — the rest aren't confirmed yet", so nothing looks lost.
2026-08-04 19:35:47 +02:00
rouggy dfe3afbf2d fix(qsl): cancel an in-flight confirmation download on close / new download
The LoTW/QRZ confirmation download ran on the app-lifetime context, so it
kept going after the QSL Manager was closed and a new download didn't stop the
previous one. A slow QRZ sync therefore bled its "flag cleared" log into a
freshly started LoTW download — the operator saw QRZ activity during a LoTW
run. Now each download gets a cancellable context: starting one cancels the
previous, closing the window cancels it (CancelConfirmations), and the parse
loops bail promptly on cancel. (A LoTW http 503 is separate — ARRL's server.)
2026-08-04 19:25:14 +02:00
rouggy bb3542c920 fix(ultrabeam): hold a commanded pattern over a remote link until confirmed
Changing Normal/180/bi over a remote connection reverted to the old pattern
after ~4s: the fixed 4s optimistic window expired while the motors were still
flipping the elements, and the lagging remote status poll then overrode the UI.
Hold the commanded direction while the motors are moving, then a short grace
window after they stop for the confirmation poll to land (SteppIR already used
a generous 45s hold, which is why only Ultrabeam-remote hit this).
2026-08-04 18:56:21 +02:00
rouggy a966abd9df fix(cw): show the Kenwood/Elecraft keyer as connected when CAT is up
The keyer panel synthesised a "connected" status for the icom/flex/yaesu
rig engines but not kenwood, so the Kenwood/Elecraft engine fell back to the
(disconnected) WinKeyer hardware status and always showed "not connected"
even though its CAT link was up.
2026-08-04 18:46:44 +02:00
rouggy 95482ec1d8 feat(cat): per-rig Kenwood/Elecraft data-mode choice (USB / MD6 / keep)
No single Kenwood mode digit fits every rig for a data mode: a K3/K4 wants
DATA (MD6), a TS-590SG/TS-990S data mode is a USB modifier set on the rig,
and MD6 on a plain Kenwood is FSK/RTTY. So the operator now chooses in
Settings → CAT: USB (default), DATA (MD6), or leave the rig's mode unchanged.

Only soundcard/data modes (FT8, PSK, JT…) follow this; RTTY/FSK stays its own
native mode, and voice/CW are untouched (isKenwoodDataMode + test).
2026-08-04 18:14:26 +02:00
rouggy 500fe8bfec fix(cw): activate the Kenwood/Elecraft keyer engine when selected
The frontend engine loader had no "kenwood" case, so a saved engine of
"kenwood" fell through to the WinKeyer default — the Kenwood/Elecraft
CW-over-CAT engine never ran even though the setting showed it selected.

Also clarify the Kenwood data-mode comment: there is no single mode digit
right for both a K3 (DATA=MD6) and a TS-590SG (MD6=FSK), so data stays on
USB as the safe common base; a rig-specific DATA mode is a follow-up.
2026-08-04 17:59:30 +02:00
14 changed files with 196 additions and 27 deletions
+57 -7
View File
@@ -126,6 +126,7 @@ const (
// backend's setting.
keyCATYaesuLowLines = "cat.yaesu.low_dtr_rts" // deassert DTR/RTS on connect
keyCATKenwoodLowLines = "cat.kenwood.low_dtr_rts" // deassert DTR/RTS on connect
keyCATKenwoodDataMode = "cat.kenwood.data_mode" // data modes → "usb" | "data" (MD6) | "keep"
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200)
keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98)
@@ -403,6 +404,10 @@ type CATSettings struct {
// adapter). NOT the radios own RJ45, which speaks Kenwoods KNS/ARCP.
KenwoodPort string `json:"kenwood_port"` // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
KenwoodBaud int `json:"kenwood_baud"` // Kenwood CAT baud (TS-590 default 9600)
// What a DATA/digital mode (FT8, PSK…) sets on the rig: "usb" (default), "data"
// (MD6 — Elecraft K3/K4 DATA mode) or "keep" (leave the rig's mode untouched,
// for a TS-590SG/TS-990S whose data mode is a USB modifier set on the rig).
KenwoodDataMode string `json:"kenwood_data_mode"`
// Per-backend: deassert DTR and RTS after opening the CAT port, for
// interfaces that read either line as PTT. Off by default: lowering them
// stops some USB-serial interfaces transmitting at all.
@@ -617,6 +622,12 @@ type App struct {
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
pttMu sync.Mutex
// confDLMu/confDLCancel cancel the in-flight confirmation download (LoTW/QRZ)
// so closing the QSL Manager — or starting another download — stops the previous
// one instead of leaving it running against the app-lifetime context (which made
// a still-running QRZ sync bleed its log into a freshly started LoTW download).
confDLMu sync.Mutex
confDLCancel context.CancelFunc
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets)
relayAutoMu sync.Mutex // serialises relay auto-control evaluation
@@ -6855,7 +6866,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
if err != nil {
return CATSettings{}, err
}
@@ -6878,6 +6889,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
KenwoodBaud: 9600,
YaesuLowLines: m[keyCATYaesuLowLines] == "1",
KenwoodLowLines: m[keyCATKenwoodLowLines] == "1",
KenwoodDataMode: m[keyCATKenwoodDataMode],
IcomPort: m[keyCATIcomPort],
IcomBaud: 115200,
IcomAddr: 0x98, // IC-7610 default
@@ -7047,6 +7059,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATKenwoodBaud: strconv.Itoa(s.KenwoodBaud),
keyCATYaesuLowLines: b01(s.YaesuLowLines),
keyCATKenwoodLowLines: b01(s.KenwoodLowLines),
keyCATKenwoodDataMode: strings.ToLower(strings.TrimSpace(s.KenwoodDataMode)),
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
@@ -10173,13 +10186,35 @@ func (a *App) DownloadConfirmations(service string, addNotFound bool, since stri
}
svc := extsvc.Service(service)
cfg := a.loadExternalServices()
go a.runDownloadConfirmations(svc, cfg, addNotFound, since)
// Cancel any download still running (a slow QRZ sync, say) before starting this
// one, so the two don't run at once and interleave their logs — and derive a
// cancellable context so closing the QSL Manager can stop it too.
a.confDLMu.Lock()
if a.confDLCancel != nil {
a.confDLCancel()
}
ctx, cancel := context.WithCancel(a.ctx)
a.confDLCancel = cancel
a.confDLMu.Unlock()
go a.runDownloadConfirmations(ctx, svc, cfg, addNotFound, since)
return nil
}
func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalServices, addNotFound bool, since string) {
// CancelConfirmations stops the in-flight LoTW/QRZ download — the QSL Manager
// calls it when the window closes, so work doesn't keep running in the background.
func (a *App) CancelConfirmations() {
a.confDLMu.Lock()
if a.confDLCancel != nil {
a.confDLCancel()
a.confDLCancel = nil
}
a.confDLMu.Unlock()
}
func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service, cfg extsvc.ExternalServices, addNotFound bool, since string) {
emit := func(line string) {
if a.ctx != nil {
// Don't keep logging into a closed/cancelled window.
if a.ctx != nil && ctx.Err() == nil {
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
}
}
@@ -10188,7 +10223,6 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": matched, "total": total})
}
}
ctx := a.ctx
matched, total, added := 0, 0, 0
// resolveSince turns the UI's request into a concrete date (or ""):
@@ -10240,6 +10274,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
var items []ConfirmationItem
var unmatched []string
perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
if ctx.Err() != nil {
return ctx.Err() // window closed / superseded
}
q, ok := adif.RecordToQSO(rec)
if !ok {
return nil
@@ -10422,6 +10459,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
// QRZ FETCH returns headerless ADIF (no <EOH>); prepend one so the
// parser treats the stream as records.
perr := adif.Parse(strings.NewReader("<EOH>\n"+adifText), func(rec adif.Record) error {
if ctx.Err() != nil {
return ctx.Err() // window closed / superseded — stop clearing flags
}
parsed++
for k := range rec {
allKeys[k] = true
@@ -10539,7 +10579,11 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
}
sort.Strings(keys)
emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", ")))
emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total))
// Spell out the funnel: QRZ hands back the WHOLE logbook, but a confirmation
// download only acts on the ones QRZ marks confirmed — the "26165 but only
// 7950?" question. Nothing is lost; the rest simply aren't confirmed yet.
emit(fmt.Sprintf("%d of your %d QRZ QSOs are confirmed by the other station — the rest aren't confirmed yet, so there's nothing to import for them.", total, parsed))
emit(fmt.Sprintf("Of those %d confirmed: %d newly marked, %d added.", total, matched, added))
if unconfirmed > 0 {
// Worth its own line and not a footnote: these QSOs were showing as
// confirmed a moment ago, and an award total may move because of it.
@@ -10591,6 +10635,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
var items []ConfirmationItem
var unmatched []string
perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
if ctx.Err() != nil {
return ctx.Err() // window closed / superseded
}
q, ok := adif.RecordToQSO(rec)
if !ok {
return nil
@@ -12965,10 +13012,13 @@ func (a *App) reloadCAT() {
// more deliberate setting, and silently preferring the wire would leave an
// operator staring at a host they typed and a radio that never answers.
if h := strings.TrimSpace(s.KenwoodHost); h != "" {
a.cat.Start(cat.NewKenwoodTCP(h, s.DigitalDefault))
kw := cat.NewKenwoodTCP(h, s.DigitalDefault)
kw.SetDataMode(s.KenwoodDataMode)
a.cat.Start(kw)
} else {
kw := cat.NewKenwood(s.KenwoodPort, s.KenwoodBaud, s.DigitalDefault)
kw.SetLowerLines(s.KenwoodLowLines)
kw.SetDataMode(s.KenwoodDataMode)
a.cat.Start(kw)
}
case "icom":
+16
View File
@@ -1,4 +1,20 @@
[
{
"version": "0.23.4",
"date": "",
"en": [
"CW keyer (Kenwood/Elecraft): choosing the Kenwood/Elecraft engine now actually switches to it. A bug left the keyer on WinKeyer even though the setting showed Kenwood/Elecraft, so its CW-over-CAT never ran.",
"Kenwood/Elecraft data mode: a new setting (Settings → CAT) chooses what a data mode (FT8/PSK…) sets on the rig — USB (default), DATA mode (MD6, for an Elecraft K3/K4), or leave the rig's mode unchanged (safest for a TS-590SG/TS-990S, whose data mode is a USB modifier set on the radio). There is no single command that fits every rig, so it's now the operator's choice.",
"Ultrabeam over a remote link: changing the pattern (Normal / 180° / bidirectional) no longer snaps back to the old one after a few seconds. The commanded pattern is now held while the motors are still moving plus a grace window after they stop, so the slow remote status poll — which lagged behind the antenna — can't revert the display while the change is in flight.",
"QSL Manager: closing the window — or starting another download — now cancels the one still in progress, so a slow QRZ sync no longer keeps running in the background and bleeds its log into a freshly started LoTW run. The QRZ download summary also spells out that only confirmed QSOs are acted on (e.g. \"7950 of 26164 are confirmed\") — a full QRZ logbook has many not-yet-confirmed QSOs, so nothing is lost even though the old wording read as if only a fraction came back. (A LoTW \"http 503\" is separate: the ARRL server being down — retry later.)"
],
"fr": [
"Keyer CW (Kenwood/Elecraft) : choisir le moteur Kenwood/Elecraft y bascule désormais réellement. Un bug laissait le keyer sur WinKeyer alors que le réglage affichait Kenwood/Elecraft, son CW-sur-CAT ne démarrait donc jamais.",
"Mode data Kenwood/Elecraft : un nouveau réglage (Réglages → CAT) choisit ce qu'un mode data (FT8/PSK…) règle sur la radio — USB (défaut), mode DATA (MD6, pour un Elecraft K3/K4), ou ne pas changer le mode de la radio (le plus sûr pour un TS-590SG/TS-990S dont le mode data est un modificateur d'USB réglé sur la radio). Aucune commande unique ne convient à toutes les radios, c'est donc désormais au choix de l'opérateur.",
"Ultrabeam en remote : changer le diagramme (Normal / 180° / bidirectionnel) ne revient plus à l'ancien au bout de quelques secondes. Le diagramme commandé est maintenu tant que les moteurs bougent, plus une fenêtre de grâce après leur arrêt — le poll de statut distant, lent et en retard sur l'antenne, ne peut donc plus faire revenir l'affichage pendant que le changement est en cours.",
"QSL Manager : fermer la fenêtre — ou lancer un autre téléchargement — annule désormais celui en cours, une synchro QRZ lente ne continue donc plus en arrière-plan à mélanger son journal à un téléchargement LoTW fraîchement lancé. Le récapitulatif QRZ indique aussi clairement que seuls les QSO confirmés sont traités (ex. « 7950 sur 26164 confirmés ») — un logbook QRZ complet contient beaucoup de QSO pas encore confirmés, rien n'est donc perdu même si l'ancien texte donnait l'impression qu'une fraction seulement était revenue. (Un « http 503 » sur LoTW est un autre sujet : serveur de l'ARRL indisponible — réessayez plus tard.)"
]
},
{
"version": "0.23.3",
"date": "",
+5 -4
View File
@@ -2706,8 +2706,9 @@ export default function App() {
s.engine === 'icom' ? 'icom'
: s.engine === 'flex' ? 'flex'
: s.engine === 'yaesu' ? 'yaesu'
: s.engine === 'serial' ? 'serial'
: 'winkeyer');
: s.engine === 'kenwood' ? 'kenwood'
: s.engine === 'serial' ? 'serial'
: 'winkeyer');
} catch { /* keyer not configured */ }
}, []);
@@ -5702,11 +5703,11 @@ export default function App() {
// exactly when its CAT backend is. Yaesu was missing from this
// list, so the panel fell back to the WinKeyer status — which
// reported disconnected, no WinKeyer being attached.
status={cwSource === 'icom' || cwSource === 'flex' || cwSource === 'yaesu'
status={cwSource === 'icom' || cwSource === 'flex' || cwSource === 'yaesu' || cwSource === 'kenwood'
? {
connected: catState.backend === cwSource && catState.connected,
busy: false, wpm: wkWpm, version: 0,
port: cwSource === 'flex' ? 'CWX' : cwSource === 'yaesu' ? 'CAT' : 'CI-V',
port: cwSource === 'flex' ? 'CWX' : cwSource === 'yaesu' || cwSource === 'kenwood' ? 'CAT' : 'CI-V',
}
: wkStatus}
ports={wkPorts}
+5 -1
View File
@@ -8,7 +8,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime';
@@ -120,6 +120,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
}, [service]);
// Closing the QSL Manager (the tab's ×) unmounts this panel — cancel any download
// still running so a slow QRZ/LoTW sync doesn't keep going in the background and
// bleed its log into the next one.
useEffect(() => () => { CancelConfirmations().catch(() => {}); }, []);
const [potaSyncing, setPotaSyncing] = useState(false);
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
const [potaErr, setPotaErr] = useState('');
+13 -1
View File
@@ -1136,7 +1136,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8', share_enabled: false, share_port: 4532,
@@ -2642,6 +2642,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</label>
<span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
</div>
<div className="space-y-1">
<Label>{t('cat.kwDataMode')}</Label>
<Select value={(catCfg as any).kenwood_data_mode || 'usb'} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_data_mode: v } as any))}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="usb">{t('cat.kwDataUsb')}</SelectItem>
<SelectItem value="data">{t('cat.kwDataMd6')}</SelectItem>
<SelectItem value="keep">{t('cat.kwDataKeep')}</SelectItem>
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">{t('cat.kwDataHint')}</span>
</div>
</>
)}
{catCfg.backend === 'icom' && (
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
// 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).
export const APP_VERSION = '0.23.3';
export const APP_VERSION = '0.23.4';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+2
View File
@@ -97,6 +97,8 @@ export function CIVTraceEnabled():Promise<boolean>;
export function CWDecoderRunning():Promise<boolean>;
export function CancelConfirmations():Promise<void>;
export function ChatAvailable():Promise<boolean>;
export function CheckForUpdate():Promise<main.UpdateInfo>;
+4
View File
@@ -142,6 +142,10 @@ export function CWDecoderRunning() {
return window['go']['main']['App']['CWDecoderRunning']();
}
export function CancelConfirmations() {
return window['go']['main']['App']['CancelConfirmations']();
}
export function ChatAvailable() {
return window['go']['main']['App']['ChatAvailable']();
}
+2
View File
@@ -1930,6 +1930,7 @@ export namespace main {
kenwood_host: string;
kenwood_port: string;
kenwood_baud: number;
kenwood_data_mode: string;
yaesu_low_lines: boolean;
kenwood_low_lines: boolean;
icom_port: string;
@@ -1975,6 +1976,7 @@ export namespace main {
this.kenwood_host = source["kenwood_host"];
this.kenwood_port = source["kenwood_port"];
this.kenwood_baud = source["kenwood_baud"];
this.kenwood_data_mode = source["kenwood_data_mode"];
this.yaesu_low_lines = source["yaesu_low_lines"];
this.kenwood_low_lines = source["kenwood_low_lines"];
this.icom_port = source["icom_port"];
+42 -5
View File
@@ -56,6 +56,12 @@ type Kenwood struct {
// a wire, which is how most operators actually put a rig on the network.
host string
digital string // mode name logged for data (FT8 by default)
// dataMode chooses what CAT mode a DATA/digital mode (FT8, PSK…) sets, because
// no single Kenwood mode digit is right for every rig: "usb" → USB (the SSB-data
// base, default), "data" → the DATA mode digit MD6 (Elecraft K3/K4), "keep" →
// leave the rig's current mode untouched (safest for a TS-590SG/TS-990S whose
// data mode is a USB modifier the operator sets on the rig). See SetMode.
dataMode string
mu sync.Mutex
port serial.Port
@@ -360,6 +366,17 @@ func (k *Kenwood) SetMode(mode string) error {
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
}
// Honour the operator's DATA-mode preference for a digital mode. No single
// mode digit fits every rig, so the operator picks: keep the rig's mode, use
// MD6 (K3/K4 DATA), or fall through to the default USB from kenwoodModeDigit.
if isKenwoodDataMode(mode) {
switch k.dataMode {
case "keep":
return nil // leave whatever data mode the operator set on the rig
case "data":
return k.write("MD6;") // Elecraft K3/K4 DATA mode
}
}
d := kenwoodModeDigit(mode, k.curFreq)
if d == 0 {
return fmt.Errorf("kenwood: no CAT mode for %q", mode)
@@ -367,6 +384,25 @@ func (k *Kenwood) SetMode(mode string) error {
return k.write(fmt.Sprintf("MD%c;", d))
}
// SetDataMode configures how a DATA/digital mode is set: "usb" (default), "data"
// (MD6, Elecraft K3/K4) or "keep" (don't change the rig's mode).
func (k *Kenwood) SetDataMode(m string) {
k.mu.Lock()
k.dataMode = strings.ToLower(strings.TrimSpace(m))
k.mu.Unlock()
}
// isKenwoodDataMode reports whether a mode name is a soundcard/data mode (FT8,
// PSK, JT…) rather than a voice/CW/RTTY mode the rig sets natively.
func isKenwoodDataMode(mode string) bool {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "", "LSB", "USB", "SSB", "CW", "CW-R", "CWR", "FM", "NFM", "AM",
"RTTY", "FSK", "RTTY-R", "FSK-R":
return false
}
return true
}
func (k *Kenwood) SetPTT(on bool) error {
k.mu.Lock()
defer k.mu.Unlock()
@@ -551,11 +587,12 @@ func kenwoodModeDigit(mode string, hz int64) byte {
}
return '2'
}
// Any other digital mode (FT8, PSK, JT…) rides the DATA input, and by universal
// convention that is ALWAYS the UPPER sideband, on every band. The SSB rule
// (LSB below 10 MHz) must NOT be applied here: doing so put a 40/80 m data spot
// on LSB, which an Elecraft K3 shows as "DATA REV" — the reversed sideband.
// (RTTY/FSK is handled above as its own FSK mode, so it isn't affected.)
// Any other digital mode (FT8, PSK, JT…) → USB on every band. There is NO single
// mode digit that is right for both families: a K3 wants DATA (MD6), a TS-590SG
// wants USB-DATA (MD2 + its DATA modifier), and MD6 on the TS-590SG is FSK/RTTY
// — wrong for FT8. USB is the safe common base (the reversed-sideband "DATA REV"
// only came from the old LSB-below-10-MHz rule). A rig-specific DATA mode is a
// follow-up that needs the model, not a blind default.
return '2'
}
+17
View File
@@ -111,6 +111,23 @@ func TestKenwoodModeDigit(t *testing.T) {
}
}
// The DATA-mode preference only applies to soundcard/data modes, never to voice,
// CW or native RTTY — getting this wrong would hijack an SSB or CW mode change.
func TestIsKenwoodDataMode(t *testing.T) {
data := []string{"FT8", "FT4", "PSK31", "JT65", "DATA", "DIGITAL", "OLIVIA"}
notData := []string{"", "LSB", "USB", "SSB", "CW", "CW-R", "FM", "AM", "RTTY", "FSK", "RTTY-R"}
for _, m := range data {
if !isKenwoodDataMode(m) {
t.Errorf("isKenwoodDataMode(%q) = false, want true", m)
}
}
for _, m := range notData {
if isKenwoodDataMode(m) {
t.Errorf("isKenwoodDataMode(%q) = true, want false", m)
}
}
}
// What the log records for each mode digit the rig reports.
func TestKenwoodModeToADIF(t *testing.T) {
cases := []struct {
+29 -5
View File
@@ -23,6 +23,12 @@ const (
ubReadTimeout = 4 * time.Second // was 1s — too tight for a remote link
ubKeepAlive = 15 * time.Second // OS-level TCP keepalive
ubMaxPollTimeout = 3 // consecutive read timeouts tolerated before reconnecting
// How long a just-commanded direction is trusted AFTER the motors have stopped
// but before the antenna's status confirms it. The timer is held off entirely
// while the motors are still moving, so this is only the grace period for the
// confirmation poll to arrive once the elements have settled — generous, because
// over a remote link that poll lags by several seconds.
ubPendingDirGrace = 8 * time.Second
)
// Protocol constants
@@ -221,12 +227,30 @@ func (c *Client) pollLoop() {
}
c.statusMu.Lock()
// Keep a just-commanded direction until the antenna reports it.
// Keep a just-commanded direction until the antenna actually reports it.
// Over a remote link the confirmation arrives several seconds after the
// command — the motors flip the elements first — so the old fixed 4 s
// timeout expired WHILE the change was still in flight, and the stale poll
// reverted the UI to the old pattern even though the antenna was on its way
// to the new one. Now: while the motors are still moving the change is in
// progress, so hold the commanded pattern and keep resetting the timer;
// only once the motors have stopped does the short grace window run, giving
// the confirmation poll time to land. The poll only wins if the motors are
// idle AND the antenna still reports a different pattern past that window —
// i.e. the command genuinely did not take.
if c.pendingDirSet {
if time.Since(c.pendingDirAt) > 4*time.Second || status.Direction == c.pendingDir {
c.pendingDirSet = false
} else {
status.Direction = c.pendingDir
if status.MotorsMoving != 0 {
c.pendingDirAt = time.Now() // still repositioning — don't start the grace timer
}
switch {
case status.Direction == c.pendingDir:
c.pendingDirSet = false // confirmed by the antenna
case time.Since(c.pendingDirAt) > ubPendingDirGrace:
c.pendingDirSet = false // motors idle, still unconfirmed → accept the poll
log.Printf("Ultrabeam: antenna never confirmed direction %d (reports %d) — dropping the hold",
c.pendingDir, status.Direction)
default:
status.Direction = c.pendingDir // still changing, or within the grace window
}
}
c.lastStatus = status
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.23.3"
appVersion = "0.23.4"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.