Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
549adc8c0e | ||
|
|
0e1e7d9f3c | ||
|
|
3dd428d748 | ||
|
|
c62d992ad6 | ||
|
|
a83acb0f9a | ||
|
|
d8b7b86eb7 | ||
|
|
3e1c36d186 | ||
|
|
6255a9f218 | ||
|
|
599621a998 | ||
|
|
d7bcf21203 | ||
|
|
589e0b9b6a | ||
|
|
c21d6cc23a | ||
|
|
ae21ddb9d7 | ||
|
|
a4623e9ea3 | ||
|
|
06e34372ed | ||
|
|
d23c4c4037 | ||
|
|
d4bfd30636 | ||
|
|
258fa717f1 | ||
|
|
aa995c250c | ||
|
|
72fee7a090 | ||
|
|
ed67ed7fe3 | ||
|
|
fddb3c45c4 | ||
|
|
da1f3eb2bd | ||
|
|
5e0bb6e68e | ||
|
|
2b61f43780 | ||
|
|
2b66e5bc7f | ||
|
|
84b06ed47b | ||
|
|
39bd1ff414 | ||
|
|
6d54100bc9 | ||
|
|
4fe5811fd2 | ||
|
|
d42e6669e1 | ||
|
|
c14353a399 | ||
|
|
734931cf54 | ||
|
|
cad4ec4b72 | ||
|
|
5623b860c2 | ||
|
|
3e22c4d1a3 | ||
|
|
d90f953df4 | ||
|
|
e4217da010 | ||
|
|
c5ac945de0 | ||
|
|
eb40991482 | ||
|
|
781cf636ea | ||
|
|
62b6fe0a3a | ||
|
|
bd2a8524dc | ||
|
|
4afd7dda90 | ||
|
|
ed930667a1 | ||
|
|
311479c52f | ||
|
|
b25efabab8 | ||
|
|
a93f52d2b9 | ||
|
|
c9f7279a01 | ||
|
|
2560fced87 | ||
|
|
1718bf6f33 | ||
|
|
c4c5db3921 | ||
|
|
a1ceea978b | ||
|
|
f4956a63bb | ||
|
|
5a4ad800b3 | ||
|
|
e152ef0ee0 | ||
|
|
b6465ddc9d | ||
|
|
961357474d | ||
|
|
702ca4f0c9 |
@@ -114,6 +114,7 @@ const (
|
|||||||
keyCATXieguPort = "cat.xiegu.port" // Xiegu CI-V serial port (G90/X6100…)
|
keyCATXieguPort = "cat.xiegu.port" // Xiegu CI-V serial port (G90/X6100…)
|
||||||
keyCATXieguBaud = "cat.xiegu.baud" // Xiegu CI-V baud (G90 default 19200)
|
keyCATXieguBaud = "cat.xiegu.baud" // Xiegu CI-V baud (G90 default 19200)
|
||||||
keyCATXieguAddr = "cat.xiegu.addr" // Xiegu CI-V address (factory 0x70)
|
keyCATXieguAddr = "cat.xiegu.addr" // Xiegu CI-V address (factory 0x70)
|
||||||
|
keyCATXieguPTTLine = "cat.xiegu.ptt_line" // "" | rts | dtr — G90 does not key over CI-V
|
||||||
keyCATYaesuPort = "cat.yaesu.port" // Yaesu CAT serial port (e.g. COM4)
|
keyCATYaesuPort = "cat.yaesu.port" // Yaesu CAT serial port (e.g. COM4)
|
||||||
keyCATYaesuBaud = "cat.yaesu.baud" // Yaesu CAT baud (FTDX10/101 default 38400)
|
keyCATYaesuBaud = "cat.yaesu.baud" // Yaesu CAT baud (FTDX10/101 default 38400)
|
||||||
keyCATKenwoodHost = "cat.kenwood.host" // Kenwood CAT over a network serial bridge (ser2net), "host:port"
|
keyCATKenwoodHost = "cat.kenwood.host" // Kenwood CAT over a network serial bridge (ser2net), "host:port"
|
||||||
@@ -141,6 +142,15 @@ const (
|
|||||||
keyAudioQSODir = "audio.qso_dir" // folder for QSO recordings
|
keyAudioQSODir = "audio.qso_dir" // folder for QSO recordings
|
||||||
keyAudioPreroll = "audio.preroll_seconds" // rolling-buffer pre-roll length
|
keyAudioPreroll = "audio.preroll_seconds" // rolling-buffer pre-roll length
|
||||||
keyAudioTXGain = "audio.tx_gain" // voice-keyer playback level % (100 = as recorded)
|
keyAudioTXGain = "audio.tx_gain" // voice-keyer playback level % (100 = as recorded)
|
||||||
|
// Replaying a QSO recording needs its OWN level, and by a wide margin.
|
||||||
|
//
|
||||||
|
// A voice-keyer message is a microphone at speaking distance; a QSO
|
||||||
|
// recording is a receiver's line output, which is far hotter. Sharing one
|
||||||
|
// setting meant the 195% an operator needs for their F-key messages was
|
||||||
|
// also applied to a recording, which then hit the transmitter flat out —
|
||||||
|
// visible on the panadapter as a signal several times the size of the S5
|
||||||
|
// station being answered.
|
||||||
|
keyAudioQSOPlayGain = "audio.qso_play_gain" // QSO-recording playback level %
|
||||||
keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr"
|
keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr"
|
||||||
keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT
|
keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT
|
||||||
keyAudioFormat = "audio.qso_format" // "wav" | "mp3"
|
keyAudioFormat = "audio.qso_format" // "wav" | "mp3"
|
||||||
@@ -361,6 +371,7 @@ type CATSettings struct {
|
|||||||
XieguPort string `json:"xiegu_port"` // Xiegu CI-V serial port (G90/X6100…)
|
XieguPort string `json:"xiegu_port"` // Xiegu CI-V serial port (G90/X6100…)
|
||||||
XieguBaud int `json:"xiegu_baud"` // Xiegu CI-V baud (G90 default 19200)
|
XieguBaud int `json:"xiegu_baud"` // Xiegu CI-V baud (G90 default 19200)
|
||||||
XieguAddr int `json:"xiegu_addr"` // Xiegu CI-V address (factory 0x70)
|
XieguAddr int `json:"xiegu_addr"` // Xiegu CI-V address (factory 0x70)
|
||||||
|
XieguPTTLine string `json:"xiegu_ptt_line"` // "", "rts" or "dtr" — how the rig is keyed
|
||||||
YaesuPort string `json:"yaesu_port"` // Yaesu CAT serial port (e.g. COM4)
|
YaesuPort string `json:"yaesu_port"` // Yaesu CAT serial port (e.g. COM4)
|
||||||
YaesuBaud int `json:"yaesu_baud"` // Yaesu CAT baud (FTDX10/101 default 38400)
|
YaesuBaud int `json:"yaesu_baud"` // Yaesu CAT baud (FTDX10/101 default 38400)
|
||||||
KenwoodHost string `json:"kenwood_host"` // "host:port" of a serial-over-network bridge (ser2net, Ethernet-serial
|
KenwoodHost string `json:"kenwood_host"` // "host:port" of a serial-over-network bridge (ser2net, Ethernet-serial
|
||||||
@@ -845,6 +856,10 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
// to a separate cat.log in the old HamLog folder, which users couldn't find).
|
// to a separate cat.log in the old HamLog folder, which users couldn't find).
|
||||||
cat.LogSink = applog.Printf
|
cat.LogSink = applog.Printf
|
||||||
audio.LogSink = applog.Printf // capture audio-goroutine panics in the app log
|
audio.LogSink = applog.Printf // capture audio-goroutine panics in the app log
|
||||||
|
// A recorder that captures nothing must reach the OPERATOR, not just the log:
|
||||||
|
// they are mid-QSO, and by the time they notice at save time the audio is
|
||||||
|
// gone for good.
|
||||||
|
audio.AlertSink = func(format string, args ...any) { a.toast(fmt.Sprintf(format, args...)) }
|
||||||
extsvc.LogSink = applog.Printf // log raw QRZ (and other) service responses for diagnosis
|
extsvc.LogSink = applog.Printf // log raw QRZ (and other) service responses for diagnosis
|
||||||
lookup.LogSink = applog.Printf // which call was queried, and why a portable lookup fell back
|
lookup.LogSink = applog.Printf // which call was queried, and why a portable lookup fell back
|
||||||
db.LogSink = applog.Printf // which schema migrations ran, and how long they took
|
db.LogSink = applog.Printf // which schema migrations ran, and how long they took
|
||||||
@@ -956,8 +971,18 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
// burst of these lines = the frontend is being re-rendered rapidly (the
|
// burst of these lines = the frontend is being re-rendered rapidly (the
|
||||||
// "screen flickers" symptom). Shows WHAT is churning — connection flap,
|
// "screen flickers" symptom). Shows WHAT is churning — connection flap,
|
||||||
// or freq/split/mode oscillating between slices during FT8.
|
// or freq/split/mode oscillating between slices during FT8.
|
||||||
applog.Printf("cat:state → connected=%v freq=%d rx=%d split=%v mode=%s band=%s",
|
// The error goes in the line too. Without it a disconnect read
|
||||||
s.Connected, s.FreqHz, s.RxFreqHz, s.Split, s.Mode, s.Band)
|
// "connected=false freq=0" and said nothing about WHY — the reason was in
|
||||||
|
// RigState.Error, which only ever reached a tooltip, so every report of
|
||||||
|
// "it stopped connecting" arrived without the one fact that explains it.
|
||||||
|
applog.Printf("cat:state → connected=%v freq=%d rx=%d split=%v mode=%s band=%s%s",
|
||||||
|
s.Connected, s.FreqHz, s.RxFreqHz, s.Split, s.Mode, s.Band,
|
||||||
|
func() string {
|
||||||
|
if s.Error == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " err=" + s.Error
|
||||||
|
}())
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
||||||
}
|
}
|
||||||
@@ -1216,6 +1241,15 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
a.qsoRec = audio.NewRecorder()
|
a.qsoRec = audio.NewRecorder()
|
||||||
|
if a.audioMgr != nil {
|
||||||
|
// A running monitor picks the new level up immediately: the operator is
|
||||||
|
// listening while they move the slider, and asking them to stop and
|
||||||
|
// restart it to hear the change is how a working control gets reported
|
||||||
|
// as broken.
|
||||||
|
if cfg, err := a.GetAudioSettings(); err == nil {
|
||||||
|
a.audioMgr.SetMonitorGain(cfg.FromGain)
|
||||||
|
}
|
||||||
|
}
|
||||||
a.startQSORecorderIfEnabled()
|
a.startQSORecorderIfEnabled()
|
||||||
|
|
||||||
// NET Control store (global JSON, shared across logbooks).
|
// NET Control store (global JSON, shared across logbooks).
|
||||||
@@ -4212,6 +4246,11 @@ type QSOAwardRef struct {
|
|||||||
Ref string `json:"ref"`
|
Ref string `json:"ref"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Pickable bool `json:"pickable"`
|
Pickable bool `json:"pickable"`
|
||||||
|
// Ambiguous marks a reference the matcher found but did NOT assign, because
|
||||||
|
// the award allows one reference per QSO and several matched. Offered as a
|
||||||
|
// choice in the entry panel instead of being added silently — see
|
||||||
|
// award.Def.OneRefPerQSO.
|
||||||
|
Ambiguous bool `json:"ambiguous,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// enrichQSOForAwards fills in CQ/ITU zone, continent and DXCC entity from
|
// enrichQSOForAwards fills in CQ/ITU zone, continent and DXCC entity from
|
||||||
@@ -4343,6 +4382,29 @@ func (a *App) ComputeQSOAwardRefs(q qso.QSO) ([]QSOAwardRef, error) {
|
|||||||
out = append(out, QSOAwardRef{Code: r.Code, Ref: rf.Ref, Name: rf.Name, Pickable: pickable})
|
out = append(out, QSOAwardRef{Code: r.Code, Ref: rf.Ref, Name: rf.Name, Pickable: pickable})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A one-reference award that matched several candidates assigned none. Say so
|
||||||
|
// HERE, while the QSO is still on screen: an operator who only learns about it
|
||||||
|
// from the awards table months later has to go and find the contact again.
|
||||||
|
for _, d := range defs {
|
||||||
|
if !d.OneRefPerQSO {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
metas := a.awardRefMetas([]award.Def{d})[strings.ToUpper(d.Code)]
|
||||||
|
ex := award.Explain(d, metas, &q)
|
||||||
|
if len(ex.Ambiguous) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names := map[string]string{}
|
||||||
|
for _, m := range metas {
|
||||||
|
names[strings.ToUpper(m.Code)] = m.Name
|
||||||
|
}
|
||||||
|
for _, ref := range ex.Ambiguous {
|
||||||
|
out = append(out, QSOAwardRef{
|
||||||
|
Code: d.Code, Ref: ref, Name: names[strings.ToUpper(ref)],
|
||||||
|
Pickable: true, Ambiguous: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6726,7 +6788,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
|
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CATSettings{}, err
|
return CATSettings{}, err
|
||||||
}
|
}
|
||||||
@@ -6778,6 +6840,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
if n, _ := strconv.Atoi(m[keyCATXieguBaud]); n > 0 {
|
if n, _ := strconv.Atoi(m[keyCATXieguBaud]); n > 0 {
|
||||||
out.XieguBaud = n
|
out.XieguBaud = n
|
||||||
}
|
}
|
||||||
|
out.XieguPTTLine = m[keyCATXieguPTTLine]
|
||||||
if n, _ := strconv.Atoi(m[keyCATXieguAddr]); n > 0 && n <= 0xFF {
|
if n, _ := strconv.Atoi(m[keyCATXieguAddr]); n > 0 && n <= 0xFF {
|
||||||
out.XieguAddr = n
|
out.XieguAddr = n
|
||||||
}
|
}
|
||||||
@@ -6898,6 +6961,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
keyCATXieguPort: strings.TrimSpace(s.XieguPort),
|
keyCATXieguPort: strings.TrimSpace(s.XieguPort),
|
||||||
keyCATXieguBaud: strconv.Itoa(s.XieguBaud),
|
keyCATXieguBaud: strconv.Itoa(s.XieguBaud),
|
||||||
keyCATXieguAddr: strconv.Itoa(s.XieguAddr),
|
keyCATXieguAddr: strconv.Itoa(s.XieguAddr),
|
||||||
|
keyCATXieguPTTLine: strings.ToLower(strings.TrimSpace(s.XieguPTTLine)),
|
||||||
keyCATYaesuPort: strings.TrimSpace(s.YaesuPort),
|
keyCATYaesuPort: strings.TrimSpace(s.YaesuPort),
|
||||||
keyCATYaesuBaud: strconv.Itoa(s.YaesuBaud),
|
keyCATYaesuBaud: strconv.Itoa(s.YaesuBaud),
|
||||||
keyCATKenwoodPort: strings.TrimSpace(s.KenwoodPort),
|
keyCATKenwoodPort: strings.TrimSpace(s.KenwoodPort),
|
||||||
@@ -6945,6 +7009,7 @@ type AudioSettings struct {
|
|||||||
FromGain int `json:"from_gain"` // From Radio (RX) mix level %, default 100
|
FromGain int `json:"from_gain"` // From Radio (RX) mix level %, default 100
|
||||||
MicGain int `json:"mic_gain"` // mic mix level %, default 100
|
MicGain int `json:"mic_gain"` // mic mix level %, default 100
|
||||||
TXGain int `json:"tx_gain"` // voice-keyer playback level %, default 100
|
TXGain int `json:"tx_gain"` // voice-keyer playback level %, default 100
|
||||||
|
QSOPlayGain int `json:"qso_play_gain"` // QSO-recording playback level %, default 100
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAudioInputDevices / ListAudioOutputDevices enumerate WASAPI endpoints
|
// ListAudioInputDevices / ListAudioOutputDevices enumerate WASAPI endpoints
|
||||||
@@ -6954,14 +7019,14 @@ func (a *App) ListAudioOutputDevices() ([]audio.Device, error) { return audio.Li
|
|||||||
|
|
||||||
// GetAudioSettings returns the stored audio config (preroll defaults to 8s).
|
// GetAudioSettings returns the stored audio config (preroll defaults to 8s).
|
||||||
func (a *App) GetAudioSettings() (AudioSettings, error) {
|
func (a *App) GetAudioSettings() (AudioSettings, error) {
|
||||||
out := AudioSettings{PrerollSeconds: 8, PTTMethod: "none", Format: "wav", FromGain: 100, MicGain: 100, TXGain: 100}
|
out := AudioSettings{PrerollSeconds: 8, PTTMethod: "none", Format: "wav", FromGain: 100, MicGain: 100, TXGain: 100, QSOPlayGain: 100}
|
||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
m, err := a.settings.GetMany(a.ctx,
|
m, err := a.settings.GetMany(a.ctx,
|
||||||
keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice,
|
keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice,
|
||||||
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat,
|
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat,
|
||||||
keyAudioFromGain, keyAudioMicGain, keyAudioTXGain)
|
keyAudioFromGain, keyAudioMicGain, keyAudioTXGain, keyAudioQSOPlayGain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -6992,6 +7057,9 @@ func (a *App) GetAudioSettings() (AudioSettings, error) {
|
|||||||
if n, _ := strconv.Atoi(m[keyAudioTXGain]); n > 0 && n <= 400 {
|
if n, _ := strconv.Atoi(m[keyAudioTXGain]); n > 0 && n <= 400 {
|
||||||
out.TXGain = n
|
out.TXGain = n
|
||||||
}
|
}
|
||||||
|
if n, _ := strconv.Atoi(m[keyAudioQSOPlayGain]); n > 0 && n <= 400 {
|
||||||
|
out.QSOPlayGain = n
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7023,6 +7091,9 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
|
|||||||
}
|
}
|
||||||
// Up to 400 %: a mic recorded quietly needs real amplification, and the
|
// Up to 400 %: a mic recorded quietly needs real amplification, and the
|
||||||
// clamp in the player keeps it from wrapping into noise.
|
// clamp in the player keeps it from wrapping into noise.
|
||||||
|
if s.QSOPlayGain <= 0 || s.QSOPlayGain > 400 {
|
||||||
|
s.QSOPlayGain = 100
|
||||||
|
}
|
||||||
if s.TXGain <= 0 || s.TXGain > 400 {
|
if s.TXGain <= 0 || s.TXGain > 400 {
|
||||||
s.TXGain = 100
|
s.TXGain = 100
|
||||||
}
|
}
|
||||||
@@ -7040,6 +7111,7 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
|
|||||||
keyAudioFromGain: strconv.Itoa(s.FromGain),
|
keyAudioFromGain: strconv.Itoa(s.FromGain),
|
||||||
keyAudioMicGain: strconv.Itoa(s.MicGain),
|
keyAudioMicGain: strconv.Itoa(s.MicGain),
|
||||||
keyAudioTXGain: strconv.Itoa(s.TXGain),
|
keyAudioTXGain: strconv.Itoa(s.TXGain),
|
||||||
|
keyAudioQSOPlayGain: strconv.Itoa(s.QSOPlayGain),
|
||||||
} {
|
} {
|
||||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -7047,6 +7119,12 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
|
|||||||
}
|
}
|
||||||
// Apply device/preroll/enable changes to the running recorder.
|
// Apply device/preroll/enable changes to the running recorder.
|
||||||
a.startQSORecorderIfEnabled()
|
a.startQSORecorderIfEnabled()
|
||||||
|
// And to a monitor ALREADY RUNNING: the operator is listening while they
|
||||||
|
// move the slider. Making them stop and restart the monitor to hear the
|
||||||
|
// change is how a working control gets reported as doing nothing.
|
||||||
|
if a.audioMgr != nil {
|
||||||
|
a.audioMgr.SetMonitorGain(s.FromGain)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7107,14 +7185,17 @@ func (a *App) qsoRecDir() string {
|
|||||||
// be e-mailed later), and auto-sends it to the contacted operator when enabled
|
// be e-mailed later), and auto-sends it to the contacted operator when enabled
|
||||||
// and an e-mail is known. Called right after a QSO is inserted (manual + UDP);
|
// and an e-mail is known. Called right after a QSO is inserted (manual + UDP);
|
||||||
// q must have its ID set.
|
// q must have its ID set.
|
||||||
// recordableMode reports whether a QSO mode is worth an audio recording —
|
// recordableMode reports whether a QSO mode is worth an audio recording: voice
|
||||||
// only voice (SSB/AM/FM) and CW. Digital modes (FT8/FT4/RTTY/PSK/JT…) carry no
|
// and CW. Digital modes (FT8/FT4/RTTY/PSK/JT…) carry only modem tones, which
|
||||||
// useful audio, so they are never recorded.
|
// nobody will ever replay, so they are never recorded.
|
||||||
|
//
|
||||||
|
// CW was excluded for a while because SmartSDR does not route the operator's own
|
||||||
|
// sidetone through DAX, making the recording sound one-sided. The audio path is
|
||||||
|
// open all the same and the other station IS captured — which is the half worth
|
||||||
|
// keeping. Operator's call.
|
||||||
func recordableMode(mode string) bool {
|
func recordableMode(mode string) bool {
|
||||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
// CW is intentionally excluded: SmartSDR doesn't route CW audio through DAX,
|
case "SSB", "USB", "LSB", "AM", "FM", "DV", "CW":
|
||||||
// so the recording is empty/useless. Phone modes only.
|
|
||||||
case "SSB", "USB", "LSB", "AM", "FM", "DV":
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -7707,16 +7788,37 @@ func (a *App) QSOAudioResume() bool {
|
|||||||
// loop. Stopping is the operator's statement that the take is finished, and it
|
// loop. Stopping is the operator's statement that the take is finished, and it
|
||||||
// is one click they have already made.
|
// is one click they have already made.
|
||||||
func (a *App) QSOAudioPlayOnAir() error {
|
func (a *App) QSOAudioPlayOnAir() error {
|
||||||
|
// Every refusal below is LOGGED as well as returned. "I click play and
|
||||||
|
// nothing happens" is what an operator sees when a function returns an error
|
||||||
|
// into a promise the interface swallowed — and the difference between "no
|
||||||
|
// output device configured" and "the take was empty" cannot be guessed from
|
||||||
|
// the outside.
|
||||||
if a.qsoRec == nil || a.audioMgr == nil {
|
if a.qsoRec == nil || a.audioMgr == nil {
|
||||||
|
applog.Printf("qso-rec: play refused — audio subsystem not initialised")
|
||||||
return fmt.Errorf("audio not initialized")
|
return fmt.Errorf("audio not initialized")
|
||||||
}
|
}
|
||||||
if !a.qsoRec.Paused() {
|
if !a.qsoRec.Paused() {
|
||||||
|
applog.Printf("qso-rec: play refused — the recording is still running (stop it first)")
|
||||||
return fmt.Errorf("stop the recording before playing it on the air")
|
return fmt.Errorf("stop the recording before playing it on the air")
|
||||||
}
|
}
|
||||||
pcm, err := a.qsoRec.PeekQSO()
|
pcm, err := a.qsoRec.PeekQSO()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
applog.Printf("qso-rec: play refused — %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
cfgEarly, _ := a.GetAudioSettings()
|
||||||
|
if strings.TrimSpace(cfgEarly.ToRadio) == "" {
|
||||||
|
// The recorder needs an INPUT from the radio; playing back needs an
|
||||||
|
// OUTPUT into it, which is a different device and may well be unset on a
|
||||||
|
// station that only ever recorded.
|
||||||
|
// Name the FIELD, not the concept. An operator who had just emptied
|
||||||
|
// "Recording mic" read this as "you cannot play back unless you also
|
||||||
|
// record yourself" — a restriction that does not exist. The mic is an
|
||||||
|
// input; this is about the output that feeds the transmitter.
|
||||||
|
applog.Printf("qso-rec: play refused — Settings → Audio → \"To Radio (TX out)\" is empty (from_radio=%q, mic=%q)",
|
||||||
|
cfgEarly.FromRadio, cfgEarly.RecordingDevice)
|
||||||
|
return fmt.Errorf("Settings → Audio → \"To Radio (TX out)\" is empty — that is the output that feeds the transmitter (the recording mic has nothing to do with playing back)")
|
||||||
|
}
|
||||||
// A plain WAV in the data dir, overwritten each time: the player takes a
|
// A plain WAV in the data dir, overwritten each time: the player takes a
|
||||||
// path, and MP3 encoding would add seconds to something the operator is
|
// path, and MP3 encoding would add seconds to something the operator is
|
||||||
// waiting on with the transmitter keyed.
|
// waiting on with the transmitter keyed.
|
||||||
@@ -7725,7 +7827,7 @@ func (a *App) QSOAudioPlayOnAir() error {
|
|||||||
return fmt.Errorf("prepare playback: %w", err)
|
return fmt.Errorf("prepare playback: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, _ := a.GetAudioSettings()
|
cfg := cfgEarly
|
||||||
if err := a.pttKey(cfg); err != nil {
|
if err := a.pttKey(cfg); err != nil {
|
||||||
applog.Printf("qso-rec: PTT on failed before playback: %v", err)
|
applog.Printf("qso-rec: PTT on failed before playback: %v", err)
|
||||||
// Keep going — the audio still reaches the rig and the operator may use VOX.
|
// Keep going — the audio still reaches the rig and the operator may use VOX.
|
||||||
@@ -7734,7 +7836,8 @@ func (a *App) QSOAudioPlayOnAir() error {
|
|||||||
a.dvkPttKeyed = true
|
a.dvkPttKeyed = true
|
||||||
a.pttMu.Unlock()
|
a.pttMu.Unlock()
|
||||||
}
|
}
|
||||||
if err := a.audioMgr.Play(cfg.ToRadio, path, cfg.TXGain); err != nil {
|
if err := a.audioMgr.Play(cfg.ToRadio, path, cfg.QSOPlayGain); err != nil {
|
||||||
|
applog.Printf("qso-rec: playback on %q failed: %v", cfg.ToRadio, err)
|
||||||
a.pttMu.Lock()
|
a.pttMu.Lock()
|
||||||
keyed := a.dvkPttKeyed
|
keyed := a.dvkPttKeyed
|
||||||
gen := a.pttGen
|
gen := a.pttGen
|
||||||
@@ -7753,6 +7856,13 @@ func (a *App) QSOAudioPlayOnAir() error {
|
|||||||
// abandoned without logging).
|
// abandoned without logging).
|
||||||
func (a *App) QSOAudioCancel() {
|
func (a *App) QSOAudioCancel() {
|
||||||
if a.qsoRec != nil {
|
if a.qsoRec != nil {
|
||||||
|
// Say so when a take is actually thrown away. This fires when the callsign
|
||||||
|
// is cleared — including when a clicked spot replaces it — and until now
|
||||||
|
// it was silent, so a recording that vanished mid-QSO left the log showing
|
||||||
|
// only its absence at save time.
|
||||||
|
if a.qsoRec.Active() {
|
||||||
|
applog.Printf("qso-rec: in-progress recording discarded (callsign cleared)")
|
||||||
|
}
|
||||||
a.qsoRec.DiscardQSO()
|
a.qsoRec.DiscardQSO()
|
||||||
}
|
}
|
||||||
a.stopManualQSORecorder()
|
a.stopManualQSORecorder()
|
||||||
@@ -8850,6 +8960,42 @@ func (a *App) DVKStop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AudioApplyLevels applies the RX and mic levels IMMEDIATELY, without saving.
|
||||||
|
//
|
||||||
|
// A level is set by ear, by dragging: the sliders only touched the settings
|
||||||
|
// panel state, so the value reached the audio engine when the operator clicked
|
||||||
|
// Save — and while they were listening and dragging, 10%% sounded exactly like
|
||||||
|
// 300%%. The control was not weak, it was not connected to anything yet.
|
||||||
|
//
|
||||||
|
// Persisting still happens on Save. This is the live half.
|
||||||
|
func (a *App) AudioApplyLevels(fromPct, micPct int) {
|
||||||
|
// PERSIST as well as apply. Starting a recording re-reads the stored
|
||||||
|
// settings and calls SetGains with them, so a live-only value was thrown
|
||||||
|
// away the moment a take began: the monitor followed the slider while two
|
||||||
|
// recordings made at 10%% and 300%% came out identical.
|
||||||
|
if a.settings != nil {
|
||||||
|
if fromPct > 0 {
|
||||||
|
_ = a.settings.Set(a.ctx, keyAudioFromGain, strconv.Itoa(fromPct))
|
||||||
|
}
|
||||||
|
if micPct > 0 {
|
||||||
|
_ = a.settings.Set(a.ctx, keyAudioMicGain, strconv.Itoa(micPct))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.audioMgr != nil {
|
||||||
|
a.audioMgr.SetMonitorGain(fromPct)
|
||||||
|
}
|
||||||
|
if a.qsoRec != nil {
|
||||||
|
f, m := float64(fromPct)/100, float64(micPct)/100
|
||||||
|
if fromPct <= 0 {
|
||||||
|
f = 1
|
||||||
|
}
|
||||||
|
if micPct <= 0 {
|
||||||
|
m = 1
|
||||||
|
}
|
||||||
|
a.qsoRec.SetGains(f, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AudioStartMonitor pipes live RX audio from the rig into your speakers so you
|
// AudioStartMonitor pipes live RX audio from the rig into your speakers so you
|
||||||
// hear the radio inside OpsLog. Source = the "From radio" capture device (for a
|
// hear the radio inside OpsLog. Source = the "From radio" capture device (for a
|
||||||
// USB-connected rig, its "USB Audio CODEC" input); sink = the "Listening"
|
// USB-connected rig, its "USB Audio CODEC" input); sink = the "Listening"
|
||||||
@@ -8864,6 +9010,7 @@ func (a *App) AudioStartMonitor() error {
|
|||||||
return fmt.Errorf(`no "From radio" capture device set — pick the rig's USB Audio CODEC in Settings → Audio`)
|
return fmt.Errorf(`no "From radio" capture device set — pick the rig's USB Audio CODEC in Settings → Audio`)
|
||||||
}
|
}
|
||||||
applog.Printf("audio: RX monitor start (from=%q → listen=%q)", cfg.FromRadio, cfg.ListeningDevice)
|
applog.Printf("audio: RX monitor start (from=%q → listen=%q)", cfg.FromRadio, cfg.ListeningDevice)
|
||||||
|
a.audioMgr.SetMonitorGain(cfg.FromGain)
|
||||||
return a.audioMgr.StartMonitor(cfg.FromRadio, cfg.ListeningDevice)
|
return a.audioMgr.StartMonitor(cfg.FromRadio, cfg.ListeningDevice)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10101,6 +10248,7 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
mIdx, _ := a.qso.BuildMatchIndex(ctx, qrzOwner)
|
mIdx, _ := a.qso.BuildMatchIndex(ctx, qrzOwner)
|
||||||
const qrzMatchWindow = 10 * time.Minute
|
const qrzMatchWindow = 10 * time.Minute
|
||||||
qrzSkippedOtherCall := 0
|
qrzSkippedOtherCall := 0
|
||||||
|
unconfirmed := 0 // QSOs whose wrongly-set QRZ flag was taken back
|
||||||
// QRZ confirmations are QRZ-specific (not award-valid), so NEW is
|
// QRZ confirmations are QRZ-specific (not award-valid), so NEW is
|
||||||
// judged only against other QRZ confirmations.
|
// judged only against other QRZ confirmations.
|
||||||
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"qrzcom_qso_download_status"})
|
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"qrzcom_qso_download_status"})
|
||||||
@@ -10127,6 +10275,25 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
allKeys[k] = true
|
allKeys[k] = true
|
||||||
}
|
}
|
||||||
if !qrzRecordConfirmed(rec) {
|
if !qrzRecordConfirmed(rec) {
|
||||||
|
// QRZ says this one is NOT confirmed. If our log says it is, that
|
||||||
|
// is a mark OpsLog set wrongly before it learned which field
|
||||||
|
// carries QRZ's answer — take it back rather than leave a QSO
|
||||||
|
// counting towards an award it has not earned.
|
||||||
|
//
|
||||||
|
// Only on an EXPLICIT no: a record with no app_qrzlog_status at
|
||||||
|
// all is QRZ saying nothing, and silence is not a retraction.
|
||||||
|
if st := strings.TrimSpace(rec["app_qrzlog_status"]); st != "" {
|
||||||
|
if q, ok := adif.RecordToQSO(rec); ok && q.Callsign != "" {
|
||||||
|
if id, found := mIdx.Match(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), qrzMatchWindow); found && alreadyQrz[id] {
|
||||||
|
if e := a.qso.ClearQRZConfirmed(ctx, id); e == nil {
|
||||||
|
delete(alreadyQrz, id)
|
||||||
|
unconfirmed++
|
||||||
|
emit(fmt.Sprintf("Callsign: %s Date: %s Band: %s Mode: %s ### NOT CONFIRMED ON QRZ — flag cleared ###",
|
||||||
|
q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04"), q.Band, q.Mode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
q, ok := adif.RecordToQSO(rec)
|
q, ok := adif.RecordToQSO(rec)
|
||||||
@@ -10221,6 +10388,11 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", ")))
|
emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", ")))
|
||||||
emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total))
|
emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total))
|
||||||
|
if unconfirmed > 0 {
|
||||||
|
// Worth its own line and not a footnote: these QSOs were showing as
|
||||||
|
// confirmed a moment ago, and an award total may move because of it.
|
||||||
|
emit(fmt.Sprintf("Cleared %d QSO(s) that QRZ.com does NOT confirm — they had been marked wrongly by an earlier version", unconfirmed))
|
||||||
|
}
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
emit("The download was INCOMPLETE — QRZ.com cut the ADIF short, so the records after that point were not read.")
|
emit("The download was INCOMPLETE — QRZ.com cut the ADIF short, so the records after that point were not read.")
|
||||||
emit("The last-download date has NOT been moved, so nothing is lost: run it again (a narrower window returns less data).")
|
emit("The last-download date has NOT been moved, so nothing is lost: run it again (a narrower window returns less data).")
|
||||||
@@ -10375,18 +10547,20 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
// "they all turn to Y" (2026-07-29), and on a log full of paper QSLs that is
|
// "they all turn to Y" (2026-07-29), and on a log full of paper QSLs that is
|
||||||
// most of it.
|
// most of it.
|
||||||
//
|
//
|
||||||
// A QRZ confirmation is QRZ's own statement, and only two fields carry it:
|
// ONE field carries it: app_qrzlog_status, where C means confirmed.
|
||||||
//
|
//
|
||||||
// app_qrzlog_status = C QRZ's confirmed marker on the record
|
// qrzcom_qso_download_status was accepted too, and that was wrong. It is not a
|
||||||
// qrzcom_qso_download_status = Y the ADIF field QRZ sets for it
|
// confirmation: QRZ sets it to Y on everything it hands back. An operator's own
|
||||||
|
// fetch showed both fields on the same record —
|
||||||
//
|
//
|
||||||
// Anything else is a claim from some other source and must not be read as a QRZ
|
// <app_qrzlog_status:1>N <qrzcom_qso_download_status:1>Y
|
||||||
// confirmation — this status feeds award slots, so a false Y is a QSO counted as
|
//
|
||||||
// confirmed when it is not.
|
// — a QSO QRZ says is NOT confirmed, which OpsLog then marked confirmed. That
|
||||||
|
// is how a whole download turned green (2026-07-31).
|
||||||
|
//
|
||||||
|
// This status feeds award slots, so a false Y is a QSO counted as confirmed when
|
||||||
|
// it is not — the one error worth being strict about here.
|
||||||
func qrzRecordConfirmed(rec adif.Record) bool {
|
func qrzRecordConfirmed(rec adif.Record) bool {
|
||||||
if strings.EqualFold(rec["qrzcom_qso_download_status"], "Y") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return strings.EqualFold(strings.TrimSpace(rec["app_qrzlog_status"]), "C")
|
return strings.EqualFold(strings.TrimSpace(rec["app_qrzlog_status"]), "C")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12199,6 +12373,106 @@ func (a *App) FlexApplyBandAntenna(band string) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// keyFlexBandPower stores the per-band, per-mode TX power map (global,
|
||||||
|
// machine-local — it describes this station's amplifier and antennas).
|
||||||
|
const keyFlexBandPower = "flex.band_power"
|
||||||
|
|
||||||
|
// FlexBandPower is the TX power to set on a band, per class of mode.
|
||||||
|
//
|
||||||
|
// Three classes rather than per-mode: what decides the power is the duty cycle
|
||||||
|
// and the amplifier's tolerance for it, not the mode's name. FT8 and RTTY hurt
|
||||||
|
// an amplifier the same way; SSB and AM do not.
|
||||||
|
//
|
||||||
|
// Zero means "leave the power alone" — an empty box must not silently mean
|
||||||
|
// zero watts, and an operator who configures 20 m only does not want every
|
||||||
|
// other band reset when they get there.
|
||||||
|
type FlexBandPower struct {
|
||||||
|
Phone int `json:"phone"`
|
||||||
|
CW int `json:"cw"`
|
||||||
|
Digi int `json:"digi"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFlexBandPower returns the band→power map (band key uppercased, "20M").
|
||||||
|
func (a *App) GetFlexBandPower() (map[string]FlexBandPower, error) {
|
||||||
|
out := map[string]FlexBandPower{}
|
||||||
|
if a.settings == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
v, _ := a.settings.GetGlobal(a.ctx, keyFlexBandPower)
|
||||||
|
if strings.TrimSpace(v) == "" {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(v), &out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveFlexBandPower persists the band→power map.
|
||||||
|
func (a *App) SaveFlexBandPower(m map[string]FlexBandPower) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.settings.SetGlobal(a.ctx, keyFlexBandPower, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// flexPowerClass sorts an ADIF mode into the three classes the power table uses.
|
||||||
|
// An unknown mode returns "" and changes nothing — guessing would be setting a
|
||||||
|
// transmit power from a name we do not recognise.
|
||||||
|
func flexPowerClass(mode string) string {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
|
case "SSB", "USB", "LSB", "AM", "FM", "DV":
|
||||||
|
return "phone"
|
||||||
|
case "CW", "CWR":
|
||||||
|
return "cw"
|
||||||
|
case "FT8", "FT4", "JT65", "JT9", "JS8", "RTTY", "PSK", "PSK31", "PSK63",
|
||||||
|
"BPSK", "QPSK", "MFSK", "OLIVIA", "CONTESTI", "DATA", "DIGITALVOICE",
|
||||||
|
"DIGU", "DIGL", "Q65", "MSK144", "WSPR", "FST4", "FST4W", "ARDOP", "VARA":
|
||||||
|
return "digi"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexApplyBandPower sets the configured TX power for a band and mode.
|
||||||
|
//
|
||||||
|
// Called on band AND mode changes: the whole point is that switching to FT8 on
|
||||||
|
// a band where 1.5 kW is fine for SSB does not put 1.5 kW into a 100% duty-cycle
|
||||||
|
// signal. No mapping, or a zero, leaves the radio alone.
|
||||||
|
func (a *App) FlexApplyBandPower(band, mode string) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
band = strings.ToUpper(strings.TrimSpace(band))
|
||||||
|
class := flexPowerClass(mode)
|
||||||
|
if band == "" || class == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m, _ := a.GetFlexBandPower()
|
||||||
|
e, ok := m[band]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pct := 0
|
||||||
|
switch class {
|
||||||
|
case "phone":
|
||||||
|
pct = e.Phone
|
||||||
|
case "cw":
|
||||||
|
pct = e.CW
|
||||||
|
case "digi":
|
||||||
|
pct = e.Digi
|
||||||
|
}
|
||||||
|
if pct <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if pct > 100 {
|
||||||
|
pct = 100
|
||||||
|
}
|
||||||
|
applog.Printf("flex: %s %s → TX power %d%%", band, class, pct)
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRFPower(pct) })
|
||||||
|
}
|
||||||
|
|
||||||
// RIT/XIT on the active slice. The offset is kept by the radio when the switch is
|
// RIT/XIT on the active slice. The offset is kept by the radio when the switch is
|
||||||
// off, so turning it back on restores it — the UI must not zero it on toggle.
|
// off, so turning it back on restores it — the UI must not zero it on toggle.
|
||||||
func (a *App) FlexSetRIT(on bool) error {
|
func (a *App) FlexSetRIT(on bool) error {
|
||||||
@@ -12511,7 +12785,12 @@ func (a *App) reloadCAT() {
|
|||||||
// Xiegu G90/X6100/X6200/X5105 — CI-V, but a REDUCED command set: no scope,
|
// Xiegu G90/X6100/X6200/X5105 — CI-V, but a REDUCED command set: no scope,
|
||||||
// no DSP block, no data mode. Its own backend rather than the Icom one at
|
// no DSP block, no data mode. Its own backend rather than the Icom one at
|
||||||
// another address, so we never poll a G90 for answers it cannot give.
|
// another address, so we never poll a G90 for answers it cannot give.
|
||||||
a.cat.Start(cat.NewXiegu(s.XieguPort, s.XieguBaud, s.XieguAddr, s.DigitalDefault))
|
xg := cat.NewXiegu(s.XieguPort, s.XieguBaud, s.XieguAddr, s.DigitalDefault)
|
||||||
|
// A G90 ignores the CI-V PTT command; its interfaces key on a hardware
|
||||||
|
// line instead. Selected here so rigctld — and therefore WSJT-X — can
|
||||||
|
// actually put the rig on the air.
|
||||||
|
xg.SetPTTLine(s.XieguPTTLine)
|
||||||
|
a.cat.Start(xg)
|
||||||
case "yaesu":
|
case "yaesu":
|
||||||
// Native Yaesu CAT over the rig's USB/serial port — no OmniRig. Every
|
// Native Yaesu CAT over the rig's USB/serial port — no OmniRig. Every
|
||||||
// Yaesu fault reported so far came from OmniRig's interpretation layer
|
// Yaesu fault reported so far came from OmniRig's interpretation layer
|
||||||
@@ -15259,6 +15538,24 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsNewUSCounty reports whether this state/county pair has never been worked.
|
||||||
|
//
|
||||||
|
// Same question the cluster's NEW CTY badge answers, asked for the contact
|
||||||
|
// currently in the entry panel: county hunters want to know BEFORE the QSO ends,
|
||||||
|
// and by the time a county shows up in the awards table the station is long
|
||||||
|
// gone. Empty (or non-US) input is not "new" — it is unknown, and a badge on a
|
||||||
|
// guess is worse than no badge.
|
||||||
|
func (a *App) IsNewUSCounty(state, cnty string) bool {
|
||||||
|
if a.qso == nil || strings.TrimSpace(cnty) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
worked, err := a.qso.CountyWorked(a.ctx, state, cnty, award.USCountyKey)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !worked
|
||||||
|
}
|
||||||
|
|
||||||
// ── CAT sharing (Hamlib NET rigctl server) ────────────────────────────────
|
// ── CAT sharing (Hamlib NET rigctl server) ────────────────────────────────
|
||||||
|
|
||||||
// catShareRig adapts the CAT manager to what the rigctld server needs. A thin
|
// catShareRig adapts the CAT manager to what the rigctld server needs. A thin
|
||||||
|
|||||||
+142
-32
@@ -1,42 +1,152 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.22.8",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Main tab: drag the divider between the two panes to resize them. The setting is remembered and travels with the data folder; double-click the divider to even them out.",
|
||||||
|
"Kenwood: bytes arriving glued to the previous answer are no longer discarded, and the link starts from a clean buffer — a rig whose replies ran together could end up one answer behind and never report connected.",
|
||||||
|
"The CAT log line now carries the error explaining a disconnection.",
|
||||||
|
"Kenwood and Yaesu CAT connect again: 0.22.7 lowered the DTR and RTS lines on those ports, which stops many USB-serial interfaces from transmitting. Only Xiegu, where the problem was reported, still does it.",
|
||||||
|
"Kenwood: a port that opens but sends nothing is now reported differently from one sending data that does not answer, and the error quotes what arrived."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Onglet Principal : faites glisser le séparateur entre les deux volets pour les redimensionner. Le réglage est mémorisé et suit le dossier de données ; double-clic pour les égaliser.",
|
||||||
|
"Kenwood : les octets reçus collés à la réponse précédente ne sont plus jetés, et la liaison démarre sur un tampon vide — une radio dont les réponses s’enchaînent pouvait rester une réponse en retard et ne jamais apparaître connectée.",
|
||||||
|
"La ligne de journal CAT indique désormais l’erreur expliquant une déconnexion.",
|
||||||
|
"Le CAT Kenwood et Yaesu se connecte de nouveau : la 0.22.7 abaissait les lignes DTR et RTS sur ces ports, ce qui empêche beaucoup d’interfaces USB-série d’émettre. Seul Xiegu, où le problème avait été signalé, le fait encore.",
|
||||||
|
"Kenwood : un port qui s’ouvre sans rien émettre est désormais distingué d’un port qui émet sans répondre, et l’erreur cite ce qui est arrivé."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.22.7",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Serial CAT no longer keys the radio just by connecting: DTR and RTS are dropped on Xiegu, Yaesu and Kenwood as they already were on Icom — a Xiegu G90 behind a DE-19 went straight into transmit and stayed there.",
|
||||||
|
"Xiegu: choose how the rig is keyed — CI-V command, RTS or DTR. A G90 does not transmit on the CI-V command, so this is also what lets WSJT-X transmit through the shared CAT link.",
|
||||||
|
"A reference you assign by hand now replaces what the matcher found instead of being added to it, so a correction survives the next recompute.",
|
||||||
|
"Awards: an award can be marked one reference per QSO. When several references then match the same contact it keeps none and lists them, so you pick — a contact does not silently get the wrong DOK. Off by default, so an n-fer POTA activation still counts every park.",
|
||||||
|
"Dark themes: the calendar icon of date and date-time fields is visible again.",
|
||||||
|
"Live award detection now sees the QTH, name, country, comment, note and grid you typed, not just the looked-up address — an award matching on the town stayed silent until the QSO was logged.",
|
||||||
|
"Info (F2) panel: the fields are sized for what they hold — a wider county, narrower prefix and zones, and the address takes the space left by the bearing and distances.",
|
||||||
|
"Clicking in the lower half no longer draws an orange rule under the tab strip.",
|
||||||
|
"A green NEW badge appears on the County field in Info (F2) when the contacted US county has never been worked.",
|
||||||
|
"DARC DOK award updated to v2 in the catalogue: one reference per QSO."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Le CAT série ne met plus la radio en émission par le seul fait de se connecter : DTR et RTS sont abaissés sur Xiegu, Yaesu et Kenwood comme ils l'étaient déjà sur Icom — un Xiegu G90 derrière un DE-19 partait en émission et y restait.",
|
||||||
|
"Xiegu : choix du passage en émission — commande CI-V, RTS ou DTR. Un G90 n'émet pas sur la commande CI-V, c'est donc aussi ce qui permet à WSJT-X d'émettre via le partage CAT.",
|
||||||
|
"Une référence que vous affectez à la main remplace désormais ce que la détection avait trouvé au lieu de s'y ajouter : une correction survit au recalcul suivant.",
|
||||||
|
"Diplômes : un diplôme peut être marqué une seule référence par QSO. Si plusieurs références correspondent alors au même contact, aucune n’est retenue et elles sont listées pour que vous choisissiez — un contact ne reçoit plus silencieusement le mauvais DOK. Désactivé par défaut, donc une activation POTA n-fer compte toujours tous les parcs.",
|
||||||
|
"Thèmes sombres : l’icône calendrier des champs date et date-heure est de nouveau visible.",
|
||||||
|
"La détection des diplômes en direct voit maintenant le QTH, le nom, le pays, le commentaire, la note et le locator saisis, et plus seulement l’adresse issue de la recherche — un diplôme qui matche sur la ville restait muet jusqu’à l’enregistrement du QSO.",
|
||||||
|
"Panneau Info (F2) : les champs sont dimensionnés selon leur contenu — comté plus large, préfixe et zones plus étroits, et l’adresse récupère la place laissée par l’azimut et les distances.",
|
||||||
|
"Un clic dans la moitié basse ne trace plus de trait orange sous la barre d’onglets.",
|
||||||
|
"Un badge vert NOUV apparaît sur le champ Comté dans Info (F2) quand le comté américain contacté n’a jamais été travaillé.",
|
||||||
|
"Diplôme DARC DOK mis à jour en v2 dans le catalogue : une seule référence par QSO."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.22.6",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"A playback that fails to start now says so in the log instead of ending silently after a tenth of a second.",
|
||||||
|
"Replaying a recording before the previous one has finished now works: the new playback waits for the audio device to be free instead of keying the radio and releasing at once.",
|
||||||
|
"The play button becomes a stop button while the recording is going out: it cuts the transmission and releases the PTT, and you can send it again straight away.",
|
||||||
|
"The \"From radio\" and mic levels take effect as you drag the slider, and the RX level now applies to what you HEAR through OpsLog, not only to recordings.",
|
||||||
|
"The padlocks on frequency, band, mode and times no longer flicker under the pointer and refuse the click.",
|
||||||
|
"The callsign field is wider, taking the room from the RST pair.",
|
||||||
|
"MP3 recordings no longer carry a hiss of their own: the 16→32 kHz conversion mirrored the whole spectrum only 7 dB down. It is now 57 dB down.",
|
||||||
|
"Replaying a QSO recording on the air has its own level, separate from the voice keyer: a recording comes from a receiver line output and needs far less gain than a message spoken into a microphone.",
|
||||||
|
"A quiet DX cluster no longer looks like a broken one: silence stopped counting as a disconnection, so a node with few spots keeps its session, its login and its filters."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Une lecture qui ne démarre pas le signale désormais dans le journal, au lieu de s'arrêter en silence au bout d'un dixième de seconde.",
|
||||||
|
"Relancer un enregistrement avant la fin du précédent fonctionne désormais : la nouvelle lecture attend que le périphérique audio soit libre, au lieu de passer en émission et de relâcher aussitôt.",
|
||||||
|
"Le bouton de lecture devient un bouton d'arrêt pendant l'émission de l'enregistrement : il coupe l'émission et relâche le PTT, et vous pouvez le renvoyer aussitôt.",
|
||||||
|
"Les niveaux « From radio » et micro agissent pendant que vous déplacez le curseur, et le niveau RX s'applique désormais à ce que vous ENTENDEZ dans OpsLog, pas seulement aux enregistrements.",
|
||||||
|
"Les cadenas de fréquence, bande, mode et heures ne scintillent plus sous le curseur et acceptent le clic.",
|
||||||
|
"Le champ indicatif est plus large, la place venant de la paire RST.",
|
||||||
|
"Les enregistrements MP3 ne portent plus un souffle qui leur est propre : la conversion 16→32 kHz recopiait tout le spectre à seulement 7 dB en dessous. Il est désormais à 57 dB.",
|
||||||
|
"La relecture d'un enregistrement de QSO a son propre niveau, distinct du manipulateur vocal : une prise vient de la sortie ligne d'un récepteur et demande bien moins de gain qu'un message dit au micro.",
|
||||||
|
"Un cluster calme ne passe plus pour un cluster mort : le silence ne compte plus comme une déconnexion, donc un nœud avec peu de spots garde sa session, son login et ses filtres."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.22.5",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Cluster filters gain NEW POTA and NEW COUNTY, alongside the existing status chips."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Les filtres du cluster gagnent NEW POTA et NEW COUNTY, à côté des pastilles de statut existantes."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.22.4",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"FlexRadio: in split, OpsLog stays on the receive slice instead of following the transmitter.",
|
||||||
|
"QSO recording works on CW again (no sidetone).",
|
||||||
|
"Playing a recording on the air now says why when it cannot.",
|
||||||
|
"The play-on-air button is hidden on CW.",
|
||||||
|
"CW decoder: a station replying at a different speed no longer decodes as a run of dashes, and callsigns are no longer split into single letters by a wide fist.",
|
||||||
|
"FlexRadio: one table per band for the antennas and the TX power per mode class (phone / CW / digital). Antennas follow the band, power follows the band and the mode; an empty box leaves the power alone.",
|
||||||
|
"Grouping the digital modes into one slot now takes effect on the spots already on screen, instead of only on those arriving afterwards.",
|
||||||
|
"The QSO right-click menu no longer runs off the bottom of the window: it opens above the cursor when there is not enough room below.",
|
||||||
|
"QRZ.com confirmations: only QRZ own confirmed marker counts now. The download also clears QSOs an earlier version marked confirmed when QRZ says they are not — run it once to correct your log.",
|
||||||
|
"Awards: a mode filter (All / CW / Phone / Digital) that stacks with the worked/confirmed one — so \"worked on CW but not confirmed on CW\" is one click."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"FlexRadio : en split, OpsLog reste sur la slice de réception au lieu de suivre l'émission.",
|
||||||
|
"L'enregistrement des QSO fonctionne à nouveau en CW (sans le signal d'écoute).",
|
||||||
|
"L'émission d'un enregistrement indique désormais pourquoi elle échoue.",
|
||||||
|
"Le bouton d'émission de l'enregistrement est masqué en CW.",
|
||||||
|
"Décodeur CW : une station qui répond à une autre vitesse ne se décode plus en série de traits, et les indicatifs ne sont plus coupés lettre par lettre par un espacement large.",
|
||||||
|
"FlexRadio : un seul tableau par bande pour les antennes et la puissance d'émission par type de mode (phonie / CW / numérique). Les antennes suivent la bande, la puissance suit la bande et le mode ; une case vide ne touche pas à la puissance.",
|
||||||
|
"Le regroupement des modes numériques en un seul créneau s'applique désormais aux spots déjà affichés, et non plus seulement à ceux qui arrivent ensuite.",
|
||||||
|
"Le menu contextuel des QSO ne déborde plus en bas de la fenêtre : il s'ouvre au-dessus du curseur quand la place manque en dessous.",
|
||||||
|
"Confirmations QRZ.com : seul le marqueur de confirmation propre à QRZ compte désormais. Le téléchargement efface aussi les QSO qu'une version antérieure avait marqués confirmés alors que QRZ dit le contraire — lancez-le une fois pour corriger votre journal.",
|
||||||
|
"Diplômes : un filtre par mode (Tous / CW / Phonie / Numérique) qui se cumule avec le filtre contacté/confirmé — « contacté en CW mais pas confirmé en CW » se lit en un clic."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.22.3",
|
"version": "0.22.3",
|
||||||
"date": "",
|
"date": "",
|
||||||
"en": [
|
"en": [
|
||||||
"The Kenwood backend is now exercised against a rig that answers: OpsLog talks to the TS-2000 emulator it already carries for ACOM amplifiers, so frequency, mode, VFO, split and PTT are checked end to end. Testing on a real Kenwood is still needed, but the dialogue itself is no longer untried.",
|
"Kenwood backend tested end to end against the built-in TS-2000 emulator.",
|
||||||
"Kenwood: split is now detected from the transmit and receive VFO (FR/FT) when the radio leaves it out of its status frame — the case seen on a Flex in Kenwood CAT mode, where the frequency read correctly but split never appeared. Radios that report it in the status frame are unaffected.",
|
"Kenwood: split detected from FR/FT when the radio omits it from its status frame.",
|
||||||
"The CAT protocol trace covers the Kenwood backend as well, not just CI-V. Settings → CAT.",
|
"CAT protocol trace now covers the Kenwood backend, not only CI-V.",
|
||||||
"Updating selected QSOs from the callsign databases now shows a progress bar with the callsign being queried, in a corner rather than a dialog so the rest of OpsLog stays usable — a contest log is thousands of contacts and one network round trip each. The menu entry is renamed \"Update from the callsign databases\": it has always queried every configured provider, QRZ.com then HamQTH.",
|
"Updating selected QSOs from the callsign databases shows a progress bar, without blocking the rest of OpsLog. Menu entry renamed \"Update from the callsign databases\".",
|
||||||
"The world map can show the grey line: the day/night terminator with its twilight band, redrawn every minute. Button at the top right of the map, off by default, and the choice is remembered.",
|
"Grey line on the world map: day/night terminator and twilight band, button at the top right.",
|
||||||
"The protocol trace checkboxes (CAT and WinKeyer) now show whether the trace is really running. They came back unticked on a trace that was still on, so ticking the box to enable it actually switched it off and the log sent afterwards contained no trace.",
|
"The CAT and WinKeyer trace checkboxes now show whether the trace is really running.",
|
||||||
"A radio reporting LSB or USB now selects SSB in the entry form. Radios report the sideband, the mode list holds SSB, so nothing matched and the mode stayed empty while the frequency tracked correctly. It also keeps the logged mode ADIF-valid: LSB and USB are submodes there, not modes.",
|
"A radio reporting LSB or USB now selects SSB in the entry form.",
|
||||||
"Kenwood CAT can go over the network: give a host:port instead of a COM port and OpsLog talks to a serial bridge (ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio). This is not the radio's own RJ45, which speaks Kenwood's KNS protocol.",
|
"Kenwood CAT over the network: give a host:port for a serial bridge instead of a COM port.",
|
||||||
"The CAT backend list is renamed by how the radio is reached: OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, network), Xiegu (USB), Icom (CI-V USB), Icom (CI-V network), TCI.",
|
"CAT backends renamed by how the radio is reached: OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, network), Xiegu (USB), Icom (CI-V USB), Icom (CI-V network), TCI.",
|
||||||
"With automatic recording off but the audio devices configured, a red dot appears where the recording counter goes. Click it to record the contact by hand: the counter starts, the dot disappears, and logging the QSO saves the file as usual. The sound devices are released again afterwards.",
|
"With automatic recording off, a red dot beside the callsign records the contact by hand.",
|
||||||
"A recording in progress can be stopped and TRANSMITTED to the station being worked, keyed and sent like a voice-keyer message — for when they ask to hear their own signal. The audio is kept and still saved with the QSO, and recording can be resumed.",
|
"A recording can be stopped and transmitted to the station being worked, like a voice-keyer message.",
|
||||||
"The award reference table can be sorted by reference (the default) or by description — click either heading, click again to reverse. Both the grid and list views follow.",
|
"Award references can be sorted by reference or by description.",
|
||||||
"Deleting a QSO can now withdraw it from QRZ.com and Club Log as well — Settings → External services, off by default. Club Log matches on callsign, time and band so it works for any QSO; QRZ.com can only remove records OpsLog uploaded itself, since its API deletes by record number only.",
|
"Deleting a QSO can also withdraw it from QRZ.com and Club Log — Settings → External services, off by default.",
|
||||||
"QSL and upload status columns are coloured: Y green, N red, R blue. Colour only, no badges. Applies everywhere the QSO table is used — recent QSOs, worked before, NET Control and the QSL manager.",
|
"QSL and upload status columns are coloured: Y green, N red, R blue.",
|
||||||
"Settings → General chooses how dates are displayed: Standard (2026-07-30), French (30-07-2026) or US (07-30-2026). Display only — the log is always stored in the ADIF standard format, and exports are unaffected.",
|
"Settings → General chooses between US / FR / Standard for dates. Display only.",
|
||||||
"New award: DARC DOK Award (DLD), the German Deutschland-Diplom, matched on the DOK reference in the QSO."
|
"New award: DARC DOK Award (DLD)."
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"Le backend Kenwood est désormais éprouvé face à une radio qui répond : OpsLog dialogue avec l'émulateur TS-2000 qu'il embarque déjà pour les amplis ACOM, ce qui vérifie fréquence, mode, VFO, split et PTT de bout en bout. Un essai sur un vrai Kenwood reste nécessaire, mais le dialogue n'est plus non testé.",
|
"Backend Kenwood testé de bout en bout contre l’émulateur TS-2000 intégré.",
|
||||||
"Kenwood : le split est désormais détecté à partir des VFO d'émission et de réception (FR/FT) quand la radio ne le renseigne pas dans sa trame d'état — le cas observé sur un Flex en mode CAT Kenwood, où la fréquence était juste mais le split n'apparaissait jamais. Les radios qui le signalent normalement ne changent pas.",
|
"Kenwood : split détecté via FR/FT quand la radio ne le renseigne pas dans sa trame d’état.",
|
||||||
"La trace du protocole CAT couvre aussi le backend Kenwood, plus seulement le CI-V. Réglages → CAT.",
|
"La trace du protocole CAT couvre aussi le backend Kenwood, plus seulement le CI-V.",
|
||||||
"La mise à jour des QSO sélectionnés depuis les annuaires affiche désormais une barre de progression avec l'indicatif en cours d'interrogation, dans un coin plutôt qu'en fenêtre : le reste d'OpsLog reste utilisable — un log de concours, c'est des milliers de contacts et un aller-retour réseau pour chacun. L'entrée de menu devient « Mettre à jour depuis les annuaires » : elle a toujours interrogé tous les annuaires configurés, QRZ.com puis HamQTH.",
|
"La mise à jour des QSO sélectionnés depuis les annuaires affiche une barre de progression, sans bloquer le reste d’OpsLog. Entrée de menu renommée « Mettre à jour depuis les annuaires ».",
|
||||||
"La carte du monde peut afficher la ligne grise : le terminateur jour/nuit et sa bande de crépuscule, redessinés chaque minute. Bouton en haut à droite de la carte, désactivé par défaut, et le choix est mémorisé.",
|
"Ligne grise sur la carte du monde : terminateur jour/nuit et bande de crépuscule, bouton en haut à droite.",
|
||||||
"Les cases de trace du protocole (CAT et WinKeyer) reflètent désormais l'état réel de la trace. Elles réapparaissaient décochées alors que la trace tournait : cocher la case pour l'activer la désactivait en fait, et le journal envoyé ensuite ne contenait aucune trace.",
|
"Les cases de trace CAT et WinKeyer reflètent désormais l’état réel de la trace.",
|
||||||
"Une radio qui annonce LSB ou USB sélectionne désormais SSB dans la saisie. Les radios annoncent la bande latérale, la liste des modes contient SSB : rien ne correspondait et le mode restait vide alors que la fréquence suivait. Cela garde aussi le mode journalisé conforme à l'ADIF, où LSB et USB sont des sous-modes et non des modes.",
|
"Une radio annonçant LSB ou USB sélectionne désormais SSB dans la saisie.",
|
||||||
"Le CAT Kenwood peut passer par le réseau : indiquez un hôte:port au lieu d'un port COM et OpsLog dialogue avec un pont série (ser2net, boîtier Ethernet-série, Raspberry Pi près de la radio). Il ne s'agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood.",
|
"CAT Kenwood par le réseau : indiquez un hôte:port de pont série au lieu d’un port COM.",
|
||||||
"La liste des backends CAT est renommée selon la façon dont la radio est jointe : OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, réseau), Xiegu (USB), Icom (CI-V USB), Icom (CI-V réseau), TCI.",
|
"Backends CAT renommés selon la façon dont la radio est jointe : OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, réseau), Xiegu (USB), Icom (CI-V USB), Icom (CI-V réseau), TCI.",
|
||||||
"Quand l'enregistrement automatique est désactivé mais que les périphériques audio sont configurés, un rond rouge apparaît à l'emplacement du compteur. Un clic enregistre le contact à la main : le compteur démarre, le rond disparaît, et journaliser le QSO enregistre le fichier comme d'habitude. Les périphériques audio sont ensuite relâchés.",
|
"Enregistrement automatique désactivé : un rond rouge à côté de l’indicatif enregistre le contact à la main.",
|
||||||
"Un enregistrement en cours peut être arrêté puis ÉMIS vers la station travaillée, radio passée en émission comme un message du manipulateur vocal — pour quand elle demande à entendre son propre signal. L'audio est conservé et toujours enregistré avec le QSO, et l'enregistrement peut reprendre.",
|
"Un enregistrement peut être arrêté puis émis vers la station travaillée, comme un message du manipulateur vocal.",
|
||||||
"Le tableau des références d'un diplôme peut être trié par référence (par défaut) ou par description — cliquez sur l'en-tête, un second clic inverse l'ordre. Les vues grille et liste suivent toutes deux.",
|
"Les références d’un diplôme peuvent être triées par référence ou par description.",
|
||||||
"Supprimer un QSO peut désormais le retirer aussi de QRZ.com et Club Log — Réglages → Services externes, désactivé par défaut. Club Log retrouve le contact par indicatif, heure et bande, donc cela vaut pour n'importe quel QSO ; QRZ.com ne peut retirer que les enregistrements envoyés par OpsLog, son API ne supprimant que par numéro d'enregistrement.",
|
"Supprimer un QSO peut aussi le retirer de QRZ.com et Club Log — Réglages → Services externes, désactivé par défaut.",
|
||||||
"Les colonnes de statut QSL et d'envoi sont colorées : Y en vert, N en rouge, R en bleu. Couleur seule, sans pastille. Partout où le tableau des QSO est utilisé — QSO récents, déjà contacté, NET Control et le gestionnaire de QSL.",
|
"Colonnes de statut QSL et d’envoi colorées : Y vert, N rouge, R bleu.",
|
||||||
"Réglages → Général permet de choisir l'affichage des dates : Standard (2026-07-30), Français (30-07-2026) ou US (07-30-2026). Affichage seulement — le journal reste toujours enregistré au format standard ADIF, et les exports ne changent pas.",
|
"Réglages → Général : choix US / FR / Standard pour les dates. Affichage seulement.",
|
||||||
"Nouveau diplôme : DARC DOK Award (DLD), le Deutschland-Diplom allemand, reconnu d'après la référence DOK du QSO."
|
"Nouveau diplôme : DARC DOK Award (DLD)."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Which class of power a mode falls into.
|
||||||
|
//
|
||||||
|
// This decides how much power leaves the amplifier, so an error here is not a
|
||||||
|
// display bug: a digital mode sorted as phone gets the SSB setting, which is
|
||||||
|
// exactly the 1.5 kW into a 100% duty cycle this feature exists to prevent.
|
||||||
|
func TestFlexPowerClass(t *testing.T) {
|
||||||
|
for _, c := range []struct{ mode, want string }{
|
||||||
|
{"SSB", "phone"}, {"USB", "phone"}, {"LSB", "phone"}, {"AM", "phone"}, {"FM", "phone"},
|
||||||
|
{"CW", "cw"}, {"cw", "cw"}, {" CW ", "cw"},
|
||||||
|
{"FT8", "digi"}, {"FT4", "digi"}, {"RTTY", "digi"}, {"PSK31", "digi"},
|
||||||
|
{"JS8", "digi"}, {"Q65", "digi"}, {"MSK144", "digi"}, {"DATA", "digi"},
|
||||||
|
{"DIGU", "digi"}, {"DIGL", "digi"}, {"WSPR", "digi"},
|
||||||
|
// Unknown or empty: no class, and the caller changes nothing. Setting a
|
||||||
|
// transmit power from a name we do not recognise is not a good guess.
|
||||||
|
{"", ""}, {"SSTV", ""}, {"BANANA", ""},
|
||||||
|
} {
|
||||||
|
if got := flexPowerClass(c.mode); got != c.want {
|
||||||
|
t.Errorf("flexPowerClass(%q) = %q, want %q", c.mode, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+166
-41
@@ -17,7 +17,7 @@ import {
|
|||||||
SMTPConfigured, SendLogToDeveloper,
|
SMTPConfigured, SendLogToDeveloper,
|
||||||
WorkedBefore,
|
WorkedBefore,
|
||||||
SetCompactMode,
|
SetCompactMode,
|
||||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
|
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, FlexApplyBandPower,
|
||||||
GetSecretStatus, UnlockSecrets,
|
GetSecretStatus, UnlockSecrets,
|
||||||
RefreshCtyDat, DownloadAllReferenceLists,
|
RefreshCtyDat, DownloadAllReferenceLists,
|
||||||
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
||||||
@@ -137,7 +137,15 @@ const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALV
|
|||||||
// Modes the QSO recorder captures (phone only). Mirrors recordableMode() in
|
// Modes the QSO recorder captures (phone only). Mirrors recordableMode() in
|
||||||
// app.go — digital modes carry no useful audio, and CW has no DAX audio on Flex,
|
// app.go — digital modes carry no useful audio, and CW has no DAX audio on Flex,
|
||||||
// so neither is recorded (no REC badge / timer for them).
|
// so neither is recorded (no REC badge / timer for them).
|
||||||
const RECORDABLE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV']);
|
// Modes a VOICE keyer may transmit on. Not a recording list — the DVK plays
|
||||||
|
// speech, and sending it on CW or a data slot keys the rig with a human voice.
|
||||||
|
const PHONE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV']);
|
||||||
|
|
||||||
|
// Modes worth recording. Phone, plus CW: the audio path stays open in CW, so
|
||||||
|
// the other station's signal is captured. Your own sidetone is not in it —
|
||||||
|
// SmartSDR does not route it through DAX — and that is accepted. Digital modes
|
||||||
|
// stay out: their audio is a modem tone nobody will ever replay.
|
||||||
|
const RECORDABLE_MODES = new Set([...PHONE_MODES, 'CW']);
|
||||||
|
|
||||||
const emptyDetails: DetailsState = {
|
const emptyDetails: DetailsState = {
|
||||||
state: '', cnty: '', address: '',
|
state: '', cnty: '', address: '',
|
||||||
@@ -385,6 +393,33 @@ function WindowControls() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LockPad — the padlock toggle shown in a lockable field's label. Its icon
|
||||||
|
// matches the state, so a glance says which fields are immune to CAT updates
|
||||||
|
// and to the live clock.
|
||||||
|
//
|
||||||
|
// It lives HERE, at module level, and not inside App. Defined inside, React saw
|
||||||
|
// a new component type on every render of App — and App re-renders about four
|
||||||
|
// times a second while the CAT polls — so the button was unmounted and rebuilt
|
||||||
|
// each time. It flickered under the pointer and swallowed clicks, because the
|
||||||
|
// node being clicked no longer existed by the time the click landed.
|
||||||
|
function LockPad({ on, title, onToggle }: { on: boolean; title: string; onToggle: () => void }) {
|
||||||
|
const Icon = on ? Lock : Unlock;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={onToggle}
|
||||||
|
title={`${on ? 'Unlock' : 'Lock'} ${title}`}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center size-3.5 rounded transition-colors',
|
||||||
|
on ? 'text-warning hover:text-warning' : 'text-muted-foreground/40 hover:text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-3" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t, lang } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
// === Lists from settings (fallback for first paint) ===
|
// === Lists from settings (fallback for first paint) ===
|
||||||
@@ -447,27 +482,6 @@ export default function App() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
// Small padlock toggle rendered inside each lockable field's label. Match
|
|
||||||
// the icon to the current state so the user can tell at a glance which
|
|
||||||
// fields are immune to CAT updates / live clock.
|
|
||||||
function LockBtn({ k, title }: { k: LockKey; title: string }) {
|
|
||||||
const on = locks[k];
|
|
||||||
const Icon = on ? Lock : Unlock;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
|
||||||
onClick={() => toggleLock(k)}
|
|
||||||
title={`${on ? 'Unlock' : 'Lock'} ${title}`}
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center justify-center size-3.5 rounded transition-colors',
|
|
||||||
on ? 'text-warning hover:text-warning' : 'text-muted-foreground/40 hover:text-muted-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="size-3" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const [band, setBand] = useState('20m');
|
const [band, setBand] = useState('20m');
|
||||||
const [mode, setMode] = useState('SSB');
|
const [mode, setMode] = useState('SSB');
|
||||||
const [freqMhz, setFreqMhz] = useState('');
|
const [freqMhz, setFreqMhz] = useState('');
|
||||||
@@ -786,7 +800,15 @@ export default function App() {
|
|||||||
const resumeRecording = () => {
|
const resumeRecording = () => {
|
||||||
QSOAudioResume().then((ok) => { if (ok) { setRecStopped(false); setRecTick((t) => t + 1); } }).catch(() => {});
|
QSOAudioResume().then((ok) => { if (ok) { setRecStopped(false); setRecTick((t) => t + 1); } }).catch(() => {});
|
||||||
};
|
};
|
||||||
|
// One button, two jobs: play, and stop what is playing.
|
||||||
|
//
|
||||||
|
// A second press used to RESTART the message from the beginning. What an
|
||||||
|
// operator wants there is to cut it short — they have heard enough, or they
|
||||||
|
// pressed it by mistake with the transmitter keyed — and then to be able to
|
||||||
|
// send it again straight away. Stopping releases the PTT through the same
|
||||||
|
// path the natural end uses.
|
||||||
const playRecordingOnAir = () => {
|
const playRecordingOnAir = () => {
|
||||||
|
if (dvkStat.playing) { DVKStop(); return; }
|
||||||
QSOAudioPlayOnAir().catch((e: any) => setError(String(e?.message ?? e)));
|
QSOAudioPlayOnAir().catch((e: any) => setError(String(e?.message ?? e)));
|
||||||
};
|
};
|
||||||
const refreshManualRecReady = () => { QSOAudioManualReady().then(setManualRecReady).catch(() => {}); };
|
const refreshManualRecReady = () => { QSOAudioManualReady().then(setManualRecReady).catch(() => {}); };
|
||||||
@@ -849,6 +871,36 @@ export default function App() {
|
|||||||
return ALWAYS_TABS.includes(saved) ? saved : 'recent';
|
return ALWAYS_TABS.includes(saved) ? saved : 'recent';
|
||||||
});
|
});
|
||||||
useEffect(() => { writeUiPref('opslog.activeTab', activeTab); }, [activeTab]);
|
useEffect(() => { writeUiPref('opslog.activeTab', activeTab); }, [activeTab]);
|
||||||
|
|
||||||
|
// Main tab: how much width the LEFT pane gets, in percent. Clamped to 15..85
|
||||||
|
// so a pane can be made small but never dragged out of existence — recovering
|
||||||
|
// from that means finding a divider that is no longer on screen.
|
||||||
|
const [mainSplit, setMainSplit] = useState<number>(() => {
|
||||||
|
const n = parseFloat(localStorage.getItem('opslog.mainSplit') || '');
|
||||||
|
return Number.isFinite(n) && n >= 15 && n <= 85 ? n : 50;
|
||||||
|
});
|
||||||
|
useEffect(() => { writeUiPref('opslog.mainSplit', String(Math.round(mainSplit))); }, [mainSplit]);
|
||||||
|
const mainSplitRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const startMainSplitDrag = (e: React.PointerEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const host = mainSplitRef.current;
|
||||||
|
if (!host) return;
|
||||||
|
// Pointer capture on the divider, so dragging over a map keeps working —
|
||||||
|
// Leaflet would otherwise swallow the moves the moment the cursor entered it.
|
||||||
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
const onMove = (ev: PointerEvent) => {
|
||||||
|
const r = host.getBoundingClientRect();
|
||||||
|
if (r.width <= 0) return;
|
||||||
|
const pct = ((ev.clientX - r.left) / r.width) * 100;
|
||||||
|
setMainSplit(Math.min(85, Math.max(15, pct)));
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
window.removeEventListener('pointermove', onMove);
|
||||||
|
window.removeEventListener('pointerup', onUp);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', onMove);
|
||||||
|
window.addEventListener('pointerup', onUp);
|
||||||
|
};
|
||||||
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
|
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
|
||||||
const [qslTabOpen, setQslTabOpen] = useState(false);
|
const [qslTabOpen, setQslTabOpen] = useState(false);
|
||||||
const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
|
const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
|
||||||
@@ -1233,7 +1285,7 @@ export default function App() {
|
|||||||
useEffect(() => { dvkAutoCqSecsRef.current = dvkAutoCqSecs; localStorage.setItem('opslog.dvkAutoCqSecs', String(dvkAutoCqSecs)); }, [dvkAutoCqSecs]);
|
useEffect(() => { dvkAutoCqSecsRef.current = dvkAutoCqSecs; localStorage.setItem('opslog.dvkAutoCqSecs', String(dvkAutoCqSecs)); }, [dvkAutoCqSecs]);
|
||||||
// The DVK is a VOICE keyer — transmitting it on CW/FT8/RTTY would key the rig
|
// The DVK is a VOICE keyer — transmitting it on CW/FT8/RTTY would key the rig
|
||||||
// with speech on a data slot. Only allow it on phone modes.
|
// with speech on a data slot. Only allow it on phone modes.
|
||||||
const isPhoneMode = (m: string) => RECORDABLE_MODES.has((m || '').toUpperCase());
|
const isPhoneMode = (m: string) => PHONE_MODES.has((m || '').toUpperCase());
|
||||||
const modeRef = useRef(mode);
|
const modeRef = useRef(mode);
|
||||||
useEffect(() => { modeRef.current = mode; }, [mode]);
|
useEffect(() => { modeRef.current = mode; }, [mode]);
|
||||||
function stopDvkAutoCq() { dvkAutoCqSlotRef.current = -1; dvkAutoCqGenRef.current++; }
|
function stopDvkAutoCq() { dvkAutoCqSlotRef.current = -1; dvkAutoCqGenRef.current++; }
|
||||||
@@ -1315,7 +1367,12 @@ export default function App() {
|
|||||||
// Status filter chips. Empty set = show every status (including
|
// Status filter chips. Empty set = show every status (including
|
||||||
// already-worked). Otherwise only matching spots pass.
|
// already-worked). Otherwise only matching spots pass.
|
||||||
type SpotStatusKey = 'new' | 'new-band' | 'new-mode' | 'new-slot' | 'worked';
|
type SpotStatusKey = 'new' | 'new-band' | 'new-mode' | 'new-slot' | 'worked';
|
||||||
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(() => lsSet<SpotStatusKey>('opslog.clusterStatusFilter'));
|
// What the cluster can be FILTERED on. A superset of the entity status: a new
|
||||||
|
// county or a new park is not a DXCC state, it is another dimension of the
|
||||||
|
// same spot — which is why the grid shows them as separate badges. Filtering
|
||||||
|
// is an OR across all of them, as it already was for a worked callsign.
|
||||||
|
type SpotFilterKey = SpotStatusKey | 'new-pota' | 'new-county';
|
||||||
|
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
|
||||||
// Mode filter chips. Empty set = show every mode. Categories map the
|
// Mode filter chips. Empty set = show every mode. Categories map the
|
||||||
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
||||||
type SpotModeCat = 'SSB' | 'CW' | 'DATA';
|
type SpotModeCat = 'SSB' | 'CW' | 'DATA';
|
||||||
@@ -2296,6 +2353,25 @@ export default function App() {
|
|||||||
FlexApplyBandAntenna(b).catch(() => {});
|
FlexApplyBandAntenna(b).catch(() => {});
|
||||||
}, [band, catState.backend, locks.band]);
|
}, [band, catState.backend, locks.band]);
|
||||||
|
|
||||||
|
// Per-band, per-mode TX power. Applied on band AND mode changes — the point is
|
||||||
|
// that moving to FT8 on a band where full power is fine for SSB does not put
|
||||||
|
// full power into a 100% duty-cycle signal.
|
||||||
|
//
|
||||||
|
// Not skipped on a locked band, unlike the antennas: the lock means "do not
|
||||||
|
// let the rig drag my log entry around", not "let the amplifier take whatever
|
||||||
|
// the last mode left it at".
|
||||||
|
const lastPwrRef = useRef('');
|
||||||
|
useEffect(() => {
|
||||||
|
if (catState.backend !== 'flex') return;
|
||||||
|
const b = band.trim();
|
||||||
|
const m = mode.trim();
|
||||||
|
if (!b || !m) return;
|
||||||
|
const key = b + '/' + m;
|
||||||
|
if (key === lastPwrRef.current) return;
|
||||||
|
lastPwrRef.current = key;
|
||||||
|
FlexApplyBandPower(b, m).catch(() => {});
|
||||||
|
}, [band, mode, catState.backend]);
|
||||||
|
|
||||||
// Cluster live wiring: hydrate per-server status + saved server list,
|
// Cluster live wiring: hydrate per-server status + saved server list,
|
||||||
// then subscribe to push events.
|
// then subscribe to push events.
|
||||||
async function reloadClusterMeta() {
|
async function reloadClusterMeta() {
|
||||||
@@ -3670,8 +3746,15 @@ export default function App() {
|
|||||||
// single-row strip or the full Log4OM-style columnar layout below. Keeping
|
// single-row strip or the full Log4OM-style columnar layout below. Keeping
|
||||||
// them as shared consts avoids duplicating the (large) per-field JSX +
|
// them as shared consts avoids duplicating the (large) per-field JSX +
|
||||||
// handlers across the two layouts.
|
// handlers across the two layouts.
|
||||||
|
// The callsign field is WIDER than the fields around it, deliberately: it is
|
||||||
|
// the one field always typed into, it carries the REC badges on its right, and
|
||||||
|
// a long portable call (VK9/OH1ABC/MM) must stay readable as it is entered.
|
||||||
|
// It was widened by taking room from the RST pair — until a signal report of
|
||||||
|
// "59+30" turned up, which is five characters plus its padding and no longer
|
||||||
|
// fitted. The RST fields are back to their original width; the callsign keeps
|
||||||
|
// the rest of the row.
|
||||||
const callsignBlock = (
|
const callsignBlock = (
|
||||||
<div className="flex flex-col w-44" data-esm="call">
|
<div className="flex flex-col w-56" data-esm="call">
|
||||||
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
|
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
|
||||||
<span className="text-primary font-semibold">{t('field.callsign')}</span>
|
<span className="text-primary font-semibold">{t('field.callsign')}</span>
|
||||||
{lookupBusy && (
|
{lookupBusy && (
|
||||||
@@ -3731,17 +3814,25 @@ export default function App() {
|
|||||||
{/* A stopped take: play it to the station being worked, or carry on
|
{/* A stopped take: play it to the station being worked, or carry on
|
||||||
recording. Both sit where the counter is, which has shifted left to
|
recording. Both sit where the counter is, which has shifted left to
|
||||||
make room. */}
|
make room. */}
|
||||||
|
{/* A stopped take: resume it, and — on PHONE only — play it to the station
|
||||||
|
being worked. In CW the transmitter builds its tone from the keyer and
|
||||||
|
ignores the DAX TX audio path entirely, so the button would key the rig
|
||||||
|
and send silence. The row itself stays, or a CW operator who stopped
|
||||||
|
would be left with no way to resume. */}
|
||||||
{recording && recStopped && RECORDABLE_MODES.has(mode.toUpperCase()) && (
|
{recording && recStopped && RECORDABLE_MODES.has(mode.toUpperCase()) && (
|
||||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1.5">
|
<span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1.5">
|
||||||
|
{PHONE_MODES.has(mode.toUpperCase()) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={playRecordingOnAir}
|
onClick={playRecordingOnAir}
|
||||||
title={t('rec.playOnAir')}
|
title={dvkStat.playing ? t('rec.stopPlaying') : t('rec.playOnAir')}
|
||||||
className="inline-flex items-center justify-center size-4 rounded text-success hover:bg-success/15"
|
className={cn('inline-flex items-center justify-center size-4 rounded',
|
||||||
|
dvkStat.playing ? 'text-destructive hover:bg-destructive-muted' : 'text-success hover:bg-success/15')}
|
||||||
>
|
>
|
||||||
▶
|
{dvkStat.playing ? <span className="size-2 bg-current" /> : '▶'}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
@@ -3849,7 +3940,7 @@ export default function App() {
|
|||||||
) : null;
|
) : null;
|
||||||
const startBlock = (
|
const startBlock = (
|
||||||
<div className="flex flex-col w-28">
|
<div className="flex flex-col w-28">
|
||||||
<Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <LockBtn k="start" title="start time" /></Label>
|
<Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <LockPad on={locks.start} title="start time" onToggle={() => toggleLock('start')} /></Label>
|
||||||
<Input
|
<Input
|
||||||
readOnly={!locks.start}
|
readOnly={!locks.start}
|
||||||
tabIndex={locks.start ? 0 : -1}
|
tabIndex={locks.start ? 0 : -1}
|
||||||
@@ -3868,7 +3959,7 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
const endBlock = (
|
const endBlock = (
|
||||||
<div className="flex flex-col w-28">
|
<div className="flex flex-col w-28">
|
||||||
<Label className="mb-1 h-3.5 flex items-center gap-1 text-danger">{t('field.endUtc')} <LockBtn k="end" title="end time" /></Label>
|
<Label className="mb-1 h-3.5 flex items-center gap-1 text-danger">{t('field.endUtc')} <LockPad on={locks.end} title="end time" onToggle={() => toggleLock('end')} /></Label>
|
||||||
<Input
|
<Input
|
||||||
readOnly={!locks.end}
|
readOnly={!locks.end}
|
||||||
tabIndex={locks.end ? 0 : -1}
|
tabIndex={locks.end ? 0 : -1}
|
||||||
@@ -4058,7 +4149,7 @@ export default function App() {
|
|||||||
// used in the full layout to save vertical height.
|
// used in the full layout to save vertical height.
|
||||||
const bandRow = (
|
const bandRow = (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.band')} <LockBtn k="band" title="band" /></Label>
|
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.band')} <LockPad on={locks.band} title="band" onToggle={() => toggleLock('band')} /></Label>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<Select value={band} onValueChange={onBandUserChange}>
|
<Select value={band} onValueChange={onBandUserChange}>
|
||||||
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
|
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
|
||||||
@@ -4069,7 +4160,7 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
const modeRow = (
|
const modeRow = (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.mode')} <LockBtn k="mode" title="mode" /></Label>
|
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.mode')} <LockPad on={locks.mode} title="mode" onToggle={() => toggleLock('mode')} /></Label>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<Select value={mode} onValueChange={onModeUserChange}>
|
<Select value={mode} onValueChange={onModeUserChange}>
|
||||||
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
|
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
|
||||||
@@ -4125,7 +4216,7 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
const freqBlock = (
|
const freqBlock = (
|
||||||
<div className="flex flex-col w-32">
|
<div className="flex flex-col w-32">
|
||||||
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockBtn k="freq" title="frequency" /></Label>
|
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockPad on={locks.freq} title="frequency" onToggle={() => toggleLock('freq')} /></Label>
|
||||||
<Input
|
<Input
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
@@ -4235,7 +4326,10 @@ export default function App() {
|
|||||||
// entity status is still new-band/new-slot (the grid flags it WKD CALL),
|
// entity status is still new-band/new-slot (the grid flags it WKD CALL),
|
||||||
// matching the "Hide worked" toggle. Additive: it still matches its own
|
// matching the "Hide worked" toggle. Additive: it still matches its own
|
||||||
// entity status too, so it stays visible under NEW BAND / NEW SLOT.
|
// entity status too, so it stays visible under NEW BAND / NEW SLOT.
|
||||||
const matches = clusterStatusFilter.has(st) || (!!e?.worked_call && clusterStatusFilter.has('worked'));
|
const matches = clusterStatusFilter.has(st)
|
||||||
|
|| (!!e?.worked_call && clusterStatusFilter.has('worked'))
|
||||||
|
|| (!!e?.new_pota && clusterStatusFilter.has('new-pota'))
|
||||||
|
|| (!!e?.new_county && clusterStatusFilter.has('new-county'));
|
||||||
if (!matches) return false;
|
if (!matches) return false;
|
||||||
}
|
}
|
||||||
if (clusterHideWorked) {
|
if (clusterHideWorked) {
|
||||||
@@ -4347,10 +4441,14 @@ export default function App() {
|
|||||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{([
|
{([
|
||||||
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' },
|
{ k: 'new' as SpotFilterKey, label: 'NEW', cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' },
|
||||||
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-warning-muted text-warning-muted-foreground border-warning-border' },
|
{ k: 'new-band' as SpotFilterKey, label: 'NEW BAND', cls: 'bg-warning-muted text-warning-muted-foreground border-warning-border' },
|
||||||
{ k: 'new-mode' as SpotStatusKey, label: 'NEW MODE', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
{ k: 'new-mode' as SpotFilterKey, label: 'NEW MODE', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||||
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
{ k: 'new-slot' as SpotFilterKey, label: 'NEW SLOT', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||||
|
// Same colours as the badges in the grid — a filter that does not
|
||||||
|
// look like what it selects has to be learned twice.
|
||||||
|
{ k: 'new-pota' as SpotFilterKey, label: 'NEW POTA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
|
||||||
|
{ k: 'new-county' as SpotFilterKey, label: 'NEW COUNTY', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
|
||||||
// (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
|
// (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
|
||||||
]).map((s) => {
|
]).map((s) => {
|
||||||
const on = clusterStatusFilter.has(s.k);
|
const on = clusterStatusFilter.has(s.k);
|
||||||
@@ -5245,6 +5343,11 @@ export default function App() {
|
|||||||
prefix={prefix}
|
prefix={prefix}
|
||||||
operatorGrid={station.my_grid}
|
operatorGrid={station.my_grid}
|
||||||
remoteGrid={grid}
|
remoteGrid={grid}
|
||||||
|
qth={qth}
|
||||||
|
name={name}
|
||||||
|
country={country}
|
||||||
|
comment={comment}
|
||||||
|
note={note}
|
||||||
details={details}
|
details={details}
|
||||||
onChange={updateDetails}
|
onChange={updateDetails}
|
||||||
wb={wb}
|
wb={wb}
|
||||||
@@ -5945,8 +6048,23 @@ export default function App() {
|
|||||||
{/* Two configurable panes (per-profile, Settings → Main view).
|
{/* Two configurable panes (per-profile, Settings → Main view).
|
||||||
Each side shows one of: great-circle map, locator map, cluster
|
Each side shows one of: great-circle map, locator map, cluster
|
||||||
or worked-before. */}
|
or worked-before. */}
|
||||||
<div className="grid grid-cols-2 grid-rows-1 gap-2 h-full min-h-0 p-2">
|
{/* The divider is draggable: a map and a cluster list want very
|
||||||
|
different widths, and which one deserves the room changes with
|
||||||
|
what the operator is doing. The share is persisted (portable),
|
||||||
|
and a double-click puts it back to even. */}
|
||||||
|
<div ref={mainSplitRef} className="grid grid-rows-1 gap-2 h-full min-h-0 p-2"
|
||||||
|
style={{ gridTemplateColumns: `${mainSplit}fr 6px ${100 - mainSplit}fr` }}>
|
||||||
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneLeft)}</div>
|
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneLeft)}</div>
|
||||||
|
<div
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
title={t('main.splitTip')}
|
||||||
|
onPointerDown={startMainSplitDrag}
|
||||||
|
onDoubleClick={() => setMainSplit(50)}
|
||||||
|
className="group relative cursor-col-resize flex items-center justify-center -mx-1 px-1"
|
||||||
|
>
|
||||||
|
<span className="h-10 w-1 rounded-full bg-border group-hover:bg-primary transition-colors" />
|
||||||
|
</div>
|
||||||
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneRight)}</div>
|
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneRight)}</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -6284,7 +6402,14 @@ export default function App() {
|
|||||||
<SettingsModal
|
<SettingsModal
|
||||||
initialSection={settingsSection}
|
initialSection={settingsSection}
|
||||||
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
|
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
|
||||||
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); refreshManualRecReady(); }}
|
onSaved={() => {
|
||||||
|
loadStation(); loadLists(); loadCATCfg(); reloadWk(); refreshManualRecReady();
|
||||||
|
// Drop the cached spot statuses. They are computed once per
|
||||||
|
// call+band+mode and never expire, so a rule change in Settings —
|
||||||
|
// grouping the digital modes into one slot, above all — left every
|
||||||
|
// spot already on screen showing the answer to the OLD question.
|
||||||
|
setSpotStatus({});
|
||||||
|
}}
|
||||||
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
|
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
|
||||||
flexAvailable={catState.backend === 'flex'}
|
flexAvailable={catState.backend === 'flex'}
|
||||||
icomAvailable={catState.backend === 'icom'}
|
icomAvailable={catState.backend === 'icom'}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export type AwardDef = {
|
|||||||
code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
|
code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
|
||||||
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
||||||
ref_display?: string; // grid column shows: ref | name | both
|
ref_display?: string; // grid column shows: ref | name | both
|
||||||
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
|
type?: string; field: string; match_by?: string; exact_match?: boolean; one_ref_per_qso?: boolean; pattern: string;
|
||||||
leading_str?: string; trailing_str?: string; dynamic?: boolean;
|
leading_str?: string; trailing_str?: string; dynamic?: boolean;
|
||||||
or_rules?: AwardOrRule[];
|
or_rules?: AwardOrRule[];
|
||||||
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
||||||
@@ -227,7 +227,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
};
|
};
|
||||||
type Explanation = {
|
type Explanation = {
|
||||||
code: string; in_scope: boolean; scope_error?: string; predefined: boolean;
|
code: string; in_scope: boolean; scope_error?: string; predefined: boolean;
|
||||||
ref_count: number; steps: Step[]; manual?: string[]; result: string[];
|
ref_count: number; steps: Step[]; manual?: string[]; ambiguous?: string[]; result: string[];
|
||||||
};
|
};
|
||||||
type TestRow = { qso: any; explanation: Explanation };
|
type TestRow = { qso: any; explanation: Explanation };
|
||||||
const [testCall, setTestCall] = useState('');
|
const [testCall, setTestCall] = useState('');
|
||||||
@@ -554,6 +554,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</Field2>
|
</Field2>
|
||||||
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> {t('awed.exactMatch')}</label>
|
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> {t('awed.exactMatch')}</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]" title={t('awed.oneRefHint')}><Checkbox checked={!!cur.one_ref_per_qso} onCheckedChange={(c) => patch({ one_ref_per_qso: !!c })} /> {t('awed.oneRef')}</label>
|
||||||
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder={t('awed.patternPlaceholder')} /></Field2>
|
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder={t('awed.patternPlaceholder')} /></Field2>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field2 label={t('awed.leadingString')}><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
<Field2 label={t('awed.leadingString')}><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
||||||
@@ -708,6 +709,13 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{(ex.ambiguous ?? []).length > 0 && (
|
||||||
|
<div className="px-3 py-2 text-xs">
|
||||||
|
<span className="font-semibold uppercase text-[10px] tracking-wide text-warning">{t('awed.testAmbiguous')}</span>{' '}
|
||||||
|
<span className="font-mono text-warning">{(ex.ambiguous ?? []).join(', ')}</span>{' '}
|
||||||
|
<span className="text-muted-foreground">{t('awed.testAmbiguousHint')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{(ex.manual ?? []).length > 0 && (
|
{(ex.manual ?? []).length > 0 && (
|
||||||
<div className="px-3 py-2 text-xs">
|
<div className="px-3 py-2 text-xs">
|
||||||
<span className="font-semibold uppercase text-[10px] tracking-wide">{t('awed.testManual')}</span>{' '}
|
<span className="font-semibold uppercase text-[10px] tracking-wide">{t('awed.testManual')}</span>{' '}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ type AwardRef = {
|
|||||||
ref: string; name?: string; group?: string; subgrp?: string;
|
ref: string; name?: string; group?: string; subgrp?: string;
|
||||||
worked: boolean; confirmed: boolean; validated: boolean;
|
worked: boolean; confirmed: boolean; validated: boolean;
|
||||||
bands: string[]; confirmed_bands: string[]; validated_bands: string[];
|
bands: string[]; confirmed_bands: string[]; validated_bands: string[];
|
||||||
|
// Mode CLASSES — "CW" | "PHONE" | "DIGI" — not ADIF modes.
|
||||||
|
modes?: string[]; confirmed_modes?: string[];
|
||||||
};
|
};
|
||||||
type AwardResult = {
|
type AwardResult = {
|
||||||
code: string; name: string; dimension: string;
|
code: string; name: string; dimension: string;
|
||||||
@@ -75,6 +77,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
|
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
|
||||||
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
|
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
|
||||||
|
// Mode filter, stacked ON TOP of the status one. "Worked on CW but not
|
||||||
|
// confirmed" is two questions at once, and answering only one of them is what
|
||||||
|
// sends an operator to a spreadsheet.
|
||||||
|
const [modeFilter, setModeFilter] = useState<'all' | 'CW' | 'PHONE' | 'DIGI'>('all');
|
||||||
const [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null);
|
const [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null);
|
||||||
const [showMissing, setShowMissing] = useState(false);
|
const [showMissing, setShowMissing] = useState(false);
|
||||||
const [stats, setStats] = useState<AwardStats | null>(null);
|
const [stats, setStats] = useState<AwardStats | null>(null);
|
||||||
@@ -222,6 +228,16 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
if (refFilter === 'worked' && !r.worked) return false;
|
if (refFilter === 'worked' && !r.worked) return false;
|
||||||
if (refFilter === 'notworked' && r.worked) return false;
|
if (refFilter === 'notworked' && r.worked) return false;
|
||||||
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
||||||
|
if (modeFilter !== 'all' && refFilter !== 'notworked') {
|
||||||
|
// A reference never worked has no mode, so "not worked" plus a mode is
|
||||||
|
// a contradiction: the mode filter stands aside rather than emptying
|
||||||
|
// the list.
|
||||||
|
if (!(r.modes ?? []).includes(modeFilter)) return false;
|
||||||
|
// Not-confirmed must mean not confirmed ON THIS MODE. An entity worked
|
||||||
|
// on CW and confirmed on SSB is still a CW entity to chase, and the
|
||||||
|
// whole point of the filter is to find those.
|
||||||
|
if (refFilter === 'worked_notconf' && (r.confirmed_modes ?? []).includes(modeFilter)) return false;
|
||||||
|
}
|
||||||
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
|
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -237,7 +253,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
}
|
}
|
||||||
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
||||||
});
|
});
|
||||||
}, [current, refSearch, refFilter, refSort, refSortDir]);
|
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0">
|
<div className="flex h-full min-h-0">
|
||||||
@@ -361,6 +377,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
||||||
|
{([['all', t('awp.filterAll')], ['CW', 'CW'], ['PHONE', t('awp.modePhone')], ['DIGI', t('awp.modeDigital')]] as const).map(([k, label]) => (
|
||||||
|
<button key={k} onClick={() => setModeFilter(k)}
|
||||||
|
className={cn('px-2 py-1', modeFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowMissing(true)}
|
onClick={() => setShowMissing(true)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
|
import { ComputeQSOAwardRefs, IsNewUSCounty } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@@ -53,6 +53,14 @@ interface Props {
|
|||||||
prefix: string;
|
prefix: string;
|
||||||
operatorGrid: string; // station.my_grid — origin for bearing/distance
|
operatorGrid: string; // station.my_grid — origin for bearing/distance
|
||||||
remoteGrid: string; // entry-strip Grid value — destination
|
remoteGrid: string; // entry-strip Grid value — destination
|
||||||
|
// Entry-strip text fields. Live award detection matches on these too — a DARC
|
||||||
|
// DOK keys off QTH, not the address — and sending only the address meant such
|
||||||
|
// an award stayed silent until the QSO was logged.
|
||||||
|
qth?: string;
|
||||||
|
name?: string;
|
||||||
|
country?: string;
|
||||||
|
comment?: string;
|
||||||
|
note?: string;
|
||||||
details: DetailsState;
|
details: DetailsState;
|
||||||
onChange: (patch: Partial<DetailsState>) => void;
|
onChange: (patch: Partial<DetailsState>) => void;
|
||||||
// Stats (F1) tab content: the worked-before matrix + optional QRZ image.
|
// Stats (F1) tab content: the worked-before matrix + optional QRZ image.
|
||||||
@@ -113,28 +121,57 @@ function numOrUndef(v: string): number | undefined {
|
|||||||
return isNaN(n) ? undefined : n;
|
return isNaN(n) ? undefined : n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Column spans written out rather than built as `col-span-${n}`: Tailwind scans
|
||||||
|
// for literal class names, so an interpolated one is never emitted.
|
||||||
|
const SPAN_CLASS: Record<number, string> = {
|
||||||
|
1: '', 2: 'col-span-2', 3: 'col-span-3', 4: 'col-span-4', 5: 'col-span-5',
|
||||||
|
6: 'col-span-6', 7: 'col-span-7', 8: 'col-span-8', 9: 'col-span-9',
|
||||||
|
10: 'col-span-10', 11: 'col-span-11', 12: 'col-span-12',
|
||||||
|
};
|
||||||
|
|
||||||
// Compact field helper to keep the JSX dense.
|
// Compact field helper to keep the JSX dense.
|
||||||
function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 4 | 6; children: React.ReactNode }) {
|
function Field({ label, span = 1, className, children }: { label: string; span?: number; className?: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 4 && 'col-span-4', span === 6 && 'col-span-6')}>
|
<div className={cn('flex flex-col min-w-0', SPAN_CLASS[span], className)}>
|
||||||
<Label className="mb-1">{label}</Label>
|
<Label className="mb-1">{label}</Label>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
|
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||||
|
|
||||||
// Live award detection: run the SAME engine used at log time over the current
|
// Live award detection: run the SAME engine used at log time over the current
|
||||||
// contact (callsign + looked-up address/state/zones) so award references the
|
// contact — callsign, the looked-up address/state/zones AND the entry-strip
|
||||||
// QSO will earn — e.g. WAPC matching "Beijing" inside the address — are
|
// text (QTH, name, country, comment, note, grid). Sending only the address was
|
||||||
// surfaced and auto-added the moment you enter a call or click a spot, instead
|
// a silent hole: a DARC DOK matches on QTH, so "Aachen" produced nothing here
|
||||||
// of only appearing after logging. Pickable matches are merged into award_refs
|
// while the award editor's Test tab, which passes the whole QSO, matched it.
|
||||||
|
// References the QSO will earn — WAPC matching "Beijing" inside the address,
|
||||||
|
// a DOK matching the town — are surfaced and auto-added the moment you enter a
|
||||||
|
// call or click a spot, instead of only appearing after logging. Pickable
|
||||||
|
// matches are merged into award_refs
|
||||||
// (idempotent; award_refs is NOT a dependency, so removing one by hand sticks).
|
// (idempotent; award_refs is NOT a dependency, so removing one by hand sticks).
|
||||||
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean }>>([]);
|
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean; ambiguous?: boolean }>>([]);
|
||||||
|
|
||||||
|
// NEW badge on the county: the same question the cluster's NEW CTY badge
|
||||||
|
// answers, for the station being worked right now. Debounced because the
|
||||||
|
// county field is typed into as well as filled by the lookup.
|
||||||
|
const [newCounty, setNewCounty] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const cnty = (details.cnty ?? '').trim();
|
||||||
|
if (!cnty) { setNewCounty(false); return; }
|
||||||
|
let alive = true;
|
||||||
|
const id = window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const isNew = await IsNewUSCounty(details.state ?? '', cnty);
|
||||||
|
if (alive) setNewCounty(!!isNew);
|
||||||
|
} catch { if (alive) setNewCounty(false); }
|
||||||
|
}, 300);
|
||||||
|
return () => { alive = false; window.clearTimeout(id); };
|
||||||
|
}, [details.state, details.cnty]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
|
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
|
||||||
const t = window.setTimeout(async () => {
|
const t = window.setTimeout(async () => {
|
||||||
@@ -142,6 +179,8 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
const q: any = {
|
const q: any = {
|
||||||
callsign, band, mode,
|
callsign, band, mode,
|
||||||
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
|
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
|
||||||
|
qth: qth ?? '', name: name ?? '', country: country ?? '',
|
||||||
|
comment: comment ?? '', notes: note ?? '', grid: remoteGrid ?? '',
|
||||||
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
|
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
|
||||||
qso_date: new Date().toISOString(),
|
qso_date: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -151,7 +190,9 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
const have = new Set(cur.split(';').filter(Boolean));
|
const have = new Set(cur.split(';').filter(Boolean));
|
||||||
let next = cur;
|
let next = cur;
|
||||||
for (const r of all) {
|
for (const r of all) {
|
||||||
if (!r.pickable) continue;
|
// An ambiguous candidate is offered, never auto-added: adding one would
|
||||||
|
// be the guess the award's "one reference per QSO" setting exists to refuse.
|
||||||
|
if (!r.pickable || r.ambiguous) continue;
|
||||||
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
|
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
|
||||||
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
|
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
|
||||||
}
|
}
|
||||||
@@ -160,7 +201,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
}, 400);
|
}, 400);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, band, mode]);
|
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, qth, name, country, comment, note, remoteGrid, band, mode]);
|
||||||
// Bearing/distance from operator's home grid to the remote station.
|
// Bearing/distance from operator's home grid to the remote station.
|
||||||
// Recomputed only when either grid actually changes.
|
// Recomputed only when either grid actually changes.
|
||||||
const path = useMemo(() => {
|
const path = useMemo(() => {
|
||||||
@@ -227,14 +268,26 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{open === 'info' && (
|
{open === 'info' && (
|
||||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
<div className="grid grid-cols-12 gap-2 px-3 py-2.5">
|
||||||
<Field label={t('detp.statePref')}>
|
{/* Twelve columns, not six: the fields hold very different things. A US
|
||||||
|
county name is long, a CQ zone is two digits — on an even six-column
|
||||||
|
grid they were the same width, so the county truncated while the
|
||||||
|
zones sat mostly empty. */}
|
||||||
|
<Field label={t('detp.statePref')} span={2}>
|
||||||
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
|
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.county')}>
|
<Field label={t('detp.county')} span={4} className="relative">
|
||||||
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} />
|
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} className={cn(newCounty && 'pr-14')} />
|
||||||
|
{newCounty && (
|
||||||
|
<span
|
||||||
|
title={t('detp.newCountyTip')}
|
||||||
|
className="absolute right-1.5 bottom-1.5 rounded px-1.5 py-0.5 text-[10px] font-bold tracking-wide bg-success text-success-foreground pointer-events-none"
|
||||||
|
>
|
||||||
|
{t('detp.newCounty')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.prefix')}>
|
<Field label={t('detp.prefix')} span={2}>
|
||||||
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.cqZone')}>
|
<Field label={t('detp.cqZone')}>
|
||||||
@@ -248,7 +301,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
{/* DXCC # closes the top row (next to the zones); Continent and
|
{/* DXCC # closes the top row (next to the zones); Continent and
|
||||||
Azimuth SP live in the main entry strip / bandeau. The long-path
|
Azimuth SP live in the main entry strip / bandeau. The long-path
|
||||||
bearing and distances move to the row below. */}
|
bearing and distances move to the row below. */}
|
||||||
<Field label={t('detp.dxcc')}>
|
<Field label={t('detp.dxcc')} span={2}>
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -257,7 +310,11 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.azimuthLp')}>
|
{/* The bearing and the two distances are read-only and hold at most
|
||||||
|
"12345 km", so they take a fixed width and the address — the one
|
||||||
|
field here that is never wide enough — gets everything left over. */}
|
||||||
|
<div className="col-span-12 flex gap-2">
|
||||||
|
<Field label={t('detp.azimuthLp')} className="w-20 shrink-0">
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -266,7 +323,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.distanceSp')}>
|
<Field label={t('detp.distanceSp')} className="w-24 shrink-0">
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -275,7 +332,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.distanceLp')}>
|
<Field label={t('detp.distanceLp')} className="w-24 shrink-0">
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -284,13 +341,14 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.address')} span={3}>
|
<Field label={t('detp.address')} className="flex-1 min-w-0">
|
||||||
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.qslMessage')} span={3}>
|
</div>
|
||||||
|
<Field label={t('detp.qslMessage')} span={7}>
|
||||||
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.qslVia')} span={2}>
|
<Field label={t('detp.qslVia')} span={5}>
|
||||||
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,10 +363,37 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
|
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
|
||||||
heightClass="flex-1 min-h-0"
|
heightClass="flex-1 min-h-0"
|
||||||
/>
|
/>
|
||||||
{detected.length > 0 && (
|
{/* Ambiguous candidates: the award allows one reference per QSO and
|
||||||
|
several matched, so none was kept. Click one to settle it while the
|
||||||
|
contact is still in front of you. */}
|
||||||
|
{detected.some((r) => r.ambiguous) && (
|
||||||
|
<div className="mt-2 text-[11px] shrink-0 leading-snug border-t border-warning/40 pt-1.5">
|
||||||
|
<span className="font-medium text-warning">{t('detp.ambiguous')}</span>{' '}
|
||||||
|
{detected.filter((r) => r.ambiguous).map((r) => (
|
||||||
|
<button
|
||||||
|
key={`amb-${r.code}@${r.ref}`}
|
||||||
|
type="button"
|
||||||
|
title={r.name ?? ''}
|
||||||
|
onClick={() => {
|
||||||
|
const entry = `${r.code.toUpperCase()}@${r.ref.toUpperCase()}`;
|
||||||
|
const cur = details.award_refs ?? '';
|
||||||
|
// Only one of the candidates can be right — replace any
|
||||||
|
// sibling already picked for this award rather than stacking.
|
||||||
|
const kept = cur.split(';').filter((e) => e && e.split('@')[0].toUpperCase() !== r.code.toUpperCase());
|
||||||
|
onChange({ award_refs: [...kept, entry].join(';') });
|
||||||
|
setDetected((d) => d.filter((x) => !x.ambiguous || x.code.toUpperCase() !== r.code.toUpperCase()));
|
||||||
|
}}
|
||||||
|
className="inline-block mr-1.5 px-1.5 py-0.5 rounded font-mono whitespace-nowrap border border-warning/50 text-warning hover:bg-warning/10"
|
||||||
|
>
|
||||||
|
{r.code}@{r.ref}{r.name ? ` — ${r.name}` : ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detected.some((r) => !r.ambiguous) && (
|
||||||
<div className="mt-2 text-[11px] text-muted-foreground shrink-0 max-h-14 overflow-y-auto leading-snug border-t border-border/50 pt-1.5">
|
<div className="mt-2 text-[11px] text-muted-foreground shrink-0 max-h-14 overflow-y-auto leading-snug border-t border-border/50 pt-1.5">
|
||||||
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
|
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
|
||||||
{detected.map((r) => (
|
{detected.filter((r) => !r.ambiguous).map((r) => (
|
||||||
<span key={`${r.code}@${r.ref}`} className="inline-block mr-1.5 font-mono whitespace-nowrap" title={r.name ?? ''}>
|
<span key={`${r.code}@${r.ref}`} className="inline-block mr-1.5 font-mono whitespace-nowrap" title={r.name ?? ''}>
|
||||||
<span className="text-foreground/80">{r.code}{r.ref ? `@${r.ref}` : ''}</span>
|
<span className="text-foreground/80">{r.code}{r.ref ? `@${r.ref}` : ''}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
|
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -38,6 +38,12 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
|||||||
// which used to dismiss the menu the instant it appeared.)
|
// which used to dismiss the menu the instant it appeared.)
|
||||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Starts at the cursor; the layout effect corrects it once the real size is
|
||||||
|
// known. Rendering at the cursor first avoids a visible jump for the common
|
||||||
|
// case where it already fits.
|
||||||
|
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||||
|
useEffect(() => { if (menu) setPos({ x: menu.x, y: menu.y }); }, [menu]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
const close = () => onClose();
|
const close = () => onClose();
|
||||||
@@ -50,16 +56,40 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
};
|
};
|
||||||
}, [menu, onClose]);
|
}, [menu, onClose]);
|
||||||
|
|
||||||
|
// Position AFTER measuring.
|
||||||
|
//
|
||||||
|
// The menu used to be clamped against a guessed height — 230 px when the
|
||||||
|
// upload submenu was present, 110 otherwise. It is far taller than that with
|
||||||
|
// a selection, so right-clicking near the bottom of the window cut half the
|
||||||
|
// entries off. Its height also depends on which actions the caller passes, so
|
||||||
|
// no constant can be right for long.
|
||||||
|
//
|
||||||
|
// Measured instead: if it does not fit below the cursor it is placed above,
|
||||||
|
// and failing that pinned to the top with its own scrollbar.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!menu || !boxRef.current) return;
|
||||||
|
const r = boxRef.current.getBoundingClientRect();
|
||||||
|
const pad = 6;
|
||||||
|
let x = menu.x;
|
||||||
|
let y = menu.y;
|
||||||
|
if (x + r.width > window.innerWidth - pad) {
|
||||||
|
x = Math.max(pad, window.innerWidth - r.width - pad);
|
||||||
|
}
|
||||||
|
if (y + r.height > window.innerHeight - pad) {
|
||||||
|
// Above the cursor, if there is room there.
|
||||||
|
y = menu.y - r.height >= pad ? menu.y - r.height : Math.max(pad, window.innerHeight - r.height - pad);
|
||||||
|
}
|
||||||
|
setPos({ x, y });
|
||||||
|
}, [menu, onSendTo]);
|
||||||
|
|
||||||
if (!menu) return null;
|
if (!menu) return null;
|
||||||
const n = menu.ids.length;
|
const n = menu.ids.length;
|
||||||
// Keep the menu on-screen near the cursor.
|
|
||||||
const x = Math.min(menu.x, window.innerWidth - 248);
|
|
||||||
const y = Math.min(menu.y, window.innerHeight - (onSendTo ? 230 : 110));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed z-[200] min-w-[240px] rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
|
ref={boxRef}
|
||||||
style={{ left: x, top: y }}
|
className="fixed z-[200] min-w-[240px] max-h-[85vh] overflow-y-auto rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
|
||||||
|
style={{ left: pos.x, top: pos.y }}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||||
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||||
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
GetAudioSettings, SaveAudioSettings, AudioApplyLevels, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||||
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
||||||
GetClublogMostWantedInfo, SetClublogMostWantedEnabled, DownloadClublogMostWanted,
|
GetClublogMostWantedInfo, SetClublogMostWantedEnabled, DownloadClublogMostWanted,
|
||||||
GetSecretStatus, SetPassphrase, RemovePassphrase,
|
GetSecretStatus, SetPassphrase, RemovePassphrase,
|
||||||
@@ -47,7 +47,7 @@ import {
|
|||||||
DownloadULSCounties, ULSStatus, BackfillUSCounties,
|
DownloadULSCounties, ULSStatus, BackfillUSCounties,
|
||||||
ComputeStationInfo,
|
ComputeStationInfo,
|
||||||
GetUIPref, SetUIPref,
|
GetUIPref, SetUIPref,
|
||||||
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, GetFlexBandPower, SaveFlexBandPower,
|
||||||
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
||||||
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
@@ -953,81 +953,121 @@ function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// FlexBandAntennasPanel — pick the RX/TX antenna per band. Applied automatically
|
// FlexBandPanel — everything that follows the band, in ONE row per band:
|
||||||
// when the band changes (frequency change / spot click). Antennas come live from
|
// antennas and TX power.
|
||||||
// the connected FlexRadio; bands come from Lists → Bands.
|
//
|
||||||
function FlexBandAntennasPanel({ bands }: { bands: string[] }) {
|
// They were two tables listing the same bands one under the other, which made
|
||||||
|
// the operator match rows by eye between them. The two settings are stored and
|
||||||
|
// applied separately (antennas on band change, power on band or mode change) —
|
||||||
|
// that is a backend detail, and no reason to split what is one decision per
|
||||||
|
// band for the person configuring it.
|
||||||
|
function FlexBandPanel({ bands }: { bands: string[] }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [rxList, setRxList] = useState<string[]>([]);
|
const [rxList, setRxList] = useState<string[]>([]);
|
||||||
const [txList, setTxList] = useState<string[]>([]);
|
const [txList, setTxList] = useState<string[]>([]);
|
||||||
const [map, setMap] = useState<Record<string, { rx: string; tx: string }>>({});
|
const [ant, setAnt] = useState<Record<string, { rx: string; tx: string }>>({});
|
||||||
|
const [pwr, setPwr] = useState<Record<string, { phone: number; cw: number; digi: number }>>({});
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
GetFlexState().then((s: any) => {
|
GetFlexState().then((s: any) => {
|
||||||
setRxList((s?.ant_list ?? []) as string[]);
|
setRxList((s?.ant_list ?? []) as string[]);
|
||||||
setTxList(((s?.tx_ant_list?.length ? s.tx_ant_list : s?.ant_list) ?? []) as string[]);
|
setTxList(((s?.tx_ant_list?.length ? s.tx_ant_list : s?.ant_list) ?? []) as string[]);
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
GetFlexBandAntennas().then((m: any) => setMap(m ?? {})).catch(() => {});
|
GetFlexBandAntennas().then((m: any) => setAnt(m ?? {})).catch(() => {});
|
||||||
|
GetFlexBandPower().then((m: any) => setPwr(m ?? {})).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
const set = (band: string, side: 'rx' | 'tx', v: string) => {
|
|
||||||
|
const saved = () => { setMsg(t('flxpw.saved')); window.setTimeout(() => setMsg(''), 1200); };
|
||||||
|
|
||||||
|
const setAntenna = (band: string, side: 'rx' | 'tx', v: string) => {
|
||||||
const key = band.toUpperCase();
|
const key = band.toUpperCase();
|
||||||
setMap((m) => {
|
setAnt((m) => {
|
||||||
const cur = m[key] ?? { rx: '', tx: '' };
|
const next = { ...m, [key]: { ...(m[key] ?? { rx: '', tx: '' }), [side]: v } };
|
||||||
const next = { ...m, [key]: { ...cur, [side]: v } };
|
SaveFlexBandAntennas(next as any).then(saved).catch((e: any) => setMsg(String(e?.message ?? e)));
|
||||||
SaveFlexBandAntennas(next as any)
|
|
||||||
.then(() => { setMsg('Saved'); window.setTimeout(() => setMsg(''), 1200); })
|
|
||||||
.catch((e: any) => setMsg(String(e?.message ?? e)));
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setPower = (band: string, kind: 'phone' | 'cw' | 'digi', v: string) => {
|
||||||
|
const key = band.toUpperCase();
|
||||||
|
const n = Math.max(0, Math.min(100, parseInt(v, 10) || 0));
|
||||||
|
setPwr((m) => {
|
||||||
|
const next = { ...m, [key]: { ...(m[key] ?? { phone: 0, cw: 0, digi: 0 }), [kind]: n } };
|
||||||
|
SaveFlexBandPower(next as any).then(saved).catch((e: any) => setMsg(String(e?.message ?? e)));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const antCell = (b: string, side: 'rx' | 'tx', list: string[]) => {
|
||||||
|
const e = ant[b.toUpperCase()] ?? { rx: '', tx: '' };
|
||||||
|
return (
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<select value={e[side] ?? ''} onChange={(ev) => setAntenna(b, side, ev.target.value)}
|
||||||
|
className="h-8 w-28 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
|
||||||
|
<option value="">— none —</option>
|
||||||
|
{list.map((a) => <option key={a} value={a}>{a}</option>)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The rule marking where band-driven settings end and mode-driven ones begin
|
||||||
|
// is drawn on the FIRST power cell, header and body alike. It used to be an
|
||||||
|
// extra empty cell in the body only, which gave the two rows different column
|
||||||
|
// counts and slid every heading one column left.
|
||||||
|
const pwrCell = (b: string, kind: 'phone' | 'cw' | 'digi') => {
|
||||||
|
const e = pwr[b.toUpperCase()] ?? { phone: 0, cw: 0, digi: 0 };
|
||||||
|
return (
|
||||||
|
<td className={cn('px-2 py-1.5', kind === 'phone' && 'border-l border-border/60')}>
|
||||||
|
<Input
|
||||||
|
type="number" min={0} max={100}
|
||||||
|
className="h-8 w-16 text-xs"
|
||||||
|
value={e[kind] ? String(e[kind]) : ''}
|
||||||
|
placeholder="—"
|
||||||
|
onChange={(ev) => setPower(b, kind, ev.target.value)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold text-foreground">FlexRadio — per-band antennas</h3>
|
<h3 className="text-base font-semibold text-foreground">{t('flxb.title')}</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{t('flxb.hint')}</p>
|
||||||
Choose the RX and TX antenna for each band. They're applied automatically when the band
|
|
||||||
changes (frequency change or clicking a spot).
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{rxList.length === 0 && (
|
{rxList.length === 0 && (
|
||||||
<div className="text-xs text-warning-muted-foreground bg-warning-muted border border-warning-border rounded-md px-3 py-2 max-w-xl">
|
<div className="text-xs text-warning-muted-foreground bg-warning-muted border border-warning-border rounded-md px-3 py-2 max-w-2xl">
|
||||||
No antennas reported yet — make sure the FlexRadio is connected (CAT interface), then reopen this panel.
|
{t('flxb.noRadio')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{bands.length === 0 ? (
|
{bands.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">No bands configured — add them in Lists → Bands.</p>
|
<p className="text-xs text-muted-foreground">{t('flxpw.noBands')}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border border-border overflow-hidden max-w-xl">
|
<div className="rounded-md border border-border overflow-x-auto max-w-3xl">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-muted/40 text-muted-foreground text-xs">
|
<thead className="bg-muted/40 text-muted-foreground text-xs">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-3 py-1.5 font-semibold">Band</th>
|
<th className="text-left px-3 py-1.5 font-semibold">{t('flxpw.band')}</th>
|
||||||
<th className="text-left px-3 py-1.5 font-semibold">RX antenna</th>
|
<th className="text-left px-2 py-1.5 font-semibold">{t('flxb.rxAnt')}</th>
|
||||||
<th className="text-left px-3 py-1.5 font-semibold">TX antenna</th>
|
<th className="text-left px-2 py-1.5 font-semibold">{t('flxb.txAnt')}</th>
|
||||||
|
<th className="text-left px-2 py-1.5 font-semibold border-l border-border/60">{t('flxpw.phone')}</th>
|
||||||
|
<th className="text-left px-2 py-1.5 font-semibold">CW</th>
|
||||||
|
<th className="text-left px-2 py-1.5 font-semibold">{t('flxpw.digi')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{bands.map((b) => {
|
{bands.map((b) => (
|
||||||
const e = map[b.toUpperCase()] ?? { rx: '', tx: '' };
|
|
||||||
return (
|
|
||||||
<tr key={b} className="border-t border-border/50">
|
<tr key={b} className="border-t border-border/50">
|
||||||
<td className="px-3 py-1.5 font-mono font-semibold">{b}</td>
|
<td className="px-3 py-1.5 font-mono font-semibold">{b}</td>
|
||||||
<td className="px-3 py-1.5">
|
{antCell(b, 'rx', rxList)}
|
||||||
<select value={e.rx ?? ''} onChange={(ev) => set(b, 'rx', ev.target.value)}
|
{antCell(b, 'tx', txList)}
|
||||||
className="h-8 w-32 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
|
{pwrCell(b, 'phone')}
|
||||||
<option value="">— none —</option>
|
{pwrCell(b, 'cw')}
|
||||||
{rxList.map((a) => <option key={a} value={a}>{a}</option>)}
|
{pwrCell(b, 'digi')}
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-1.5">
|
|
||||||
<select value={e.tx ?? ''} onChange={(ev) => set(b, 'tx', ev.target.value)}
|
|
||||||
className="h-8 w-32 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
|
|
||||||
<option value="">— none —</option>
|
|
||||||
{txList.map((a) => <option key={a} value={a}>{a}</option>)}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -1096,7 +1136,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [modeDraft, setModeDraft] = useState('');
|
const [modeDraft, setModeDraft] = useState('');
|
||||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
|
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
|
||||||
yaesu_port: '', yaesu_baud: 38400, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70,
|
yaesu_port: '', yaesu_baud: 38400, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
|
||||||
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
||||||
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
||||||
digital_default: 'FT8', share_enabled: false, share_port: 4532,
|
digital_default: 'FT8', share_enabled: false, share_port: 4532,
|
||||||
@@ -1158,13 +1198,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
|
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
|
||||||
qso_record: boolean; qso_dir: string; preroll_seconds: number;
|
qso_record: boolean; qso_dir: string; preroll_seconds: number;
|
||||||
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3';
|
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3';
|
||||||
from_gain: number; mic_gain: number; tx_gain: number;
|
from_gain: number; mic_gain: number; tx_gain: number; qso_play_gain: number;
|
||||||
};
|
};
|
||||||
type AudioDev = { id: string; name: string; default: boolean };
|
type AudioDev = { id: string; name: string; default: boolean };
|
||||||
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
|
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
|
||||||
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
|
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
|
||||||
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav',
|
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav',
|
||||||
from_gain: 100, mic_gain: 100, tx_gain: 100,
|
from_gain: 100, mic_gain: 100, tx_gain: 100, qso_play_gain: 100,
|
||||||
});
|
});
|
||||||
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
|
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
|
||||||
const [audioOutputs, setAudioOutputs] = useState<AudioDev[]>([]);
|
const [audioOutputs, setAudioOutputs] = useState<AudioDev[]>([]);
|
||||||
@@ -2498,6 +2538,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}} />
|
}} />
|
||||||
<span className="text-xs text-muted-foreground">{t('cat.xieguAddrHint')}</span>
|
<span className="text-xs text-muted-foreground">{t('cat.xieguAddrHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('cat.xieguPTTLine')}</Label>
|
||||||
|
<Select value={catCfg.xiegu_ptt_line || 'civ'}
|
||||||
|
onValueChange={(v) => setCatCfg((s) => ({ ...s, xiegu_ptt_line: v === 'civ' ? '' : v }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="civ">{t('cat.xieguPTTCiv')}</SelectItem>
|
||||||
|
<SelectItem value="rts">RTS</SelectItem>
|
||||||
|
<SelectItem value="dtr">DTR</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-xs text-muted-foreground">{t('cat.xieguPTTHint')}</span>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{catCfg.backend === 'yaesu' && (
|
{catCfg.backend === 'yaesu' && (
|
||||||
@@ -5075,13 +5128,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Label className="text-sm">{t('aud.fromLevel')}</Label>
|
<Label className="text-sm">{t('aud.fromLevel')}</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input type="range" min={10} max={300} step={5} value={audioCfg.from_gain}
|
<input type="range" min={10} max={300} step={5} value={audioCfg.from_gain}
|
||||||
onChange={(e) => setAudioField({ from_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
|
onChange={(e) => { const v = parseInt(e.target.value, 10); setAudioField({ from_gain: v }); AudioApplyLevels(v, audioCfg.mic_gain); }} className="w-48 accent-primary" />
|
||||||
<span className="font-mono text-xs w-12 text-right">{audioCfg.from_gain}%</span>
|
<span className="font-mono text-xs w-12 text-right">{audioCfg.from_gain}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
<Label className="text-sm">{t('aud.qsoPlayLevel')}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="range" min={10} max={300} step={5} value={audioCfg.qso_play_gain}
|
||||||
|
onChange={(e) => setAudioField({ qso_play_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
|
||||||
|
<span className="font-mono text-xs w-12 text-right">{audioCfg.qso_play_gain}%</span>
|
||||||
|
</div>
|
||||||
<Label className="text-sm">{t('aud.micLevel')}</Label>
|
<Label className="text-sm">{t('aud.micLevel')}</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input type="range" min={10} max={300} step={5} value={audioCfg.mic_gain}
|
<input type="range" min={10} max={300} step={5} value={audioCfg.mic_gain}
|
||||||
onChange={(e) => setAudioField({ mic_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
|
onChange={(e) => { const v = parseInt(e.target.value, 10); setAudioField({ mic_gain: v }); AudioApplyLevels(audioCfg.from_gain, v); }} className="w-48 accent-primary" />
|
||||||
<span className="font-mono text-xs w-12 text-right">{audioCfg.mic_gain}%</span>
|
<span className="font-mono text-xs w-12 text-right">{audioCfg.mic_gain}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -5591,7 +5650,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
antgenius: AntGeniusPanelSettings,
|
antgenius: AntGeniusPanelSettings,
|
||||||
tunergenius: TunerGeniusPanelSettings,
|
tunergenius: TunerGeniusPanelSettings,
|
||||||
pgxl: PGXLPanelSettings,
|
pgxl: PGXLPanelSettings,
|
||||||
flex: () => <FlexBandAntennasPanel bands={lists.bands ?? []} />,
|
flex: () => <FlexBandPanel bands={lists.bands ?? []} />,
|
||||||
audio: AudioPanel,
|
audio: AudioPanel,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ const TabsContent = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<TabsPrimitive.Content
|
<TabsPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', className)}
|
// No focus ring on the panel itself. Radix makes the content focusable and
|
||||||
|
// moves focus into it when a tab is selected, so clicking anywhere in the
|
||||||
|
// lower half drew a full-width orange rule under the tab strip. The panel is
|
||||||
|
// a container — what it holds carries its own focus styling.
|
||||||
|
className={cn('mt-2 focus-visible:outline-none', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
+16
-16
@@ -51,7 +51,7 @@ const en: Dict = {
|
|||||||
'dup.colDate': 'Date / time (UTC)', 'dup.colFreq': 'Freq', 'dup.colRst': 'RST s/r', 'dup.colName': 'Name',
|
'dup.colDate': 'Date / time (UTC)', 'dup.colFreq': 'Freq', 'dup.colRst': 'RST s/r', 'dup.colName': 'Name',
|
||||||
'profileScope.saved': 'Saved for profile', 'profileScope.switch': '— switch profiles to edit another identity.',
|
'profileScope.saved': 'Saved for profile', 'profileScope.switch': '— switch profiles to edit another identity.',
|
||||||
// Tabs
|
// Tabs
|
||||||
'tab.main': 'Main', 'tab.recent': 'Recent QSOs', 'tab.cluster': 'Cluster', 'tab.worked': 'Worked before',
|
'tab.main': 'Main', 'main.splitTip': 'Drag to resize the panes — double-click to even them out', 'tab.recent': 'Recent QSOs', 'tab.cluster': 'Cluster', 'tab.worked': 'Worked before',
|
||||||
'tab.awards': 'Awards', 'tab.bandmap': 'Band Map', 'tab.contest': 'Contest', 'tab.net': 'Net',
|
'tab.awards': 'Awards', 'tab.bandmap': 'Band Map', 'tab.contest': 'Contest', 'tab.net': 'Net',
|
||||||
// Entry form
|
// Entry form
|
||||||
'field.callsign': 'Callsign', 'field.name': 'Name', 'field.qth': 'QTH', 'field.grid': 'Grid',
|
'field.callsign': 'Callsign', 'field.name': 'Name', 'field.qth': 'QTH', 'field.grid': 'Grid',
|
||||||
@@ -135,7 +135,7 @@ const en: Dict = {
|
|||||||
'imp.ctyDesc': "Recompute Country, DXCC & CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). ClubLog's DXpedition overrides are applied on top per QSO date (e.g. TO974REF → Reunion, TO2A 2012 → French Guiana) whenever the ClubLog data is downloaded. Everything else in the ADIF is kept as-is. Tip: use Update duplicates to re-fix QSOs already in your log.",
|
'imp.ctyDesc': "Recompute Country, DXCC & CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). ClubLog's DXpedition overrides are applied on top per QSO date (e.g. TO974REF → Reunion, TO2A 2012 → French Guiana) whenever the ClubLog data is downloaded. Everything else in the ADIF is kept as-is. Tip: use Update duplicates to re-fix QSOs already in your log.",
|
||||||
'imp.stationTitle': 'Fill my station fields from my profile',
|
'imp.stationTitle': 'Fill my station fields from my profile',
|
||||||
'imp.stationDesc': "Backfill empty MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power…) plus Operator and Owner callsign from your active profile. Existing values are kept. Only STATION_CALLSIGN is left untouched so a mixed-call log isn't re-routed. It ALSO stamps your default confirmation statuses (paper QSL, LoTW, eQSL, Club Log, HRDLog, QRZ.com sent and received) on the ones the file leaves empty — a WSJT-X log carries almost none. Enable when importing your own log.",
|
'imp.stationDesc': "Backfill empty MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power…) plus Operator and Owner callsign from your active profile. Existing values are kept. Only STATION_CALLSIGN is left untouched so a mixed-call log isn't re-routed. It ALSO stamps your default confirmation statuses (paper QSL, LoTW, eQSL, Club Log, HRDLog, QRZ.com sent and received) on the ones the file leaves empty — a WSJT-X log carries almost none. Enable when importing your own log.",
|
||||||
'imp.cancel': 'Cancel', 'rec.manualStart': 'Record this contact. Automatic recording is off, so capture starts now \u2014 there is no pre-roll of what came before.', 'rec.stop': 'Stop the recording. The audio is kept and still saved with the QSO \u2014 stop it to play it back to the station you are working.', 'rec.resume': 'Carry on recording, appending to what is already captured.', 'rec.playOnAir': 'TRANSMIT the recording to the station you are working: keys the radio and sends it like a voice-keyer message.', 'rec.manualFailed': 'Recording could not be started \u2014 check the audio devices in Settings.', 'imp.mapAdd': 'The file puts a field in the wrong place\u2026', 'imp.mapTitle': 'Move fields on import', 'imp.mapDesc': 'Contest software stores the exchange where its own module keeps it. The RSGB IOTA contest exports the island reference in STATE \u2014 imported as-is it becomes a US state and the IOTA award stays empty. The destination is only filled where the file left it blank.', 'imp.mapMore': 'Add another', 'imp.import': 'Import',
|
'imp.cancel': 'Cancel', 'rec.manualStart': 'Record this contact. Automatic recording is off, so capture starts now \u2014 there is no pre-roll of what came before.', 'rec.stop': 'Stop the recording. The audio is kept and still saved with the QSO \u2014 stop it to play it back to the station you are working.', 'rec.resume': 'Carry on recording, appending to what is already captured.', 'rec.playOnAir': 'TRANSMIT the recording to the station you are working: keys the radio and sends it like a voice-keyer message.', 'rec.stopPlaying': 'Stop transmitting the recording', 'rec.manualFailed': 'Recording could not be started \u2014 check the audio devices in Settings.', 'imp.mapAdd': 'The file puts a field in the wrong place\u2026', 'imp.mapTitle': 'Move fields on import', 'imp.mapDesc': 'Contest software stores the exchange where its own module keeps it. The RSGB IOTA contest exports the island reference in STATE \u2014 imported as-is it becomes a US state and the IOTA award stays empty. The destination is only filled where the file left it blank.', 'imp.mapMore': 'Add another', 'imp.import': 'Import',
|
||||||
'imp.progressTitle': 'Importing ADIF…', 'bulk.progressTitle': 'Updating the selected QSOs\u2026',
|
'imp.progressTitle': 'Importing ADIF…', 'bulk.progressTitle': 'Updating the selected QSOs\u2026',
|
||||||
'imp.progressCount': '{done} / {tot} records · {pct}%', 'imp.progressCountOnly': '{done} records…',
|
'imp.progressCount': '{done} / {tot} records · {pct}%', 'imp.progressCountOnly': '{done} records…',
|
||||||
'imp.complete': 'Import complete.',
|
'imp.complete': 'Import complete.',
|
||||||
@@ -277,7 +277,7 @@ const en: Dict = {
|
|||||||
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
||||||
'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
||||||
// CAT panel body
|
// CAT panel body
|
||||||
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'A native CAT backend owns the radio serial port, so no other program can reach it. This lets WSJT-X, JTDX, MSHV or Log4OM talk to the rig THROUGH OpsLog: in the other program pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532. Works with every backend, not only the native ones.', 'cat.sharePort': 'Sharing port', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, network)', 'cat.civTrace': 'Log the CAT protocol', 'cat.civTraceHint': 'Writes every CAT frame to and from the radio into the log \u2014 CI-V as hex, Kenwood as the text it exchanges. For reporting a rig that answers oddly \u2014 a button that does the wrong thing, a frequency that jumps. Session only: it is a diagnostic, and it makes the log large.', 'cat.kenwoodPort': 'Kenwood COM port', 'cat.kenwoodPortHint': 'The rig\u2019s CAT/USB serial port (TS-590, TS-890, TS-990, TS-2000, and Elecraft K3/K4 which speak the same dialect).', 'cat.kenwoodBaudHint': 'Must match MENU on the radio: a TS-590 leaves the factory at 9600, a TS-890 at 115200.', 'cat.kenwoodHost': 'Or over the network (host:port)', 'cat.kenwoodHostHint': 'A serial-over-network bridge \u2014 ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio. Filled in, it is used INSTEAD of the COM port above. This is not the radio\u2019s own RJ45 socket, which speaks Kenwood\u2019s KNS protocol and is not supported.', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V network)', 'cat.optTci': 'TCI',
|
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'A native CAT backend owns the radio serial port, so no other program can reach it. This lets WSJT-X, JTDX, MSHV or Log4OM talk to the rig THROUGH OpsLog: in the other program pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532. Works with every backend, not only the native ones.', 'cat.sharePort': 'Sharing port', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.xieguPTTLine': 'How the rig is keyed', 'cat.xieguPTTCiv': 'CI-V command', 'cat.xieguPTTHint': 'A G90 does not transmit on the CI-V command: interfaces like the DE-19 key it on RTS or DTR. Pick the line yours uses \u2014 it is also what lets WSJT-X transmit through the shared CAT link.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, network)', 'cat.civTrace': 'Log the CAT protocol', 'cat.civTraceHint': 'Writes every CAT frame to and from the radio into the log \u2014 CI-V as hex, Kenwood as the text it exchanges. For reporting a rig that answers oddly \u2014 a button that does the wrong thing, a frequency that jumps. Session only: it is a diagnostic, and it makes the log large.', 'cat.kenwoodPort': 'Kenwood COM port', 'cat.kenwoodPortHint': 'The rig\u2019s CAT/USB serial port (TS-590, TS-890, TS-990, TS-2000, and Elecraft K3/K4 which speak the same dialect).', 'cat.kenwoodBaudHint': 'Must match MENU on the radio: a TS-590 leaves the factory at 9600, a TS-890 at 115200.', 'cat.kenwoodHost': 'Or over the network (host:port)', 'cat.kenwoodHostHint': 'A serial-over-network bridge \u2014 ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio. Filled in, it is used INSTEAD of the COM port above. This is not the radio\u2019s own RJ45 socket, which speaks Kenwood\u2019s KNS protocol and is not supported.', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V network)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 power per band and mode', 'flxpw.hint': 'TX power applied when the band or mode changes. Leave a box empty to leave the power alone.', 'flxpw.band': 'Band', 'flxb.title': 'FlexRadio \u2014 per-band antennas and power', 'flxb.hint': 'Antennas are applied when the band changes; power when the band or the mode changes. Leave a power box empty to leave it alone.', 'flxb.rxAnt': 'RX antenna', 'flxb.txAnt': 'TX antenna', 'flxb.noRadio': 'No antennas reported yet \u2014 connect the FlexRadio, then reopen this panel.', 'flxpw.phone': 'Phone', 'flxpw.digi': 'Digital', 'flxpw.noBands': 'No bands configured \u2014 add them in Lists \u2192 Bands.', 'flxpw.saved': 'Saved',
|
||||||
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
|
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
|
||||||
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
|
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
|
||||||
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
|
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
|
||||||
@@ -366,14 +366,14 @@ const en: Dict = {
|
|||||||
'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',
|
'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',
|
'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': 'Paper QSL sent status', 'fltb.fQslSentDate': 'Paper QSL sent date', 'fltb.fQslRcvd': 'Paper QSL received status', 'fltb.fQslRcvdDate': 'Paper QSL received date', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent status', 'fltb.fLotwSentDate': 'LoTW sent date', 'fltb.fLotwRcvd': 'LoTW received status', 'fltb.fLotwRcvdDate': 'LoTW received date', 'fltb.fEqslSent': 'eQSL sent status', 'fltb.fEqslSentDate': 'eQSL sent date', 'fltb.fEqslRcvd': 'eQSL received status', 'fltb.fEqslRcvdDate': 'eQSL received date', 'fltb.fQrzSent': 'QRZ.com sent status', 'fltb.fQrzSentDate': 'QRZ.com sent date', 'fltb.fQrzRcvd': 'QRZ.com received status', 'fltb.fQrzRcvdDate': 'QRZ.com received date', 'fltb.fClublogSent': 'Club Log sent status', 'fltb.fClublogSentDate': 'Club Log sent date', 'fltb.fHrdlogSent': 'HRDLog sent status', 'fltb.fHrdlogSentDate': 'HRDLog sent date', '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',
|
'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': 'Paper QSL sent status', 'fltb.fQslSentDate': 'Paper QSL sent date', 'fltb.fQslRcvd': 'Paper QSL received status', 'fltb.fQslRcvdDate': 'Paper QSL received date', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent status', 'fltb.fLotwSentDate': 'LoTW sent date', 'fltb.fLotwRcvd': 'LoTW received status', 'fltb.fLotwRcvdDate': 'LoTW received date', 'fltb.fEqslSent': 'eQSL sent status', 'fltb.fEqslSentDate': 'eQSL sent date', 'fltb.fEqslRcvd': 'eQSL received status', 'fltb.fEqslRcvdDate': 'eQSL received date', 'fltb.fQrzSent': 'QRZ.com sent status', 'fltb.fQrzSentDate': 'QRZ.com sent date', 'fltb.fQrzRcvd': 'QRZ.com received status', 'fltb.fQrzRcvdDate': 'QRZ.com received date', 'fltb.fClublogSent': 'Club Log sent status', 'fltb.fClublogSentDate': 'Club Log sent date', 'fltb.fHrdlogSent': 'HRDLog sent status', 'fltb.fHrdlogSentDate': 'HRDLog sent date', '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',
|
'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.newCounty': 'NEW', 'detp.newCountyTip': 'County never worked before', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ', 'detp.ituZone': 'ITU', '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.ambiguous': 'Ambiguous — pick one:', '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',
|
||||||
// Awards (ref picker / ref selector / awards panel / award editor)
|
// Awards (ref picker / ref selector / awards panel / award editor)
|
||||||
'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.',
|
'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.',
|
||||||
'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.',
|
'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.',
|
||||||
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
|
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.modePhone': 'Phone', 'awp.modeDigital': 'Digital', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
|
||||||
'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Fallback searches', 'awed.orAlsoMatch': '— tried in order, only if nothing matched yet; first hit wins', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference',
|
'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.oneRef': 'One reference per QSO', 'awed.oneRefHint': 'When several references match the same contact, assign none and list it under missing references. For awards where two entries can share a description — two DOKs called Gießen — picking one at random would write the wrong one into the log.', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Fallback searches', 'awed.orAlsoMatch': '— tried in order, only if nothing matched yet; first hit wins', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference',
|
||||||
'awed.updateAvailable': 'An updated version of this award is available', 'awed.updateOverwrites': 'You have modified this award, so the update was not applied. Taking it replaces your definition and reference list.', 'awed.updateApply': 'Update', 'awed.updateKeepMine': 'Keep mine',
|
'awed.updateAvailable': 'An updated version of this award is available', 'awed.updateOverwrites': 'You have modified this award, so the update was not applied. Taking it replaces your definition and reference list.', 'awed.updateApply': 'Update', 'awed.updateKeepMine': 'Keep mine',
|
||||||
'awed.tabTest': 'Test', 'awed.testCallsign': 'Test against callsign', 'awed.testRun': 'Test', 'awed.testSavedOnly': 'Tests the SAVED award — save your changes first.', 'awed.testNoMatch': 'no match', 'awed.testOutOfScope': 'QSO out of scope — no rule was run.', 'awed.testSkipped': 'not run: an earlier rule already matched', 'awed.testFieldValue': 'Field', 'awed.testEmptyField': 'empty', 'awed.testNoCandidate': 'produced no candidate', 'awed.testManual': 'Manual override', 'awed.testSameAs': '+{n} other QSO(s), same result',
|
'awed.tabTest': 'Test', 'awed.testCallsign': 'Test against callsign', 'awed.testRun': 'Test', 'awed.testSavedOnly': 'Tests the SAVED award — save your changes first.', 'awed.testNoMatch': 'no match', 'awed.testOutOfScope': 'QSO out of scope — no rule was run.', 'awed.testSkipped': 'not run: an earlier rule already matched', 'awed.testFieldValue': 'Field', 'awed.testEmptyField': 'empty', 'awed.testNoCandidate': 'produced no candidate', 'awed.testManual': 'Manual override', 'awed.testAmbiguous': 'Ambiguous', 'awed.testAmbiguousHint': '— none kept, this award allows one reference per QSO. Assign the right one by hand.', 'awed.testSameAs': '+{n} other QSO(s), same result',
|
||||||
'awed.exportOne': 'Share {code}',
|
'awed.exportOne': 'Share {code}',
|
||||||
'awed.onlyHere': 'local',
|
'awed.onlyHere': 'local',
|
||||||
'awed.onlyHereTip': 'Yours — not shipped with OpsLog. Its JSON is kept up to date in the awards folder; send that file to share it.',
|
'awed.onlyHereTip': 'Yours — not shipped with OpsLog. Its JSON is kept up to date in the awards folder; send that file to share it.',
|
||||||
@@ -424,7 +424,7 @@ const en: Dict = {
|
|||||||
'aud.txOn': 'TRANSMITTING — mic → To Radio, PTT keyed. Click to stop.', 'aud.txHint': 'Live mic → rig with PTT (USB now; network TX later).',
|
'aud.txOn': 'TRANSMITTING — mic → To Radio, PTT keyed. Click to stop.', 'aud.txHint': 'Live mic → rig with PTT (USB now; network TX later).',
|
||||||
'aud.recorder': 'QSO recorder', 'aud.recordEvery': 'Record every QSO to an audio file (From Radio + your mic)', 'aud.recFolder': 'Recordings folder', 'aud.browse': 'Browse…',
|
'aud.recorder': 'QSO recorder', 'aud.recordEvery': 'Record every QSO to an audio file (From Radio + your mic)', 'aud.recFolder': 'Recordings folder', 'aud.browse': 'Browse…',
|
||||||
'aud.preroll': 'Pre-roll (seconds)', 'aud.format': 'File format', 'aud.wav': 'WAV (lossless, larger)', 'aud.mp3': 'MP3 (compressed, small)',
|
'aud.preroll': 'Pre-roll (seconds)', 'aud.format': 'File format', 'aud.wav': 'WAV (lossless, larger)', 'aud.mp3': 'MP3 (compressed, small)',
|
||||||
'aud.fromLevel': 'From Radio level', 'aud.txLevel': 'Voice keyer level', 'aud.txLevelHint': 'Level of the recorded messages sent to the radio. Raise it if your voice keyer is much quieter than your microphone; Play previews at this same level. If the radio transmits almost nothing, its modulation source is still the front microphone: on an FTDX10 set MENU → SSB MOD SOURCE to REAR (the USB input).', 'aud.micLevel': 'Mic level', 'aud.levelHint': 'If your voice is louder than the station, lower Mic level.',
|
'aud.fromLevel': 'From Radio level', 'aud.txLevel': 'Voice keyer level', 'aud.txLevelHint': 'Level of the recorded messages sent to the radio. Raise it if your voice keyer is much quieter than your microphone; Play previews at this same level. If the radio transmits almost nothing, its modulation source is still the front microphone: on an FTDX10 set MENU → SSB MOD SOURCE to REAR (the USB input).', 'aud.micLevel': 'Mic level', 'aud.qsoPlayLevel': 'QSO playback level', 'aud.levelHint': 'If your voice is louder than the station, lower Mic level.',
|
||||||
'aud.autoSend': 'Auto-send the recording to the station by e-mail when I log a QSO',
|
'aud.autoSend': 'Auto-send the recording to the station by e-mail when I log a QSO',
|
||||||
'aud.dvkTitle': 'Voice keyer messages (F1–F6)', 'aud.pttMethod': 'PTT method', 'aud.pttNone': 'None (VOX)', 'aud.pttCat': 'CAT (the radio link)', 'aud.pttRts': 'Serial RTS', 'aud.pttDtr': 'Serial DTR',
|
'aud.dvkTitle': 'Voice keyer messages (F1–F6)', 'aud.pttMethod': 'PTT method', 'aud.pttNone': 'None (VOX)', 'aud.pttCat': 'CAT (the radio link)', 'aud.pttRts': 'Serial RTS', 'aud.pttDtr': 'Serial DTR',
|
||||||
'aud.testPtt': 'Test PTT', 'aud.pttPort': 'PTT COM port', 'aud.pickPort': 'Pick a COM port', 'aud.selectPort': '— select —', 'aud.refresh': 'Refresh',
|
'aud.testPtt': 'Test PTT', 'aud.pttPort': 'PTT COM port', 'aud.pickPort': 'Pick a COM port', 'aud.selectPort': '— select —', 'aud.refresh': 'Refresh',
|
||||||
@@ -464,7 +464,7 @@ const fr: Dict = {
|
|||||||
'dup.selectExtras': 'Tout sélectionner sauf le premier', 'dup.deselectAll': 'Tout désélectionner', 'dup.deleteSel': 'Supprimer {n} sélectionné(s)', 'dup.deleteConfirm': 'Supprimer {n} QSO ? Action irréversible.', 'dup.deleted': '{n} doublon(s) supprimé(s)', 'dup.keep': 'le plus ancien', 'dup.close': 'Fermer',
|
'dup.selectExtras': 'Tout sélectionner sauf le premier', 'dup.deselectAll': 'Tout désélectionner', 'dup.deleteSel': 'Supprimer {n} sélectionné(s)', 'dup.deleteConfirm': 'Supprimer {n} QSO ? Action irréversible.', 'dup.deleted': '{n} doublon(s) supprimé(s)', 'dup.keep': 'le plus ancien', 'dup.close': 'Fermer',
|
||||||
'dup.colDate': 'Date / heure (UTC)', 'dup.colFreq': 'Fréq', 'dup.colRst': 'RST e/r', 'dup.colName': 'Nom',
|
'dup.colDate': 'Date / heure (UTC)', 'dup.colFreq': 'Fréq', 'dup.colRst': 'RST e/r', 'dup.colName': 'Nom',
|
||||||
'profileScope.saved': 'Enregistré pour le profil', 'profileScope.switch': "— change de profil pour éditer une autre identité.",
|
'profileScope.saved': 'Enregistré pour le profil', 'profileScope.switch': "— change de profil pour éditer une autre identité.",
|
||||||
'tab.main': 'Principal', 'tab.recent': 'QSO récents', 'tab.cluster': 'Cluster', 'tab.worked': 'Déjà contacté',
|
'tab.main': 'Principal', 'main.splitTip': 'Faites glisser pour redimensionner les volets — double-clic pour les égaliser', 'tab.recent': 'QSO récents', 'tab.cluster': 'Cluster', 'tab.worked': 'Déjà contacté',
|
||||||
'tab.awards': 'Diplômes', 'tab.bandmap': 'Carte de bande', 'tab.contest': 'Contest', 'tab.net': 'NET',
|
'tab.awards': 'Diplômes', 'tab.bandmap': 'Carte de bande', 'tab.contest': 'Contest', 'tab.net': 'NET',
|
||||||
'field.callsign': 'Indicatif', 'field.name': 'Nom', 'field.qth': 'QTH', 'field.grid': 'Locator',
|
'field.callsign': 'Indicatif', 'field.name': 'Nom', 'field.qth': 'QTH', 'field.grid': 'Locator',
|
||||||
'field.band': 'Bande', 'field.mode': 'Mode', 'field.country': 'Pays', 'field.comment': 'Commentaire',
|
'field.band': 'Bande', 'field.mode': 'Mode', 'field.country': 'Pays', 'field.comment': 'Commentaire',
|
||||||
@@ -546,7 +546,7 @@ const fr: Dict = {
|
|||||||
'imp.ctyDesc': "Recalcule Pays, DXCC & zones CQ/ITU depuis cty.dat, en écrasant le fichier — corrige ce que les logiciels de contest exportent mal (ex. RG2Y en Russie asiatique au lieu d'européenne). Les exceptions DXpédition de ClubLog s'appliquent par-dessus selon la date du QSO (ex. TO974REF → Réunion, TO2A 2012 → Guyane française) dès que les données ClubLog sont téléchargées. Tout le reste de l'ADIF est conservé tel quel. Astuce : utilise Mettre à jour les doublons pour re-corriger des QSO déjà dans le log.",
|
'imp.ctyDesc': "Recalcule Pays, DXCC & zones CQ/ITU depuis cty.dat, en écrasant le fichier — corrige ce que les logiciels de contest exportent mal (ex. RG2Y en Russie asiatique au lieu d'européenne). Les exceptions DXpédition de ClubLog s'appliquent par-dessus selon la date du QSO (ex. TO974REF → Réunion, TO2A 2012 → Guyane française) dès que les données ClubLog sont téléchargées. Tout le reste de l'ADIF est conservé tel quel. Astuce : utilise Mettre à jour les doublons pour re-corriger des QSO déjà dans le log.",
|
||||||
'imp.stationTitle': 'Remplir mes champs station depuis mon profil',
|
'imp.stationTitle': 'Remplir mes champs station depuis mon profil',
|
||||||
'imp.stationDesc': "Complète les champs MY_* vides (locator, rig, antenne, adresse, ville, état, comté, réf. SOTA/POTA, puissance TX…) plus Opérateur et Indicatif propriétaire depuis le profil actif. Les valeurs existantes sont conservées. Seul STATION_CALLSIGN n'est jamais touché pour ne pas re-router un log multi-indicatifs. Elle applique AUSSI tes statuts de confirmation par défaut (QSL papier, LoTW, eQSL, Club Log, HRDLog, QRZ.com envoyé et reçu) sur ceux que le fichier laisse vides — un log WSJT-X n'en contient pratiquement aucun. À activer quand tu importes ton propre log.",
|
'imp.stationDesc': "Complète les champs MY_* vides (locator, rig, antenne, adresse, ville, état, comté, réf. SOTA/POTA, puissance TX…) plus Opérateur et Indicatif propriétaire depuis le profil actif. Les valeurs existantes sont conservées. Seul STATION_CALLSIGN n'est jamais touché pour ne pas re-router un log multi-indicatifs. Elle applique AUSSI tes statuts de confirmation par défaut (QSL papier, LoTW, eQSL, Club Log, HRDLog, QRZ.com envoyé et reçu) sur ceux que le fichier laisse vides — un log WSJT-X n'en contient pratiquement aucun. À activer quand tu importes ton propre log.",
|
||||||
'imp.cancel': 'Annuler', 'rec.manualStart': 'Enregistrer ce contact. L\u2019enregistrement automatique est d\u00e9sactiv\u00e9 : la capture commence maintenant, sans les secondes qui pr\u00e9c\u00e8dent.', 'rec.stop': 'Arr\u00eater l\u2019enregistrement. L\u2019audio est conserv\u00e9 et sera enregistr\u00e9 avec le QSO \u2014 arr\u00eatez-le pour le repasser \u00e0 la station travaill\u00e9e.', 'rec.resume': 'Reprendre l\u2019enregistrement, \u00e0 la suite de ce qui est d\u00e9j\u00e0 captur\u00e9.', 'rec.playOnAir': '\u00c9METTRE l\u2019enregistrement vers la station travaill\u00e9e : passe la radio en \u00e9mission et l\u2019envoie comme un message du manipulateur vocal.', 'rec.manualFailed': 'L\u2019enregistrement n\u2019a pas pu d\u00e9marrer \u2014 v\u00e9rifiez les p\u00e9riph\u00e9riques audio dans les R\u00e9glages.', 'imp.mapAdd': 'Le fichier met un champ au mauvais endroit\u2026', 'imp.mapTitle': 'D\u00e9placer des champs \u00e0 l\u2019import', 'imp.mapDesc': 'Les logiciels de concours rangent l\u2019\u00e9change l\u00e0 o\u00f9 leur module le garde. Le contest IOTA de la RSGB exporte la r\u00e9f\u00e9rence d\u2019\u00eele dans STATE \u2014 import\u00e9e telle quelle elle devient un \u00e9tat am\u00e9ricain et le dipl\u00f4me IOTA reste vide. La destination n\u2019est remplie que l\u00e0 o\u00f9 le fichier l\u2019a laiss\u00e9e vide.', 'imp.mapMore': 'Ajouter une ligne', 'imp.import': 'Importer',
|
'imp.cancel': 'Annuler', 'rec.manualStart': 'Enregistrer ce contact. L\u2019enregistrement automatique est d\u00e9sactiv\u00e9 : la capture commence maintenant, sans les secondes qui pr\u00e9c\u00e8dent.', 'rec.stop': 'Arr\u00eater l\u2019enregistrement. L\u2019audio est conserv\u00e9 et sera enregistr\u00e9 avec le QSO \u2014 arr\u00eatez-le pour le repasser \u00e0 la station travaill\u00e9e.', 'rec.resume': 'Reprendre l\u2019enregistrement, \u00e0 la suite de ce qui est d\u00e9j\u00e0 captur\u00e9.', 'rec.playOnAir': '\u00c9METTRE l\u2019enregistrement vers la station travaill\u00e9e : passe la radio en \u00e9mission et l\u2019envoie comme un message du manipulateur vocal.', 'rec.stopPlaying': 'Arrêter l’émission de l’enregistrement', 'rec.manualFailed': 'L\u2019enregistrement n\u2019a pas pu d\u00e9marrer \u2014 v\u00e9rifiez les p\u00e9riph\u00e9riques audio dans les R\u00e9glages.', 'imp.mapAdd': 'Le fichier met un champ au mauvais endroit\u2026', 'imp.mapTitle': 'D\u00e9placer des champs \u00e0 l\u2019import', 'imp.mapDesc': 'Les logiciels de concours rangent l\u2019\u00e9change l\u00e0 o\u00f9 leur module le garde. Le contest IOTA de la RSGB exporte la r\u00e9f\u00e9rence d\u2019\u00eele dans STATE \u2014 import\u00e9e telle quelle elle devient un \u00e9tat am\u00e9ricain et le dipl\u00f4me IOTA reste vide. La destination n\u2019est remplie que l\u00e0 o\u00f9 le fichier l\u2019a laiss\u00e9e vide.', 'imp.mapMore': 'Ajouter une ligne', 'imp.import': 'Importer',
|
||||||
'imp.progressTitle': 'Import ADIF en cours…', 'bulk.progressTitle': 'Mise \u00e0 jour des QSO s\u00e9lectionn\u00e9s\u2026',
|
'imp.progressTitle': 'Import ADIF en cours…', 'bulk.progressTitle': 'Mise \u00e0 jour des QSO s\u00e9lectionn\u00e9s\u2026',
|
||||||
'imp.progressCount': '{done} / {tot} enregistrements · {pct}%', 'imp.progressCountOnly': '{done} enregistrements…',
|
'imp.progressCount': '{done} / {tot} enregistrements · {pct}%', 'imp.progressCountOnly': '{done} enregistrements…',
|
||||||
'imp.complete': 'Import terminé.',
|
'imp.complete': 'Import terminé.',
|
||||||
@@ -678,7 +678,7 @@ const fr: Dict = {
|
|||||||
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à tout contrôleur réglé sur Yaesu GS-232A — sans PstRotator. microHAM ARCO : régler Config → LAN → CONTROL PROTOCOL (ou USB CONTROL PROTOCOL) sur « Yaesu GS-232A » ; en USB la vitesse est sans importance. ERC (Easy Rotor Control, ERC Mini compris) : son émulation DOIT être réglée sur GS-232 — un ERC laissé en Hy-Gain DCU-1 parle un autre jeu de commandes et ne répondra pas — puis choisir son port COM et la même vitesse que dans la configuration de l'ERC.", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à tout contrôleur réglé sur Yaesu GS-232A — sans PstRotator. microHAM ARCO : régler Config → LAN → CONTROL PROTOCOL (ou USB CONTROL PROTOCOL) sur « Yaesu GS-232A » ; en USB la vitesse est sans importance. ERC (Easy Rotor Control, ERC Mini compris) : son émulation DOIT être réglée sur GS-232 — un ERC laissé en Hy-Gain DCU-1 parle un autre jeu de commandes et ne répondra pas — puis choisir son port COM et la même vitesse que dans la configuration de l'ERC.", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
||||||
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
||||||
'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
||||||
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Un backend CAT natif occupe le port série de la radio, aucun autre logiciel ne peut donc y accéder. Ceci permet à WSJT-X, JTDX, MSHV ou Log4OM de dialoguer avec la radio À TRAVERS OpsLog : dans l'autre logiciel, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532. Fonctionne avec tous les backends, pas seulement les natifs.", 'cat.sharePort': 'Port de partage', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, réseau)', 'cat.civTrace': 'Journaliser le protocole CAT', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CAT \u00e9chang\u00e9e avec la radio \u2014 CI-V en hexad\u00e9cimal, Kenwood en texte. Pour signaler une radio qui r\u00e9pond de travers \u2014 un bouton qui fait autre chose, une fr\u00e9quence qui saute. Valable pour la session seulement : c\u2019est un diagnostic, et le journal grossit vite.', 'cat.kenwoodPort': 'Port COM Kenwood', 'cat.kenwoodPortHint': 'Le port s\u00e9rie CAT/USB de la radio (TS-590, TS-890, TS-990, TS-2000, ainsi que les Elecraft K3/K4 qui parlent le m\u00eame dialecte).', 'cat.kenwoodBaudHint': 'Doit correspondre au MENU de la radio : un TS-590 sort d\u2019usine \u00e0 9600, un TS-890 \u00e0 115200.', 'cat.kenwoodHost': 'Ou par le r\u00e9seau (h\u00f4te:port)', 'cat.kenwoodHostHint': 'Un pont s\u00e9rie-r\u00e9seau \u2014 ser2net, un bo\u00eetier Ethernet-s\u00e9rie, un Raspberry Pi pr\u00e8s de la radio. S\u2019il est rempli, il est utilis\u00e9 \u00c0 LA PLACE du port COM ci-dessus. Il ne s\u2019agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood et n\u2019est pas prise en charge.', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V réseau)', 'cat.optTci': 'TCI',
|
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Un backend CAT natif occupe le port série de la radio, aucun autre logiciel ne peut donc y accéder. Ceci permet à WSJT-X, JTDX, MSHV ou Log4OM de dialoguer avec la radio À TRAVERS OpsLog : dans l'autre logiciel, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532. Fonctionne avec tous les backends, pas seulement les natifs.", 'cat.sharePort': 'Port de partage', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.xieguPTTLine': 'Passage en \u00e9mission', 'cat.xieguPTTCiv': 'Commande CI-V', 'cat.xieguPTTHint': 'Un G90 ne passe pas en \u00e9mission sur la commande CI-V : les interfaces comme le DE-19 le pilotent par RTS ou DTR. Choisissez la ligne de la v\u00f4tre \u2014 c\u2019est aussi ce qui permet \u00e0 WSJT-X d\u2019\u00e9mettre via le partage CAT.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, réseau)', 'cat.civTrace': 'Journaliser le protocole CAT', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CAT \u00e9chang\u00e9e avec la radio \u2014 CI-V en hexad\u00e9cimal, Kenwood en texte. Pour signaler une radio qui r\u00e9pond de travers \u2014 un bouton qui fait autre chose, une fr\u00e9quence qui saute. Valable pour la session seulement : c\u2019est un diagnostic, et le journal grossit vite.', 'cat.kenwoodPort': 'Port COM Kenwood', 'cat.kenwoodPortHint': 'Le port s\u00e9rie CAT/USB de la radio (TS-590, TS-890, TS-990, TS-2000, ainsi que les Elecraft K3/K4 qui parlent le m\u00eame dialecte).', 'cat.kenwoodBaudHint': 'Doit correspondre au MENU de la radio : un TS-590 sort d\u2019usine \u00e0 9600, un TS-890 \u00e0 115200.', 'cat.kenwoodHost': 'Ou par le r\u00e9seau (h\u00f4te:port)', 'cat.kenwoodHostHint': 'Un pont s\u00e9rie-r\u00e9seau \u2014 ser2net, un bo\u00eetier Ethernet-s\u00e9rie, un Raspberry Pi pr\u00e8s de la radio. S\u2019il est rempli, il est utilis\u00e9 \u00c0 LA PLACE du port COM ci-dessus. Il ne s\u2019agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood et n\u2019est pas prise en charge.', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V réseau)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 puissance par bande et par mode', 'flxpw.hint': 'Puissance d\u2019\u00e9mission appliqu\u00e9e au changement de bande ou de mode. Laissez une case vide pour ne pas toucher \u00e0 la puissance.', 'flxpw.band': 'Bande', 'flxb.title': 'FlexRadio \u2014 antennes et puissance par bande', 'flxb.hint': 'Les antennes sont appliqu\u00e9es au changement de bande ; la puissance au changement de bande ou de mode. Laissez une case de puissance vide pour ne pas y toucher.', 'flxb.rxAnt': 'Antenne RX', 'flxb.txAnt': 'Antenne TX', 'flxb.noRadio': 'Aucune antenne remont\u00e9e \u2014 connectez le FlexRadio, puis rouvrez ce panneau.', 'flxpw.phone': 'Phonie', 'flxpw.digi': 'Num\u00e9rique', 'flxpw.noBands': 'Aucune bande configur\u00e9e \u2014 ajoutez-les dans Listes \u2192 Bandes.', 'flxpw.saved': 'Enregistr\u00e9',
|
||||||
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
|
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
|
||||||
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
|
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
|
||||||
'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
|
'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
|
||||||
@@ -760,13 +760,13 @@ const fr: Dict = {
|
|||||||
'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',
|
'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',
|
'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': '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': 'Paper QSL sent status', 'fltb.fQslSentDate': 'Paper QSL sent date', 'fltb.fQslRcvd': 'Paper QSL received status', 'fltb.fQslRcvdDate': 'Paper QSL received date', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent status', 'fltb.fLotwSentDate': 'LoTW sent date', 'fltb.fLotwRcvd': 'LoTW received status', 'fltb.fLotwRcvdDate': 'LoTW received date', 'fltb.fEqslSent': 'eQSL sent status', 'fltb.fEqslSentDate': 'eQSL sent date', 'fltb.fEqslRcvd': 'eQSL received status', 'fltb.fEqslRcvdDate': 'eQSL received date', 'fltb.fQrzSent': 'QRZ.com sent status', 'fltb.fQrzSentDate': 'QRZ.com sent date', 'fltb.fQrzRcvd': 'QRZ.com received status', 'fltb.fQrzRcvdDate': 'QRZ.com received date', 'fltb.fClublogSent': 'Club Log sent status', 'fltb.fClublogSentDate': 'Club Log sent date', 'fltb.fHrdlogSent': 'HRDLog sent status', 'fltb.fHrdlogSentDate': 'HRDLog sent date', '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': 'é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',
|
'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': 'Paper QSL sent status', 'fltb.fQslSentDate': 'Paper QSL sent date', 'fltb.fQslRcvd': 'Paper QSL received status', 'fltb.fQslRcvdDate': 'Paper QSL received date', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent status', 'fltb.fLotwSentDate': 'LoTW sent date', 'fltb.fLotwRcvd': 'LoTW received status', 'fltb.fLotwRcvdDate': 'LoTW received date', 'fltb.fEqslSent': 'eQSL sent status', 'fltb.fEqslSentDate': 'eQSL sent date', 'fltb.fEqslRcvd': 'eQSL received status', 'fltb.fEqslRcvdDate': 'eQSL received date', 'fltb.fQrzSent': 'QRZ.com sent status', 'fltb.fQrzSentDate': 'QRZ.com sent date', 'fltb.fQrzRcvd': 'QRZ.com received status', 'fltb.fQrzRcvdDate': 'QRZ.com received date', 'fltb.fClublogSent': 'Club Log sent status', 'fltb.fClublogSentDate': 'Club Log sent date', 'fltb.fHrdlogSent': 'HRDLog sent status', 'fltb.fHrdlogSentDate': 'HRDLog sent date', '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': 'é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',
|
'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.newCounty': 'NOUV', 'detp.newCountyTip': 'Comté jamais contacté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'CQ', 'detp.ituZone': '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.ambiguous': 'Ambigu — choisissez :', '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',
|
||||||
'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.',
|
'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.',
|
||||||
'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.',
|
'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.',
|
||||||
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
|
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.modePhone': 'Phonie', 'awp.modeDigital': 'Numérique', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
|
||||||
'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches de repli', 'awed.orAlsoMatch': "— essayées dans l'ordre, seulement si rien n'a encore été trouvé ; la première qui marche gagne", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence',
|
'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.oneRef': 'Une seule référence par QSO', 'awed.oneRefHint': 'Quand plusieurs références correspondent au même contact, n'+'’en affecter aucune et le lister dans les références manquantes. Pour les diplômes où deux entrées partagent une description — deux DOK nommés Gießen — en choisir une au hasard inscrirait la mauvaise dans le journal.', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches de repli', 'awed.orAlsoMatch': "— essayées dans l'ordre, seulement si rien n'a encore été trouvé ; la première qui marche gagne", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence',
|
||||||
'awed.updateAvailable': 'Une nouvelle version de ce diplôme est disponible', 'awed.updateOverwrites': "Tu as modifié ce diplôme, la mise à jour n'a donc pas été appliquée. L'accepter remplacera ta définition et ta liste de références.", 'awed.updateApply': 'Mettre à jour', 'awed.updateKeepMine': 'Garder les miennes',
|
'awed.updateAvailable': 'Une nouvelle version de ce diplôme est disponible', 'awed.updateOverwrites': "Tu as modifié ce diplôme, la mise à jour n'a donc pas été appliquée. L'accepter remplacera ta définition et ta liste de références.", 'awed.updateApply': 'Mettre à jour', 'awed.updateKeepMine': 'Garder les miennes',
|
||||||
'awed.tabTest': 'Test', 'awed.testCallsign': 'Tester avec un indicatif', 'awed.testRun': 'Tester', 'awed.testSavedOnly': 'Teste le diplôme ENREGISTRÉ — enregistre tes modifications avant.', 'awed.testNoMatch': 'aucune correspondance', 'awed.testOutOfScope': "QSO hors périmètre — aucune règle n'a été exécutée.", 'awed.testSkipped': "non exécutée : une règle précédente a déjà trouvé", 'awed.testFieldValue': 'Champ', 'awed.testEmptyField': 'vide', 'awed.testNoCandidate': "n'a produit aucun candidat", 'awed.testManual': 'Référence forcée à la main', 'awed.testSameAs': '+{n} autre(s) QSO, même résultat',
|
'awed.tabTest': 'Test', 'awed.testCallsign': 'Tester avec un indicatif', 'awed.testRun': 'Tester', 'awed.testSavedOnly': 'Teste le diplôme ENREGISTRÉ — enregistre tes modifications avant.', 'awed.testNoMatch': 'aucune correspondance', 'awed.testOutOfScope': "QSO hors périmètre — aucune règle n'a été exécutée.", 'awed.testSkipped': "non exécutée : une règle précédente a déjà trouvé", 'awed.testFieldValue': 'Champ', 'awed.testEmptyField': 'vide', 'awed.testNoCandidate': "n'a produit aucun candidat", 'awed.testManual': 'Référence forcée à la main', 'awed.testAmbiguous': 'Ambigu', 'awed.testAmbiguousHint': '— aucune retenue, ce diplôme n’admet qu’une référence par QSO. Affectez la bonne à la main.', 'awed.testSameAs': '+{n} autre(s) QSO, même résultat',
|
||||||
'awed.exportOne': 'Partager {code}',
|
'awed.exportOne': 'Partager {code}',
|
||||||
'awed.onlyHere': 'local',
|
'awed.onlyHere': 'local',
|
||||||
'awed.onlyHereTip': 'À toi — non livré avec OpsLog. Son JSON est tenu à jour dans le dossier awards ; envoie ce fichier pour le partager.',
|
'awed.onlyHereTip': 'À toi — non livré avec OpsLog. Son JSON est tenu à jour dans le dossier awards ; envoie ce fichier pour le partager.',
|
||||||
@@ -815,7 +815,7 @@ const fr: Dict = {
|
|||||||
'aud.txOn': 'ÉMISSION — micro → Vers la radio, PTT activé. Cliquez pour arrêter.', 'aud.txHint': "Micro direct → poste avec PTT (USB pour l'instant ; TX réseau plus tard).",
|
'aud.txOn': 'ÉMISSION — micro → Vers la radio, PTT activé. Cliquez pour arrêter.', 'aud.txHint': "Micro direct → poste avec PTT (USB pour l'instant ; TX réseau plus tard).",
|
||||||
'aud.recorder': 'Enregistreur de QSO', 'aud.recordEvery': 'Enregistrer chaque QSO dans un fichier audio (Depuis la radio + votre micro)', 'aud.recFolder': 'Dossier des enregistrements', 'aud.browse': 'Parcourir…',
|
'aud.recorder': 'Enregistreur de QSO', 'aud.recordEvery': 'Enregistrer chaque QSO dans un fichier audio (Depuis la radio + votre micro)', 'aud.recFolder': 'Dossier des enregistrements', 'aud.browse': 'Parcourir…',
|
||||||
'aud.preroll': 'Pré-enregistrement (secondes)', 'aud.format': 'Format de fichier', 'aud.wav': 'WAV (sans perte, plus volumineux)', 'aud.mp3': 'MP3 (compressé, léger)',
|
'aud.preroll': 'Pré-enregistrement (secondes)', 'aud.format': 'Format de fichier', 'aud.wav': 'WAV (sans perte, plus volumineux)', 'aud.mp3': 'MP3 (compressé, léger)',
|
||||||
'aud.fromLevel': 'Niveau depuis la radio', 'aud.txLevel': 'Niveau du voice keyer', 'aud.txLevelHint': "Niveau des messages enregistrés envoyés à la radio. Augmentez-le si votre voice keyer est bien plus faible que votre micro ; Lire fait entendre ce même niveau. Si la radio n'émet presque rien, c'est que sa source de modulation est restée le micro de façade : sur un FTDX10, réglez MENU → SSB MOD SOURCE sur REAR (l'entrée USB).", 'aud.micLevel': 'Niveau micro', 'aud.levelHint': 'Si votre voix est plus forte que la station, baissez le niveau micro.',
|
'aud.fromLevel': 'Niveau depuis la radio', 'aud.txLevel': 'Niveau du voice keyer', 'aud.txLevelHint': "Niveau des messages enregistrés envoyés à la radio. Augmentez-le si votre voice keyer est bien plus faible que votre micro ; Lire fait entendre ce même niveau. Si la radio n'émet presque rien, c'est que sa source de modulation est restée le micro de façade : sur un FTDX10, réglez MENU → SSB MOD SOURCE sur REAR (l'entrée USB).", 'aud.micLevel': 'Niveau micro', 'aud.qsoPlayLevel': 'Niveau de relecture QSO', 'aud.levelHint': 'Si votre voix est plus forte que la station, baissez le niveau micro.',
|
||||||
'aud.autoSend': "Envoyer automatiquement l'enregistrement à la station par e-mail lorsque j'enregistre un QSO",
|
'aud.autoSend': "Envoyer automatiquement l'enregistrement à la station par e-mail lorsque j'enregistre un QSO",
|
||||||
'aud.dvkTitle': 'Messages du manipulateur vocal (F1–F6)', 'aud.pttMethod': 'Méthode PTT', 'aud.pttNone': 'Aucune (VOX)', 'aud.pttCat': 'CAT (la liaison radio)', 'aud.pttRts': 'RTS série', 'aud.pttDtr': 'DTR série',
|
'aud.dvkTitle': 'Messages du manipulateur vocal (F1–F6)', 'aud.pttMethod': 'Méthode PTT', 'aud.pttNone': 'Aucune (VOX)', 'aud.pttCat': 'CAT (la liaison radio)', 'aud.pttRts': 'RTS série', 'aud.pttDtr': 'DTR série',
|
||||||
'aud.testPtt': 'Tester le PTT', 'aud.pttPort': 'Port COM du PTT', 'aud.pickPort': 'Choisir un port COM', 'aud.selectPort': '— choisir —', 'aud.refresh': 'Actualiser',
|
'aud.testPtt': 'Tester le PTT', 'aud.pttPort': 'Port COM du PTT', 'aud.pickPort': 'Choisir un port COM', 'aud.selectPort': '— choisir —', 'aud.refresh': 'Actualiser',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// folder (data/) to another machine. These helpers mirror them into the DB
|
// folder (data/) to another machine. These helpers mirror them into the DB
|
||||||
// settings table (ui.* keys, like the grid columns) so the whole setup is
|
// settings table (ui.* keys, like the grid columns) so the whole setup is
|
||||||
// identical after a copy.
|
// identical after a copy.
|
||||||
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
import { GetUIPref, SetUIPref, LogUIError } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
// Keys that must travel with data/ (DB is the portable source of truth; the
|
// Keys that must travel with data/ (DB is the portable source of truth; the
|
||||||
// localStorage copy is just a fast, synchronous cache).
|
// localStorage copy is just a fast, synchronous cache).
|
||||||
@@ -36,6 +36,7 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||||
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||||
'opslog.activeTab', // last selected tab
|
'opslog.activeTab', // last selected tab
|
||||||
|
'opslog.mainSplit', // Main tab: width share of the left pane (percent)
|
||||||
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
||||||
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
||||||
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
||||||
@@ -65,5 +66,11 @@ export async function syncPortablePrefs(): Promise<void> {
|
|||||||
// Use it everywhere these keys are written instead of localStorage.setItem.
|
// Use it everywhere these keys are written instead of localStorage.setItem.
|
||||||
export function writeUiPref(key: string, value: string): void {
|
export function writeUiPref(key: string, value: string): void {
|
||||||
try { localStorage.setItem(key, value); } catch { /* quota / private mode */ }
|
try { localStorage.setItem(key, value); } catch { /* quota / private mode */ }
|
||||||
SetUIPref(key, value).catch(() => { /* DB unavailable — the cache still holds it */ });
|
SetUIPref(key, value).catch((e: any) => {
|
||||||
|
// The cache still holds it, so the interface behaves — but the BACKEND
|
||||||
|
// reads some of these keys too (digital-mode grouping decides how the
|
||||||
|
// cluster judges a slot). A write that reaches localStorage and not the
|
||||||
|
// database makes the two disagree, and used to do so in silence.
|
||||||
|
try { LogUIError("ui pref", "could not store " + key + ": " + String(e?.message ?? e), ""); } catch {}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -679,10 +679,10 @@
|
|||||||
disappears on the dark themes. Invert it there — the control itself is worth
|
disappears on the dark themes. Invert it there — the control itself is worth
|
||||||
keeping (OS calendar, locale date order, keyboard entry), only its icon needs
|
keeping (OS calendar, locale date order, keyboard entry), only its icon needs
|
||||||
help. */
|
help. */
|
||||||
[data-theme='dim-slate'] input[type='date']::-webkit-calendar-picker-indicator,
|
Every temporal input carries the same glyph, so the rule names them all:
|
||||||
[data-theme='dark-warm'] input[type='date']::-webkit-calendar-picker-indicator,
|
datetime-local was left out at first and its icon stayed invisible. */
|
||||||
[data-theme='dark-graphite'] input[type='date']::-webkit-calendar-picker-indicator,
|
:is([data-theme='dim-slate'], [data-theme='dark-warm'], [data-theme='dark-graphite'], [data-theme='high-contrast'])
|
||||||
[data-theme='high-contrast'] input[type='date']::-webkit-calendar-picker-indicator {
|
:is(input[type='date'], input[type='datetime-local'], input[type='time'], input[type='month'], input[type='week'])::-webkit-calendar-picker-indicator {
|
||||||
filter: invert(1) brightness(1.6);
|
filter: invert(1) brightness(1.8);
|
||||||
opacity: 0.75;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// 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).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.22.3';
|
export const APP_VERSION = '0.22.8';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+10
@@ -61,6 +61,8 @@ export function ApplyAwardUpdate(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
||||||
|
|
||||||
|
export function AudioApplyLevels(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function AudioMonitorActive():Promise<boolean>;
|
export function AudioMonitorActive():Promise<boolean>;
|
||||||
|
|
||||||
export function AudioStartMonitor():Promise<void>;
|
export function AudioStartMonitor():Promise<void>;
|
||||||
@@ -217,6 +219,8 @@ export function FlexAmpOperate(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
|
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexApplyBandPower(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexBackspaceCW(arg1:number):Promise<void>;
|
export function FlexBackspaceCW(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexMox(arg1:boolean):Promise<void>;
|
export function FlexMox(arg1:boolean):Promise<void>;
|
||||||
@@ -419,6 +423,8 @@ export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
|||||||
|
|
||||||
export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
|
export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
|
||||||
|
|
||||||
|
export function GetFlexBandPower():Promise<Record<string, main.FlexBandPower>>;
|
||||||
|
|
||||||
export function GetFlexState():Promise<cat.FlexTXState>;
|
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||||
|
|
||||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||||
@@ -599,6 +605,8 @@ export function ImportAwards():Promise<main.AwardImportResult>;
|
|||||||
|
|
||||||
export function InspectAwardImport():Promise<main.AwardImportPreview>;
|
export function InspectAwardImport():Promise<main.AwardImportPreview>;
|
||||||
|
|
||||||
|
export function IsNewUSCounty(arg1:string,arg2:string):Promise<boolean>;
|
||||||
|
|
||||||
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
||||||
|
|
||||||
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
||||||
@@ -849,6 +857,8 @@ export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>
|
|||||||
|
|
||||||
export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Promise<void>;
|
export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveFlexBandPower(arg1:Record<string, main.FlexBandPower>):Promise<void>;
|
||||||
|
|
||||||
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
|
|||||||
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
|
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AudioApplyLevels(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['AudioApplyLevels'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function AudioMonitorActive() {
|
export function AudioMonitorActive() {
|
||||||
return window['go']['main']['App']['AudioMonitorActive']();
|
return window['go']['main']['App']['AudioMonitorActive']();
|
||||||
}
|
}
|
||||||
@@ -382,6 +386,10 @@ export function FlexApplyBandAntenna(arg1) {
|
|||||||
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
|
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexApplyBandPower(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['FlexApplyBandPower'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexBackspaceCW(arg1) {
|
export function FlexBackspaceCW(arg1) {
|
||||||
return window['go']['main']['App']['FlexBackspaceCW'](arg1);
|
return window['go']['main']['App']['FlexBackspaceCW'](arg1);
|
||||||
}
|
}
|
||||||
@@ -786,6 +794,10 @@ export function GetFlexBandAntennas() {
|
|||||||
return window['go']['main']['App']['GetFlexBandAntennas']();
|
return window['go']['main']['App']['GetFlexBandAntennas']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetFlexBandPower() {
|
||||||
|
return window['go']['main']['App']['GetFlexBandPower']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetFlexState() {
|
export function GetFlexState() {
|
||||||
return window['go']['main']['App']['GetFlexState']();
|
return window['go']['main']['App']['GetFlexState']();
|
||||||
}
|
}
|
||||||
@@ -1146,6 +1158,10 @@ export function InspectAwardImport() {
|
|||||||
return window['go']['main']['App']['InspectAwardImport']();
|
return window['go']['main']['App']['InspectAwardImport']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IsNewUSCounty(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['IsNewUSCounty'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function LaunchAutostartProgram(arg1) {
|
export function LaunchAutostartProgram(arg1) {
|
||||||
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1646,6 +1662,10 @@ export function SaveFlexBandAntennas(arg1) {
|
|||||||
return window['go']['main']['App']['SaveFlexBandAntennas'](arg1);
|
return window['go']['main']['App']['SaveFlexBandAntennas'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveFlexBandPower(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveFlexBandPower'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveListsSettings(arg1) {
|
export function SaveListsSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveListsSettings'](arg1);
|
return window['go']['main']['App']['SaveListsSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ export namespace award {
|
|||||||
leading_str?: string;
|
leading_str?: string;
|
||||||
trailing_str?: string;
|
trailing_str?: string;
|
||||||
dynamic?: boolean;
|
dynamic?: boolean;
|
||||||
|
one_ref_per_qso?: boolean;
|
||||||
or_rules?: OrRule[];
|
or_rules?: OrRule[];
|
||||||
dxcc_filter: number[];
|
dxcc_filter: number[];
|
||||||
valid_bands?: string[];
|
valid_bands?: string[];
|
||||||
@@ -356,6 +357,7 @@ export namespace award {
|
|||||||
this.leading_str = source["leading_str"];
|
this.leading_str = source["leading_str"];
|
||||||
this.trailing_str = source["trailing_str"];
|
this.trailing_str = source["trailing_str"];
|
||||||
this.dynamic = source["dynamic"];
|
this.dynamic = source["dynamic"];
|
||||||
|
this.one_ref_per_qso = source["one_ref_per_qso"];
|
||||||
this.or_rules = this.convertValues(source["or_rules"], OrRule);
|
this.or_rules = this.convertValues(source["or_rules"], OrRule);
|
||||||
this.dxcc_filter = source["dxcc_filter"];
|
this.dxcc_filter = source["dxcc_filter"];
|
||||||
this.valid_bands = source["valid_bands"];
|
this.valid_bands = source["valid_bands"];
|
||||||
@@ -461,6 +463,8 @@ export namespace award {
|
|||||||
ref_count: number;
|
ref_count: number;
|
||||||
steps: Step[];
|
steps: Step[];
|
||||||
manual?: string[];
|
manual?: string[];
|
||||||
|
superseded?: string[];
|
||||||
|
ambiguous?: string[];
|
||||||
result: string[];
|
result: string[];
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
@@ -476,6 +480,8 @@ export namespace award {
|
|||||||
this.ref_count = source["ref_count"];
|
this.ref_count = source["ref_count"];
|
||||||
this.steps = this.convertValues(source["steps"], Step);
|
this.steps = this.convertValues(source["steps"], Step);
|
||||||
this.manual = source["manual"];
|
this.manual = source["manual"];
|
||||||
|
this.superseded = source["superseded"];
|
||||||
|
this.ambiguous = source["ambiguous"];
|
||||||
this.result = source["result"];
|
this.result = source["result"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,6 +512,8 @@ export namespace award {
|
|||||||
worked: boolean;
|
worked: boolean;
|
||||||
confirmed: boolean;
|
confirmed: boolean;
|
||||||
validated: boolean;
|
validated: boolean;
|
||||||
|
modes: string[];
|
||||||
|
confirmed_modes: string[];
|
||||||
bands: string[];
|
bands: string[];
|
||||||
confirmed_bands: string[];
|
confirmed_bands: string[];
|
||||||
validated_bands: string[];
|
validated_bands: string[];
|
||||||
@@ -523,6 +531,8 @@ export namespace award {
|
|||||||
this.worked = source["worked"];
|
this.worked = source["worked"];
|
||||||
this.confirmed = source["confirmed"];
|
this.confirmed = source["confirmed"];
|
||||||
this.validated = source["validated"];
|
this.validated = source["validated"];
|
||||||
|
this.modes = source["modes"];
|
||||||
|
this.confirmed_modes = source["confirmed_modes"];
|
||||||
this.bands = source["bands"];
|
this.bands = source["bands"];
|
||||||
this.confirmed_bands = source["confirmed_bands"];
|
this.confirmed_bands = source["confirmed_bands"];
|
||||||
this.validated_bands = source["validated_bands"];
|
this.validated_bands = source["validated_bands"];
|
||||||
@@ -1604,6 +1614,7 @@ export namespace main {
|
|||||||
from_gain: number;
|
from_gain: number;
|
||||||
mic_gain: number;
|
mic_gain: number;
|
||||||
tx_gain: number;
|
tx_gain: number;
|
||||||
|
qso_play_gain: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new AudioSettings(source);
|
return new AudioSettings(source);
|
||||||
@@ -1624,6 +1635,7 @@ export namespace main {
|
|||||||
this.from_gain = source["from_gain"];
|
this.from_gain = source["from_gain"];
|
||||||
this.mic_gain = source["mic_gain"];
|
this.mic_gain = source["mic_gain"];
|
||||||
this.tx_gain = source["tx_gain"];
|
this.tx_gain = source["tx_gain"];
|
||||||
|
this.qso_play_gain = source["qso_play_gain"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class AutostartLaunchResult {
|
export class AutostartLaunchResult {
|
||||||
@@ -1906,6 +1918,7 @@ export namespace main {
|
|||||||
xiegu_port: string;
|
xiegu_port: string;
|
||||||
xiegu_baud: number;
|
xiegu_baud: number;
|
||||||
xiegu_addr: number;
|
xiegu_addr: number;
|
||||||
|
xiegu_ptt_line: string;
|
||||||
yaesu_port: string;
|
yaesu_port: string;
|
||||||
yaesu_baud: number;
|
yaesu_baud: number;
|
||||||
kenwood_host: string;
|
kenwood_host: string;
|
||||||
@@ -1945,6 +1958,7 @@ export namespace main {
|
|||||||
this.xiegu_port = source["xiegu_port"];
|
this.xiegu_port = source["xiegu_port"];
|
||||||
this.xiegu_baud = source["xiegu_baud"];
|
this.xiegu_baud = source["xiegu_baud"];
|
||||||
this.xiegu_addr = source["xiegu_addr"];
|
this.xiegu_addr = source["xiegu_addr"];
|
||||||
|
this.xiegu_ptt_line = source["xiegu_ptt_line"];
|
||||||
this.yaesu_port = source["yaesu_port"];
|
this.yaesu_port = source["yaesu_port"];
|
||||||
this.yaesu_baud = source["yaesu_baud"];
|
this.yaesu_baud = source["yaesu_baud"];
|
||||||
this.kenwood_host = source["kenwood_host"];
|
this.kenwood_host = source["kenwood_host"];
|
||||||
@@ -2694,6 +2708,7 @@ export namespace main {
|
|||||||
ref: string;
|
ref: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
pickable: boolean;
|
pickable: boolean;
|
||||||
|
ambiguous?: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new QSOAwardRef(source);
|
return new QSOAwardRef(source);
|
||||||
@@ -2705,6 +2720,7 @@ export namespace main {
|
|||||||
this.ref = source["ref"];
|
this.ref = source["ref"];
|
||||||
this.name = source["name"];
|
this.name = source["name"];
|
||||||
this.pickable = source["pickable"];
|
this.pickable = source["pickable"];
|
||||||
|
this.ambiguous = source["ambiguous"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class QSORate {
|
export class QSORate {
|
||||||
|
|||||||
@@ -101,3 +101,30 @@ func endpointName(dev *wca.IMMDevice, fallback string) string {
|
|||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeviceName resolves an endpoint id to its friendly name.
|
||||||
|
//
|
||||||
|
// Diagnostics quote the id that was CONFIGURED, which is a GUID — an operator
|
||||||
|
// told "no audio at all from {0.0.1.00000000}.{6a27abfd…}" learns nothing they
|
||||||
|
// can act on, while "no audio at all from DAX RX 1 (FlexRadio DAX)" points
|
||||||
|
// straight at the DAX panel.
|
||||||
|
//
|
||||||
|
// Falls back to the id when the endpoint cannot be found, which is itself worth
|
||||||
|
// seeing: a device that has disappeared explains an empty recording too.
|
||||||
|
func DeviceName(id string) string {
|
||||||
|
if id == "" {
|
||||||
|
return "(none)"
|
||||||
|
}
|
||||||
|
for _, list := range []func() ([]Device, error){ListInputDevices, ListOutputDevices} {
|
||||||
|
devs, err := list()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, d := range devs {
|
||||||
|
if d.ID == id {
|
||||||
|
return d.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,7 +80,17 @@ func pcmFormat() *wca.WAVEFORMATEX {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const autoConvert = wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | wca.AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY
|
// autoConvert lets WASAPI resample between the device format and ours.
|
||||||
|
//
|
||||||
|
// SRC_DEFAULT_QUALITY used to be set alongside it, and that name is misleading:
|
||||||
|
// it selects the CHEAP converter, meant for cases where quality does not matter.
|
||||||
|
// Going from a rig 48 kHz stream down to our 16 kHz, it folds everything above
|
||||||
|
// 8 kHz back into the audio band — and a receiver hiss is mostly high
|
||||||
|
// frequencies. The result was a recording where the noise sat ON TOP of the
|
||||||
|
// voice, while the same audio heard live had the voice well clear of it.
|
||||||
|
//
|
||||||
|
// Without the flag Windows uses its normal converter, which filters first.
|
||||||
|
const autoConvert = wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
|
||||||
|
|
||||||
// recordPCM captures from a device into 16 kHz mono 16-bit PCM bytes until the
|
// recordPCM captures from a device into 16 kHz mono 16-bit PCM bytes until the
|
||||||
// stop channel is closed.
|
// stop channel is closed.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package audio
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manager owns the DVK record/playback lifecycle: at most one recording and
|
// Manager owns the DVK record/playback lifecycle: at most one recording and
|
||||||
@@ -14,7 +15,18 @@ type Manager struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
recStop chan struct{}
|
recStop chan struct{}
|
||||||
recDone chan recResult
|
recDone chan recResult
|
||||||
|
// monGainPct scales what the RX monitor plays, 100 = as captured.
|
||||||
|
//
|
||||||
|
// The "From radio" slider used to reach only the QSO recorder, so an
|
||||||
|
// operator listening through OpsLog heard no difference between 10% and
|
||||||
|
// 150% — the setting looked broken because it was, for the thing they were
|
||||||
|
// listening to.
|
||||||
|
monGainPct int
|
||||||
playStop chan struct{}
|
playStop chan struct{}
|
||||||
|
// playDone closes when the playback goroutine has returned and the audio
|
||||||
|
// device is free again. Without it, the next Play raced the old one for the
|
||||||
|
// device and lost.
|
||||||
|
playDone chan struct{}
|
||||||
monStop chan struct{} // RX monitor passthrough (capture → render)
|
monStop chan struct{} // RX monitor passthrough (capture → render)
|
||||||
monRing *pcmRing // live audio hand-off, also fed by the network stream
|
monRing *pcmRing // live audio hand-off, also fed by the network stream
|
||||||
txStop chan struct{} // TX audio passthrough (mic → rig)
|
txStop chan struct{} // TX audio passthrough (mic → rig)
|
||||||
@@ -125,18 +137,38 @@ func (m *Manager) Play(deviceID, path string, gainPct int) error {
|
|||||||
pcm[i], pcm[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
|
pcm[i], pcm[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Waits for any previous playback to have RELEASED THE DEVICE.
|
||||||
|
//
|
||||||
|
// It used to only signal the old one to stop and start a new one at once.
|
||||||
|
// The old goroutine still held the WASAPI render client for a moment, so the
|
||||||
|
// new client could not start: it returned immediately, the PTT was keyed and
|
||||||
|
// released a tenth of a second later, and nothing came out. On the air that
|
||||||
|
// looked like "press play again and you must wait the whole length of the
|
||||||
|
// message before it will play at all".
|
||||||
m.StopPlayback()
|
m.StopPlayback()
|
||||||
stop := make(chan struct{})
|
stop := make(chan struct{})
|
||||||
|
done := make(chan struct{})
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.playStop = stop
|
m.playStop = stop
|
||||||
|
m.playDone = done
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
go func() {
|
go func() {
|
||||||
_ = playPCM(deviceID, pcm, rate, ch, bits, stop)
|
// The error was discarded. A device that refuses to start returns here
|
||||||
|
// instantly, the PTT is released 120 ms later, and NOTHING says why —
|
||||||
|
// which is exactly what a station heard as "it plays once, then never
|
||||||
|
// again": the call succeeded, the sound did not.
|
||||||
|
if err := playPCM(deviceID, pcm, rate, ch, bits, stop); err != nil {
|
||||||
|
LogSink("audio: playback on %q failed: %v", DeviceName(deviceID), err)
|
||||||
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
if m.playStop == stop {
|
if m.playStop == stop {
|
||||||
m.playStop = nil
|
m.playStop = nil
|
||||||
}
|
}
|
||||||
|
if m.playDone == done {
|
||||||
|
m.playDone = nil
|
||||||
|
}
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
close(done) // the device is free from here
|
||||||
m.notify()
|
m.notify()
|
||||||
}()
|
}()
|
||||||
m.notify()
|
m.notify()
|
||||||
@@ -147,12 +179,24 @@ func (m *Manager) Play(deviceID, path string, gainPct int) error {
|
|||||||
func (m *Manager) StopPlayback() {
|
func (m *Manager) StopPlayback() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
stop := m.playStop
|
stop := m.playStop
|
||||||
|
done := m.playDone
|
||||||
m.playStop = nil
|
m.playStop = nil
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
if stop != nil {
|
if stop == nil {
|
||||||
close(stop)
|
return
|
||||||
m.notify()
|
|
||||||
}
|
}
|
||||||
|
close(stop)
|
||||||
|
// Wait for the goroutine to release the device — that is the whole point of
|
||||||
|
// stopping before starting again. Bounded: a wedged WASAPI call must not
|
||||||
|
// freeze the caller, which here is the operator clicking a button.
|
||||||
|
if done != nil {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(1500 * time.Millisecond):
|
||||||
|
LogSink("audio: previous playback did not release the device within 1.5 s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.notify()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- RX audio monitor (Phase 2: USB codec passthrough) --------------------
|
// ---- RX audio monitor (Phase 2: USB codec passthrough) --------------------
|
||||||
@@ -188,9 +232,12 @@ func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
|
|||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
if capture {
|
if capture {
|
||||||
// Producer: capture the rig's USB audio into the ring.
|
// Producer: capture the rig's USB audio into the ring, at the level the
|
||||||
|
// operator set. Applied HERE rather than on the render side so the
|
||||||
|
// network-fed path (PushMonitorAudio) keeps its own untouched levels —
|
||||||
|
// that stream is already scaled by the radio.
|
||||||
go func() {
|
go func() {
|
||||||
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(chunk) })
|
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(m.scaleMonitor(chunk)) })
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
// Consumer: render the ring to the output device at the internal 16 kHz mono.
|
// Consumer: render the ring to the output device at the internal 16 kHz mono.
|
||||||
@@ -201,6 +248,38 @@ func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMonitorGain sets the RX monitor level in percent (100 = as captured).
|
||||||
|
// Takes effect on the next captured chunk — no need to restart the monitor.
|
||||||
|
func (m *Manager) SetMonitorGain(pct int) {
|
||||||
|
if pct <= 0 {
|
||||||
|
pct = 100
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.monGainPct = pct
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// scaleMonitor applies the monitor level to one captured chunk, returning a
|
||||||
|
// buffer the ring may keep. At unity it hands the chunk straight back: the
|
||||||
|
// common case must not pay for a copy 30 times a second.
|
||||||
|
func (m *Manager) scaleMonitor(chunk []byte) []byte {
|
||||||
|
m.mu.Lock()
|
||||||
|
pct := m.monGainPct
|
||||||
|
m.mu.Unlock()
|
||||||
|
if pct == 0 || pct == 100 {
|
||||||
|
return chunk
|
||||||
|
}
|
||||||
|
g := float64(pct) / 100
|
||||||
|
out := make([]byte, len(chunk))
|
||||||
|
copy(out, chunk)
|
||||||
|
for i := 0; i+1 < len(out); i += 2 {
|
||||||
|
v := int16(uint16(out[i]) | uint16(out[i+1])<<8)
|
||||||
|
v = scalePCM(v, g)
|
||||||
|
out[i], out[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// StopMonitor stops the RX monitor passthrough.
|
// StopMonitor stops the RX monitor passthrough.
|
||||||
func (m *Manager) StopMonitor() {
|
func (m *Manager) StopMonitor() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
|||||||
+70
-7
@@ -3,6 +3,7 @@
|
|||||||
package audio
|
package audio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/braheezy/shine-mp3/pkg/mp3"
|
"github.com/braheezy/shine-mp3/pkg/mp3"
|
||||||
@@ -50,19 +51,81 @@ func writeMP3(path string, pcm []byte) error {
|
|||||||
return enc.Write(f, stereo)
|
return enc.Write(f, stereo)
|
||||||
}
|
}
|
||||||
|
|
||||||
// upsample2 doubles the sample rate with linear interpolation (16 kHz → 32 kHz).
|
// upsample2 doubles the sample rate, 16 kHz → 32 kHz, WITH the interpolation
|
||||||
|
// filter that makes the operation legitimate.
|
||||||
|
//
|
||||||
|
// It used to interpolate linearly — each new sample the average of its
|
||||||
|
// neighbours. That is an upsampler in name only: every component at f is
|
||||||
|
// mirrored to 16 kHz − f, measured just 7 dB down (see the test). On speech it
|
||||||
|
// adds brightness; on receiver noise, which is broadband, it lays a second
|
||||||
|
// noise floor across the top of the band. Operators heard it as a hiss louder
|
||||||
|
// than the voice, present in the MP3 and absent from the WAV of the very same
|
||||||
|
// audio.
|
||||||
|
//
|
||||||
|
// Zero-stuffing followed by a windowed-sinc low-pass at the old Nyquist is the
|
||||||
|
// textbook answer, and it puts the image below 40 dB. 63 taps is a few hundred
|
||||||
|
// microseconds of work on an eight-second recording — nothing, next to the MP3
|
||||||
|
// encode that follows.
|
||||||
func upsample2(in []int16) []int16 {
|
func upsample2(in []int16) []int16 {
|
||||||
if len(in) == 0 {
|
if len(in) == 0 {
|
||||||
return in
|
return in
|
||||||
}
|
}
|
||||||
out := make([]int16, len(in)*2)
|
out := make([]int16, len(in)*2)
|
||||||
for i := range in {
|
half := len(upsampleFIR) / 2
|
||||||
out[2*i] = in[i]
|
for i := range out {
|
||||||
if i+1 < len(in) {
|
var acc float64
|
||||||
out[2*i+1] = int16((int32(in[i]) + int32(in[i+1])) / 2)
|
// The zero-stuffed signal is non-zero only at even indices, so only
|
||||||
} else {
|
// every other tap contributes — the loop skips the zeros rather than
|
||||||
out[2*i+1] = in[i]
|
// multiplying by them.
|
||||||
|
start := (i - half + 1) &^ 1 // first even index in the window
|
||||||
|
for j := start; j <= i+half; j += 2 {
|
||||||
|
k := i - j + half
|
||||||
|
if k < 0 || k >= len(upsampleFIR) {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
src := j / 2
|
||||||
|
if src < 0 || src >= len(in) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
acc += float64(in[src]) * upsampleFIR[k]
|
||||||
|
}
|
||||||
|
// ×2 for the energy lost to zero-stuffing, then clamp.
|
||||||
|
v := acc * 2
|
||||||
|
if v > 32767 {
|
||||||
|
v = 32767
|
||||||
|
} else if v < -32768 {
|
||||||
|
v = -32768
|
||||||
|
}
|
||||||
|
out[i] = int16(v)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// upsampleFIR is a 63-tap Hamming-windowed sinc, cut off at a quarter of the
|
||||||
|
// NEW rate (8 kHz at 32 kHz) — exactly the old Nyquist, which is where the
|
||||||
|
// images begin.
|
||||||
|
var upsampleFIR = func() []float64 {
|
||||||
|
const n = 63
|
||||||
|
const fc = 0.25 // cycles/sample at the new rate
|
||||||
|
h := make([]float64, n)
|
||||||
|
mid := (n - 1) / 2
|
||||||
|
var sum float64
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
m := float64(i - mid)
|
||||||
|
var v float64
|
||||||
|
if m == 0 {
|
||||||
|
v = 2 * fc
|
||||||
|
} else {
|
||||||
|
v = math.Sin(2*math.Pi*fc*m) / (math.Pi * m)
|
||||||
|
}
|
||||||
|
// Hamming window: a rectangular one would ring and put the stopband
|
||||||
|
// only ~21 dB down, which is not enough to be worth the filter.
|
||||||
|
v *= 0.54 - 0.46*math.Cos(2*math.Pi*float64(i)/float64(n-1))
|
||||||
|
h[i] = v
|
||||||
|
sum += v
|
||||||
|
}
|
||||||
|
for i := range h {
|
||||||
|
h[i] /= sum // unity gain at DC
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}()
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Playing a stopped take must not consume it.
|
||||||
|
//
|
||||||
|
// Reported on the air: the recording plays once, then nothing — "as if it threw
|
||||||
|
// the sound away after playing it". PeekQSO is what the playback reads, and it
|
||||||
|
// is meant to COPY; TakeQSO is the one that clears.
|
||||||
|
func TestPeekDoesNotConsumeTheTake(t *testing.T) {
|
||||||
|
r := &Recorder{running: true, active: true, paused: true, prerollSamples: sampleRate}
|
||||||
|
r.acc = make([]int16, 4000)
|
||||||
|
for i := range r.acc {
|
||||||
|
r.acc[i] = int16(i % 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
first, err := r.PeekQSO()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first peek: %v", err)
|
||||||
|
}
|
||||||
|
second, err := r.PeekQSO()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second peek: %v — the take was consumed by the first", err)
|
||||||
|
}
|
||||||
|
if len(first) != len(second) {
|
||||||
|
t.Errorf("second peek returned %d bytes, first %d", len(second), len(first))
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the copy must be independent: the caller holds these bytes while the
|
||||||
|
// recorder may still be appending to its own slice.
|
||||||
|
if len(first) > 0 {
|
||||||
|
first[0] = ^first[0]
|
||||||
|
again, _ := r.PeekQSO()
|
||||||
|
if len(again) > 0 && again[0] == first[0] {
|
||||||
|
t.Error("PeekQSO handed out its internal buffer, not a copy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resuming and stopping again keeps everything captured so far.
|
||||||
|
r.ResumeQSO()
|
||||||
|
r.mu.Lock()
|
||||||
|
r.acc = append(r.acc, make([]int16, 1000)...)
|
||||||
|
r.mu.Unlock()
|
||||||
|
r.PauseQSO()
|
||||||
|
third, err := r.PeekQSO()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("peek after resume: %v", err)
|
||||||
|
}
|
||||||
|
if len(third) <= len(second) {
|
||||||
|
t.Errorf("after resuming, the take is %d bytes — it should have grown past %d", len(third), len(second))
|
||||||
|
}
|
||||||
|
_ = time.Now
|
||||||
|
}
|
||||||
+180
-6
@@ -15,6 +15,14 @@ import (
|
|||||||
// Defaults to a no-op so the package is usable without wiring.
|
// Defaults to a no-op so the package is usable without wiring.
|
||||||
var LogSink = func(string, ...any) {}
|
var LogSink = func(string, ...any) {}
|
||||||
|
|
||||||
|
// AlertSink receives the few audio problems an operator must see WHILE they are
|
||||||
|
// operating, not afterwards in a log file — a capture device that opens but
|
||||||
|
// never streams being the one that matters: the recording is silently empty and
|
||||||
|
// nothing says so until the QSO is logged and gone.
|
||||||
|
//
|
||||||
|
// Set to a toast emitter at startup; a no-op keeps the package standalone.
|
||||||
|
var AlertSink = func(string, ...any) {}
|
||||||
|
|
||||||
// recoverGoroutine turns a panic in a long-running audio goroutine into a logged
|
// recoverGoroutine turns a panic in a long-running audio goroutine into a logged
|
||||||
// event with a stack trace instead of a silent process-killing crash. (It can't
|
// event with a stack trace instead of a silent process-killing crash. (It can't
|
||||||
// catch a hard Windows access violation from the WASAPI layer — those are fatal
|
// catch a hard Windows access violation from the WASAPI layer — those are fatal
|
||||||
@@ -45,6 +53,16 @@ type Recorder struct {
|
|||||||
srcMu sync.Mutex
|
srcMu sync.Mutex
|
||||||
bufA []int16 // From Radio
|
bufA []int16 // From Radio
|
||||||
bufB []int16 // mic
|
bufB []int16 // mic
|
||||||
|
// When each source last delivered samples. A configured device that never
|
||||||
|
// produces anything is not an error anywhere — the capture call just sits
|
||||||
|
// there — so the only way to notice is to watch the clock.
|
||||||
|
lastA, lastB time.Time
|
||||||
|
// startedAt is when capture began — the reference for a source that has not
|
||||||
|
// delivered anything at all yet.
|
||||||
|
startedAt time.Time
|
||||||
|
// deadB (deadA) latches once a source has been declared silent, so the
|
||||||
|
// warning is logged once rather than 25 times a second.
|
||||||
|
deadA, deadB bool
|
||||||
twoSrc bool
|
twoSrc bool
|
||||||
gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu
|
gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu
|
||||||
gainB float64 // mic gain
|
gainB float64 // mic gain
|
||||||
@@ -115,11 +133,37 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
|||||||
if prerollSec < 0 {
|
if prerollSec < 0 {
|
||||||
prerollSec = 0
|
prerollSec = 0
|
||||||
}
|
}
|
||||||
|
// Does the configured endpoint still EXIST?
|
||||||
|
//
|
||||||
|
// Endpoint ids are stored, and a DAX channel that is reconfigured, disabled
|
||||||
|
// or removed comes back with a different id. The old one then opens without
|
||||||
|
// complaint on some drivers and simply never streams — which is
|
||||||
|
// indistinguishable from a quiet band until the recording turns out empty.
|
||||||
|
// Checking the list takes milliseconds and answers it outright.
|
||||||
|
if devs, derr := ListInputDevices(); derr == nil {
|
||||||
|
known := func(id string) bool {
|
||||||
|
for _, d := range devs {
|
||||||
|
if d.ID == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if fromDev != "" && !known(fromDev) {
|
||||||
|
LogSink("recorder: the configured radio input no longer exists (%s) — re-select it in Settings → Audio", fromDev)
|
||||||
|
AlertSink("The configured radio audio input no longer exists — re-select it in Settings")
|
||||||
|
}
|
||||||
|
if micDev != "" && micDev != fromDev && !known(micDev) {
|
||||||
|
LogSink("recorder: the configured microphone no longer exists (%s) — re-select it in Settings → Audio", micDev)
|
||||||
|
}
|
||||||
|
}
|
||||||
r.prerollSamples = prerollSec * sampleRate
|
r.prerollSamples = prerollSec * sampleRate
|
||||||
r.twoSrc = micDev != "" && micDev != fromDev
|
r.twoSrc = micDev != "" && micDev != fromDev
|
||||||
r.stopCh = make(chan struct{})
|
r.stopCh = make(chan struct{})
|
||||||
r.running = true
|
r.running = true
|
||||||
|
r.startedAt = time.Now()
|
||||||
r.ring, r.acc, r.active, r.paused, r.bufA, r.bufB = nil, nil, false, false, nil, nil
|
r.ring, r.acc, r.active, r.paused, r.bufA, r.bufB = nil, nil, false, false, nil, nil
|
||||||
|
r.lastA, r.lastB, r.deadA, r.deadB = time.Time{}, time.Time{}, false, false
|
||||||
stop := r.stopCh
|
stop := r.stopCh
|
||||||
twoSrc := r.twoSrc
|
twoSrc := r.twoSrc
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
@@ -129,27 +173,79 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
|||||||
go func() {
|
go func() {
|
||||||
defer r.wg.Done()
|
defer r.wg.Done()
|
||||||
defer recoverGoroutine("recorder capture (radio)")
|
defer recoverGoroutine("recorder capture (radio)")
|
||||||
_ = captureStream(fromDev, stop, func(chunk []byte) {
|
// The error was discarded here. A device that cannot be opened — renamed,
|
||||||
|
// unplugged, held by another program — then looked exactly like a device
|
||||||
|
// that is merely quiet, and the recording came out empty with nothing in
|
||||||
|
// the log to say why.
|
||||||
|
if err := captureStream(fromDev, stop, func(chunk []byte) {
|
||||||
s := bytesToInt16(chunk)
|
s := bytesToInt16(chunk)
|
||||||
r.srcMu.Lock()
|
r.srcMu.Lock()
|
||||||
r.bufA = append(r.bufA, s...)
|
r.bufA = append(r.bufA, s...)
|
||||||
|
r.lastA = time.Now()
|
||||||
r.srcMu.Unlock()
|
r.srcMu.Unlock()
|
||||||
})
|
}); err != nil {
|
||||||
|
LogSink("recorder: capture from %q failed: %v", DeviceName(fromDev), err)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
if twoSrc {
|
if twoSrc {
|
||||||
r.wg.Add(1)
|
r.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer r.wg.Done()
|
defer r.wg.Done()
|
||||||
defer recoverGoroutine("recorder capture (mic)")
|
defer recoverGoroutine("recorder capture (mic)")
|
||||||
_ = captureStream(micDev, stop, func(chunk []byte) {
|
if err := captureStream(micDev, stop, func(chunk []byte) {
|
||||||
s := bytesToInt16(chunk)
|
s := bytesToInt16(chunk)
|
||||||
r.srcMu.Lock()
|
r.srcMu.Lock()
|
||||||
r.bufB = append(r.bufB, s...)
|
r.bufB = append(r.bufB, s...)
|
||||||
|
r.lastB = time.Now()
|
||||||
r.srcMu.Unlock()
|
r.srcMu.Unlock()
|
||||||
})
|
}); err != nil {
|
||||||
|
LogSink("recorder: capture from %q failed: %v", DeviceName(micDev), err)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Name the devices being recorded FROM, once, at the start.
|
||||||
|
//
|
||||||
|
// A station with two radios has two sets of endpoints, and the recorder will
|
||||||
|
// happily capture the one that is not being listened to: the result is a
|
||||||
|
// file full of hiss with no relation to what the operator hears, and nothing
|
||||||
|
// anywhere said which receiver it came from.
|
||||||
|
if twoSrc {
|
||||||
|
LogSink("recorder: capturing %q + %q", DeviceName(fromDev), DeviceName(micDev))
|
||||||
|
} else {
|
||||||
|
LogSink("recorder: capturing %q", DeviceName(fromDev))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watchdog. A WASAPI device can open cleanly and then produce nothing at
|
||||||
|
// all — a DAX channel with no stream behind it does exactly that, and it is
|
||||||
|
// indistinguishable from silence until the recording turns out to be empty
|
||||||
|
// at the end of a QSO. Say it once, three seconds in, while there is still
|
||||||
|
// time to fix the setup.
|
||||||
|
r.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer r.wg.Done()
|
||||||
|
defer recoverGoroutine("recorder watchdog")
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
}
|
||||||
|
r.srcMu.Lock()
|
||||||
|
aQuiet, bQuiet := r.lastA.IsZero(), r.lastB.IsZero()
|
||||||
|
r.srcMu.Unlock()
|
||||||
|
if aQuiet {
|
||||||
|
LogSink("recorder: no audio at all from %q after 3 s — the device opened but nothing is streaming", DeviceName(fromDev))
|
||||||
|
AlertSink("No audio from %s — nothing is being recorded", DeviceName(fromDev))
|
||||||
|
}
|
||||||
|
// The mic is only worth a warning when the RADIO is silent too. On CW the
|
||||||
|
// mic channel legitimately delivers nothing, and the mixer has already
|
||||||
|
// said "recording the radio alone" — repeating it as an alarm made the log
|
||||||
|
// read as if something were broken while the recording was going fine.
|
||||||
|
if twoSrc && bQuiet && aQuiet {
|
||||||
|
LogSink("recorder: no audio at all from %q either", DeviceName(micDev))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// Mixer goroutine.
|
// Mixer goroutine.
|
||||||
r.wg.Add(1)
|
r.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -171,15 +267,79 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
|||||||
|
|
||||||
// mixTick drains the source queues, mixes what's available, and appends to the
|
// mixTick drains the source queues, mixes what's available, and appends to the
|
||||||
// ring + active accumulation.
|
// ring + active accumulation.
|
||||||
|
// deadSourceAfter is how long a source may deliver nothing before the recorder
|
||||||
|
// carries on without it. Long enough not to trip on a scheduling hiccup, short
|
||||||
|
// enough that almost nothing is lost from the source that IS working.
|
||||||
|
const deadSourceAfter = 1500 * time.Millisecond
|
||||||
|
|
||||||
func (r *Recorder) mixTick() {
|
func (r *Recorder) mixTick() {
|
||||||
r.srcMu.Lock()
|
r.srcMu.Lock()
|
||||||
var mixed []int16
|
var mixed []int16
|
||||||
if r.twoSrc {
|
if r.twoSrc {
|
||||||
|
// A source that has been silent for a while is treated as ABSENT and the
|
||||||
|
// other one is recorded alone.
|
||||||
|
//
|
||||||
|
// Two sources used to mean min(len(A), len(B)) samples: if one device
|
||||||
|
// delivered nothing, NOTHING was recorded, and the drift guard below
|
||||||
|
// then threw the live source away a second at a time. That is exactly
|
||||||
|
// what happened on a Flex in CW — DAX Mic delivers nothing when the mic
|
||||||
|
// path is not running — and the operator got "recording was empty" after
|
||||||
|
// a whole QSO. Half a recording is worth having; silence is not.
|
||||||
|
now := time.Now()
|
||||||
|
// A source is dry when it has been quiet for too long — and a source that
|
||||||
|
// has NEVER delivered is measured from when capture started, because it
|
||||||
|
// has no last-delivery time of its own.
|
||||||
|
//
|
||||||
|
// The first version required a source to have spoken at least once. That
|
||||||
|
// covers a device that stops, but not the one that actually happens: a
|
||||||
|
// DAX Mic channel that is simply switched off never delivers a single
|
||||||
|
// sample, so it stayed "not yet dry" forever and took the whole recording
|
||||||
|
// down with it. Three empty CW recordings, with the radio audio streaming
|
||||||
|
// perfectly the entire time.
|
||||||
|
dry := func(last time.Time) bool {
|
||||||
|
if last.IsZero() {
|
||||||
|
return now.Sub(r.startedAt) > deadSourceAfter
|
||||||
|
}
|
||||||
|
return now.Sub(last) > deadSourceAfter
|
||||||
|
}
|
||||||
|
aDry, bDry := dry(r.lastA), dry(r.lastB)
|
||||||
|
if bDry && !aDry && len(r.bufA) > 0 {
|
||||||
|
if !r.deadB {
|
||||||
|
r.deadB = true
|
||||||
|
LogSink("recorder: the second audio source is silent — recording the radio alone")
|
||||||
|
}
|
||||||
|
mixed = make([]int16, len(r.bufA))
|
||||||
|
for i, v := range r.bufA {
|
||||||
|
mixed[i] = scaleSample(v, r.gainA)
|
||||||
|
}
|
||||||
|
r.bufA = r.bufA[:0]
|
||||||
|
r.bufB = r.bufB[:0]
|
||||||
|
r.srcMu.Unlock()
|
||||||
|
r.store(mixed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if aDry && !bDry && len(r.bufB) > 0 {
|
||||||
|
if !r.deadA {
|
||||||
|
r.deadA = true
|
||||||
|
LogSink("recorder: the radio audio source is silent — recording the microphone alone")
|
||||||
|
}
|
||||||
|
mixed = make([]int16, len(r.bufB))
|
||||||
|
for i, v := range r.bufB {
|
||||||
|
mixed[i] = scaleSample(v, r.gainB)
|
||||||
|
}
|
||||||
|
r.bufA = r.bufA[:0]
|
||||||
|
r.bufB = r.bufB[:0]
|
||||||
|
r.srcMu.Unlock()
|
||||||
|
r.store(mixed)
|
||||||
|
return
|
||||||
|
}
|
||||||
n := len(r.bufA)
|
n := len(r.bufA)
|
||||||
if len(r.bufB) < n {
|
if len(r.bufB) < n {
|
||||||
n = len(r.bufB)
|
n = len(r.bufB)
|
||||||
}
|
}
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
|
// Both alive again after one was written off.
|
||||||
|
r.deadA, r.deadB = false, false
|
||||||
mixed = make([]int16, n)
|
mixed = make([]int16, n)
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
mixed[i] = clampSum(scaleSample(r.bufA[i], r.gainA), scaleSample(r.bufB[i], r.gainB))
|
mixed[i] = clampSum(scaleSample(r.bufA[i], r.gainA), scaleSample(r.bufB[i], r.gainB))
|
||||||
@@ -187,13 +347,21 @@ func (r *Recorder) mixTick() {
|
|||||||
r.bufA = append(r.bufA[:0], r.bufA[n:]...)
|
r.bufA = append(r.bufA[:0], r.bufA[n:]...)
|
||||||
r.bufB = append(r.bufB[:0], r.bufB[n:]...)
|
r.bufB = append(r.bufB[:0], r.bufB[n:]...)
|
||||||
}
|
}
|
||||||
// Drift guard: if the clocks diverge, drop the excess so the two
|
// Drift guard: two sound cards run on their own clocks, so drop the
|
||||||
// sources stay roughly aligned (≤1 s skew).
|
// excess to keep them within a second of each other.
|
||||||
|
//
|
||||||
|
// ONLY while both are actually running. A starved source is not drift: it
|
||||||
|
// made this guard throw away the radio audio a second at a time during
|
||||||
|
// the grace period, so the opening of every recording was lost even
|
||||||
|
// though it had been captured. Waiting costs nothing now — whatever is
|
||||||
|
// buffered is written whole the moment the silent source is written off.
|
||||||
|
if len(r.bufA) > 0 && len(r.bufB) > 0 {
|
||||||
if d := len(r.bufA) - len(r.bufB); d > sampleRate {
|
if d := len(r.bufA) - len(r.bufB); d > sampleRate {
|
||||||
r.bufA = append(r.bufA[:0], r.bufA[d:]...)
|
r.bufA = append(r.bufA[:0], r.bufA[d:]...)
|
||||||
} else if d < -sampleRate {
|
} else if d < -sampleRate {
|
||||||
r.bufB = append(r.bufB[:0], r.bufB[-d:]...)
|
r.bufB = append(r.bufB[:0], r.bufB[-d:]...)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else if len(r.bufA) > 0 {
|
} else if len(r.bufA) > 0 {
|
||||||
mixed = make([]int16, len(r.bufA))
|
mixed = make([]int16, len(r.bufA))
|
||||||
for i, s := range r.bufA {
|
for i, s := range r.bufA {
|
||||||
@@ -203,6 +371,12 @@ func (r *Recorder) mixTick() {
|
|||||||
}
|
}
|
||||||
r.srcMu.Unlock()
|
r.srcMu.Unlock()
|
||||||
|
|
||||||
|
r.store(mixed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// store appends mixed samples to the pre-roll ring and, when a take is running,
|
||||||
|
// to the take itself.
|
||||||
|
func (r *Recorder) store(mixed []int16) {
|
||||||
if len(mixed) == 0 {
|
if len(mixed) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A silent second source must not silence the recording.
|
||||||
|
//
|
||||||
|
// From a real session: a Flex with "From Radio" on DAX RX and "Recording mic"
|
||||||
|
// on DAX Mic. In CW the mic path does not run, so DAX Mic delivered nothing —
|
||||||
|
// and because the mixer took min(len(A), len(B)) samples, nothing at all was
|
||||||
|
// recorded. The whole QSO ended in "recording was empty".
|
||||||
|
func TestMixerSurvivesASilentSource(t *testing.T) {
|
||||||
|
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||||
|
|
||||||
|
// The radio has been talking; the mic has not spoken for well over the
|
||||||
|
// grace period.
|
||||||
|
r.bufA = make([]int16, 800)
|
||||||
|
for i := range r.bufA {
|
||||||
|
r.bufA[i] = 1000
|
||||||
|
}
|
||||||
|
r.lastA = time.Now()
|
||||||
|
r.lastB = time.Now().Add(-5 * time.Second)
|
||||||
|
|
||||||
|
r.mixTick()
|
||||||
|
|
||||||
|
if len(r.acc) == 0 {
|
||||||
|
t.Fatal("nothing was recorded — a silent mic must not stop the radio being captured")
|
||||||
|
}
|
||||||
|
if r.acc[0] != 1000 {
|
||||||
|
t.Errorf("sample = %d, want 1000 — the live source must pass through unchanged", r.acc[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// While BOTH sources are alive the two are mixed, as before.
|
||||||
|
func TestMixerStillMixesBothSources(t *testing.T) {
|
||||||
|
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||||
|
r.bufA = []int16{100, 100, 100}
|
||||||
|
r.bufB = []int16{50, 50, 50}
|
||||||
|
now := time.Now()
|
||||||
|
r.lastA, r.lastB = now, now
|
||||||
|
|
||||||
|
r.mixTick()
|
||||||
|
|
||||||
|
if len(r.acc) != 3 {
|
||||||
|
t.Fatalf("recorded %d samples, want 3", len(r.acc))
|
||||||
|
}
|
||||||
|
if r.acc[0] != 150 {
|
||||||
|
t.Errorf("sample = %d, want 150 — both sources should be summed", r.acc[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just after capture starts, a source that has not delivered yet is NOT written
|
||||||
|
// off: the first samples take a moment to arrive, and declaring the mic dead at
|
||||||
|
// once would drop the opening of every recording.
|
||||||
|
func TestMixerWaitsBrieflyAtStartup(t *testing.T) {
|
||||||
|
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||||
|
r.startedAt = time.Now()
|
||||||
|
r.bufA = []int16{100, 100}
|
||||||
|
|
||||||
|
r.mixTick()
|
||||||
|
|
||||||
|
if len(r.acc) != 0 {
|
||||||
|
t.Errorf("recorded %d samples immediately — the second source deserves a moment to start", len(r.acc))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A source that has NEVER delivered is written off once enough time has passed.
|
||||||
|
//
|
||||||
|
// This is the case that actually happens, and the one the first version missed:
|
||||||
|
// a DAX Mic channel switched off delivers not one sample, so it had no
|
||||||
|
// last-delivery time and stayed forever "not yet dry" — taking the whole
|
||||||
|
// recording down with it while the radio audio streamed perfectly.
|
||||||
|
func TestMixerWritesOffASourceThatNeverSpoke(t *testing.T) {
|
||||||
|
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||||
|
r.startedAt = time.Now().Add(-5 * time.Second)
|
||||||
|
r.bufA = []int16{700, 700, 700}
|
||||||
|
r.lastA = time.Now()
|
||||||
|
// lastB stays zero: the mic has never produced anything at all.
|
||||||
|
|
||||||
|
r.mixTick()
|
||||||
|
|
||||||
|
if len(r.acc) != 3 {
|
||||||
|
t.Fatalf("recorded %d samples, want 3 — the radio was streaming the whole time", len(r.acc))
|
||||||
|
}
|
||||||
|
if r.acc[0] != 700 {
|
||||||
|
t.Errorf("sample = %d, want 700", r.acc[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing captured during the grace period is thrown away.
|
||||||
|
//
|
||||||
|
// The drift guard exists for two sound cards running on their own clocks. A
|
||||||
|
// STARVED source is not drift, and treating it as such discarded the radio
|
||||||
|
// audio a second at a time while the mixer was still waiting for the mic — so
|
||||||
|
// the opening of every CW recording was lost although it had been captured.
|
||||||
|
func TestGracePeriodKeepsWhatWasCaptured(t *testing.T) {
|
||||||
|
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||||
|
r.startedAt = time.Now()
|
||||||
|
|
||||||
|
// Three seconds of radio audio arrive while the mic says nothing at all.
|
||||||
|
r.bufA = make([]int16, 3*sampleRate)
|
||||||
|
for i := range r.bufA {
|
||||||
|
r.bufA[i] = 500
|
||||||
|
}
|
||||||
|
r.lastA = time.Now()
|
||||||
|
r.mixTick() // still inside the grace period: nothing is written yet…
|
||||||
|
|
||||||
|
if len(r.acc) != 0 {
|
||||||
|
t.Fatalf("wrote %d samples before the mic was written off", len(r.acc))
|
||||||
|
}
|
||||||
|
if len(r.bufA) != 3*sampleRate {
|
||||||
|
t.Fatalf("buffered audio was trimmed to %d samples — it must be kept, not dropped", len(r.bufA))
|
||||||
|
}
|
||||||
|
|
||||||
|
// …and once the mic is written off, everything captured comes through.
|
||||||
|
r.startedAt = time.Now().Add(-5 * time.Second)
|
||||||
|
r.mixTick()
|
||||||
|
if len(r.acc) != 3*sampleRate {
|
||||||
|
t.Errorf("recorded %d samples, want the full %d captured during the wait", len(r.acc), 3*sampleRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// goertzel returns the magnitude of one frequency bin, so a test can ask "how
|
||||||
|
// much energy is at 10 kHz" without pulling in an FFT.
|
||||||
|
func goertzel(x []int16, fs, freq float64) float64 {
|
||||||
|
w := 2 * math.Pi * freq / fs
|
||||||
|
c := 2 * math.Cos(w)
|
||||||
|
var s1, s2 float64
|
||||||
|
for _, v := range x {
|
||||||
|
s := float64(v) + c*s1 - s2
|
||||||
|
s2, s1 = s1, s
|
||||||
|
}
|
||||||
|
return math.Hypot(s1-s2*math.Cos(w), s2*math.Sin(w))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsampling 16 kHz → 32 kHz must not create an audible mirror image.
|
||||||
|
//
|
||||||
|
// MP3 recordings came back with a hiss that is not on the air and is not in the
|
||||||
|
// WAV of the same audio — "louder than the voice", from a station that hears the
|
||||||
|
// voice well clear of the noise. The MP3 path is the only one that resamples,
|
||||||
|
// and it did so by linear interpolation: every component at f is mirrored to
|
||||||
|
// 16 kHz − f, which for broadband receiver noise means a second noise floor
|
||||||
|
// spread across the top of the band.
|
||||||
|
func TestUpsampleSuppressesTheImage(t *testing.T) {
|
||||||
|
const fs = 16000.0
|
||||||
|
const tone = 6000.0 // its image lands at 16000 − 6000 = 10 kHz
|
||||||
|
|
||||||
|
in := make([]int16, 4096)
|
||||||
|
for i := range in {
|
||||||
|
in[i] = int16(12000 * math.Sin(2*math.Pi*tone*float64(i)/fs))
|
||||||
|
}
|
||||||
|
|
||||||
|
out := upsample2(in)
|
||||||
|
if len(out) != 2*len(in) {
|
||||||
|
t.Fatalf("upsample2 returned %d samples for %d", len(out), len(in))
|
||||||
|
}
|
||||||
|
|
||||||
|
wanted := goertzel(out, 2*fs, tone)
|
||||||
|
image := goertzel(out, 2*fs, 2*fs-tone-16000) // = 10 kHz
|
||||||
|
if wanted == 0 {
|
||||||
|
t.Fatal("the tone itself did not survive upsampling")
|
||||||
|
}
|
||||||
|
db := 20 * math.Log10(image/wanted)
|
||||||
|
t.Logf("image at 10 kHz is %.1f dB below the 6 kHz tone", db)
|
||||||
|
if db > -35 {
|
||||||
|
t.Errorf("image only %.1f dB down — it is audible as added hiss; want at least 35 dB", db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The filter must not change the level, or every existing recording would come
|
||||||
|
// back quieter (or clipped) after the fix — a second surprise on top of the one
|
||||||
|
// being repaired.
|
||||||
|
func TestUpsampleKeepsTheLevel(t *testing.T) {
|
||||||
|
const fs = 16000.0
|
||||||
|
in := make([]int16, 4096)
|
||||||
|
for i := range in {
|
||||||
|
in[i] = int16(10000 * math.Sin(2*math.Pi*1000*float64(i)/fs))
|
||||||
|
}
|
||||||
|
rms := func(x []int16) float64 {
|
||||||
|
var acc float64
|
||||||
|
for _, v := range x {
|
||||||
|
acc += float64(v) * float64(v)
|
||||||
|
}
|
||||||
|
return math.Sqrt(acc / float64(len(x)))
|
||||||
|
}
|
||||||
|
// Skip the filter's warm-up and tail, where the window is only partly fed.
|
||||||
|
out := upsample2(in)
|
||||||
|
got, want := rms(out[200:len(out)-200]), rms(in[100:len(in)-100])
|
||||||
|
ratio := got / want
|
||||||
|
t.Logf("level after upsampling: ×%.3f", ratio)
|
||||||
|
if ratio < 0.9 || ratio > 1.1 {
|
||||||
|
t.Errorf("level changed by ×%.3f — want within 10%%", ratio)
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
-6
@@ -75,10 +75,22 @@ type Def struct {
|
|||||||
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
|
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
|
||||||
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
|
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
|
||||||
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
||||||
// NOTE: there is no "one reference per QSO" switch, and there was never any
|
// OneRefPerQSO refuses an AMBIGUOUS match rather than guessing.
|
||||||
// point in one. A QSO ALWAYS yields every reference its field holds — an n-fer
|
//
|
||||||
// POTA activation ("US-6544,US-0680"), a VUCC contact on a grid line. The old
|
// This is not the old `Multi` flag, which was removed for good reason: a QSO
|
||||||
// `Multi` flag was read by nothing; its checkbox changed nothing.
|
// always yields every reference its FIELD holds — an n-fer POTA activation
|
||||||
|
// ("US-6544,US-0680"), a VUCC contact on a grid line. That is several
|
||||||
|
// references, correctly.
|
||||||
|
//
|
||||||
|
// The case here is the opposite. The field holds ONE value and two entries in
|
||||||
|
// the reference list carry it: two German DOKs are both called "Gießen",
|
||||||
|
// because two clubs share the town. The matcher is not wrong, the data is
|
||||||
|
// ambiguous — and picking one would write a reference into the log that may
|
||||||
|
// well be the other. So on an award where a QSO can only ever count for one
|
||||||
|
// reference, an ambiguous match assigns NONE and the contact appears under
|
||||||
|
// "missing references", where the operator picks. Their choice then sticks:
|
||||||
|
// a manual reference overrides the matcher.
|
||||||
|
OneRefPerQSO bool `json:"one_ref_per_qso,omitempty"`
|
||||||
|
|
||||||
// OrRules are ordered FALLBACK searches for the primary one above: they are
|
// OrRules are ordered FALLBACK searches for the primary one above: they are
|
||||||
// tried IN ORDER and only while nothing has matched yet — the first rule that
|
// tried IN ORDER and only while nothing has matched yet — the first rule that
|
||||||
@@ -341,6 +353,12 @@ type Ref struct {
|
|||||||
Worked bool `json:"worked"`
|
Worked bool `json:"worked"`
|
||||||
Confirmed bool `json:"confirmed"`
|
Confirmed bool `json:"confirmed"`
|
||||||
Validated bool `json:"validated"`
|
Validated bool `json:"validated"`
|
||||||
|
// Modes / ConfirmedModes are CLASSES, not ADIF modes: "CW", "PHONE",
|
||||||
|
// "DIGI". An operator asking "which entities have I worked on CW but not
|
||||||
|
// confirmed" does not care whether it was FT8 or RTTY on the digital side,
|
||||||
|
// and a list of twenty mode names would not answer the question they asked.
|
||||||
|
Modes []string `json:"modes"`
|
||||||
|
ConfirmedModes []string `json:"confirmed_modes"`
|
||||||
Bands []string `json:"bands"`
|
Bands []string `json:"bands"`
|
||||||
ConfirmedBands []string `json:"confirmed_bands"`
|
ConfirmedBands []string `json:"confirmed_bands"`
|
||||||
ValidatedBands []string `json:"validated_bands"`
|
ValidatedBands []string `json:"validated_bands"`
|
||||||
@@ -360,11 +378,33 @@ type Result struct {
|
|||||||
Error string `json:"error,omitempty"` // e.g. bad regexp pattern
|
Error string `json:"error,omitempty"` // e.g. bad regexp pattern
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ModeClass sorts an ADIF mode into the three classes an operator thinks in:
|
||||||
|
// CW, PHONE, DIGI. Anything unrecognised returns "" and is simply not counted
|
||||||
|
// under any class — better than inventing one, since these classes drive a
|
||||||
|
// filter that decides what the operator is shown.
|
||||||
|
func ModeClass(mode string) string {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
|
case "CW", "CWR":
|
||||||
|
return "CW"
|
||||||
|
case "SSB", "USB", "LSB", "AM", "FM", "DV", "PHONE", "DIGITALVOICE":
|
||||||
|
return "PHONE"
|
||||||
|
case "":
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Everything else that a logbook actually carries is a data mode: FT8, FT4,
|
||||||
|
// RTTY, PSK31, JS8, Q65, MSK144, OLIVIA, VARA… Listing them exhaustively
|
||||||
|
// would mean a new mode silently vanishing from the filter the day it
|
||||||
|
// appears, which is worse than treating an unknown data mode as data.
|
||||||
|
return "DIGI"
|
||||||
|
}
|
||||||
|
|
||||||
// NameResolver optionally maps a (field, ref) pair to a human name. May be nil.
|
// NameResolver optionally maps a (field, ref) pair to a human name. May be nil.
|
||||||
type NameResolver func(field, ref string) string
|
type NameResolver func(field, ref string) string
|
||||||
|
|
||||||
type refAgg struct {
|
type refAgg struct {
|
||||||
bands map[string]struct{}
|
bands map[string]struct{}
|
||||||
|
modes map[string]struct{}
|
||||||
|
confirmedModes map[string]struct{}
|
||||||
confirmedBands map[string]struct{}
|
confirmedBands map[string]struct{}
|
||||||
validatedBands map[string]struct{}
|
validatedBands map[string]struct{}
|
||||||
anyConfirmed bool
|
anyConfirmed bool
|
||||||
@@ -481,22 +521,33 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
band := strings.ToLower(strings.TrimSpace(q.Band))
|
band := strings.ToLower(strings.TrimSpace(q.Band))
|
||||||
|
modeClass := ModeClass(q.Mode)
|
||||||
isConf := confirmed(q, d.Confirm)
|
isConf := confirmed(q, d.Confirm)
|
||||||
isVal := confirmed(q, d.Validate)
|
isVal := confirmed(q, d.Validate)
|
||||||
for _, ref := range refs {
|
for _, ref := range refs {
|
||||||
a := agg[i][ref]
|
a := agg[i][ref]
|
||||||
if a == nil {
|
if a == nil {
|
||||||
a = &refAgg{bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{}, validatedBands: map[string]struct{}{}}
|
a = &refAgg{
|
||||||
|
bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{},
|
||||||
|
validatedBands: map[string]struct{}{},
|
||||||
|
modes: map[string]struct{}{}, confirmedModes: map[string]struct{}{},
|
||||||
|
}
|
||||||
agg[i][ref] = a
|
agg[i][ref] = a
|
||||||
}
|
}
|
||||||
if band != "" {
|
if band != "" {
|
||||||
a.bands[band] = struct{}{}
|
a.bands[band] = struct{}{}
|
||||||
}
|
}
|
||||||
|
if modeClass != "" {
|
||||||
|
a.modes[modeClass] = struct{}{}
|
||||||
|
}
|
||||||
if isConf {
|
if isConf {
|
||||||
a.anyConfirmed = true
|
a.anyConfirmed = true
|
||||||
if band != "" {
|
if band != "" {
|
||||||
a.confirmedBands[band] = struct{}{}
|
a.confirmedBands[band] = struct{}{}
|
||||||
}
|
}
|
||||||
|
if modeClass != "" {
|
||||||
|
a.confirmedModes[modeClass] = struct{}{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if isVal {
|
if isVal {
|
||||||
a.anyValidated = true
|
a.anyValidated = true
|
||||||
@@ -525,6 +576,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
|||||||
r.Validated++
|
r.Validated++
|
||||||
}
|
}
|
||||||
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Validated: a.anyValidated,
|
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Validated: a.anyValidated,
|
||||||
|
Modes: setToSorted(a.modes), ConfirmedModes: setToSorted(a.confirmedModes),
|
||||||
Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands), ValidatedBands: setToSorted(a.validatedBands)}
|
Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands), ValidatedBands: setToSorted(a.validatedBands)}
|
||||||
labelRef(&rf, d, ref, rl, hasList, nameOf)
|
labelRef(&rf, d, ref, rl, hasList, nameOf)
|
||||||
r.Refs = append(r.Refs, rf)
|
r.Refs = append(r.Refs, rf)
|
||||||
@@ -819,6 +871,14 @@ type Explanation struct {
|
|||||||
RefCount int `json:"ref_count"` // size of that list
|
RefCount int `json:"ref_count"` // size of that list
|
||||||
Steps []Step `json:"steps"`
|
Steps []Step `json:"steps"`
|
||||||
Manual []string `json:"manual,omitempty"` // references the operator assigned by hand
|
Manual []string `json:"manual,omitempty"` // references the operator assigned by hand
|
||||||
|
// Superseded is what the matcher had found before the operator's choice
|
||||||
|
// replaced it — shown in the trace so a correction is explainable rather
|
||||||
|
// than mysterious.
|
||||||
|
Superseded []string `json:"superseded,omitempty"`
|
||||||
|
// Ambiguous lists the references that matched when the award allows only one
|
||||||
|
// — none of them was kept. Shown so the operator sees WHICH ones to choose
|
||||||
|
// between rather than an unexplained blank.
|
||||||
|
Ambiguous []string `json:"ambiguous,omitempty"`
|
||||||
Result []string `json:"result"` // what the QSO finally counts for
|
Result []string `json:"result"` // what the QSO finally counts for
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -937,8 +997,31 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
|
|||||||
if ex != nil {
|
if ex != nil {
|
||||||
ex.Manual = manual
|
ex.Manual = manual
|
||||||
}
|
}
|
||||||
found = append(found, manual...)
|
// An operator's choice REPLACES what the matcher found. It does not join it.
|
||||||
|
//
|
||||||
|
// Adding to it was the behaviour, and it made a correction impossible: a
|
||||||
|
// German address matching two or three DOKs kept them all, so deleting the
|
||||||
|
// wrong ones and assigning the right one lasted exactly until the next
|
||||||
|
// recompute, which put them straight back. The operator was arguing with
|
||||||
|
// the matcher and could not win.
|
||||||
|
//
|
||||||
|
// One manual entry is stored per award (setOverrideRef drops the previous
|
||||||
|
// one), so this is an override in the storage as well as in the name.
|
||||||
|
if len(manual) > 0 {
|
||||||
|
if ex != nil {
|
||||||
|
ex.Superseded = dedupe(found)
|
||||||
|
}
|
||||||
|
found = manual
|
||||||
|
}
|
||||||
out := dedupe(found)
|
out := dedupe(found)
|
||||||
|
// Ambiguity is not a result. See OneRefPerQSO.
|
||||||
|
if d.OneRefPerQSO && len(manual) == 0 && len(out) > 1 {
|
||||||
|
if ex != nil {
|
||||||
|
ex.Ambiguous = out
|
||||||
|
ex.Result = []string{}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if ex != nil {
|
if ex != nil {
|
||||||
ex.Result = out
|
ex.Result = out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,14 @@
|
|||||||
"type": "QSOFIELDS",
|
"type": "QSOFIELDS",
|
||||||
"field": "address",
|
"field": "address",
|
||||||
"match_by": "description",
|
"match_by": "description",
|
||||||
|
"exact_match": true,
|
||||||
"pattern": "",
|
"pattern": "",
|
||||||
|
"one_ref_per_qso": true,
|
||||||
"or_rules": [
|
"or_rules": [
|
||||||
{
|
{
|
||||||
"field": "qth",
|
"field": "qth",
|
||||||
"match_by": "description"
|
"match_by": "description",
|
||||||
|
"exact_match": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"dxcc_filter": [
|
"dxcc_filter": [
|
||||||
@@ -34,7 +37,7 @@
|
|||||||
],
|
],
|
||||||
"total": 0,
|
"total": 0,
|
||||||
"builtin": true,
|
"builtin": true,
|
||||||
"version": 1
|
"version": 2
|
||||||
},
|
},
|
||||||
"references": [
|
"references": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package award
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The three classes an operator thinks in — the ones the awards filter offers.
|
||||||
|
//
|
||||||
|
// "Which entities have I worked on CW but not confirmed" is the question this
|
||||||
|
// serves, so the classes must match how the question is asked: CW, phone, data.
|
||||||
|
// Which data mode it was does not enter into it.
|
||||||
|
func TestModeClass(t *testing.T) {
|
||||||
|
for _, c := range []struct{ mode, want string }{
|
||||||
|
{"CW", "CW"}, {"cw", "CW"}, {" CW ", "CW"}, {"CWR", "CW"},
|
||||||
|
{"SSB", "PHONE"}, {"USB", "PHONE"}, {"LSB", "PHONE"},
|
||||||
|
{"AM", "PHONE"}, {"FM", "PHONE"}, {"DV", "PHONE"},
|
||||||
|
{"FT8", "DIGI"}, {"FT4", "DIGI"}, {"RTTY", "DIGI"}, {"PSK31", "DIGI"},
|
||||||
|
{"JS8", "DIGI"}, {"Q65", "DIGI"}, {"OLIVIA", "DIGI"},
|
||||||
|
// A mode nobody has heard of yet is data, not nothing: the alternative
|
||||||
|
// is a new digital mode silently vanishing from the filter the day it
|
||||||
|
// appears.
|
||||||
|
{"SOMETHINGNEW", "DIGI"},
|
||||||
|
// No mode at all is not a class, and must not be counted as one.
|
||||||
|
{"", ""}, {" ", ""},
|
||||||
|
} {
|
||||||
|
if got := ModeClass(c.mode); got != c.want {
|
||||||
|
t.Errorf("ModeClass(%q) = %q, want %q", c.mode, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package award
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An operator's correction must survive a recompute.
|
||||||
|
//
|
||||||
|
// Reported on the DARC DOK award: a German address matches two or three DOKs,
|
||||||
|
// the operator deletes the wrong ones and assigns the right one, and the next
|
||||||
|
// recompute puts the wrong ones straight back. The manual reference was being
|
||||||
|
// ADDED to what the matcher found instead of replacing it, so the correction
|
||||||
|
// could never win.
|
||||||
|
func TestManualReferenceReplacesTheMatch(t *testing.T) {
|
||||||
|
// An award that reads the address and matches a reference's description —
|
||||||
|
// the shape the DOK award uses, and the shape that over-matches.
|
||||||
|
def := Def{
|
||||||
|
Code: "DLD", Name: "DARC DOK", Field: "address",
|
||||||
|
MatchBy: "description", Type: "QSOFIELDS", Valid: true,
|
||||||
|
}
|
||||||
|
metas := []RefMeta{
|
||||||
|
{Code: "A01", Name: "Berlin", Valid: true},
|
||||||
|
{Code: "B05", Name: "Berlin Mitte", Valid: true},
|
||||||
|
}
|
||||||
|
// An address that legitimately matches both.
|
||||||
|
q := &qso.QSO{
|
||||||
|
Callsign: "DL2FDM",
|
||||||
|
Address: "Berlin Mitte, 10115 Berlin",
|
||||||
|
}
|
||||||
|
|
||||||
|
auto := Explain(def, metas, q)
|
||||||
|
if len(auto.Result) < 2 {
|
||||||
|
t.Fatalf("the test needs an over-matching case; got %v", auto.Result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The operator picks the right one.
|
||||||
|
q.Extras = map[string]string{ManualRefsKey: "DLD@B05"}
|
||||||
|
fixed := Explain(def, metas, q)
|
||||||
|
|
||||||
|
if got := strings.Join(fixed.Result, ","); got != "B05" {
|
||||||
|
t.Errorf("after the correction the QSO counts for %q — want B05 alone", got)
|
||||||
|
}
|
||||||
|
if len(fixed.Superseded) == 0 {
|
||||||
|
t.Error("the trace does not say what the correction replaced")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A QSO with no correction still gets everything the matcher found: the
|
||||||
|
// override must not disable matching for everyone else.
|
||||||
|
q2 := &qso.QSO{Callsign: "DL3XYZ", Address: "Berlin Mitte, 10115 Berlin"}
|
||||||
|
if len(Explain(def, metas, q2).Result) < 2 {
|
||||||
|
t.Error("an untouched QSO lost its automatic references")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On an award where a QSO can only count for ONE reference, an ambiguous match
|
||||||
|
// assigns none.
|
||||||
|
//
|
||||||
|
// Two DARC DOKs are called "Gießen" because two clubs share the town. Exact
|
||||||
|
// matching finds both, and both are right as far as the matcher can tell.
|
||||||
|
// Picking one would write a reference into the log that may well be the other —
|
||||||
|
// so the contact is left for the operator, who now has the last word.
|
||||||
|
func TestAmbiguousMatchIsRefusedWhenOnlyOneReferenceIsAllowed(t *testing.T) {
|
||||||
|
def := Def{
|
||||||
|
Code: "DLD", Name: "DARC DOK", Field: "qth",
|
||||||
|
MatchBy: "description", ExactMatch: true, Type: "QSOFIELDS", Valid: true,
|
||||||
|
OneRefPerQSO: true,
|
||||||
|
}
|
||||||
|
metas := []RefMeta{
|
||||||
|
{Code: "F07", Name: "Gießen", Valid: true},
|
||||||
|
{Code: "F61", Name: "Gießen", Valid: true},
|
||||||
|
{Code: "B11", Name: "Kassel", Valid: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
amb := Explain(def, metas, &qso.QSO{Callsign: "DL2FDM", QTH: "Gießen"})
|
||||||
|
if len(amb.Result) != 0 {
|
||||||
|
t.Errorf("an ambiguous match produced %v — it should produce nothing", amb.Result)
|
||||||
|
}
|
||||||
|
if len(amb.Ambiguous) != 2 {
|
||||||
|
t.Errorf("the trace lists %v — it should name both candidates", amb.Ambiguous)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unambiguous one is untouched: this must not make the award stricter in
|
||||||
|
// general, only where the data genuinely does not decide.
|
||||||
|
ok := Explain(def, metas, &qso.QSO{Callsign: "DL3XYZ", QTH: "Kassel"})
|
||||||
|
if len(ok.Result) != 1 || ok.Result[0] != "B11" {
|
||||||
|
t.Errorf("an unambiguous QSO gave %v — want B11", ok.Result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the operator's choice still wins over the ambiguity.
|
||||||
|
fixed := Explain(def, metas, &qso.QSO{
|
||||||
|
Callsign: "DL2FDM", QTH: "Gießen",
|
||||||
|
Extras: map[string]string{ManualRefsKey: "DLD@F61"},
|
||||||
|
})
|
||||||
|
if len(fixed.Result) != 1 || fixed.Result[0] != "F61" {
|
||||||
|
t.Errorf("after the operator picked F61 the QSO counts for %v", fixed.Result)
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
-4
@@ -36,6 +36,11 @@ type Flex struct {
|
|||||||
gotHandle bool
|
gotHandle bool
|
||||||
|
|
||||||
slices map[int]*flexSlice
|
slices map[int]*flexSlice
|
||||||
|
// pinnedSlice is the slice the operator chose IN OPSLOG (-1 = none). It
|
||||||
|
// overrides the radio's own "active" flag, and only an OpsLog click changes
|
||||||
|
// it — activating a slice on the radio's front panel or in SmartSDR does
|
||||||
|
// not, by design (see mainSliceLocked).
|
||||||
|
pinnedSlice int
|
||||||
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
|
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
|
||||||
amp flexAmp // external amplifier (PowerGenius XL) state
|
amp flexAmp // external amplifier (PowerGenius XL) state
|
||||||
micProfiles []string // available mic profiles (SmartSDR "profile mic list")
|
micProfiles []string // available mic profiles (SmartSDR "profile mic list")
|
||||||
@@ -183,6 +188,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
|||||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
||||||
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||||
|
pinnedSlice: -1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -968,7 +974,17 @@ func (f *Flex) mainSliceLocked() (int, *flexSlice) {
|
|||||||
// map order returned a RANDOM active slice each call → the operating frequency
|
// map order returned a RANDOM active slice each call → the operating frequency
|
||||||
// flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever.
|
// flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever.
|
||||||
// Deterministic order = the lowest-indexed active slice wins, stably.
|
// Deterministic order = the lowest-indexed active slice wins, stably.
|
||||||
|
// An explicit choice made in OpsLog wins over everything, including the
|
||||||
|
// radio's active flag. It is dropped only when that slice stops being in
|
||||||
|
// use — a slice the operator closed is not a choice any more.
|
||||||
|
if f.pinnedSlice >= 0 {
|
||||||
|
if s := f.slices[f.pinnedSlice]; s != nil && s.inUse {
|
||||||
|
return f.pinnedSlice, s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
firstInUse := -1
|
firstInUse := -1
|
||||||
|
chosen := -1
|
||||||
for _, idx := range f.sortedSliceIdxLocked() {
|
for _, idx := range f.sortedSliceIdxLocked() {
|
||||||
s := f.slices[idx]
|
s := f.slices[idx]
|
||||||
if !s.inUse {
|
if !s.inUse {
|
||||||
@@ -978,12 +994,57 @@ func (f *Flex) mainSliceLocked() (int, *flexSlice) {
|
|||||||
firstInUse = idx
|
firstInUse = idx
|
||||||
}
|
}
|
||||||
if s.active {
|
if s.active {
|
||||||
|
chosen = idx
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chosen < 0 {
|
||||||
|
chosen = firstInUse
|
||||||
|
}
|
||||||
|
if chosen < 0 {
|
||||||
|
return -1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// In SPLIT, stay on the RECEIVE slice.
|
||||||
|
//
|
||||||
|
// SmartSDR moves its "active" flag to the TX slice as soon as the transmit
|
||||||
|
// frequency is touched, so OpsLog followed the transmitter. But the operator
|
||||||
|
// is LISTENING to the DX on the other slice: the S-meter, the audio level,
|
||||||
|
// the filter and the DSP they are adjusting all belong there, and following
|
||||||
|
// the TX slice hands them the controls for a receiver they are not using.
|
||||||
|
//
|
||||||
|
// Only in split, and only when nothing was pinned above — clicking slice B
|
||||||
|
// in OpsLog still selects it and keeps it.
|
||||||
|
if txS := f.txSliceLocked(); txS != nil && f.slices[chosen] == txS {
|
||||||
|
if rxIdx, rxS := f.splitPartnerLocked(txS); rxS != nil {
|
||||||
|
return rxIdx, rxS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chosen, f.slices[chosen]
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitPartnerLocked returns the slice that FORMS A SPLIT with txS: in use, on
|
||||||
|
// the same band, at a different frequency, in the same class (phone with phone,
|
||||||
|
// CW with CW — so SSB alongside FT8 on one band is not a split).
|
||||||
|
//
|
||||||
|
// Lowest index first, so the answer is stable; map order is randomised in Go
|
||||||
|
// and gave a different partner on each poll. Caller holds f.mu.
|
||||||
|
func (f *Flex) splitPartnerLocked(txS *flexSlice) (int, *flexSlice) {
|
||||||
|
if txS == nil {
|
||||||
|
return -1, nil
|
||||||
|
}
|
||||||
|
bt := BandFromHz(txS.freqHz)
|
||||||
|
ct := flexSplitClass(txS.mode)
|
||||||
|
if bt == "" || ct == "" {
|
||||||
|
return -1, nil
|
||||||
|
}
|
||||||
|
for _, idx := range f.sortedSliceIdxLocked() {
|
||||||
|
s := f.slices[idx]
|
||||||
|
if s != nil && s.inUse && s != txS && s.freqHz != txS.freqHz &&
|
||||||
|
BandFromHz(s.freqHz) == bt && flexSplitClass(s.mode) == ct {
|
||||||
return idx, s
|
return idx, s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if firstInUse >= 0 {
|
|
||||||
return firstInUse, f.slices[firstInUse]
|
|
||||||
}
|
|
||||||
return -1, nil
|
return -1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1089,6 +1150,11 @@ func (f *Flex) SetActiveSlice(idx int) error {
|
|||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("flex: no slice %d", idx)
|
return fmt.Errorf("flex: no slice %d", idx)
|
||||||
}
|
}
|
||||||
|
// Remember it: this is the operator speaking, and it must survive the radio
|
||||||
|
// moving its own active flag to the transmitter during split.
|
||||||
|
f.mu.Lock()
|
||||||
|
f.pinnedSlice = idx
|
||||||
|
f.mu.Unlock()
|
||||||
f.send(fmt.Sprintf("slice s %d active=1", idx))
|
f.send(fmt.Sprintf("slice s %d active=1", idx))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1389,6 +1455,7 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Ints(sidx)
|
sort.Ints(sidx)
|
||||||
|
mainIdx, _ := f.mainSliceLocked()
|
||||||
for _, i := range sidx {
|
for _, i := range sidx {
|
||||||
s := f.slices[i]
|
s := f.slices[i]
|
||||||
st.Slices = append(st.Slices, FlexSliceInfo{
|
st.Slices = append(st.Slices, FlexSliceInfo{
|
||||||
@@ -1397,7 +1464,11 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
FreqHz: s.freqHz,
|
FreqHz: s.freqHz,
|
||||||
Mode: flexModeToADIF(s.mode),
|
Mode: flexModeToADIF(s.mode),
|
||||||
Band: BandFromHz(s.freqHz),
|
Band: BandFromHz(s.freqHz),
|
||||||
Active: s.active,
|
// The slice OpsLog is working with, which is not always the one the
|
||||||
|
// radio has focused — in split OpsLog stays on RX. Reporting the
|
||||||
|
// radio's flag here would highlight one slice while every control
|
||||||
|
// acted on another.
|
||||||
|
Active: i == mainIdx,
|
||||||
TX: s.tx,
|
TX: s.tx,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// In split, OpsLog stays on the RECEIVE slice.
|
||||||
|
//
|
||||||
|
// SmartSDR moves its "active" flag onto the TX slice as soon as the transmit
|
||||||
|
// frequency is touched. Following it hands the operator the S-meter, audio
|
||||||
|
// level, filter and DSP of a receiver they are not listening to — while the DX
|
||||||
|
// they are working is on the other slice.
|
||||||
|
func TestMainSliceStaysOnRXInSplit(t *testing.T) {
|
||||||
|
mk := func(hz int64, mode string, active, tx bool) *flexSlice {
|
||||||
|
return &flexSlice{freqHz: hz, mode: mode, active: active, tx: tx, inUse: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Working a DX on 14.100, transmitting up on 14.200. The radio has focused
|
||||||
|
// the transmitter.
|
||||||
|
f := &Flex{pinnedSlice: -1, slices: map[int]*flexSlice{
|
||||||
|
0: mk(14_100_000, "USB", false, false), // where the DX is heard
|
||||||
|
1: mk(14_200_000, "USB", true, true), // where we transmit, radio-focused
|
||||||
|
}}
|
||||||
|
idx, s := f.mainSliceLocked()
|
||||||
|
if idx != 0 || s == nil || s.freqHz != 14_100_000 {
|
||||||
|
t.Errorf("main slice = %d (%v Hz) — want slice 0, the RX side on 14.100", idx, s.freqHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simplex: the radio's focus is authoritative again, nothing to prefer.
|
||||||
|
f2 := &Flex{pinnedSlice: -1, slices: map[int]*flexSlice{
|
||||||
|
0: mk(14_100_000, "USB", false, false),
|
||||||
|
1: mk(14_200_000, "USB", true, true),
|
||||||
|
}}
|
||||||
|
f2.slices[0].inUse = false // only the TX slice is in use → simplex
|
||||||
|
if idx, _ := f2.mainSliceLocked(); idx != 1 {
|
||||||
|
t.Errorf("simplex main slice = %d, want 1 — the only slice in use", idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two same-band slices in DIFFERENT classes are not a split (SSB + FT8), so
|
||||||
|
// there is nothing to prefer and the radio's focus stands.
|
||||||
|
f3 := &Flex{pinnedSlice: -1, slices: map[int]*flexSlice{
|
||||||
|
0: mk(14_074_000, "DIGU", false, false),
|
||||||
|
1: mk(14_200_000, "USB", true, true),
|
||||||
|
}}
|
||||||
|
if idx, _ := f3.mainSliceLocked(); idx != 1 {
|
||||||
|
t.Errorf("SSB+FT8 main slice = %d, want 1 — that is not a split", idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A slice picked IN OPSLOG wins over everything, including the split rule and
|
||||||
|
// the radio's own focus. Picking one on the radio does not move OpsLog.
|
||||||
|
func TestPinnedSliceWins(t *testing.T) {
|
||||||
|
mk := func(hz int64, mode string, active, tx bool) *flexSlice {
|
||||||
|
return &flexSlice{freqHz: hz, mode: mode, active: active, tx: tx, inUse: true}
|
||||||
|
}
|
||||||
|
f := &Flex{pinnedSlice: 1, slices: map[int]*flexSlice{
|
||||||
|
0: mk(14_100_000, "USB", true, false), // radio-focused
|
||||||
|
1: mk(14_200_000, "USB", false, true), // chosen in OpsLog
|
||||||
|
}}
|
||||||
|
if idx, _ := f.mainSliceLocked(); idx != 1 {
|
||||||
|
t.Errorf("main slice = %d — an explicit choice in OpsLog must win", idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pin on a slice that is no longer in use is not a choice any more: it
|
||||||
|
// must not strand OpsLog on a receiver that has been closed.
|
||||||
|
f.slices[1].inUse = false
|
||||||
|
if idx, _ := f.mainSliceLocked(); idx != 0 {
|
||||||
|
t.Errorf("main slice = %d — a closed slice cannot stay pinned", idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
-17
@@ -29,6 +29,7 @@ package cat
|
|||||||
// from the radio, and the rig file decides what a "Freq" property means.
|
// from the radio, and the rig file decides what a "Freq" property means.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
@@ -71,6 +72,31 @@ type Kenwood struct {
|
|||||||
curVFO string // "A" or "B"
|
curVFO string // "A" or "B"
|
||||||
// Commands this rig answered "?;" to — asked once, then never again.
|
// Commands this rig answered "?;" to — asked once, then never again.
|
||||||
unsupported map[string]bool
|
unsupported map[string]bool
|
||||||
|
|
||||||
|
// rx holds bytes read but not yet consumed, ACROSS calls to ask.
|
||||||
|
//
|
||||||
|
// It has to survive: a rig answers faster than we ask, so one Read often
|
||||||
|
// returns a whole reply plus the start of the next frame. When this buffer
|
||||||
|
// was local to ask, everything after the matched frame was dropped — half a
|
||||||
|
// frame included — and the link desynchronised permanently: every ask then
|
||||||
|
// found the PREVIOUS command's answer and timed out waiting for its own.
|
||||||
|
// That is the "discarding \" 000000000010000000;\" while waiting for IF"
|
||||||
|
// a TS-480 reported, followed by connected=false for ever.
|
||||||
|
rx []byte
|
||||||
|
|
||||||
|
// heard is whatever arrived during Connect that was not a reply we wanted.
|
||||||
|
// Kept only to put it in the error message: "not answering" and "answering
|
||||||
|
// something unreadable" are different faults with different fixes.
|
||||||
|
heard string
|
||||||
|
}
|
||||||
|
|
||||||
|
// where names the link for a message, so an error does not read "COM @ 0 baud"
|
||||||
|
// after a network connect.
|
||||||
|
func (k *Kenwood) where() string {
|
||||||
|
if k.host != "" {
|
||||||
|
return k.host
|
||||||
|
}
|
||||||
|
return k.portName
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewKenwoodTCP builds a backend that reaches the rig over a socket instead of
|
// NewKenwoodTCP builds a backend that reaches the rig over a socket instead of
|
||||||
@@ -122,6 +148,14 @@ func (k *Kenwood) Connect() error {
|
|||||||
// request/response pairs and make a reply impossible to attribute. We poll.
|
// request/response pairs and make a reply impossible to attribute. We poll.
|
||||||
_ = k.write("AI0;")
|
_ = k.write("AI0;")
|
||||||
|
|
||||||
|
// Start from silence. A reconnect inherits whatever the rig said last —
|
||||||
|
// unsolicited frames sent before AI0 landed, the tail of an answer nobody
|
||||||
|
// read — and one stale frame is enough to leave every ask one reply behind
|
||||||
|
// its question for the rest of the session.
|
||||||
|
k.rx = nil
|
||||||
|
k.drain(250 * time.Millisecond)
|
||||||
|
k.heard = ""
|
||||||
|
|
||||||
answered := false
|
answered := false
|
||||||
if id, err := k.ask("ID;"); err == nil && strings.HasPrefix(id, "ID") {
|
if id, err := k.ask("ID;"); err == nil && strings.HasPrefix(id, "ID") {
|
||||||
answered = true
|
answered = true
|
||||||
@@ -140,10 +174,22 @@ func (k *Kenwood) Connect() error {
|
|||||||
}
|
}
|
||||||
if !answered {
|
if !answered {
|
||||||
k.model = ""
|
k.model = ""
|
||||||
if k.host != "" {
|
if k.heard == "" && len(k.rx) > 0 {
|
||||||
return fmt.Errorf("kenwood: %s accepted the connection but the rig is not answering — check that the serial bridge points at the radio and that the radio is switched on", k.host)
|
k.heard = string(k.rx) // an unterminated fragment is evidence too
|
||||||
}
|
}
|
||||||
return fmt.Errorf("kenwood: %s opened but the rig is not answering — check that it is switched on and set to %d baud", k.portName, k.baud)
|
// Distinguish silence from noise. "The rig is not answering" sent an
|
||||||
|
// operator checking the power switch and the baud rate on a radio that was
|
||||||
|
// visibly talking — its frames were arriving, they just did not match what
|
||||||
|
// was asked (wrong baud garbles them; an interface echoing our own
|
||||||
|
// commands back produces the same). Say which of the two it is, and quote
|
||||||
|
// what came back, because that is the fact that decides where to look.
|
||||||
|
if seen := k.heard; seen != "" {
|
||||||
|
return fmt.Errorf("kenwood: %s is sending data but no reply to ID; or IF; — got %q. Check the baud rate (set to %d here) and that nothing else is echoing the port", k.where(), seen, k.baud)
|
||||||
|
}
|
||||||
|
if k.host != "" {
|
||||||
|
return fmt.Errorf("kenwood: %s accepted the connection but the rig sent nothing — check that the serial bridge points at the radio and that the radio is switched on", k.host)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("kenwood: %s opened but the rig sent nothing — check that it is switched on and set to %d baud", k.portName, k.baud)
|
||||||
}
|
}
|
||||||
// Name what was actually connected to. A log reading "connected on @ 0 baud"
|
// Name what was actually connected to. A log reading "connected on @ 0 baud"
|
||||||
// after a network connect is the kind of line that sends someone hunting a
|
// after a network connect is the kind of line that sends someone hunting a
|
||||||
@@ -300,6 +346,23 @@ func (k *Kenwood) write(cmd string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// drain reads and throws away whatever the rig has already sent, until it stays
|
||||||
|
// quiet for one read timeout or the budget runs out.
|
||||||
|
func (k *Kenwood) drain(budget time.Duration) {
|
||||||
|
if k.port == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmp := make([]byte, 256)
|
||||||
|
deadline := time.Now().Add(budget)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
n, err := k.port.Read(tmp)
|
||||||
|
if err != nil || n == 0 {
|
||||||
|
return // an error here is not interesting: we are throwing this away
|
||||||
|
}
|
||||||
|
traceText("kenwood", "RX-drop", string(tmp[:n]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ask sends a query and returns the reply belonging to THAT command. Anything
|
// ask sends a query and returns the reply belonging to THAT command. Anything
|
||||||
// else on the wire is discarded: a stray frame parsed as a frequency reads as
|
// else on the wire is discarded: a stray frame parsed as a frequency reads as
|
||||||
// "lost the rig" to the Manager, which then reconnects — the CAT link dropping
|
// "lost the rig" to the Manager, which then reconnects — the CAT link dropping
|
||||||
@@ -312,25 +375,18 @@ func (k *Kenwood) ask(cmd string) (string, error) {
|
|||||||
if err := k.write(cmd); err != nil {
|
if err := k.write(cmd); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
buf := make([]byte, 0, 64)
|
|
||||||
tmp := make([]byte, 64)
|
tmp := make([]byte, 64)
|
||||||
deadline := time.Now().Add(600 * time.Millisecond)
|
deadline := time.Now().Add(600 * time.Millisecond)
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
n, err := k.port.Read(tmp)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
continue // read timeout — the rig may still be composing its answer
|
|
||||||
}
|
|
||||||
buf = append(buf, tmp[:n]...)
|
|
||||||
for {
|
for {
|
||||||
i := strings.IndexByte(string(buf), ';')
|
// Consume whatever is already buffered BEFORE reading more: the answer
|
||||||
|
// may have arrived attached to the previous one.
|
||||||
|
for {
|
||||||
|
i := bytes.IndexByte(k.rx, ';')
|
||||||
if i < 0 {
|
if i < 0 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
frame := string(buf[:i+1])
|
frame := string(k.rx[:i+1])
|
||||||
buf = buf[i+1:]
|
k.rx = k.rx[i+1:]
|
||||||
traceText("kenwood", "RX", frame)
|
traceText("kenwood", "RX", frame)
|
||||||
if frame == "?;" {
|
if frame == "?;" {
|
||||||
// The rig rejected the command. Remember it so the poll loop stops
|
// The rig rejected the command. Remember it so the poll loop stops
|
||||||
@@ -343,10 +399,26 @@ func (k *Kenwood) ask(cmd string) (string, error) {
|
|||||||
return frame, nil
|
return frame, nil
|
||||||
}
|
}
|
||||||
debugLog.Printf("kenwood: discarding %q while waiting for %s", frame, want)
|
debugLog.Printf("kenwood: discarding %q while waiting for %s", frame, want)
|
||||||
|
// Remember the first unexpected frame: if the whole handshake fails, this
|
||||||
|
// is what tells the operator the radio was talking after all.
|
||||||
|
if k.heard == "" {
|
||||||
|
k.heard = frame
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !time.Now().Before(deadline) {
|
||||||
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
|
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
|
||||||
}
|
}
|
||||||
|
n, err := k.port.Read(tmp)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
k.rx = append(k.rx, tmp[:n]...)
|
||||||
|
}
|
||||||
|
// n == 0 is a read timeout, not silence for good: the rig may still be
|
||||||
|
// composing its answer.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Frame parsing ──────────────────────────────────────────────────────────
|
// ── Frame parsing ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -475,6 +547,10 @@ var kenwoodModels = map[string]string{
|
|||||||
"019": "TS-2000",
|
"019": "TS-2000",
|
||||||
"020": "TS-480",
|
"020": "TS-480",
|
||||||
"021": "TS-590S",
|
"021": "TS-590S",
|
||||||
|
// 022 reported by a real TS-990S in the field. Kenwood's documentation gives
|
||||||
|
// 024 for that radio, so both are kept: the observed value wins where they
|
||||||
|
// disagree, and neither maps to anything else.
|
||||||
|
"022": "TS-990S",
|
||||||
"023": "TS-590SG",
|
"023": "TS-590SG",
|
||||||
"024": "TS-990S",
|
"024": "TS-990S",
|
||||||
"025": "TS-890S",
|
"025": "TS-890S",
|
||||||
@@ -492,7 +568,23 @@ func (k *Kenwood) openPort() (serial.Port, error) {
|
|||||||
}
|
}
|
||||||
return &tcpSerial{conn: c}, nil
|
return &tcpSerial{conn: c}, nil
|
||||||
}
|
}
|
||||||
return serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
|
p, err := serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// The modem lines are LEFT ALONE.
|
||||||
|
//
|
||||||
|
// They were briefly deasserted here, to stop an interface that reads them as
|
||||||
|
// PTT from keying the rig on connect. That silenced radios instead: a TS-990
|
||||||
|
// on COM3 opened fine and answered nothing, because a great many USB-serial
|
||||||
|
// interfaces will not transmit with RTS low — hardware flow control, or an
|
||||||
|
// output stage the line enables. "Opened but the rig sent nothing" was this,
|
||||||
|
// and it arrived as "CAT stopped working after the update".
|
||||||
|
//
|
||||||
|
// The fault that change was written for was a Xiegu G90 behind a DE-19, and
|
||||||
|
// that backend now has an explicit setting for which line keys it. A rig
|
||||||
|
// whose PTT is a CAT command has no business touching DTR or RTS at all.
|
||||||
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// tcpSerial presents a TCP connection as a serial.Port, so the backend has one
|
// tcpSerial presents a TCP connection as a serial.Port, so the backend has one
|
||||||
|
|||||||
@@ -265,8 +265,8 @@ func TestKenwoodSilentRigIsNotConnected(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("a silent port was reported as a connected rig")
|
t.Fatal("a silent port was reported as a connected rig")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "not answering") {
|
if !strings.Contains(err.Error(), "sent nothing") {
|
||||||
t.Errorf("error was %q — it should say the rig is not answering", err)
|
t.Errorf("error was %q — a silent port should be reported as silence", err)
|
||||||
}
|
}
|
||||||
if k.model != "" {
|
if k.model != "" {
|
||||||
t.Errorf("a stale model survived a failed connect: %q", k.model)
|
t.Errorf("a stale model survived a failed connect: %q", k.model)
|
||||||
@@ -355,3 +355,26 @@ func TestKenwoodSplitWhenFRFTRejected(t *testing.T) {
|
|||||||
t.Errorf("IF-reported split broke: split=%v tx=%d rx=%d", s.Split, s.FreqHz, s.RxFreqHz)
|
t.Errorf("IF-reported split broke: split=%v tx=%d rx=%d", s.Split, s.FreqHz, s.RxFreqHz)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A rig that talks but never answers what was asked must not be reported as a
|
||||||
|
// silent one.
|
||||||
|
//
|
||||||
|
// The two faults need opposite responses: silence means the radio is off, the
|
||||||
|
// wrong port, or a dead cable; noise means the baud rate is wrong or something
|
||||||
|
// is echoing the line. "The rig is not answering" sent an operator checking the
|
||||||
|
// power switch on a radio whose frames were visibly arriving.
|
||||||
|
func TestKenwoodNoisyRigIsReportedAsNoiseNotSilence(t *testing.T) {
|
||||||
|
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||||
|
k.dialPort = func() (serial.Port, error) {
|
||||||
|
return &fakeSerial{toRig: &strings.Builder{}, answer: func(cmd string) string {
|
||||||
|
return "XX9999;" // something, but never the reply to ID; or IF;
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
err := k.Connect()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a rig answering gibberish was reported as connected")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "sending data") || !strings.Contains(err.Error(), "XX9999;") {
|
||||||
|
t.Errorf("error was %q — it should say data arrived, and quote it", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ type Xiegu struct {
|
|||||||
// asking every cycle — a Xiegu that has no split must not cost a timeout per
|
// asking every cycle — a Xiegu that has no split must not cost a timeout per
|
||||||
// poll, which would slow the whole loop to a crawl.
|
// poll, which would slow the whole loop to a crawl.
|
||||||
splitSupported bool
|
splitSupported bool
|
||||||
|
// pttLine is "", "rts" or "dtr": which hardware line keys the rig, when the
|
||||||
|
// CI-V command does not.
|
||||||
|
pttLine string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewXiegu(portName string, baud int, addr int, digital string) *Xiegu {
|
func NewXiegu(portName string, baud int, addr int, digital string) *Xiegu {
|
||||||
@@ -74,6 +77,14 @@ func NewXiegu(portName string, baud int, addr int, digital string) *Xiegu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPTTLine selects the hardware line that keys this rig: "rts", "dtr", or ""
|
||||||
|
// for the CI-V command. Set before Connect.
|
||||||
|
func (x *Xiegu) SetPTTLine(line string) {
|
||||||
|
x.mu.Lock()
|
||||||
|
x.pttLine = strings.ToLower(strings.TrimSpace(line))
|
||||||
|
x.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func (x *Xiegu) Name() string { return "xiegu" }
|
func (x *Xiegu) Name() string { return "xiegu" }
|
||||||
|
|
||||||
func (x *Xiegu) Connect() error {
|
func (x *Xiegu) Connect() error {
|
||||||
@@ -87,6 +98,20 @@ func (x *Xiegu) Connect() error {
|
|||||||
return fmt.Errorf("xiegu: open %s @ %d baud: %w", x.portName, x.baud, err)
|
return fmt.Errorf("xiegu: open %s @ %d baud: %w", x.portName, x.baud, err)
|
||||||
}
|
}
|
||||||
p.SetReadTimeout(200 * time.Millisecond)
|
p.SetReadTimeout(200 * time.Millisecond)
|
||||||
|
// Deassert DTR and RTS.
|
||||||
|
//
|
||||||
|
// Windows raises both when a serial port is opened, and a great many
|
||||||
|
// interfaces read them as PTT: a Xiegu G90 behind a DE-19 goes into
|
||||||
|
// transmit the moment OpsLog connects and STAYS there — Xiegu's own
|
||||||
|
// documentation asks for RTS and DTR low. The same applies to an Icom with
|
||||||
|
// USB SEND mapped to a line (which is why the CI-V backend has done this
|
||||||
|
// from the start) and to any rig keyed by a home-made cable.
|
||||||
|
//
|
||||||
|
// PTT on this backend is a CAT command, so neither line should ever be
|
||||||
|
// asserted here. A station keying by RTS/DTR configures that separately,
|
||||||
|
// on its own port.
|
||||||
|
_ = p.SetDTR(false)
|
||||||
|
_ = p.SetRTS(false)
|
||||||
x.port = p
|
x.port = p
|
||||||
x.splitSupported = true
|
x.splitSupported = true
|
||||||
|
|
||||||
@@ -177,12 +202,25 @@ func (x *Xiegu) SetMode(mode string) error {
|
|||||||
return x.send(civ.CmdSetMode, m)
|
return x.send(civ.CmdSetMode, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPTT keys the transmitter, by CI-V or by a hardware line.
|
||||||
|
//
|
||||||
|
// A G90 does not transmit on the CI-V PTT command. Xiegu's own interfaces key
|
||||||
|
// on RTS or DTR instead — the DE-19 does exactly that — which is why the line
|
||||||
|
// can be selected here. Without it, WSJT-X talking to OpsLog's rigctld server
|
||||||
|
// decoded perfectly and never transmitted: the command left, the radio ignored
|
||||||
|
// it, and nothing in the chain was wrong enough to complain.
|
||||||
func (x *Xiegu) SetPTT(on bool) error {
|
func (x *Xiegu) SetPTT(on bool) error {
|
||||||
x.mu.Lock()
|
x.mu.Lock()
|
||||||
defer x.mu.Unlock()
|
defer x.mu.Unlock()
|
||||||
if x.port == nil {
|
if x.port == nil {
|
||||||
return fmt.Errorf("xiegu: not connected")
|
return fmt.Errorf("xiegu: not connected")
|
||||||
}
|
}
|
||||||
|
switch x.pttLine {
|
||||||
|
case "rts":
|
||||||
|
return x.port.SetRTS(on)
|
||||||
|
case "dtr":
|
||||||
|
return x.port.SetDTR(on)
|
||||||
|
}
|
||||||
v := byte(0x00)
|
v := byte(0x00)
|
||||||
if on {
|
if on {
|
||||||
v = 0x01
|
v = 0x01
|
||||||
|
|||||||
@@ -150,6 +150,12 @@ func (y *Yaesu) Connect() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err)
|
return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err)
|
||||||
}
|
}
|
||||||
|
// The modem lines are LEFT ALONE — see the same note in kenwood.go.
|
||||||
|
//
|
||||||
|
// Deasserting them to keep an interface from keying the rig silenced radios
|
||||||
|
// instead: many USB-serial interfaces will not transmit with RTS low. The
|
||||||
|
// Xiegu fault that change was written for is handled in that backend, which
|
||||||
|
// now has an explicit setting for which line keys the rig.
|
||||||
p.SetReadTimeout(300 * time.Millisecond)
|
p.SetReadTimeout(300 * time.Millisecond)
|
||||||
y.port = p
|
y.port = p
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ package cluster
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -346,12 +347,29 @@ func (s *session) run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// idleTick is how often the read is interrupted so the loop can notice a stop
|
||||||
|
// request. It is NOT a liveness test — see the read loop.
|
||||||
|
//
|
||||||
|
// A var, not a const, so the test that proves a silent node survives can shorten
|
||||||
|
// it: at 30 s that test spends a minute doing nothing, and a slow test is a test
|
||||||
|
// that gets skipped.
|
||||||
|
var idleTick = 30 * time.Second
|
||||||
|
|
||||||
|
// quietNotice is the silence after which the log says so, once.
|
||||||
|
const quietNotice = 10 * time.Minute
|
||||||
|
|
||||||
// runOnce dials, optionally logs in, sends init commands, parses spots.
|
// runOnce dials, optionally logs in, sends init commands, parses spots.
|
||||||
// Returns the moment we marked the link "connected" (zero if dial failed)
|
// Returns the moment we marked the link "connected" (zero if dial failed)
|
||||||
// and the error that ended the session (nil if stopCh).
|
// and the error that ended the session (nil if stopCh).
|
||||||
func (s *session) runOnce() (time.Time, error) {
|
func (s *session) runOnce() (time.Time, error) {
|
||||||
addr := net.JoinHostPort(s.cfg.Host, fmt.Sprintf("%d", s.cfg.Port)) // IPv6-safe
|
addr := net.JoinHostPort(s.cfg.Host, fmt.Sprintf("%d", s.cfg.Port)) // IPv6-safe
|
||||||
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
// KeepAlive is set explicitly rather than left to the package default: it is
|
||||||
|
// what actually detects a dead peer here, so it should be visible in the
|
||||||
|
// code that depends on it. The OS probes an idle connection and a genuine
|
||||||
|
// failure surfaces as an error on Read — which is the only thing that ends
|
||||||
|
// a session below.
|
||||||
|
d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||||||
|
conn, err := d.Dial("tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return time.Time{}, fmt.Errorf("dial %s: %w", addr, err)
|
return time.Time{}, fmt.Errorf("dial %s: %w", addr, err)
|
||||||
}
|
}
|
||||||
@@ -423,6 +441,8 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var connectedAt time.Time
|
var connectedAt time.Time
|
||||||
|
var quiet time.Duration // how long the node has said nothing
|
||||||
|
var pending string // a line cut in half by a read deadline
|
||||||
rd := bufio.NewReader(conn)
|
rd := bufio.NewReader(conn)
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -431,11 +451,37 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(120 * time.Second))
|
// The deadline exists to keep this loop responsive to stopCh, NOT to
|
||||||
line, err := rd.ReadString('\n')
|
// judge the link. A quiet node is a quiet node: some clusters send
|
||||||
|
// nothing for minutes on a dead band, and tearing the socket down over
|
||||||
|
// it produced a reconnect every two minutes — which loses the login,
|
||||||
|
// the filters, and any spot that arrived during the gap.
|
||||||
|
//
|
||||||
|
// Only a REAL error ends the session. A dead peer still gets caught:
|
||||||
|
// TCP keepalive probes an idle connection and its failure arrives here
|
||||||
|
// as an error, not as a timeout.
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(idleTick))
|
||||||
|
chunk, err := rd.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
var ne net.Error
|
||||||
|
if errors.As(err, &ne) && ne.Timeout() {
|
||||||
|
// ReadString hands back what it HAD read along with the timeout.
|
||||||
|
// Keep it: a spot line straddling the deadline would otherwise lose
|
||||||
|
// its first half and arrive as nonsense, or vanish entirely.
|
||||||
|
pending += chunk
|
||||||
|
quiet += idleTick
|
||||||
|
// Said once at the first long silence, so a genuinely mute node is
|
||||||
|
// visible without a line every tick.
|
||||||
|
if quiet == quietNotice {
|
||||||
|
applog.Printf("cluster[%s] no traffic for %s — still connected", s.cfg.Name, quiet)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
return connectedAt, fmt.Errorf("read: %w", err)
|
return connectedAt, fmt.Errorf("read: %w", err)
|
||||||
}
|
}
|
||||||
|
quiet = 0
|
||||||
|
line := pending + chunk
|
||||||
|
pending = ""
|
||||||
line = strings.TrimRight(line, "\r\n")
|
line = strings.TrimRight(line, "\r\n")
|
||||||
// Strip terminal BELLs (\a) and other control bytes that some nodes
|
// Strip terminal BELLs (\a) and other control bytes that some nodes
|
||||||
// (e.g. F5LEN) append to spot lines — "DX de …0935Z KN04\a\a" — which
|
// (e.g. F5LEN) append to spot lines — "DX de …0935Z KN04\a\a" — which
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A quiet cluster must not be treated as a broken one.
|
||||||
|
//
|
||||||
|
// Reported by an operator whose node sends little: nothing for two minutes and
|
||||||
|
// OpsLog reconnected — losing the login, the filters, and any spot that landed
|
||||||
|
// during the gap. The read deadline was being read as a verdict on the link
|
||||||
|
// rather than as a way to stay responsive to a stop request.
|
||||||
|
//
|
||||||
|
// The test holds the connection open, silent for longer than one deadline, then
|
||||||
|
// sends a spot. A session that survives receives it; one that reconnects does
|
||||||
|
// not, because this listener never accepts twice.
|
||||||
|
func TestSilenceDoesNotEndTheSession(t *testing.T) {
|
||||||
|
// Shorten the tick so the test exercises the real code path in seconds.
|
||||||
|
defer func(orig time.Duration) { idleTick = orig }(idleTick)
|
||||||
|
idleTick = time.Second
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
accepted := make(chan net.Conn, 2)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
c, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accepted <- c
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
host, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
||||||
|
port, err := strconv.Atoi(portStr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
spots := make(chan Spot, 4)
|
||||||
|
s := &session{
|
||||||
|
cfg: ServerConfig{Name: "quiet", Host: host, Port: port},
|
||||||
|
onSpot: func(sp Spot) { spots <- sp },
|
||||||
|
onLine: func(Line) {},
|
||||||
|
onStatus: func() {},
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { _, err := s.runOnce(); done <- err }()
|
||||||
|
|
||||||
|
conn := <-accepted
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Silent for longer than one deadline: the loop must ride through it.
|
||||||
|
time.Sleep(3 * idleTick)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case c := <-accepted:
|
||||||
|
c.Close()
|
||||||
|
t.Fatal("the session reconnected during the silence")
|
||||||
|
case err := <-done:
|
||||||
|
t.Fatalf("the session ended during the silence: %v", err)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now a spot: it proves the ORIGINAL connection is still the live one.
|
||||||
|
if _, err := conn.Write([]byte("DX de F4BPO: 14074.0 OY1CT FT8 1234Z\r\n")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case sp := <-spots:
|
||||||
|
if sp.DXCall != "OY1CT" {
|
||||||
|
t.Errorf("spot from the wrong station: %+v", sp)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("the spot sent after the silence never arrived")
|
||||||
|
}
|
||||||
|
|
||||||
|
close(s.stopCh)
|
||||||
|
<-done
|
||||||
|
}
|
||||||
@@ -125,6 +125,10 @@ type Decoder struct {
|
|||||||
pendHops int // consecutive hops the raw state has disagreed
|
pendHops int // consecutive hops the raw state has disagreed
|
||||||
|
|
||||||
// Two-cluster element timing (ms).
|
// Two-cluster element timing (ms).
|
||||||
|
// muLetterGap is this operator's typical gap BETWEEN LETTERS (ms), so the
|
||||||
|
// word boundary can follow a wide fist instead of chopping up callsigns.
|
||||||
|
// Zero until enough gaps have been seen.
|
||||||
|
muLetterGap float64
|
||||||
muDit, muDah float64
|
muDit, muDah float64
|
||||||
marksSeen int
|
marksSeen int
|
||||||
|
|
||||||
@@ -195,6 +199,19 @@ const (
|
|||||||
|
|
||||||
charGapDits = 2.2 // gap > this ⇒ character boundary (geom. mean of 1 & 3 ≈ 1.7, plus margin for sloppy fists)
|
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)
|
wordGapDits = 4.6 // gap > this ⇒ word boundary (geom. mean of 3 & 7)
|
||||||
|
|
||||||
|
// newOverFloorMs is the shortest silence that may end an over — see newOverMs.
|
||||||
|
// Past it the speed estimate is dropped: the next voice on the frequency is
|
||||||
|
// probably somebody else, and the last operator's speed is not evidence
|
||||||
|
// about them. Two seconds is far longer than any word gap (7 dits is 0.42 s
|
||||||
|
// even at 10 wpm) and far shorter than a pause between overs.
|
||||||
|
newOverFloorMs = 600.0
|
||||||
|
|
||||||
|
// wordGapRatio places the word boundary relative to the letter gaps this
|
||||||
|
// operator ACTUALLY sends, for fists whose letter spacing runs wide. The
|
||||||
|
// fixed 4.6-dit rule turned a 5-dit letter gap into a word, so a callsign
|
||||||
|
// arrived as "O Y 1 C T" — which is worse than two words run together.
|
||||||
|
wordGapRatio = 1.6
|
||||||
)
|
)
|
||||||
|
|
||||||
// New builds a decoder for the given sample rate. onChar receives decoded text
|
// New builds a decoder for the given sample rate. onChar receives decoded text
|
||||||
@@ -260,6 +277,7 @@ func (d *Decoder) Reset() {
|
|||||||
d.stableHops, d.pendHops = 0, 0
|
d.stableHops, d.pendHops = 0, 0
|
||||||
d.bankTick, d.betterHops = 0, 0
|
d.bankTick, d.betterHops = 0, 0
|
||||||
d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0
|
d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0
|
||||||
|
d.muLetterGap = 0
|
||||||
d.elemMs = d.elemMs[:0]
|
d.elemMs = d.elemMs[:0]
|
||||||
d.charEmitted, d.wordEmitted, d.textSince = true, true, false
|
d.charEmitted, d.wordEmitted, d.textSince = true, true, false
|
||||||
}
|
}
|
||||||
@@ -578,6 +596,30 @@ func (d *Decoder) endMark(hops int) {
|
|||||||
// steadier than dits in hand keying).
|
// steadier than dits in hand keying).
|
||||||
func (d *Decoder) endSpace(hops int) {
|
func (d *Decoder) endSpace(hops int) {
|
||||||
ms := float64(hops)*d.hopMs + d.biasMs // gaps shrink by what marks gained
|
ms := float64(hops)*d.hopMs + d.biasMs // gaps shrink by what marks gained
|
||||||
|
|
||||||
|
// A long silence ends the over — see newOverMs. Forget the speed: coming back at 14 wpm
|
||||||
|
// after following someone at 32, every element of the newcomer measured
|
||||||
|
// longer than the stale dah threshold and the first words decoded as a run
|
||||||
|
// of T's. Starting from the seed instead, the estimate re-converges within a
|
||||||
|
// character — which is exactly how a freshly started decoder behaves, and
|
||||||
|
// that case was always fine.
|
||||||
|
if d.marksSeen > 0 && ms > d.newOverMs() {
|
||||||
|
d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0
|
||||||
|
d.muLetterGap = 0
|
||||||
|
d.elemMs = d.elemMs[:0]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letter gaps: everything between a character boundary and a word boundary.
|
||||||
|
// Kept so the word boundary can follow this operator's own spacing.
|
||||||
|
if d.marksSeen > 0 && ms > charGapDits*d.muDit && ms < wordGapDits*d.muDit*2 {
|
||||||
|
if d.muLetterGap == 0 {
|
||||||
|
d.muLetterGap = ms
|
||||||
|
} else {
|
||||||
|
d.muLetterGap += (ms - d.muLetterGap) * 0.2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if d.marksSeen < 1 || ms < 0.35*d.muDit || ms > 1.7*d.muDit {
|
if d.marksSeen < 1 || ms < 0.35*d.muDit || ms > 1.7*d.muDit {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -592,6 +634,40 @@ func (d *Decoder) endSpace(hops int) {
|
|||||||
d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs)
|
d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newOverMs is the silence above which the speed estimate is dropped.
|
||||||
|
//
|
||||||
|
// It cannot be a fixed duration: 0.75 s is a quick turnaround at 30 wpm but is
|
||||||
|
// barely a word gap at 10 wpm (7 dits = 0.84 s). So it scales with the current
|
||||||
|
// estimate, with a floor so a fast operator pausing to think is not mistaken
|
||||||
|
// for a new station on every hesitation.
|
||||||
|
func (d *Decoder) newOverMs() float64 {
|
||||||
|
if v := 12 * d.muDit; v > newOverFloorMs {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return newOverFloorMs
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordGapMs is the silence above which a word boundary is declared.
|
||||||
|
//
|
||||||
|
// The textbook answer is 4.6 dits — the geometric mean of a 3-dit letter gap
|
||||||
|
// and a 7-dit word gap. It assumes the operator sends textbook spacing. Many do
|
||||||
|
// not: a fist that leaves 5 dits between letters had every letter turned into a
|
||||||
|
// word, so callsigns arrived in pieces.
|
||||||
|
//
|
||||||
|
// So the boundary also follows the letter gaps actually observed. Whichever is
|
||||||
|
// larger wins: a textbook fist keeps the textbook boundary, a wide one gets a
|
||||||
|
// wider boundary. The cost is that a wide sender's words may run together —
|
||||||
|
// which is a far smaller price than a callsign broken into letters.
|
||||||
|
func (d *Decoder) wordGapMs() float64 {
|
||||||
|
fixed := wordGapDits * d.muDit
|
||||||
|
if d.muLetterGap > 0 {
|
||||||
|
if adaptive := wordGapRatio * d.muLetterGap; adaptive > fixed {
|
||||||
|
return adaptive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fixed
|
||||||
|
}
|
||||||
|
|
||||||
// spaceProgress emits the pending character / word space LIVE once the current
|
// spaceProgress emits the pending character / word space LIVE once the current
|
||||||
// gap crosses each boundary (instead of waiting for the next mark), so text
|
// gap crosses each boundary (instead of waiting for the next mark), so text
|
||||||
// appears as it is sent.
|
// appears as it is sent.
|
||||||
@@ -601,7 +677,7 @@ func (d *Decoder) spaceProgress() {
|
|||||||
d.flushChar()
|
d.flushChar()
|
||||||
d.charEmitted = true
|
d.charEmitted = true
|
||||||
}
|
}
|
||||||
if !d.wordEmitted && gapMs > wordGapDits*d.muDit {
|
if !d.wordEmitted && gapMs > d.wordGapMs() {
|
||||||
if d.textSince && d.onChar != nil {
|
if d.textSince && d.onChar != nil {
|
||||||
d.onChar(" ")
|
d.onChar(" ")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package cwdecode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// keyLoose keys a message with SLIGHTLY WIDE letter spacing — the way a great
|
||||||
|
// many operators actually send. letterGapDits replaces the standard 3.
|
||||||
|
//
|
||||||
|
// Same envelope shaping as keyMessage: hard edges would make an easier signal
|
||||||
|
// than anything on the air, and would prove nothing.
|
||||||
|
func keyLoose(msg string, fs, wpm int, pitch, amp float64, letterGapDits float64) []int16 {
|
||||||
|
dot := fs * 1200 / (wpm * 1000)
|
||||||
|
edge := fs * 5 / 1000
|
||||||
|
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)
|
||||||
|
phase += dphi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
silence(fs / 4)
|
||||||
|
for i := 0; i < len(msg); i++ {
|
||||||
|
ch := msg[i]
|
||||||
|
if ch == ' ' {
|
||||||
|
silence(4 * dot)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
code := c2m[ch]
|
||||||
|
for j := 0; j < len(code); j++ {
|
||||||
|
if code[j] == '.' {
|
||||||
|
tone(dot)
|
||||||
|
} else {
|
||||||
|
tone(3 * dot)
|
||||||
|
}
|
||||||
|
silence(dot)
|
||||||
|
}
|
||||||
|
// The element gap above already contributes one dit.
|
||||||
|
silence(int((letterGapDits - 1) * float64(dot)))
|
||||||
|
}
|
||||||
|
silence(fs / 2)
|
||||||
|
return toInt16(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first character of an over must decode, not arrive as "?".
|
||||||
|
//
|
||||||
|
// Reported on the air: "the first letter or digit often turns into ?". Each
|
||||||
|
// element is classified against the running dit estimate, and at the start of
|
||||||
|
// an over that estimate belongs to whatever was decoded last — a different
|
||||||
|
// operator, at a different speed. The first character pays for it.
|
||||||
|
func TestFirstCharacterOfAnOver(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
for _, wpm := range []int{15, 22, 30} {
|
||||||
|
got := decode(t, keyMessage("F4BPO DE OY1CT K", fs, wpm, 700, 9000), 0)
|
||||||
|
if strings.HasPrefix(got, "?") {
|
||||||
|
t.Errorf("@%d wpm: decoded %q — the first character was lost", wpm, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A callsign must not be broken up when the sender's letter spacing is a little
|
||||||
|
// wide.
|
||||||
|
//
|
||||||
|
// Reported on the air: "sometimes there are spaces inside the call". A word
|
||||||
|
// boundary is declared above 4.6 dits of silence, so an operator whose letter
|
||||||
|
// gaps run to 4.5 dits has every letter turned into a word of its own.
|
||||||
|
func TestWideLetterSpacingDoesNotSplitTheCall(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
for _, gap := range []float64{3.5, 4.0, 4.5} {
|
||||||
|
got := decode(t, keyLoose("DE OY1CT K", fs, 20, 700, 9000, gap), 0)
|
||||||
|
for _, split := range []string{"O Y", "Y 1", "1 C", "C T"} {
|
||||||
|
if strings.Contains(got, split) {
|
||||||
|
t.Errorf("letter gap %.1f dits: decoded %q — the callsign was broken at %q", gap, got, split)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeSeq runs several transmissions through the SAME decoder, as happens on
|
||||||
|
// the air: the estimate carried into an over is whatever the previous station
|
||||||
|
// left behind.
|
||||||
|
func decodeSeq(t *testing.T, parts ...[]int16) []string {
|
||||||
|
t.Helper()
|
||||||
|
var cur strings.Builder
|
||||||
|
d := New(16000, func(s string) { cur.WriteString(s) }, nil)
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
cur.Reset()
|
||||||
|
for i := 0; i < len(p); i += 256 {
|
||||||
|
end := i + 256
|
||||||
|
if end > len(p) {
|
||||||
|
end = len(p)
|
||||||
|
}
|
||||||
|
d.Process(p[i:end])
|
||||||
|
}
|
||||||
|
out = append(out, strings.ToUpper(cur.String()))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fast station, then a slow one — the real reason the first character is
|
||||||
|
// lost. A fresh decoder starts from a neutral estimate and adapts within a
|
||||||
|
// character or two; a decoder that has just followed someone at 30 wpm judges
|
||||||
|
// the newcomer's first dits against 30 wpm.
|
||||||
|
func TestFirstCharacterAfterASpeedChange(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
got := decodeSeq(t,
|
||||||
|
keyMessage("CQ CQ DE DL1ABC K", fs, 32, 700, 9000),
|
||||||
|
keyMessage("DL1ABC DE OY1CT K", fs, 14, 700, 9000),
|
||||||
|
)
|
||||||
|
if strings.HasPrefix(got[1], "?") {
|
||||||
|
t.Errorf("after 32→14 wpm: %q — the first character of the reply was lost", got[1])
|
||||||
|
}
|
||||||
|
if !strings.Contains(got[1], "OY1CT") {
|
||||||
|
t.Errorf("after 32→14 wpm: %q — want the callsign intact", got[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand-sent letter gaps run wide. Above 4.6 dits every letter becomes a word,
|
||||||
|
// so a callsign arrives in pieces.
|
||||||
|
func TestVeryWideLetterSpacing(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
for _, gap := range []float64{5.0, 5.5, 6.0} {
|
||||||
|
got := decode(t, keyLoose("DE OY1CT K", fs, 18, 700, 9000, gap), 0)
|
||||||
|
if strings.Contains(got, "O Y") || strings.Contains(got, "Y 1") || strings.Contains(got, "1 C") {
|
||||||
|
t.Errorf("letter gap %.1f dits: decoded %q — the callsign was broken up", gap, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2061,6 +2061,41 @@ func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty stri
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountyWorked reports whether one US county has already been worked.
|
||||||
|
//
|
||||||
|
// It exists so the entry panel can flag a new county without loading the whole
|
||||||
|
// worked-county set on every keystroke — the county arrives from a QRZ lookup,
|
||||||
|
// which is exactly when the operator is deciding whether the contact is worth
|
||||||
|
// chasing. The comparison goes through keyFn on both sides (rather than a SQL
|
||||||
|
// equality) because logged county spellings vary — "Los Angeles" against
|
||||||
|
// "LOS ANGELES, CA" — and the key function is what already resolves that
|
||||||
|
// everywhere else.
|
||||||
|
func (r *Repo) CountyWorked(ctx context.Context, state, cnty string, keyFn func(state, cnty string) string) (bool, error) {
|
||||||
|
want := keyFn(state, cnty)
|
||||||
|
if want == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
// Narrowed to the one state: a few dozen rows, not the whole logbook.
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT DISTINCT COALESCE(state,''), COALESCE(cnty,'') FROM qso
|
||||||
|
WHERE dxcc IN (291,110,6) AND cnty IS NOT NULL AND cnty != ''
|
||||||
|
AND upper(COALESCE(state,'')) = upper(?)`, strings.TrimSpace(state))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var st, cn string
|
||||||
|
if err := rows.Scan(&st, &cn); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if keyFn(st, cn) == want {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// WorkedCallBandModeKeys returns the set of every worked "CALL|BAND|MODE" key
|
// WorkedCallBandModeKeys returns the set of every worked "CALL|BAND|MODE" key
|
||||||
// (all upper-cased), loaded in one pass. It backs the in-memory worked-index the
|
// (all upper-cased), loaded in one pass. It backs the in-memory worked-index the
|
||||||
// alert engine checks per cluster spot — a DB query per spot cannot keep up with
|
// alert engine checks per cluster spot — a DB query per spot cannot keep up with
|
||||||
@@ -2506,6 +2541,23 @@ func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) erro
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClearQRZConfirmed takes back a QRZ confirmation.
|
||||||
|
//
|
||||||
|
// Needed because OpsLog set some wrongly: it read qrzcom_qso_download_status,
|
||||||
|
// which QRZ puts on everything it hands back, as QRZ's confirmation. Those
|
||||||
|
// QSOs count towards award slots, so leaving them green until the operator
|
||||||
|
// notices is not an option — and nothing else in the app would ever undo them.
|
||||||
|
func (r *Repo) ClearQRZConfirmed(ctx context.Context, id int64) error {
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
`UPDATE qso SET qrzcom_qso_download_status = 'N', qrzcom_qso_download_date = NULL,
|
||||||
|
updated_at = ? WHERE id = ?`,
|
||||||
|
db.NowISO(), id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("clear qrz confirmed %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// MarkEQSLConfirmed stamps EQSL_QSL_RCVD=Y and the received date on a QSO after
|
// MarkEQSLConfirmed stamps EQSL_QSL_RCVD=Y and the received date on a QSO after
|
||||||
// an eQSL Inbox download. date is an ADIF YYYYMMDD string.
|
// an eQSL Inbox download. date is an ADIF YYYYMMDD string.
|
||||||
func (r *Repo) MarkEQSLConfirmed(ctx context.Context, id int64, date string) error {
|
func (r *Repo) MarkEQSLConfirmed(ctx context.Context, id int64, date string) error {
|
||||||
|
|||||||
+24
-7
@@ -6,12 +6,22 @@ import (
|
|||||||
"hamlog/internal/adif"
|
"hamlog/internal/adif"
|
||||||
)
|
)
|
||||||
|
|
||||||
// What counts as a QRZ.com confirmation.
|
// What counts as a QRZ.com confirmation: app_qrzlog_status = C, and nothing else.
|
||||||
//
|
//
|
||||||
// Reported 2026-07-29: after a confirmation download, every QSO turned to Y. The
|
// Narrowed twice, each time on evidence from an operator's own log.
|
||||||
// test accepted qsl_rcvd = Y — but that is the operator's own PAPER QSL flag,
|
//
|
||||||
// which they uploaded to QRZ themselves, so every QSO with a card came back
|
// 2026-07-29 — qsl_rcvd = Y was accepted. That is the operator's own PAPER QSL
|
||||||
// looking QRZ-confirmed. On a log full of paper QSLs that is most of it.
|
// flag, which they uploaded to QRZ themselves, so every QSO with a card came
|
||||||
|
// back looking QRZ-confirmed.
|
||||||
|
//
|
||||||
|
// 2026-07-31 — qrzcom_qso_download_status = Y was accepted, and this test said
|
||||||
|
// it should be. A fetch showed both fields on ONE record:
|
||||||
|
//
|
||||||
|
// <app_qrzlog_status:1>N <qrzcom_qso_download_status:1>Y
|
||||||
|
//
|
||||||
|
// QRZ says not confirmed and sets the download field anyway: it marks what was
|
||||||
|
// handed back, not what was confirmed. Eighteen QSOs, all "UPDATED", all green,
|
||||||
|
// none of them confirmed on QRZ.
|
||||||
//
|
//
|
||||||
// This status feeds the award slots, so a false Y is a QSO counted as confirmed
|
// This status feeds the award slots, so a false Y is a QSO counted as confirmed
|
||||||
// when it is not — the reason the rule is narrow rather than generous.
|
// when it is not — the reason the rule is narrow rather than generous.
|
||||||
@@ -21,8 +31,7 @@ func TestQRZRecordConfirmed(t *testing.T) {
|
|||||||
rec adif.Record
|
rec adif.Record
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
// QRZ's own statements.
|
// QRZ's own statement.
|
||||||
{"QRZ download status Y", adif.Record{"qrzcom_qso_download_status": "Y"}, true},
|
|
||||||
{"QRZ log status C", adif.Record{"app_qrzlog_status": "C"}, true},
|
{"QRZ log status C", adif.Record{"app_qrzlog_status": "C"}, true},
|
||||||
{"lower case is still QRZ's answer", adif.Record{"app_qrzlog_status": "c"}, true},
|
{"lower case is still QRZ's answer", adif.Record{"app_qrzlog_status": "c"}, true},
|
||||||
{"padded", adif.Record{"app_qrzlog_status": " C "}, true},
|
{"padded", adif.Record{"app_qrzlog_status": " C "}, true},
|
||||||
@@ -33,6 +42,14 @@ func TestQRZRecordConfirmed(t *testing.T) {
|
|||||||
{"LoTW confirmed, QRZ silent", adif.Record{"lotw_qsl_rcvd": "Y"}, false},
|
{"LoTW confirmed, QRZ silent", adif.Record{"lotw_qsl_rcvd": "Y"}, false},
|
||||||
{"eQSL confirmed, QRZ silent", adif.Record{"eqsl_qsl_rcvd": "Y"}, false},
|
{"eQSL confirmed, QRZ silent", adif.Record{"eqsl_qsl_rcvd": "Y"}, false},
|
||||||
|
|
||||||
|
// The download flag is not an answer: QRZ sets it on everything it returns.
|
||||||
|
{"downloaded, not confirmed", adif.Record{"qrzcom_qso_download_status": "Y"}, false},
|
||||||
|
{"the record from the report", adif.Record{
|
||||||
|
"call": "IQ4J", "band": "17m", "mode": "FT8",
|
||||||
|
"qrzcom_qso_upload_status": "Y", "app_qrzlog_status": "N",
|
||||||
|
"qrzcom_qso_download_date": "20260731", "qrzcom_qso_download_status": "Y",
|
||||||
|
}, false},
|
||||||
|
|
||||||
// Explicit negatives and nothing at all.
|
// Explicit negatives and nothing at all.
|
||||||
{"QRZ says no", adif.Record{"qrzcom_qso_download_status": "N"}, false},
|
{"QRZ says no", adif.Record{"qrzcom_qso_download_status": "N"}, false},
|
||||||
{"QRZ status not confirmed", adif.Record{"app_qrzlog_status": "N"}, false},
|
{"QRZ status not confirmed", adif.Record{"app_qrzlog_status": "N"}, false},
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Which modes produce a recording worth keeping.
|
||||||
|
//
|
||||||
|
// CW has moved in and out of this list: it was excluded because SmartSDR does
|
||||||
|
// not route the operator's own sidetone through DAX, then restored because the
|
||||||
|
// other station is captured all the same. Pinned so it does not drift back.
|
||||||
|
func TestRecordableMode(t *testing.T) {
|
||||||
|
for _, m := range []string{"SSB", "USB", "LSB", "AM", "FM", "DV", "CW", "cw", " CW "} {
|
||||||
|
if !recordableMode(m) {
|
||||||
|
t.Errorf("recordableMode(%q) = false — it should be recorded", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Digital audio is a modem tone nobody replays.
|
||||||
|
for _, m := range []string{"FT8", "FT4", "RTTY", "PSK31", "JT65", "", "DIGU"} {
|
||||||
|
if recordableMode(m) {
|
||||||
|
t.Errorf("recordableMode(%q) = true — digital modes carry no useful audio", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.22.3"
|
appVersion = "0.22.8"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user