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 }