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" "time" "go.bug.st/serial" ) // 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(). type serialKeyer struct { port serial.Port keyDTR bool // true: CW on DTR, PTT on RTS (N1MM default). false: swapped. invert bool // true: active-LOW — asserting a line means driving it LOW usePTT bool leadMs int tailMs int 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(port serial.Port, cfg Config, onBusy func(bool)) *serialKeyer { k := &serialKeyer{ port: port, keyDTR: !strings.EqualFold(strings.TrimSpace(cfg.CWKeyLine), "rts"), // default DTR=CW invert: cfg.CWInvert, usePTT: cfg.UsePTT, leadMs: cfg.LeadInMs, tailMs: cfg.TailMs, 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.idle() // start inactive: key up, PTT off go k.run() return k } // 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 } // setKey raises/lowers the CW key line; setPTT the PTT line (only when enabled). func (k *serialKeyer) setKey(down bool) { if k.keyDTR { _ = k.port.SetDTR(k.level(down)) } else { _ = k.port.SetRTS(k.level(down)) } } func (k *serialKeyer) setPTT(on bool) { if !k.usePTT { return } if k.keyDTR { _ = k.port.SetRTS(k.level(on)) } else { _ = k.port.SetDTR(k.level(on)) } } // idle drives BOTH lines to their inactive level (key up, PTT off), respecting // the invert option — so an active-LOW interface isn't left keyed at rest. func (k *serialKeyer) idle() { _ = k.port.SetDTR(k.level(false)) _ = k.port.SetRTS(k.level(false)) } // 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 } func (k *serialKeyer) run() { defer close(k.done) defer k.idle() for { select { case <-k.stop: return case s := <-k.in: k.onBusy(true) k.setPTT(true) k.sleep(time.Duration(k.leadMs) * time.Millisecond) k.keyText(s) hang := time.Duration(k.tailMs) * 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 }