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.
This commit is contained in:
2026-07-23 20:27:05 +02:00
parent a71e48f811
commit 89584f173d
5 changed files with 72 additions and 18 deletions
+8
View File
@@ -13245,6 +13245,14 @@ func (a *App) SaveWinkeyerSettings(s WinkeyerSettings) error {
return err 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 return nil
} }
+2
View File
@@ -3,10 +3,12 @@
"version": "0.20.12", "version": "0.20.12",
"date": "2026-07-23", "date": "2026-07-23",
"en": [ "en": [
"Serial CW keyer: 'Key PTT line' now defaults on when you pick the Serial engine (without it the rig drops to RX between words on semi-break-in), and changing the PTT / key-line / speed options now applies immediately without disconnecting the keyer. It also logs each transmission's PTT state to help diagnose a rig that still won't hold TX.",
"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.", "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." "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": [ "fr": [
"Keyer CW série : « Key PTT line » est maintenant activé par défaut quand on choisit le moteur Série (sans lui la radio retombe en RX entre les mots en semi-break-in), et changer les options PTT / ligne de manip / vitesse s'applique immédiatement sans déconnecter le keyer. Il logue aussi l'état PTT de chaque émission pour diagnostiquer une radio qui ne tient toujours pas le TX.",
"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.", "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." "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."
] ]
+3 -1
View File
@@ -3027,7 +3027,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className="grid grid-cols-4 gap-3 items-end"> <div className="grid grid-cols-4 gap-3 items-end">
<div className="space-y-1"> <div className="space-y-1">
<Label>Keyer engine</Label> <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> <SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="winkeyer">WinKeyer (K1EL, serial)</SelectItem> <SelectItem value="winkeyer">WinKeyer (K1EL, serial)</SelectItem>
+44 -17
View File
@@ -14,9 +14,12 @@ package winkeyer
import ( import (
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"go.bug.st/serial" "go.bug.st/serial"
"hamlog/internal/applog"
) )
// morseTable maps a keyable character to its Morse element string (. = dit, // morseTable maps a keyable character to its Morse element string (. = dit,
@@ -35,14 +38,16 @@ var morseTable = map[rune]string{
} }
// serialKeyer bit-bangs CW on a serial control line. Owned by a winkeyer.Manager // 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 { type serialKeyer struct {
port serial.Port port serial.Port
keyDTR bool // true: CW on DTR, PTT on RTS (N1MM default). false: swapped. keyDTR atomic.Bool // true: CW on DTR, PTT on RTS (N1MM default). false: swapped.
invert bool // true: active-LOW — asserting a line means driving it LOW invert atomic.Bool // true: active-LOW — asserting a line means driving it LOW
usePTT bool usePTT atomic.Bool // hold the PTT line for the whole transmission
leadMs int leadMs atomic.Int32 // PTT lead-in
tailMs int tailMs atomic.Int32 // PTT hold (hang) after the last element
onBusy func(bool) onBusy func(bool)
mu sync.Mutex mu sync.Mutex
@@ -58,11 +63,6 @@ type serialKeyer struct {
func newSerialKeyer(port serial.Port, cfg Config, onBusy func(bool)) *serialKeyer { func newSerialKeyer(port serial.Port, cfg Config, onBusy func(bool)) *serialKeyer {
k := &serialKeyer{ k := &serialKeyer{
port: port, 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,
onBusy: onBusy, onBusy: onBusy,
wpm: cfg.WPM, wpm: cfg.WPM,
farns: cfg.Farnsworth, farns: cfg.Farnsworth,
@@ -71,28 +71,49 @@ func newSerialKeyer(port serial.Port, cfg Config, onBusy func(bool)) *serialKeye
stop: make(chan struct{}), stop: make(chan struct{}),
done: 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 k.idle() // start inactive: key up, PTT off
go k.run() go k.run()
return k 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 // level maps a logical "active" state to the physical line level, honouring the
// invert (active-LOW) option: normally active = HIGH, inverted active = LOW. // 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). // setKey raises/lowers the CW key line; setPTT the PTT line (only when enabled).
func (k *serialKeyer) setKey(down bool) { func (k *serialKeyer) setKey(down bool) {
if k.keyDTR { if k.keyDTR.Load() {
_ = k.port.SetDTR(k.level(down)) _ = k.port.SetDTR(k.level(down))
} else { } else {
_ = k.port.SetRTS(k.level(down)) _ = k.port.SetRTS(k.level(down))
} }
} }
func (k *serialKeyer) setPTT(on bool) { func (k *serialKeyer) setPTT(on bool) {
if !k.usePTT { if !k.usePTT.Load() {
return return
} }
if k.keyDTR { if k.keyDTR.Load() {
_ = k.port.SetRTS(k.level(on)) _ = k.port.SetRTS(k.level(on))
} else { } else {
_ = k.port.SetDTR(k.level(on)) _ = k.port.SetDTR(k.level(on))
@@ -150,10 +171,16 @@ func (k *serialKeyer) run() {
return return
case s := <-k.in: case s := <-k.in:
k.onBusy(true) 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.setPTT(true)
k.sleep(time.Duration(k.leadMs) * time.Millisecond) k.sleep(time.Duration(k.leadMs.Load()) * time.Millisecond)
k.keyText(s) k.keyText(s)
hang := time.Duration(k.tailMs) * time.Millisecond hang := time.Duration(k.tailMs.Load()) * time.Millisecond
if hang <= 0 { if hang <= 0 {
hang = 5 * time.Millisecond hang = 5 * time.Millisecond
} }
+15
View File
@@ -189,6 +189,21 @@ func (m *Manager) Connect(cfg Config) error {
return nil 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 // 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). // handshake, no read loop. The PC bit-bangs the Morse itself (see serialcw.go).
func (m *Manager) connectSerial(cfg Config) error { func (m *Manager) connectSerial(cfg Config) error {