Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa871a07b7 | ||
|
|
89584f173d | ||
|
|
a71e48f811 | ||
|
|
5c4f101402 | ||
|
|
ede9461010 | ||
|
|
e6ca7a8bdd | ||
|
|
9be3f9147b | ||
|
|
f7f801cdb7 | ||
|
|
43f15f1a2c | ||
|
|
2c5416500f | ||
|
|
fcdc5809e6 | ||
|
|
7254950162 | ||
|
|
1668455ff4 |
@@ -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"
|
||||
@@ -214,9 +215,11 @@ const (
|
||||
keyWKUsePTT = "winkeyer.use_ptt"
|
||||
keyWKSerialEcho = "winkeyer.serial_echo"
|
||||
keyWKMacros = "winkeyer.macros" // JSON array of {label,text}
|
||||
keyWKEngine = "winkeyer.engine" // "winkeyer" | "icom" | "tci"
|
||||
keyWKEngine = "winkeyer.engine" // "winkeyer" | "serial" | "icom" | "flex" | "tci"
|
||||
keyWKEscClears = "winkeyer.esc_clears_call" // ESC also clears the callsign
|
||||
keyWKSendOnType = "winkeyer.send_on_type" // key characters live as typed
|
||||
keyWKCWLine = "winkeyer.cw_key_line" // serial engine: "dtr" (CW) / "rts" (PTT) or swapped
|
||||
keyWKCWInvert = "winkeyer.cw_invert" // serial engine: invert line polarity (active-LOW)
|
||||
|
||||
keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start
|
||||
|
||||
@@ -462,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
|
||||
@@ -952,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...) })
|
||||
@@ -1833,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)
|
||||
}
|
||||
@@ -5238,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
|
||||
@@ -7318,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).
|
||||
@@ -13052,13 +13161,17 @@ func (a *App) GetWinkeyerSettings() (WinkeyerSettings, error) {
|
||||
keyWKEnabled, keyWKPort, keyWKBaud, keyWKWPM, keyWKWeight, keyWKLeadIn,
|
||||
keyWKTail, keyWKRatio, keyWKFarnsworth, keyWKSidetone, keyWKMode,
|
||||
keyWKSwap, keyWKAutoSpace, keyWKUsePTT, keyWKSerialEcho, keyWKMacros,
|
||||
keyWKEngine, keyWKEscClears, keyWKSendOnType)
|
||||
keyWKEngine, keyWKEscClears, keyWKSendOnType, keyWKCWLine, keyWKCWInvert)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if v := m[keyWKEngine]; v != "" {
|
||||
out.Engine = v
|
||||
}
|
||||
if v := m[keyWKCWLine]; v != "" {
|
||||
out.CWKeyLine = v
|
||||
}
|
||||
out.CWInvert = m[keyWKCWInvert] == "1"
|
||||
if v := m[keyWKEscClears]; v != "" {
|
||||
out.EscClearsCall = v == "1"
|
||||
}
|
||||
@@ -13125,11 +13238,21 @@ func (a *App) SaveWinkeyerSettings(s WinkeyerSettings) error {
|
||||
keyWKEngine: strings.TrimSpace(s.Engine),
|
||||
keyWKEscClears: boolStr(s.EscClearsCall),
|
||||
keyWKSendOnType: boolStr(s.SendOnType),
|
||||
keyWKCWLine: strings.TrimSpace(s.CWKeyLine),
|
||||
keyWKCWInvert: boolStr(s.CWInvert),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -13142,7 +13265,13 @@ func (a *App) WinkeyerConnect() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.winkeyer.Connect(s.Config)
|
||||
cfg := s.Config
|
||||
// The "serial" engine keys CW on the port's DTR/RTS lines (SCU-17 style)
|
||||
// instead of talking the K1EL WinKeyer protocol — flag it for the manager.
|
||||
if s.Engine == "serial" {
|
||||
cfg.Type = "serial"
|
||||
}
|
||||
return a.winkeyer.Connect(cfg)
|
||||
}
|
||||
|
||||
// WinkeyerDisconnect closes the serial link.
|
||||
|
||||
@@ -1,4 +1,36 @@
|
||||
[
|
||||
{
|
||||
"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",
|
||||
"en": [
|
||||
"Fixed clicking a spot / band-map entry not moving the radio on Yaesu (and other non-Icom) rigs controlled through OmniRig — e.g. the FT-891. OpsLog tuned via OmniRig's SetSimplexMode, which on these rigs returns success but silently doesn't move the VFO (mode changes worked, so it looked like a puzzle). It now also writes the VFO frequency property directly, which does move them. Icom rigs are unchanged (there SetSimplexMode is the reliable path).",
|
||||
"New CW keyer engine: 'Serial port (DTR=CW / RTS=PTT)'. OpsLog can now key CW by toggling a serial control line itself — the hardware-keying method N1MM/WSJT use with a Yaesu SCU-17 and generic keying interfaces — without a K1EL WinKeyer. Settings → CW Keyer → Keyer engine: pick 'Serial port', choose the keying COM port and whether CW is on DTR (PTT on RTS) or swapped. Speed, Farnsworth, lead-in/tail and PTT keying all apply; macros, auto-CQ and <LOGQSO> work exactly as with the WinKeyer. An 'Invert keying (active-LOW)' option is there for interfaces that key backwards / transmit at rest.",
|
||||
"Fixed the spectrum scope never displaying on the IC-7300: its CI-V waveform frames actually carry the same leading main-scope selector byte as the dual-scope IC-7610/9700, but OpsLog assumed the 7300 omitted it — so it read the frame sequence number from the wrong byte (always 0), discarded every frame, and drew nothing. The frame layout is now detected from the data itself, so the 7300 (and other single-scope Icoms) render correctly, in both center and fixed modes. The IC-7610/9700 are unaffected.",
|
||||
"IC-7300 scope: the center/fixed mode and edge-frequency commands now include the selector byte the rig requires, so switching the scope between center-on-VFO and fixed span from OpsLog is accepted instead of being rejected.",
|
||||
"Closing OpsLog no longer switches off the scope display on the radio itself: turning the scope off used to send both 'stop CI-V data' and 'display off', so quitting blanked a local IC-7300's own scope screen. It now only stops the CI-V data stream on disable and never touches the radio's display (the display-on command is sent solely when enabling)."
|
||||
],
|
||||
"fr": [
|
||||
"Correction : cliquer un spot / une entrée de band-map ne changeait pas la fréquence sur les Yaesu (et autres radios non-Icom) pilotées via OmniRig — p. ex. le FT-891. OpsLog accordait via SetSimplexMode d'OmniRig, qui sur ces radios renvoie « OK » mais ne bouge pas le VFO (le changement de mode marchait, d'où l'énigme). Il écrit maintenant aussi directement la propriété fréquence du VFO, ce qui les fait bien QSY. Les Icom sont inchangés (là, SetSimplexMode reste la bonne méthode).",
|
||||
"Nouveau moteur de keyer CW : « Port série (DTR=CW / RTS=PTT) ». OpsLog peut désormais manipuler la CW en basculant lui-même une ligne de contrôle série — la méthode de keying matériel qu'utilisent N1MM/WSJT avec un Yaesu SCU-17 et les interfaces génériques — sans WinKeyer K1EL. Réglages → Keyer CW → Moteur : choisis « Port série », le port COM de keying et si la CW est sur DTR (PTT sur RTS) ou l'inverse. Vitesse, Farnsworth, lead-in/tail et PTT s'appliquent ; les macros, l'auto-CQ et <LOGQSO> fonctionnent exactement comme avec le WinKeyer. Une option « Inverser le keying (actif-BAS) » est prévue pour les interfaces qui manipulent à l'envers / émettent au repos.",
|
||||
"Correction du scope spectral qui ne s'affichait jamais sur l'IC-7300 : ses trames de forme d'onde CI-V portent en réalité le même octet sélecteur de scope en tête que les IC-7610/9700 (dual-scope), mais OpsLog supposait que le 7300 ne l'envoyait pas — il lisait donc le numéro de séquence de la trame sur le mauvais octet (toujours 0), jetait toutes les trames et ne dessinait rien. La disposition des trames est maintenant détectée à partir des données elles-mêmes, donc le 7300 (et les autres Icom mono-scope) s'affichent correctement, en mode centre comme en fixe. Les IC-7610/9700 ne sont pas affectés.",
|
||||
"Scope IC-7300 : les commandes de mode centre/fixe et de fréquences de bords incluent maintenant l'octet sélecteur requis par la radio, donc basculer le scope entre centre-sur-VFO et span fixe depuis OpsLog est accepté au lieu d'être rejeté.",
|
||||
"Fermer OpsLog n'éteint plus le scope sur la radio elle-même : couper le scope envoyait à la fois « stop données CI-V » et « affichage OFF », donc quitter éteignait l'écran scope d'un IC-7300 local. Désormais seule la diffusion des données CI-V est coupée à la désactivation, l'affichage de la radio n'est jamais touché (la commande d'allumage n'est envoyée qu'à l'activation)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.20.10",
|
||||
"date": "2026-07-23",
|
||||
|
||||
@@ -2130,7 +2130,7 @@ export default function App() {
|
||||
setWkMacros((s.macros ?? []) as WKMacro[]);
|
||||
setWkEscClears(s.esc_clears_call !== false);
|
||||
setWkSendOnType(!!s.send_on_type);
|
||||
setWkEngine(s.engine === 'icom' ? 'icom' : s.engine === 'flex' ? 'flex' : 'winkeyer');
|
||||
setWkEngine(s.engine === 'icom' ? 'icom' : s.engine === 'flex' ? 'flex' : s.engine === 'serial' ? 'serial' : 'winkeyer');
|
||||
} catch { /* keyer not configured */ }
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -1074,13 +1075,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
port: string; baud: number; wpm: number; weight: number;
|
||||
lead_in_ms: number; tail_ms: number; ratio: number; farnsworth: number;
|
||||
sidetone_hz: number; mode: string; swap: boolean; autospace: boolean;
|
||||
use_ptt: boolean; serial_echo: boolean; macros: WKMac[];
|
||||
use_ptt: boolean; serial_echo: boolean; cw_key_line: string; cw_invert: boolean; macros: WKMac[];
|
||||
};
|
||||
const [wk, setWk] = useState<WKSettings>({
|
||||
enabled: false, engine: 'winkeyer', esc_clears_call: true,
|
||||
port: '', baud: 1200, wpm: 25, weight: 50, lead_in_ms: 10,
|
||||
tail_ms: 50, ratio: 50, farnsworth: 0, sidetone_hz: 600, mode: 'iambic_b',
|
||||
swap: false, autospace: true, use_ptt: false, serial_echo: true, macros: [],
|
||||
swap: false, autospace: true, use_ptt: false, serial_echo: true, cw_key_line: 'dtr', cw_invert: false, macros: [],
|
||||
});
|
||||
const [wkPorts, setWkPorts] = useState<string[]>([]);
|
||||
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
|
||||
@@ -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,10 +3027,13 @@ 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 (serial)</SelectItem>
|
||||
<SelectItem value="winkeyer">WinKeyer (K1EL, serial)</SelectItem>
|
||||
<SelectItem value="serial">Serial port (DTR=CW / RTS=PTT)</SelectItem>
|
||||
<SelectItem value="icom">Icom CI-V (rig keyer)</SelectItem>
|
||||
<SelectItem value="flex">FlexRadio (CWX)</SelectItem>
|
||||
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
|
||||
@@ -3076,6 +3086,65 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : wk.engine === 'serial' ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
OpsLog keys the Morse itself by toggling a serial control line — the "hardware CW keying" a Yaesu <strong>SCU-17</strong> and generic interfaces use (like N1MM: DTR = CW, RTS = PTT). No WinKeyer chip. Put the rig in CW mode and pick the interface's <strong>keying COM port</strong> below (this is the CW/PTT port, <em>not</em> the CAT port). Speed, Farnsworth, lead-in/tail and PTT apply; paddle mode, ratio and sidetone are WinKeyer-only.
|
||||
</p>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>Keying COM port</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={wk.port || '_'} onValueChange={(v) => setWkField({ port: v === '_' ? '' : v })}>
|
||||
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder="— COM port —" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2" title="Reload ports"
|
||||
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
<ArrowDown className="size-3.5 rotate-90" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>CW key line</Label>
|
||||
<Select value={wk.cw_key_line === 'rts' ? 'rts' : 'dtr'} onValueChange={(v) => setWkField({ cw_key_line: v })}>
|
||||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="dtr">DTR = CW, RTS = PTT</SelectItem>
|
||||
<SelectItem value="rts">RTS = CW, DTR = PTT</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Speed (WPM)</Label>
|
||||
<Input type="number" min={5} max={99} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Farnsworth</Label>
|
||||
<Input type="number" min={0} max={99} value={wk.farnsworth} onChange={(e) => setWkField({ farnsworth: num(e.target.value, 0) })} className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Lead-in (ms)</Label>
|
||||
<Input type="number" min={0} value={wk.lead_in_ms} onChange={(e) => setWkField({ lead_in_ms: num(e.target.value, 10) })} className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Tail (ms)</Label>
|
||||
<Input type="number" min={0} value={wk.tail_ms} onChange={(e) => setWkField({ tail_ms: num(e.target.value, 50) })} className="font-mono" />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer pb-1.5 self-end">
|
||||
<Checkbox checked={wk.use_ptt} onCheckedChange={(c) => setWkField({ use_ptt: !!c })} /> Key PTT line
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={wk.cw_invert} onCheckedChange={(c) => setWkField({ cw_invert: !!c })} />
|
||||
Invert keying (active-LOW) — tick this only if the rig transmits at rest / keys backwards
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
@@ -4721,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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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; };
|
||||
|
||||
@@ -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.20.10';
|
||||
export const APP_VERSION = '0.20.11';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+6
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -3035,6 +3055,9 @@ export namespace main {
|
||||
autospace: boolean;
|
||||
use_ptt: boolean;
|
||||
serial_echo: boolean;
|
||||
type: string;
|
||||
cw_key_line: string;
|
||||
cw_invert: boolean;
|
||||
engine: string;
|
||||
esc_clears_call: boolean;
|
||||
send_on_type: boolean;
|
||||
@@ -3061,6 +3084,9 @@ export namespace main {
|
||||
this.autospace = source["autospace"];
|
||||
this.use_ptt = source["use_ptt"];
|
||||
this.serial_echo = source["serial_echo"];
|
||||
this.type = source["type"];
|
||||
this.cw_key_line = source["cw_key_line"];
|
||||
this.cw_invert = source["cw_invert"];
|
||||
this.engine = source["engine"];
|
||||
this.esc_clears_call = source["esc_clears_call"];
|
||||
this.send_on_type = source["send_on_type"];
|
||||
@@ -4219,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 = {}) {
|
||||
@@ -4243,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);
|
||||
}
|
||||
|
||||
|
||||
+33
-15
@@ -224,11 +224,14 @@ func (b *IcomSerial) Connect() error {
|
||||
idAddr = f.Data[1]
|
||||
}
|
||||
}
|
||||
// Dual-scope rigs (IC-7610/9700) prefix each waveform frame with a main/sub
|
||||
// selector byte; single-scope rigs (IC-7300…) do not. Decide from the
|
||||
// IDENTIFIED model, NOT the configured address: an IC-7300 run at 0x98 must
|
||||
// still parse single-scope frames (this was the "scope blank on the 7300" bug).
|
||||
b.dualScope = idAddr == 0x98 || idAddr == 0xA2
|
||||
// Whether the rig's scope protocol carries a leading main/sub SELECTOR byte on
|
||||
// every 0x27 frame and command. The IC-7610/9700 (dual scope) do — and the logs
|
||||
// prove the IC-7300 does too (frames arrive as `27 00 00 <seq> <total> …` and a
|
||||
// mode read answers `27 14 00 <mode>`), even though it has a single scope. The
|
||||
// waveform parser auto-detects this per-frame regardless, so a 7300 at a
|
||||
// non-default address still RENDERS; this flag only drives the SET/read commands
|
||||
// (mode, span, edges), which need the 0x00 selector to be accepted on the 7300.
|
||||
b.dualScope = idAddr == 0x98 || idAddr == 0xA2 || idAddr == 0x94
|
||||
// Defer the DSP snapshot until the rig actually answers CI-V. Over the network
|
||||
// the rig may still be booting (or off) at Connect, so an immediate readDSP
|
||||
// would time out and leave every control at 0 / off with no retry. ReadState
|
||||
@@ -603,12 +606,20 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
|
||||
rawN++
|
||||
applog.Printf("icom scope raw #%d: len=%d data=[% X]", rawN, len(f.Data), f.Data)
|
||||
}
|
||||
// Frame layout after the 0x00 sub-command byte is one of:
|
||||
// [selector][seq][total][data…] (IC-7610/9700 AND, as the logs prove,
|
||||
// the IC-7300 — selector 0x00 = main)
|
||||
// [seq][total][data…] (a rig with no selector byte)
|
||||
// The sequence starts at 1, so a 0x00 at Data[1] can only be the main-scope
|
||||
// selector — never a valid sequence. Keying this off the rig ADDRESS was the
|
||||
// long-standing "blank scope on the IC-7300" bug: the 7300 DOES send the
|
||||
// selector, so idx stayed 1, seq was read as 0x00, and every frame was
|
||||
// dropped. Detect it from the byte itself instead — address-independent.
|
||||
idx := 1
|
||||
if b.dualScope {
|
||||
if len(f.Data) < 2 || f.Data[1] != 0x00 {
|
||||
continue // only the MAIN scope
|
||||
}
|
||||
idx = 2
|
||||
if len(f.Data) >= 2 && f.Data[1] == 0x00 {
|
||||
idx = 2 // leading main-scope selector byte present
|
||||
} else if b.dualScope {
|
||||
continue // dual-scope rig SUB frame (selector != 0x00) — main only
|
||||
}
|
||||
if len(f.Data) < idx+2 {
|
||||
continue
|
||||
@@ -725,12 +736,19 @@ func (b *IcomSerial) SetScope(on bool) error {
|
||||
// but nothing shows, compare its `icom scope raw` frames against this.
|
||||
applog.Printf("icom scope: enable on rig=%q addr=0x%02X dualScope=%v (expect %s frame layout)",
|
||||
b.model, b.rigAddr, b.dualScope, map[bool]string{true: "27 00 [MS] [seq] [total] …", false: "27 00 [seq] [total] …"}[b.dualScope])
|
||||
// Turn the scope DISPLAY on — needed so the rig actually streams (esp. when
|
||||
// remote and we can't touch the front panel). ONLY on enable: we must never
|
||||
// switch the display OFF, because that is the operator's own scope on the
|
||||
// radio, and closing OpsLog (SetScope(false)) blanking a local IC-7300's
|
||||
// screen is exactly the regression this avoids. Some firmwares don't ack a
|
||||
// 0x27 set; a timeout isn't fatal, so log and continue.
|
||||
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, 0x01); err != nil {
|
||||
applog.Printf("icom scope: display on ack: %v", err)
|
||||
}
|
||||
}
|
||||
// Some firmwares don't ack 0x27 sets; a timeout here isn't fatal, so log and
|
||||
// continue rather than abort the second command.
|
||||
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, boolByte(on)); err != nil {
|
||||
applog.Printf("icom scope: display on=%v ack: %v", on, err)
|
||||
}
|
||||
// Waveform data OUTPUT over CI-V: enabled with the scope, and — crucially —
|
||||
// the ONLY thing we switch off on disable, so the radio's own scope display is
|
||||
// left exactly as the operator had it.
|
||||
if err := b.exec(civ.CmdScope, civ.SubScopeOn, boolByte(on)); err != nil {
|
||||
applog.Printf("icom scope: output on=%v ack: %v", on, err)
|
||||
}
|
||||
|
||||
+15
-4
@@ -267,12 +267,23 @@ func (o *OmniRig) SetFrequency(hz int64) error {
|
||||
// method (RX=TX=freq, simplex). It works on rigs — notably Icom (IC-9100) —
|
||||
// where direct FreqA/FreqB writes are accepted but never move the radio.
|
||||
// Clearing split is the right thing when tuning to a spot anyway.
|
||||
simplexOK := false
|
||||
if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil {
|
||||
simplexOK = true
|
||||
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode(%d) OK", hz32)
|
||||
} else {
|
||||
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v) — using property writes", err)
|
||||
// Fallback: write the active VFO's property AND the generic Freq
|
||||
// (always — some .ini honour only one, and split here is often misread).
|
||||
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v)", err)
|
||||
}
|
||||
|
||||
// On Yaesu / Kenwood (and anything non-Icom), SetSimplexMode frequently returns
|
||||
// OK but is a SILENT NO-OP — the rig never moves. Confirmed on an FT-891 over
|
||||
// OmniRig: every spot click logged "SetSimplexMode OK" yet FreqA stayed put,
|
||||
// while SetMode on the same .ini worked fine (so the CAT link is healthy).
|
||||
// Writing the VFO frequency PROPERTY directly DOES move them, so also do that
|
||||
// here. It is skipped on Icom, where the direct write is the unreliable one and
|
||||
// could nudge the wrong Main/Sub VFO — there SetSimplexMode is authoritative.
|
||||
isIcom := strings.HasPrefix(strings.ToUpper(strings.TrimSpace(rigType)), "IC")
|
||||
if !isIcom || !simplexOK {
|
||||
prop := "FreqA"
|
||||
switch vfo {
|
||||
case "B", "BB", "BA":
|
||||
@@ -287,7 +298,7 @@ func (o *OmniRig) SetFrequency(hz int64) error {
|
||||
okAny = true
|
||||
}
|
||||
}
|
||||
if !okAny {
|
||||
if !simplexOK && !okAny {
|
||||
return fmt.Errorf("OmniRig: no writable frequency property for this rig")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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() }
|
||||
@@ -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) }
|
||||
@@ -0,0 +1,326 @@
|
||||
package winkeyer
|
||||
|
||||
// serialcw.go — CW keying by toggling a serial control line, the "hardware CW
|
||||
// keying" method N1MM / WSJT / fldigi use with a Yaesu SCU-17 and similar
|
||||
// interfaces (DTR = CW key, RTS = PTT). No K1EL WinKeyer chip involved: the PC
|
||||
// bit-bangs the Morse itself on the DTR (or RTS) line at the configured WPM.
|
||||
//
|
||||
// A single worker goroutine consumes queued text and keys it element by element,
|
||||
// so Send returns immediately (like the WinKeyer's buffered send) and Stop can
|
||||
// abort mid-message. Speed changes apply live between characters. PTT (when
|
||||
// enabled) is asserted on the OTHER line for the whole transmission plus a
|
||||
// lead-in / tail, and held through a short hang so send-on-type doesn't chatter.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"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{
|
||||
'A': ".-", 'B': "-...", 'C': "-.-.", 'D': "-..", 'E': ".", 'F': "..-.",
|
||||
'G': "--.", 'H': "....", 'I': "..", 'J': ".---", 'K': "-.-", 'L': ".-..",
|
||||
'M': "--", 'N': "-.", 'O': "---", 'P': ".--.", 'Q': "--.-", 'R': ".-.",
|
||||
'S': "...", 'T': "-", 'U': "..-", 'V': "...-", 'W': ".--", 'X': "-..-",
|
||||
'Y': "-.--", 'Z': "--..",
|
||||
'0': "-----", '1': ".----", '2': "..---", '3': "...--", '4': "....-",
|
||||
'5': ".....", '6': "-....", '7': "--...", '8': "---..", '9': "----.",
|
||||
'.': ".-.-.-", ',': "--..--", '?': "..--..", '/': "-..-.", '=': "-...-",
|
||||
'+': ".-.-.", '-': "-....-", ':': "---...", '(': "-.--.", ')': "-.--.-",
|
||||
';': "-.-.-.", '"': ".-..-.", '\'': ".----.", '@': ".--.-.",
|
||||
}
|
||||
|
||||
// serialKeyer bit-bangs CW on a serial control line. Owned by a winkeyer.Manager
|
||||
// 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 {
|
||||
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
|
||||
wpm int
|
||||
farns int
|
||||
abort chan struct{} // recreated by Stop; keying aborts when it closes
|
||||
|
||||
in chan string
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newSerialKeyer(line lineCtl, cfg Config, onBusy func(bool)) *serialKeyer {
|
||||
k := &serialKeyer{
|
||||
line: line,
|
||||
onBusy: onBusy,
|
||||
wpm: cfg.WPM,
|
||||
farns: cfg.Farnsworth,
|
||||
abort: make(chan struct{}),
|
||||
in: make(chan string, 256),
|
||||
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.Load() }
|
||||
|
||||
// setKey raises/lowers the CW key line; setPTT the PTT line (only when enabled).
|
||||
func (k *serialKeyer) setKey(down bool) {
|
||||
if k.keyDTR.Load() {
|
||||
_ = k.line.setDTR(k.level(down))
|
||||
} else {
|
||||
_ = k.line.setRTS(k.level(down))
|
||||
}
|
||||
}
|
||||
func (k *serialKeyer) setPTT(on bool) {
|
||||
if !k.usePTT.Load() {
|
||||
return
|
||||
}
|
||||
if k.keyDTR.Load() {
|
||||
_ = k.line.setRTS(k.level(on))
|
||||
} else {
|
||||
_ = 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.line.setDTR(k.level(false))
|
||||
_ = k.line.setRTS(k.level(false))
|
||||
}
|
||||
|
||||
// SetSpeed / Send / Stop mirror the WinKeyer manager's control surface.
|
||||
func (k *serialKeyer) SetSpeed(wpm int) {
|
||||
k.mu.Lock()
|
||||
k.wpm = wpm
|
||||
k.mu.Unlock()
|
||||
}
|
||||
|
||||
func (k *serialKeyer) Send(text string) {
|
||||
select {
|
||||
case k.in <- text:
|
||||
default: // queue full (pathological) — drop rather than block the app binding
|
||||
}
|
||||
}
|
||||
|
||||
// Stop aborts the character being keyed and flushes anything queued.
|
||||
func (k *serialKeyer) Stop() {
|
||||
k.mu.Lock()
|
||||
close(k.abort)
|
||||
k.abort = make(chan struct{})
|
||||
k.mu.Unlock()
|
||||
for drained := false; !drained; {
|
||||
select {
|
||||
case <-k.in:
|
||||
default:
|
||||
drained = true
|
||||
}
|
||||
}
|
||||
k.setKey(false)
|
||||
}
|
||||
|
||||
func (k *serialKeyer) Close() {
|
||||
close(k.stop)
|
||||
<-k.done
|
||||
_ = k.line.close()
|
||||
}
|
||||
|
||||
func (k *serialKeyer) run() {
|
||||
defer close(k.done)
|
||||
defer k.idle()
|
||||
for {
|
||||
select {
|
||||
case <-k.stop:
|
||||
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.Load()) * time.Millisecond)
|
||||
k.keyText(s)
|
||||
hang := time.Duration(k.tailMs.Load()) * time.Millisecond
|
||||
if hang <= 0 {
|
||||
hang = 5 * time.Millisecond
|
||||
}
|
||||
hold: // hold PTT briefly so back-to-back sends (send-on-type) don't chatter
|
||||
for {
|
||||
select {
|
||||
case <-k.stop:
|
||||
k.idle()
|
||||
k.onBusy(false)
|
||||
return
|
||||
case s2 := <-k.in:
|
||||
k.keyText(s2)
|
||||
case <-time.After(hang):
|
||||
break hold
|
||||
}
|
||||
}
|
||||
k.setPTT(false)
|
||||
k.onBusy(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// keyText keys one string. Element gaps use the character speed (WPM); the
|
||||
// inter-character (3-unit) and inter-word (7-unit) gaps use the Farnsworth speed
|
||||
// when one is set, so slow-copy CW keeps full-speed characters with wider spacing.
|
||||
func (k *serialKeyer) keyText(s string) {
|
||||
k.mu.Lock()
|
||||
abort := k.abort
|
||||
k.mu.Unlock()
|
||||
for _, r := range strings.ToUpper(s) {
|
||||
select {
|
||||
case <-abort:
|
||||
k.setKey(false)
|
||||
return
|
||||
case <-k.stop:
|
||||
k.setKey(false)
|
||||
return
|
||||
default:
|
||||
}
|
||||
ditW, ditF := k.dits()
|
||||
if r == ' ' {
|
||||
// Word gap: 7 units total. A 3-unit inter-char gap was already emitted
|
||||
// after the previous character, so add 4 more.
|
||||
if !k.gap(4*ditF, abort) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
code, ok := morseTable[r]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for j, el := range code {
|
||||
if el == '.' {
|
||||
if !k.mark(ditW, abort) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if !k.mark(3*ditW, abort) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if j < len(code)-1 { // intra-character (element) gap: 1 unit
|
||||
if !k.gap(ditW, abort) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if !k.gap(3*ditF, abort) { // inter-character gap: 3 units
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mark holds the key down for d; gap holds it up for d. Both abort cleanly
|
||||
// (leaving the key UP) when Stop closes abort or the keyer shuts down.
|
||||
func (k *serialKeyer) mark(d time.Duration, abort chan struct{}) bool {
|
||||
k.setKey(true)
|
||||
ok := k.wait(d, abort)
|
||||
k.setKey(false)
|
||||
return ok
|
||||
}
|
||||
func (k *serialKeyer) gap(d time.Duration, abort chan struct{}) bool { return k.wait(d, abort) }
|
||||
|
||||
func (k *serialKeyer) wait(d time.Duration, abort chan struct{}) bool {
|
||||
if d <= 0 {
|
||||
return true
|
||||
}
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-t.C:
|
||||
return true
|
||||
case <-abort:
|
||||
return false
|
||||
case <-k.stop:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// sleep is a non-abortable pause (used for the short PTT lead-in).
|
||||
func (k *serialKeyer) sleep(d time.Duration) {
|
||||
if d <= 0 {
|
||||
return
|
||||
}
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-t.C:
|
||||
case <-k.stop:
|
||||
}
|
||||
}
|
||||
|
||||
// dits returns the element (character-speed) dit and the spacing (Farnsworth)
|
||||
// dit as durations, read live so a speed change mid-message takes effect.
|
||||
func (k *serialKeyer) dits() (elem, space time.Duration) {
|
||||
k.mu.Lock()
|
||||
w, f := k.wpm, k.farns
|
||||
k.mu.Unlock()
|
||||
if w < 5 {
|
||||
w = 5
|
||||
}
|
||||
if w > 99 {
|
||||
w = 99
|
||||
}
|
||||
elem = time.Duration(float64(time.Millisecond) * 1200.0 / float64(w))
|
||||
space = elem
|
||||
if f > 0 && f < w {
|
||||
space = time.Duration(float64(time.Millisecond) * 1200.0 / float64(f))
|
||||
}
|
||||
return elem, space
|
||||
}
|
||||
@@ -49,6 +49,16 @@ type Config struct {
|
||||
AutoSpace bool `json:"autospace"` // auto letter-space
|
||||
UsePTT bool `json:"use_ptt"` // key PTT (Key/PTT output)
|
||||
SerialEcho bool `json:"serial_echo"` // device echoes sent chars back to host
|
||||
|
||||
// Type selects the keyer engine on this serial port:
|
||||
// "" / "k1el" → a K1EL WinKeyer chip (the default, everything above applies)
|
||||
// "serial" → the PC bit-bangs Morse on a control line (no WinKeyer chip):
|
||||
// the "hardware CW keying" a Yaesu SCU-17 / generic interface
|
||||
// uses. WPM / Weight / Farnsworth / LeadIn / Tail / UsePTT still
|
||||
// apply; the paddle/sidetone/ratio fields are WinKeyer-only.
|
||||
Type string `json:"type"`
|
||||
CWKeyLine string `json:"cw_key_line"` // serial: "dtr" (CW on DTR, PTT on RTS — default) | "rts"
|
||||
CWInvert bool `json:"cw_invert"` // serial: invert line polarity (active-LOW) for both key and PTT
|
||||
}
|
||||
|
||||
func (c Config) normalised() Config {
|
||||
@@ -93,6 +103,7 @@ type Manager struct {
|
||||
status Status
|
||||
stopRead chan struct{}
|
||||
doneRead chan struct{}
|
||||
serial *serialKeyer // non-nil when cfg.Type == "serial" (DTR/RTS line keying)
|
||||
|
||||
onStatus func(Status)
|
||||
onEcho func(string) // chars the device echoes back as it keys them
|
||||
@@ -130,6 +141,9 @@ func (m *Manager) Connect(cfg Config) error {
|
||||
if strings.TrimSpace(cfg.Port) == "" {
|
||||
return fmt.Errorf("winkeyer: no serial port selected")
|
||||
}
|
||||
if cfg.Type == "serial" {
|
||||
return m.connectSerial(cfg)
|
||||
}
|
||||
m.Disconnect() // drop any existing link first
|
||||
|
||||
p, err := serial.Open(cfg.Port, &serial.Mode{
|
||||
@@ -175,6 +189,59 @@ 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
|
||||
|
||||
// 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(line, cfg, func(busy bool) {
|
||||
m.mu.Lock()
|
||||
changed := m.status.Busy != busy
|
||||
m.status.Busy = busy
|
||||
m.mu.Unlock()
|
||||
if changed {
|
||||
m.emit()
|
||||
}
|
||||
})
|
||||
|
||||
m.mu.Lock()
|
||||
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()
|
||||
|
||||
lineDesc := "DTR (CW) / RTS (PTT)"
|
||||
if strings.EqualFold(cfg.CWKeyLine, "rts") {
|
||||
lineDesc = "RTS (CW) / DTR (PTT)"
|
||||
}
|
||||
applog.Printf("winkeyer: serial CW keyer on %s — %s, PTT=%v (EscapeCommFunction line ctl)", cfg.Port, lineDesc, cfg.UsePTT)
|
||||
m.emit()
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyConfig pushes the keying parameters to the device.
|
||||
func (m *Manager) applyConfig(c Config) error {
|
||||
cmds := [][]byte{
|
||||
@@ -256,7 +323,12 @@ func (m *Manager) SetSpeed(wpm int) error {
|
||||
if wpm > 99 {
|
||||
wpm = 99
|
||||
}
|
||||
if err := m.write([]byte{0x02, byte(wpm)}); err != nil {
|
||||
m.mu.Lock()
|
||||
sk := m.serial
|
||||
m.mu.Unlock()
|
||||
if sk != nil {
|
||||
sk.SetSpeed(wpm)
|
||||
} else if err := m.write([]byte{0x02, byte(wpm)}); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
@@ -283,18 +355,39 @@ func (m *Manager) Send(text string) error {
|
||||
if out == "" {
|
||||
return nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
sk := m.serial
|
||||
m.mu.Unlock()
|
||||
if sk != nil {
|
||||
sk.Send(out)
|
||||
return nil
|
||||
}
|
||||
return m.write([]byte(out))
|
||||
}
|
||||
|
||||
// Stop aborts the current message and clears the keyer buffer (command 0x0A).
|
||||
func (m *Manager) Stop() error {
|
||||
m.mu.Lock()
|
||||
sk := m.serial
|
||||
m.mu.Unlock()
|
||||
if sk != nil {
|
||||
sk.Stop()
|
||||
return nil
|
||||
}
|
||||
return m.write([]byte{0x0A})
|
||||
}
|
||||
|
||||
// Backspace removes the most recent character from the keyer's send buffer,
|
||||
// IF it hasn't been keyed yet (command 0x08). Used by "send on typing" mode
|
||||
// so a fast typo can be corrected before it goes on the air.
|
||||
// so a fast typo can be corrected before it goes on the air. The serial line
|
||||
// keyer has no buffer to un-key from, so backspace is a no-op there.
|
||||
func (m *Manager) Backspace() error {
|
||||
m.mu.Lock()
|
||||
sk := m.serial
|
||||
m.mu.Unlock()
|
||||
if sk != nil {
|
||||
return nil
|
||||
}
|
||||
return m.write([]byte{0x08})
|
||||
}
|
||||
|
||||
@@ -313,14 +406,29 @@ func (m *Manager) write(b []byte) error {
|
||||
func (m *Manager) Disconnect() {
|
||||
m.mu.Lock()
|
||||
p := m.port
|
||||
sk := m.serial
|
||||
stop, done := m.stopRead, m.doneRead
|
||||
m.port = nil
|
||||
m.serial = nil
|
||||
m.stopRead = nil
|
||||
m.doneRead = nil
|
||||
connected := m.status.Connected
|
||||
m.status = Status{Connected: false}
|
||||
m.mu.Unlock()
|
||||
|
||||
if sk != nil {
|
||||
// Serial line keyer: stop the worker (drops the key/PTT lines) then close.
|
||||
sk.Close()
|
||||
if p != nil {
|
||||
_ = p.Close()
|
||||
}
|
||||
if connected {
|
||||
applog.Printf("winkeyer: serial CW keyer disconnected")
|
||||
m.emit()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if p != nil {
|
||||
_, _ = p.Write([]byte{0x00, 0x03}) // Host Close
|
||||
_ = p.Close()
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.20.10"
|
||||
appVersion = "0.20.11"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user