diff --git a/app.go b/app.go index 41e43d8..384d06f 100644 --- a/app.go +++ b/app.go @@ -214,9 +214,10 @@ 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 keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start @@ -13052,13 +13053,16 @@ 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) if err != nil { return out, err } if v := m[keyWKEngine]; v != "" { out.Engine = v } + if v := m[keyWKCWLine]; v != "" { + out.CWKeyLine = v + } if v := m[keyWKEscClears]; v != "" { out.EscClearsCall = v == "1" } @@ -13125,6 +13129,7 @@ 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), } { if err := a.settings.Set(a.ctx, k, v); err != nil { return err @@ -13142,7 +13147,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. diff --git a/changelog.json b/changelog.json index b5e147d..1d2d8c8 100644 --- a/changelog.json +++ b/changelog.json @@ -3,11 +3,13 @@ "version": "0.20.11", "date": "2026-07-23", "en": [ + "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 work exactly as with the WinKeyer.", "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": [ + "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 fonctionnent exactement comme avec le WinKeyer.", "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)." diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 39b2994..8a53ff4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 */ } }, []); diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index c358e90..a25ad00 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -1074,13 +1074,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; macros: WKMac[]; }; const [wk, setWk] = useState({ 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', macros: [], }); const [wkPorts, setWkPorts] = useState([]); const setWkField = (patch: Partial) => setWk((s) => ({ ...s, ...patch })); @@ -3023,7 +3023,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan setWkField({ port: v === '_' ? '' : v })}> + + + {wkPorts.length === 0 && No ports found} + {wkPorts.map((p) => {p})} + + + + + +
+ + +
+
+ + setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" /> +
+ +
+
+ + setWkField({ farnsworth: num(e.target.value, 0) })} className="font-mono" /> +
+
+ + setWkField({ lead_in_ms: num(e.target.value, 10) })} className="font-mono" /> +
+
+ + setWkField({ tail_ms: num(e.target.value, 50) })} className="font-mono" /> +
+ +
+ ) : ( <>
diff --git a/internal/winkeyer/serialcw.go b/internal/winkeyer/serialcw.go new file mode 100644 index 0000000..81f0ba6 --- /dev/null +++ b/internal/winkeyer/serialcw.go @@ -0,0 +1,276 @@ +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. + 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 + 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.allLow() // start idle: key up, PTT off + go k.run() + return k +} + +// 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(down) + } else { + _ = k.port.SetRTS(down) + } +} +func (k *serialKeyer) setPTT(on bool) { + if !k.usePTT { + return + } + if k.keyDTR { + _ = k.port.SetRTS(on) + } else { + _ = k.port.SetDTR(on) + } +} +func (k *serialKeyer) allLow() { _ = k.port.SetDTR(false); _ = k.port.SetRTS(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.allLow() + 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.allLow() + 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 +} diff --git a/internal/winkeyer/winkeyer.go b/internal/winkeyer/winkeyer.go index d70e0d6..aae5964 100644 --- a/internal/winkeyer/winkeyer.go +++ b/internal/winkeyer/winkeyer.go @@ -49,6 +49,15 @@ 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" } func (c Config) normalised() Config { @@ -93,6 +102,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 +140,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 +188,47 @@ func (m *Manager) Connect(cfg Config) error { return nil } +// connectSerial opens the port for line-keying (DTR=CW / RTS=PTT) — no K1EL +// handshake, no read loop. The PC bit-bangs the Morse itself (see serialcw.go). +func (m *Manager) connectSerial(cfg Config) error { + m.Disconnect() // drop any existing link first + + p, err := serial.Open(cfg.Port, &serial.Mode{ + BaudRate: cfg.Baud, + DataBits: 8, + Parity: serial.NoParity, + StopBits: serial.OneStopBit, + }) + if err != nil { + return fmt.Errorf("winkeyer: open %s: %w", cfg.Port, err) + } + // The keyer updates busy state as it keys; mirror it into status + notify. + sk := newSerialKeyer(p, cfg, func(busy bool) { + m.mu.Lock() + changed := m.status.Busy != busy + m.status.Busy = busy + m.mu.Unlock() + if changed { + m.emit() + } + }) + + m.mu.Lock() + m.port = p + m.serial = sk + m.cfg = cfg + m.status = Status{Connected: true, WPM: cfg.WPM, Port: cfg.Port} + m.mu.Unlock() + + line := "DTR (CW) / RTS (PTT)" + if strings.EqualFold(cfg.CWKeyLine, "rts") { + line = "RTS (CW) / DTR (PTT)" + } + applog.Printf("winkeyer: serial CW keyer on %s — %s, PTT=%v", cfg.Port, line, 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 +310,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 +342,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 +393,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()