Compare commits

..
5 Commits
Author SHA1 Message Date
rouggy aa871a07b7 fix: serial CW keyer PTT dropping between words on CP210x (SCU-17)
Confirmed from HB9HBY's log: ptt=true, yet the FT-891 dropped to RX between
words during a CQ macro — while the same SCU-17 works in N1MM. Root cause:
go.bug.st/serial drives DTR/RTS through GetCommState/SetCommState, which on the
SCU-17's Silicon Labs CP210x drops the asserted RTS (PTT) the moment DTR (CW)
is toggled. N1MM/WSJT use EscapeCommFunction, which sets one line without
disturbing the other.

OpsLog now drives the lines the same way: a new lineCtl abstraction with a
Windows implementation (linectl_windows.go) that opens the COM port via
CreateFile and toggles DTR/RTS with windows.EscapeCommFunction (SETDTR/CLRDTR/
SETRTS/CLRRTS). linectl_other.go keeps go.bug.st/serial for non-Windows builds.
The serial keyer holds a lineCtl instead of a serial.Port. RTS (PTT) now stays
asserted for the whole macro, so the rig holds TX between words.

Promotes golang.org/x/sys to a direct dependency.
2026-07-23 21:35:59 +02:00
rouggy 89584f173d fix: serial CW keyer PTT — default on, live-apply, and diagnostic log
Reported: on a CQ macro the rig drops to RX between words instead of holding TX
for the whole macro. Root cause is PTT not being held (usePTT off, or the RTS
PTT line not wired/honoured). Improvements:

- "Key PTT line" now defaults ON when the Serial engine is selected — without it
  the rig runs on semi-break-in and drops between words.
- The serial keyer's line options (PTT, key line, invert, lead/tail, speed) are
  now atomic and live-updatable: SaveWinkeyerSettings -> ApplySerialConfig applies
  them from the next character, no reconnect needed.
- Each transmission logs its PTT state / key line / lead/tail, so a rig that
  still won't hold TX can be diagnosed from the app log.

The macro itself is sent as one string (not fragmented), so PTT is genuinely
held across word gaps when usePTT is on — pending on-air confirmation via the log.
2026-07-23 20:27:05 +02:00
rouggy a71e48f811 feat: ClubLog Most Wanted rank in the entry band matrix
Opt-in (Settings → General). Shows a "MW #rank" pill next to the DXCC entity
name in the entry-strip band/slot matrix — ClubLog's Most Wanted ranking
(1 = most wanted), personalised to the operator's callsign and refreshed daily.

- internal/clublog/mostwanted.go: fetch mostwanted.php?api=1&callsign=<CALL>
  (rank→DXCC JSON, real User-Agent), invert to dxcc→rank, cache to
  <dataDir>/clublog_mostwanted.json; NeedsRefresh invalidates on callsign change.
- app.go: keyClublogMostWanted setting, a.clublogMW, startup refresh goroutine,
  activeCallsign() helper, and Get/Set/Download bindings mirroring the cty ones.
  WorkedBefore now carries MWRank (qso.WorkedBefore.MWRank) when enabled.
- BandSlotGrid.tsx: MW pill next to the entity name, colour-tiered by rank.
- SettingsModal General: toggle + download button + status, mirroring the
  ClubLog cty-exceptions block.

Also reorganises the changelog: the theme-persistence fix (landed after the
v0.20.11 tag) plus this feature go under a new 0.20.12 entry; 0.20.11 keeps only
what shipped in its binary.
2026-07-23 20:01:28 +02:00
rouggy 5c4f101402 docs: correct theme-fix rationale — local SQLite, not remote MySQL
The settings store is backed by the LOCAL SQLite config DB (wired before the
slow remote-MySQL logbook connect), so the "not ready" window is the brief
startup gap before OnStartup builds the store, not a MySQL delay. Fix behaviour
is unchanged; only the comments and changelog wording are corrected.
2026-07-23 19:15:50 +02:00
rouggy ede9461010 fix: theme reverts to light after an update when the logbook DB is slow
An update can clear the WebView's localStorage; the theme is then restored from
the portable DB pref, but GetUIPref returned ("", nil) while the settings store
was still starting (indistinguishable from a genuinely-unset pref), so the
self-heal gave up after ~2.4s and fell back to the light default.

GetUIPref now returns an error when the settings store isn't wired yet, and the
theme self-heal treats that as "not ready → keep retrying" (up to ~36s) vs a
real empty answer ("unset → keep default"). A dark theme survives the relaunch.
2026-07-23 19:13:00 +02:00
14 changed files with 656 additions and 44 deletions
+117 -2
View File
@@ -136,6 +136,7 @@ const (
keyAwardEditsSeeded = "awards.defs.editflag" // one-shot: back-fill the user-edited flag
keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions
keyClublogMostWanted = "clublog.most_wanted" // "1" → show ClubLog Most Wanted rank in the entry matrix
// E-mail / SMTP — send QSO recordings to the correspondent.
keyEmailEnabled = "email.enabled"
@@ -464,6 +465,7 @@ type App struct {
extsvc *extsvc.Manager
winkeyer *winkeyer.Manager
clublog *clublog.Manager
clublogMW *clublog.MostWanted // ClubLog "Most Wanted" DXCC ranking (opt-in)
motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
motorInhibStop chan struct{} // stops the "inhibit TX while moving" loop; nil when off
@@ -954,6 +956,30 @@ func (a *App) startup(ctx context.Context) {
}
}()
// ClubLog "Most Wanted" DXCC ranking (opt-in, Settings → General). Loaded from
// the cached JSON, then refreshed daily and whenever the operator's callsign
// changes (the ranking is personalised per callsign). Best-effort.
a.clublogMW = clublog.NewMostWanted(dataDir)
go func() {
if a.settings == nil {
return
}
if v, _ := a.settings.Get(a.ctx, keyClublogMostWanted); v != "1" {
return // feature off — don't touch the network
}
_ = a.clublogMW.LoadFromDisk()
call := a.activeCallsign()
if a.clublogMW.NeedsRefresh(call, 24*time.Hour) {
if err := a.clublogMW.Download(context.Background(), call); err != nil {
applog.Printf("clublog most-wanted: auto-refresh failed: %v", err)
}
}
if a.clublogMW.Loaded() {
c, d, n := a.clublogMW.Info()
applog.Printf("clublog most-wanted: loaded %d entities for %s (%s)", n, c, d)
}
}()
// POTA: background poller of api.pota.app so cluster spots can be tagged
// when the DX station is currently activating a park. Best-effort.
a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) })
@@ -1835,7 +1861,14 @@ func (a *App) groupDigitalSlots() bool {
func (a *App) GetUIPref(key string) (string, error) {
if a.settings == nil {
return "", nil
// Distinct from a genuinely-empty pref: the (LOCAL SQLite) settings store
// isn't wired yet. There's a brief window at launch where the frontend can
// call this before OnStartup has opened the DB and built the store. The UI
// uses the error to keep RETRYING rather than treat it as "unset" and fall
// back to a default — the "dark theme reverts to light after an update" bug
// (the update cleared localStorage, and the DB read gave up too early while
// the store was still coming up).
return "", fmt.Errorf("settings store not ready")
}
return a.settings.Get(a.ctx, "ui."+key)
}
@@ -5240,7 +5273,13 @@ func (a *App) WorkedBefore(callsign string, dxccHint int) (qso.WorkedBefore, err
dxccHint = dxcc.EntityDXCC(m.Entity.Name)
}
}
return a.qso.WorkedBefore(a.ctx, callsign, dxccHint)
wb, err := a.qso.WorkedBefore(a.ctx, callsign, dxccHint)
// Attach the ClubLog Most Wanted rank for this entity (opt-in) so the entry
// matrix can show it next to the country name.
if err == nil && wb.DXCC > 0 && a.clublogMW != nil && a.clublogMostWantedEnabled() {
wb.MWRank = a.clublogMW.Rank(wb.DXCC)
}
return wb, err
}
// SetCompactMode toggles a tiny always-on-top window that exposes just the
@@ -7320,6 +7359,74 @@ func (a *App) DownloadClublogCty() (ClublogCtyInfo, error) {
return a.GetClublogCtyInfo(), nil
}
// activeCallsign is the current profile's callsign (upper-cased), "" if none.
func (a *App) activeCallsign() string {
if a.profiles == nil {
return ""
}
if p, err := a.profiles.Active(a.ctx); err == nil {
return strings.ToUpper(strings.TrimSpace(p.Callsign))
}
return ""
}
func (a *App) clublogMostWantedEnabled() bool {
if a.settings == nil {
return false
}
v, _ := a.settings.Get(a.ctx, keyClublogMostWanted)
return v == "1"
}
// ClublogMostWantedInfo is the UI status of the ClubLog Most Wanted data.
type ClublogMostWantedInfo struct {
Enabled bool `json:"enabled"`
Loaded bool `json:"loaded"`
Callsign string `json:"callsign"`
Date string `json:"date"`
Count int `json:"count"`
}
// GetClublogMostWantedInfo returns the current Most Wanted status.
func (a *App) GetClublogMostWantedInfo() ClublogMostWantedInfo {
info := ClublogMostWantedInfo{Enabled: a.clublogMostWantedEnabled()}
if a.clublogMW != nil {
info.Loaded = a.clublogMW.Loaded()
info.Callsign, info.Date, info.Count = a.clublogMW.Info()
}
return info
}
// SetClublogMostWantedEnabled toggles the Most Wanted feature. On first enable it
// loads the cached list (the UI then calls Download to fetch a fresh one).
func (a *App) SetClublogMostWantedEnabled(on bool) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
v := "0"
if on {
v = "1"
}
if err := a.settings.Set(a.ctx, keyClublogMostWanted, v); err != nil {
return err
}
if on && a.clublogMW != nil && !a.clublogMW.Loaded() {
_ = a.clublogMW.LoadFromDisk() // ok if not downloaded yet
}
return nil
}
// DownloadClublogMostWanted fetches a fresh Most Wanted list for the active call.
func (a *App) DownloadClublogMostWanted() (ClublogMostWantedInfo, error) {
if a.clublogMW == nil {
return ClublogMostWantedInfo{}, fmt.Errorf("clublog not initialized")
}
if err := a.clublogMW.Download(a.ctx, a.activeCallsign()); err != nil {
return a.GetClublogMostWantedInfo(), err
}
return a.GetClublogMostWantedInfo(), nil
}
// applyClublogException overrides a QSO's entity fields from a ClubLog
// exception matching its callsign at its date. force=true ignores the
// enable toggle (used by the explicit "Update from ClubLog" action).
@@ -13138,6 +13245,14 @@ func (a *App) SaveWinkeyerSettings(s WinkeyerSettings) error {
return err
}
}
// Apply line-control changes (PTT keying, key line, invert, lead/tail, speed)
// to a running serial keyer immediately — no reconnect needed. Harmless when
// the keyer isn't the serial type or isn't connected.
if a.winkeyer != nil && s.Engine == "serial" {
cfg := s.Config
cfg.Type = "serial"
a.winkeyer.ApplySerialConfig(cfg)
}
return nil
}
+14
View File
@@ -1,4 +1,18 @@
[
{
"version": "0.20.12",
"date": "2026-07-23",
"en": [
"Serial CW keyer: fixed the rig dropping to RX between words during a macro (PTT not held). On USB-serial interfaces like the Yaesu SCU-17 (CP210x), the previous method of driving the DTR/RTS lines would drop the held RTS (PTT) as soon as DTR (CW) toggled. OpsLog now drives the lines with the direct Win32 method N1MM/WSJT use (EscapeCommFunction), so RTS stays asserted for the whole transmission. 'Key PTT line' also defaults on for the Serial engine, and PTT / key-line / speed changes apply immediately without reconnecting the keyer.",
"New (Settings → General): show the ClubLog 'Most Wanted' rank in the entry-strip band matrix. When on, a 'MW #rank' pill appears next to the DXCC entity name (1 = the most wanted entity), coloured hotter the more wanted it is. The list is fetched from ClubLog and personalised to your callsign, refreshed daily.",
"Fixed the colour theme sometimes resetting to light after an update/relaunch: an update can clear the WebView's localStorage, and the fallback that restores the theme from the local settings database gave up after ~2.4s — occasionally too soon during the brief startup window before that store is ready. It now keeps retrying until the store actually answers, so a dark theme is reliably restored."
],
"fr": [
"Keyer CW série : correction de la radio qui retombait en RX entre les mots pendant une macro (PTT non tenu). Sur les interfaces USB-série comme le Yaesu SCU-17 (CP210x), l'ancienne méthode de pilotage des lignes DTR/RTS faisait retomber le RTS (PTT) dès que DTR (CW) basculait. OpsLog pilote maintenant les lignes avec la méthode Win32 directe qu'utilisent N1MM/WSJT (EscapeCommFunction), donc RTS reste asserté toute l'émission. « Key PTT line » est aussi activé par défaut pour le moteur Série, et les changements PTT / ligne / vitesse s'appliquent immédiatement sans reconnecter le keyer.",
"Nouveau (Réglages → Général) : afficher le rang « Most Wanted » de ClubLog dans la matrice de bandes de la barre de saisie. Activé, une pastille « MW #rang » apparaît à côté du nom de l'entité DXCC (1 = l'entité la plus recherchée), d'autant plus colorée qu'elle est recherchée. La liste est récupérée depuis ClubLog et personnalisée selon ton indicatif, rafraîchie quotidiennement.",
"Correction du thème de couleur qui revenait parfois au clair après une mise à jour/relancement : une mise à jour peut vider le localStorage de la WebView, et le repli qui restaure le thème depuis la base de réglages locale abandonnait après ~2,4s — parfois trop tôt pendant le court instant de démarrage avant que ce store soit prêt. Il réessaie maintenant jusqu'à ce que le store réponde vraiment, donc un thème sombre est restauré de façon fiable."
]
},
{
"version": "0.20.11",
"date": "2026-07-23",
+17
View File
@@ -94,6 +94,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
const dxcc = wb?.dxcc ?? 0;
const dxccName = wb?.dxcc_name ?? '';
const dxccCount = wb?.dxcc_count ?? 0;
const mwRank = wb?.mw_rank ?? 0; // ClubLog Most Wanted rank (1 = most wanted; 0 = off/unknown)
const callCount = wb?.count ?? 0; // QSOs with this exact callsign
const hasDxcc = dxcc > 0;
const newOne = hasDxcc && dxccCount === 0;
@@ -136,6 +137,20 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
const newMode = slotNew && bandWorked && !modeWorked;
const newSlot = slotNew && bandWorked && modeWorked;
// ClubLog Most Wanted rank pill (shown next to the entity name when the feature
// is on). Hotter colour the more wanted the entity is.
const mwBadge = mwRank > 0 ? (
<Badge
title={`ClubLog Most Wanted #${mwRank}`}
className={cn('px-1.5 py-0 text-[10px] font-bold shrink-0',
mwRank <= 100 ? 'bg-danger text-danger-foreground'
: mwRank <= 250 ? 'bg-warning text-warning-foreground'
: 'bg-muted text-muted-foreground')}
>
MW #{mwRank}
</Badge>
) : null;
return (
<section
className={cn(
@@ -154,12 +169,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
<strong className="text-warning-muted-foreground font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
{' '}· never worked this entity
</span>
{mwBadge}
</>
) : hasDxcc ? (
<>
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
{dxccName || `DXCC #${dxcc}`}
</Badge>
{mwBadge}
<span className="text-xs text-muted-foreground">
<strong className="text-foreground font-semibold">{dxccCount}</strong>{' '}
QSO{dxccCount > 1 ? 's' : ''} with this entity
+53 -1
View File
@@ -17,6 +17,7 @@ import {
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
GetClublogMostWantedInfo, SetClublogMostWantedEnabled, DownloadClublogMostWanted,
GetSecretStatus, SetPassphrase, RemovePassphrase,
GetEmailSettings, SaveEmailSettings, TestEmail,
QSLGetEmailTemplates, QSLSaveEmailTemplates,
@@ -1183,6 +1184,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [clubBusy, setClubBusy] = useState(false);
const [clubErr, setClubErr] = useState('');
useEffect(() => { GetClublogCtyInfo().then((i) => setClubInfo(i as ClubInfo)).catch(() => {}); }, []);
// ClubLog Most Wanted (rank shown in the entry matrix).
type MWInfo = { enabled: boolean; loaded: boolean; callsign: string; date: string; count: number };
const [mwInfo, setMwInfo] = useState<MWInfo>({ enabled: false, loaded: false, callsign: '', date: '', count: 0 });
const [mwBusy, setMwBusy] = useState(false);
const [mwErr, setMwErr] = useState('');
useEffect(() => { GetClublogMostWantedInfo().then((i) => setMwInfo(i as MWInfo)).catch(() => {}); }, []);
const reloadDvk = () => { GetDVKMessages().then((m) => setDvkMsgs((m ?? []) as DVKMsg[])).catch(() => {}); };
useEffect(() => {
GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
@@ -3020,7 +3027,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className="grid grid-cols-4 gap-3 items-end">
<div className="space-y-1">
<Label>Keyer engine</Label>
<Select value={wk.engine} onValueChange={(v) => setWkField({ engine: v })}>
{/* Picking the serial engine defaults PTT-keying ON: without RTS held,
the rig drops to RX between words (semi-break-in). */}
<Select value={wk.engine} onValueChange={(v) => setWkField(v === 'serial' ? { engine: v, use_ptt: true } : { engine: v })}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="winkeyer">WinKeyer (K1EL, serial)</SelectItem>
@@ -4781,6 +4790,49 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
{clubErr && <p className="text-[11px] text-destructive pl-6">{clubErr}</p>}
</div>
<div className="border-t border-border/60 pt-4 space-y-2">
<h4 className="text-sm font-semibold text-foreground">ClubLog Most Wanted</h4>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox
checked={mwInfo.enabled}
onCheckedChange={async (c) => {
const v = !!c; setMwInfo((s) => ({ ...s, enabled: v })); setMwErr('');
try {
await SetClublogMostWantedEnabled(v);
let info = (await GetClublogMostWantedInfo()) as MWInfo;
// First enable with no cached list → fetch it now (for the active call).
if (v && !info.loaded) {
setMwBusy(true);
try { info = (await DownloadClublogMostWanted()) as MWInfo; }
finally { setMwBusy(false); }
}
setMwInfo(info);
} catch (e: any) { setMwErr(String(e?.message ?? e)); }
}}
className="mt-0.5"
/>
<span>
Show ClubLog "Most Wanted" rank in the entry matrix
<span className="block text-xs text-muted-foreground mt-0.5">
Displays a <strong>MW #rank</strong> pill next to the entity name (1 = the most wanted DXCC entity), fetched
from ClubLog and <strong>personalised to your callsign</strong>. Refreshed daily.
</span>
</span>
</label>
<div className="flex items-center gap-3 pl-6">
<Button variant="outline" size="sm" className="h-8" disabled={mwBusy}
onClick={() => { setMwBusy(true); setMwErr(''); DownloadClublogMostWanted().then((i) => setMwInfo(i as MWInfo)).catch((e: any) => setMwErr(String(e?.message ?? e))).finally(() => setMwBusy(false)); }}>
{mwBusy ? 'Downloading' : (mwInfo.loaded ? 'Update Most Wanted' : 'Download Most Wanted')}
</Button>
<span className="text-[11px] text-muted-foreground">
{mwInfo.loaded
? `${mwInfo.count.toLocaleString()} entities${mwInfo.callsign ? ' · ' + mwInfo.callsign : ''}${mwInfo.date ? ' · ' + mwInfo.date : ''}`
: 'not downloaded'}
</span>
</div>
{mwErr && <p className="text-[11px] text-destructive pl-6">{mwErr}</p>}
</div>
</div>
</>
);
+11 -3
View File
@@ -73,16 +73,24 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const load = () => {
tries += 1;
GetUIPref(LS_KEY).then((raw) => {
// Backend ANSWERED (settings store ready). Apply a valid stored value; an
// empty/invalid one means the theme is genuinely unset → keep the default.
// Either way we're done — do NOT retry (retrying only matters while the
// backend is still starting, which now surfaces as a rejected promise).
if (cancelled || userPicked.current) return;
const v = raw as ThemeChoice;
if (v && ALL.includes(v)) {
try { localStorage.setItem(LS_KEY, v); } catch { /* quota */ }
applyThemeToDom(v); // idempotent — safe to call unconditionally
setThemeState(v);
return; // restored
}
if (tries < 8) window.setTimeout(load, 300); // empty (unset or backend not ready yet) → retry
}).catch(() => { if (!cancelled && tries < 8) window.setTimeout(load, 300); });
}).catch(() => {
// Settings store not ready yet — a brief startup window before the backend
// has opened the local DB and built the store (the frontend can query it
// first). Keep retrying well past the old 2.4s cap so a dark theme isn't
// lost to the light default after an update cleared localStorage.
if (!cancelled && !userPicked.current && tries < 120) window.setTimeout(load, 300);
});
};
load();
return () => { cancelled = true; };
+6
View File
@@ -162,6 +162,8 @@ export function DownloadAndApplyUpdate(arg1:string):Promise<void>;
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
export function DownloadClublogMostWanted():Promise<main.ClublogMostWantedInfo>;
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
export function DownloadLoTWUsers():Promise<number>;
@@ -374,6 +376,8 @@ export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
export function GetClublogMostWantedInfo():Promise<main.ClublogMostWantedInfo>;
export function GetClusterAutoConnect():Promise<boolean>;
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
@@ -856,6 +860,8 @@ export function SetCATMode(arg1:string):Promise<void>;
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
export function SetClublogMostWantedEnabled(arg1:boolean):Promise<void>;
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
export function SetCompactMode(arg1:boolean):Promise<void>;
+12
View File
@@ -278,6 +278,10 @@ export function DownloadClublogCty() {
return window['go']['main']['App']['DownloadClublogCty']();
}
export function DownloadClublogMostWanted() {
return window['go']['main']['App']['DownloadClublogMostWanted']();
}
export function DownloadConfirmations(arg1, arg2, arg3) {
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3);
}
@@ -702,6 +706,10 @@ export function GetClublogCtyInfo() {
return window['go']['main']['App']['GetClublogCtyInfo']();
}
export function GetClublogMostWantedInfo() {
return window['go']['main']['App']['GetClublogMostWantedInfo']();
}
export function GetClusterAutoConnect() {
return window['go']['main']['App']['GetClusterAutoConnect']();
}
@@ -1666,6 +1674,10 @@ export function SetClublogCtyEnabled(arg1) {
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
}
export function SetClublogMostWantedEnabled(arg1) {
return window['go']['main']['App']['SetClublogMostWantedEnabled'](arg1);
}
export function SetClusterAutoConnect(arg1) {
return window['go']['main']['App']['SetClusterAutoConnect'](arg1);
}
+22
View File
@@ -1949,6 +1949,26 @@ export namespace main {
this.count = source["count"];
}
}
export class ClublogMostWantedInfo {
enabled: boolean;
loaded: boolean;
callsign: string;
date: string;
count: number;
static createFrom(source: any = {}) {
return new ClublogMostWantedInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.loaded = source["loaded"];
this.callsign = source["callsign"];
this.date = source["date"];
this.count = source["count"];
}
}
export class ContestBandRow {
band: string;
count: number;
@@ -4225,6 +4245,7 @@ export namespace qso {
dxcc_bands: string[];
dxcc_modes: string[];
dxcc_band_modes: BandMode[];
mw_rank?: number;
band_status: BandStatus[];
static createFrom(source: any = {}) {
@@ -4249,6 +4270,7 @@ export namespace qso {
this.dxcc_bands = source["dxcc_bands"];
this.dxcc_modes = source["dxcc_modes"];
this.dxcc_band_modes = this.convertValues(source["dxcc_band_modes"], BandMode);
this.mw_rank = source["mw_rank"];
this.band_status = this.convertValues(source["band_status"], BandStatus);
}
+194
View File
@@ -0,0 +1,194 @@
package clublog
// mostwanted.go — the ClubLog "Most Wanted" DXCC ranking. ClubLog publishes, per
// callsign, how wanted each DXCC entity is (rank 1 = the single most wanted, e.g.
// P5 North Korea). We fetch it, invert it to a DXCC-number → rank map, cache it on
// disk (personalised to the operator's callsign) and refresh daily. The rank is
// surfaced next to the entity in the entry-strip band/slot matrix.
//
// Endpoint (same one DXHunter uses): mostwanted.php?api=1 returns JSON
// { "1": "344", "2": "123", ... } rank → ADIF (DXCC) number
// Passing &callsign=<CALL> personalises the ranking to that operator's log.
// api=1 is just the "return JSON" flag — no API key is needed here.
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
const mwURL = "https://clublog.org/mostwanted.php?api=1"
const mwFile = "clublog_mostwanted.json"
// mwUserAgent — ClubLog's nginx 403s the default Go user-agent, so set a real one.
const mwUserAgent = "OpsLog (amateur radio logger)"
// mwCache is the on-disk shape (ranks keyed by DXCC number as a string for JSON).
type mwCache struct {
Callsign string `json:"callsign"`
FetchedAt time.Time `json:"fetched_at"`
Ranks map[string]int `json:"ranks"`
}
// MostWanted owns the on-disk cache and the parsed DXCC-number → rank map.
type MostWanted struct {
dir string
mu sync.RWMutex
ranks map[int]int // dxcc number → rank (1 = most wanted)
call string // callsign the current ranks were fetched for
fetched time.Time
}
func NewMostWanted(dataDir string) *MostWanted {
return &MostWanted{dir: dataDir, ranks: map[int]int{}}
}
func (m *MostWanted) path() string { return filepath.Join(m.dir, mwFile) }
// Rank returns the most-wanted rank for a DXCC number, or 0 if unknown/unloaded.
func (m *MostWanted) Rank(dxcc int) int {
if dxcc <= 0 {
return 0
}
m.mu.RLock()
defer m.mu.RUnlock()
return m.ranks[dxcc]
}
// Ranks returns a copy of the whole DXCC-number → rank map.
func (m *MostWanted) Ranks() map[int]int {
m.mu.RLock()
defer m.mu.RUnlock()
out := make(map[int]int, len(m.ranks))
for k, v := range m.ranks {
out[k] = v
}
return out
}
// Info reports the callsign the list was fetched for, its date, and entity count.
func (m *MostWanted) Info() (call, date string, count int) {
m.mu.RLock()
defer m.mu.RUnlock()
d := ""
if !m.fetched.IsZero() {
d = m.fetched.Format("2006-01-02")
}
return m.call, d, len(m.ranks)
}
func (m *MostWanted) Loaded() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.ranks) > 0
}
// LoadFromDisk reads the cached JSON into memory. Does NOT download.
func (m *MostWanted) LoadFromDisk() error {
b, err := os.ReadFile(m.path())
if err != nil {
return err
}
var c mwCache
if err := json.Unmarshal(b, &c); err != nil {
return err
}
ranks := make(map[int]int, len(c.Ranks))
for k, v := range c.Ranks {
if n, err := strconv.Atoi(k); err == nil && n > 0 && v > 0 {
ranks[n] = v
}
}
m.mu.Lock()
m.ranks = ranks
m.call = c.Callsign
m.fetched = c.FetchedAt
m.mu.Unlock()
return nil
}
// NeedsRefresh reports whether the list is empty, older than maxAge, or was
// fetched for a DIFFERENT callsign (the ranking is personalised, so a profile
// switch invalidates it).
func (m *MostWanted) NeedsRefresh(callsign string, maxAge time.Duration) bool {
m.mu.RLock()
defer m.mu.RUnlock()
if len(m.ranks) == 0 {
return true
}
if !strings.EqualFold(strings.TrimSpace(callsign), m.call) {
return true
}
return time.Since(m.fetched) > maxAge
}
// Download fetches the most-wanted list for callsign, writes it atomically, and
// swaps it into memory.
func (m *MostWanted) Download(ctx context.Context, callsign string) error {
if err := os.MkdirAll(m.dir, 0o755); err != nil {
return err
}
call := strings.ToUpper(strings.TrimSpace(callsign))
url := mwURL
if call != "" {
url += "&callsign=" + call
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", mwUserAgent)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("clublog mostwanted HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return err
}
// { "1": "344", "2": "123", ... } — rank → ADIF (DXCC) number.
var raw map[string]string
if err := json.Unmarshal(body, &raw); err != nil {
return fmt.Errorf("clublog mostwanted parse: %w", err)
}
ranks := make(map[int]int, len(raw))
strRanks := make(map[string]int, len(raw))
for rankStr, adif := range raw {
rank, e1 := strconv.Atoi(strings.TrimSpace(rankStr))
dxcc, e2 := strconv.Atoi(strings.TrimSpace(adif))
if e1 == nil && e2 == nil && rank > 0 && dxcc > 0 {
ranks[dxcc] = rank
strRanks[adif] = rank
}
}
if len(ranks) == 0 {
return fmt.Errorf("clublog mostwanted: empty/invalid response")
}
now := time.Now()
if b, err := json.Marshal(mwCache{Callsign: call, FetchedAt: now, Ranks: strRanks}); err == nil {
tmp := m.path() + ".tmp"
if os.WriteFile(tmp, b, 0o644) == nil {
_ = os.Rename(tmp, m.path())
}
}
m.mu.Lock()
m.ranks = ranks
m.call = call
m.fetched = now
m.mu.Unlock()
return nil
}
+4
View File
@@ -1370,6 +1370,10 @@ type WorkedBefore struct {
DXCCModes []string `json:"dxcc_modes"`
DXCCBandModes []BandMode `json:"dxcc_band_modes"`
// MWRank is the ClubLog "Most Wanted" rank for this entity (1 = most wanted),
// 0 when unknown or the feature is off. Populated by the app layer, not here.
MWRank int `json:"mw_rank,omitempty"`
// Status grid driving the band×class matrix in the UI. One entry per
// (band, class) where ANY QSO exists in this DXCC. Only the highest
// status for that cell is kept (call_c > call_w > dxcc_c > dxcc_w).
+37
View File
@@ -0,0 +1,37 @@
//go:build !windows
package winkeyer
// linectl_other.go — non-Windows DTR/RTS line control via go.bug.st/serial. On
// Linux/macOS the SetDTR/SetRTS ioctls control the lines independently, so the
// CP210x-on-Windows problem that motivated the native path (see linectl_windows.go)
// doesn't apply. OpsLog ships on Windows; this keeps the package building elsewhere.
import (
"fmt"
"strings"
"go.bug.st/serial"
)
type serialLineCtl struct {
port serial.Port
}
func openLineCtl(port string) (lineCtl, error) {
if strings.TrimSpace(port) == "" {
return nil, fmt.Errorf("no serial port")
}
p, err := serial.Open(port, &serial.Mode{BaudRate: 9600, DataBits: 8, Parity: serial.NoParity, StopBits: serial.OneStopBit})
if err != nil {
return nil, fmt.Errorf("open %s: %w", port, err)
}
c := &serialLineCtl{port: p}
_ = c.setDTR(false)
_ = c.setRTS(false)
return c, nil
}
func (c *serialLineCtl) setDTR(on bool) error { return c.port.SetDTR(on) }
func (c *serialLineCtl) setRTS(on bool) error { return c.port.SetRTS(on) }
func (c *serialLineCtl) close() error { return c.port.Close() }
+81
View File
@@ -0,0 +1,81 @@
//go:build windows
package winkeyer
// linectl_windows.go — DTR/RTS line control via EscapeCommFunction, the direct
// Win32 method N1MM/WSJT/fldigi use. It sets ONE line at a time without a
// GetCommState/SetCommState round-trip, so a held RTS (PTT) is NOT disturbed when
// DTR (CW) is toggled. go.bug.st/serial drives the lines via SetCommState, which
// on USB-serial adapters (the SCU-17's CP210x, FTDI) drops the held RTS the moment
// DTR changes — the "rig drops to RX between words" bug this fixes.
import (
"fmt"
"strings"
"golang.org/x/sys/windows"
)
type winLineCtl struct {
h windows.Handle
}
// openLineCtl opens a COM port for line control only (no data I/O).
func openLineCtl(port string) (lineCtl, error) {
name := strings.TrimSpace(port)
if name == "" {
return nil, fmt.Errorf("no serial port")
}
// The \\.\ prefix is required for COM10+ and harmless for COM1-9.
if !strings.HasPrefix(name, `\\.\`) {
name = `\\.\` + name
}
p, err := windows.UTF16PtrFromString(name)
if err != nil {
return nil, err
}
h, err := windows.CreateFile(p,
windows.GENERIC_READ|windows.GENERIC_WRITE, 0, nil,
windows.OPEN_EXISTING, 0, 0)
if err != nil {
return nil, fmt.Errorf("open %s: %w", port, err)
}
// A minimal DCB so the driver is in a sane state. Baud is irrelevant for pure
// line control, but leaving fDtr/fRtsControl at DISABLE means only our explicit
// EscapeCommFunction calls drive the lines.
var dcb windows.DCB
if windows.GetCommState(h, &dcb) == nil {
dcb.BaudRate = 9600
dcb.ByteSize = 8
dcb.Parity = 0 // NOPARITY
dcb.StopBits = 0 // ONESTOPBIT
// Clear DTR (bits 4-5) and RTS (bits 12-13) control fields → DISABLE, so the
// lines idle low until we assert them, and no hardware flow control fights us.
dcb.Flags &^= 0x00000030
dcb.Flags &^= 0x00003000
_ = windows.SetCommState(h, &dcb)
}
c := &winLineCtl{h: h}
// Start both lines low (inactive) — the keyer's idle() will do this too.
_ = c.setDTR(false)
_ = c.setRTS(false)
return c, nil
}
func (c *winLineCtl) setDTR(on bool) error {
f := uint32(windows.CLRDTR)
if on {
f = windows.SETDTR
}
return windows.EscapeCommFunction(c.h, f)
}
func (c *winLineCtl) setRTS(on bool) error {
f := uint32(windows.CLRRTS)
if on {
f = windows.SETRTS
}
return windows.EscapeCommFunction(c.h, f)
}
func (c *winLineCtl) close() error { return windows.CloseHandle(c.h) }
+65 -27
View File
@@ -14,11 +14,24 @@ package winkeyer
import (
"strings"
"sync"
"sync/atomic"
"time"
"go.bug.st/serial"
"hamlog/internal/applog"
)
// lineCtl drives a serial port's DTR and RTS control lines. On Windows it uses
// EscapeCommFunction (SETDTR/CLRDTR/SETRTS/CLRRTS) — the direct method N1MM/WSJT
// use, reliable on USB-serial adapters (CP210x/FTDI). go.bug.st/serial drives the
// lines via SetCommState instead, which on a CP210x drops the held RTS (PTT) as
// soon as DTR (CW) is toggled — the "rig drops to RX between words" bug. See
// linectl_windows.go / linectl_other.go.
type lineCtl interface {
setDTR(on bool) error
setRTS(on bool) error
close() error
}
// morseTable maps a keyable character to its Morse element string (. = dit,
// - = dah). Mirrors winkeyer.allowedCW.
var morseTable = map[rune]string{
@@ -35,14 +48,16 @@ var morseTable = map[rune]string{
}
// serialKeyer bit-bangs CW on a serial control line. Owned by a winkeyer.Manager
// while Type == "serial"; all keying happens in run().
// while Type == "serial"; all keying happens in run(). The line-control options
// (key line, invert, PTT, lead/tail) are atomic so ApplyConfig can update them
// LIVE — e.g. ticking "Key PTT line" takes effect without reconnecting the keyer.
type serialKeyer struct {
port serial.Port
keyDTR bool // true: CW on DTR, PTT on RTS (N1MM default). false: swapped.
invert bool // true: active-LOW — asserting a line means driving it LOW
usePTT bool
leadMs int
tailMs int
line lineCtl
keyDTR atomic.Bool // true: CW on DTR, PTT on RTS (N1MM default). false: swapped.
invert atomic.Bool // true: active-LOW — asserting a line means driving it LOW
usePTT atomic.Bool // hold the PTT line for the whole transmission
leadMs atomic.Int32 // PTT lead-in
tailMs atomic.Int32 // PTT hold (hang) after the last element
onBusy func(bool)
mu sync.Mutex
@@ -55,14 +70,9 @@ type serialKeyer struct {
done chan struct{}
}
func newSerialKeyer(port serial.Port, cfg Config, onBusy func(bool)) *serialKeyer {
func newSerialKeyer(line lineCtl, cfg Config, onBusy func(bool)) *serialKeyer {
k := &serialKeyer{
port: port,
keyDTR: !strings.EqualFold(strings.TrimSpace(cfg.CWKeyLine), "rts"), // default DTR=CW
invert: cfg.CWInvert,
usePTT: cfg.UsePTT,
leadMs: cfg.LeadInMs,
tailMs: cfg.TailMs,
line: line,
onBusy: onBusy,
wpm: cfg.WPM,
farns: cfg.Farnsworth,
@@ -71,39 +81,60 @@ func newSerialKeyer(port serial.Port, cfg Config, onBusy func(bool)) *serialKeye
stop: make(chan struct{}),
done: make(chan struct{}),
}
k.keyDTR.Store(!strings.EqualFold(strings.TrimSpace(cfg.CWKeyLine), "rts")) // default DTR=CW
k.invert.Store(cfg.CWInvert)
k.usePTT.Store(cfg.UsePTT)
k.leadMs.Store(int32(cfg.LeadInMs))
k.tailMs.Store(int32(cfg.TailMs))
k.idle() // start inactive: key up, PTT off
go k.run()
return k
}
// ApplyConfig live-updates the line-control options (no port reopen), so a
// settings change is felt from the next character without a reconnect. Speed and
// Farnsworth are updated too. keyText re-reads these per element.
func (k *serialKeyer) ApplyConfig(cfg Config) {
k.keyDTR.Store(!strings.EqualFold(strings.TrimSpace(cfg.CWKeyLine), "rts"))
k.invert.Store(cfg.CWInvert)
k.usePTT.Store(cfg.UsePTT)
k.leadMs.Store(int32(cfg.LeadInMs))
k.tailMs.Store(int32(cfg.TailMs))
k.mu.Lock()
k.wpm = cfg.WPM
k.farns = cfg.Farnsworth
k.mu.Unlock()
k.idle() // re-assert idle lines with any new polarity/key-line choice
}
// level maps a logical "active" state to the physical line level, honouring the
// invert (active-LOW) option: normally active = HIGH, inverted active = LOW.
func (k *serialKeyer) level(active bool) bool { return active != k.invert }
func (k *serialKeyer) level(active bool) bool { return active != k.invert.Load() }
// setKey raises/lowers the CW key line; setPTT the PTT line (only when enabled).
func (k *serialKeyer) setKey(down bool) {
if k.keyDTR {
_ = k.port.SetDTR(k.level(down))
if k.keyDTR.Load() {
_ = k.line.setDTR(k.level(down))
} else {
_ = k.port.SetRTS(k.level(down))
_ = k.line.setRTS(k.level(down))
}
}
func (k *serialKeyer) setPTT(on bool) {
if !k.usePTT {
if !k.usePTT.Load() {
return
}
if k.keyDTR {
_ = k.port.SetRTS(k.level(on))
if k.keyDTR.Load() {
_ = k.line.setRTS(k.level(on))
} else {
_ = k.port.SetDTR(k.level(on))
_ = k.line.setDTR(k.level(on))
}
}
// idle drives BOTH lines to their inactive level (key up, PTT off), respecting
// the invert option — so an active-LOW interface isn't left keyed at rest.
func (k *serialKeyer) idle() {
_ = k.port.SetDTR(k.level(false))
_ = k.port.SetRTS(k.level(false))
_ = k.line.setDTR(k.level(false))
_ = k.line.setRTS(k.level(false))
}
// SetSpeed / Send / Stop mirror the WinKeyer manager's control surface.
@@ -139,6 +170,7 @@ func (k *serialKeyer) Stop() {
func (k *serialKeyer) Close() {
close(k.stop)
<-k.done
_ = k.line.close()
}
func (k *serialKeyer) run() {
@@ -150,10 +182,16 @@ func (k *serialKeyer) run() {
return
case s := <-k.in:
k.onBusy(true)
// Diagnostic: confirms whether PTT is actually held for the whole macro
// (a rig dropping to RX between words = usePTT off, or the interface's
// PTT line isn't wired / honoured in CW).
applog.Printf("winkeyer serial: TX %.40q ptt=%v line=%s lead=%dms tail=%dms",
s, k.usePTT.Load(), map[bool]string{true: "DTR-CW/RTS-PTT", false: "RTS-CW/DTR-PTT"}[k.keyDTR.Load()],
k.leadMs.Load(), k.tailMs.Load())
k.setPTT(true)
k.sleep(time.Duration(k.leadMs) * time.Millisecond)
k.sleep(time.Duration(k.leadMs.Load()) * time.Millisecond)
k.keyText(s)
hang := time.Duration(k.tailMs) * time.Millisecond
hang := time.Duration(k.tailMs.Load()) * time.Millisecond
if hang <= 0 {
hang = 5 * time.Millisecond
}
+23 -11
View File
@@ -189,22 +189,34 @@ func (m *Manager) Connect(cfg Config) error {
return nil
}
// ApplySerialConfig live-updates a running serial-line keyer's control options
// (PTT keying, key line, invert, lead/tail, speed) WITHOUT reopening the port —
// so ticking "Key PTT line" (or changing the key line) takes effect from the next
// character, no reconnect needed. No-op unless a serial keyer is running.
func (m *Manager) ApplySerialConfig(cfg Config) {
cfg = cfg.normalised()
m.mu.Lock()
sk := m.serial
m.cfg = cfg
m.mu.Unlock()
if sk != nil {
sk.ApplyConfig(cfg)
}
}
// connectSerial opens the port for line-keying (DTR=CW / RTS=PTT) — no K1EL
// handshake, no read loop. The PC bit-bangs the Morse itself (see serialcw.go).
func (m *Manager) connectSerial(cfg Config) error {
m.Disconnect() // drop any existing link first
p, err := serial.Open(cfg.Port, &serial.Mode{
BaudRate: cfg.Baud,
DataBits: 8,
Parity: serial.NoParity,
StopBits: serial.OneStopBit,
})
// Open for line control only (DTR/RTS). On Windows this uses EscapeCommFunction
// so a held RTS (PTT) survives DTR (CW) toggling on USB adapters — see linectl_*.
line, err := openLineCtl(cfg.Port)
if err != nil {
return fmt.Errorf("winkeyer: open %s: %w", cfg.Port, err)
}
// The keyer updates busy state as it keys; mirror it into status + notify.
sk := newSerialKeyer(p, cfg, func(busy bool) {
sk := newSerialKeyer(line, cfg, func(busy bool) {
m.mu.Lock()
changed := m.status.Busy != busy
m.status.Busy = busy
@@ -215,17 +227,17 @@ func (m *Manager) connectSerial(cfg Config) error {
})
m.mu.Lock()
m.port = p
m.port = nil // serial keyer owns its own (line-only) port, not m.port
m.serial = sk
m.cfg = cfg
m.status = Status{Connected: true, WPM: cfg.WPM, Port: cfg.Port}
m.mu.Unlock()
line := "DTR (CW) / RTS (PTT)"
lineDesc := "DTR (CW) / RTS (PTT)"
if strings.EqualFold(cfg.CWKeyLine, "rts") {
line = "RTS (CW) / DTR (PTT)"
lineDesc = "RTS (CW) / DTR (PTT)"
}
applog.Printf("winkeyer: serial CW keyer on %s — %s, PTT=%v", cfg.Port, line, cfg.UsePTT)
applog.Printf("winkeyer: serial CW keyer on %s — %s, PTT=%v (EscapeCommFunction line ctl)", cfg.Port, lineDesc, cfg.UsePTT)
m.emit()
return nil
}