Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4afd7dda90 | ||
|
|
ed930667a1 | ||
|
|
311479c52f | ||
|
|
b25efabab8 | ||
|
|
a93f52d2b9 | ||
|
|
c9f7279a01 | ||
|
|
2560fced87 | ||
|
|
1718bf6f33 | ||
|
|
c4c5db3921 | ||
|
|
a1ceea978b | ||
|
|
f4956a63bb | ||
|
|
5a4ad800b3 | ||
|
|
e152ef0ee0 | ||
|
|
b6465ddc9d | ||
|
|
961357474d | ||
|
|
702ca4f0c9 | ||
|
|
93b4964b3b | ||
|
|
14c57035e1 | ||
|
|
7a7fa62af7 | ||
|
|
6ee4c430c7 | ||
|
|
0df6370c59 | ||
|
|
fa94657619 | ||
|
|
f5b74856c6 | ||
|
|
d42fdab85c | ||
|
|
83a8708d60 | ||
|
|
c0671bfe2e | ||
|
|
ec0cb95de9 | ||
|
|
9d691343cf | ||
|
|
c6088bb2de | ||
|
|
069728ac68 | ||
|
|
f221c0ee1e | ||
|
|
013492adb0 | ||
|
|
3a87b58ba3 | ||
|
|
e6b8a17772 | ||
|
|
802c6e8c43 | ||
|
|
f82631690a | ||
|
|
3b58e39eec | ||
|
|
45e3f3edb8 | ||
|
|
c931d8a762 | ||
|
|
eb271e8f20 | ||
|
|
e2d2485703 |
@@ -116,6 +116,7 @@ const (
|
||||
keyCATXieguAddr = "cat.xiegu.addr" // Xiegu CI-V address (factory 0x70)
|
||||
keyCATYaesuPort = "cat.yaesu.port" // Yaesu CAT serial port (e.g. COM4)
|
||||
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"
|
||||
keyCATKenwoodPort = "cat.kenwood.port" // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
|
||||
keyCATKenwoodBaud = "cat.kenwood.baud" // Kenwood CAT baud (TS-590 default 9600, TS-890 115200)
|
||||
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
|
||||
@@ -271,7 +272,10 @@ const (
|
||||
|
||||
// External services (logbook upload). QRZ.com first; Clublog / LoTW
|
||||
// will add their own keys under the same extsvc.* prefix.
|
||||
keyExtQRZAPIKey = "extsvc.qrz.api_key"
|
||||
keyExtQRZAPIKey = "extsvc.qrz.api_key"
|
||||
// Opt-in, and off by default: withdrawing a QSO from QRZ or Club Log cannot
|
||||
// be undone, and a local delete is not always meant to reach the world.
|
||||
keyExtDeleteRemote = "extsvc.delete_remote" // also delete from QRZ/Club Log when a QSO is deleted
|
||||
keyExtQRZForceCall = "extsvc.qrz.force_station_callsign"
|
||||
keyExtQRZAutoUpload = "extsvc.qrz.auto_upload"
|
||||
keyExtQRZUploadMode = "extsvc.qrz.upload_mode"
|
||||
@@ -359,23 +363,25 @@ type CATSettings struct {
|
||||
XieguAddr int `json:"xiegu_addr"` // Xiegu CI-V address (factory 0x70)
|
||||
YaesuPort string `json:"yaesu_port"` // Yaesu CAT serial port (e.g. COM4)
|
||||
YaesuBaud int `json:"yaesu_baud"` // Yaesu CAT baud (FTDX10/101 default 38400)
|
||||
KenwoodPort string `json:"kenwood_port"` // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
|
||||
KenwoodBaud int `json:"kenwood_baud"` // Kenwood CAT baud (TS-590 default 9600)
|
||||
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
|
||||
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
|
||||
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
|
||||
IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server)
|
||||
IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID
|
||||
IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password
|
||||
IcomNetAudio bool `json:"icom_net_audio"` // Icom network: stream RX audio (50003) — experimental, needs on-rig verification
|
||||
TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR)
|
||||
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
|
||||
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
|
||||
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
|
||||
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
|
||||
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
|
||||
ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs (Hamlib NET rigctl)
|
||||
SharePort int `json:"share_port"` // TCP port for it (default 4532)
|
||||
KenwoodHost string `json:"kenwood_host"` // "host:port" of a serial-over-network bridge (ser2net, Ethernet-serial
|
||||
// adapter). NOT the radio’s own RJ45, which speaks Kenwood’s KNS/ARCP.
|
||||
KenwoodPort string `json:"kenwood_port"` // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
|
||||
KenwoodBaud int `json:"kenwood_baud"` // Kenwood CAT baud (TS-590 default 9600)
|
||||
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
|
||||
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
|
||||
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
|
||||
IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server)
|
||||
IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID
|
||||
IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password
|
||||
IcomNetAudio bool `json:"icom_net_audio"` // Icom network: stream RX audio (50003) — experimental, needs on-rig verification
|
||||
TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR)
|
||||
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
|
||||
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
|
||||
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
|
||||
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
|
||||
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
|
||||
ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs (Hamlib NET rigctl)
|
||||
SharePort int `json:"share_port"` // TCP port for it (default 4532)
|
||||
}
|
||||
|
||||
// ModePreset is a mode entry with default RST values to auto-populate
|
||||
@@ -535,10 +541,15 @@ type App struct {
|
||||
ampsMu sync.Mutex // guards ampInsts
|
||||
ampInsts map[string]*ampInst // one running client per enabled configured amplifier, by config ID
|
||||
audioMgr *audio.Manager
|
||||
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
||||
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
|
||||
lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign)
|
||||
scp *scp.Manager // Super Check Partial / N+1 callsign master list
|
||||
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
||||
// qsoRecManual marks a take the operator started by hand while automatic
|
||||
// recording is OFF. Such a take opened the sound devices itself, so it must
|
||||
// close them again when it ends — an operator who records one contact does
|
||||
// not expect their microphone held open for the rest of the session.
|
||||
qsoRecManual atomic.Bool
|
||||
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
|
||||
lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign)
|
||||
scp *scp.Manager // Super Check Partial / N+1 callsign master list
|
||||
|
||||
// NET Control: persistent net definitions/rosters (global JSON) + the live
|
||||
// session (in-memory only — active stations currently in QSO).
|
||||
@@ -833,7 +844,11 @@ func (a *App) startup(ctx context.Context) {
|
||||
// Route CAT/OmniRig debug lines into the unified app log (they used to go
|
||||
// to a separate cat.log in the old HamLog folder, which users couldn't find).
|
||||
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
|
||||
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
|
||||
@@ -5505,15 +5520,77 @@ func (a *App) DeleteQSO(id int64) error {
|
||||
if a.qso == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
a.deleteRemoteCopies([]int64{id})
|
||||
return a.qso.Delete(a.ctx, id)
|
||||
}
|
||||
|
||||
// deleteRemoteCopies withdraws QSOs from QRZ.com and Club Log, when the
|
||||
// operator has asked for that.
|
||||
//
|
||||
// Called BEFORE the local delete, because both services need the QSO's own
|
||||
// fields to find their copy — Club Log matches on callsign, exact time and
|
||||
// band, and QRZ needs the logid stored on the record. Once the row is gone
|
||||
// there is nothing left to ask with.
|
||||
//
|
||||
// Failures never block the local delete. The operator asked for this QSO to go;
|
||||
// a refusing website is not a reason to keep it, and the log says what happened
|
||||
// so it can be sorted out on the site afterwards.
|
||||
func (a *App) deleteRemoteCopies(ids []int64) {
|
||||
if a.qso == nil || len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
prefix := ""
|
||||
if a.profileHasGroup(markerExtsvc) {
|
||||
prefix = a.profileScope()
|
||||
}
|
||||
m, err := a.getManyScoped(prefix, keyExtDeleteRemote)
|
||||
if err != nil || m[keyExtDeleteRemote] != "1" {
|
||||
return
|
||||
}
|
||||
cfg := a.loadExternalServices()
|
||||
doQRZ := strings.TrimSpace(cfg.QRZ.APIKey) != ""
|
||||
doClublog := strings.TrimSpace(cfg.Clublog.Email) != "" && cfg.Clublog.Password != ""
|
||||
if !doQRZ && !doClublog {
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
q, err := a.qso.GetByID(a.ctx, id)
|
||||
if err != nil || q.Callsign == "" {
|
||||
continue
|
||||
}
|
||||
if doQRZ {
|
||||
logID := ""
|
||||
if q.Extras != nil {
|
||||
logID = q.Extras["APP_OPSLOG_QRZ_LOGID"]
|
||||
}
|
||||
if strings.TrimSpace(logID) == "" {
|
||||
// Uploaded before OpsLog kept the identifier, or never uploaded.
|
||||
// QRZ has no delete-by-callsign-and-date, so say so plainly rather
|
||||
// than let the operator believe it was withdrawn.
|
||||
applog.Printf("extsvc: QSO %d (%s) has no QRZ logid — remove it on qrz.com by hand", id, q.Callsign)
|
||||
} else if msg, err := extsvc.DeleteQRZ(a.ctx, nil, cfg.QRZ.APIKey, []string{logID}); err != nil {
|
||||
applog.Printf("extsvc: QRZ delete of QSO %d (%s) failed: %v", id, q.Callsign, err)
|
||||
} else {
|
||||
applog.Printf("extsvc: QRZ delete of QSO %d (%s): %s", id, q.Callsign, msg)
|
||||
}
|
||||
}
|
||||
if doClublog {
|
||||
if msg, err := extsvc.DeleteClublog(a.ctx, nil, cfg.Clublog, q.Callsign, q.QSODate, q.Band); err != nil {
|
||||
applog.Printf("extsvc: Club Log delete of QSO %d (%s) failed: %v", id, q.Callsign, err)
|
||||
} else {
|
||||
applog.Printf("extsvc: Club Log delete of QSO %d (%s): %s", id, q.Callsign, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteQSOs removes several QSOs at once (multi-row selection). Returns the
|
||||
// number actually deleted.
|
||||
func (a *App) DeleteQSOs(ids []int64) (int64, error) {
|
||||
if a.qso == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
}
|
||||
a.deleteRemoteCopies(ids)
|
||||
return a.qso.DeleteMany(a.ctx, ids)
|
||||
}
|
||||
|
||||
@@ -6653,7 +6730,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
if a.settings == nil {
|
||||
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, 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, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
|
||||
if err != nil {
|
||||
return CATSettings{}, err
|
||||
}
|
||||
@@ -6672,6 +6749,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
YaesuPort: m[keyCATYaesuPort],
|
||||
YaesuBaud: 38400,
|
||||
KenwoodPort: m[keyCATKenwoodPort],
|
||||
KenwoodHost: m[keyCATKenwoodHost],
|
||||
KenwoodBaud: 9600,
|
||||
IcomPort: m[keyCATIcomPort],
|
||||
IcomBaud: 115200,
|
||||
@@ -6827,6 +6905,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
keyCATYaesuPort: strings.TrimSpace(s.YaesuPort),
|
||||
keyCATYaesuBaud: strconv.Itoa(s.YaesuBaud),
|
||||
keyCATKenwoodPort: strings.TrimSpace(s.KenwoodPort),
|
||||
keyCATKenwoodHost: strings.TrimSpace(s.KenwoodHost),
|
||||
keyCATKenwoodBaud: strconv.Itoa(s.KenwoodBaud),
|
||||
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
|
||||
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
|
||||
@@ -7032,14 +7111,17 @@ func (a *App) qsoRecDir() string {
|
||||
// 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);
|
||||
// q must have its ID set.
|
||||
// recordableMode reports whether a QSO mode is worth an audio recording —
|
||||
// only voice (SSB/AM/FM) and CW. Digital modes (FT8/FT4/RTTY/PSK/JT…) carry no
|
||||
// useful audio, so they are never recorded.
|
||||
// recordableMode reports whether a QSO mode is worth an audio recording: voice
|
||||
// and CW. Digital modes (FT8/FT4/RTTY/PSK/JT…) carry only modem tones, which
|
||||
// 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 {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
// CW is intentionally excluded: SmartSDR doesn't route CW audio through DAX,
|
||||
// so the recording is empty/useless. Phone modes only.
|
||||
case "SSB", "USB", "LSB", "AM", "FM", "DV":
|
||||
case "SSB", "USB", "LSB", "AM", "FM", "DV", "CW":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -7054,10 +7136,20 @@ func (a *App) saveQSORecording(q *qso.QSO) {
|
||||
applog.Printf("qso-rec: PANIC in saveQSORecording: %v", r)
|
||||
}
|
||||
}()
|
||||
// Every exit from here ends the take, including the digital-mode discard and
|
||||
// the snapshot failure below — so release the devices from one place rather
|
||||
// than remembering to do it at each return.
|
||||
defer a.stopManualQSORecorder()
|
||||
// Both of these were SILENT returns. When an operator reports "the recording
|
||||
// was not saved", the log has to say which of the two happened — no take was
|
||||
// running, or the mode disqualified it — because they are different problems
|
||||
// with different fixes and neither leaves a file behind to inspect.
|
||||
if a.qsoRec == nil || !a.qsoRec.Active() {
|
||||
applog.Printf("qso-rec: nothing saved for %s — no recording was running at log time", q.Callsign)
|
||||
return
|
||||
}
|
||||
if !recordableMode(q.Mode) {
|
||||
applog.Printf("qso-rec: nothing saved for %s — mode %q carries no audio worth keeping", q.Callsign, q.Mode)
|
||||
a.qsoRec.DiscardQSO() // digital mode — drop the buffered audio
|
||||
return
|
||||
}
|
||||
@@ -7531,12 +7623,170 @@ func (a *App) QSOAudioResetClock() bool {
|
||||
return a.qsoRec.Active()
|
||||
}
|
||||
|
||||
// QSOAudioManualReady reports whether a recording can be started BY HAND: the
|
||||
// capture devices are configured, but automatic recording is off.
|
||||
//
|
||||
// It is what decides whether the entry strip offers a record button. Without
|
||||
// it the button would appear for operators who have no sound card configured
|
||||
// and could only ever produce an error.
|
||||
func (a *App) QSOAudioManualReady() bool {
|
||||
if a.qsoRec == nil {
|
||||
return false
|
||||
}
|
||||
cfg, _ := a.GetAudioSettings()
|
||||
return !cfg.QSORecord && strings.TrimSpace(cfg.FromRadio) != ""
|
||||
}
|
||||
|
||||
// QSOAudioManualStart begins a recording when automatic recording is off.
|
||||
//
|
||||
// With the automatic recorder disabled the capture engine is not merely idle,
|
||||
// it is not running at all — so this starts it first. That costs the pre-roll:
|
||||
// an automatic recording opens with the seconds BEFORE the callsign was typed,
|
||||
// a manual one can only start now. Better to say that in a comment than to have
|
||||
// an operator wonder why one file has the DX's call in it and the other does
|
||||
// not.
|
||||
func (a *App) QSOAudioManualStart() bool {
|
||||
if a.qsoRec == nil {
|
||||
return false
|
||||
}
|
||||
if !a.qsoRec.Running() {
|
||||
cfg, _ := a.GetAudioSettings()
|
||||
if strings.TrimSpace(cfg.FromRadio) == "" {
|
||||
return false
|
||||
}
|
||||
if err := a.qsoRec.Start(cfg.FromRadio, cfg.RecordingDevice, cfg.PrerollSeconds); err != nil {
|
||||
applog.Printf("qso-rec: manual start failed: %v", err)
|
||||
return false
|
||||
}
|
||||
fromGain, micGain := float64(cfg.FromGain)/100, float64(cfg.MicGain)/100
|
||||
if cfg.FromGain == 0 {
|
||||
fromGain = 1
|
||||
}
|
||||
if cfg.MicGain == 0 {
|
||||
micGain = 1
|
||||
}
|
||||
a.qsoRec.SetGains(fromGain, micGain)
|
||||
a.qsoRecManual.Store(true)
|
||||
applog.Printf("qso-rec: manual recording started by the operator")
|
||||
}
|
||||
a.qsoRec.BeginQSO()
|
||||
return a.qsoRec.Active()
|
||||
}
|
||||
|
||||
// stopManualQSORecorder releases the sound devices after a hand-started take,
|
||||
// but only if this session opened them. When automatic recording is on the
|
||||
// engine belongs to the app and must keep running for the next contact.
|
||||
func (a *App) stopManualQSORecorder() {
|
||||
if a.qsoRec == nil || !a.qsoRecManual.Swap(false) {
|
||||
return
|
||||
}
|
||||
a.qsoRec.Stop()
|
||||
applog.Printf("qso-rec: manual recording finished — sound devices released")
|
||||
}
|
||||
|
||||
// QSOAudioStop freezes the recording without ending it, so it can be played
|
||||
// back and still be saved with the QSO afterwards.
|
||||
func (a *App) QSOAudioStop() bool {
|
||||
if a.qsoRec == nil {
|
||||
return false
|
||||
}
|
||||
return a.qsoRec.PauseQSO()
|
||||
}
|
||||
|
||||
// QSOAudioResume continues a frozen recording, appending to what is there.
|
||||
func (a *App) QSOAudioResume() bool {
|
||||
if a.qsoRec == nil {
|
||||
return false
|
||||
}
|
||||
return a.qsoRec.ResumeQSO()
|
||||
}
|
||||
|
||||
// QSOAudioPlayOnAir transmits the recording made so far, through the DVK's
|
||||
// output device and PTT.
|
||||
//
|
||||
// The situation this is for: you are working a station, you have been recording,
|
||||
// and they ask to hear it. You stop, you send it to them, and the recording is
|
||||
// still saved with the QSO.
|
||||
//
|
||||
// It goes out over the AIR. So it refuses unless the take has been stopped
|
||||
// first: playing while still recording would feed the transmitted audio back
|
||||
// into the same take, and on a rig monitoring its own transmission that is a
|
||||
// loop. Stopping is the operator's statement that the take is finished, and it
|
||||
// is one click they have already made.
|
||||
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 {
|
||||
applog.Printf("qso-rec: play refused — audio subsystem not initialised")
|
||||
return fmt.Errorf("audio not initialized")
|
||||
}
|
||||
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")
|
||||
}
|
||||
pcm, err := a.qsoRec.PeekQSO()
|
||||
if err != nil {
|
||||
applog.Printf("qso-rec: play refused — %v", 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.
|
||||
applog.Printf("qso-rec: play refused — no output device to the radio configured (Settings → Audio)")
|
||||
return fmt.Errorf("no audio output to the radio is configured — set it in Settings → Audio")
|
||||
}
|
||||
// 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
|
||||
// waiting on with the transmitter keyed.
|
||||
path := filepath.Join(a.dataDir, "qso_playback.wav")
|
||||
if err := audio.WritePCM(path, pcm); err != nil {
|
||||
return fmt.Errorf("prepare playback: %w", err)
|
||||
}
|
||||
|
||||
cfg := cfgEarly
|
||||
if err := a.pttKey(cfg); err != nil {
|
||||
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.
|
||||
} else if cfg.PTTMethod != "" && cfg.PTTMethod != "none" {
|
||||
a.pttMu.Lock()
|
||||
a.dvkPttKeyed = true
|
||||
a.pttMu.Unlock()
|
||||
}
|
||||
if err := a.audioMgr.Play(cfg.ToRadio, path, cfg.TXGain); err != nil {
|
||||
applog.Printf("qso-rec: playback on %q failed: %v", cfg.ToRadio, err)
|
||||
a.pttMu.Lock()
|
||||
keyed := a.dvkPttKeyed
|
||||
gen := a.pttGen
|
||||
a.dvkPttKeyed = false
|
||||
a.pttMu.Unlock()
|
||||
if keyed {
|
||||
go a.dvkUnkeyPTT(gen)
|
||||
}
|
||||
return err
|
||||
}
|
||||
applog.Printf("qso-rec: playing the recording on the air (%d bytes)", len(pcm))
|
||||
return nil
|
||||
}
|
||||
|
||||
// QSOAudioCancel drops the in-progress recording (callsign cleared, QSO
|
||||
// abandoned without logging).
|
||||
func (a *App) QSOAudioCancel() {
|
||||
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.stopManualQSORecorder()
|
||||
}
|
||||
|
||||
// RestartQSORecorder applies new audio settings to the running recorder.
|
||||
@@ -8934,10 +9184,11 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||
keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode,
|
||||
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
|
||||
keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
|
||||
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode)
|
||||
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
out.DeleteRemote = m[keyExtDeleteRemote] == "1"
|
||||
out.QRZ = extsvc.ServiceConfig{
|
||||
APIKey: m[keyExtQRZAPIKey],
|
||||
ForceStationCallsign: m[keyExtQRZForceCall],
|
||||
@@ -9079,8 +9330,13 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
||||
if cfg.Cloudlog.AutoUpload {
|
||||
clgAuto = "1"
|
||||
}
|
||||
delRemote := "0"
|
||||
if cfg.DeleteRemote {
|
||||
delRemote = "1"
|
||||
}
|
||||
scope := a.profileScope() // write under the active profile's prefix
|
||||
for k, v := range map[string]string{
|
||||
keyExtDeleteRemote: delRemote,
|
||||
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
|
||||
keyExtQRZForceCall: strings.ToUpper(strings.TrimSpace(cfg.QRZ.ForceStationCallsign)),
|
||||
keyExtQRZAutoUpload: auto,
|
||||
@@ -10262,6 +10518,23 @@ func (a *App) UpdateQSOsFromCty(ids []int64) (int, error) {
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// emitBulkProgress reports how far a bulk update has got.
|
||||
//
|
||||
// The QRZ pass is the one that needs it: it is a NETWORK round trip per QSO, so
|
||||
// 102 contacts imported from a contest take a couple of minutes during which
|
||||
// nothing moved on screen and the only honest reading was "it has frozen". The
|
||||
// cty.dat and ClubLog passes are local and usually instant, but they report too
|
||||
// — a progress bar that appears only when things are slow teaches people that
|
||||
// its absence means trouble.
|
||||
func (a *App) emitBulkProgress(op string, processed, total int, call string) {
|
||||
wruntime.EventsEmit(a.ctx, "bulkupdate:progress", map[string]any{
|
||||
"op": op,
|
||||
"processed": processed,
|
||||
"total": total,
|
||||
"call": call,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateQSOsFromQRZ re-queries the callsign database (QRZ.com / HamQTH per
|
||||
// the configured providers) for each QSO id and overwrites the geographic
|
||||
// + entity fields (country, continent, DXCC, zones, grid, state, county)
|
||||
@@ -10272,13 +10545,23 @@ func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
|
||||
return 0, fmt.Errorf("not initialized")
|
||||
}
|
||||
changed := 0
|
||||
for _, id := range ids {
|
||||
total := len(ids)
|
||||
a.emitBulkProgress("qrz", 0, total, "")
|
||||
defer a.emitBulkProgress("qrz", total, total, "")
|
||||
for i, id := range ids {
|
||||
q, err := a.qso.GetByID(a.ctx, id)
|
||||
if err != nil || q.Callsign == "" {
|
||||
a.emitBulkProgress("qrz", i+1, total, "")
|
||||
continue
|
||||
}
|
||||
// Reported BEFORE the lookup, so the callsign on screen is the one being
|
||||
// waited on rather than the one already finished — with a slow provider
|
||||
// that is the difference between "working on F4BPO" and a name that
|
||||
// lags a QSO behind whatever is actually taking the time.
|
||||
a.emitBulkProgress("qrz", i, total, q.Callsign)
|
||||
r, err := a.lookup.Lookup(a.ctx, q.Callsign)
|
||||
if err != nil {
|
||||
a.emitBulkProgress("qrz", i+1, total, "")
|
||||
continue
|
||||
}
|
||||
if r.Country != "" {
|
||||
@@ -10330,6 +10613,7 @@ func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
|
||||
if err := a.qso.Update(a.ctx, q); err == nil {
|
||||
changed++
|
||||
}
|
||||
a.emitBulkProgress("qrz", i+1, total, "")
|
||||
}
|
||||
if changed > 0 {
|
||||
a.invalidateAwardStats()
|
||||
@@ -10531,6 +10815,17 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
|
||||
if a.qso == nil {
|
||||
return
|
||||
}
|
||||
// Keep the identifier the service handed back. QRZ deletes ONLY by logid —
|
||||
// its API has no delete-by-callsign-and-date — so a QSO uploaded without
|
||||
// recording this can never be withdrawn from QRZ afterwards except by hand.
|
||||
// It costs one small write per upload and it is the only chance to get it.
|
||||
if strings.TrimSpace(logID) != "" && id != 0 {
|
||||
key := "APP_OPSLOG_" + strings.ToUpper(string(svc)) + "_LOGID"
|
||||
if err := a.qso.SetExtra(ctx, id, key, strings.TrimSpace(logID)); err != nil {
|
||||
applog.Printf("extsvc: store %s logid for QSO %d: %v", svc, id, err)
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
switch svc {
|
||||
case extsvc.ServiceQRZ:
|
||||
@@ -12259,7 +12554,14 @@ func (a *App) reloadCAT() {
|
||||
// same dialect (Elecraft K3/K4, and the "Kenwood" setting on other rigs).
|
||||
// One IF; frame carries frequency, mode, VFO and split, so the poll costs a
|
||||
// single round trip where OmniRig needed a rig file to describe each one.
|
||||
a.cat.Start(cat.NewKenwood(s.KenwoodPort, s.KenwoodBaud, s.DigitalDefault))
|
||||
// A network address wins over the COM port when both are filled: it is the
|
||||
// more deliberate setting, and silently preferring the wire would leave an
|
||||
// operator staring at a host they typed and a radio that never answers.
|
||||
if h := strings.TrimSpace(s.KenwoodHost); h != "" {
|
||||
a.cat.Start(cat.NewKenwoodTCP(h, s.DigitalDefault))
|
||||
} else {
|
||||
a.cat.Start(cat.NewKenwood(s.KenwoodPort, s.KenwoodBaud, s.DigitalDefault))
|
||||
}
|
||||
case "icom":
|
||||
// Native Icom CI-V over the radio's USB serial port (local control).
|
||||
// Same civ protocol the network backend reuses for remote.
|
||||
@@ -14478,6 +14780,17 @@ func (a *App) SetCIVTrace(on bool) {
|
||||
cat.SetCIVTrace(on)
|
||||
}
|
||||
|
||||
// CIVTraceEnabled and WinkeyerTraceEnabled report whether each trace is running.
|
||||
//
|
||||
// The settings dialog needs them. Its checkboxes are React state initialised to
|
||||
// false on every mount, so a trace left on came back UNTICKED while it was still
|
||||
// running: the operator ticks the box to \"switch it on\", which switches it off,
|
||||
// and the log they send then contains no trace at all. Reported by a Xiegu user.
|
||||
func (a *App) CIVTraceEnabled() bool { return cat.CIVTraceEnabled() }
|
||||
|
||||
// WinkeyerTraceEnabled — same, for the keyer trace.
|
||||
func (a *App) WinkeyerTraceEnabled() bool { return winkeyer.TraceEnabled() }
|
||||
|
||||
// WinkeyerConnect opens the serial link using the saved config.
|
||||
func (a *App) WinkeyerConnect() error {
|
||||
if a.winkeyer == nil {
|
||||
|
||||
@@ -1,4 +1,60 @@
|
||||
[
|
||||
{
|
||||
"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."
|
||||
],
|
||||
"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."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.3",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Kenwood backend tested end to end against the built-in TS-2000 emulator.",
|
||||
"Kenwood: split detected from FR/FT when the radio omits it from its status frame.",
|
||||
"CAT protocol trace now covers the Kenwood backend, not only CI-V.",
|
||||
"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\".",
|
||||
"Grey line on the world map: day/night terminator and twilight band, button at the top right.",
|
||||
"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.",
|
||||
"Kenwood CAT over the network: give a host:port for a serial bridge instead of a COM port.",
|
||||
"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, a red dot beside the callsign records the contact by hand.",
|
||||
"A recording can be stopped and transmitted to the station being worked, like a voice-keyer message.",
|
||||
"Award references can be sorted by reference or by description.",
|
||||
"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.",
|
||||
"Settings → General chooses between US / FR / Standard for dates. Display only.",
|
||||
"New award: DARC DOK Award (DLD)."
|
||||
],
|
||||
"fr": [
|
||||
"Backend Kenwood testé de bout en bout contre l’émulateur TS-2000 intégré.",
|
||||
"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.",
|
||||
"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 ».",
|
||||
"Ligne grise sur la carte du monde : terminateur jour/nuit et bande de crépuscule, bouton en haut à droite.",
|
||||
"Les cases de trace CAT et WinKeyer reflètent désormais l’état réel de la trace.",
|
||||
"Une radio annonçant LSB ou USB sélectionne désormais SSB dans la saisie.",
|
||||
"CAT Kenwood par le réseau : indiquez un hôte:port de pont série au lieu d’un port COM.",
|
||||
"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.",
|
||||
"Enregistrement automatique désactivé : un rond rouge à côté de l’indicatif enregistre le contact à la main.",
|
||||
"Un enregistrement peut être arrêté puis émis vers la station travaillée, comme un message du manipulateur vocal.",
|
||||
"Les références d’un diplôme peuvent être triées par référence ou par description.",
|
||||
"Supprimer un QSO peut aussi le retirer de QRZ.com et Club Log — Réglages → Services externes, désactivé par défaut.",
|
||||
"Colonnes de statut QSL et d’envoi colorées : Y vert, N rouge, R bleu.",
|
||||
"Réglages → Général : choix US / FR / Standard pour les dates. Affichage seulement.",
|
||||
"Nouveau diplôme : DARC DOK Award (DLD)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.2",
|
||||
"date": "2026-07-30",
|
||||
|
||||
+193
-23
@@ -44,6 +44,7 @@ import {
|
||||
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
||||
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
||||
QSOAudioManualReady, QSOAudioManualStart, QSOAudioStop, QSOAudioResume, QSOAudioPlayOnAir,
|
||||
GetAwardDefs,
|
||||
GetUIPref, GetActiveProfile, ListProfiles, ActivateProfile, QuitApp,
|
||||
ReportLiveActivity, LiveLastQSOAgeSec,
|
||||
@@ -99,6 +100,7 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||
import { RotorCompass } from '@/components/RotorCompass';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { formatDateTimeUTC } from '@/lib/dateFormat';
|
||||
import { setGridPrefsProfile, flushGridPrefs } from '@/lib/gridPrefs';
|
||||
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
|
||||
|
||||
@@ -135,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
|
||||
// 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).
|
||||
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 = {
|
||||
state: '', cnty: '', address: '',
|
||||
@@ -168,11 +178,7 @@ function parseAwardRefs(s: any): Record<string, string> {
|
||||
}
|
||||
|
||||
function fmtDateUTC(s: any): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (isNaN(d.getTime())) return s;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
return formatDateTimeUTC(s);
|
||||
}
|
||||
function fmtFreq(hz?: number): string {
|
||||
if (!hz) return '';
|
||||
@@ -392,6 +398,8 @@ export default function App() {
|
||||
// === Lists from settings (fallback for first paint) ===
|
||||
const [bands, setBands] = useState<string[]>(DEFAULT_BANDS);
|
||||
const [modes, setModes] = useState<string[]>(DEFAULT_MODES);
|
||||
const modesRef = useRef(modes);
|
||||
useEffect(() => { modesRef.current = modes; }, [modes]);
|
||||
const [modePresets, setModePresets] = useState<ModePreset[]>([]);
|
||||
|
||||
// === Entry ===
|
||||
@@ -766,16 +774,51 @@ export default function App() {
|
||||
// Elapsed recording time (seconds) shown next to the red dot, ticking once a
|
||||
// second while a recording is in progress.
|
||||
const [recSeconds, setRecSeconds] = useState(0);
|
||||
// Mirrored in a ref so the clock can RESUME from where it stopped rather than
|
||||
// restart, without the timer effect depending on the value it sets.
|
||||
const recSecondsRef = useRef(0);
|
||||
useEffect(() => { recSecondsRef.current = recSeconds; }, [recSeconds]);
|
||||
// Bumped on every (re)start so the timer resets even when `recording` was
|
||||
// already true (jumping spot→spot keeps recording=true but starts a fresh take).
|
||||
const [recTick, setRecTick] = useState(0);
|
||||
// True when the sound devices are configured but automatic recording is OFF —
|
||||
// the only case where a record button makes sense. Asking the backend rather
|
||||
// than reading a preference here keeps the two from drifting apart.
|
||||
const [manualRecReady, setManualRecReady] = useState(false);
|
||||
// A stopped take is still a take: the audio is kept and will be saved with the
|
||||
// QSO. This only says the clock has stopped and the recording can be played.
|
||||
const [recStopped, setRecStopped] = useState(false);
|
||||
const stopRecording = () => {
|
||||
QSOAudioStop().then((ok) => { if (ok) setRecStopped(true); }).catch(() => {});
|
||||
};
|
||||
const resumeRecording = () => {
|
||||
QSOAudioResume().then((ok) => { if (ok) { setRecStopped(false); setRecTick((t) => t + 1); } }).catch(() => {});
|
||||
};
|
||||
const playRecordingOnAir = () => {
|
||||
QSOAudioPlayOnAir().catch((e: any) => setError(String(e?.message ?? e)));
|
||||
};
|
||||
const refreshManualRecReady = () => { QSOAudioManualReady().then(setManualRecReady).catch(() => {}); };
|
||||
useEffect(() => { refreshManualRecReady(); }, []);
|
||||
const startManualRecording = () => {
|
||||
QSOAudioManualStart().then((active) => {
|
||||
setRecording(active);
|
||||
setRecTick((t) => t + 1);
|
||||
if (!active) setError(t('rec.manualFailed'));
|
||||
}).catch((e: any) => setError(String(e?.message ?? e)));
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!recording) { setRecSeconds(0); return; }
|
||||
// A stopped take freezes the clock where it is: it must show the length of
|
||||
// the file, not the time since the QSO began.
|
||||
if (recStopped) return;
|
||||
const from = recSecondsRef.current;
|
||||
const start = Date.now();
|
||||
setRecSeconds(0);
|
||||
const id = window.setInterval(() => setRecSeconds(Math.floor((Date.now() - start) / 1000)), 1000);
|
||||
const id = window.setInterval(() => setRecSeconds(from + Math.floor((Date.now() - start) / 1000)), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [recording, recTick]);
|
||||
}, [recording, recTick, recStopped]);
|
||||
// recTick means "a fresh take" — that is the only case where the clock returns
|
||||
// to zero, as opposed to resuming after a stop.
|
||||
useEffect(() => { setRecSeconds(0); recSecondsRef.current = 0; setRecStopped(false); }, [recTick]);
|
||||
// The callsign the in-progress recording belongs to (uppercased; '' = none).
|
||||
// Lets us restart from zero when the operator edits the call to a different
|
||||
// station mid-recording, instead of continuing the old take.
|
||||
@@ -1198,7 +1241,7 @@ export default function App() {
|
||||
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
|
||||
// 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);
|
||||
useEffect(() => { modeRef.current = mode; }, [mode]);
|
||||
function stopDvkAutoCq() { dvkAutoCqSlotRef.current = -1; dvkAutoCqGenRef.current++; }
|
||||
@@ -1518,6 +1561,8 @@ export default function App() {
|
||||
const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip');
|
||||
const [importApplyCty, setImportApplyCty] = useState(true);
|
||||
const [importApplyStation, setImportApplyStation] = useState(false);
|
||||
// Progress of a bulk update (QRZ.com / cty.dat / ClubLog) over selected QSOs.
|
||||
const [bulkProgress, setBulkProgress] = useState<{ op: string; processed: number; total: number; call: string } | null>(null);
|
||||
// Field remapping, off unless the operator adds a row. Contest software puts
|
||||
// the exchange where its own module keeps it rather than where ADIF says: the
|
||||
// RSGB IOTA contest exports the island reference in STATE, which imports as a
|
||||
@@ -2042,9 +2087,29 @@ export default function App() {
|
||||
// a 599 left from a CW QSO would stick when jumping to an SSB spot.
|
||||
function applyModeFromSpot(m: string) {
|
||||
if (!m) return;
|
||||
setMode(m);
|
||||
const logged = catModeToLoggedMode(m);
|
||||
setMode(logged);
|
||||
rstUserEditedRef.current = false;
|
||||
applyModePreset(m);
|
||||
applyModePreset(logged);
|
||||
}
|
||||
// catModeToLoggedMode maps what a radio reports onto a mode this station
|
||||
// actually logs.
|
||||
//
|
||||
// Radios report the SIDEBAND — a Kenwood or Yaesu on 40 m answers LSB, not
|
||||
// SSB. The mode list is SSB, so nothing matched and the selector sat empty
|
||||
// while the frequency tracked perfectly: "the mode is not recognised".
|
||||
//
|
||||
// It is also the ADIF-correct direction. LSB and USB are SUBMODEs there, not
|
||||
// modes; MODE=LSB is not a valid ADIF value and is what would be sent to LoTW
|
||||
// and eQSL. So this protects the upload as much as the selector.
|
||||
//
|
||||
// A station that has deliberately put LSB or USB in its own mode list keeps
|
||||
// them: the list is the operator's statement of what they log.
|
||||
function catModeToLoggedMode(m: string): string {
|
||||
const list = modesRef.current;
|
||||
if (!m || list.includes(m)) return m;
|
||||
if ((m === 'LSB' || m === 'USB') && list.includes('SSB')) return 'SSB';
|
||||
return m;
|
||||
}
|
||||
function applyModePreset(m: string) {
|
||||
if (rstUserEditedRef.current) return;
|
||||
@@ -2211,8 +2276,9 @@ export default function App() {
|
||||
else if (s.mode) nextMode = s.mode;
|
||||
}
|
||||
if (nextMode) {
|
||||
setMode(nextMode);
|
||||
applyModePreset(nextMode); // flip 599↔59 on mode change (respects edits)
|
||||
const logged = catModeToLoggedMode(nextMode);
|
||||
setMode(logged);
|
||||
applyModePreset(logged); // flip 599↔59 on mode change (respects edits)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2402,6 +2468,14 @@ export default function App() {
|
||||
const call = String(p?.call ?? '');
|
||||
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
|
||||
});
|
||||
const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => {
|
||||
const total = Number(p?.total ?? 0);
|
||||
const processed = Number(p?.processed ?? 0);
|
||||
// The closing event (processed === total) clears the overlay: the work is
|
||||
// done, and a bar left at 100% would have to be dismissed by hand.
|
||||
if (total > 0 && processed >= total) { setBulkProgress(null); return; }
|
||||
setBulkProgress({ op: String(p?.op ?? ''), processed, total, call: String(p?.call ?? '') });
|
||||
});
|
||||
const unsubProg = EventsOn('import:progress', (p: any) => {
|
||||
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
|
||||
});
|
||||
@@ -2428,7 +2502,7 @@ export default function App() {
|
||||
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
||||
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
|
||||
});
|
||||
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); unsubAdifMon?.(); };
|
||||
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -2935,9 +3009,9 @@ export default function App() {
|
||||
}
|
||||
async function bulkUpdateFromQRZ(ids: number[]) {
|
||||
if (ids.length === 0) return;
|
||||
showToast(`Querying QRZ.com for ${ids.length} QSO${ids.length > 1 ? 's' : ''}…`);
|
||||
try { await afterBulkUpdate(await UpdateQSOsFromQRZ(ids as any), 'from QRZ.com'); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
finally { setBulkProgress(null); }
|
||||
}
|
||||
async function bulkUpdateFromClublog(ids: number[]) {
|
||||
if (ids.length === 0) return;
|
||||
@@ -3647,18 +3721,82 @@ export default function App() {
|
||||
DUPE
|
||||
</span>
|
||||
)}
|
||||
{recording && RECORDABLE_MODES.has(mode.toUpperCase()) && (
|
||||
{/* Not recording, but able to: offer a record button in the slot the
|
||||
counter will occupy. It disappears the moment recording starts, so the
|
||||
two never fight over the space — and it is only offered on modes that
|
||||
produce audio worth keeping. */}
|
||||
{!recording && manualRecReady && RECORDABLE_MODES.has(mode.toUpperCase()) && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={resetRecordingClock}
|
||||
title="Click to restart the recording from 0 — drops everything captured so far (incl. pre-roll) so the file holds only your exchange"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-destructive whitespace-nowrap cursor-pointer rounded px-1 hover:bg-destructive-muted"
|
||||
onClick={startManualRecording}
|
||||
title={t('rec.manualStart')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center justify-center size-4 rounded-full cursor-pointer hover:bg-destructive-muted"
|
||||
>
|
||||
<span className="size-2 rounded-full bg-destructive animate-pulse" />
|
||||
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
|
||||
<span className="size-2.5 rounded-full border border-destructive bg-destructive/40 hover:bg-destructive" />
|
||||
</button>
|
||||
)}
|
||||
{/* 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
|
||||
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()) && (
|
||||
<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
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={playRecordingOnAir}
|
||||
title={t('rec.playOnAir')}
|
||||
className="inline-flex items-center justify-center size-4 rounded text-success hover:bg-success/15"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={resumeRecording}
|
||||
title={t('rec.resume')}
|
||||
className="inline-flex items-center justify-center size-4 rounded text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<span className="size-2 rounded-full bg-destructive" />
|
||||
</button>
|
||||
<span className="text-[10px] font-semibold tabular-nums text-muted-foreground whitespace-nowrap">
|
||||
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{/* Stop and the running counter share ONE flex row. They were two absolute
|
||||
boxes at guessed offsets and overlapped as soon as the counter passed a
|
||||
minute — a layout that only looked right for the first 60 seconds. */}
|
||||
{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">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => { e.stopPropagation(); stopRecording(); }}
|
||||
title={t('rec.stop')}
|
||||
className="inline-flex items-center justify-center size-4 rounded text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<span className="size-2 bg-current" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={resetRecordingClock}
|
||||
title="Click to restart the recording from 0 — drops everything captured so far (incl. pre-roll) so the file holds only your exchange"
|
||||
className="inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-destructive whitespace-nowrap cursor-pointer rounded px-1 hover:bg-destructive-muted"
|
||||
>
|
||||
<span className="size-2 rounded-full bg-destructive animate-pulse" />
|
||||
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<Input
|
||||
ref={callsignRef}
|
||||
// The call field is marked out at REST, not only on focus: operators were
|
||||
@@ -3666,7 +3804,10 @@ export default function App() {
|
||||
// exactly the same. A primary-tinted border plus an inset left bar make
|
||||
// it the obvious one without adding a colour the theme doesn't own. The
|
||||
// contest-dupe state still overrides it — that one is a warning.
|
||||
// Room on the right for the REC badges while they are shown, so a long
|
||||
// callsign runs under them instead of being typed over.
|
||||
className={cn('font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card',
|
||||
recording && RECORDABLE_MODES.has(mode.toUpperCase()) && 'pr-24',
|
||||
'border-primary/45 shadow-[inset_3px_0_0_0_var(--primary)]',
|
||||
contest.active && contestDupe && 'ring-2 ring-destructive !bg-destructive-muted focus:!bg-destructive-muted text-destructive-muted-foreground shadow-none')}
|
||||
value={callsign}
|
||||
@@ -6158,7 +6299,7 @@ export default function App() {
|
||||
<SettingsModal
|
||||
initialSection={settingsSection}
|
||||
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
|
||||
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); }}
|
||||
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); refreshManualRecReady(); }}
|
||||
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
|
||||
flexAvailable={catState.backend === 'flex'}
|
||||
icomAvailable={catState.backend === 'icom'}
|
||||
@@ -6384,6 +6525,35 @@ export default function App() {
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Deliberately NOT a modal. A contest log is thousands of QSOs and one
|
||||
network round trip each, so this runs for ten minutes \u2014 a dialog would
|
||||
hold the whole application hostage for the duration, and the operator
|
||||
has a radio to work. It sits in the corner, above everything, and
|
||||
stays out of the way. */}
|
||||
{bulkProgress && (
|
||||
<div className="fixed bottom-3 right-3 z-50 w-64 rounded-lg border border-border bg-card/95 backdrop-blur shadow-lg px-3 py-2 space-y-1.5">
|
||||
<div className="text-xs font-medium">{t('bulk.progressTitle')}</div>
|
||||
{(() => {
|
||||
const { processed, total, call } = bulkProgress;
|
||||
const pct = total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : 0;
|
||||
return (
|
||||
<>
|
||||
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full bg-primary rounded-full transition-[width] duration-150', total === 0 && 'w-1/3 animate-pulse')}
|
||||
style={total > 0 ? { width: `${pct}%` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span className="font-mono">{call || '\u00a0'}</span>
|
||||
<span className="tabular-nums">{total > 0 ? `${processed} / ${total}` : ''}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importing && (
|
||||
<Dialog open>
|
||||
<DialogContent className="max-w-sm px-6" hideClose>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AwardEditor } from '@/components/AwardEditor';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
|
||||
type BandCount = { band: string; worked: number; confirmed: number };
|
||||
type AwardRef = {
|
||||
@@ -191,17 +192,52 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
return idx.length ? idx : all;
|
||||
}, [stats, awardBands]);
|
||||
|
||||
// Sort order for the reference table. Reference is the default because that
|
||||
// is how award lists are published and how an operator reads a paper list;
|
||||
// description answers the other question — "have I worked anything in
|
||||
// Alicante" — which reference order scatters across the whole table.
|
||||
// Remembered through writeUiPref rather than localStorage, so the choice
|
||||
// travels with a copied data/ folder like every other UI preference here.
|
||||
const [refSort, setRefSort] = useState<'ref' | 'name'>(
|
||||
() => (localStorage.getItem('opslog.awardRefSort') === 'name' ? 'name' : 'ref'));
|
||||
const [refSortDir, setRefSortDir] = useState<'asc' | 'desc'>(
|
||||
() => (localStorage.getItem('opslog.awardRefSortDir') === 'desc' ? 'desc' : 'asc'));
|
||||
const toggleRefSort = (k: 'ref' | 'name') => {
|
||||
if (k === refSort) {
|
||||
const d = refSortDir === 'asc' ? 'desc' : 'asc';
|
||||
setRefSortDir(d);
|
||||
writeUiPref('opslog.awardRefSortDir', d);
|
||||
} else {
|
||||
setRefSort(k);
|
||||
setRefSortDir('asc');
|
||||
writeUiPref('opslog.awardRefSort', k);
|
||||
writeUiPref('opslog.awardRefSortDir', 'asc');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredRefs = useMemo(() => {
|
||||
if (!current) return [];
|
||||
const q = refSearch.trim().toUpperCase();
|
||||
return (current.refs ?? []).filter((r) => {
|
||||
const rows = (current.refs ?? []).filter((r) => {
|
||||
if (refFilter === 'worked' && !r.worked) return false;
|
||||
if (refFilter === 'notworked' && r.worked) return false;
|
||||
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
||||
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
|
||||
return true;
|
||||
});
|
||||
}, [current, refSearch, refFilter]);
|
||||
const dir = refSortDir === 'asc' ? 1 : -1;
|
||||
return rows.sort((a, b) => {
|
||||
if (refSort === 'name') {
|
||||
// localeCompare, not a byte comparison: these lists are Spanish, French
|
||||
// and Italian place names, where "Ávila" belongs beside "Avilés" rather
|
||||
// than after "Zaragoza".
|
||||
const c = (a.name ?? '').localeCompare(b.name ?? '', undefined, { sensitivity: 'base' });
|
||||
if (c !== 0) return c * dir;
|
||||
return a.ref.localeCompare(b.ref) * dir; // stable tie-break
|
||||
}
|
||||
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
||||
});
|
||||
}, [current, refSearch, refFilter, refSort, refSortDir]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
@@ -386,8 +422,18 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="bg-card">
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">{t('awp.ref')}</th>
|
||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.description')}</th>
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">
|
||||
<button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{t('awp.ref')}
|
||||
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">
|
||||
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{t('awp.description')}
|
||||
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</th>
|
||||
{gridBands.map((b) => (
|
||||
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
|
||||
))}
|
||||
@@ -424,8 +470,18 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.ref')}</th>
|
||||
<th className="py-1.5 pr-2 font-medium">{t('awp.name')}</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-24">
|
||||
<button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{t('awp.ref')}
|
||||
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</th>
|
||||
<th className="py-1.5 pr-2 font-medium">
|
||||
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{t('awp.name')}
|
||||
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-40">{t('awp.groupCol')}</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
|
||||
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { nightPolygon } from '../lib/greyline';
|
||||
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
|
||||
@@ -115,6 +116,12 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
// pans/zooms freely (e.g. a whole-world view) and the view is remembered
|
||||
// across restarts. Default on.
|
||||
const [autoZoom, setAutoZoom] = useState(() => localStorage.getItem('opslog.mapAutoZoomDX') !== '0');
|
||||
|
||||
// Grey line — the day/night terminator with its twilight band. Off by
|
||||
// default: it is a working tool for the operator who wants it, not decoration
|
||||
// for the one who does not.
|
||||
const [greyline, setGreyline] = useState(() => localStorage.getItem('opslog.mapGreyline') === '1');
|
||||
const greylineLayer = useRef<L.LayerGroup | null>(null);
|
||||
const autoZoomRef = useRef(autoZoom);
|
||||
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
|
||||
|
||||
@@ -152,6 +159,80 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||
}, [basemap]);
|
||||
|
||||
// Draw the grey line, and keep it moving.
|
||||
//
|
||||
// Redrawn every minute: the terminator travels a quarter of a degree in that
|
||||
// time, which is invisible, but a map left on all day would otherwise be
|
||||
// silently wrong by evening — and the whole point of the display is knowing
|
||||
// where the line is NOW.
|
||||
//
|
||||
// It lives in its own pane below the overlay pane so the path and beam are
|
||||
// never hidden behind the shading, and is non-interactive so it can never
|
||||
// swallow a click meant for the map.
|
||||
useEffect(() => {
|
||||
const m = worldMap.current;
|
||||
if (!m) return;
|
||||
|
||||
if (!greyline) {
|
||||
greylineLayer.current?.remove();
|
||||
greylineLayer.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m.getPane('greyline')) {
|
||||
const pane = m.createPane('greyline');
|
||||
pane.style.zIndex = '350'; // above tiles (200), below overlays (400)
|
||||
pane.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
const now = new Date();
|
||||
greylineLayer.current?.remove();
|
||||
const g = L.layerGroup([], { pane: 'greyline' });
|
||||
|
||||
// The rings span −180…+180 once, but this map repeats the world sideways
|
||||
// (worldCopyJump, and zoomed out several copies are on screen at once). A
|
||||
// single ring therefore ends in a hard vertical edge wherever a copy
|
||||
// begins — the shading looked like a rectangle laid over the planet.
|
||||
//
|
||||
// So each ring is drawn again shifted a full turn either way. Three copies
|
||||
// cover every zoom level this map reaches; they cost nothing, being three
|
||||
// polygons on a canvas layer.
|
||||
const COPIES = [-360, 0, 360];
|
||||
const shifted = (ring: [number, number][], by: number): [number, number][] =>
|
||||
by === 0 ? ring : ring.map(([lat, lon]) => [lat, lon + by] as [number, number]);
|
||||
|
||||
// Two bands, drawn dark-to-light so they read as one gradient into night:
|
||||
// full darkness (Sun below the horizon) and civil twilight (down to −6°),
|
||||
// which is the band operators actually mean by "grey line".
|
||||
const nightRing = nightPolygon(now, 0);
|
||||
const twilightRing = nightPolygon(now, -6);
|
||||
// The terminator itself, so the line is readable at a glance rather than
|
||||
// being inferred from where the shading fades. Dropping the last two
|
||||
// points leaves the terminator alone, without the segment that closes the
|
||||
// polygon along the dark pole.
|
||||
const lineRing = nightRing.slice(0, -2);
|
||||
|
||||
for (const by of COPIES) {
|
||||
L.polygon(shifted(twilightRing, by), {
|
||||
pane: 'greyline', stroke: false, fillColor: '#0b1220', fillOpacity: 0.22, interactive: false,
|
||||
}).addTo(g);
|
||||
L.polygon(shifted(nightRing, by), {
|
||||
pane: 'greyline', stroke: false, fillColor: '#0b1220', fillOpacity: 0.32, interactive: false,
|
||||
}).addTo(g);
|
||||
L.polyline(shifted(lineRing, by), {
|
||||
pane: 'greyline', color: '#f59e0b', weight: 1, opacity: 0.75, interactive: false,
|
||||
}).addTo(g);
|
||||
}
|
||||
g.addTo(m);
|
||||
greylineLayer.current = g;
|
||||
};
|
||||
|
||||
draw();
|
||||
const id = window.setInterval(draw, 60_000);
|
||||
return () => { window.clearInterval(id); };
|
||||
}, [greyline]);
|
||||
|
||||
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
||||
useEffect(() => {
|
||||
const wm = worldMap.current, wo = worldOverlay.current;
|
||||
@@ -293,6 +374,21 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
>
|
||||
Zoom DX
|
||||
</button>
|
||||
{/* Grey line toggle, under the zoom button. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const v = !greyline;
|
||||
setGreyline(v);
|
||||
writeUiPref('opslog.mapGreyline', v ? '1' : '0');
|
||||
}}
|
||||
title={greyline ? 'Grey line is ON — day/night terminator and twilight band' : 'Show the grey line (day/night terminator)'}
|
||||
className={`absolute top-9 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
|
||||
greyline ? 'bg-primary text-primary-foreground border-primary' : 'bg-card/90 text-muted-foreground border-border hover:bg-card'
|
||||
}`}
|
||||
>
|
||||
Grey line
|
||||
</button>
|
||||
{path && (
|
||||
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
||||
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||
} from 'ag-grid-community';
|
||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||
import { qslStatusCellClass } from '@/lib/qslStatus';
|
||||
import { formatDateTimeUTC, formatDateOnly, getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
||||
import type { QSOForm } from '@/types';
|
||||
@@ -85,23 +87,13 @@ function fmtMhzDots(hz?: number): string {
|
||||
return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`;
|
||||
}
|
||||
|
||||
// Both formatters follow the display preference (Settings → General). What is
|
||||
// STORED never changes — see lib/dateFormat.
|
||||
function fmtDateUTC(s: any): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (isNaN(d.getTime())) return s;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
return formatDateTimeUTC(s);
|
||||
}
|
||||
function fmtDateOnly(s: any): string {
|
||||
if (!s) return '';
|
||||
const t = String(s).trim();
|
||||
// QSL/LoTW/eQSL/ClubLog dates are ADIF YYYYMMDD; upload dates may be ISO.
|
||||
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
|
||||
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
||||
const d = new Date(t);
|
||||
if (isNaN(d.getTime())) return t;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||
return formatDateOnly(s);
|
||||
}
|
||||
|
||||
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
|
||||
@@ -178,8 +170,8 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
||||
|
||||
// ── QSL ──
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
|
||||
@@ -187,32 +179,32 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
|
||||
|
||||
// ── LoTW ──
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent_date'), colId: 'lotw_sent_date', headerName: t('rqg.h.lotw_sent_date'), field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd_date'), colId: 'lotw_rcvd_date', headerName: t('rqg.h.lotw_rcvd_date'), field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
// ── eQSL ──
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent_date'), colId: 'eqsl_sent_date', headerName: t('rqg.h.eqsl_sent_date'), field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||
|
||||
// ── Uploads (online logbooks) ──
|
||||
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
||||
// for QRZ only, a "download status/date" (= it came back confirmed). We
|
||||
// relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
|
||||
// NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent_date'), colId: 'clublog_qso_upload_date', headerName: t('rqg.h.clublog_sent_date'), field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent_date'), colId: 'hrdlog_qso_upload_date', headerName: t('rqg.h.hrdlog_sent_date'), field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
@@ -303,7 +295,11 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
||||
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
|
||||
|
||||
// Localized column catalog — rebuilt when the language changes.
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
|
||||
// Rebuild the columns when the format changes: the formatters are captured
|
||||
// inside the column definitions, so nothing else would notice.
|
||||
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
||||
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
|
||||
|
||||
// Right-click: if the clicked row isn't already part of the selection,
|
||||
// select just it; then open the bulk-action menu on the whole selection.
|
||||
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
GetQSLDefaults, SaveQSLDefaults,
|
||||
SetWinkeyerTrace,
|
||||
SetCIVTrace,
|
||||
CIVTraceEnabled,
|
||||
WinkeyerTraceEnabled,
|
||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
|
||||
GetPOTAToken, SavePOTAToken,
|
||||
TestLoTWUpload, ListTQSLStationLocations,
|
||||
@@ -67,6 +69,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
|
||||
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
||||
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
|
||||
import { OperatingPanel } from '@/components/OperatingPanel';
|
||||
@@ -1093,7 +1096,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const [modeDraft, setModeDraft] = useState('');
|
||||
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,
|
||||
yaesu_port: '', yaesu_baud: 38400, kenwood_port: '', kenwood_baud: 9600, 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,
|
||||
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,
|
||||
digital_default: 'FT8', share_enabled: false, share_port: 4532,
|
||||
@@ -1136,8 +1139,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
});
|
||||
const [wkPorts, setWkPorts] = useState<string[]>([]);
|
||||
// Session-only: the byte trace is a diagnostic, never a saved preference.
|
||||
//
|
||||
// But it must still SHOW its real state. These start false on every mount, so
|
||||
// a trace already running came back unticked; the operator ticks the box to
|
||||
// turn it on, which turns it off, and the log they send has no trace in it.
|
||||
// Reported by a Xiegu user. Hydrated from the backend below.
|
||||
const [dateFmtSel, setDateFmtSel] = useState<DateFormat>(getDateFormat);
|
||||
const [wkTrace, setWkTrace] = useState(false);
|
||||
const [civTrace, setCivTrace] = useState(false);
|
||||
useEffect(() => {
|
||||
CIVTraceEnabled().then((v) => setCivTrace(!!v)).catch(() => {});
|
||||
WinkeyerTraceEnabled().then((v) => setWkTrace(!!v)).catch(() => {});
|
||||
}, []);
|
||||
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
|
||||
|
||||
// ── Audio (DVK + QSO recorder) ──
|
||||
@@ -1276,7 +1289,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
upload_flags: string[]; write_log: boolean;
|
||||
auto_upload: boolean; upload_mode: string;
|
||||
};
|
||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg };
|
||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; delete_remote?: boolean };
|
||||
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
|
||||
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
||||
@@ -1284,7 +1297,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
auto_upload: false, upload_mode: 'immediate',
|
||||
});
|
||||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(),
|
||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), delete_remote: false,
|
||||
});
|
||||
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [qrzTesting, setQrzTesting] = useState(false);
|
||||
@@ -2516,7 +2529,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(catCfg.backend === 'icom' || catCfg.backend === 'xiegu') && (
|
||||
{['icom', 'xiegu', 'kenwood'].includes(catCfg.backend) && (
|
||||
<div className="border-t border-border/60 pt-3">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={civTrace} onCheckedChange={(c) => { setCivTrace(!!c); SetCIVTrace(!!c); }} />
|
||||
@@ -2552,6 +2565,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</Select>
|
||||
<span className="text-xs text-muted-foreground">{t('cat.kenwoodBaudHint')}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.kenwoodHost')}</Label>
|
||||
<Input
|
||||
value={catCfg.kenwood_host || ''}
|
||||
placeholder="192.168.1.50:4532"
|
||||
onChange={(e) => setCatCfg((s) => ({ ...s, kenwood_host: e.target.value }))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t('cat.kenwoodHostHint')}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{catCfg.backend === 'icom' && (
|
||||
@@ -4203,6 +4225,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
|
||||
<ProfileScopeNote profile={activeProfileObj} />
|
||||
|
||||
{/* Applies to BOTH services, so it sits above the tab strip rather than
|
||||
inside one tab where it would look like a QRZ-only setting (and be
|
||||
invisible to anyone who only opens the Club Log tab). */}
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer mb-4 max-w-2xl">
|
||||
<Checkbox
|
||||
checked={!!extSvc.delete_remote}
|
||||
onCheckedChange={(c) => setExtSvc((v) => ({ ...v, delete_remote: !!c }))}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{t('es.deleteRemote')}
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">{t('es.deleteRemoteHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Tab strip */}
|
||||
<div className="flex flex-wrap gap-1 border-b border-border mb-4">
|
||||
{TABS.map((tab) => (
|
||||
@@ -5187,6 +5224,27 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display only. The log is stored in ISO/ADIF whatever is chosen here
|
||||
— that is what ADIF specifies and what sorts correctly, and a
|
||||
stored format that followed a preference would make the log
|
||||
unreadable the day the preference changed. */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className="text-sm w-40">{t('gen.dateFormat')}</Label>
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
{([['iso', t('gen.dateStandard'), '2026-07-30 14:25'],
|
||||
['fr', t('gen.dateFR'), '30-07-2026 14:25'],
|
||||
['us', t('gen.dateUS'), '07-30-2026 14:25']] as [DateFormat, string, string][]).map(([code, label, sample]) => (
|
||||
<button key={code} type="button" title={sample}
|
||||
onClick={() => { setDateFormat(code); setDateFmtSel(code); }}
|
||||
className={cn('flex flex-col items-start px-3 py-1 text-sm font-medium border-l border-border first:border-l-0',
|
||||
dateFmtSel === code ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{label}
|
||||
<span className="text-[10px] font-mono opacity-70">{sample}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ThemeSelector />
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { WorkedBeforeView, QSOForm } from '@/types';
|
||||
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
||||
import { getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
|
||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -63,7 +64,9 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
|
||||
// Localized column catalog (shared with the Recent QSOs grid).
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
|
||||
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
||||
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
|
||||
|
||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// How dates are DISPLAYED. Nothing here touches what is stored.
|
||||
//
|
||||
// The log keeps ADIF/ISO throughout — the database, ADIF export, LoTW, every
|
||||
// upload. This is the reading layer only, because an operator reading
|
||||
// "2026-07-30" as the 7th of the 30th month is reading their own log wrongly,
|
||||
// and that is a display problem, not a data one.
|
||||
//
|
||||
// Storage stays ISO deliberately and permanently: it sorts correctly as text,
|
||||
// it is what ADIF specifies, and a log whose stored format followed a UI
|
||||
// preference would become unreadable the day the preference changed.
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
|
||||
export type DateFormat = 'iso' | 'fr' | 'us';
|
||||
|
||||
const KEY = 'opslog.dateFormat';
|
||||
|
||||
function read(): DateFormat {
|
||||
const v = localStorage.getItem(KEY);
|
||||
return v === 'fr' || v === 'us' ? v : 'iso';
|
||||
}
|
||||
|
||||
let current: DateFormat = read();
|
||||
|
||||
// A minimal store so every grid and panel re-renders the moment the format
|
||||
// changes, rather than showing the old one until the next restart.
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function getDateFormat(): DateFormat {
|
||||
return current;
|
||||
}
|
||||
|
||||
export function setDateFormat(f: DateFormat): void {
|
||||
current = f;
|
||||
writeUiPref(KEY, f);
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function subscribeDateFormat(fn: () => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
// reloadDateFormat re-reads the stored value — for after the portable prefs
|
||||
// have been synced from the database at startup, which happens AFTER this
|
||||
// module's first read.
|
||||
export function reloadDateFormat(): void {
|
||||
const v = read();
|
||||
if (v !== current) {
|
||||
current = v;
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
// order arranges the three parts. Separators stay '-' in every format: a slash
|
||||
// in the French one would be conventional but it also invites the reader to
|
||||
// take it for a US date, which is exactly the confusion being fixed.
|
||||
function order(y: number, m: number, d: number): string {
|
||||
switch (current) {
|
||||
case 'fr':
|
||||
return `${pad(d)}-${pad(m)}-${y}`;
|
||||
case 'us':
|
||||
return `${pad(m)}-${pad(d)}-${y}`;
|
||||
}
|
||||
return `${y}-${pad(m)}-${pad(d)}`;
|
||||
}
|
||||
|
||||
// formatDateTimeUTC renders a timestamp with the time, in UTC.
|
||||
//
|
||||
// UTC is not negotiable and is not part of the preference: a logbook is kept in
|
||||
// UTC, and a date shown in local time would disagree with the QSO's own record
|
||||
// twice a year.
|
||||
export function formatDateTimeUTC(s: unknown): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(String(s));
|
||||
if (isNaN(d.getTime())) return String(s);
|
||||
return `${order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
// formatDateOnly renders a date with no time. It accepts both the ADIF form
|
||||
// (YYYYMMDD, used by every QSL and upload date) and anything Date can parse.
|
||||
export function formatDateOnly(s: unknown): string {
|
||||
if (!s) return '';
|
||||
const t = String(s).trim();
|
||||
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
|
||||
if (m) return order(Number(m[1]), Number(m[2]), Number(m[3]));
|
||||
const d = new Date(t);
|
||||
if (isNaN(d.getTime())) return t;
|
||||
return order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Grey line — the day/night terminator, and the twilight band around it where
|
||||
// HF propagation briefly does things it does at no other time.
|
||||
//
|
||||
// The maths is the standard low-precision solar position (Meeus, chapter 25, as
|
||||
// used by NOAA's solar calculator): good to well under a degree, which is far
|
||||
// finer than the band being drawn. No ephemeris, no network, no dependency —
|
||||
// this has to work in a field shack with no internet.
|
||||
//
|
||||
// Everything here is pure so it can be tested against known positions; the
|
||||
// drawing lives in the map component.
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
// julianDay for a date, including the fraction of the day.
|
||||
export function julianDay(d: Date): number {
|
||||
return d.getTime() / 86400000 + 2440587.5;
|
||||
}
|
||||
|
||||
// Sun's declination and Greenwich hour angle, both in degrees.
|
||||
//
|
||||
// Returned together because they are computed from the same intermediates and
|
||||
// every caller needs both — splitting them into two exported functions would
|
||||
// double the work at each call for the sake of a tidier signature.
|
||||
export function sunPosition(date: Date): { dec: number; gha: number } {
|
||||
const jd = julianDay(date);
|
||||
const n = jd - 2451545.0; // days from J2000.0
|
||||
|
||||
// Mean longitude and mean anomaly of the Sun.
|
||||
const L = (280.460 + 0.9856474 * n) % 360;
|
||||
const g = ((357.528 + 0.9856003 * n) % 360) * DEG;
|
||||
|
||||
// Ecliptic longitude: the equation of centre applied to the mean longitude.
|
||||
const lambda = (L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g)) * DEG;
|
||||
|
||||
// Obliquity of the ecliptic, slowly decreasing.
|
||||
const eps = (23.439 - 0.0000004 * n) * DEG;
|
||||
|
||||
const dec = Math.asin(Math.sin(eps) * Math.sin(lambda)) / DEG;
|
||||
|
||||
// Right ascension, kept in the same quadrant as the ecliptic longitude —
|
||||
// atan2 alone lands 180° out for half the year, which mirrors the whole
|
||||
// terminator about the prime meridian. Six months of a plausible-looking
|
||||
// wrong answer is exactly the kind of bug that survives review.
|
||||
let ra = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda)) / DEG;
|
||||
if (ra < 0) ra += 360;
|
||||
|
||||
// Greenwich mean sidereal time, in degrees.
|
||||
const gmst = (280.46061837 + 360.98564736629 * n) % 360;
|
||||
|
||||
let gha = gmst - ra;
|
||||
gha = ((gha % 360) + 360) % 360;
|
||||
return { dec, gha };
|
||||
}
|
||||
|
||||
// terminatorLatitude returns the latitude, at a given longitude, where the Sun
|
||||
// sits at `altitude` degrees above (or below) the horizon.
|
||||
//
|
||||
// altitude 0 is the geometric terminator; -6 is civil twilight, the outer edge
|
||||
// of the grey line as operators use the term.
|
||||
//
|
||||
// Returns null where no such latitude exists at that longitude — inside the
|
||||
// polar day or polar night, where the Sun never crosses that altitude at all.
|
||||
export function terminatorLatitude(lonDeg: number, dec: number, gha: number, altitude = 0): number | null {
|
||||
const ha = (gha + lonDeg) * DEG; // local hour angle
|
||||
const d = dec * DEG;
|
||||
const h = altitude * DEG;
|
||||
|
||||
// From sin(alt) = sin(dec)sin(lat) + cos(dec)cos(lat)cos(ha), solved for lat:
|
||||
// A·sin(lat) + B·cos(lat) = sin(alt), A = sin(dec), B = cos(dec)cos(ha)
|
||||
//
|
||||
// That has TWO solutions per meridian, and picking the wrong one is not a
|
||||
// small error — it mirrors the answer about the equator. New York at 02:00
|
||||
// local in December came out as daylight because the branch chosen put the
|
||||
// terminator at +63° where it belongs at −63°: a shaded map that looks
|
||||
// entirely plausible and is exactly wrong.
|
||||
//
|
||||
// So anchor on the geometric terminator, where the answer is unambiguous and
|
||||
// classical, and take whichever branch is nearest it. At altitude 0 that
|
||||
// reproduces it exactly; for the twilight band it follows it by continuity,
|
||||
// which is the physical requirement — the twilight curve is a small offset
|
||||
// from the terminator, never on the other side of the planet.
|
||||
const A = Math.sin(d);
|
||||
const B = Math.cos(d) * Math.cos(ha);
|
||||
const R = Math.hypot(A, B);
|
||||
if (R === 0) return null;
|
||||
const s = Math.sin(h) / R;
|
||||
if (s < -1 || s > 1) return null; // the Sun never reaches this altitude here
|
||||
|
||||
// The classical terminator. Division by zero is deliberate and correct here:
|
||||
// at an equinox tan(dec) → 0 and the terminator becomes a meridian, which is
|
||||
// exactly what atan(±Infinity) = ±90° expresses.
|
||||
const t = Math.tan(d);
|
||||
if (t === 0 && Math.cos(ha) === 0) return null; // degenerate: no crossing to name
|
||||
const lat0 = Math.atan(-Math.cos(ha) / t) / DEG;
|
||||
|
||||
const phi = Math.atan2(B, A); // phase of the A·sin(lat) + B·cos(lat) sum
|
||||
const fold = (x: number) => {
|
||||
let v = ((x + 180) % 360 + 360) % 360 - 180;
|
||||
if (v > 90) v = 180 - v;
|
||||
if (v < -90) v = -180 - v;
|
||||
return v;
|
||||
};
|
||||
const c1 = fold((Math.asin(s) - phi) / DEG);
|
||||
const c2 = fold((Math.PI - Math.asin(s) - phi) / DEG);
|
||||
return Math.abs(c1 - lat0) <= Math.abs(c2 - lat0) ? c1 : c2;
|
||||
}
|
||||
|
||||
// nightPolygon returns a ring covering the part of the world where the Sun is
|
||||
// below `altitude`, ready for L.polygon (as [lat, lon] pairs).
|
||||
//
|
||||
// The ring runs along the terminator from west to east and closes along
|
||||
// whichever pole is in darkness — which pole that is flips with the season, and
|
||||
// getting it wrong shades the lit half of the planet.
|
||||
export function nightPolygon(date: Date, altitude = 0, stepDeg = 2): [number, number][] {
|
||||
const { dec, gha } = sunPosition(date);
|
||||
const ring: [number, number][] = [];
|
||||
|
||||
for (let lon = -180; lon <= 180; lon += stepDeg) {
|
||||
const lat = terminatorLatitude(lon, dec, gha, altitude);
|
||||
// Inside a polar day/night there is no crossing; clamp to the pole so the
|
||||
// ring stays closed rather than tearing open across the map.
|
||||
ring.push([lat === null ? (dec > 0 ? -90 : 90) : lat, lon]);
|
||||
}
|
||||
|
||||
// Northern summer (positive declination) → the SOUTH pole is dark.
|
||||
const pole = dec > 0 ? -90 : 90;
|
||||
ring.push([pole, 180]);
|
||||
ring.push([pole, -180]);
|
||||
return ring;
|
||||
}
|
||||
+12
-12
@@ -63,7 +63,7 @@ const en: Dict = {
|
||||
// Language chooser
|
||||
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
|
||||
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': "What's new", 'whatsnew.close': 'Got it', 'whatsnew.none': 'No changelog available for this version yet.',
|
||||
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
|
||||
'settings.language': 'Language', 'gen.dateFormat': 'Date display', 'gen.dateStandard': 'Standard', 'gen.dateFR': 'French', 'gen.dateUS': 'US', 'settings.languageHint': 'Interface language.',
|
||||
'stats.tab': 'Statistics', 'stats.title': 'Logbook statistics', 'stats.loading': 'Crunching the log…',
|
||||
'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh',
|
||||
'stats.qsos': 'QSOs', 'stats.uniqueCalls': 'Unique callsigns', 'stats.entities': 'Entities',
|
||||
@@ -135,8 +135,8 @@ 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.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.cancel': 'Cancel', '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…',
|
||||
'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.progressTitle': 'Importing ADIF…', 'bulk.progressTitle': 'Updating the selected QSOs\u2026',
|
||||
'imp.progressCount': '{done} / {tot} records · {pct}%', 'imp.progressCountOnly': '{done} records…',
|
||||
'imp.complete': 'Import complete.',
|
||||
'imp.imported': '{n} imported', 'imp.updated': '{n} updated', 'imp.duplicates': '{n} duplicates', 'imp.skipped': '{n} skipped', 'imp.total': '{n} total',
|
||||
@@ -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).',
|
||||
'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.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', '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 (native CI-V)', '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 (native CAT)', 'cat.optKenwood': 'Kenwood (native)', 'cat.civTrace': 'Log the CI-V protocol', 'cat.civTraceHint': 'Writes every CI-V frame to and from the radio into the log, as hex. 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.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 serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
||||
'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.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.icomNetAudio': 'Stream RX audio over the network (experimental)',
|
||||
@@ -298,7 +298,7 @@ const en: Dict = {
|
||||
'es.usernameCall': 'Username (callsign)', 'es.eqslUserPh': 'your eQSL.cc login (callsign)', 'es.eqslPwPh': 'eQSL.cc account password', 'es.qthNick': 'QTH nickname', 'es.qthNickPh': 'optional — required only if your eQSL account has several QTHs', 'es.eqslHint': 'The QTH nickname tells eQSL which location profile to file the QSO under. Leave blank if you have only one.',
|
||||
'es.lotwUser': 'LoTW user', 'es.lotwUserPh': 'LoTW website login (for downloading confirmations)', 'es.lotwPw': 'LoTW password', 'es.lotwPwPh': 'LoTW website password', 'es.tqslPath': 'TQSL path', 'es.stationLoc': 'Station location', 'es.pickLoc': '— pick a TQSL location —', 'es.noLoc': 'No TQSL locations found', 'es.reloadLoc': 'Reload locations from TQSL', 'es.lotwForcePh': "e.g. F4BPO/P — leave blank to use the QSO's own call", 'es.lotwForceHint': 'Overrides STATION_CALLSIGN at sign time so one certificate can sign several calls (F4BPO, F4BPO/P, TM2Q). Pick the matching Station Location above.', 'es.keyPw': 'Key password', 'es.keyPwPh': 'only if your certificate key has a password', 'es.considerUnsent': 'Consider as unsent', 'es.flagN': 'No (N)', 'es.flagR': 'Requested (R)', 'es.lotwUnsentHint': "At app close, every QSO whose LoTW sent status is one of these is signed and uploaded in one TQSL batch — including QSOs imported from an ADIF. Uploaded QSOs become Y and won't be re-sent. Must include your default sent status from Confirmations.", 'es.lotwAutoClose': 'Automatic upload on application close', 'es.lotwWriteLog': 'Write TQSL diagnostic log (-t)',
|
||||
'es.potaHint': 'Update your QSOs with the park reference from your pota.app hunter log. Paste your session token: log in at pota.app, open the browser DevTools → Network tab, click any api.pota.app request, and copy the full Authorization header value. The token expires after a while — re-copy it if the sync fails.', 'es.sessionToken': 'Session token', 'es.saving': 'Saving…', 'es.saveToken': 'Save token', 'es.potaThen': 'Then run the sync from the QSL Manager tab → service POTA hunter log (you can see and fix unmatched QSOs there).',
|
||||
'es.soon': 'soon', 'es.comingSoon': '{name} — coming soon', 'es.notWired': "This external service isn't wired up yet.",
|
||||
'es.soon': 'soon', 'es.deleteRemote': 'Also delete from QRZ.com and Club Log', 'es.deleteRemoteHint': 'When you delete a QSO here, withdraw it from those services too. QRZ.com can only delete records OpsLog uploaded itself \u2014 older ones must be removed on the website.', 'es.comingSoon': '{name} — coming soon', 'es.notWired': "This external service isn't wired up yet.",
|
||||
'lotw.usersTitle': 'LoTW user list', 'lotw.usersHint': "Downloads ARRL's public list of LoTW users + their last-upload date, to show a colour-coded LoTW badge next to a callsign (green < 1 week · amber 1–4 weeks · red > 30 days).", 'lotw.usersDownload': 'Download LoTW user list', 'lotw.usersDownloading': 'Downloading…', 'lotw.usersLoaded': '{n} users loaded · updated {date}', 'lotw.usersNone': 'Not downloaded yet — the badge stays hidden until you do.',
|
||||
'scp.title': 'Super Check Partial', 'scp.partial': 'Partial', 'scp.close': 'Close', 'scp.fill': 'Fill {call}', 'scp.none': 'No match', 'scp.typeMore': 'Type a call…', 'scp.noList': 'Download the SCP list in Settings → General.', 'scp.enable': 'Super Check Partial / N+1 callsign helper', 'scp.settingsHint': 'Downloads the community MASTER.SCP master list and shows a two-column widget as you type a call: Partial (known calls containing what you typed) and N+1 (calls one character away) — to spot and fix a busted call.', 'scp.download': 'Update SCP list', 'scp.downloading': 'Downloading…', 'scp.loaded': '{n} calls loaded · updated {date}', 'scp.notLoaded': 'Not downloaded yet.',
|
||||
'hw.connecting': 'Connecting…', 'hw.testConn': 'Test connection', 'hw.sending': 'Sending…', 'hw.testRotator': 'Test (point to 0°)',
|
||||
@@ -398,7 +398,7 @@ const en: Dict = {
|
||||
'awed.importReplace': 'Replace mine',
|
||||
'awed.importCopy': 'Import as {code}-2',
|
||||
// QSO modals (context menu / bulk edit / QSL manager / QSO edit)
|
||||
'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from QRZ.com', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportSelectedFields': 'Export selected — choose fields… ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…',
|
||||
'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from the callsign databases', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportSelectedFields': 'Export selected — choose fields… ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…',
|
||||
'exp.title': 'Export to ADIF', 'exp.desc': 'Choose which fields to write.',
|
||||
'exp.stdTitle': 'Standard ADIF fields', 'exp.stdDesc': 'Official ADIF 3.1.7 fields only — best for uploading to other logbooks (LoTW, QRZ, Club Log…).',
|
||||
'exp.allTitle': 'All OpsLog fields', 'exp.allDesc': 'Everything, including OpsLog-specific and app-defined tags — a full backup you can re-import here.',
|
||||
@@ -475,7 +475,7 @@ const fr: Dict = {
|
||||
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
|
||||
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': 'Nouveautés', 'whatsnew.close': 'Compris', 'whatsnew.none': 'Aucune nouveauté pour cette version pour le moment.',
|
||||
'upd.checking': 'Recherche de mises à jour…', 'upd.upToDate': 'Vous êtes à jour',
|
||||
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
|
||||
'settings.language': 'Langue', 'gen.dateFormat': 'Affichage des dates', 'gen.dateStandard': 'Standard', 'gen.dateFR': 'Fran\u00e7ais', 'gen.dateUS': 'US', 'settings.languageHint': "Langue de l'interface.",
|
||||
'stats.tab': 'Statistiques', 'stats.title': 'Statistiques du journal', 'stats.loading': 'Analyse du journal…',
|
||||
'stats.noData': 'Aucune donnée', 'stats.charts': 'Graphiques', 'stats.table': 'Tableau', 'stats.refresh': 'Rafraîchir',
|
||||
'stats.qsos': 'QSO', 'stats.uniqueCalls': 'Indicatifs uniques', 'stats.entities': 'Entités',
|
||||
@@ -546,8 +546,8 @@ 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.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.cancel': 'Annuler', '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…',
|
||||
'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.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.complete': 'Import terminé.',
|
||||
'imp.imported': '{n} importé(s)', 'imp.updated': '{n} mis à jour', 'imp.duplicates': '{n} doublon(s)', 'imp.skipped': '{n} ignoré(s)', 'imp.total': '{n} au total',
|
||||
@@ -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.",
|
||||
'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',
|
||||
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', '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 (CI-V natif)', '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 (CAT natif)', 'cat.optKenwood': 'Kenwood (natif)', 'cat.civTrace': 'Journaliser le protocole CI-V', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CI-V \u00e9chang\u00e9e avec la radio, en hexad\u00e9cimal. 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.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 série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
||||
'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.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.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
|
||||
@@ -698,7 +698,7 @@ const fr: Dict = {
|
||||
'es.usernameCall': 'Identifiant (indicatif)', 'es.eqslUserPh': 'ton identifiant eQSL.cc (indicatif)', 'es.eqslPwPh': 'mot de passe du compte eQSL.cc', 'es.qthNick': 'Surnom QTH', 'es.qthNickPh': 'optionnel — requis seulement si ton compte eQSL a plusieurs QTH', 'es.eqslHint': "Le surnom QTH indique à eQSL sous quel profil de lieu classer le QSO. Laisse vide si tu n'en as qu'un.",
|
||||
'es.lotwUser': 'Utilisateur LoTW', 'es.lotwUserPh': 'identifiant du site LoTW (pour télécharger les confirmations)', 'es.lotwPw': 'Mot de passe LoTW', 'es.lotwPwPh': 'mot de passe du site LoTW', 'es.tqslPath': 'Chemin TQSL', 'es.stationLoc': 'Station location', 'es.pickLoc': '— choisir une Station Location TQSL —', 'es.noLoc': 'Aucune Station Location TQSL trouvée', 'es.reloadLoc': 'Recharger les locations depuis TQSL', 'es.lotwForcePh': "p. ex. F4BPO/P — laisse vide pour utiliser l'indicatif du QSO", 'es.lotwForceHint': "Remplace STATION_CALLSIGN à la signature pour qu'un même certificat signe plusieurs indicatifs (F4BPO, F4BPO/P, TM2Q). Choisis la Station Location correspondante ci-dessus.", 'es.keyPw': 'Mot de passe de la clé', 'es.keyPwPh': 'seulement si la clé de ton certificat a un mot de passe', 'es.considerUnsent': 'Considérer comme non envoyé', 'es.flagN': 'Non (N)', 'es.flagR': 'Demandé (R)', 'es.lotwUnsentHint': "À la fermeture, chaque QSO dont le statut LoTW « envoyé » est l'un de ceux-ci est signé et envoyé dans un même lot TQSL — y compris les QSO importés depuis un ADIF. Les QSO envoyés passent à Y et ne sont pas renvoyés. Doit inclure ton statut « envoyé » par défaut défini dans Confirmations.", 'es.lotwAutoClose': "Envoi automatique à la fermeture de l'application", 'es.lotwWriteLog': 'Écrire le journal de diagnostic TQSL (-t)',
|
||||
'es.potaHint': "Met à jour tes QSO avec la référence du parc depuis ton carnet chasseur pota.app. Colle ton jeton de session : connecte-toi sur pota.app, ouvre les DevTools du navigateur → onglet Network, clique sur une requête api.pota.app, et copie toute la valeur de l'en-tête Authorization. Le jeton expire au bout d'un moment — recopie-le si la synchro échoue.", 'es.sessionToken': 'Jeton de session', 'es.saving': 'Enregistrement…', 'es.saveToken': 'Enregistrer le jeton', 'es.potaThen': "Puis lance la synchro depuis l'onglet Gestionnaire QSL → service POTA hunter log (tu peux y voir et corriger les QSO non appariés).",
|
||||
'es.soon': 'bientôt', 'es.comingSoon': '{name} — bientôt', 'es.notWired': "Ce service externe n'est pas encore branché.",
|
||||
'es.soon': 'bientôt', 'es.deleteRemote': 'Supprimer aussi sur QRZ.com et Club Log', 'es.deleteRemoteHint': 'Quand vous supprimez un QSO ici, le retirer aussi de ces services. QRZ.com ne peut supprimer que les enregistrements envoy\u00e9s par OpsLog \u2014 les plus anciens doivent \u00eatre retir\u00e9s sur le site.', 'es.comingSoon': '{name} — bientôt', 'es.notWired': "Ce service externe n'est pas encore branché.",
|
||||
'lotw.usersTitle': 'Liste des utilisateurs LoTW', 'lotw.usersHint': "Télécharge la liste publique ARRL des utilisateurs LoTW + leur date de dernier upload, pour afficher un badge LoTW coloré à côté d'un indicatif (vert < 1 semaine · ambre 1–4 semaines · rouge > 30 j).", 'lotw.usersDownload': 'Télécharger la liste LoTW', 'lotw.usersDownloading': 'Téléchargement…', 'lotw.usersLoaded': '{n} utilisateurs chargés · maj {date}', 'lotw.usersNone': 'Pas encore téléchargée — le badge reste caché tant que ce n’est pas fait.',
|
||||
'scp.title': 'Super Check Partial', 'scp.partial': 'Partiel', 'scp.close': 'Fermer', 'scp.fill': 'Remplir {call}', 'scp.none': 'Aucun', 'scp.typeMore': 'Tape un indicatif…', 'scp.noList': 'Télécharge la liste SCP dans Réglages → Général.', 'scp.enable': 'Assistant indicatifs Super Check Partial / N+1', 'scp.settingsHint': "Télécharge la liste communautaire MASTER.SCP et affiche un widget en 2 colonnes pendant que tu tapes un indicatif : Partiel (indicatifs connus contenant ce que tu tapes) et N+1 (indicatifs à une lettre près) — pour repérer et corriger un call busté.", 'scp.download': 'Mettre à jour la liste SCP', 'scp.downloading': 'Téléchargement…', 'scp.loaded': '{n} indicatifs chargés · maj {date}', 'scp.notLoaded': 'Pas encore téléchargée.',
|
||||
'hw.connecting': 'Connexion…', 'hw.testConn': 'Tester la connexion', 'hw.sending': 'Envoi…', 'hw.testRotator': 'Tester (pointer vers 0°)',
|
||||
@@ -790,7 +790,7 @@ const fr: Dict = {
|
||||
'awed.importKeepMine': 'Garder le mien',
|
||||
'awed.importReplace': 'Remplacer le mien',
|
||||
'awed.importCopy': 'Importer en {code}-2',
|
||||
'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis QRZ.com', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…',
|
||||
'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis les annuaires', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…',
|
||||
'exp.title': 'Exporter en ADIF', 'exp.desc': 'Choisissez les champs à écrire.',
|
||||
'exp.stdTitle': 'Champs ADIF standard', 'exp.stdDesc': 'Uniquement les champs officiels ADIF 3.1.7 — idéal pour l’envoi vers d’autres carnets (LoTW, QRZ, Club Log…).',
|
||||
'exp.allTitle': 'Tous les champs OpsLog', 'exp.allDesc': 'Tout, y compris les champs spécifiques à OpsLog et les balises applicatives — une sauvegarde complète ré-importable ici.',
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Colouring for QSL / confirmation status letters.
|
||||
//
|
||||
// These columns are read by COLOUR before they are read as letters: an operator
|
||||
// scanning a hundred rows wants to see where the gaps are, not to read "Y" a
|
||||
// hundred times.
|
||||
//
|
||||
// Colour ONLY — no pill, no badge. These values sit among callsigns and dates,
|
||||
// and a column of coloured boxes would shout louder than the callsign it
|
||||
// belongs to.
|
||||
//
|
||||
// The classes are semantic tokens, not fixed colours, so all four themes follow
|
||||
// and the contrast stays right on the light ones.
|
||||
|
||||
// ADIF confirmation values, and what they mean to the operator:
|
||||
// Y confirmed / sent
|
||||
// N not sent, not received
|
||||
// R requested — queued, waiting on the other station or the service
|
||||
// I ignore (ADIF's "invalid/ignore"), left in default ink: it is neither
|
||||
// good news nor bad, and colouring it would put it in one camp or the
|
||||
// other.
|
||||
//
|
||||
// Anything else — a blank, or a status some other logger wrote — is left alone
|
||||
// rather than guessed at.
|
||||
export function qslStatusClass(value: unknown): string {
|
||||
switch (String(value ?? '').trim().toUpperCase()) {
|
||||
case 'Y':
|
||||
return 'text-success';
|
||||
case 'N':
|
||||
return 'text-destructive';
|
||||
case 'R':
|
||||
return 'text-info';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// qslStatusCellClass is the AG-Grid form: same rule, plus the monospace the
|
||||
// status columns already used so the letters stay in a straight column.
|
||||
export function qslStatusCellClass(p: { value?: unknown }): string {
|
||||
const c = qslStatusClass(p?.value);
|
||||
return c ? 'font-mono ' + c : 'font-mono';
|
||||
}
|
||||
@@ -28,6 +28,9 @@ const PORTABLE_KEYS = [
|
||||
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
|
||||
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
||||
'opslog.dateFormat', // how dates are DISPLAYED (iso / fr / us); storage stays ISO
|
||||
'opslog.mapGreyline', // world map: grey line (day/night terminator) shown
|
||||
'opslog.awardRefSort', 'opslog.awardRefSortDir', // award reference table: sort column and direction
|
||||
// Cluster filter selections — restored on reopen.
|
||||
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
||||
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { syncPortablePrefs } from './lib/uiPref'
|
||||
import { I18nProvider } from './lib/i18n'
|
||||
import { ThemeProvider, initTheme } from './lib/theme'
|
||||
import { ErrorBoundary, installGlobalErrorLogging } from './components/ErrorBoundary'
|
||||
import { reloadDateFormat } from './lib/dateFormat'
|
||||
|
||||
const container = document.getElementById('root')
|
||||
|
||||
@@ -18,6 +19,10 @@ syncPortablePrefs().finally(() => {
|
||||
// Stamp the persisted theme onto <html> before render so the correct
|
||||
// palette is applied immediately (portable pref hydrated just above).
|
||||
initTheme()
|
||||
// The date format module read localStorage at import time, which is BEFORE
|
||||
// the portable prefs were pulled from the database. Re-read it now, or a
|
||||
// copied data/ folder would show ISO until the next launch.
|
||||
reloadDateFormat()
|
||||
// Catch what a boundary cannot: errors thrown from timers, event handlers and
|
||||
// rejected promises. Installed before the first render so a fault during
|
||||
// startup is reported too.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.22.2';
|
||||
export const APP_VERSION = '0.22.3';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+14
@@ -91,6 +91,8 @@ export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Prom
|
||||
|
||||
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||
|
||||
export function CIVTraceEnabled():Promise<boolean>;
|
||||
|
||||
export function CWDecoderRunning():Promise<boolean>;
|
||||
|
||||
export function ChatAvailable():Promise<boolean>;
|
||||
@@ -747,10 +749,20 @@ export function QSOAudioBegin():Promise<boolean>;
|
||||
|
||||
export function QSOAudioCancel():Promise<void>;
|
||||
|
||||
export function QSOAudioManualReady():Promise<boolean>;
|
||||
|
||||
export function QSOAudioManualStart():Promise<boolean>;
|
||||
|
||||
export function QSOAudioPlayOnAir():Promise<void>;
|
||||
|
||||
export function QSOAudioResetClock():Promise<boolean>;
|
||||
|
||||
export function QSOAudioRestart():Promise<boolean>;
|
||||
|
||||
export function QSOAudioResume():Promise<boolean>;
|
||||
|
||||
export function QSOAudioStop():Promise<boolean>;
|
||||
|
||||
export function QuitApp():Promise<void>;
|
||||
|
||||
export function RecomputeAllAwardRefs():Promise<number>;
|
||||
@@ -1037,6 +1049,8 @@ export function WinkeyerSetSpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function WinkeyerStop():Promise<void>;
|
||||
|
||||
export function WinkeyerTraceEnabled():Promise<boolean>;
|
||||
|
||||
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
||||
|
||||
export function YaesuSendCW(arg1:string):Promise<void>;
|
||||
|
||||
@@ -130,6 +130,10 @@ export function BulkUpdateQSL(arg1, arg2) {
|
||||
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function CIVTraceEnabled() {
|
||||
return window['go']['main']['App']['CIVTraceEnabled']();
|
||||
}
|
||||
|
||||
export function CWDecoderRunning() {
|
||||
return window['go']['main']['App']['CWDecoderRunning']();
|
||||
}
|
||||
@@ -1442,6 +1446,18 @@ export function QSOAudioCancel() {
|
||||
return window['go']['main']['App']['QSOAudioCancel']();
|
||||
}
|
||||
|
||||
export function QSOAudioManualReady() {
|
||||
return window['go']['main']['App']['QSOAudioManualReady']();
|
||||
}
|
||||
|
||||
export function QSOAudioManualStart() {
|
||||
return window['go']['main']['App']['QSOAudioManualStart']();
|
||||
}
|
||||
|
||||
export function QSOAudioPlayOnAir() {
|
||||
return window['go']['main']['App']['QSOAudioPlayOnAir']();
|
||||
}
|
||||
|
||||
export function QSOAudioResetClock() {
|
||||
return window['go']['main']['App']['QSOAudioResetClock']();
|
||||
}
|
||||
@@ -1450,6 +1466,14 @@ export function QSOAudioRestart() {
|
||||
return window['go']['main']['App']['QSOAudioRestart']();
|
||||
}
|
||||
|
||||
export function QSOAudioResume() {
|
||||
return window['go']['main']['App']['QSOAudioResume']();
|
||||
}
|
||||
|
||||
export function QSOAudioStop() {
|
||||
return window['go']['main']['App']['QSOAudioStop']();
|
||||
}
|
||||
|
||||
export function QuitApp() {
|
||||
return window['go']['main']['App']['QuitApp']();
|
||||
}
|
||||
@@ -2022,6 +2046,10 @@ export function WinkeyerStop() {
|
||||
return window['go']['main']['App']['WinkeyerStop']();
|
||||
}
|
||||
|
||||
export function WinkeyerTraceEnabled() {
|
||||
return window['go']['main']['App']['WinkeyerTraceEnabled']();
|
||||
}
|
||||
|
||||
export function WorkedBefore(arg1, arg2) {
|
||||
return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -1297,6 +1297,7 @@ export namespace extsvc {
|
||||
hrdlog: ServiceConfig;
|
||||
eqsl: ServiceConfig;
|
||||
cloudlog: ServiceConfig;
|
||||
delete_remote: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ExternalServices(source);
|
||||
@@ -1310,6 +1311,7 @@ export namespace extsvc {
|
||||
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
|
||||
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
|
||||
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
|
||||
this.delete_remote = source["delete_remote"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
@@ -1906,6 +1908,7 @@ export namespace main {
|
||||
xiegu_addr: number;
|
||||
yaesu_port: string;
|
||||
yaesu_baud: number;
|
||||
kenwood_host: string;
|
||||
kenwood_port: string;
|
||||
kenwood_baud: number;
|
||||
icom_port: string;
|
||||
@@ -1944,6 +1947,7 @@ export namespace main {
|
||||
this.xiegu_addr = source["xiegu_addr"];
|
||||
this.yaesu_port = source["yaesu_port"];
|
||||
this.yaesu_baud = source["yaesu_baud"];
|
||||
this.kenwood_host = source["kenwood_host"];
|
||||
this.kenwood_port = source["kenwood_port"];
|
||||
this.kenwood_baud = source["kenwood_baud"];
|
||||
this.icom_port = source["icom_port"];
|
||||
|
||||
@@ -101,3 +101,30 @@ func endpointName(dev *wca.IMMDevice, fallback string) string {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
+242
-23
@@ -15,6 +15,14 @@ import (
|
||||
// Defaults to a no-op so the package is usable without wiring.
|
||||
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
|
||||
// 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
|
||||
@@ -42,16 +50,31 @@ type Recorder struct {
|
||||
prerollSamples int
|
||||
|
||||
// Per-source sample queues (guarded by srcMu), drained by the mixer.
|
||||
srcMu sync.Mutex
|
||||
bufA []int16 // From Radio
|
||||
bufB []int16 // mic
|
||||
twoSrc bool
|
||||
gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu
|
||||
gainB float64 // mic gain
|
||||
srcMu sync.Mutex
|
||||
bufA []int16 // From Radio
|
||||
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
|
||||
gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu
|
||||
gainB float64 // mic gain
|
||||
|
||||
// Mixed output state (guarded by mu).
|
||||
ring []int16 // last prerollSamples of mixed audio
|
||||
active bool
|
||||
// paused freezes an ACTIVE take: nothing more is accumulated, but everything
|
||||
// captured so far is kept and the take still ends normally when the QSO is
|
||||
// logged. It is what lets an operator stop, play the recording back on the
|
||||
// air to the station they are working, and still have it saved with the QSO.
|
||||
paused bool
|
||||
acc []int16 // active QSO accumulation (seeded from ring on BeginQSO)
|
||||
}
|
||||
|
||||
@@ -110,11 +133,37 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
||||
if 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.twoSrc = micDev != "" && micDev != fromDev
|
||||
r.stopCh = make(chan struct{})
|
||||
r.running = true
|
||||
r.ring, r.acc, r.active, r.bufA, r.bufB = nil, nil, false, nil, nil
|
||||
r.startedAt = time.Now()
|
||||
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
|
||||
twoSrc := r.twoSrc
|
||||
r.mu.Unlock()
|
||||
@@ -124,27 +173,67 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
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)
|
||||
r.srcMu.Lock()
|
||||
r.bufA = append(r.bufA, s...)
|
||||
r.lastA = time.Now()
|
||||
r.srcMu.Unlock()
|
||||
})
|
||||
}); err != nil {
|
||||
LogSink("recorder: capture from %q failed: %v", DeviceName(fromDev), err)
|
||||
}
|
||||
}()
|
||||
if twoSrc {
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
defer recoverGoroutine("recorder capture (mic)")
|
||||
_ = captureStream(micDev, stop, func(chunk []byte) {
|
||||
if err := captureStream(micDev, stop, func(chunk []byte) {
|
||||
s := bytesToInt16(chunk)
|
||||
r.srcMu.Lock()
|
||||
r.bufB = append(r.bufB, s...)
|
||||
r.lastB = time.Now()
|
||||
r.srcMu.Unlock()
|
||||
})
|
||||
}); err != nil {
|
||||
LogSink("recorder: capture from %q failed: %v", DeviceName(micDev), err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 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.
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
@@ -166,15 +255,79 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
||||
|
||||
// mixTick drains the source queues, mixes what's available, and appends to the
|
||||
// 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() {
|
||||
r.srcMu.Lock()
|
||||
var mixed []int16
|
||||
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)
|
||||
if len(r.bufB) < n {
|
||||
n = len(r.bufB)
|
||||
}
|
||||
if n > 0 {
|
||||
// Both alive again after one was written off.
|
||||
r.deadA, r.deadB = false, false
|
||||
mixed = make([]int16, n)
|
||||
for i := 0; i < n; i++ {
|
||||
mixed[i] = clampSum(scaleSample(r.bufA[i], r.gainA), scaleSample(r.bufB[i], r.gainB))
|
||||
@@ -182,12 +335,20 @@ func (r *Recorder) mixTick() {
|
||||
r.bufA = append(r.bufA[:0], r.bufA[n:]...)
|
||||
r.bufB = append(r.bufB[:0], r.bufB[n:]...)
|
||||
}
|
||||
// Drift guard: if the clocks diverge, drop the excess so the two
|
||||
// sources stay roughly aligned (≤1 s skew).
|
||||
if d := len(r.bufA) - len(r.bufB); d > sampleRate {
|
||||
r.bufA = append(r.bufA[:0], r.bufA[d:]...)
|
||||
} else if d < -sampleRate {
|
||||
r.bufB = append(r.bufB[:0], r.bufB[-d:]...)
|
||||
// Drift guard: two sound cards run on their own clocks, so drop the
|
||||
// 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 {
|
||||
r.bufA = append(r.bufA[:0], r.bufA[d:]...)
|
||||
} else if d < -sampleRate {
|
||||
r.bufB = append(r.bufB[:0], r.bufB[-d:]...)
|
||||
}
|
||||
}
|
||||
} else if len(r.bufA) > 0 {
|
||||
mixed = make([]int16, len(r.bufA))
|
||||
@@ -198,6 +359,12 @@ func (r *Recorder) mixTick() {
|
||||
}
|
||||
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 {
|
||||
return
|
||||
}
|
||||
@@ -206,7 +373,7 @@ func (r *Recorder) mixTick() {
|
||||
if len(r.ring) > r.prerollSamples {
|
||||
r.ring = append(r.ring[:0], r.ring[len(r.ring)-r.prerollSamples:]...)
|
||||
}
|
||||
if r.active {
|
||||
if r.active && !r.paused {
|
||||
r.acc = append(r.acc, mixed...)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
@@ -221,7 +388,7 @@ func (r *Recorder) BeginQSO() {
|
||||
return
|
||||
}
|
||||
r.acc = append([]int16(nil), r.ring...)
|
||||
r.active = true
|
||||
r.active, r.paused = true, false
|
||||
}
|
||||
|
||||
// RestartQSO begins a fresh accumulation even if one is already active —
|
||||
@@ -236,7 +403,7 @@ func (r *Recorder) RestartQSO() {
|
||||
return
|
||||
}
|
||||
r.acc = append([]int16(nil), r.ring...)
|
||||
r.active = true
|
||||
r.active, r.paused = true, false
|
||||
}
|
||||
|
||||
// ResetQSOClock restarts the active accumulation from ZERO — discarding
|
||||
@@ -266,7 +433,7 @@ func (r *Recorder) TakeQSO() ([]byte, error) {
|
||||
return nil, fmt.Errorf("no active recording")
|
||||
}
|
||||
samples := r.acc
|
||||
r.acc, r.active = nil, false
|
||||
r.acc, r.active, r.paused = nil, false, false
|
||||
r.mu.Unlock()
|
||||
if len(samples) == 0 {
|
||||
return nil, fmt.Errorf("recording was empty")
|
||||
@@ -295,7 +462,7 @@ func (r *Recorder) SaveQSO(path string) error {
|
||||
// DiscardQSO drops the active accumulation without saving (callsign cleared).
|
||||
func (r *Recorder) DiscardQSO() {
|
||||
r.mu.Lock()
|
||||
r.acc, r.active = nil, false
|
||||
r.acc, r.active, r.paused = nil, false, false
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -313,7 +480,7 @@ func (r *Recorder) Stop() {
|
||||
close(stop)
|
||||
r.wg.Wait()
|
||||
r.mu.Lock()
|
||||
r.ring, r.acc, r.active = nil, nil, false
|
||||
r.ring, r.acc, r.active, r.paused = nil, nil, false, false
|
||||
r.mu.Unlock()
|
||||
r.srcMu.Lock()
|
||||
r.bufA, r.bufB = nil, nil
|
||||
@@ -346,3 +513,55 @@ func int16sToBytes(s []int16) []byte {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// PauseQSO freezes the active take without ending it: nothing more is recorded,
|
||||
// nothing is thrown away, and logging the QSO still writes the file.
|
||||
//
|
||||
// This exists for one situation, which is common enough on the bands to be
|
||||
// worth the state: you are working a station, you have been recording, and they
|
||||
// ask to hear it. You stop, you play it back to them on the air, and the
|
||||
// recording is still saved with the QSO afterwards.
|
||||
func (r *Recorder) PauseQSO() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.active {
|
||||
return false
|
||||
}
|
||||
r.paused = true
|
||||
return true
|
||||
}
|
||||
|
||||
// ResumeQSO continues an interrupted take, appending to what is already there.
|
||||
func (r *Recorder) ResumeQSO() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.active {
|
||||
return false
|
||||
}
|
||||
r.paused = false
|
||||
return true
|
||||
}
|
||||
|
||||
// Paused reports whether the active take is frozen.
|
||||
func (r *Recorder) Paused() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.active && r.paused
|
||||
}
|
||||
|
||||
// PeekQSO returns what has been captured so far WITHOUT ending the take.
|
||||
//
|
||||
// Unlike TakeQSO this keeps the audio, because the take is going to be played
|
||||
// back and then still saved with the QSO. The copy is deliberate: the caller
|
||||
// gets bytes it can hold while the recorder keeps appending to its own slice.
|
||||
func (r *Recorder) PeekQSO() ([]byte, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.active {
|
||||
return nil, fmt.Errorf("no active recording")
|
||||
}
|
||||
if len(r.acc) == 0 {
|
||||
return nil, fmt.Errorf("recording is empty")
|
||||
}
|
||||
return int16sToBytes(append([]int16(nil), r.acc...)), nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -33,13 +33,13 @@ func writeWAV(path string, pcm []byte) error {
|
||||
put(uint32(36 + dataLen))
|
||||
f.WriteString("WAVE")
|
||||
f.WriteString("fmt ")
|
||||
put(uint32(16)) // PCM fmt chunk size
|
||||
put(uint16(1)) // WAVE_FORMAT_PCM
|
||||
put(uint16(channels)) //
|
||||
put(uint32(sampleRate)) //
|
||||
put(uint32(bytesPerSec)) // byte rate
|
||||
put(uint16(blockAlign)) //
|
||||
put(uint16(bitsPerSample)) //
|
||||
put(uint32(16)) // PCM fmt chunk size
|
||||
put(uint16(1)) // WAVE_FORMAT_PCM
|
||||
put(uint16(channels)) //
|
||||
put(uint32(sampleRate)) //
|
||||
put(uint32(bytesPerSec)) // byte rate
|
||||
put(uint16(blockAlign)) //
|
||||
put(uint16(bitsPerSample)) //
|
||||
f.WriteString("data")
|
||||
put(uint32(dataLen))
|
||||
_, err = f.Write(pcm)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+76
-5
@@ -35,7 +35,12 @@ type Flex struct {
|
||||
model string
|
||||
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)
|
||||
amp flexAmp // external amplifier (PowerGenius XL) state
|
||||
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{},
|
||||
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||
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
|
||||
// flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever.
|
||||
// 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
|
||||
chosen := -1
|
||||
for _, idx := range f.sortedSliceIdxLocked() {
|
||||
s := f.slices[idx]
|
||||
if !s.inUse {
|
||||
@@ -978,11 +994,56 @@ func (f *Flex) mainSliceLocked() (int, *flexSlice) {
|
||||
firstInUse = idx
|
||||
}
|
||||
if s.active {
|
||||
return idx, s
|
||||
chosen = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
if firstInUse >= 0 {
|
||||
return firstInUse, f.slices[firstInUse]
|
||||
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 -1, nil
|
||||
}
|
||||
@@ -1089,6 +1150,11 @@ func (f *Flex) SetActiveSlice(idx int) error {
|
||||
if !exists {
|
||||
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))
|
||||
return nil
|
||||
}
|
||||
@@ -1389,6 +1455,7 @@ func (f *Flex) FlexState() FlexTXState {
|
||||
}
|
||||
}
|
||||
sort.Ints(sidx)
|
||||
mainIdx, _ := f.mainSliceLocked()
|
||||
for _, i := range sidx {
|
||||
s := f.slices[i]
|
||||
st.Slices = append(st.Slices, FlexSliceInfo{
|
||||
@@ -1397,7 +1464,11 @@ func (f *Flex) FlexState() FlexTXState {
|
||||
FreqHz: s.freqHz,
|
||||
Mode: flexModeToADIF(s.mode),
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+154
-5
@@ -29,7 +29,9 @@ package cat
|
||||
// from the radio, and the rig file decides what a "Freq" property means.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -43,11 +45,26 @@ import (
|
||||
type Kenwood struct {
|
||||
portName string
|
||||
baud int
|
||||
digital string // mode name logged for data (FT8 by default)
|
||||
// host is "address:port" for a serial link reached over the network — a
|
||||
// ser2net daemon, an Ethernet-serial adapter, a Raspberry Pi in the shack.
|
||||
//
|
||||
// This is NOT Kenwood's own network protocol. A TS-890 or TS-990 speaks
|
||||
// KNS/ARCP over its Ethernet socket, with a session and authentication, and
|
||||
// that is a different piece of work needing one of those radios to confirm
|
||||
// it. What this covers is the same CAT byte stream over a socket instead of
|
||||
// a wire, which is how most operators actually put a rig on the network.
|
||||
host string
|
||||
digital string // mode name logged for data (FT8 by default)
|
||||
|
||||
mu sync.Mutex
|
||||
port serial.Port
|
||||
|
||||
// dialPort, when set, replaces serial.Open. It exists so the backend can be
|
||||
// driven against internal/catemu — which already SPEAKS this dialect to
|
||||
// satisfy an ACOM amplifier — without a radio, a COM port or a null-modem
|
||||
// pair. Written for a Kenwood backend nobody here owns a rig to test.
|
||||
dialPort func() (serial.Port, error)
|
||||
|
||||
model string
|
||||
curFreq int64
|
||||
curRXFreq int64
|
||||
@@ -56,6 +73,14 @@ type Kenwood struct {
|
||||
unsupported map[string]bool
|
||||
}
|
||||
|
||||
// NewKenwoodTCP builds a backend that reaches the rig over a socket instead of
|
||||
// a COM port (ser2net and friends).
|
||||
func NewKenwoodTCP(hostPort, digital string) *Kenwood {
|
||||
k := NewKenwood("", 0, digital)
|
||||
k.host = strings.TrimSpace(hostPort)
|
||||
return k
|
||||
}
|
||||
|
||||
func NewKenwood(portName string, baud int, digital string) *Kenwood {
|
||||
if baud <= 0 {
|
||||
baud = 9600 // TS-590 factory default; TS-890 ships at 115200
|
||||
@@ -71,8 +96,8 @@ func (k *Kenwood) Name() string { return "kenwood" }
|
||||
func (k *Kenwood) Connect() error {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.portName == "" {
|
||||
return fmt.Errorf("kenwood: no serial port configured")
|
||||
if k.portName == "" && k.host == "" {
|
||||
return fmt.Errorf("kenwood: no serial port or network address configured")
|
||||
}
|
||||
// Close any handle still held before opening another: Connect runs again on
|
||||
// every reconnect, and Windows opens a serial port exclusively, so a leaked
|
||||
@@ -82,8 +107,11 @@ func (k *Kenwood) Connect() error {
|
||||
_ = k.port.Close()
|
||||
k.port = nil
|
||||
}
|
||||
p, err := serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
|
||||
p, err := k.openPort()
|
||||
if err != nil {
|
||||
if k.host != "" {
|
||||
return fmt.Errorf("kenwood: connect %s: %w", k.host, err)
|
||||
}
|
||||
return fmt.Errorf("kenwood: open %s @ %d baud: %w", k.portName, k.baud, err)
|
||||
}
|
||||
p.SetReadTimeout(300 * time.Millisecond)
|
||||
@@ -112,9 +140,19 @@ func (k *Kenwood) Connect() error {
|
||||
}
|
||||
if !answered {
|
||||
k.model = ""
|
||||
if k.host != "" {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
debugLog.Printf("kenwood: connected on %s @ %d baud, model=%q", k.portName, k.baud, k.model)
|
||||
// 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
|
||||
// serial fault that does not exist.
|
||||
if k.host != "" {
|
||||
debugLog.Printf("kenwood: connected to %s (network serial bridge), model=%q", k.host, k.model)
|
||||
} else {
|
||||
debugLog.Printf("kenwood: connected on %s @ %d baud, model=%q", k.portName, k.baud, k.model)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -154,6 +192,34 @@ func (k *Kenwood) ReadState() (RigState, error) {
|
||||
// IF reports the frequency of the VFO in USE (what the operator hears).
|
||||
rx := f.FreqHz
|
||||
tx := rx
|
||||
|
||||
// IF's split bit is not filled in by every rig that speaks this dialect —
|
||||
// reported on a Flex through its Kenwood CAT emulation, where the frequency
|
||||
// reads perfectly and split never appears. So ask the question directly as
|
||||
// well: split IS "the transmit VFO differs from the receive VFO", which is
|
||||
// what FR/FT answer, and it is the same rule the Yaesu backend settled on.
|
||||
//
|
||||
// A rig that rejects FR/FT answers "?;" once and is never asked again, so
|
||||
// this costs two short commands per poll only where it actually works.
|
||||
split := f.Split
|
||||
if !split {
|
||||
rxv, rxOK := k.askVFO("FR;")
|
||||
txv, txOK := k.askVFO("FT;")
|
||||
if rxOK && txOK && rxv != txv {
|
||||
split = true
|
||||
// Trust FR over IF for which VFO is in use: they were asked in the
|
||||
// same breath, and a rig that leaves the split bit empty may be just
|
||||
// as vague about the VFO field.
|
||||
// f.VFO too, not just the reported state: the block below picks the
|
||||
// TRANSMIT VFO as "the other one" from f.VFO, and leaving the two
|
||||
// disagreeing would read the transmit frequency off the wrong dial.
|
||||
if rxv == "A" || rxv == "B" {
|
||||
k.curVFO, s.Vfo, f.VFO = rxv, rxv, rxv
|
||||
}
|
||||
}
|
||||
}
|
||||
f.Split = split
|
||||
|
||||
if f.Split {
|
||||
// The transmit VFO is the other one. Read it rather than assume, and fall
|
||||
// back to simplex if it cannot be read: a wrong TX frequency is written
|
||||
@@ -229,6 +295,7 @@ func (k *Kenwood) write(cmd string) error {
|
||||
if k.port == nil {
|
||||
return fmt.Errorf("kenwood: not connected")
|
||||
}
|
||||
traceText("kenwood", "TX", cmd)
|
||||
_, err := k.port.Write([]byte(cmd))
|
||||
return err
|
||||
}
|
||||
@@ -264,6 +331,7 @@ func (k *Kenwood) ask(cmd string) (string, error) {
|
||||
}
|
||||
frame := string(buf[:i+1])
|
||||
buf = buf[i+1:]
|
||||
traceText("kenwood", "RX", frame)
|
||||
if frame == "?;" {
|
||||
// The rig rejected the command. Remember it so the poll loop stops
|
||||
// paying a 600 ms timeout for it on every cycle.
|
||||
@@ -411,3 +479,84 @@ var kenwoodModels = map[string]string{
|
||||
"024": "TS-990S",
|
||||
"025": "TS-890S",
|
||||
}
|
||||
|
||||
// openPort opens the serial link, or whatever dialPort provides in a test.
|
||||
func (k *Kenwood) openPort() (serial.Port, error) {
|
||||
if k.dialPort != nil {
|
||||
return k.dialPort()
|
||||
}
|
||||
if k.host != "" {
|
||||
c, err := net.DialTimeout("tcp", k.host, 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tcpSerial{conn: c}, nil
|
||||
}
|
||||
return serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
|
||||
}
|
||||
|
||||
// tcpSerial presents a TCP connection as a serial.Port, so the backend has one
|
||||
// code path whether the rig is on a wire or on the network.
|
||||
//
|
||||
// The modem-control methods are no-ops rather than errors: a bridge has no DTR
|
||||
// to raise, and failing them would break a caller that sets them defensively.
|
||||
type tcpSerial struct{ conn net.Conn }
|
||||
|
||||
func (t *tcpSerial) Read(p []byte) (int, error) {
|
||||
n, err := t.conn.Read(p)
|
||||
// A read deadline expiring is this transport's "no data yet", exactly what a
|
||||
// serial read timeout means to the caller — not a dead link. Reporting it as
|
||||
// an error would make ask() abandon a rig that is merely thinking.
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
return n, nil
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
func (t *tcpSerial) Write(p []byte) (int, error) { return t.conn.Write(p) }
|
||||
func (t *tcpSerial) Close() error { return t.conn.Close() }
|
||||
func (t *tcpSerial) SetReadTimeout(d time.Duration) error {
|
||||
if d <= 0 {
|
||||
return t.conn.SetReadDeadline(time.Time{})
|
||||
}
|
||||
return t.conn.SetReadDeadline(time.Now().Add(d))
|
||||
}
|
||||
func (t *tcpSerial) SetMode(*serial.Mode) error { return nil }
|
||||
func (t *tcpSerial) Drain() error { return nil }
|
||||
func (t *tcpSerial) ResetInputBuffer() error { return nil }
|
||||
func (t *tcpSerial) ResetOutputBuffer() error { return nil }
|
||||
func (t *tcpSerial) SetDTR(bool) error { return nil }
|
||||
func (t *tcpSerial) SetRTS(bool) error { return nil }
|
||||
func (t *tcpSerial) GetModemStatusBits() (*serial.ModemStatusBits, error) {
|
||||
return &serial.ModemStatusBits{}, nil
|
||||
}
|
||||
func (t *tcpSerial) Break(time.Duration) error { return nil }
|
||||
|
||||
// askVFO asks FR; or FT; and returns "A" or "B".
|
||||
//
|
||||
// The reply is FR0; / FR1; — the digit right after the two-letter command.
|
||||
// Anything else (a rig that answers with more fields, or not at all) returns
|
||||
// false, and the caller keeps whatever IF said rather than inventing a split.
|
||||
func (k *Kenwood) askVFO(cmd string) (string, bool) {
|
||||
r, err := k.ask(cmd)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
want := cmdPrefix(cmd)
|
||||
body := strings.TrimSuffix(strings.TrimPrefix(r, want), ";")
|
||||
if body == "" {
|
||||
return "", false
|
||||
}
|
||||
switch body[0] {
|
||||
case '0':
|
||||
return "A", true
|
||||
case '1':
|
||||
return "B", true
|
||||
}
|
||||
// 2 is "sub receiver" on a TS-2000 — real, but not a VFO we track. Saying
|
||||
// nothing is better than mapping it onto A or B and reporting a split that
|
||||
// does not exist.
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
// The Kenwood backend driven against a rig that answers, with no hardware.
|
||||
//
|
||||
// Nobody here owns a Kenwood, and a backend that has never completed a single
|
||||
// exchange is a guess however carefully it was written. But this repository
|
||||
// already contains the other half of the conversation: internal/catemu ANSWERS
|
||||
// this dialect, pretending to be a TS-2000 so an ACOM amplifier follows OpsLog.
|
||||
// The responder below replies exactly as catemu does — same FA/FB widths, same
|
||||
// 38-character IF layout, same ID019 — so the two halves are checked against
|
||||
// each other rather than against my reading of a manual.
|
||||
//
|
||||
// What this proves: the round trip completes, the model is identified, and
|
||||
// frequency, mode, VFO and split come back correct, including the second read
|
||||
// that split requires. What it cannot prove: that a real TS-590 answers on the
|
||||
// same timings, or that its firmware fills every IF field the way the
|
||||
// documentation says. Those still need a radio.
|
||||
|
||||
// fakeSerial is one end of an in-memory serial link. Only Read and Write carry
|
||||
// meaning; the rest satisfy the interface.
|
||||
type fakeSerial struct {
|
||||
mu sync.Mutex
|
||||
toRig *strings.Builder // what the backend has written
|
||||
fromRig []byte // what the rig has queued for the backend
|
||||
answer func(cmd string) string
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (f *fakeSerial) Write(p []byte) (int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.closed {
|
||||
return 0, fmt.Errorf("closed")
|
||||
}
|
||||
f.toRig.Write(p)
|
||||
// A rig answers as each terminated command arrives.
|
||||
for {
|
||||
s := f.toRig.String()
|
||||
i := strings.IndexByte(s, ';')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
cmd := s[:i+1]
|
||||
rest := s[i+1:]
|
||||
f.toRig.Reset()
|
||||
f.toRig.WriteString(rest)
|
||||
if r := f.answer(cmd); r != "" {
|
||||
f.fromRig = append(f.fromRig, r...)
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (f *fakeSerial) Read(p []byte) (int, error) {
|
||||
// A real port blocks until data or timeout; the backend polls, so returning
|
||||
// (0, nil) on an empty buffer models a read timeout with no bytes.
|
||||
for i := 0; i < 50; i++ {
|
||||
f.mu.Lock()
|
||||
if f.closed {
|
||||
f.mu.Unlock()
|
||||
return 0, fmt.Errorf("closed")
|
||||
}
|
||||
if len(f.fromRig) > 0 {
|
||||
n := copy(p, f.fromRig)
|
||||
f.fromRig = f.fromRig[n:]
|
||||
f.mu.Unlock()
|
||||
return n, nil
|
||||
}
|
||||
f.mu.Unlock()
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (f *fakeSerial) Close() error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSerial) SetMode(*serial.Mode) error { return nil }
|
||||
func (f *fakeSerial) Drain() error { return nil }
|
||||
func (f *fakeSerial) ResetInputBuffer() error { return nil }
|
||||
func (f *fakeSerial) ResetOutputBuffer() error { return nil }
|
||||
func (f *fakeSerial) SetDTR(bool) error { return nil }
|
||||
func (f *fakeSerial) SetRTS(bool) error { return nil }
|
||||
func (f *fakeSerial) GetModemStatusBits() (*serial.ModemStatusBits, error) {
|
||||
return &serial.ModemStatusBits{}, nil
|
||||
}
|
||||
func (f *fakeSerial) SetReadTimeout(time.Duration) error { return nil }
|
||||
func (f *fakeSerial) Break(time.Duration) error { return nil }
|
||||
|
||||
// ts2000 answers as internal/catemu does. vfoA/vfoB are the two dials; mode is
|
||||
// the Kenwood digit; split selects which VFO transmits.
|
||||
type ts2000 struct {
|
||||
vfoA, vfoB int64
|
||||
mode byte
|
||||
onB bool
|
||||
split bool
|
||||
// lazyIF models a rig that answers FR/FT correctly but never fills IF's
|
||||
// split bit — the behaviour reported on a Flex through its Kenwood CAT
|
||||
// emulation, where the frequency reads perfectly and split never appears.
|
||||
lazyIF bool
|
||||
// noVFOCmds models a rig that rejects FR/FT outright ("?;").
|
||||
noVFOCmds bool
|
||||
seen []string
|
||||
}
|
||||
|
||||
func (r *ts2000) answer(cmd string) string {
|
||||
r.seen = append(r.seen, cmd)
|
||||
cur := r.vfoA
|
||||
vfoDigit := byte('0')
|
||||
if r.onB {
|
||||
cur, vfoDigit = r.vfoB, '1'
|
||||
}
|
||||
switch {
|
||||
case cmd == "ID;":
|
||||
return "ID019;" // TS-2000, exactly what catemu reports
|
||||
case cmd == "FA;":
|
||||
return fmt.Sprintf("FA%011d;", r.vfoA)
|
||||
case cmd == "FB;":
|
||||
return fmt.Sprintf("FB%011d;", r.vfoB)
|
||||
case cmd == "IF;":
|
||||
split := byte('0')
|
||||
if r.split && !r.lazyIF {
|
||||
split = '1'
|
||||
}
|
||||
// The catemu layout: IF | freq(11) | step(4) | RIT(±5) | 3 | mem(2) |
|
||||
// rx/tx | mode | VFO | scan | split | tone | tone#(2) | shift | ;
|
||||
return fmt.Sprintf("IF%011d%04d%+06d%03d%02d%01d%c%c%01d%c%01d%02d%01d;",
|
||||
cur, 0, 0, 0, 0, 0, r.mode, vfoDigit, 0, split, 0, 0, 0)
|
||||
case strings.HasPrefix(cmd, "FA") && len(cmd) > 3:
|
||||
fmt.Sscanf(cmd, "FA%d;", &r.vfoA)
|
||||
return ""
|
||||
case strings.HasPrefix(cmd, "FB") && len(cmd) > 3:
|
||||
fmt.Sscanf(cmd, "FB%d;", &r.vfoB)
|
||||
return ""
|
||||
case strings.HasPrefix(cmd, "MD") && len(cmd) == 4:
|
||||
r.mode = cmd[2]
|
||||
return ""
|
||||
case cmd == "FR;" || cmd == "FT;":
|
||||
if r.noVFOCmds {
|
||||
return "?;" // rejected, as a rig that does not know the command answers
|
||||
}
|
||||
// FR is the receive VFO, FT the transmit one. They differ exactly when
|
||||
// the rig is in split.
|
||||
v := vfoDigit
|
||||
if cmd == "FT;" && r.split {
|
||||
if vfoDigit == '0' {
|
||||
v = '1'
|
||||
} else {
|
||||
v = '0'
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s%c;", strings.TrimSuffix(cmd, ";"), v)
|
||||
}
|
||||
return "" // AI0;, TX;, RX; — set commands, no reply, as on a real rig
|
||||
}
|
||||
|
||||
func dialTo(rig *ts2000) func() (serial.Port, error) {
|
||||
return func() (serial.Port, error) {
|
||||
return &fakeSerial{toRig: &strings.Builder{}, answer: rig.answer}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestKenwoodAgainstEmulatedRig(t *testing.T) {
|
||||
rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2'} // 20 m USB
|
||||
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||
k.dialPort = dialTo(rig)
|
||||
|
||||
if err := k.Connect(); err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer k.Disconnect()
|
||||
if k.model != "TS-2000" {
|
||||
t.Errorf("model = %q, want TS-2000 (from ID019)", k.model)
|
||||
}
|
||||
|
||||
// Simplex on A.
|
||||
s, err := k.ReadState()
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !s.Connected || s.FreqHz != 14250000 || s.Mode != "USB" || s.Vfo != "A" || s.Split {
|
||||
t.Errorf("simplex A gave %+v — want 14250000 USB on A, no split", s)
|
||||
}
|
||||
|
||||
// On B: everything must follow the VFO in use, the fault that took several
|
||||
// rounds to settle in the Yaesu backend.
|
||||
rig.onB = true
|
||||
if s, err = k.ReadState(); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if s.FreqHz != 14260000 || s.Vfo != "B" {
|
||||
t.Errorf("on B gave %d on VFO %s — want 14260000 on B", s.FreqHz, s.Vfo)
|
||||
}
|
||||
|
||||
// Tuning must write to the VFO in use, not blindly to FA.
|
||||
if err := k.SetFrequency(14265000); err != nil {
|
||||
t.Fatalf("set freq: %v", err)
|
||||
}
|
||||
if rig.vfoB != 14265000 {
|
||||
t.Errorf("VFO B = %d, want 14265000 — the write went to the wrong VFO", rig.vfoB)
|
||||
}
|
||||
if rig.vfoA != 14250000 {
|
||||
t.Errorf("VFO A was disturbed: %d", rig.vfoA)
|
||||
}
|
||||
|
||||
// Split: receive on B, transmit on A. ADIF says FREQ is the TRANSMIT
|
||||
// frequency, so the two must not be swapped — a mistake that writes the
|
||||
// wrong frequency into every logged QSO.
|
||||
rig.split = true
|
||||
if s, err = k.ReadState(); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !s.Split || s.FreqHz != 14250000 || s.RxFreqHz != 14265000 {
|
||||
t.Errorf("split gave tx=%d rx=%d split=%v — want tx 14250000 (A), rx 14265000 (B)",
|
||||
s.FreqHz, s.RxFreqHz, s.Split)
|
||||
}
|
||||
|
||||
// Mode: CW below 10 MHz stays CW; the sideband convention only governs SSB.
|
||||
rig.split = false
|
||||
if err := k.SetMode("CW"); err != nil {
|
||||
t.Fatalf("set mode: %v", err)
|
||||
}
|
||||
if rig.mode != '3' {
|
||||
t.Errorf("mode digit = %q, want '3' (CW)", rig.mode)
|
||||
}
|
||||
|
||||
// PTT is a bare command, and the rig must have actually seen it.
|
||||
if err := k.SetPTT(true); err != nil {
|
||||
t.Fatalf("ptt: %v", err)
|
||||
}
|
||||
if err := k.SetPTT(false); err != nil {
|
||||
t.Fatalf("ptt off: %v", err)
|
||||
}
|
||||
seen := strings.Join(rig.seen, " ")
|
||||
for _, want := range []string{"AI0;", "TX;", "RX;"} {
|
||||
if !strings.Contains(seen, want) {
|
||||
t.Errorf("the rig never received %s — sent: %s", want, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A port that opens onto silence must be reported as such, not as a connected
|
||||
// radio. A powered-off rig logged as "connected" wasted an evening of a user's
|
||||
// time on the Yaesu backend.
|
||||
func TestKenwoodSilentRigIsNotConnected(t *testing.T) {
|
||||
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||
k.dialPort = func() (serial.Port, error) {
|
||||
return &fakeSerial{toRig: &strings.Builder{}, answer: func(string) string { return "" }}, nil
|
||||
}
|
||||
err := k.Connect()
|
||||
if err == nil {
|
||||
t.Fatal("a silent port was reported as a connected rig")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not answering") {
|
||||
t.Errorf("error was %q — it should say the rig is not answering", err)
|
||||
}
|
||||
if k.model != "" {
|
||||
t.Errorf("a stale model survived a failed connect: %q", k.model)
|
||||
}
|
||||
}
|
||||
|
||||
// Split found through FR/FT when the rig never fills IF's split bit.
|
||||
//
|
||||
// Reported on a Flex through its Kenwood CAT emulation: frequency read
|
||||
// perfectly, split never appeared. Rather than guess at which IF column that
|
||||
// firmware populates, ask the question that DEFINES split — is the transmit VFO
|
||||
// a different VFO from the receive one — which is what FR and FT answer, and
|
||||
// the same rule the Yaesu backend settled on after several wrong turns.
|
||||
func TestKenwoodSplitFromFRFT(t *testing.T) {
|
||||
rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2', lazyIF: true}
|
||||
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||
k.dialPort = dialTo(rig)
|
||||
if err := k.Connect(); err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer k.Disconnect()
|
||||
|
||||
// Simplex: FR and FT agree, and nothing may be invented from that.
|
||||
s, err := k.ReadState()
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if s.Split {
|
||||
t.Errorf("split reported while FR and FT agree: tx=%d rx=%d", s.FreqHz, s.RxFreqHz)
|
||||
}
|
||||
|
||||
// Split on, IF still silent about it: receive on A, transmit on B.
|
||||
rig.split = true
|
||||
if s, err = k.ReadState(); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !s.Split {
|
||||
t.Fatal("split not detected — FR/FT disagreed and IF's bit was empty, which is the reported case")
|
||||
}
|
||||
if s.FreqHz != 14260000 || s.RxFreqHz != 14250000 {
|
||||
t.Errorf("tx=%d rx=%d — want tx 14260000 (B), rx 14250000 (A)", s.FreqHz, s.RxFreqHz)
|
||||
}
|
||||
}
|
||||
|
||||
// A rig that rejects FR/FT is asked once, then left alone — and its IF split
|
||||
// bit still works. The fallback must not cost a timeout on every poll, nor
|
||||
// break the rigs that were already fine.
|
||||
func TestKenwoodSplitWhenFRFTRejected(t *testing.T) {
|
||||
rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2', noVFOCmds: true}
|
||||
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||
k.dialPort = dialTo(rig)
|
||||
if err := k.Connect(); err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer k.Disconnect()
|
||||
|
||||
if _, err := k.ReadState(); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
asked := 0
|
||||
for _, c := range rig.seen {
|
||||
if c == "FR;" || c == "FT;" {
|
||||
asked++
|
||||
}
|
||||
}
|
||||
if _, err := k.ReadState(); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
after := 0
|
||||
for _, c := range rig.seen {
|
||||
if c == "FR;" || c == "FT;" {
|
||||
after++
|
||||
}
|
||||
}
|
||||
if after != asked {
|
||||
t.Errorf("a rejected command was asked again: %d then %d", asked, after)
|
||||
}
|
||||
|
||||
// IF's own split bit still drives the result on such a rig.
|
||||
rig.split = true
|
||||
s, err := k.ReadState()
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !s.Split || s.FreqHz != 14260000 || s.RxFreqHz != 14250000 {
|
||||
t.Errorf("IF-reported split broke: split=%v tx=%d rx=%d", s.Split, s.FreqHz, s.RxFreqHz)
|
||||
}
|
||||
}
|
||||
+34
-18
@@ -75,37 +75,53 @@ func DebugLogPath() string {
|
||||
return filepath.Join(base, "OpsLog", "cat.log")
|
||||
}
|
||||
|
||||
// ── CI-V byte trace ────────────────────────────────────────────────────────
|
||||
// ── CAT wire trace ─────────────────────────────────────────
|
||||
//
|
||||
// Opt-in, off by default. Turning it on logs every CI-V frame sent and received
|
||||
// as hex, exactly like the WinKeyer protocol trace.
|
||||
// Opt-in, off by default. Turning it on logs every frame sent and received —
|
||||
// CI-V as hex, the ASCII backends (Kenwood, Yaesu) as the text they exchange.
|
||||
//
|
||||
// That trace exists because a fault nobody could reason about — "the keyer sends
|
||||
// one element then stalls" — was settled in one line the moment the actual bytes
|
||||
// were visible. The CI-V link has now produced the same class of report: a MOX
|
||||
// button that sets split instead of transmitting, on a rig whose PTT command is
|
||||
// demonstrably correct in the source. Guessing at that from the command table
|
||||
// has already cost this project a wrong fix; the bytes will say what the rig was
|
||||
// really asked.
|
||||
var civTrace atomic.Bool
|
||||
// It exists because a fault nobody could reason about — "the keyer sends one
|
||||
// element then stalls" — was settled in one line the moment the actual bytes
|
||||
// were visible, and the CAT links have since produced the same class of report:
|
||||
// a MOX button that sets split instead of transmitting, and a split the Kenwood
|
||||
// backend does not recognise on a rig whose frequency it reads perfectly.
|
||||
//
|
||||
// Both are questions about what the RIG actually said. Guessing at that from a
|
||||
// protocol document has already cost this project a wrong fix that broke the
|
||||
// case which already worked.
|
||||
var catTrace atomic.Bool
|
||||
|
||||
// SetCIVTrace turns the CI-V byte trace on or off.
|
||||
// SetCIVTrace turns the CAT wire trace on or off. Named for the CI-V link it
|
||||
// was written for; it now covers the text backends too.
|
||||
func SetCIVTrace(on bool) {
|
||||
civTrace.Store(on)
|
||||
catTrace.Store(on)
|
||||
if on {
|
||||
debugLog.Printf("civ trace ON — every CI-V frame to and from the rig is logged")
|
||||
debugLog.Printf("wire trace ON — every CAT frame to and from the rig is logged")
|
||||
} else {
|
||||
debugLog.Printf("civ trace OFF")
|
||||
debugLog.Printf("wire trace OFF")
|
||||
}
|
||||
}
|
||||
|
||||
// CIVTraceEnabled reports whether the trace is running (for the settings UI).
|
||||
func CIVTraceEnabled() bool { return civTrace.Load() }
|
||||
func CIVTraceEnabled() bool { return catTrace.Load() }
|
||||
|
||||
// traceCIV logs one frame. dir is "TX" (to the rig) or "RX" (from it).
|
||||
// traceCIV logs one CI-V frame. dir is "TX" (to the rig) or "RX" (from it).
|
||||
func traceCIV(dir string, b []byte) {
|
||||
if !civTrace.Load() || len(b) == 0 {
|
||||
if !catTrace.Load() || len(b) == 0 {
|
||||
return
|
||||
}
|
||||
debugLog.Printf("civ %s % X", dir, b)
|
||||
}
|
||||
|
||||
// traceText logs one ASCII exchange, for the Kenwood and Yaesu backends.
|
||||
//
|
||||
// The text is QUOTED. A truncated or empty reply must be visible as such
|
||||
// rather than vanish into the line: "" and ";" look identical unquoted, and
|
||||
// telling them apart is the whole question when a rig answers a command it
|
||||
// does not really support.
|
||||
func traceText(backend, dir, s string) {
|
||||
if !catTrace.Load() || s == "" {
|
||||
return
|
||||
}
|
||||
debugLog.Printf("%s %s %q", backend, dir, s)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package extsvc
|
||||
|
||||
// Withdrawing a QSO from the external services after it is deleted locally.
|
||||
//
|
||||
// The two services could hardly be less alike, and the difference decides what
|
||||
// is possible for QSOs already in the log:
|
||||
//
|
||||
// - QRZ.com deletes ONLY by logid — the identifier it returns on INSERT.
|
||||
// There is no delete-by-callsign-and-date. A QSO uploaded before OpsLog
|
||||
// started recording that identifier therefore cannot be withdrawn from
|
||||
// here; the operator has to do it on the website. QRZ's own documentation
|
||||
// puts it plainly: "There is no undo from this operation."
|
||||
// - Club Log deletes by MATCH: the worked callsign, the exact timestamp and
|
||||
// the band. Nothing has to have been stored in advance, so this works for
|
||||
// every QSO in the log, past or future.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// clublogDeleteURL is Club Log's real-time single-QSO delete endpoint.
|
||||
const clublogDeleteURL = "https://clublog.org/delete.php"
|
||||
|
||||
// DeleteQRZ removes QSOs from a QRZ logbook by their logid.
|
||||
//
|
||||
// The identifiers come from the LOGID QRZ returns on INSERT, which OpsLog
|
||||
// stores on the QSO as APP_OPSLOG_QRZ_LOGID.
|
||||
//
|
||||
// QRZ answers RESULT=OK when every id was deleted, PARTIAL when some were not
|
||||
// found, FAIL when none were. PARTIAL and FAIL are NOT treated as errors here:
|
||||
// the QSO is being deleted locally either way, and "it was already gone from
|
||||
// QRZ" is the outcome the operator wanted. The message is returned so the
|
||||
// caller can log what actually happened.
|
||||
func DeleteQRZ(ctx context.Context, client *http.Client, apiKey string, logIDs []string) (string, error) {
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
if apiKey == "" {
|
||||
return "", fmt.Errorf("qrz: api key not set")
|
||||
}
|
||||
ids := make([]string, 0, len(logIDs))
|
||||
for _, id := range logIDs {
|
||||
if id = strings.TrimSpace(id); id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return "", fmt.Errorf("qrz: no logid recorded for this QSO — it can only be removed on qrz.com")
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("KEY", apiKey)
|
||||
form.Set("ACTION", "DELETE")
|
||||
form.Set("LOGIDS", strings.Join(ids, ","))
|
||||
|
||||
body, err := postForm(ctx, client, qrzAPIURL, form, 20*time.Second)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("qrz: %w", err)
|
||||
}
|
||||
LogSink("qrz: DELETE raw response: %s", strings.TrimSpace(body))
|
||||
|
||||
vals, _ := url.ParseQuery(strings.TrimSpace(body))
|
||||
result := strings.ToUpper(strings.TrimSpace(vals.Get("RESULT")))
|
||||
count := strings.TrimSpace(vals.Get("COUNT"))
|
||||
switch result {
|
||||
case "OK", "PARTIAL", "FAIL":
|
||||
return fmt.Sprintf("RESULT=%s COUNT=%s", result, count), nil
|
||||
case "AUTH":
|
||||
return "", fmt.Errorf("qrz: the logbook key was refused")
|
||||
}
|
||||
return "", fmt.Errorf("qrz: unexpected reply %q", strings.TrimSpace(body))
|
||||
}
|
||||
|
||||
// DeleteClublog removes one QSO from a Club Log logbook.
|
||||
//
|
||||
// whenUTC must be the QSO's start time; Club Log matches on it EXACTLY, to the
|
||||
// second, so a rounded or local-time value simply finds nothing. band is an
|
||||
// ADIF band name ("20m"), translated below to the band number Club Log wants.
|
||||
func DeleteClublog(ctx context.Context, client *http.Client, cfg ServiceConfig, dxCall string, whenUTC time.Time, band string) (string, error) {
|
||||
email := strings.TrimSpace(cfg.Email)
|
||||
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
|
||||
dx := strings.ToUpper(strings.TrimSpace(dxCall))
|
||||
switch {
|
||||
case email == "":
|
||||
return "", fmt.Errorf("clublog: account email not set")
|
||||
case cfg.Password == "":
|
||||
return "", fmt.Errorf("clublog: password not set")
|
||||
case call == "":
|
||||
return "", fmt.Errorf("clublog: logbook callsign not set")
|
||||
case dx == "":
|
||||
return "", fmt.Errorf("clublog: no callsign to delete")
|
||||
case whenUTC.IsZero():
|
||||
return "", fmt.Errorf("clublog: the QSO has no time — Club Log matches on it exactly")
|
||||
}
|
||||
bandID, ok := clublogBandID(band)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("clublog: band %q is not one Club Log accepts for deletion", band)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("email", email)
|
||||
form.Set("password", cfg.Password)
|
||||
form.Set("callsign", call)
|
||||
form.Set("dxcall", dx)
|
||||
form.Set("datetime", whenUTC.UTC().Format("2006-01-02 15:04:05"))
|
||||
form.Set("bandid", strconv.Itoa(bandID))
|
||||
api := strings.TrimSpace(cfg.APIKey)
|
||||
if api == "" {
|
||||
api = clublogAppAPIKey
|
||||
}
|
||||
form.Set("api", api)
|
||||
|
||||
body, err := postForm(ctx, client, clublogDeleteURL, form, 20*time.Second)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("clublog: %w", err)
|
||||
}
|
||||
msg := strings.TrimSpace(body)
|
||||
LogSink("clublog: DELETE raw response: %s", msg)
|
||||
// "QSO Not Deleted" means no record matched. That is not an error worth
|
||||
// stopping for — the QSO is going away locally regardless, and a QSO absent
|
||||
// from Club Log is the state being asked for.
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// clublogBandID maps an ADIF band name to Club Log's band number.
|
||||
//
|
||||
// The list is Club Log's own, and it is deliberately NOT derived from the
|
||||
// frequency: Club Log accepts these values and no others, so a band outside it
|
||||
// must be reported rather than approximated to a neighbour.
|
||||
func clublogBandID(band string) (int, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(band)) {
|
||||
case "160m":
|
||||
return 160, true
|
||||
case "80m":
|
||||
return 80, true
|
||||
case "60m":
|
||||
return 60, true
|
||||
case "40m":
|
||||
return 40, true
|
||||
case "30m":
|
||||
return 30, true
|
||||
case "20m":
|
||||
return 20, true
|
||||
case "17m":
|
||||
return 17, true
|
||||
case "15m":
|
||||
return 15, true
|
||||
case "12m":
|
||||
return 12, true
|
||||
case "10m":
|
||||
return 10, true
|
||||
case "6m":
|
||||
return 6, true
|
||||
case "4m":
|
||||
return 4, true
|
||||
case "2m":
|
||||
return 2, true
|
||||
case "70cm":
|
||||
return 70, true
|
||||
case "23cm":
|
||||
return 23, true
|
||||
case "13cm":
|
||||
return 13, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// postForm is the shared request/read for both deletes.
|
||||
func postForm(ctx context.Context, client *http.Client, endpoint string, form url.Values, timeout time.Duration) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", clublogUserAgent)
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: timeout}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
b := string(raw)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(b))
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package extsvc
|
||||
|
||||
import "testing"
|
||||
|
||||
// Club Log matches a deletion on callsign, exact time and BAND NUMBER. A band
|
||||
// that maps to the wrong number does not fail — it points the delete at a
|
||||
// different QSO, or at nothing, silently. So the table is pinned.
|
||||
func TestClublogBandID(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
band string
|
||||
want int
|
||||
}{
|
||||
{"160m", 160}, {"80m", 80}, {"60m", 60}, {"40m", 40}, {"30m", 30},
|
||||
{"20m", 20}, {"17m", 17}, {"15m", 15}, {"12m", 12}, {"10m", 10},
|
||||
{"6m", 6}, {"4m", 4}, {"2m", 2},
|
||||
{"70cm", 70}, {"23cm", 23}, {"13cm", 13},
|
||||
{"20M", 20}, {" 20m ", 20}, // ADIF case and stray spaces
|
||||
} {
|
||||
got, ok := clublogBandID(c.band)
|
||||
if !ok || got != c.want {
|
||||
t.Errorf("clublogBandID(%q) = %d,%v — want %d", c.band, got, ok, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Bands Club Log does not accept must be REFUSED, never approximated to a
|
||||
// neighbour: 6 cm silently becoming 13 cm would delete somebody else's QSO.
|
||||
for _, bad := range []string{"", "6cm", "3cm", "2200m", "630m", "9cm", "banana"} {
|
||||
if id, ok := clublogBandID(bad); ok {
|
||||
t.Errorf("clublogBandID(%q) accepted as %d — it is not a Club Log band", bad, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,13 @@ type ExternalServices struct {
|
||||
HRDLog ServiceConfig `json:"hrdlog"`
|
||||
EQSL ServiceConfig `json:"eqsl"`
|
||||
Cloudlog ServiceConfig `json:"cloudlog"`
|
||||
|
||||
// DeleteRemote asks OpsLog to withdraw a QSO from QRZ.com and Club Log when
|
||||
// it is deleted locally. Off unless the operator turns it on: neither
|
||||
// service can undo it, and a local delete is not always meant to reach the
|
||||
// world — a duplicate cleared from a contest log was never meant to be a
|
||||
// statement about the DX's log.
|
||||
DeleteRemote bool `json:"delete_remote"`
|
||||
}
|
||||
|
||||
// UploadResult is the outcome of a single upload attempt.
|
||||
|
||||
@@ -629,3 +629,11 @@ func isTimeout(err error) bool {
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "timeout")
|
||||
}
|
||||
|
||||
// TraceEnabled reports whether the protocol trace is running.
|
||||
//
|
||||
// The settings dialog needs it: the checkbox is React state that starts false on
|
||||
// every mount, so a trace switched on stayed on in the backend while the box
|
||||
// reappeared unticked — the operator ticks it again, which switches it OFF, and
|
||||
// the log they then send has no trace in it at all.
|
||||
func TraceEnabled() bool { return traceOn.Load() }
|
||||
|
||||
@@ -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 (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.22.2"
|
||||
appVersion = "0.22.3"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user