This commit is contained in:
2026-06-13 16:17:58 +02:00
parent ff53831be4
commit 00cab6b204
4 changed files with 78 additions and 37 deletions
+10 -1
View File
@@ -4,7 +4,16 @@
"Bash(go get *)",
"Bash(go build *)",
"Bash(wails generate *)",
"Bash(npm run *)"
"Bash(npm run *)",
"Bash(gofmt -w app.go internal/winkeyer/winkeyer.go)",
"Bash(timeout 20 git status --short)",
"Bash(echo \"exit: $?\")",
"Bash(GIT_TERMINAL_PROMPT=0 timeout 20 git push --dry-run)",
"Bash(echo \"push exit: $?\")",
"Bash(git config *)",
"Bash(ls \"/c/Program Files/Git/mingw64/bin/git-credential-manager\"*.exe)",
"Bash(ls \"/c/Program Files/Git/mingw64/libexec/git-core/git-credential-manager\"*.exe)",
"Bash(which git-credential-manager *)"
]
}
}
+13 -2
View File
@@ -10,6 +10,7 @@ import (
"math"
"os"
"path/filepath"
"runtime/debug"
"sort"
"strconv"
"strings"
@@ -1194,10 +1195,20 @@ func (a *App) reloadLookupProviders() {
// --- QSO bindings ---
func (a *App) AddQSO(q qso.QSO) (int64, error) {
func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
}
// Never let a panic in the logging path crash the whole app — the user lost
// QSOs that way (CW + WinKeyer). Surface it as an error instead.
defer func() {
if r := recover(); r != nil {
applog.Printf("PANIC in AddQSO: %v\n%s", r, debug.Stack())
if id == 0 {
err = fmt.Errorf("internal error while logging: %v", r)
}
}
}()
a.applyStationDefaults(&q)
a.applyDXCCNumber(&q)
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
@@ -1210,7 +1221,7 @@ func (a *App) AddQSO(q qso.QSO) (int64, error) {
q.Email = lr.Email
}
}
id, err := a.qso.Add(a.ctx, q)
id, err = a.qso.Add(a.ctx, q)
if err == nil {
q.ID = id
a.saveQSORecording(&q)
+16 -6
View File
@@ -1294,9 +1294,15 @@ export default function App() {
email: details.email,
};
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
const loggedCall = String(payload.callsign ?? '');
const loggedDxcc = typeof payload.dxcc === 'number' ? payload.dxcc : 0;
await AddQSO(payload);
resetEntry();
await refresh();
// Refresh the Worked-before matrix so the just-logged band/mode flips to
// "worked" — resetEntry cleared it, so re-fetch for the logged call (a
// live DB query, so it now includes this QSO).
if (loggedCall.length >= 3) runWorkedBefore(loggedCall, loggedDxcc);
} catch (e: any) {
setError(String(e?.message ?? e));
} finally { setSaving(false); }
@@ -1556,6 +1562,11 @@ export default function App() {
setDetails((d) => ({ ...d, award_refs: refs.map((r) => `POTA@${r}`).join(';') }));
}
function onCallsignInput(v: string, opts?: { force?: boolean }) {
// Programmatic call-sets (force: spot click, UDP, external app) count as
// "not manually typed", so a later UDP DX call (DXHunter remote control) can
// still replace it. Without this, clicking a cluster spot froze the call:
// applyUdpCall saw current != lastUdpCall and refused every later UDP call.
if (opts?.force) lastUdpCallRef.current = v.trim().toUpperCase();
// No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
// on every status packet. If it matches what's already in the entry,
// do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and
@@ -1567,13 +1578,12 @@ export default function App() {
// Recording START happens on blur (leaving the callsign field), NOT here —
// you may type a call and work it minutes later. Clearing it cancels.
if (v.trim() === '') { QSOAudioCancel(); setRecording(false); recordingCallRef.current = ""; }
const wasEmpty = callsign.trim() === '';
const isEmpty = v.trim() === '';
if (wasEmpty && !isEmpty && !locks.start) {
// First keystroke of a new QSO — freeze the start time so it doesn't
// drift even if the lookup or typing takes 30 seconds. Skip when
// start is locked: the user is back-entering a past QSO and set a
// specific time manually.
if (!isEmpty && !locks.start) {
// Restart the start time on every callsign change (each keystroke, a
// clicked spot, a new call): "Start UTC" should mark when you actually
// began this contact, not a stale time frozen at the first keystroke.
// Skip when start is locked (back-entering a past QSO at a chosen time).
setQsoStartedAt(new Date());
} else if (isEmpty && !locks.start) {
// Callsign wiped → user abandoned this QSO; reset the timer.
+39 -28
View File
@@ -13,6 +13,7 @@ package winkeyer
import (
"fmt"
"runtime/debug"
"strings"
"sync"
"time"
@@ -26,28 +27,28 @@ import (
type Mode string
const (
ModeIambicB Mode = "iambic_b"
ModeIambicA Mode = "iambic_a"
ModeUltimatic Mode = "ultimatic"
ModeBug Mode = "bug"
ModeIambicB Mode = "iambic_b"
ModeIambicA Mode = "iambic_a"
ModeUltimatic Mode = "ultimatic"
ModeBug Mode = "bug"
)
// Config is the keyer configuration the UI persists and applies on connect.
type Config struct {
Port string `json:"port"` // e.g. "COM6"
Baud int `json:"baud"` // 1200 for WK2, also fine for WK3
WPM int `json:"wpm"` // 5..99
Weight int `json:"weight"` // 10..90, 50 = normal
LeadInMs int `json:"lead_in_ms"` // PTT lead-in, 10 ms units sent to device
TailMs int `json:"tail_ms"` // PTT tail
Ratio int `json:"ratio"` // dah/dit ratio 33..66 (50 = 3:1)
Farnsworth int `json:"farnsworth"` // Farnsworth WPM (0 = off)
Sidetone int `json:"sidetone_hz"` // 0 = off; else target Hz (mapped to WK code)
Mode Mode `json:"mode"` // paddle mode
Swap bool `json:"swap"` // swap dit/dah paddles
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
Port string `json:"port"` // e.g. "COM6"
Baud int `json:"baud"` // 1200 for WK2, also fine for WK3
WPM int `json:"wpm"` // 5..99
Weight int `json:"weight"` // 10..90, 50 = normal
LeadInMs int `json:"lead_in_ms"` // PTT lead-in, 10 ms units sent to device
TailMs int `json:"tail_ms"` // PTT tail
Ratio int `json:"ratio"` // dah/dit ratio 33..66 (50 = 3:1)
Farnsworth int `json:"farnsworth"` // Farnsworth WPM (0 = off)
Sidetone int `json:"sidetone_hz"` // 0 = off; else target Hz (mapped to WK code)
Mode Mode `json:"mode"` // paddle mode
Swap bool `json:"swap"` // swap dit/dah paddles
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
}
func (c Config) normalised() Config {
@@ -77,9 +78,9 @@ func (c Config) normalised() Config {
// Status is pushed to the UI whenever the link state or keyer activity changes.
type Status struct {
Connected bool `json:"connected"`
Busy bool `json:"busy"` // device is currently sending CW
WPM int `json:"wpm"` // current speed (tracks the speed pot)
Version int `json:"version"` // host firmware version byte
Busy bool `json:"busy"` // device is currently sending CW
WPM int `json:"wpm"` // current speed (tracks the speed pot)
Version int `json:"version"` // host firmware version byte
Port string `json:"port"`
Error string `json:"error,omitempty"`
}
@@ -177,11 +178,11 @@ func (m *Manager) Connect(cfg Config) error {
// applyConfig pushes the keying parameters to the device.
func (m *Manager) applyConfig(c Config) error {
cmds := [][]byte{
{0x0E, modeRegister(c)}, // set mode register (paddle mode, swap, autospace…)
{0x02, byte(c.WPM)}, // set speed (WPM)
{0x03, byte(c.Weight)}, // set weighting
{0x0E, modeRegister(c)}, // set mode register (paddle mode, swap, autospace…)
{0x02, byte(c.WPM)}, // set speed (WPM)
{0x03, byte(c.Weight)}, // set weighting
{0x04, byte(c.LeadInMs / 10), byte(c.TailMs / 10)}, // PTT lead-in / tail (10 ms units)
{0x11, byte(c.Ratio)}, // set dit/dah ratio
{0x11, byte(c.Ratio)}, // set dit/dah ratio
}
// Sidetone: <0x01 n>. Bit6 enables, low nibble selects the pitch divisor.
cmds = append(cmds, []byte{0x01, sidetoneCode(c.Sidetone)})
@@ -197,9 +198,11 @@ func (m *Manager) applyConfig(c Config) error {
}
// modeRegister builds the WinKey mode-register byte (command 0x0E).
// bits 1..0 : paddle mode (00 Iambic-B, 01 Iambic-A, 10 Ultimatic, 11 Bug)
// bit 3 : paddle swap
// bit 0/... : (autospace is bit 0 of a separate group on some firmwares)
//
// bits 1..0 : paddle mode (00 Iambic-B, 01 Iambic-A, 10 Ultimatic, 11 Bug)
// bit 3 : paddle swap
// bit 0/... : (autospace is bit 0 of a separate group on some firmwares)
//
// We keep to the widely-compatible WK2 layout.
func modeRegister(c Config) byte {
var b byte
@@ -339,6 +342,14 @@ func (m *Manager) Disconnect() {
// the echo of characters being sent. We track busy/idle and the speed pot.
func (m *Manager) readLoop(p serial.Port, stop, done chan struct{}) {
defer close(done)
// A panic here (e.g. in a status/echo callback) would otherwise take down
// the whole app — which showed up as a crash when logging a CW QSO while
// the keyer was still streaming echo bytes. Recover, log, end the loop.
defer func() {
if r := recover(); r != nil {
applog.Printf("winkeyer: readLoop panic recovered: %v\n%s", r, debug.Stack())
}
}()
buf := make([]byte, 64)
for {
select {