Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a0a349fb3 | ||
|
|
5e09b6a796 | ||
|
|
d6a2e84eed | ||
|
|
7b0ef0ba97 | ||
|
|
aa871a07b7 | ||
|
|
89584f173d | ||
|
|
a71e48f811 | ||
|
|
5c4f101402 | ||
|
|
ede9461010 | ||
|
|
e6ca7a8bdd | ||
|
|
9be3f9147b | ||
|
|
f7f801cdb7 | ||
|
|
43f15f1a2c | ||
|
|
2c5416500f | ||
|
|
fcdc5809e6 | ||
|
|
7254950162 | ||
|
|
1668455ff4 |
@@ -33,6 +33,7 @@ import (
|
||||
"hamlog/internal/clublog"
|
||||
"hamlog/internal/cluster"
|
||||
"hamlog/internal/contest"
|
||||
"hamlog/internal/cwdecode"
|
||||
"hamlog/internal/db"
|
||||
"hamlog/internal/dxcc"
|
||||
"hamlog/internal/email"
|
||||
@@ -136,6 +137,7 @@ const (
|
||||
keyAwardEditsSeeded = "awards.defs.editflag" // one-shot: back-fill the user-edited flag
|
||||
|
||||
keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions
|
||||
keyClublogMostWanted = "clublog.most_wanted" // "1" → show ClubLog Most Wanted rank in the entry matrix
|
||||
|
||||
// E-mail / SMTP — send QSO recordings to the correspondent.
|
||||
keyEmailEnabled = "email.enabled"
|
||||
@@ -214,9 +216,11 @@ 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
|
||||
keyWKCWInvert = "winkeyer.cw_invert" // serial engine: invert line polarity (active-LOW)
|
||||
|
||||
keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start
|
||||
|
||||
@@ -462,6 +466,7 @@ type App struct {
|
||||
extsvc *extsvc.Manager
|
||||
winkeyer *winkeyer.Manager
|
||||
clublog *clublog.Manager
|
||||
clublogMW *clublog.MostWanted // ClubLog "Most Wanted" DXCC ranking (opt-in)
|
||||
motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled
|
||||
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
|
||||
motorInhibStop chan struct{} // stops the "inhibit TX while moving" loop; nil when off
|
||||
@@ -488,6 +493,11 @@ type App struct {
|
||||
|
||||
alertStore *alerts.Store // DX-cluster spot alert rules (global JSON)
|
||||
|
||||
cwMu sync.Mutex // guards the CW decoder lifecycle
|
||||
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
|
||||
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
|
||||
cwPitchHz int // manual pitch override (0 = auto / follow Flex)
|
||||
|
||||
startupProfile string // --profile <name> from the command line (activate at startup)
|
||||
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
||||
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
||||
@@ -952,6 +962,30 @@ func (a *App) startup(ctx context.Context) {
|
||||
}
|
||||
}()
|
||||
|
||||
// ClubLog "Most Wanted" DXCC ranking (opt-in, Settings → General). Loaded from
|
||||
// the cached JSON, then refreshed daily and whenever the operator's callsign
|
||||
// changes (the ranking is personalised per callsign). Best-effort.
|
||||
a.clublogMW = clublog.NewMostWanted(dataDir)
|
||||
go func() {
|
||||
if a.settings == nil {
|
||||
return
|
||||
}
|
||||
if v, _ := a.settings.Get(a.ctx, keyClublogMostWanted); v != "1" {
|
||||
return // feature off — don't touch the network
|
||||
}
|
||||
_ = a.clublogMW.LoadFromDisk()
|
||||
call := a.activeCallsign()
|
||||
if a.clublogMW.NeedsRefresh(call, 24*time.Hour) {
|
||||
if err := a.clublogMW.Download(context.Background(), call); err != nil {
|
||||
applog.Printf("clublog most-wanted: auto-refresh failed: %v", err)
|
||||
}
|
||||
}
|
||||
if a.clublogMW.Loaded() {
|
||||
c, d, n := a.clublogMW.Info()
|
||||
applog.Printf("clublog most-wanted: loaded %d entities for %s (%s)", n, c, d)
|
||||
}
|
||||
}()
|
||||
|
||||
// POTA: background poller of api.pota.app so cluster spots can be tagged
|
||||
// when the DX station is currently activating a park. Best-effort.
|
||||
a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) })
|
||||
@@ -1833,7 +1867,14 @@ func (a *App) groupDigitalSlots() bool {
|
||||
|
||||
func (a *App) GetUIPref(key string) (string, error) {
|
||||
if a.settings == nil {
|
||||
return "", nil
|
||||
// Distinct from a genuinely-empty pref: the (LOCAL SQLite) settings store
|
||||
// isn't wired yet. There's a brief window at launch where the frontend can
|
||||
// call this before OnStartup has opened the DB and built the store. The UI
|
||||
// uses the error to keep RETRYING rather than treat it as "unset" and fall
|
||||
// back to a default — the "dark theme reverts to light after an update" bug
|
||||
// (the update cleared localStorage, and the DB read gave up too early while
|
||||
// the store was still coming up).
|
||||
return "", fmt.Errorf("settings store not ready")
|
||||
}
|
||||
return a.settings.Get(a.ctx, "ui."+key)
|
||||
}
|
||||
@@ -5238,7 +5279,13 @@ func (a *App) WorkedBefore(callsign string, dxccHint int) (qso.WorkedBefore, err
|
||||
dxccHint = dxcc.EntityDXCC(m.Entity.Name)
|
||||
}
|
||||
}
|
||||
return a.qso.WorkedBefore(a.ctx, callsign, dxccHint)
|
||||
wb, err := a.qso.WorkedBefore(a.ctx, callsign, dxccHint)
|
||||
// Attach the ClubLog Most Wanted rank for this entity (opt-in) so the entry
|
||||
// matrix can show it next to the country name.
|
||||
if err == nil && wb.DXCC > 0 && a.clublogMW != nil && a.clublogMostWantedEnabled() {
|
||||
wb.MWRank = a.clublogMW.Rank(wb.DXCC)
|
||||
}
|
||||
return wb, err
|
||||
}
|
||||
|
||||
// SetCompactMode toggles a tiny always-on-top window that exposes just the
|
||||
@@ -7318,6 +7365,74 @@ func (a *App) DownloadClublogCty() (ClublogCtyInfo, error) {
|
||||
return a.GetClublogCtyInfo(), nil
|
||||
}
|
||||
|
||||
// activeCallsign is the current profile's callsign (upper-cased), "" if none.
|
||||
func (a *App) activeCallsign() string {
|
||||
if a.profiles == nil {
|
||||
return ""
|
||||
}
|
||||
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||
return strings.ToUpper(strings.TrimSpace(p.Callsign))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *App) clublogMostWantedEnabled() bool {
|
||||
if a.settings == nil {
|
||||
return false
|
||||
}
|
||||
v, _ := a.settings.Get(a.ctx, keyClublogMostWanted)
|
||||
return v == "1"
|
||||
}
|
||||
|
||||
// ClublogMostWantedInfo is the UI status of the ClubLog Most Wanted data.
|
||||
type ClublogMostWantedInfo struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Loaded bool `json:"loaded"`
|
||||
Callsign string `json:"callsign"`
|
||||
Date string `json:"date"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// GetClublogMostWantedInfo returns the current Most Wanted status.
|
||||
func (a *App) GetClublogMostWantedInfo() ClublogMostWantedInfo {
|
||||
info := ClublogMostWantedInfo{Enabled: a.clublogMostWantedEnabled()}
|
||||
if a.clublogMW != nil {
|
||||
info.Loaded = a.clublogMW.Loaded()
|
||||
info.Callsign, info.Date, info.Count = a.clublogMW.Info()
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// SetClublogMostWantedEnabled toggles the Most Wanted feature. On first enable it
|
||||
// loads the cached list (the UI then calls Download to fetch a fresh one).
|
||||
func (a *App) SetClublogMostWantedEnabled(on bool) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
v := "0"
|
||||
if on {
|
||||
v = "1"
|
||||
}
|
||||
if err := a.settings.Set(a.ctx, keyClublogMostWanted, v); err != nil {
|
||||
return err
|
||||
}
|
||||
if on && a.clublogMW != nil && !a.clublogMW.Loaded() {
|
||||
_ = a.clublogMW.LoadFromDisk() // ok if not downloaded yet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadClublogMostWanted fetches a fresh Most Wanted list for the active call.
|
||||
func (a *App) DownloadClublogMostWanted() (ClublogMostWantedInfo, error) {
|
||||
if a.clublogMW == nil {
|
||||
return ClublogMostWantedInfo{}, fmt.Errorf("clublog not initialized")
|
||||
}
|
||||
if err := a.clublogMW.Download(a.ctx, a.activeCallsign()); err != nil {
|
||||
return a.GetClublogMostWantedInfo(), err
|
||||
}
|
||||
return a.GetClublogMostWantedInfo(), nil
|
||||
}
|
||||
|
||||
// applyClublogException overrides a QSO's entity fields from a ClubLog
|
||||
// exception matching its callsign at its date. force=true ignores the
|
||||
// enable toggle (used by the explicit "Update from ClubLog" action).
|
||||
@@ -13052,13 +13167,17 @@ 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, keyWKCWInvert)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if v := m[keyWKEngine]; v != "" {
|
||||
out.Engine = v
|
||||
}
|
||||
if v := m[keyWKCWLine]; v != "" {
|
||||
out.CWKeyLine = v
|
||||
}
|
||||
out.CWInvert = m[keyWKCWInvert] == "1"
|
||||
if v := m[keyWKEscClears]; v != "" {
|
||||
out.EscClearsCall = v == "1"
|
||||
}
|
||||
@@ -13125,11 +13244,21 @@ 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),
|
||||
keyWKCWInvert: boolStr(s.CWInvert),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -13142,7 +13271,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.
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/audio"
|
||||
"hamlog/internal/cwdecode"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// CW decoder: taps the RX audio device (the same "From radio" capture the DVK
|
||||
// and QSO recorder use) and streams decoded Morse text to the UI. It is started
|
||||
// only by the frontend, and only while the entry mode is CW.
|
||||
//
|
||||
// Pitch targeting: the single-channel decoder is far more reliable when it locks
|
||||
// to a KNOWN pitch (a narrow filter at the signal frequency, like a skimmer)
|
||||
// instead of auto-searching for the loudest tone. So we follow the radio's CW
|
||||
// pitch (FlexRadio cw_pitch) when available — or a manual override — and fall
|
||||
// back to auto-search otherwise.
|
||||
|
||||
// cwTargetPitch returns the pitch (Hz) the decoder should lock to: the manual
|
||||
// override if set, else the FlexRadio's CW pitch when it's in CW, else 0 (auto).
|
||||
func (a *App) cwTargetPitch() int {
|
||||
if a.cwPitchHz > 0 {
|
||||
return a.cwPitchHz
|
||||
}
|
||||
if a.cat != nil {
|
||||
if st, ok := a.cat.FlexState(); ok && st.Available {
|
||||
// Only trust the radio's pitch when it's actually in CW.
|
||||
if st.Mode == "CW" || st.Mode == "CWL" || st.Mode == "CWU" {
|
||||
if st.CWPitch > 0 {
|
||||
return st.CWPitch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// StartCWDecoder begins decoding CW from the configured RX audio device. The
|
||||
// frontend calls this when the decoder toggle is on AND the mode is CW. Safe to
|
||||
// call repeatedly; a second call is a no-op while already running.
|
||||
func (a *App) StartCWDecoder() error {
|
||||
a.cwMu.Lock()
|
||||
defer a.cwMu.Unlock()
|
||||
if a.cwStop != nil {
|
||||
return nil // already running
|
||||
}
|
||||
dev := ""
|
||||
if a.settings != nil {
|
||||
dev, _ = a.settings.Get(a.ctx, keyAudioFromRadio)
|
||||
}
|
||||
if dev == "" {
|
||||
return fmt.Errorf("no RX audio device configured (set \"From radio\" in Audio settings)")
|
||||
}
|
||||
|
||||
dec := cwdecode.New(audio.SampleRate,
|
||||
func(text string) {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "cw:text", text)
|
||||
}
|
||||
},
|
||||
func(st cwdecode.Status) {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "cw:status", st)
|
||||
}
|
||||
},
|
||||
)
|
||||
dec.SetTarget(a.cwTargetPitch())
|
||||
a.cwDecoder = dec
|
||||
|
||||
stop := make(chan struct{})
|
||||
a.cwStop = stop
|
||||
go func() {
|
||||
if err := audio.StreamCapture(dev, stop, dec.Process); err != nil {
|
||||
applog.Printf("cw: capture failed: %v", err)
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "cw:error", err.Error())
|
||||
}
|
||||
}
|
||||
a.cwMu.Lock()
|
||||
if a.cwStop == stop {
|
||||
a.cwStop = nil
|
||||
a.cwDecoder = nil
|
||||
}
|
||||
a.cwMu.Unlock()
|
||||
}()
|
||||
// Follow the radio's CW pitch live (every second) while this run is active.
|
||||
go a.cwFollowPitch(stop, dec)
|
||||
return nil
|
||||
}
|
||||
|
||||
// cwFollowPitch keeps the decoder locked to the current target pitch until stop.
|
||||
func (a *App) cwFollowPitch(stop <-chan struct{}, dec *cwdecode.Decoder) {
|
||||
t := time.NewTicker(time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
dec.SetTarget(a.cwTargetPitch())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StopCWDecoder halts the CW decoder if running.
|
||||
func (a *App) StopCWDecoder() {
|
||||
a.cwMu.Lock()
|
||||
stop := a.cwStop
|
||||
a.cwStop = nil
|
||||
a.cwDecoder = nil
|
||||
a.cwMu.Unlock()
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
}
|
||||
}
|
||||
|
||||
// CWDecoderRunning reports whether the decoder is currently capturing.
|
||||
func (a *App) CWDecoderRunning() bool {
|
||||
a.cwMu.Lock()
|
||||
defer a.cwMu.Unlock()
|
||||
return a.cwStop != nil
|
||||
}
|
||||
|
||||
// SetCWDecoderPitch sets a manual decode pitch (Hz); 0 returns to auto (follow
|
||||
// the Flex CW pitch, or search). Applies live to a running decoder.
|
||||
func (a *App) SetCWDecoderPitch(hz int) {
|
||||
if hz < 0 {
|
||||
hz = 0
|
||||
}
|
||||
a.cwMu.Lock()
|
||||
a.cwPitchHz = hz
|
||||
dec := a.cwDecoder
|
||||
a.cwMu.Unlock()
|
||||
if dec != nil {
|
||||
dec.SetTarget(a.cwTargetPitch())
|
||||
}
|
||||
}
|
||||
|
||||
// GetCWDecoderPitch returns the manual override (0 = auto / follow Flex).
|
||||
func (a *App) GetCWDecoderPitch() int {
|
||||
a.cwMu.Lock()
|
||||
defer a.cwMu.Unlock()
|
||||
return a.cwPitchHz
|
||||
}
|
||||
@@ -1,4 +1,42 @@
|
||||
[
|
||||
{
|
||||
"version": "0.20.12",
|
||||
"date": "2026-07-23",
|
||||
"en": [
|
||||
"NET Control: the on-air list now follows a mic-pass order you control instead of log time. A pinned '#' column numbers the round, ⬆⬇ buttons move the selected station up/down the queue, new check-ins join the end, and 'Log & end' advances to the next in line. The order is remembered per net. Header sorting is disabled on this list so a click can't shuffle the round.",
|
||||
"The CW decoder (RX audio → text) is back, with a rebuilt decoding engine. The first version struggled on real signals; the new one adds a windowed tone detector, an adaptive dB envelope with noise squelch, two-way debouncing (a brief fade inside a dash no longer shatters it into dots), per-character dit/dah classification (even the first letter of an over decodes at any speed), and gap-fed speed tracking. It rides QSB, ignores QRM on other pitches, follows 12–40 WPM and sloppy hand keying — all proven by a synthetic-signal test suite. Tools → CW decoder (or the ear button): decodes while the mode is CW, with WPM/pitch/level display, pitch lock (follows the FlexRadio CW pitch automatically), and click-a-word to fill the callsign. Weak signals in heavy QRM remain hard — that's CW Skimmer territory — but clean-to-moderate signals now decode solidly.",
|
||||
"The CW Keyer settings panel is now fully translated (it had stayed in English), and the long serial-engine explanation paragraph was removed to keep it clean.",
|
||||
"Serial CW keyer: fixed the rig dropping to RX between words during a macro (PTT not held). On USB-serial interfaces like the Yaesu SCU-17 (CP210x), the previous method of driving the DTR/RTS lines would drop the held RTS (PTT) as soon as DTR (CW) toggled. OpsLog now drives the lines with the direct Win32 method N1MM/WSJT use (EscapeCommFunction), so RTS stays asserted for the whole transmission. 'Key PTT line' also defaults on for the Serial engine, and PTT / key-line / speed changes apply immediately without reconnecting the keyer.",
|
||||
"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."
|
||||
],
|
||||
"fr": [
|
||||
"NET Control : la liste on-air suit maintenant un ordre de passage du micro que tu contrôles, au lieu de l'heure de log. Une colonne « # » épinglée numérote le tour, les boutons ⬆⬇ montent/descendent la station sélectionnée dans la file, les nouveaux arrivants rejoignent la fin, et « Logger & terminer » passe au suivant. L'ordre est mémorisé par NET. Le tri par en-tête est désactivé sur cette liste pour qu'un clic ne bouscule pas le tour.",
|
||||
"Le décodeur CW (audio RX → texte) est de retour, avec un moteur de décodage reconstruit. La première version peinait sur les vrais signaux ; la nouvelle ajoute un détecteur de tonalité fenêtré, une enveloppe dB adaptative avec squelch de bruit, un anti-rebond bilatéral (un bref fading dans un trait ne le brise plus en points), une classification point/trait par caractère (même la première lettre d'un over se décode à n'importe quelle vitesse) et un suivi de vitesse nourri par les espaces. Il tient le QSB, ignore le QRM sur d'autres tonalités, suit de 12 à 40 WPM et la manipulation humaine approximative — le tout prouvé par une suite de tests sur signaux synthétiques. Outils → Décodeur CW (ou le bouton oreille) : décode quand le mode est CW, avec affichage WPM/tonalité/niveau, verrouillage de tonalité (suit automatiquement le pitch CW du FlexRadio) et clic-sur-un-mot pour remplir l'indicatif. Les signaux faibles dans un fort QRM restent difficiles — c'est le territoire de CW Skimmer — mais les signaux propres à moyens se décodent maintenant solidement.",
|
||||
"Le panneau de réglages du Manipulateur CW est maintenant entièrement traduit (il était resté en anglais), et le long paragraphe d'explication du moteur série a été retiré pour l'alléger.",
|
||||
"Keyer CW série : correction de la radio qui retombait en RX entre les mots pendant une macro (PTT non tenu). Sur les interfaces USB-série comme le Yaesu SCU-17 (CP210x), l'ancienne méthode de pilotage des lignes DTR/RTS faisait retomber le RTS (PTT) dès que DTR (CW) basculait. OpsLog pilote maintenant les lignes avec la méthode Win32 directe qu'utilisent N1MM/WSJT (EscapeCommFunction), donc RTS reste asserté toute l'émission. « Key PTT line » est aussi activé par défaut pour le moteur Série, et les changements PTT / ligne / vitesse s'appliquent immédiatement sans reconnecter le keyer.",
|
||||
"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."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.20.11",
|
||||
"date": "2026-07-23",
|
||||
"en": [
|
||||
"Fixed clicking a spot / band-map entry not moving the radio on Yaesu (and other non-Icom) rigs controlled through OmniRig — e.g. the FT-891. OpsLog tuned via OmniRig's SetSimplexMode, which on these rigs returns success but silently doesn't move the VFO (mode changes worked, so it looked like a puzzle). It now also writes the VFO frequency property directly, which does move them. Icom rigs are unchanged (there SetSimplexMode is the reliable path).",
|
||||
"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 <LOGQSO> work exactly as with the WinKeyer. An 'Invert keying (active-LOW)' option is there for interfaces that key backwards / transmit at rest.",
|
||||
"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": [
|
||||
"Correction : cliquer un spot / une entrée de band-map ne changeait pas la fréquence sur les Yaesu (et autres radios non-Icom) pilotées via OmniRig — p. ex. le FT-891. OpsLog accordait via SetSimplexMode d'OmniRig, qui sur ces radios renvoie « OK » mais ne bouge pas le VFO (le changement de mode marchait, d'où l'énigme). Il écrit maintenant aussi directement la propriété fréquence du VFO, ce qui les fait bien QSY. Les Icom sont inchangés (là, SetSimplexMode reste la bonne méthode).",
|
||||
"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 <LOGQSO> fonctionnent exactement comme avec le WinKeyer. Une option « Inverser le keying (actif-BAS) » est prévue pour les interfaces qui manipulent à l'envers / émettent au repos.",
|
||||
"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)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.20.10",
|
||||
"date": "2026-07-23",
|
||||
|
||||
+98
-3
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Eraser, Hash, Loader2, Lock,
|
||||
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
|
||||
FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
|
||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
||||
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
||||
GetAwardDefs,
|
||||
@@ -852,6 +853,37 @@ export default function App() {
|
||||
|
||||
// === Digital Voice Keyer (DVK) ===
|
||||
|
||||
// CW decoder: taps RX audio and decodes Morse. Runs only when enabled AND the
|
||||
// mode is CW. The decoded text appears in a strip above the tabs.
|
||||
const [cwEnabled, setCwEnabled] = useState(() => localStorage.getItem('opslog.cwDecoder') === '1');
|
||||
const [cwText, setCwText] = useState('');
|
||||
const [cwStatus, setCwStatus] = useState<{ wpm: number; pitch: number; level: number; active: boolean }>({ wpm: 0, pitch: 0, level: 0, active: false });
|
||||
const cwOn = cwEnabled && mode === 'CW';
|
||||
// Keep the decoded line scrolled to the newest text (left-aligned, no scrollbar).
|
||||
const cwScrollRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { const el = cwScrollRef.current; if (el) el.scrollLeft = el.scrollWidth; }, [cwText]);
|
||||
// Manual pitch override ('' = Auto: follow the radio's CW pitch / search).
|
||||
const [cwPitch, setCwPitch] = useState(() => localStorage.getItem('opslog.cwPitch') || '');
|
||||
useEffect(() => {
|
||||
const hz = parseInt(cwPitch, 10);
|
||||
SetCWDecoderPitch(Number.isFinite(hz) ? hz : 0).catch(() => {});
|
||||
localStorage.setItem('opslog.cwPitch', cwPitch);
|
||||
}, [cwPitch, cwOn]);
|
||||
useEffect(() => {
|
||||
const offT = EventsOn('cw:text', (txt: string) => setCwText((s) => (s + txt).slice(-200)));
|
||||
const offS = EventsOn('cw:status', (st: any) => setCwStatus(st));
|
||||
const offE = EventsOn('cw:error', (e: string) => { setError(String(e)); setCwEnabled(false); });
|
||||
return () => { offT?.(); offS?.(); offE?.(); };
|
||||
}, []);
|
||||
// Start/stop the backend decoder as the (enabled, mode) combination changes.
|
||||
useEffect(() => {
|
||||
if (cwOn) { StartCWDecoder().catch((e: any) => { setError(String(e?.message ?? e)); setCwEnabled(false); }); }
|
||||
else { StopCWDecoder().catch(() => {}); }
|
||||
}, [cwOn]);
|
||||
function toggleCwDecoder() {
|
||||
setCwEnabled((v) => { const n = !v; localStorage.setItem('opslog.cwDecoder', n ? '1' : '0'); return n; });
|
||||
}
|
||||
|
||||
// === Multi-op chat (shared MySQL logbook) — docked panel like rotor/DVK ===
|
||||
const [chatAvailable, setChatAvailable] = useState(false);
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
@@ -2130,7 +2162,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 */ }
|
||||
}, []);
|
||||
|
||||
@@ -2908,6 +2940,7 @@ export default function App() {
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
||||
{ type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' },
|
||||
{ type: 'item', label: (cwEnabled ? '✓ ' : '') + t('tools.cwDecoder'), action: 'tools.cwdecoder' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' },
|
||||
{ type: 'item', label: (contestTabEnabled ? '✓ ' : '') + t('tools.contest'), action: 'tools.contest' },
|
||||
@@ -2930,7 +2963,7 @@ export default function App() {
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
||||
]},
|
||||
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
|
||||
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
|
||||
|
||||
function handleMenu(action: string) {
|
||||
switch (action) {
|
||||
@@ -2951,6 +2984,7 @@ export default function App() {
|
||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||
case 'tools.cwdecoder': toggleCwDecoder(); break;
|
||||
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
|
||||
case 'tools.contest': setContestTabEnabled((v) => { const nv = !v; if (nv) setActiveTab('contest'); else setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); return nv; }); break;
|
||||
case 'tools.alerts': setAlertsOpen(true); break;
|
||||
@@ -4000,6 +4034,20 @@ export default function App() {
|
||||
<Zap className="size-4" />
|
||||
{wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-warning animate-pulse" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCwDecoder}
|
||||
title={cwEnabled ? (mode === 'CW' ? t('cwd.tipOnCw') : t('cwd.tipOnIdle')) : t('cwd.tipOff')}
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||
cwOn && cwStatus.active ? 'border-success bg-success-muted text-success-muted-foreground'
|
||||
: cwEnabled ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
|
||||
: 'border-border text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<Ear className="size-4" />
|
||||
{cwOn && cwStatus.active && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success animate-pulse" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
|
||||
@@ -4664,6 +4712,53 @@ export default function App() {
|
||||
</div>{/* /entry + aside row */}
|
||||
|
||||
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
|
||||
{cwOn && (
|
||||
<div className="ml-2.5 mt-1.5 -mb-1 w-[45%] flex items-center gap-2 rounded-md border border-success-border/70 bg-success-muted/60 px-2 py-1.5 text-xs">
|
||||
<Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-success' : 'text-muted-foreground')} />
|
||||
{/* Input-level meter — if this stays flat with a strong signal, the RX
|
||||
audio device is wrong/silent rather than a decode problem. */}
|
||||
<div className="shrink-0 w-12 h-1.5 rounded bg-muted overflow-hidden" title={t('cwd.levelTip', { pct: Math.round(cwStatus.level * 100) })}>
|
||||
<div className="h-full bg-success transition-[width] duration-100" style={{ width: `${Math.min(100, Math.round(cwStatus.level * 100))}%` }} />
|
||||
</div>
|
||||
<span className="shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||
{cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'}
|
||||
</span>
|
||||
{/* Lock pitch: blank = Auto (follow the Flex CW pitch / search). */}
|
||||
<input
|
||||
type="number"
|
||||
value={cwPitch}
|
||||
onChange={(e) => setCwPitch(e.target.value)}
|
||||
placeholder="auto"
|
||||
title={t('cwd.pitchTip')}
|
||||
className="shrink-0 w-14 h-5 rounded border border-success-border/70 bg-card/60 px-1 text-[10px] font-mono text-center outline-none"
|
||||
/>
|
||||
{/* Left-aligned single line, no scrollbar; auto-scrolled to the newest
|
||||
text (see cwScrollRef effect) so the latest stays in view. */}
|
||||
<div ref={cwScrollRef} className="flex-1 min-w-0 overflow-hidden font-mono leading-none flex items-center">
|
||||
{cwText.trim() === '' ? (
|
||||
<span className="text-muted-foreground italic">{t('cwd.listening')}</span>
|
||||
) : (
|
||||
<div className="inline-flex items-center whitespace-nowrap">
|
||||
{cwText.trim().split(/\s+/).map((tok, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="mr-1 shrink-0 rounded px-1 leading-none hover:bg-success-muted/70"
|
||||
title={t('cwd.useCall')}
|
||||
onClick={() => onCallsignInput(tok, { force: true })}
|
||||
>
|
||||
{tok}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="shrink-0 text-muted-foreground hover:text-foreground" title={t('cwd.clear')} onClick={() => setCwText('')}>
|
||||
<Eraser className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
|
||||
{compact ? null : <>
|
||||
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
|
||||
|
||||
@@ -94,6 +94,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
const dxcc = wb?.dxcc ?? 0;
|
||||
const dxccName = wb?.dxcc_name ?? '';
|
||||
const dxccCount = wb?.dxcc_count ?? 0;
|
||||
const mwRank = wb?.mw_rank ?? 0; // ClubLog Most Wanted rank (1 = most wanted; 0 = off/unknown)
|
||||
const callCount = wb?.count ?? 0; // QSOs with this exact callsign
|
||||
const hasDxcc = dxcc > 0;
|
||||
const newOne = hasDxcc && dxccCount === 0;
|
||||
@@ -136,6 +137,20 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
const newMode = slotNew && bandWorked && !modeWorked;
|
||||
const newSlot = slotNew && bandWorked && modeWorked;
|
||||
|
||||
// ClubLog Most Wanted rank pill (shown next to the entity name when the feature
|
||||
// is on). Hotter colour the more wanted the entity is.
|
||||
const mwBadge = mwRank > 0 ? (
|
||||
<Badge
|
||||
title={`ClubLog Most Wanted #${mwRank}`}
|
||||
className={cn('px-1.5 py-0 text-[10px] font-bold shrink-0',
|
||||
mwRank <= 100 ? 'bg-danger text-danger-foreground'
|
||||
: mwRank <= 250 ? 'bg-warning text-warning-foreground'
|
||||
: 'bg-muted text-muted-foreground')}
|
||||
>
|
||||
MW #{mwRank}
|
||||
</Badge>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
@@ -154,12 +169,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
<strong className="text-warning-muted-foreground font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
|
||||
{' '}· never worked this entity
|
||||
</span>
|
||||
{mwBadge}
|
||||
</>
|
||||
) : hasDxcc ? (
|
||||
<>
|
||||
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
|
||||
{dxccName || `DXCC #${dxcc}`}
|
||||
</Badge>
|
||||
{mwBadge}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<strong className="text-foreground font-semibold">{dxccCount}</strong>{' '}
|
||||
QSO{dxccCount > 1 ? 's' : ''} with this entity
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from 'ag-grid-community';
|
||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History } from 'lucide-react';
|
||||
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
@@ -166,6 +166,53 @@ export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHand
|
||||
catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
// ---- Mic pass order ------------------------------------------------------
|
||||
// The on-air grid shows the stations in the order the mic goes around — NOT
|
||||
// by log time. The order is owned here as a list of draft ids: new check-ins
|
||||
// join at the END of the queue, logged/cancelled ones drop out, and the ⬆⬇
|
||||
// buttons move the selected station. Persisted per net so a panel remount
|
||||
// (tab switch) keeps the round.
|
||||
const [orderIds, setOrderIds] = useState<number[]>([]);
|
||||
const orderKey = openId ? `opslog.netOrder.${openId}` : '';
|
||||
// Load the saved order when a net opens.
|
||||
useEffect(() => {
|
||||
if (!orderKey) { setOrderIds([]); return; }
|
||||
try { setOrderIds(JSON.parse(localStorage.getItem(orderKey) || '[]')); }
|
||||
catch { setOrderIds([]); }
|
||||
}, [orderKey]);
|
||||
// Reconcile with the live list: keep known ids in their order, append new
|
||||
// ones, drop the gone. Persist.
|
||||
useEffect(() => {
|
||||
setOrderIds((cur) => {
|
||||
const liveIds = active.map((a) => a.id as number).filter((id) => id != null);
|
||||
const liveSet = new Set(liveIds);
|
||||
const kept = cur.filter((id) => liveSet.has(id));
|
||||
const keptSet = new Set(kept);
|
||||
const next = [...kept, ...liveIds.filter((id) => !keptSet.has(id))];
|
||||
if (next.length === cur.length && next.every((v, i) => v === cur[i])) return cur;
|
||||
if (orderKey) { try { localStorage.setItem(orderKey, JSON.stringify(next)); } catch { /* quota */ } }
|
||||
return next;
|
||||
});
|
||||
}, [active, orderKey]);
|
||||
const activeOrdered = useMemo(() => {
|
||||
const byId = new Map(active.map((a) => [a.id as number, a]));
|
||||
return orderIds.map((id) => byId.get(id)).filter(Boolean) as QSOForm[];
|
||||
}, [active, orderIds]);
|
||||
// Move the selected on-air station up/down the queue.
|
||||
const moveSelected = useCallback((dir: -1 | 1) => {
|
||||
const id = selectedActiveIds[0];
|
||||
if (id == null) return;
|
||||
setOrderIds((cur) => {
|
||||
const i = cur.indexOf(id);
|
||||
const j = i + dir;
|
||||
if (i < 0 || j < 0 || j >= cur.length) return cur;
|
||||
const next = [...cur];
|
||||
[next[i], next[j]] = [next[j], next[i]];
|
||||
if (orderKey) { try { localStorage.setItem(orderKey, JSON.stringify(next)); } catch { /* quota */ } }
|
||||
return next;
|
||||
});
|
||||
}, [selectedActiveIds, orderKey]);
|
||||
|
||||
useEffect(() => { refreshNets(); }, [refreshNets]);
|
||||
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
|
||||
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
|
||||
@@ -221,11 +268,11 @@ export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHand
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
// Active → logged (end QSO, removed from session, written to the logbook).
|
||||
// Then auto-select the FIRST remaining on-air station, so the operator can
|
||||
// chain "log → log → log" without re-clicking a row each time.
|
||||
// Then auto-select the NEXT station in the mic-pass order, so the operator
|
||||
// can chain "log → log → log" without re-clicking a row each time.
|
||||
async function deactivate(id?: number) {
|
||||
if (id == null) return;
|
||||
const next = active.find((a) => a.id !== id); // computed BEFORE the list shrinks
|
||||
const next = activeOrdered.find((a) => a.id !== id); // computed BEFORE the list shrinks
|
||||
try {
|
||||
await NetDeactivate(id);
|
||||
await refreshActive();
|
||||
@@ -356,11 +403,12 @@ export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHand
|
||||
</div>
|
||||
<div ref={onAirWrap} className="flex flex-col min-h-0 flex-1">
|
||||
<RecentQSOsGrid
|
||||
rows={active}
|
||||
total={active.length}
|
||||
rows={activeOrdered}
|
||||
total={activeOrdered.length}
|
||||
storageKey="net.onair"
|
||||
selectRowSignal={selectRow}
|
||||
rowDragCall
|
||||
passOrder
|
||||
onGridApi={(api) => { onAirApi.current = api; wireDropZones(); }}
|
||||
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
|
||||
onRowDoubleClicked={(q) => setEditingDraft(q)}
|
||||
@@ -373,6 +421,19 @@ export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHand
|
||||
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
|
||||
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
||||
</Button>
|
||||
{/* Mic pass order: move the selected station up/down the round. */}
|
||||
<div className="flex items-center gap-0.5 pl-1 border-l border-border/50">
|
||||
<Button variant="ghost" size="icon" className="size-7" title={t('ncp.moveUp')}
|
||||
disabled={selectedActiveIds[0] == null || orderIds.indexOf(selectedActiveIds[0]) <= 0}
|
||||
onClick={() => moveSelected(-1)}>
|
||||
<ChevronUp className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="size-7" title={t('ncp.moveDown')}
|
||||
disabled={selectedActiveIds[0] == null || orderIds.indexOf(selectedActiveIds[0]) >= orderIds.length - 1}
|
||||
onClick={() => moveSelected(1)}>
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto" onClick={logAll}>
|
||||
<MinusCircle className="size-3.5" /> {t('ncp.logAll', { n: active.length })}
|
||||
</Button>
|
||||
|
||||
@@ -37,6 +37,11 @@ type Props = {
|
||||
// row onto the roster to log it). Pair with onGridApi so the parent can
|
||||
// register external drop zones via api.addRowDropZone.
|
||||
rowDragCall?: boolean;
|
||||
// Pass-order mode (NET Control on-air queue): the ROW ORDER GIVEN IN `rows`
|
||||
// is the display order — a pinned "#" column numbers it, and all column
|
||||
// sorting is disabled (including saved sorts) so the queue can't be shuffled
|
||||
// by a header click. The parent owns/reorders the array.
|
||||
passOrder?: boolean;
|
||||
onGridApi?: (api: any) => void;
|
||||
// storageKey scopes the persisted column layout/visibility. Omit for the main
|
||||
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
|
||||
@@ -257,7 +262,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
||||
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
||||
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
||||
|
||||
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDragCall, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
||||
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
@@ -330,8 +335,20 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
||||
if (rowDragCall && ((rest as any).colId === 'callsign' || (rest as any).field === 'callsign')) {
|
||||
col.rowDrag = true;
|
||||
}
|
||||
if (passOrder) {
|
||||
delete (col as any).sort; // e.g. the date column's default 'desc'
|
||||
col.sortable = false;
|
||||
}
|
||||
return col;
|
||||
});
|
||||
if (passOrder) {
|
||||
base.unshift({
|
||||
colId: '__order', headerName: '#', width: 46, pinned: 'left',
|
||||
sortable: false, resizable: false, suppressMovable: true, filter: false,
|
||||
cellClass: 'font-mono text-muted-foreground tabular-nums',
|
||||
valueGetter: (p) => (p.node?.rowIndex ?? 0) + 1,
|
||||
} as ColDef<QSOForm>);
|
||||
}
|
||||
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
||||
colId: `award_${a.code}`,
|
||||
headerName: a.code,
|
||||
@@ -344,7 +361,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
||||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||
}));
|
||||
return [...base, ...awards];
|
||||
}, [awardCols, COL_CATALOG, t, awardShown, rowDragCall]);
|
||||
}, [awardCols, COL_CATALOG, t, awardShown, rowDragCall, passOrder]);
|
||||
|
||||
// Enforce award-column visibility via the API after the columns (re)appear —
|
||||
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
|
||||
@@ -360,21 +377,27 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
||||
}, [awardCols, awardShown]);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: true,
|
||||
sortable: !passOrder,
|
||||
resizable: true,
|
||||
filter: true,
|
||||
suppressMovable: false,
|
||||
}), []);
|
||||
}), [passOrder]);
|
||||
|
||||
// In pass-order mode a saved sort (from before the mode existed, or synced
|
||||
// from elsewhere) must not shuffle the queue — strip sorts when restoring.
|
||||
const sanitizeState = useCallback((state: any[]) => (
|
||||
passOrder ? state.map((s) => ({ ...s, sort: null })) : state
|
||||
), [passOrder]);
|
||||
|
||||
function onGridReady(e: GridReadyEvent) {
|
||||
onGridApi?.(e.api);
|
||||
const local = loadLocal(colStateKey);
|
||||
if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
|
||||
if (local) e.api.applyColumnState({ state: sanitizeState(stripAwardCols(local)) as ColumnState[], applyOrder: true });
|
||||
// Fall back to the portable DB copy when the local cache is empty
|
||||
// (fresh machine / after a reinstall), then re-seed the cache.
|
||||
loadRemote(colStateKey).then((remote) => {
|
||||
if (remote && !local) {
|
||||
e.api.applyColumnState({ state: stripAwardCols(remote) as ColumnState[], applyOrder: true });
|
||||
e.api.applyColumnState({ state: sanitizeState(stripAwardCols(remote)) as ColumnState[], applyOrder: true });
|
||||
seedLocal(colStateKey, remote);
|
||||
}
|
||||
});
|
||||
@@ -384,9 +407,16 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
||||
const reportFilteredCount = useCallback((e: { api?: any }) => {
|
||||
const api = e?.api ?? gridRef.current?.api;
|
||||
if (api?.getDisplayedRowCount) setDispCount(api.getDisplayedRowCount());
|
||||
// Pass-order "#" is a rowIndex-based valueGetter; AG-Grid keeps the same
|
||||
// row node across a reorder (getRowId by id), so the number is cached and
|
||||
// stale until we force it. Refresh it on every model update (a reorder
|
||||
// fires one). force:true bypasses the value cache.
|
||||
if (passOrder && api?.refreshCells) {
|
||||
api.refreshCells({ columns: ['__order'], force: true });
|
||||
}
|
||||
if (!api || !onFilteredCountChange) return;
|
||||
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
|
||||
}, [onFilteredCountChange]);
|
||||
}, [onFilteredCountChange, passOrder]);
|
||||
const saveColumnState = useCallback(() => {
|
||||
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||
const state = gridRef.current?.api?.getColumnState();
|
||||
@@ -403,7 +433,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
||||
useEffect(() => {
|
||||
const api = gridRef.current?.api;
|
||||
const local = loadLocal(colStateKey);
|
||||
if (api && local) api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
|
||||
if (api && local) api.applyColumnState({ state: sanitizeState(stripAwardCols(local)) as ColumnState[], applyOrder: true });
|
||||
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
||||
GetClublogMostWantedInfo, SetClublogMostWantedEnabled, DownloadClublogMostWanted,
|
||||
GetSecretStatus, SetPassphrase, RemovePassphrase,
|
||||
GetEmailSettings, SaveEmailSettings, TestEmail,
|
||||
QSLGetEmailTemplates, QSLSaveEmailTemplates,
|
||||
@@ -1074,13 +1075,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; cw_invert: boolean; macros: WKMac[];
|
||||
};
|
||||
const [wk, setWk] = useState<WKSettings>({
|
||||
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', cw_invert: false, macros: [],
|
||||
});
|
||||
const [wkPorts, setWkPorts] = useState<string[]>([]);
|
||||
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
|
||||
@@ -1183,6 +1184,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const [clubBusy, setClubBusy] = useState(false);
|
||||
const [clubErr, setClubErr] = useState('');
|
||||
useEffect(() => { GetClublogCtyInfo().then((i) => setClubInfo(i as ClubInfo)).catch(() => {}); }, []);
|
||||
// ClubLog Most Wanted (rank shown in the entry matrix).
|
||||
type MWInfo = { enabled: boolean; loaded: boolean; callsign: string; date: string; count: number };
|
||||
const [mwInfo, setMwInfo] = useState<MWInfo>({ enabled: false, loaded: false, callsign: '', date: '', count: 0 });
|
||||
const [mwBusy, setMwBusy] = useState(false);
|
||||
const [mwErr, setMwErr] = useState('');
|
||||
useEffect(() => { GetClublogMostWantedInfo().then((i) => setMwInfo(i as MWInfo)).catch(() => {}); }, []);
|
||||
const reloadDvk = () => { GetDVKMessages().then((m) => setDvkMsgs((m ?? []) as DVKMsg[])).catch(() => {}); };
|
||||
useEffect(() => {
|
||||
GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
|
||||
@@ -3014,44 +3021,45 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={wk.enabled} onCheckedChange={(c) => setWkField({ enabled: !!c })} />
|
||||
Enable CW keyer (shows the keyer panel)
|
||||
{t('wk.enable')}
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3 items-end">
|
||||
<div className="space-y-1">
|
||||
<Label>Keyer engine</Label>
|
||||
<Select value={wk.engine} onValueChange={(v) => setWkField({ engine: v })}>
|
||||
<Label>{t('wk.engine')}</Label>
|
||||
{/* 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>
|
||||
<SelectContent>
|
||||
<SelectItem value="winkeyer">WinKeyer (serial)</SelectItem>
|
||||
<SelectItem value="icom">Icom CI-V (rig keyer)</SelectItem>
|
||||
<SelectItem value="flex">FlexRadio (CWX)</SelectItem>
|
||||
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
|
||||
<SelectItem value="winkeyer">{t('wk.engWinkeyer')}</SelectItem>
|
||||
<SelectItem value="serial">{t('wk.engSerial')}</SelectItem>
|
||||
<SelectItem value="icom">{t('wk.engIcom')}</SelectItem>
|
||||
<SelectItem value="flex">{t('wk.engFlex')}</SelectItem>
|
||||
<SelectItem value="tci" disabled>{t('wk.engTci')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer col-span-3 pb-1.5">
|
||||
<Checkbox checked={wk.esc_clears_call} onCheckedChange={(c) => setWkField({ esc_clears_call: !!c })} />
|
||||
ESC clears the callsign too (otherwise ESC only stops transmission)
|
||||
{t('wk.escClears')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{wk.engine === 'icom' ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
Icom CI-V keys CW through the rig's own keyer over the existing CAT connection (command 0x17) — it reuses the CAT COM port set in Settings → CAT, so there's nothing else to wire up here. Put the rig in CW mode. Weight, ratio, sidetone, paddle mode… are configured on the radio; only the speed is set from here (the rig's KEY SPEED).
|
||||
{t('wk.icomNote')}
|
||||
</p>
|
||||
{(!catCfg.enabled || catCfg.backend !== 'icom') && (
|
||||
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
||||
<span aria-hidden>⚠</span>
|
||||
<span>
|
||||
Your CAT backend is set to <strong>{catCfg.enabled ? (catCfg.backend || 'none') : 'disabled'}</strong>. Icom CI-V CW needs the CAT backend set to <strong>Icom</strong> and connected — change it under Settings → CAT interface, otherwise sending CW will fail.
|
||||
</span>
|
||||
<span>{t('wk.catWarnIcom', { backend: catCfg.enabled ? (catCfg.backend || 'none') : 'disabled' })}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Speed (WPM)</Label>
|
||||
<Label>{t('wk.speed')}</Label>
|
||||
<Input type="number" min={6} max={48} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -3059,44 +3067,98 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
) : wk.engine === 'flex' ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
FlexRadio keys CW through the radio's <strong>CWX</strong> keyer over the existing SmartSDR CAT connection — no WinKeyer or SmartCAT needed. It reuses the connection set in Settings → CAT, so there's nothing else to wire up here. Put a slice in CW mode. Only the speed is set from here; weight, sidetone and break-in are configured on the radio (break-in must be on for CW to actually transmit).
|
||||
{t('wk.flexNote')}
|
||||
</p>
|
||||
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
|
||||
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
||||
<span aria-hidden>⚠</span>
|
||||
<span>
|
||||
Your CAT backend is set to <strong>{catCfg.enabled ? (catCfg.backend || 'none') : 'disabled'}</strong>. Flex CWX needs the CAT backend set to <strong>FlexRadio</strong> and connected — change it under Settings → CAT interface, otherwise sending CW will fail.
|
||||
</span>
|
||||
<span>{t('wk.catWarnFlex', { backend: catCfg.enabled ? (catCfg.backend || 'none') : 'disabled' })}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Speed (WPM)</Label>
|
||||
<Label>{t('wk.speed')}</Label>
|
||||
<Input type="number" min={6} max={48} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
) : wk.engine === 'serial' ? (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>Serial port</Label>
|
||||
<Label>{t('wk.keyingPort')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={wk.port || '_'} onValueChange={(v) => setWkField({ port: v === '_' ? '' : v })}>
|
||||
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder="— COM port —" /></SelectTrigger>
|
||||
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder={t('wk.comPort')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||
{wkPorts.length === 0 && <SelectItem value="_" disabled>{t('wk.noPorts')}</SelectItem>}
|
||||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2" title="Reload ports"
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2" title={t('wk.reloadPorts')}
|
||||
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
<ArrowDown className="size-3.5 rotate-90" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Baud</Label>
|
||||
<Label>{t('wk.cwKeyLine')}</Label>
|
||||
<Select value={wk.cw_key_line === 'rts' ? 'rts' : 'dtr'} onValueChange={(v) => setWkField({ cw_key_line: v })}>
|
||||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="dtr">{t('wk.lineDtr')}</SelectItem>
|
||||
<SelectItem value="rts">{t('wk.lineRts')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('wk.speed')}</Label>
|
||||
<Input type="number" min={5} max={99} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('wk.farnsworth')}</Label>
|
||||
<Input type="number" min={0} max={99} value={wk.farnsworth} onChange={(e) => setWkField({ farnsworth: num(e.target.value, 0) })} className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('wk.leadIn')}</Label>
|
||||
<Input type="number" min={0} value={wk.lead_in_ms} onChange={(e) => setWkField({ lead_in_ms: num(e.target.value, 10) })} className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('wk.tail')}</Label>
|
||||
<Input type="number" min={0} value={wk.tail_ms} onChange={(e) => setWkField({ tail_ms: num(e.target.value, 50) })} className="font-mono" />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer pb-1.5 self-end">
|
||||
<Checkbox checked={wk.use_ptt} onCheckedChange={(c) => setWkField({ use_ptt: !!c })} /> {t('wk.keyPtt')}
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={wk.cw_invert} onCheckedChange={(c) => setWkField({ cw_invert: !!c })} />
|
||||
{t('wk.invert')}
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>{t('wk.serialPort')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={wk.port || '_'} onValueChange={(v) => setWkField({ port: v === '_' ? '' : v })}>
|
||||
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder={t('wk.comPort')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{wkPorts.length === 0 && <SelectItem value="_" disabled>{t('wk.noPorts')}</SelectItem>}
|
||||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2" title={t('wk.reloadPorts')}
|
||||
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
<ArrowDown className="size-3.5 rotate-90" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('wk.baud')}</Label>
|
||||
<Select value={String(wk.baud)} onValueChange={(v) => setWkField({ baud: num(v, 1200) })}>
|
||||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -3105,38 +3167,38 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Speed (WPM)</Label>
|
||||
<Label>{t('wk.speed')}</Label>
|
||||
<Input type="number" min={5} max={99} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Weight</Label>
|
||||
<Label>{t('wk.weight')}</Label>
|
||||
<Input type="number" min={10} max={90} value={wk.weight} onChange={(e) => setWkField({ weight: num(e.target.value, 50) })} className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Lead-in (ms)</Label>
|
||||
<Label>{t('wk.leadIn')}</Label>
|
||||
<Input type="number" min={0} value={wk.lead_in_ms} onChange={(e) => setWkField({ lead_in_ms: num(e.target.value, 10) })} className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Tail (ms)</Label>
|
||||
<Label>{t('wk.tail')}</Label>
|
||||
<Input type="number" min={0} value={wk.tail_ms} onChange={(e) => setWkField({ tail_ms: num(e.target.value, 50) })} className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Ratio (33-66)</Label>
|
||||
<Label>{t('wk.ratio')}</Label>
|
||||
<Input type="number" min={33} max={66} value={wk.ratio} onChange={(e) => setWkField({ ratio: num(e.target.value, 50) })} className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Farnsworth</Label>
|
||||
<Label>{t('wk.farnsworth')}</Label>
|
||||
<Input type="number" min={0} max={99} value={wk.farnsworth} onChange={(e) => setWkField({ farnsworth: num(e.target.value, 0) })} className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Sidetone (Hz)</Label>
|
||||
<Label>{t('wk.sidetone')}</Label>
|
||||
<Input type="number" min={0} value={wk.sidetone_hz} onChange={(e) => setWkField({ sidetone_hz: num(e.target.value, 600) })} className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>Paddle mode</Label>
|
||||
<Label>{t('wk.paddleMode')}</Label>
|
||||
<Select value={wk.mode} onValueChange={(v) => setWkField({ mode: v })}>
|
||||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -3151,16 +3213,16 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={wk.swap} onCheckedChange={(c) => setWkField({ swap: !!c })} /> Swap paddles
|
||||
<Checkbox checked={wk.swap} onCheckedChange={(c) => setWkField({ swap: !!c })} /> {t('wk.swapPaddles')}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={wk.autospace} onCheckedChange={(c) => setWkField({ autospace: !!c })} /> Auto-space
|
||||
<Checkbox checked={wk.autospace} onCheckedChange={(c) => setWkField({ autospace: !!c })} /> {t('wk.autospace')}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={wk.use_ptt} onCheckedChange={(c) => setWkField({ use_ptt: !!c })} /> Key PTT
|
||||
<Checkbox checked={wk.use_ptt} onCheckedChange={(c) => setWkField({ use_ptt: !!c })} /> {t('wk.keyPttShort')}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> Serial echo
|
||||
<Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> {t('wk.serialEcho')}
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
@@ -3168,9 +3230,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
|
||||
{/* Macro editor */}
|
||||
<div className="border-t border-border/60 pt-3">
|
||||
<Label className="text-sm font-medium">CW message macros (F1…)</Label>
|
||||
<Label className="text-sm font-medium">{t('wk.macroTitle')}</Label>
|
||||
<p className="text-[11px] text-muted-foreground mb-2">
|
||||
Use variables: <span className="font-mono"><MY_CALL> <CALL> <STX> <STRX> <MY_NAME> <HIS_NAME> <MY_QTH> <GRID> <CONT_TX> <n></span> (cut numbers: 9→N, 0→T). <span className="font-mono">*</span>=my call, <span className="font-mono">!</span>=his call. <span className="font-mono"><LOGQSO></span> = log the QSO when the macro is sent (e.g. <span className="font-mono">73 TU <LOGQSO></span>).
|
||||
{t('wk.macroVars')} <span className="font-mono"><MY_CALL> <CALL> <STX> <STRX> <MY_NAME> <HIS_NAME> <MY_QTH> <GRID> <CONT_TX> <n></span> {t('wk.macroCut')} <span className="font-mono">*</span>={t('wk.macroMyCall')}, <span className="font-mono">!</span>={t('wk.macroHisCall')}. <span className="font-mono"><LOGQSO></span> = {t('wk.macroLog')} (<span className="font-mono">73 TU <LOGQSO></span>).
|
||||
</p>
|
||||
<div className="space-y-1.5 max-h-[34vh] overflow-y-auto pr-1">
|
||||
{Array.from({ length: 12 }).map((_, i) => {
|
||||
@@ -3178,7 +3240,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="font-mono text-[10px] text-primary font-semibold w-6 shrink-0">F{i + 1}</span>
|
||||
<Input className="h-8 w-28 shrink-0 text-xs" placeholder="Label" value={m.label} onChange={(e) => setMacro(i, { label: e.target.value })} />
|
||||
<Input className="h-8 w-28 shrink-0 text-xs" placeholder={t('wk.label')} value={m.label} onChange={(e) => setMacro(i, { label: e.target.value })} />
|
||||
<Input className="h-8 flex-1 font-mono text-xs" placeholder="CQ CQ DE <MY_CALL> K" value={m.text} onChange={(e) => setMacro(i, { text: e.target.value })} />
|
||||
</div>
|
||||
);
|
||||
@@ -4721,6 +4783,49 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
{clubErr && <p className="text-[11px] text-destructive pl-6">{clubErr}</p>}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">ClubLog Most Wanted</h4>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={mwInfo.enabled}
|
||||
onCheckedChange={async (c) => {
|
||||
const v = !!c; setMwInfo((s) => ({ ...s, enabled: v })); setMwErr('');
|
||||
try {
|
||||
await SetClublogMostWantedEnabled(v);
|
||||
let info = (await GetClublogMostWantedInfo()) as MWInfo;
|
||||
// First enable with no cached list → fetch it now (for the active call).
|
||||
if (v && !info.loaded) {
|
||||
setMwBusy(true);
|
||||
try { info = (await DownloadClublogMostWanted()) as MWInfo; }
|
||||
finally { setMwBusy(false); }
|
||||
}
|
||||
setMwInfo(info);
|
||||
} catch (e: any) { setMwErr(String(e?.message ?? e)); }
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
Show ClubLog "Most Wanted" rank in the entry matrix
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
Displays a <strong>MW #rank</strong> pill next to the entity name (1 = the most wanted DXCC entity), fetched
|
||||
from ClubLog and <strong>personalised to your callsign</strong>. Refreshed daily.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-3 pl-6">
|
||||
<Button variant="outline" size="sm" className="h-8" disabled={mwBusy}
|
||||
onClick={() => { setMwBusy(true); setMwErr(''); DownloadClublogMostWanted().then((i) => setMwInfo(i as MWInfo)).catch((e: any) => setMwErr(String(e?.message ?? e))).finally(() => setMwBusy(false)); }}>
|
||||
{mwBusy ? 'Downloading…' : (mwInfo.loaded ? 'Update Most Wanted' : 'Download Most Wanted')}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{mwInfo.loaded
|
||||
? `${mwInfo.count.toLocaleString()} entities${mwInfo.callsign ? ' · ' + mwInfo.callsign : ''}${mwInfo.date ? ' · ' + mwInfo.date : ''}`
|
||||
: 'not downloaded'}
|
||||
</span>
|
||||
</div>
|
||||
{mwErr && <p className="text-[11px] text-destructive pl-6">{mwErr}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -32,6 +32,12 @@ const en: Dict = {
|
||||
'view.refresh': 'Refresh', 'view.clearFilters': 'Clear filters',
|
||||
'tools.qslManager': 'QSL Manager…', 'tools.qslDesigner': 'QSL Card Designer…',
|
||||
'tools.winkeyer': 'WinKeyer CW keyer', 'tools.dvk': 'Digital Voice Keyer', 'tools.cwDecoder': 'CW decoder (RX audio)',
|
||||
'cwd.listening': 'listening…', 'cwd.clear': 'Clear', 'cwd.useCall': 'Use as callsign',
|
||||
'cwd.pitchTip': "Lock the decoder to this pitch (Hz). Blank = follow the radio's CW pitch / auto-search.",
|
||||
'cwd.levelTip': 'Audio level {pct}%',
|
||||
'cwd.tipOnCw': 'CW decoder — on (decoding) · click to disable',
|
||||
'cwd.tipOnIdle': 'CW decoder — on, idle until CW mode · click to disable',
|
||||
'cwd.tipOff': 'CW decoder · click to enable (decodes RX audio in CW mode)',
|
||||
'tools.net': 'NET Control', 'tools.alerts': 'Alert management…', 'tools.contest': 'Contest mode',
|
||||
'alert.tuneHint': 'Click to tune the rig to this spot (freq + mode) and fill the call', 'alert.dismiss': 'Dismiss', 'alert.pending': '{n} recent spot alert(s) — click to view', 'alert.noneShort': 'No recent alerts', 'alert.recent': 'Recent alerts', 'alert.clear': 'Clear',
|
||||
'menu.help': 'Help', 'help.about': 'About OpsLog', 'tools.duplicates': 'Find duplicates…',
|
||||
@@ -134,6 +140,21 @@ const en: Dict = {
|
||||
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag the grip handle on the left of a panel to move it. Pick a column count to lay them out in a grid.', 'station.dragMove': 'Drag to move this panel', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save', 'station.test': 'Test connection', 'station.testOk': 'Connected — {n} relays', 'station.testFail': 'Not connected', 'station.detectNone': 'No FTDI board found — check the cable and that the D2XX driver is installed.', 'station.detectFound': '{n} board(s) detected.',
|
||||
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
|
||||
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplifier', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
|
||||
// CW Keyer settings panel
|
||||
'wk.enable': 'Enable CW keyer (shows the keyer panel)', 'wk.engine': 'Keyer engine',
|
||||
'wk.escClears': 'ESC clears the callsign too (otherwise ESC only stops transmission)',
|
||||
'wk.engWinkeyer': 'WinKeyer (K1EL, serial)', 'wk.engSerial': 'Serial port (DTR=CW / RTS=PTT)', 'wk.engIcom': 'Icom CI-V (rig keyer)', 'wk.engFlex': 'FlexRadio (CWX)', 'wk.engTci': 'TCI (coming soon)',
|
||||
'wk.icomNote': "Icom CI-V keys CW through the rig's own keyer over the existing CAT connection (command 0x17) — it reuses the CAT COM port set in Settings → CAT, so there's nothing else to wire up here. Put the rig in CW mode. Weight, ratio, sidetone, paddle mode… are configured on the radio; only the speed is set from here (the rig's KEY SPEED).",
|
||||
'wk.flexNote': "FlexRadio keys CW through the radio's CWX keyer over the existing SmartSDR CAT connection — no WinKeyer or SmartCAT needed. It reuses the connection set in Settings → CAT, so there's nothing else to wire up here. Put a slice in CW mode. Only the speed is set from here; weight, sidetone and break-in are configured on the radio (break-in must be on for CW to actually transmit).",
|
||||
'wk.catWarnIcom': 'Your CAT backend is set to {backend}. Icom CI-V CW needs the CAT backend set to Icom and connected — change it under Settings → CAT interface, otherwise sending CW will fail.',
|
||||
'wk.catWarnFlex': 'Your CAT backend is set to {backend}. Flex CWX needs the CAT backend set to FlexRadio and connected — change it under Settings → CAT interface, otherwise sending CW will fail.',
|
||||
'wk.keyingPort': 'Keying COM port', 'wk.cwKeyLine': 'CW key line', 'wk.lineDtr': 'DTR = CW, RTS = PTT', 'wk.lineRts': 'RTS = CW, DTR = PTT',
|
||||
'wk.speed': 'Speed (WPM)', 'wk.farnsworth': 'Farnsworth', 'wk.leadIn': 'Lead-in (ms)', 'wk.tail': 'Tail (ms)', 'wk.keyPtt': 'Key PTT line',
|
||||
'wk.invert': 'Invert keying (active-LOW) — tick this only if the rig transmits at rest / keys backwards',
|
||||
'wk.serialPort': 'Serial port', 'wk.baud': 'Baud', 'wk.weight': 'Weight', 'wk.ratio': 'Ratio (33-66)', 'wk.sidetone': 'Sidetone (Hz)', 'wk.paddleMode': 'Paddle mode',
|
||||
'wk.swapPaddles': 'Swap paddles', 'wk.autospace': 'Auto-space', 'wk.keyPttShort': 'Key PTT', 'wk.serialEcho': 'Serial echo',
|
||||
'wk.noPorts': 'No ports found', 'wk.reloadPorts': 'Reload ports', 'wk.comPort': '— COM port —',
|
||||
'wk.macroTitle': 'CW message macros (F1…)', 'wk.macroVars': 'Use variables:', 'wk.macroCut': '(cut numbers: 9→N, 0→T).', 'wk.macroMyCall': 'my call', 'wk.macroHisCall': 'his call', 'wk.macroLog': 'log the QSO when the macro is sent', 'wk.label': 'Label',
|
||||
'sec.relayauto': 'Relay auto-control',
|
||||
'relayauto.hint': 'Automatically switch Station Control relays from the rig frequency / band (like PstRotator). Each relay: a frequency window (ON inside, OFF outside) or a set of bands. Relays are set up in the Station Control panel.',
|
||||
'relayauto.enable': 'Enable relay auto-control',
|
||||
@@ -291,7 +312,7 @@ const en: Dict = {
|
||||
// Misc panels/modals (alerts / send-spot / net / udp / filter / details)
|
||||
'altm.filterPh': 'Filter…', 'altm.noMatch': 'no match', 'altm.noneAll': 'none selected = ALL', 'altm.nSelected': '{n} selected', 'altm.giveName': 'Give the rule a name', 'altm.deleteConfirm': 'Delete alert "{name}"?', 'altm.title': 'Alert management', 'altm.desc': 'Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).', 'altm.rules': 'Rules', 'altm.noRules': 'No rules yet — click +', 'altm.emailTo': 'Alert e-mail to', 'altm.selectOrCreate': 'Select or create a rule.', 'altm.tabDef': 'Definition', 'altm.tabCall': 'Call / DXCC', 'altm.tabBandMode': 'Band / Mode', 'altm.tabOrigin': 'Origin', 'altm.ruleName': 'Rule name', 'altm.alertEnabled': 'Alert enabled', 'altm.againAfter': 'Alert again after (min)', 'altm.againHint': '0 = once/session · -1 = always', 'altm.actions': 'Actions', 'altm.visual': 'Visual', 'altm.sound': 'Sound', 'altm.email': 'E-mail', 'altm.skipWorked': 'Skip calls already worked (same band + mode)', 'altm.callsigns': 'Callsigns (one per line, wildcards: IW3*, */P)', 'altm.countries': 'Countries (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bands', 'altm.modes': 'Modes', 'altm.spotterCall': 'Spotter callsign (wildcard)', 'altm.spotterCallPh': 'e.g. F* or DL1ABC', 'altm.spotterContinents': 'Spotter continents', 'altm.spotterCountries': 'Spotter countries', 'altm.delete': 'Delete', 'altm.saveRule': 'Save rule', 'altm.close': 'Close',
|
||||
'spm.callRequired': 'Callsign required', 'spm.freqRequired': 'Frequency (kHz) required', 'spm.title': 'Send DX Spot', 'spm.callsign': 'Callsign', 'spm.callPh': 'DX call', 'spm.frequency': 'Frequency (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'e.g. CW · TNX QSO', 'spm.latestQsos': 'Latest QSOs', 'spm.spotSent': 'Spot sent ✓', 'spm.masterCluster': 'Master cluster', 'spm.cancel': 'Cancel', 'spm.sending': 'Sending…', 'spm.sendSpot': 'Send spot',
|
||||
'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Worked before', 'ncp.wbHint': 'Click a station (on air or roster) to see prior QSOs', 'ncp.wbNone': 'No prior QSO with', 'ncp.wbFirst': 'first', 'ncp.wbLast': 'last', 'ncp.wbResize': 'Drag to resize', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'double-click → edit all fields · "Log & end" to save', 'ncp.logEndSelected': 'Log & end selected', 'ncp.logAll': 'Log everyone ({n})', 'ncp.logAllConfirm': 'Log all {n} on-air station(s) to the logbook?', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET',
|
||||
'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Worked before', 'ncp.wbHint': 'Click a station (on air or roster) to see prior QSOs', 'ncp.wbNone': 'No prior QSO with', 'ncp.wbFirst': 'first', 'ncp.wbLast': 'last', 'ncp.wbResize': 'Drag to resize', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'mic-pass order · ⬆⬇ to reorder · double-click → edit · "Log & end" to save', 'ncp.moveUp': 'Move up the mic-pass order', 'ncp.moveDown': 'Move down the mic-pass order', 'ncp.logEndSelected': 'Log & end selected', 'ncp.logAll': 'Log everyone ({n})', 'ncp.logAllConfirm': 'Log all {n} on-air station(s) to the logbook?', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET',
|
||||
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcDbLabel': 'ADIF Message', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.svcPstLabel': 'PstRotator frequency', 'udpp.svcPstHint': 'Sends the rig frequency as <PST><FREQUENCY> whenever it changes — set PstRotatorAz tracker to DXLog.net (default port 12040).', 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (freq + mode)', 'udpp.svcN1mmRadioHint': 'Broadcasts the rig frequency/mode as N1MM Logger+ RadioInfo XML on every change — consumed by PstRotator (N1MM tracker) and many other tools.', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'UDP connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save',
|
||||
'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'QSL sent', 'fltb.fQslRcvd': 'QSL rcvd', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent', 'fltb.fLotwRcvd': 'LoTW rcvd', 'fltb.fEqslSent': 'eQSL sent', 'fltb.fEqslRcvd': 'eQSL rcvd', 'fltb.fQrzUpload': 'QRZ upload status', 'fltb.fClublogUpload': 'ClubLog upload status', 'fltb.fHrdlogUpload': 'HRDLog upload status', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Station callsign (my call)', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close',
|
||||
'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ zone', 'detp.ituZone': 'ITU zone', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via', 'detp.detected': 'Detected — this contact will count for:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email',
|
||||
@@ -351,6 +372,12 @@ const fr: Dict = {
|
||||
'view.refresh': 'Rafraîchir', 'view.clearFilters': 'Effacer les filtres',
|
||||
'tools.qslManager': 'Gestionnaire QSL…', 'tools.qslDesigner': 'Créateur de carte QSL…',
|
||||
'tools.winkeyer': 'Manipulateur CW WinKeyer', 'tools.dvk': 'Manipulateur vocal numérique', 'tools.cwDecoder': 'Décodeur CW (audio RX)',
|
||||
'cwd.listening': 'écoute…', 'cwd.clear': 'Effacer', 'cwd.useCall': "Utiliser comme indicatif",
|
||||
'cwd.pitchTip': "Verrouille le décodeur sur cette tonalité (Hz). Vide = suit le pitch CW de la radio / recherche auto.",
|
||||
'cwd.levelTip': 'Niveau audio {pct}%',
|
||||
'cwd.tipOnCw': 'Décodeur CW — actif (décode) · clic pour désactiver',
|
||||
'cwd.tipOnIdle': 'Décodeur CW — actif, en veille hors mode CW · clic pour désactiver',
|
||||
'cwd.tipOff': 'Décodeur CW · clic pour activer (décode l’audio RX en mode CW)',
|
||||
'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest',
|
||||
'alert.tuneHint': 'Cliquer pour accorder la radio sur ce spot (fréq + mode) et remplir l\'indicatif', 'alert.dismiss': 'Fermer', 'alert.pending': '{n} alerte(s) de spot récente(s) — cliquer pour voir', 'alert.noneShort': 'Aucune alerte récente', 'alert.recent': 'Alertes récentes', 'alert.clear': 'Effacer',
|
||||
'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog', 'tools.duplicates': 'Trouver les doublons…',
|
||||
@@ -449,6 +476,21 @@ const fr: Dict = {
|
||||
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse la poignée à gauche d un panneau pour le déplacer. Choisis un nombre de colonnes pour la disposition.', 'station.dragMove': 'Glisser pour déplacer ce panneau', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer', 'station.test': 'Tester la connexion', 'station.testOk': 'Connecté — {n} relais', 'station.testFail': 'Non connecté', 'station.detectNone': 'Aucune carte FTDI trouvée — vérifie le câble et que le driver D2XX est installé.', 'station.detectFound': '{n} carte(s) détectée(s).',
|
||||
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
|
||||
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplificateur', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
||||
// Panneau Manipulateur CW
|
||||
'wk.enable': 'Activer le manipulateur CW (affiche le panneau)', 'wk.engine': 'Moteur du manipulateur',
|
||||
'wk.escClears': "ÉCHAP efface aussi l'indicatif (sinon ÉCHAP arrête seulement la transmission)",
|
||||
'wk.engWinkeyer': 'WinKeyer (K1EL, série)', 'wk.engSerial': 'Port série (DTR=CW / RTS=PTT)', 'wk.engIcom': 'Icom CI-V (keyer de la radio)', 'wk.engFlex': 'FlexRadio (CWX)', 'wk.engTci': 'TCI (bientôt)',
|
||||
'wk.icomNote': "L'Icom CI-V manipule la CW via le keyer interne de la radio sur la connexion CAT existante (commande 0x17) — il réutilise le port COM CAT défini dans Réglages → CAT, rien d'autre à câbler ici. Mets la radio en mode CW. Poids, ratio, sidetone, mode paddle… se règlent sur la radio ; seule la vitesse est définie ici (KEY SPEED de la radio).",
|
||||
'wk.flexNote': "FlexRadio manipule la CW via le keyer CWX de la radio sur la connexion SmartSDR CAT existante — pas besoin de WinKeyer ni de SmartCAT. Il réutilise la connexion définie dans Réglages → CAT, rien d'autre à câbler ici. Mets une slice en mode CW. Seule la vitesse est définie ici ; poids, sidetone et break-in se règlent sur la radio (le break-in doit être activé pour que la CW parte vraiment).",
|
||||
'wk.catWarnIcom': "Ton backend CAT est réglé sur {backend}. La CW Icom CI-V nécessite le backend CAT réglé sur Icom et connecté — change-le dans Réglages → Interface CAT, sinon l'envoi CW échouera.",
|
||||
'wk.catWarnFlex': "Ton backend CAT est réglé sur {backend}. Le Flex CWX nécessite le backend CAT réglé sur FlexRadio et connecté — change-le dans Réglages → Interface CAT, sinon l'envoi CW échouera.",
|
||||
'wk.keyingPort': 'Port COM de manip', 'wk.cwKeyLine': 'Ligne de manip CW', 'wk.lineDtr': 'DTR = CW, RTS = PTT', 'wk.lineRts': 'RTS = CW, DTR = PTT',
|
||||
'wk.speed': 'Vitesse (WPM)', 'wk.farnsworth': 'Farnsworth', 'wk.leadIn': 'Lead-in (ms)', 'wk.tail': 'Tail (ms)', 'wk.keyPtt': 'Manipuler la ligne PTT',
|
||||
'wk.invert': "Inverser le keying (actif-BAS) — à cocher seulement si la radio émet au repos / manipule à l'envers",
|
||||
'wk.serialPort': 'Port série', 'wk.baud': 'Baud', 'wk.weight': 'Poids', 'wk.ratio': 'Ratio (33-66)', 'wk.sidetone': 'Sidetone (Hz)', 'wk.paddleMode': 'Mode paddle',
|
||||
'wk.swapPaddles': 'Inverser les paddles', 'wk.autospace': 'Auto-espacement', 'wk.keyPttShort': 'Manipuler PTT', 'wk.serialEcho': 'Écho série',
|
||||
'wk.noPorts': 'Aucun port trouvé', 'wk.reloadPorts': 'Recharger les ports', 'wk.comPort': '— port COM —',
|
||||
'wk.macroTitle': 'Macros de messages CW (F1…)', 'wk.macroVars': 'Variables :', 'wk.macroCut': '(chiffres abrégés : 9→N, 0→T).', 'wk.macroMyCall': 'mon indicatif', 'wk.macroHisCall': 'son indicatif', 'wk.macroLog': 'logue le QSO quand la macro est envoyée', 'wk.label': 'Libellé',
|
||||
'sec.relayauto': 'Relais automatiques',
|
||||
'relayauto.hint': "Commute automatiquement les relais du Station Control selon la fréquence / bande du poste (comme PstRotator). Par relais : une plage de fréquence (ON dedans, OFF dehors) ou un ensemble de bandes. Les relais se configurent dans le panneau Station Control.",
|
||||
'relayauto.enable': 'Activer les relais automatiques',
|
||||
@@ -589,7 +631,7 @@ const fr: Dict = {
|
||||
'qrz.openTitle': 'Ouvrir {call} sur QRZ.com',
|
||||
'altm.filterPh': 'Filtrer…', 'altm.noMatch': 'aucun résultat', 'altm.noneAll': 'aucune sélection = TOUT', 'altm.nSelected': '{n} sélectionné(s)', 'altm.giveName': 'Donne un nom à la règle', 'altm.deleteConfirm': "Supprimer l'alerte « {name} » ?", 'altm.title': 'Gestion des alertes', 'altm.desc': 'Alerte quand un spot correspond à une règle. Filtres vides = TOUT ; les filtres définis sont combinés par ET (ex. France + 20m = stations françaises sur 20m).', 'altm.rules': 'Règles', 'altm.noRules': 'Aucune règle — clique sur +', 'altm.emailTo': "E-mail d'alerte à", 'altm.selectOrCreate': 'Sélectionne ou crée une règle.', 'altm.tabDef': 'Définition', 'altm.tabCall': 'Indicatif / DXCC', 'altm.tabBandMode': 'Bande / Mode', 'altm.tabOrigin': 'Origine', 'altm.ruleName': 'Nom de la règle', 'altm.alertEnabled': 'Alerte activée', 'altm.againAfter': 'Réalerter après (min)', 'altm.againHint': '0 = une fois/session · -1 = toujours', 'altm.actions': 'Actions', 'altm.visual': 'Visuel', 'altm.sound': 'Son', 'altm.email': 'E-mail', 'altm.skipWorked': 'Ignorer les indicatifs déjà contactés (même bande + mode)', 'altm.callsigns': 'Indicatifs (un par ligne, jokers : IW3*, */P)', 'altm.countries': 'Pays (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bandes', 'altm.modes': 'Modes', 'altm.spotterCall': 'Indicatif du spotteur (joker)', 'altm.spotterCallPh': 'ex. F* ou DL1ABC', 'altm.spotterContinents': 'Continents du spotteur', 'altm.spotterCountries': 'Pays du spotteur', 'altm.delete': 'Supprimer', 'altm.saveRule': 'Enregistrer la règle', 'altm.close': 'Fermer',
|
||||
'spm.callRequired': 'Indicatif requis', 'spm.freqRequired': 'Fréquence (kHz) requise', 'spm.title': 'Envoyer un spot DX', 'spm.callsign': 'Indicatif', 'spm.callPh': 'Indicatif DX', 'spm.frequency': 'Fréquence (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'ex. CW · TNX QSO', 'spm.latestQsos': 'Derniers QSO', 'spm.spotSent': 'Spot envoyé ✓', 'spm.masterCluster': 'Cluster maître', 'spm.cancel': 'Annuler', 'spm.sending': 'Envoi…', 'spm.sendSpot': 'Envoyer le spot',
|
||||
'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Déjà contacté', 'ncp.wbHint': 'Cliquer une station (on air ou roster) pour voir les QSO précédents', 'ncp.wbNone': 'Aucun QSO précédent avec', 'ncp.wbFirst': 'premier', 'ncp.wbLast': 'dernier', 'ncp.wbResize': 'Glisser pour redimensionner', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'double-clic → éditer tous les champs · « Logger & terminer » pour enregistrer', 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.logAll': 'Logger tout le monde ({n})', 'ncp.logAllConfirm': 'Logger les {n} station(s) on air dans le logbook ?', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET',
|
||||
'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Déjà contacté', 'ncp.wbHint': 'Cliquer une station (on air ou roster) pour voir les QSO précédents', 'ncp.wbNone': 'Aucun QSO précédent avec', 'ncp.wbFirst': 'premier', 'ncp.wbLast': 'dernier', 'ncp.wbResize': 'Glisser pour redimensionner', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'ordre de passage du micro · ⬆⬇ pour réordonner · double-clic → éditer · « Logger & terminer »', 'ncp.moveUp': "Monter dans l'ordre de passage", 'ncp.moveDown': "Descendre dans l'ordre de passage", 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.logAll': 'Logger tout le monde ({n})', 'ncp.logAllConfirm': 'Logger les {n} station(s) on air dans le logbook ?', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET',
|
||||
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcDbLabel': "ADIF Message", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.svcPstLabel': 'Fréquence PstRotator', 'udpp.svcPstHint': "Envoie la fréquence du poste en <PST><FREQUENCY> à chaque changement — règle le tracker de PstRotatorAz sur DXLog.net (port 12040 par défaut).", 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (fréq + mode)', 'udpp.svcN1mmRadioHint': "Diffuse la fréquence/mode du poste en XML RadioInfo N1MM Logger+ à chaque changement — lu par PstRotator (tracker N1MM) et beaucoup d'autres outils.", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions UDP permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer',
|
||||
'fltb.fCallsign': 'Indicatif', 'fltb.fDate': 'Date / heure (UTC)', 'fltb.fEndDate': 'Date / heure de fin', 'fltb.fBand': 'Bande', 'fltb.fRxBand': 'Bande RX', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Sous-mode', 'fltb.fFreq': 'Fréquence (Hz)', 'fltb.fRxFreq': 'Fréquence RX (Hz)', 'fltb.fRstSent': 'RST envoyé', 'fltb.fRstRcvd': 'RST reçu', 'fltb.fName': 'Nom', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Adresse', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Locator', 'fltb.fCountry': 'Pays', 'fltb.fState': 'État', 'fltb.fCounty': 'Comté', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'Zone CQ', 'fltb.fItuz': 'Zone ITU', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'Réf SOTA', 'fltb.fPota': 'Réf POTA', 'fltb.fWwff': 'Réf WWFF', 'fltb.fRig': 'Station', 'fltb.fAntenna': 'Antenne', 'fltb.fQslSent': 'QSL envoyée', 'fltb.fQslRcvd': 'QSL reçue', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW envoyé', 'fltb.fLotwRcvd': 'LoTW reçu', 'fltb.fEqslSent': 'eQSL envoyé', 'fltb.fEqslRcvd': 'eQSL reçu', 'fltb.fQrzUpload': 'Statut upload QRZ', 'fltb.fClublogUpload': 'Statut upload ClubLog', 'fltb.fHrdlogUpload': 'Statut upload HRDLog', 'fltb.fContestId': 'ID contest', 'fltb.fSerialRcvd': 'Numéro reçu', 'fltb.fSerialSent': 'Numéro envoyé', 'fltb.fPropMode': 'Mode de propagation', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Station callsign (mon indicatif)', 'fltb.fOperator': 'Opérateur', 'fltb.fOwnerCallsign': 'Indicatif propriétaire', 'fltb.fMyGrid': 'Mon locator', 'fltb.fMyCountry': 'Mon pays', 'fltb.fMyState': 'Mon état', 'fltb.fMyCounty': 'Mon comté', 'fltb.fMyIota': 'Mon IOTA', 'fltb.fMySota': 'Ma réf SOTA', 'fltb.fMyPota': 'Ma réf POTA', 'fltb.fMyWwff': 'Ma réf WWFF', 'fltb.fMyStreet': 'Ma rue', 'fltb.fMyCity': 'Ma ville', 'fltb.fMyPostal': 'Mon code postal', 'fltb.fMyRig': 'Ma station', 'fltb.fMyAntenna': 'Mon antenne', 'fltb.fTxPower': 'Puissance TX (W)', 'fltb.fComment': 'Commentaire', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer',
|
||||
'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'Zone CQ', 'detp.ituZone': 'Zone ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact',
|
||||
|
||||
@@ -73,16 +73,24 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const load = () => {
|
||||
tries += 1;
|
||||
GetUIPref(LS_KEY).then((raw) => {
|
||||
// Backend ANSWERED (settings store ready). Apply a valid stored value; an
|
||||
// empty/invalid one means the theme is genuinely unset → keep the default.
|
||||
// Either way we're done — do NOT retry (retrying only matters while the
|
||||
// backend is still starting, which now surfaces as a rejected promise).
|
||||
if (cancelled || userPicked.current) return;
|
||||
const v = raw as ThemeChoice;
|
||||
if (v && ALL.includes(v)) {
|
||||
try { localStorage.setItem(LS_KEY, v); } catch { /* quota */ }
|
||||
applyThemeToDom(v); // idempotent — safe to call unconditionally
|
||||
setThemeState(v);
|
||||
return; // restored
|
||||
}
|
||||
if (tries < 8) window.setTimeout(load, 300); // empty (unset or backend not ready yet) → retry
|
||||
}).catch(() => { if (!cancelled && tries < 8) window.setTimeout(load, 300); });
|
||||
}).catch(() => {
|
||||
// Settings store not ready yet — a brief startup window before the backend
|
||||
// has opened the local DB and built the store (the frontend can query it
|
||||
// first). Keep retrying well past the old 2.4s cap so a dark theme isn't
|
||||
// lost to the light default after an update cleared localStorage.
|
||||
if (!cancelled && !userPicked.current && tries < 120) window.setTimeout(load, 300);
|
||||
});
|
||||
};
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.20.10';
|
||||
export const APP_VERSION = '0.20.11';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+16
@@ -86,6 +86,8 @@ export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Prom
|
||||
|
||||
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||
|
||||
export function CWDecoderRunning():Promise<boolean>;
|
||||
|
||||
export function ChatAvailable():Promise<boolean>;
|
||||
|
||||
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||
@@ -162,6 +164,8 @@ export function DownloadAndApplyUpdate(arg1:string):Promise<void>;
|
||||
|
||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||
|
||||
export function DownloadClublogMostWanted():Promise<main.ClublogMostWantedInfo>;
|
||||
|
||||
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
||||
|
||||
export function DownloadLoTWUsers():Promise<number>;
|
||||
@@ -366,6 +370,8 @@ export function GetCATSettings():Promise<main.CATSettings>;
|
||||
|
||||
export function GetCATState():Promise<cat.RigState>;
|
||||
|
||||
export function GetCWDecoderPitch():Promise<number>;
|
||||
|
||||
export function GetCatalogCodes():Promise<Array<string>>;
|
||||
|
||||
export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
|
||||
@@ -374,6 +380,8 @@ export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||
|
||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||
|
||||
export function GetClublogMostWantedInfo():Promise<main.ClublogMostWantedInfo>;
|
||||
|
||||
export function GetClusterAutoConnect():Promise<boolean>;
|
||||
|
||||
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
||||
@@ -854,8 +862,12 @@ export function SetCATFrequency(arg1:number):Promise<void>;
|
||||
|
||||
export function SetCATMode(arg1:string):Promise<void>;
|
||||
|
||||
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
||||
|
||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetClublogMostWantedEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetCompactMode(arg1:boolean):Promise<void>;
|
||||
@@ -870,8 +882,12 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||
|
||||
export function StartCWDecoder():Promise<void>;
|
||||
|
||||
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||
|
||||
export function StopCWDecoder():Promise<void>;
|
||||
|
||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||
|
||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||
|
||||
@@ -126,6 +126,10 @@ export function BulkUpdateQSL(arg1, arg2) {
|
||||
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function CWDecoderRunning() {
|
||||
return window['go']['main']['App']['CWDecoderRunning']();
|
||||
}
|
||||
|
||||
export function ChatAvailable() {
|
||||
return window['go']['main']['App']['ChatAvailable']();
|
||||
}
|
||||
@@ -278,6 +282,10 @@ export function DownloadClublogCty() {
|
||||
return window['go']['main']['App']['DownloadClublogCty']();
|
||||
}
|
||||
|
||||
export function DownloadClublogMostWanted() {
|
||||
return window['go']['main']['App']['DownloadClublogMostWanted']();
|
||||
}
|
||||
|
||||
export function DownloadConfirmations(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3);
|
||||
}
|
||||
@@ -686,6 +694,10 @@ export function GetCATState() {
|
||||
return window['go']['main']['App']['GetCATState']();
|
||||
}
|
||||
|
||||
export function GetCWDecoderPitch() {
|
||||
return window['go']['main']['App']['GetCWDecoderPitch']();
|
||||
}
|
||||
|
||||
export function GetCatalogCodes() {
|
||||
return window['go']['main']['App']['GetCatalogCodes']();
|
||||
}
|
||||
@@ -702,6 +714,10 @@ export function GetClublogCtyInfo() {
|
||||
return window['go']['main']['App']['GetClublogCtyInfo']();
|
||||
}
|
||||
|
||||
export function GetClublogMostWantedInfo() {
|
||||
return window['go']['main']['App']['GetClublogMostWantedInfo']();
|
||||
}
|
||||
|
||||
export function GetClusterAutoConnect() {
|
||||
return window['go']['main']['App']['GetClusterAutoConnect']();
|
||||
}
|
||||
@@ -1662,10 +1678,18 @@ export function SetCATMode(arg1) {
|
||||
return window['go']['main']['App']['SetCATMode'](arg1);
|
||||
}
|
||||
|
||||
export function SetCWDecoderPitch(arg1) {
|
||||
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
|
||||
}
|
||||
|
||||
export function SetClublogCtyEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
||||
}
|
||||
|
||||
export function SetClublogMostWantedEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetClublogMostWantedEnabled'](arg1);
|
||||
}
|
||||
|
||||
export function SetClusterAutoConnect(arg1) {
|
||||
return window['go']['main']['App']['SetClusterAutoConnect'](arg1);
|
||||
}
|
||||
@@ -1694,10 +1718,18 @@ export function SetUltrabeamDirection(arg1) {
|
||||
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
||||
}
|
||||
|
||||
export function StartCWDecoder() {
|
||||
return window['go']['main']['App']['StartCWDecoder']();
|
||||
}
|
||||
|
||||
export function StationSetRelay(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function StopCWDecoder() {
|
||||
return window['go']['main']['App']['StopCWDecoder']();
|
||||
}
|
||||
|
||||
export function SwitchCATRig(arg1) {
|
||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||
}
|
||||
|
||||
@@ -1949,6 +1949,26 @@ export namespace main {
|
||||
this.count = source["count"];
|
||||
}
|
||||
}
|
||||
export class ClublogMostWantedInfo {
|
||||
enabled: boolean;
|
||||
loaded: boolean;
|
||||
callsign: string;
|
||||
date: string;
|
||||
count: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ClublogMostWantedInfo(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.loaded = source["loaded"];
|
||||
this.callsign = source["callsign"];
|
||||
this.date = source["date"];
|
||||
this.count = source["count"];
|
||||
}
|
||||
}
|
||||
export class ContestBandRow {
|
||||
band: string;
|
||||
count: number;
|
||||
@@ -3035,6 +3055,9 @@ export namespace main {
|
||||
autospace: boolean;
|
||||
use_ptt: boolean;
|
||||
serial_echo: boolean;
|
||||
type: string;
|
||||
cw_key_line: string;
|
||||
cw_invert: boolean;
|
||||
engine: string;
|
||||
esc_clears_call: boolean;
|
||||
send_on_type: boolean;
|
||||
@@ -3061,6 +3084,9 @@ export namespace main {
|
||||
this.autospace = source["autospace"];
|
||||
this.use_ptt = source["use_ptt"];
|
||||
this.serial_echo = source["serial_echo"];
|
||||
this.type = source["type"];
|
||||
this.cw_key_line = source["cw_key_line"];
|
||||
this.cw_invert = source["cw_invert"];
|
||||
this.engine = source["engine"];
|
||||
this.esc_clears_call = source["esc_clears_call"];
|
||||
this.send_on_type = source["send_on_type"];
|
||||
@@ -4219,6 +4245,7 @@ export namespace qso {
|
||||
dxcc_bands: string[];
|
||||
dxcc_modes: string[];
|
||||
dxcc_band_modes: BandMode[];
|
||||
mw_rank?: number;
|
||||
band_status: BandStatus[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -4243,6 +4270,7 @@ export namespace qso {
|
||||
this.dxcc_bands = source["dxcc_bands"];
|
||||
this.dxcc_modes = source["dxcc_modes"];
|
||||
this.dxcc_band_modes = this.convertValues(source["dxcc_band_modes"], BandMode);
|
||||
this.mw_rank = source["mw_rank"];
|
||||
this.band_status = this.convertValues(source["band_status"], BandStatus);
|
||||
}
|
||||
|
||||
|
||||
+32
-14
@@ -224,11 +224,14 @@ func (b *IcomSerial) Connect() error {
|
||||
idAddr = f.Data[1]
|
||||
}
|
||||
}
|
||||
// Dual-scope rigs (IC-7610/9700) prefix each waveform frame with a main/sub
|
||||
// selector byte; single-scope rigs (IC-7300…) do not. Decide from the
|
||||
// IDENTIFIED model, NOT the configured address: an IC-7300 run at 0x98 must
|
||||
// still parse single-scope frames (this was the "scope blank on the 7300" bug).
|
||||
b.dualScope = idAddr == 0x98 || idAddr == 0xA2
|
||||
// Whether the rig's scope protocol carries a leading main/sub SELECTOR byte on
|
||||
// every 0x27 frame and command. The IC-7610/9700 (dual scope) do — and the logs
|
||||
// prove the IC-7300 does too (frames arrive as `27 00 00 <seq> <total> …` and a
|
||||
// mode read answers `27 14 00 <mode>`), even though it has a single scope. The
|
||||
// waveform parser auto-detects this per-frame regardless, so a 7300 at a
|
||||
// non-default address still RENDERS; this flag only drives the SET/read commands
|
||||
// (mode, span, edges), which need the 0x00 selector to be accepted on the 7300.
|
||||
b.dualScope = idAddr == 0x98 || idAddr == 0xA2 || idAddr == 0x94
|
||||
// Defer the DSP snapshot until the rig actually answers CI-V. Over the network
|
||||
// the rig may still be booting (or off) at Connect, so an immediate readDSP
|
||||
// would time out and leave every control at 0 / off with no retry. ReadState
|
||||
@@ -603,12 +606,20 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
|
||||
rawN++
|
||||
applog.Printf("icom scope raw #%d: len=%d data=[% X]", rawN, len(f.Data), f.Data)
|
||||
}
|
||||
// Frame layout after the 0x00 sub-command byte is one of:
|
||||
// [selector][seq][total][data…] (IC-7610/9700 AND, as the logs prove,
|
||||
// the IC-7300 — selector 0x00 = main)
|
||||
// [seq][total][data…] (a rig with no selector byte)
|
||||
// The sequence starts at 1, so a 0x00 at Data[1] can only be the main-scope
|
||||
// selector — never a valid sequence. Keying this off the rig ADDRESS was the
|
||||
// long-standing "blank scope on the IC-7300" bug: the 7300 DOES send the
|
||||
// selector, so idx stayed 1, seq was read as 0x00, and every frame was
|
||||
// dropped. Detect it from the byte itself instead — address-independent.
|
||||
idx := 1
|
||||
if b.dualScope {
|
||||
if len(f.Data) < 2 || f.Data[1] != 0x00 {
|
||||
continue // only the MAIN scope
|
||||
}
|
||||
idx = 2
|
||||
if len(f.Data) >= 2 && f.Data[1] == 0x00 {
|
||||
idx = 2 // leading main-scope selector byte present
|
||||
} else if b.dualScope {
|
||||
continue // dual-scope rig SUB frame (selector != 0x00) — main only
|
||||
}
|
||||
if len(f.Data) < idx+2 {
|
||||
continue
|
||||
@@ -725,12 +736,19 @@ func (b *IcomSerial) SetScope(on bool) error {
|
||||
// but nothing shows, compare its `icom scope raw` frames against this.
|
||||
applog.Printf("icom scope: enable on rig=%q addr=0x%02X dualScope=%v (expect %s frame layout)",
|
||||
b.model, b.rigAddr, b.dualScope, map[bool]string{true: "27 00 [MS] [seq] [total] …", false: "27 00 [seq] [total] …"}[b.dualScope])
|
||||
// Turn the scope DISPLAY on — needed so the rig actually streams (esp. when
|
||||
// remote and we can't touch the front panel). ONLY on enable: we must never
|
||||
// switch the display OFF, because that is the operator's own scope on the
|
||||
// radio, and closing OpsLog (SetScope(false)) blanking a local IC-7300's
|
||||
// screen is exactly the regression this avoids. Some firmwares don't ack a
|
||||
// 0x27 set; a timeout isn't fatal, so log and continue.
|
||||
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, 0x01); err != nil {
|
||||
applog.Printf("icom scope: display on ack: %v", err)
|
||||
}
|
||||
// Some firmwares don't ack 0x27 sets; a timeout here isn't fatal, so log and
|
||||
// continue rather than abort the second command.
|
||||
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, boolByte(on)); err != nil {
|
||||
applog.Printf("icom scope: display on=%v ack: %v", on, err)
|
||||
}
|
||||
// Waveform data OUTPUT over CI-V: enabled with the scope, and — crucially —
|
||||
// the ONLY thing we switch off on disable, so the radio's own scope display is
|
||||
// left exactly as the operator had it.
|
||||
if err := b.exec(civ.CmdScope, civ.SubScopeOn, boolByte(on)); err != nil {
|
||||
applog.Printf("icom scope: output on=%v ack: %v", on, err)
|
||||
}
|
||||
|
||||
+15
-4
@@ -267,12 +267,23 @@ func (o *OmniRig) SetFrequency(hz int64) error {
|
||||
// method (RX=TX=freq, simplex). It works on rigs — notably Icom (IC-9100) —
|
||||
// where direct FreqA/FreqB writes are accepted but never move the radio.
|
||||
// Clearing split is the right thing when tuning to a spot anyway.
|
||||
simplexOK := false
|
||||
if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil {
|
||||
simplexOK = true
|
||||
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode(%d) OK", hz32)
|
||||
} else {
|
||||
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v) — using property writes", err)
|
||||
// Fallback: write the active VFO's property AND the generic Freq
|
||||
// (always — some .ini honour only one, and split here is often misread).
|
||||
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v)", err)
|
||||
}
|
||||
|
||||
// On Yaesu / Kenwood (and anything non-Icom), SetSimplexMode frequently returns
|
||||
// OK but is a SILENT NO-OP — the rig never moves. Confirmed on an FT-891 over
|
||||
// OmniRig: every spot click logged "SetSimplexMode OK" yet FreqA stayed put,
|
||||
// while SetMode on the same .ini worked fine (so the CAT link is healthy).
|
||||
// Writing the VFO frequency PROPERTY directly DOES move them, so also do that
|
||||
// here. It is skipped on Icom, where the direct write is the unreliable one and
|
||||
// could nudge the wrong Main/Sub VFO — there SetSimplexMode is authoritative.
|
||||
isIcom := strings.HasPrefix(strings.ToUpper(strings.TrimSpace(rigType)), "IC")
|
||||
if !isIcom || !simplexOK {
|
||||
prop := "FreqA"
|
||||
switch vfo {
|
||||
case "B", "BB", "BA":
|
||||
@@ -287,7 +298,7 @@ func (o *OmniRig) SetFrequency(hz int64) error {
|
||||
okAny = true
|
||||
}
|
||||
}
|
||||
if !okAny {
|
||||
if !simplexOK && !okAny {
|
||||
return fmt.Errorf("OmniRig: no writable frequency property for this rig")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package clublog
|
||||
|
||||
// mostwanted.go — the ClubLog "Most Wanted" DXCC ranking. ClubLog publishes, per
|
||||
// callsign, how wanted each DXCC entity is (rank 1 = the single most wanted, e.g.
|
||||
// P5 North Korea). We fetch it, invert it to a DXCC-number → rank map, cache it on
|
||||
// disk (personalised to the operator's callsign) and refresh daily. The rank is
|
||||
// surfaced next to the entity in the entry-strip band/slot matrix.
|
||||
//
|
||||
// Endpoint (same one DXHunter uses): mostwanted.php?api=1 returns JSON
|
||||
// { "1": "344", "2": "123", ... } rank → ADIF (DXCC) number
|
||||
// Passing &callsign=<CALL> personalises the ranking to that operator's log.
|
||||
// api=1 is just the "return JSON" flag — no API key is needed here.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const mwURL = "https://clublog.org/mostwanted.php?api=1"
|
||||
const mwFile = "clublog_mostwanted.json"
|
||||
|
||||
// mwUserAgent — ClubLog's nginx 403s the default Go user-agent, so set a real one.
|
||||
const mwUserAgent = "OpsLog (amateur radio logger)"
|
||||
|
||||
// mwCache is the on-disk shape (ranks keyed by DXCC number as a string for JSON).
|
||||
type mwCache struct {
|
||||
Callsign string `json:"callsign"`
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
Ranks map[string]int `json:"ranks"`
|
||||
}
|
||||
|
||||
// MostWanted owns the on-disk cache and the parsed DXCC-number → rank map.
|
||||
type MostWanted struct {
|
||||
dir string
|
||||
|
||||
mu sync.RWMutex
|
||||
ranks map[int]int // dxcc number → rank (1 = most wanted)
|
||||
call string // callsign the current ranks were fetched for
|
||||
fetched time.Time
|
||||
}
|
||||
|
||||
func NewMostWanted(dataDir string) *MostWanted {
|
||||
return &MostWanted{dir: dataDir, ranks: map[int]int{}}
|
||||
}
|
||||
|
||||
func (m *MostWanted) path() string { return filepath.Join(m.dir, mwFile) }
|
||||
|
||||
// Rank returns the most-wanted rank for a DXCC number, or 0 if unknown/unloaded.
|
||||
func (m *MostWanted) Rank(dxcc int) int {
|
||||
if dxcc <= 0 {
|
||||
return 0
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.ranks[dxcc]
|
||||
}
|
||||
|
||||
// Ranks returns a copy of the whole DXCC-number → rank map.
|
||||
func (m *MostWanted) Ranks() map[int]int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make(map[int]int, len(m.ranks))
|
||||
for k, v := range m.ranks {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Info reports the callsign the list was fetched for, its date, and entity count.
|
||||
func (m *MostWanted) Info() (call, date string, count int) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
d := ""
|
||||
if !m.fetched.IsZero() {
|
||||
d = m.fetched.Format("2006-01-02")
|
||||
}
|
||||
return m.call, d, len(m.ranks)
|
||||
}
|
||||
|
||||
func (m *MostWanted) Loaded() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.ranks) > 0
|
||||
}
|
||||
|
||||
// LoadFromDisk reads the cached JSON into memory. Does NOT download.
|
||||
func (m *MostWanted) LoadFromDisk() error {
|
||||
b, err := os.ReadFile(m.path())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var c mwCache
|
||||
if err := json.Unmarshal(b, &c); err != nil {
|
||||
return err
|
||||
}
|
||||
ranks := make(map[int]int, len(c.Ranks))
|
||||
for k, v := range c.Ranks {
|
||||
if n, err := strconv.Atoi(k); err == nil && n > 0 && v > 0 {
|
||||
ranks[n] = v
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.ranks = ranks
|
||||
m.call = c.Callsign
|
||||
m.fetched = c.FetchedAt
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// NeedsRefresh reports whether the list is empty, older than maxAge, or was
|
||||
// fetched for a DIFFERENT callsign (the ranking is personalised, so a profile
|
||||
// switch invalidates it).
|
||||
func (m *MostWanted) NeedsRefresh(callsign string, maxAge time.Duration) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if len(m.ranks) == 0 {
|
||||
return true
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(callsign), m.call) {
|
||||
return true
|
||||
}
|
||||
return time.Since(m.fetched) > maxAge
|
||||
}
|
||||
|
||||
// Download fetches the most-wanted list for callsign, writes it atomically, and
|
||||
// swaps it into memory.
|
||||
func (m *MostWanted) Download(ctx context.Context, callsign string) error {
|
||||
if err := os.MkdirAll(m.dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
call := strings.ToUpper(strings.TrimSpace(callsign))
|
||||
url := mwURL
|
||||
if call != "" {
|
||||
url += "&callsign=" + call
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", mwUserAgent)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("clublog mostwanted HTTP %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// { "1": "344", "2": "123", ... } — rank → ADIF (DXCC) number.
|
||||
var raw map[string]string
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return fmt.Errorf("clublog mostwanted parse: %w", err)
|
||||
}
|
||||
ranks := make(map[int]int, len(raw))
|
||||
strRanks := make(map[string]int, len(raw))
|
||||
for rankStr, adif := range raw {
|
||||
rank, e1 := strconv.Atoi(strings.TrimSpace(rankStr))
|
||||
dxcc, e2 := strconv.Atoi(strings.TrimSpace(adif))
|
||||
if e1 == nil && e2 == nil && rank > 0 && dxcc > 0 {
|
||||
ranks[dxcc] = rank
|
||||
strRanks[adif] = rank
|
||||
}
|
||||
}
|
||||
if len(ranks) == 0 {
|
||||
return fmt.Errorf("clublog mostwanted: empty/invalid response")
|
||||
}
|
||||
now := time.Now()
|
||||
if b, err := json.Marshal(mwCache{Callsign: call, FetchedAt: now, Ranks: strRanks}); err == nil {
|
||||
tmp := m.path() + ".tmp"
|
||||
if os.WriteFile(tmp, b, 0o644) == nil {
|
||||
_ = os.Rename(tmp, m.path())
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.ranks = ranks
|
||||
m.call = call
|
||||
m.fetched = now
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
// Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of
|
||||
// mono PCM samples into decoded text.
|
||||
//
|
||||
// This is the second generation of the decoder. The first one worked on clean
|
||||
// machine keying but fell apart on real signals; every stage below exists to
|
||||
// fix a specific failure of that version (and of naïve Goertzel decoders in
|
||||
// general):
|
||||
//
|
||||
// audio ─ Hamming-windowed Goertzel bank ─ pitch lock ─ dB envelope with
|
||||
// separate noise-floor / peak trackers ─ hysteresis slicer + SNR squelch ─
|
||||
// DEBOUNCED mark/space stream ─ two-cluster dit/dah length tracking ─
|
||||
// element / character / word segmentation ─ Morse table ─ text
|
||||
//
|
||||
// The fixes that matter, in order of impact:
|
||||
//
|
||||
// 1. Debounced transitions BOTH ways. The old decoder rejected too-short
|
||||
// marks but not too-short SPACES, so a one-hop fade inside a dah (QSB,
|
||||
// static crash) split it into two dits — the single biggest source of
|
||||
// garbage on real signals. Here a state flip must persist for a glitch
|
||||
// time (~0.3 dit) before it is committed; shorter flips are folded back
|
||||
// into the surrounding element.
|
||||
//
|
||||
// 2. Two-cluster timing. Dit and dah lengths are tracked as two separate
|
||||
// moving centres with the decision boundary at their geometric mean,
|
||||
// instead of one EMA "dot length" that both classifies marks and is
|
||||
// updated by that same classification (a feedback loop that spiralled to
|
||||
// "all dits at 60 WPM" the moment it started mis-classifying). The
|
||||
// inter-element gaps also feed the dit centre — spaces are timing
|
||||
// evidence too, and hand keying is often more regular in its gaps than
|
||||
// in its dits.
|
||||
//
|
||||
// 3. dB-domain envelope. Peak and noise floor are tracked in dB with
|
||||
// asymmetric attack/decay, the slicer runs at 55%/38% of the span with
|
||||
// hysteresis, and a minimum-span squelch (6 dB) keeps pure noise from
|
||||
// keying at all. In the old linear-magnitude scheme weak signals lived
|
||||
// in the bottom few percent of the scale and QSB swallowed them.
|
||||
//
|
||||
// 4. Windowed Goertzel. A Hamming window tames spectral leakage so a strong
|
||||
// tone doesn't bleed across the whole bank and corrupt both the noise
|
||||
// estimate and the lock choice.
|
||||
//
|
||||
// Kept from the first version because they were right: the pitch LOCK (decode
|
||||
// one tone, ignore QRM at other pitches), pitch targeting (follow the radio's
|
||||
// known CW pitch instead of searching — SetTarget), tiered acquisition
|
||||
// (strong signals lock on the first hop so their opening dit isn't eaten),
|
||||
// the floor frozen during key-down (a rising floor mid-dah fragments it), and
|
||||
// the end-of-over flush when the lock releases.
|
||||
//
|
||||
// Deliberately dependency-free and fed by plain []int16 so the whole pipeline
|
||||
// is unit-tested with synthetic signals (see cwdecode_test.go: clean keying at
|
||||
// several speeds, added noise, QSB fading, QRM on a nearby pitch, and a
|
||||
// noise-only squelch test).
|
||||
//
|
||||
// Honest expectations: on clean or moderately noisy signals this decodes
|
||||
// solidly; very weak signals in heavy QRM remain hard for any envelope
|
||||
// decoder — the tools that shine there (CW Skimmer, SDC) use probabilistic
|
||||
// sequence estimation on top. This stage is designed so such a layer could be
|
||||
// added later without touching the DSP.
|
||||
package cwdecode
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Status is a periodic snapshot for the UI (pitch lock, speed, signal).
|
||||
type Status struct {
|
||||
WPM int `json:"wpm"`
|
||||
Pitch int `json:"pitch"` // Hz of the locked tone (0 = not locked)
|
||||
Level float64 `json:"level"` // 0..1 input audio level (RMS) for the meter
|
||||
Active bool `json:"active"` // a tone is currently keyed down
|
||||
}
|
||||
|
||||
// Decoder consumes PCM and emits decoded characters via onChar (one or more
|
||||
// characters at a time, including " " for word gaps) and periodic onStatus.
|
||||
// Process must be called from a single goroutine; SetTarget is safe to call
|
||||
// concurrently.
|
||||
type Decoder struct {
|
||||
fs int
|
||||
hop int // samples between analyses (~6 ms)
|
||||
win int // Goertzel window length (~20 ms)
|
||||
hopMs float64 // hop duration in ms
|
||||
biasMs float64 // envelope widening caused by the analysis window (see below)
|
||||
|
||||
window []float64 // Hamming window, len win
|
||||
ring []float64 // circular raw-sample buffer, len win
|
||||
rpos int // next write position in ring
|
||||
filled int // samples written so far (until >= win)
|
||||
acc int // samples since last analysis
|
||||
|
||||
ws []float64 // scratch: windowed samples for this hop
|
||||
|
||||
// Search bank (only run while unlocked / untargeted).
|
||||
freqs []float64
|
||||
coeffs []float64
|
||||
mags []float64 // dB per bin
|
||||
nbuf []float64 // scratch for the median
|
||||
|
||||
// Fixed-pitch target (Hz). 0 = auto-search; >0 = decode exactly this pitch
|
||||
// and ignore everything else (e.g. follow the radio's CW pitch). Set live
|
||||
// from another goroutine, so it's atomic.
|
||||
targetHz atomic.Int32
|
||||
targetFor float64 // freq the cached target coeff was computed for
|
||||
targetCoeff float64
|
||||
|
||||
// Pitch lock.
|
||||
lockIdx int // bin index while auto-locked; -1 = unlocked
|
||||
candIdx int
|
||||
candHops int
|
||||
quietHops int // consecutive key-up hops while locked (drives release)
|
||||
bankTick int // hops since the bank last ran while locked
|
||||
betterHops int // evidence that a different bin is the real signal
|
||||
|
||||
// Envelope (dB domain) on the locked/target tone.
|
||||
floorDB, peakDB float64
|
||||
bankNoiseDB float64 // broadband reference: EMA of the bank median
|
||||
haveBankNoise bool
|
||||
envSeeded bool
|
||||
rawKey bool // slicer output this hop
|
||||
|
||||
// Debounced mark/space state machine.
|
||||
key bool // committed state (true = mark)
|
||||
stableHops int // hops in the committed state
|
||||
pendHops int // consecutive hops the raw state has disagreed
|
||||
|
||||
// Two-cluster element timing (ms).
|
||||
muDit, muDah float64
|
||||
marksSeen int
|
||||
|
||||
// Character assembly. Element DURATIONS are stored and only classified
|
||||
// into dits/dahs when the character is flushed: by then the character's
|
||||
// own marks are all known, and a bimodal batch carries its own dit/dah
|
||||
// boundary — so even the very first character of an over decodes
|
||||
// correctly at any speed, before the global clusters have converged.
|
||||
elemMs []float64
|
||||
charEmitted bool
|
||||
wordEmitted bool
|
||||
textSince bool // something was decoded since lock (guards leading spaces)
|
||||
|
||||
lastPitch float64
|
||||
lastRMS float64
|
||||
|
||||
statusEvery int
|
||||
sinceStatus int
|
||||
|
||||
onChar func(string)
|
||||
onStatus func(Status)
|
||||
}
|
||||
|
||||
var morse = map[string]byte{
|
||||
".-": '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',
|
||||
".-.-.-": '.', "--..--": ',', "..--..": '?', "-..-.": '/', "-...-": '=',
|
||||
".-.-.": '+', "-.-.--": '!', "---...": ':', "-....-": '-', ".--.-.": '@',
|
||||
}
|
||||
|
||||
// Tunables (hops are ~6 ms).
|
||||
const (
|
||||
minDitMs = 20.0 // 60 WPM ceiling
|
||||
maxDitMs = 240.0 // 5 WPM floor
|
||||
seedDit = 60.0 // 20 WPM seed before any marks are seen
|
||||
|
||||
acqStrongDB = 12.0 // lock on the FIRST hop above this SNR (don't eat the opening dit)
|
||||
acqWeakDB = 8.5 // lock after sustained hops above this SNR
|
||||
// Successive analysis windows overlap ~70%, so consecutive hops are highly
|
||||
// correlated — a noise spike easily "persists" 2–3 hops. Requiring ~2 full
|
||||
// window lengths of persistence makes a false noise lock genuinely rare.
|
||||
acqWeakHops = 6
|
||||
|
||||
squelchDB = 6.0 // minimum peak-floor span to key at all
|
||||
// The span alone can't reject pure noise: a noise bin's dB level swings
|
||||
// ±5–6 dB, which the peak/floor trackers happily turn into a keyable
|
||||
// span. The second squelch is ABSOLUTE: the envelope peak must stand well
|
||||
// above the broadband noise reference (the bank median) — a keyed tone
|
||||
// does, noise never sustainably does.
|
||||
snrSquelchDB = 9.5
|
||||
|
||||
// The slicer's span is CAPPED: with a very quiet background (high SNR, or
|
||||
// digitally silent test signals) the raw floor sits so far below the peak
|
||||
// that the off-threshold lands in the analysis window's skirts — every
|
||||
// mark then stretches by almost a full window and every gap shrinks,
|
||||
// until character gaps fall below the segmentation threshold and letters
|
||||
// merge. Capping the usable span keeps the slicer crossing near the
|
||||
// signal edges regardless of how quiet the background is.
|
||||
spanCapDB = 30.0
|
||||
|
||||
onFrac = 0.55 // slicer thresholds as a fraction of the (capped) span…
|
||||
offFrac = 0.38 // …with hysteresis
|
||||
|
||||
charGapDits = 2.2 // gap > this ⇒ character boundary (geom. mean of 1 & 3 ≈ 1.7, plus margin for sloppy fists)
|
||||
wordGapDits = 4.6 // gap > this ⇒ word boundary (geom. mean of 3 & 7)
|
||||
)
|
||||
|
||||
// New builds a decoder for the given sample rate. onChar receives decoded text
|
||||
// incrementally; onStatus receives ~10 snapshots/second. Either may be nil.
|
||||
func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
|
||||
if sampleRate <= 0 {
|
||||
sampleRate = 16000
|
||||
}
|
||||
d := &Decoder{
|
||||
fs: sampleRate,
|
||||
hop: sampleRate * 5 / 1000, // 5 ms — resolves dits up to ~50 WPM
|
||||
win: sampleRate * 16 / 1000, // 16 ms window (≈80 Hz bandwidth with Hamming; a 20 ms window left 40 WPM inter-element gaps with almost no envelope dip)
|
||||
lockIdx: -1,
|
||||
candIdx: -1,
|
||||
muDit: seedDit,
|
||||
muDah: 3 * seedDit,
|
||||
onChar: onChar,
|
||||
onStatus: onStatus,
|
||||
}
|
||||
if d.hop < 1 {
|
||||
d.hop = 1
|
||||
}
|
||||
if d.win < 4*d.hop {
|
||||
d.win = 4 * d.hop
|
||||
}
|
||||
d.hopMs = float64(d.hop) / float64(d.fs) * 1000
|
||||
// Even with the span cap, the analysis window widens every mark and
|
||||
// narrows every gap by roughly half a window on each edge (the tone leaks
|
||||
// into windows that straddle an edge). Durations are de-biased by this
|
||||
// constant so the timing clusters and gap thresholds see true lengths.
|
||||
d.biasMs = 0.6 * float64(d.win) / float64(d.fs) * 1000
|
||||
d.statusEvery = int(math.Max(1, 100/d.hopMs)) // ~10 Hz
|
||||
d.ring = make([]float64, d.win)
|
||||
d.ws = make([]float64, d.win)
|
||||
// Hamming window: −43 dB sidelobes keep a strong tone from bleeding across
|
||||
// the bank (rectangular Goertzel leaks at −13 dB, enough to fool the lock).
|
||||
d.window = make([]float64, d.win)
|
||||
for i := range d.window {
|
||||
d.window[i] = 0.54 - 0.46*math.Cos(2*math.Pi*float64(i)/float64(d.win-1))
|
||||
}
|
||||
// Candidate CW tones: 400–1000 Hz every 30 Hz. Deliberately NOT lower:
|
||||
// low-frequency hum/rumble rises toward DC and would win the argmax.
|
||||
for f := 400.0; f <= 1000.0; f += 30 {
|
||||
d.freqs = append(d.freqs, f)
|
||||
d.coeffs = append(d.coeffs, 2*math.Cos(2*math.Pi*f/float64(d.fs)))
|
||||
}
|
||||
d.mags = make([]float64, len(d.freqs))
|
||||
d.nbuf = make([]float64, len(d.freqs))
|
||||
return d
|
||||
}
|
||||
|
||||
// SetTarget fixes the decode pitch to hz (an exact-frequency detector, not the
|
||||
// nearest search bin), or returns to auto-search when hz <= 0. Safe to call
|
||||
// concurrently.
|
||||
func (d *Decoder) SetTarget(hz int) { d.targetHz.Store(int32(hz)) }
|
||||
|
||||
// Reset clears decode state (e.g. when the user re-arms the decoder).
|
||||
func (d *Decoder) Reset() {
|
||||
d.rpos, d.filled, d.acc = 0, 0, 0
|
||||
d.lockIdx, d.candIdx, d.candHops, d.quietHops = -1, -1, 0, 0
|
||||
d.envSeeded, d.rawKey, d.key = false, false, false
|
||||
d.haveBankNoise = false
|
||||
d.stableHops, d.pendHops = 0, 0
|
||||
d.bankTick, d.betterHops = 0, 0
|
||||
d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0
|
||||
d.elemMs = d.elemMs[:0]
|
||||
d.charEmitted, d.wordEmitted, d.textSince = true, true, false
|
||||
}
|
||||
|
||||
// Process feeds a block of mono samples through the decoder.
|
||||
func (d *Decoder) Process(samples []int16) {
|
||||
for _, s := range samples {
|
||||
d.ring[d.rpos] = float64(s)
|
||||
d.rpos++
|
||||
if d.rpos == d.win {
|
||||
d.rpos = 0
|
||||
}
|
||||
if d.filled < d.win {
|
||||
d.filled++
|
||||
}
|
||||
d.acc++
|
||||
if d.acc >= d.hop && d.filled >= d.win {
|
||||
d.acc = 0
|
||||
d.hopStep()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// goertzelDB returns the tone power (dB, arbitrary reference) of the windowed
|
||||
// scratch buffer at the detector coefficient c.
|
||||
func (d *Decoder) goertzelDB(c float64) float64 {
|
||||
var s1, s2 float64
|
||||
for _, x := range d.ws {
|
||||
s0 := x + c*s1 - s2
|
||||
s2 = s1
|
||||
s1 = s0
|
||||
}
|
||||
p := s1*s1 + s2*s2 - c*s1*s2
|
||||
if p < 1e-12 {
|
||||
p = 1e-12
|
||||
}
|
||||
return 10 * math.Log10(p)
|
||||
}
|
||||
|
||||
// hopStep runs one analysis hop: tone detection, envelope, slicer, timing.
|
||||
func (d *Decoder) hopStep() {
|
||||
// Materialize the window (oldest→newest; order is irrelevant for power)
|
||||
// and the RMS level for the UI meter.
|
||||
var sumSq float64
|
||||
for i := 0; i < d.win; i++ {
|
||||
x := d.ring[(d.rpos+i)%d.win]
|
||||
d.ws[i] = x * d.window[i]
|
||||
sumSq += x * x
|
||||
}
|
||||
d.lastRMS = math.Min(1, math.Sqrt(sumSq/float64(d.win))/32768*4)
|
||||
|
||||
toneDB, haveTone := 0.0, false
|
||||
|
||||
if th := float64(d.targetHz.Load()); th > 0 {
|
||||
// Fixed pitch: one exact-frequency detector, like a skimmer channel.
|
||||
if th != d.targetFor {
|
||||
d.targetFor = th
|
||||
d.targetCoeff = 2 * math.Cos(2*math.Pi*th/float64(d.fs))
|
||||
d.envSeeded = false // re-seed the envelope for the new channel
|
||||
}
|
||||
toneDB = d.goertzelDB(d.targetCoeff)
|
||||
d.lastPitch = th
|
||||
d.lockIdx = -1 // targeting supersedes the auto lock
|
||||
haveTone = true
|
||||
// Refresh the broadband noise reference for the absolute squelch.
|
||||
d.bankTick++
|
||||
if d.bankTick >= 8 {
|
||||
d.bankTick = 0
|
||||
for i, c := range d.coeffs {
|
||||
d.mags[i] = d.goertzelDB(c)
|
||||
}
|
||||
copy(d.nbuf, d.mags)
|
||||
sort.Float64s(d.nbuf)
|
||||
d.updateBankNoise(d.nbuf[len(d.nbuf)/2])
|
||||
}
|
||||
} else if d.lockIdx >= 0 {
|
||||
// Auto-locked: only the locked bin is needed per hop.
|
||||
toneDB = d.goertzelDB(d.coeffs[d.lockIdx])
|
||||
d.lastPitch = d.freqs[d.lockIdx]
|
||||
haveTone = true
|
||||
// Supervised re-lock: periodically sweep the whole bank anyway. If a
|
||||
// clearly stronger tone lives on a DIFFERENT pitch and keeps doing so,
|
||||
// the current lock is wrong (locked onto noise, or the operator moved)
|
||||
// — jump to the real signal instead of decoding garbage until the
|
||||
// quiet-release finally fires.
|
||||
d.bankTick++
|
||||
if d.bankTick >= 8 {
|
||||
d.bankTick = 0
|
||||
bestIdx, bestDB := -1, math.Inf(-1)
|
||||
for i, c := range d.coeffs {
|
||||
m := d.goertzelDB(c)
|
||||
d.mags[i] = m
|
||||
if i >= d.lockIdx-1 && i <= d.lockIdx+1 {
|
||||
continue
|
||||
}
|
||||
if m > bestDB {
|
||||
bestDB, bestIdx = m, i
|
||||
}
|
||||
}
|
||||
copy(d.nbuf, d.mags)
|
||||
sort.Float64s(d.nbuf)
|
||||
d.updateBankNoise(d.nbuf[len(d.nbuf)/2])
|
||||
if bestIdx >= 0 && bestDB > toneDB+6 {
|
||||
d.betterHops += 2
|
||||
} else if d.betterHops > 0 {
|
||||
d.betterHops--
|
||||
}
|
||||
if d.betterHops >= 24 && bestIdx >= 0 { // ~12 confirmations over ~1 s
|
||||
d.flushPending()
|
||||
copy(d.nbuf, d.mags)
|
||||
sort.Float64s(d.nbuf)
|
||||
d.lockIdx = bestIdx
|
||||
// Seed the envelope honestly: floor from the bank median, NOT
|
||||
// peak−cap — fabricating a full span would let a noise re-lock
|
||||
// key freely until the trackers converged.
|
||||
d.floorDB, d.peakDB = d.nbuf[len(d.nbuf)/2], bestDB
|
||||
d.quietHops, d.bankTick, d.betterHops = 0, 0, 0
|
||||
d.marksSeen = 0
|
||||
d.elemMs = d.elemMs[:0]
|
||||
d.charEmitted, d.wordEmitted, d.textSince = true, true, false
|
||||
d.key, d.stableHops, d.pendHops = false, 0, 0
|
||||
toneDB = d.goertzelDB(d.coeffs[d.lockIdx])
|
||||
d.lastPitch = d.freqs[d.lockIdx]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unlocked: run the whole bank, estimate noise, hunt for a tone.
|
||||
maxIdx, maxDB := 0, math.Inf(-1)
|
||||
for i, c := range d.coeffs {
|
||||
m := d.goertzelDB(c)
|
||||
d.mags[i] = m
|
||||
if m > maxDB {
|
||||
maxDB, maxIdx = m, i
|
||||
}
|
||||
}
|
||||
copy(d.nbuf, d.mags)
|
||||
sort.Float64s(d.nbuf)
|
||||
noise := d.nbuf[len(d.nbuf)/2] // median: robust to a few strong tones
|
||||
d.updateBankNoise(noise)
|
||||
snr := maxDB - noise
|
||||
|
||||
near := d.candIdx >= 0 && maxIdx >= d.candIdx-1 && maxIdx <= d.candIdx+1
|
||||
if near {
|
||||
d.candHops++
|
||||
} else {
|
||||
d.candIdx, d.candHops = maxIdx, 1
|
||||
}
|
||||
// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so
|
||||
// its opening dit isn't eaten), a marginal one must persist a few hops
|
||||
// (so we don't lock onto a noise spike).
|
||||
if snr > acqStrongDB || (d.candHops >= acqWeakHops && snr > acqWeakDB) {
|
||||
d.lockIdx = maxIdx
|
||||
d.floorDB, d.peakDB = noise, maxDB
|
||||
d.envSeeded = true
|
||||
d.quietHops = 0
|
||||
d.bankTick, d.betterHops = 0, 0
|
||||
d.marksSeen = 0 // relearn speed quickly for this signal (keep muDit as seed)
|
||||
d.elemMs = d.elemMs[:0]
|
||||
d.charEmitted, d.wordEmitted, d.textSince = true, true, false
|
||||
d.key, d.stableHops, d.pendHops = false, 0, 0
|
||||
toneDB = maxDB
|
||||
d.lastPitch = d.freqs[maxIdx]
|
||||
haveTone = true
|
||||
} else {
|
||||
d.lastPitch = 0
|
||||
}
|
||||
}
|
||||
|
||||
if !haveTone {
|
||||
d.rawKey = false
|
||||
d.emitStatus()
|
||||
return
|
||||
}
|
||||
if !d.envSeeded {
|
||||
// First hop on a targeted channel: seed from the current reading.
|
||||
d.floorDB, d.peakDB = toneDB, toneDB+squelchDB
|
||||
d.envSeeded = true
|
||||
}
|
||||
|
||||
// ---- Envelope: separate floor / peak trackers, dB domain. ----
|
||||
// Floor drops fast, but only RISES while keyed up: a floor creeping up
|
||||
// under a long dah shrinks the span until the dah fragments into dits.
|
||||
if toneDB < d.floorDB {
|
||||
d.floorDB += (toneDB - d.floorDB) * 0.3
|
||||
} else if !d.rawKey {
|
||||
d.floorDB += (toneDB - d.floorDB) * 0.015
|
||||
}
|
||||
// Peak attacks fast and decays slowly toward current conditions (~1.5 s),
|
||||
// so QSB is followed without collapsing across ordinary word gaps.
|
||||
if toneDB > d.peakDB {
|
||||
d.peakDB += (toneDB - d.peakDB) * 0.4
|
||||
} else {
|
||||
d.peakDB += (toneDB - d.peakDB) * 0.004
|
||||
}
|
||||
if d.peakDB < d.floorDB {
|
||||
d.peakDB = d.floorDB
|
||||
}
|
||||
|
||||
// ---- Slicer with hysteresis + SNR squelch. ----
|
||||
if d.peakDB-d.floorDB < squelchDB ||
|
||||
(d.haveBankNoise && d.peakDB < d.bankNoiseDB+snrSquelchDB) {
|
||||
d.rawKey = false
|
||||
} else {
|
||||
// Cap the usable span so the slicer crossings stay near the keying
|
||||
// edges even over a dead-quiet background (see spanCapDB).
|
||||
effFloor := math.Max(d.floorDB, d.peakDB-spanCapDB)
|
||||
span := d.peakDB - effFloor
|
||||
if d.rawKey {
|
||||
d.rawKey = toneDB > effFloor+offFrac*span
|
||||
} else {
|
||||
d.rawKey = toneDB > effFloor+onFrac*span
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Auto-lock release after a long quiet spell. ----
|
||||
if d.lockIdx >= 0 {
|
||||
if d.rawKey {
|
||||
d.quietHops = 0
|
||||
} else {
|
||||
d.quietHops++
|
||||
// Long enough to survive slow-speed word gaps (7 dits), short
|
||||
// enough to retune to a new signal within a few seconds.
|
||||
release := int(math.Max(2000, 12*d.muDit) / d.hopMs)
|
||||
if d.quietHops > release {
|
||||
d.flushPending()
|
||||
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
|
||||
d.rawKey = false
|
||||
d.lastPitch = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
d.timing()
|
||||
d.emitStatus()
|
||||
}
|
||||
|
||||
// updateBankNoise folds a fresh bank-median reading into the broadband noise
|
||||
// reference used by the absolute squelch.
|
||||
func (d *Decoder) updateBankNoise(medianDB float64) {
|
||||
if !d.haveBankNoise {
|
||||
d.bankNoiseDB, d.haveBankNoise = medianDB, true
|
||||
return
|
||||
}
|
||||
d.bankNoiseDB += (medianDB - d.bankNoiseDB) * 0.2
|
||||
}
|
||||
|
||||
// glitchHops is the debounce time in hops: ~0.3 dit, clamped to 1..3 hops
|
||||
// (6–19 ms) so it neither swallows fast dits nor passes static crashes.
|
||||
func (d *Decoder) glitchHops() int {
|
||||
g := int(math.Round(0.3 * d.muDit / d.hopMs))
|
||||
if g < 1 {
|
||||
g = 1
|
||||
}
|
||||
if g > 3 {
|
||||
g = 3
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// timing runs the debounced mark/space state machine for one hop.
|
||||
func (d *Decoder) timing() {
|
||||
if d.rawKey == d.key {
|
||||
// Agreement folds any pending flip back into the committed state:
|
||||
// a sub-glitch dropout inside a dah (or spike inside a gap) vanishes.
|
||||
d.stableHops += 1 + d.pendHops
|
||||
d.pendHops = 0
|
||||
} else {
|
||||
d.pendHops++
|
||||
if d.pendHops > d.glitchHops() {
|
||||
seg := d.stableHops
|
||||
wasMark := d.key
|
||||
d.key = d.rawKey
|
||||
d.stableHops = d.pendHops
|
||||
d.pendHops = 0
|
||||
if wasMark {
|
||||
d.endMark(seg)
|
||||
} else {
|
||||
d.endSpace(seg)
|
||||
}
|
||||
if d.key {
|
||||
d.charEmitted, d.wordEmitted = false, false
|
||||
}
|
||||
}
|
||||
}
|
||||
if !d.key {
|
||||
d.spaceProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// endMark stores a finished key-down run for the character batch. Cluster
|
||||
// updates deliberately do NOT happen here: attributing a mark to the dit or
|
||||
// dah centre with the still-converging global boundary poisons the clusters
|
||||
// (a dah-led character at an unexpected speed lands its dahs in the dit
|
||||
// centre, which then oscillates). Both classification AND cluster updates
|
||||
// happen per character in flushChar, where the batch's own contrast makes
|
||||
// the attribution reliable.
|
||||
func (d *Decoder) endMark(hops int) {
|
||||
ms := float64(hops)*d.hopMs - d.biasMs // de-bias the window widening
|
||||
if ms < 0.5*minDitMs {
|
||||
return // debounce residue — not a credible element
|
||||
}
|
||||
d.elemMs = append(d.elemMs, ms)
|
||||
// Bootstrap: a first mark SHORTER than the seeded dit can only be a dit of
|
||||
// a faster sender — jump the estimate straight there instead of easing in.
|
||||
if d.marksSeen == 0 && ms < d.muDit {
|
||||
d.muDit = math.Max(ms, minDitMs)
|
||||
}
|
||||
d.marksSeen++
|
||||
if len(d.elemMs) > 8 {
|
||||
d.elemMs = d.elemMs[:0] // no Morse character is that long — noise run
|
||||
}
|
||||
}
|
||||
|
||||
// endSpace runs when a mark begins: the finished gap, if it was clearly an
|
||||
// inter-element one, is extra evidence for the dit length (gaps are often
|
||||
// steadier than dits in hand keying).
|
||||
func (d *Decoder) endSpace(hops int) {
|
||||
ms := float64(hops)*d.hopMs + d.biasMs // gaps shrink by what marks gained
|
||||
if d.marksSeen < 1 || ms < 0.35*d.muDit || ms > 1.7*d.muDit {
|
||||
return
|
||||
}
|
||||
// Early on, gaps are the FASTEST way to find the true dit length (the very
|
||||
// first inter-element gap is one, whatever the first mark was); once the
|
||||
// clusters have settled they are just a gentle refinement.
|
||||
alpha := 0.08
|
||||
if d.marksSeen < 8 {
|
||||
alpha = 0.35
|
||||
}
|
||||
d.muDit += (ms - d.muDit) * alpha
|
||||
d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs)
|
||||
}
|
||||
|
||||
// spaceProgress emits the pending character / word space LIVE once the current
|
||||
// gap crosses each boundary (instead of waiting for the next mark), so text
|
||||
// appears as it is sent.
|
||||
func (d *Decoder) spaceProgress() {
|
||||
gapMs := float64(d.stableHops)*d.hopMs + d.biasMs
|
||||
if !d.charEmitted && gapMs > charGapDits*d.muDit {
|
||||
d.flushChar()
|
||||
d.charEmitted = true
|
||||
}
|
||||
if !d.wordEmitted && gapMs > wordGapDits*d.muDit {
|
||||
if d.textSince && d.onChar != nil {
|
||||
d.onChar(" ")
|
||||
}
|
||||
d.wordEmitted = true
|
||||
}
|
||||
}
|
||||
|
||||
// flushChar classifies the accumulated mark durations into dits/dahs and
|
||||
// emits the character. Classification happens HERE, not as marks arrive: a
|
||||
// character whose own marks are clearly bimodal carries its own dit/dah
|
||||
// boundary (their geometric mean), which decodes correctly even before the
|
||||
// global clusters have converged — the first character of an over included.
|
||||
// Unimodal characters (EEE, TTT, 555…) fall back to the global boundary.
|
||||
func (d *Decoder) flushChar() {
|
||||
if len(d.elemMs) == 0 {
|
||||
return
|
||||
}
|
||||
lo, hi := d.elemMs[0], d.elemMs[0]
|
||||
for _, v := range d.elemMs[1:] {
|
||||
lo = math.Min(lo, v)
|
||||
hi = math.Max(hi, v)
|
||||
}
|
||||
b := math.Sqrt(d.muDit * d.muDah)
|
||||
if hi >= 2*lo {
|
||||
b = math.Sqrt(lo * hi) // the batch is bimodal: trust its own contrast
|
||||
}
|
||||
alpha := 0.18
|
||||
if d.marksSeen <= 8 {
|
||||
alpha = 0.45 // converge fast on a new sender
|
||||
}
|
||||
pat := make([]byte, len(d.elemMs))
|
||||
for i, v := range d.elemMs {
|
||||
if v < b {
|
||||
pat[i] = '.'
|
||||
d.muDit += (v - d.muDit) * alpha
|
||||
} else {
|
||||
pat[i] = '-'
|
||||
d.muDah += (v - d.muDah) * alpha
|
||||
}
|
||||
}
|
||||
// Ratio guards: dah stays 2–4.8 dits; dit stays within speed limits.
|
||||
d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs)
|
||||
if d.muDah < 2.0*d.muDit {
|
||||
d.muDah = 2.0 * d.muDit
|
||||
}
|
||||
if d.muDah > 4.8*d.muDit {
|
||||
d.muDah = 4.8 * d.muDit
|
||||
}
|
||||
d.elemMs = d.elemMs[:0]
|
||||
if c, ok := morse[string(pat)]; ok {
|
||||
if d.onChar != nil {
|
||||
d.onChar(string(c))
|
||||
}
|
||||
d.textSince = true
|
||||
} else if len(pat) <= 7 {
|
||||
// Morse-shaped but unknown → flag it; longer runs are noise, drop.
|
||||
if d.onChar != nil {
|
||||
d.onChar("?")
|
||||
}
|
||||
d.textSince = true
|
||||
}
|
||||
}
|
||||
|
||||
// flushPending finishes the in-progress character and word at end-of-over
|
||||
// (lock release), so the last word isn't left hanging until the next signal.
|
||||
func (d *Decoder) flushPending() {
|
||||
if !d.charEmitted {
|
||||
d.flushChar()
|
||||
d.charEmitted = true
|
||||
}
|
||||
if !d.wordEmitted && d.textSince && d.onChar != nil {
|
||||
d.onChar(" ")
|
||||
d.wordEmitted = true
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Decoder) emitStatus() {
|
||||
d.sinceStatus++
|
||||
if d.sinceStatus < d.statusEvery || d.onStatus == nil {
|
||||
return
|
||||
}
|
||||
d.sinceStatus = 0
|
||||
wpm := 0
|
||||
if d.muDit > 0 && (d.lockIdx >= 0 || d.targetHz.Load() > 0) {
|
||||
wpm = int(math.Round(1200 / d.muDit))
|
||||
}
|
||||
d.onStatus(Status{
|
||||
WPM: wpm,
|
||||
Pitch: int(math.Round(d.lastPitch)),
|
||||
Level: d.lastRMS,
|
||||
Active: d.rawKey,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package cwdecode
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---- Synthesizer -----------------------------------------------------------
|
||||
|
||||
func charToMorse() map[byte]string {
|
||||
m := map[byte]string{}
|
||||
for code, ch := range morse {
|
||||
m[ch] = code
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// keyMessage synthesizes keyed CW for msg with raised-cosine edges (5 ms), so
|
||||
// the signal has realistic click-free envelopes rather than hard steps.
|
||||
func keyMessage(msg string, fs, wpm int, pitch, amp float64) []int16 {
|
||||
dot := fs * 1200 / (wpm * 1000) // samples per dit
|
||||
edge := fs * 5 / 1000 // 5 ms shaping
|
||||
c2m := charToMorse()
|
||||
var out []float64
|
||||
phase := 0.0
|
||||
dphi := 2 * math.Pi * pitch / float64(fs)
|
||||
|
||||
tone := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
g := 1.0
|
||||
if i < edge {
|
||||
g = 0.5 - 0.5*math.Cos(math.Pi*float64(i)/float64(edge))
|
||||
} else if n-1-i < edge {
|
||||
g = 0.5 - 0.5*math.Cos(math.Pi*float64(n-1-i)/float64(edge))
|
||||
}
|
||||
out = append(out, amp*g*math.Sin(phase))
|
||||
phase += dphi
|
||||
}
|
||||
}
|
||||
silence := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, 0)
|
||||
}
|
||||
}
|
||||
|
||||
silence(fs / 4) // lead-in for envelope warm-up
|
||||
for i := 0; i < len(msg); i++ {
|
||||
ch := msg[i]
|
||||
if ch == ' ' {
|
||||
silence(4 * dot) // + trailing 3 from the previous char = 7 total
|
||||
continue
|
||||
}
|
||||
code := c2m[ch]
|
||||
for j := 0; j < len(code); j++ {
|
||||
if code[j] == '.' {
|
||||
tone(dot)
|
||||
} else {
|
||||
tone(3 * dot)
|
||||
}
|
||||
silence(dot)
|
||||
}
|
||||
silence(2 * dot) // + trailing element gap = 3 total
|
||||
}
|
||||
silence(fs / 2)
|
||||
return toInt16(out)
|
||||
}
|
||||
|
||||
func toInt16(x []float64) []int16 {
|
||||
out := make([]int16, len(x))
|
||||
for i, v := range x {
|
||||
if v > 32767 {
|
||||
v = 32767
|
||||
} else if v < -32768 {
|
||||
v = -32768
|
||||
}
|
||||
out[i] = int16(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func addNoise(s []int16, sigma float64, seed int64) []int16 {
|
||||
r := rand.New(rand.NewSource(seed))
|
||||
out := make([]int16, len(s))
|
||||
for i, v := range s {
|
||||
out[i] = int16(math.Max(-32768, math.Min(32767, float64(v)+r.NormFloat64()*sigma)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// applyQSB modulates the amplitude between lo..1.0 at rate Hz (slow fading).
|
||||
func applyQSB(s []int16, fs int, rate, lo float64) []int16 {
|
||||
out := make([]int16, len(s))
|
||||
for i, v := range s {
|
||||
g := lo + (1-lo)*(0.5+0.5*math.Sin(2*math.Pi*rate*float64(i)/float64(fs)))
|
||||
out[i] = int16(float64(v) * g)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// applyDropouts blanks brief windows (ms long) every period ms — static-crash
|
||||
// style holes that land inside dahs and gaps alike.
|
||||
func applyDropouts(s []int16, fs int, everyMs, holeMs int) []int16 {
|
||||
out := make([]int16, len(s))
|
||||
copy(out, s)
|
||||
every := fs * everyMs / 1000
|
||||
hole := fs * holeMs / 1000
|
||||
for start := every; start+hole < len(out); start += every {
|
||||
for i := start; i < start+hole; i++ {
|
||||
out[i] = 0
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mix(a, b []int16) []int16 {
|
||||
n := len(a)
|
||||
if len(b) > n {
|
||||
n = len(b)
|
||||
}
|
||||
out := make([]int16, n)
|
||||
for i := 0; i < n; i++ {
|
||||
var v int
|
||||
if i < len(a) {
|
||||
v += int(a[i])
|
||||
}
|
||||
if i < len(b) {
|
||||
v += int(b[i])
|
||||
}
|
||||
if v > 32767 {
|
||||
v = 32767
|
||||
} else if v < -32768 {
|
||||
v = -32768
|
||||
}
|
||||
out[i] = int16(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// decode runs samples through a fresh decoder in live-sized chunks.
|
||||
func decode(t *testing.T, samples []int16, targetHz int) string {
|
||||
t.Helper()
|
||||
var sb strings.Builder
|
||||
d := New(16000, func(s string) { sb.WriteString(s) }, nil)
|
||||
if targetHz > 0 {
|
||||
d.SetTarget(targetHz)
|
||||
}
|
||||
for i := 0; i < len(samples); i += 256 {
|
||||
end := i + 256
|
||||
if end > len(samples) {
|
||||
end = len(samples)
|
||||
}
|
||||
d.Process(samples[i:end])
|
||||
}
|
||||
return strings.ToUpper(sb.String())
|
||||
}
|
||||
|
||||
func wantContains(t *testing.T, got, want, label string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("%s: decoded %q, want it to contain %q", label, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tests -----------------------------------------------------------------
|
||||
|
||||
func TestCleanSignalSpeeds(t *testing.T) {
|
||||
const fs = 16000
|
||||
for _, wpm := range []int{12, 18, 25, 32, 40} {
|
||||
got := decode(t, keyMessage("CQ TEST DE F4BPO K", fs, wpm, 700, 9000), 0)
|
||||
wantContains(t, got, "CQ TEST DE F4BPO K", "clean @"+itoa(wpm)+"wpm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOtherPitches(t *testing.T) {
|
||||
const fs = 16000
|
||||
for _, pitch := range []float64{450, 600, 850} {
|
||||
got := decode(t, keyMessage("PARIS PARIS", fs, 22, pitch, 9000), 0)
|
||||
wantContains(t, got, "PARIS PARIS", "pitch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithNoise(t *testing.T) {
|
||||
const fs = 16000
|
||||
clean := keyMessage("CQ CQ DE HB9HBY", fs, 22, 700, 9000)
|
||||
noisy := addNoise(clean, 2000, 1) // ≈13 dB tone/noise in the audio band
|
||||
got := decode(t, noisy, 0)
|
||||
wantContains(t, got, "CQ CQ DE HB9HBY", "noise")
|
||||
}
|
||||
|
||||
// QSB fading between 35% and 100% amplitude — the adaptive dB envelope must
|
||||
// ride it. The old linear envelope lost the faded halves entirely.
|
||||
func TestQSBFading(t *testing.T) {
|
||||
const fs = 16000
|
||||
clean := keyMessage("CQ CQ CQ DE F4BPO F4BPO", fs, 20, 700, 12000)
|
||||
faded := applyQSB(clean, fs, 0.4, 0.35)
|
||||
got := decode(t, faded, 0)
|
||||
wantContains(t, got, "DE F4BPO", "qsb")
|
||||
}
|
||||
|
||||
// Brief 10 ms holes punched every 150 ms — they land inside dahs. Without the
|
||||
// two-sided debounce every hit dah shatters into dits (the old decoder's
|
||||
// single worst failure on real signals).
|
||||
func TestDropoutsInsideDahs(t *testing.T) {
|
||||
const fs = 16000
|
||||
clean := keyMessage("TEST TEST TEST", fs, 18, 700, 9000)
|
||||
holed := applyDropouts(clean, fs, 150, 10)
|
||||
got := decode(t, holed, 0)
|
||||
wantContains(t, got, "TEST TEST", "dropouts")
|
||||
}
|
||||
|
||||
// QRM: a second, slightly weaker keyed signal at 950 Hz. The pitch lock must
|
||||
// hold the 700 Hz target and ignore the interferer.
|
||||
func TestQRMAutoLock(t *testing.T) {
|
||||
const fs = 16000
|
||||
target := keyMessage("PARIS PARIS PARIS", fs, 20, 700, 9000)
|
||||
qrm := keyMessage("QRZ QRZ QRZ QRZ QRZ", fs, 26, 950, 5000)
|
||||
got := decode(t, mix(target, qrm), 0)
|
||||
wantContains(t, got, "PARIS", "qrm-auto")
|
||||
}
|
||||
|
||||
// Targeted mode: with two comparable signals, SetTarget must decode the chosen
|
||||
// one even though the other is as strong.
|
||||
func TestQRMTargeted(t *testing.T) {
|
||||
const fs = 16000
|
||||
want := keyMessage("SOS SOS SOS", fs, 20, 600, 8000)
|
||||
other := keyMessage("QRL QRL QRL QRL", fs, 24, 900, 8000)
|
||||
got := decode(t, mix(want, other), 600)
|
||||
wantContains(t, got, "SOS SOS", "qrm-target")
|
||||
}
|
||||
|
||||
// Pure noise must stay silent: the squelch keys nothing, so no text at all.
|
||||
func TestNoiseOnlySquelch(t *testing.T) {
|
||||
const fs = 16000
|
||||
noise := addNoise(make([]int16, fs*6), 3000, 7)
|
||||
got := strings.TrimSpace(decode(t, noise, 0))
|
||||
if len(got) > 2 { // tolerate at most a stray flagged char
|
||||
t.Fatalf("squelch: decoded %q from pure noise, want (almost) nothing", got)
|
||||
}
|
||||
}
|
||||
|
||||
// keyMessageJitter synthesizes hand-sent CW: every element and gap duration
|
||||
// is jittered (elements ±je, gaps ±jg, uniform), like a human fist.
|
||||
func keyMessageJitter(msg string, fs, wpm int, pitch, amp, je, jg float64, seed int64) []int16 {
|
||||
r := rand.New(rand.NewSource(seed))
|
||||
dot := float64(fs) * 1200 / (float64(wpm) * 1000)
|
||||
edge := fs * 5 / 1000
|
||||
c2m := charToMorse()
|
||||
var out []float64
|
||||
phase := 0.0
|
||||
dphi := 2 * math.Pi * pitch / float64(fs)
|
||||
jit := func(n float64, j float64) int { return int(n * (1 + (r.Float64()*2-1)*j)) }
|
||||
tone := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
g := 1.0
|
||||
if i < edge {
|
||||
g = 0.5 - 0.5*math.Cos(math.Pi*float64(i)/float64(edge))
|
||||
} else if n-1-i < edge {
|
||||
g = 0.5 - 0.5*math.Cos(math.Pi*float64(n-1-i)/float64(edge))
|
||||
}
|
||||
out = append(out, amp*g*math.Sin(phase))
|
||||
phase += dphi
|
||||
}
|
||||
}
|
||||
silence := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, 0)
|
||||
}
|
||||
}
|
||||
silence(fs / 4)
|
||||
for i := 0; i < len(msg); i++ {
|
||||
ch := msg[i]
|
||||
if ch == ' ' {
|
||||
silence(jit(4*dot, jg))
|
||||
continue
|
||||
}
|
||||
code := c2m[ch]
|
||||
for j := 0; j < len(code); j++ {
|
||||
if code[j] == '.' {
|
||||
tone(jit(dot, je))
|
||||
} else {
|
||||
tone(jit(3*dot, je))
|
||||
}
|
||||
silence(jit(dot, jg))
|
||||
}
|
||||
silence(jit(2*dot, jg))
|
||||
}
|
||||
silence(fs / 2)
|
||||
return toInt16(out)
|
||||
}
|
||||
|
||||
// Hand keying: ±20% element jitter, ±25% gap jitter — a sloppy but readable
|
||||
// human fist. The batch classifier and the gap-fed dit tracking must ride it.
|
||||
func TestHandKeying(t *testing.T) {
|
||||
const fs = 16000
|
||||
for seed := int64(1); seed <= 3; seed++ {
|
||||
s := keyMessageJitter("CQ CQ DE HB9HBY HB9HBY K", fs, 22, 700, 9000, 0.20, 0.25, seed)
|
||||
got := decode(t, s, 0)
|
||||
wantContains(t, got, "HB9HBY", "hand-keying")
|
||||
}
|
||||
}
|
||||
|
||||
// Speed change mid-over: the cluster tracker must follow 25 → 15 WPM.
|
||||
func TestSpeedChange(t *testing.T) {
|
||||
const fs = 16000
|
||||
fast := keyMessage("CQ CQ CQ DE F4BPO", fs, 25, 700, 9000)
|
||||
slow := keyMessage("UR RST 599 599", fs, 15, 700, 9000)
|
||||
got := decode(t, append(fast, slow...), 0)
|
||||
wantContains(t, got, "F4BPO", "speed-fast-part")
|
||||
wantContains(t, got, "599", "speed-slow-part")
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b [8]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package cwdecode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDebugTrace prints the element stream for a chosen case — a development
|
||||
// aid, not an assertion test. Run with: go test -run TestDebugTrace -v
|
||||
func TestDebugTrace(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("debug aid")
|
||||
}
|
||||
const fs = 16000
|
||||
samples := keyMessage("CQ TEST DE F4BPO K", fs, 40, 700, 9000)
|
||||
var d *Decoder
|
||||
d = New(fs, func(s string) { fmt.Printf("EMIT %q (muDit=%.0f muDah=%.0f)\n", s, d.muDit, d.muDah) }, nil)
|
||||
lastMarks := 0
|
||||
for i := 0; i < len(samples); i += 256 {
|
||||
end := i + 256
|
||||
if end > len(samples) {
|
||||
end = len(samples)
|
||||
}
|
||||
d.Process(samples[i:end])
|
||||
if d.marksSeen != lastMarks {
|
||||
lastMarks = d.marksSeen
|
||||
fmt.Printf("mark#%d elems=%v muDit=%.0f muDah=%.0f\n", d.marksSeen, d.elemMs, d.muDit, d.muDah)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1370,6 +1370,10 @@ type WorkedBefore struct {
|
||||
DXCCModes []string `json:"dxcc_modes"`
|
||||
DXCCBandModes []BandMode `json:"dxcc_band_modes"`
|
||||
|
||||
// MWRank is the ClubLog "Most Wanted" rank for this entity (1 = most wanted),
|
||||
// 0 when unknown or the feature is off. Populated by the app layer, not here.
|
||||
MWRank int `json:"mw_rank,omitempty"`
|
||||
|
||||
// Status grid driving the band×class matrix in the UI. One entry per
|
||||
// (band, class) where ANY QSO exists in this DXCC. Only the highest
|
||||
// status for that cell is kept (call_c > call_w > dxcc_c > dxcc_w).
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
//go:build !windows
|
||||
|
||||
package winkeyer
|
||||
|
||||
// linectl_other.go — non-Windows DTR/RTS line control via go.bug.st/serial. On
|
||||
// Linux/macOS the SetDTR/SetRTS ioctls control the lines independently, so the
|
||||
// CP210x-on-Windows problem that motivated the native path (see linectl_windows.go)
|
||||
// doesn't apply. OpsLog ships on Windows; this keeps the package building elsewhere.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
type serialLineCtl struct {
|
||||
port serial.Port
|
||||
}
|
||||
|
||||
func openLineCtl(port string) (lineCtl, error) {
|
||||
if strings.TrimSpace(port) == "" {
|
||||
return nil, fmt.Errorf("no serial port")
|
||||
}
|
||||
p, err := serial.Open(port, &serial.Mode{BaudRate: 9600, DataBits: 8, Parity: serial.NoParity, StopBits: serial.OneStopBit})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s: %w", port, err)
|
||||
}
|
||||
c := &serialLineCtl{port: p}
|
||||
_ = c.setDTR(false)
|
||||
_ = c.setRTS(false)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *serialLineCtl) setDTR(on bool) error { return c.port.SetDTR(on) }
|
||||
func (c *serialLineCtl) setRTS(on bool) error { return c.port.SetRTS(on) }
|
||||
func (c *serialLineCtl) close() error { return c.port.Close() }
|
||||
@@ -0,0 +1,81 @@
|
||||
//go:build windows
|
||||
|
||||
package winkeyer
|
||||
|
||||
// linectl_windows.go — DTR/RTS line control via EscapeCommFunction, the direct
|
||||
// Win32 method N1MM/WSJT/fldigi use. It sets ONE line at a time without a
|
||||
// GetCommState/SetCommState round-trip, so a held RTS (PTT) is NOT disturbed when
|
||||
// DTR (CW) is toggled. go.bug.st/serial drives the lines via SetCommState, which
|
||||
// on USB-serial adapters (the SCU-17's CP210x, FTDI) drops the held RTS the moment
|
||||
// DTR changes — the "rig drops to RX between words" bug this fixes.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
type winLineCtl struct {
|
||||
h windows.Handle
|
||||
}
|
||||
|
||||
// openLineCtl opens a COM port for line control only (no data I/O).
|
||||
func openLineCtl(port string) (lineCtl, error) {
|
||||
name := strings.TrimSpace(port)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("no serial port")
|
||||
}
|
||||
// The \\.\ prefix is required for COM10+ and harmless for COM1-9.
|
||||
if !strings.HasPrefix(name, `\\.\`) {
|
||||
name = `\\.\` + name
|
||||
}
|
||||
p, err := windows.UTF16PtrFromString(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h, err := windows.CreateFile(p,
|
||||
windows.GENERIC_READ|windows.GENERIC_WRITE, 0, nil,
|
||||
windows.OPEN_EXISTING, 0, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s: %w", port, err)
|
||||
}
|
||||
// A minimal DCB so the driver is in a sane state. Baud is irrelevant for pure
|
||||
// line control, but leaving fDtr/fRtsControl at DISABLE means only our explicit
|
||||
// EscapeCommFunction calls drive the lines.
|
||||
var dcb windows.DCB
|
||||
if windows.GetCommState(h, &dcb) == nil {
|
||||
dcb.BaudRate = 9600
|
||||
dcb.ByteSize = 8
|
||||
dcb.Parity = 0 // NOPARITY
|
||||
dcb.StopBits = 0 // ONESTOPBIT
|
||||
// Clear DTR (bits 4-5) and RTS (bits 12-13) control fields → DISABLE, so the
|
||||
// lines idle low until we assert them, and no hardware flow control fights us.
|
||||
dcb.Flags &^= 0x00000030
|
||||
dcb.Flags &^= 0x00003000
|
||||
_ = windows.SetCommState(h, &dcb)
|
||||
}
|
||||
c := &winLineCtl{h: h}
|
||||
// Start both lines low (inactive) — the keyer's idle() will do this too.
|
||||
_ = c.setDTR(false)
|
||||
_ = c.setRTS(false)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *winLineCtl) setDTR(on bool) error {
|
||||
f := uint32(windows.CLRDTR)
|
||||
if on {
|
||||
f = windows.SETDTR
|
||||
}
|
||||
return windows.EscapeCommFunction(c.h, f)
|
||||
}
|
||||
|
||||
func (c *winLineCtl) setRTS(on bool) error {
|
||||
f := uint32(windows.CLRRTS)
|
||||
if on {
|
||||
f = windows.SETRTS
|
||||
}
|
||||
return windows.EscapeCommFunction(c.h, f)
|
||||
}
|
||||
|
||||
func (c *winLineCtl) close() error { return windows.CloseHandle(c.h) }
|
||||
@@ -0,0 +1,326 @@
|
||||
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
|
||||
}
|
||||
@@ -49,6 +49,16 @@ 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"
|
||||
CWInvert bool `json:"cw_invert"` // serial: invert line polarity (active-LOW) for both key and PTT
|
||||
}
|
||||
|
||||
func (c Config) normalised() Config {
|
||||
@@ -93,6 +103,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 +141,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 +189,59 @@ func (m *Manager) Connect(cfg Config) error {
|
||||
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
|
||||
// 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
|
||||
|
||||
// Open for line control only (DTR/RTS). On Windows this uses EscapeCommFunction
|
||||
// so a held RTS (PTT) survives DTR (CW) toggling on USB adapters — see linectl_*.
|
||||
line, err := openLineCtl(cfg.Port)
|
||||
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(line, 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 = nil // serial keyer owns its own (line-only) port, not m.port
|
||||
m.serial = sk
|
||||
m.cfg = cfg
|
||||
m.status = Status{Connected: true, WPM: cfg.WPM, Port: cfg.Port}
|
||||
m.mu.Unlock()
|
||||
|
||||
lineDesc := "DTR (CW) / RTS (PTT)"
|
||||
if strings.EqualFold(cfg.CWKeyLine, "rts") {
|
||||
lineDesc = "RTS (CW) / DTR (PTT)"
|
||||
}
|
||||
applog.Printf("winkeyer: serial CW keyer on %s — %s, PTT=%v (EscapeCommFunction line ctl)", cfg.Port, lineDesc, 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 +323,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 +355,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 +406,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()
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.20.10"
|
||||
appVersion = "0.20.11"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user