Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
712a124081 | ||
|
|
08a932c9c1 | ||
|
|
fee2303625 | ||
|
|
4d5c5c3eb3 | ||
|
|
d322ea9a07 | ||
|
|
75c02ea2a0 | ||
|
|
ef84b04c99 | ||
|
|
fbd6cf400f | ||
|
|
b16cb973f2 | ||
|
|
da886de189 | ||
|
|
7759e09b4e | ||
|
|
a8990ff29d | ||
|
|
e3b810b4ff | ||
|
|
337392bb6d | ||
|
|
d98bb2e929 | ||
|
|
3ab6b95ff8 | ||
|
|
b0da6a3d14 | ||
|
|
2d7469a7f7 | ||
|
|
775f411606 | ||
|
|
6501e97895 | ||
|
|
f3807c21ba | ||
|
|
d536c39ef5 | ||
|
|
284a7a18c7 | ||
|
|
482f81fe45 | ||
|
|
5d7a9a9562 | ||
|
|
a036120871 | ||
|
|
ccceab9d02 | ||
|
|
35db1440e4 | ||
|
|
e3aabc06a4 | ||
|
|
ab9d0bfe0f | ||
|
|
a3fd32ec91 | ||
|
|
c297f91ca8 | ||
|
|
ee1f9ccf35 | ||
|
|
6fb7f06375 |
@@ -93,6 +93,8 @@ Where two devices are interchangeable (Ultrabeam / SteppIR), `app.go` defines a
|
||||
|
||||
**Changelog is mandatory.** Every user-visible change gets an entry in `changelog.json`, **in both `en` and `fr`**, in the same session as the change. Keep entries to one or two sentences — rationale belongs in the commit message. New work goes under the next version number; the release script bumps the version constants.
|
||||
|
||||
**Append new entries at the BOTTOM of a version block**, never at the top. A block is read top to bottom, so a feature must appear before the fixes made to it — prepending meant an operator read "the Yaesu meters are corrected" several entries before learning a Yaesu console existed at all.
|
||||
|
||||
**Version lives in two places** and must stay in lockstep: `appVersion` in `telemetry.go` and `APP_VERSION` in `frontend/src/version.ts`.
|
||||
|
||||
**Bilingual UI.** Every user-visible string goes through `t()` from `frontend/src/lib/i18n.tsx` (~700 keys), with both English and French provided. No hardcoded display strings.
|
||||
|
||||
@@ -116,6 +116,8 @@ 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)
|
||||
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)
|
||||
keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200)
|
||||
keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98)
|
||||
@@ -178,7 +180,8 @@ const (
|
||||
keyRotatorType = "rotator.type" // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) | "arco" (microHAM ARCO, GS-232A)
|
||||
keyRotatorNum = "rotator.num" // Rotator Genius rotator index: 1 or 2
|
||||
keyRotatorTransport = "rotator.transport" // arco: "tcp" (LAN) | "serial" (USB virtual COM)
|
||||
keyRotatorComPort = "rotator.com_port" // arco serial transport
|
||||
keyRotatorComPort = "rotator.com_port" // GS-232 serial transport
|
||||
keyRotatorBaud = "rotator.baud" // GS-232 serial baud (an ERC needs it; an ARCO ignores it)
|
||||
|
||||
// Motorized antenna (Ultrabeam or SteppIR) — Hardware → Antenna. Keys keep the
|
||||
// "ultrabeam." prefix for backward compatibility with configs saved before the
|
||||
@@ -356,6 +359,8 @@ 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)
|
||||
@@ -1262,6 +1267,27 @@ type StartupStatus struct {
|
||||
|
||||
// GetStartupStatus exposes whatever happened during startup so the UI
|
||||
// can show a useful error instead of just "db not initialized".
|
||||
// LogUIError records a crash or unhandled error from the interface in the app
|
||||
// log, with whatever context the window can give.
|
||||
//
|
||||
// Until this existed, a render error emptied the window and left NOTHING: no
|
||||
// line in opslog.log, no dialog. Two separate white-screen reports — one after
|
||||
// logging a 10 GHz QSO, one on starting an auto-call — could not be diagnosed at
|
||||
// all, because the only evidence lived in a console the operator had to know to
|
||||
// open. A fault the user cannot report is a fault that cannot be fixed.
|
||||
func (a *App) LogUIError(kind, message, stack string) {
|
||||
msg := strings.TrimSpace(message)
|
||||
if msg == "" {
|
||||
msg = "(no message)"
|
||||
}
|
||||
applog.Printf("ui %s: %s", strings.TrimSpace(kind), msg)
|
||||
if st := strings.TrimSpace(stack); st != "" {
|
||||
// The stack goes in whole: minified frames are still the difference between
|
||||
// "somewhere in the app" and one component.
|
||||
applog.Printf("ui stack: %s", st)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) GetStartupStatus() StartupStatus {
|
||||
return StartupStatus{
|
||||
OK: a.startupErr == "",
|
||||
@@ -1893,7 +1919,27 @@ func (a *App) GetDBConnectionInfo() DBConnectionInfo {
|
||||
}
|
||||
return DBConnectionInfo{Backend: "mysql", Label: "MySQL"}
|
||||
}
|
||||
return DBConnectionInfo{Backend: "sqlite", Label: a.dbPath}
|
||||
// The LOGBOOK file, not the settings database.
|
||||
//
|
||||
// This reported a.dbPath — the settings/profile database — under a tooltip
|
||||
// reading "Local SQLite logbook". The two are separate files since the logbook
|
||||
// split, so an operator checking where their QSOs live, or which file to back
|
||||
// up, was pointed at the wrong one.
|
||||
//
|
||||
// Same resolution order as connectLogbook: the active profile's own file if it
|
||||
// names one, then the default logbook.db, and only if neither exists does the
|
||||
// settings db genuinely serve as the logbook.
|
||||
lp := ""
|
||||
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||
lp = strings.TrimSpace(p.DB.Path)
|
||||
}
|
||||
if lp == "" {
|
||||
lp = a.logbookPath
|
||||
}
|
||||
if lp == "" {
|
||||
lp = a.dbPath
|
||||
}
|
||||
return DBConnectionInfo{Backend: "sqlite", Label: lp}
|
||||
}
|
||||
|
||||
// connectLogbook opens the logbook connection for a profile's DB target: a
|
||||
@@ -5879,11 +5925,17 @@ func (a *App) OpenADIFFile() (string, error) {
|
||||
// Log4OM / LoTW without clobbering fields the file omits
|
||||
// - "all" : insert every record, duplicates included
|
||||
//
|
||||
// fieldMap moves ADIF fields before conversion (source → destination, ADIF tag
|
||||
// names in any case). Contest exports put the exchange where their module keeps
|
||||
// it rather than where ADIF says: the RSGB IOTA contest arrives with the island
|
||||
// reference in STATE, which imports as a US state and leaves the IOTA award
|
||||
// empty. The destination is only filled when the file left it blank.
|
||||
//
|
||||
// applyCty, when true, recomputes country / continent / DXCC / CQ / ITU from
|
||||
// cty.dat for every record, overriding what the file carries — corrects the
|
||||
// wrong COUNTRY that contest software often exports (e.g. RG2Y as Asiatic
|
||||
// Russia). Everything else in the ADIF is still preserved verbatim.
|
||||
func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStation bool) (adif.ImportResult, error) {
|
||||
func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStation bool, fieldMap map[string]string) (adif.ImportResult, error) {
|
||||
if a.qso == nil {
|
||||
return adif.ImportResult{}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
@@ -5897,6 +5949,18 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStatio
|
||||
// descriptive metadata and safe to fill (identity fields are still left
|
||||
// alone, see applyStationDefaults).
|
||||
im := &adif.Importer{Repo: a.qso}
|
||||
if len(fieldMap) > 0 {
|
||||
lower := make(map[string]string, len(fieldMap))
|
||||
for from, to := range fieldMap {
|
||||
from = strings.ToLower(strings.TrimSpace(from))
|
||||
to = strings.ToLower(strings.TrimSpace(to))
|
||||
if from != "" && to != "" && from != to {
|
||||
lower[from] = to
|
||||
}
|
||||
}
|
||||
im.FieldMap = lower
|
||||
applog.Printf("import: field mapping %v", lower)
|
||||
}
|
||||
switch dupMode {
|
||||
case "update":
|
||||
im.UpdateDuplicates = true
|
||||
@@ -6589,7 +6653,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, 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, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
|
||||
if err != nil {
|
||||
return CATSettings{}, err
|
||||
}
|
||||
@@ -6607,6 +6671,8 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
XieguAddr: cat.XieguDefaultAddr,
|
||||
YaesuPort: m[keyCATYaesuPort],
|
||||
YaesuBaud: 38400,
|
||||
KenwoodPort: m[keyCATKenwoodPort],
|
||||
KenwoodBaud: 9600,
|
||||
IcomPort: m[keyCATIcomPort],
|
||||
IcomBaud: 115200,
|
||||
IcomAddr: 0x98, // IC-7610 default
|
||||
@@ -6644,6 +6710,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
if n, _ := strconv.Atoi(m[keyCATYaesuBaud]); n > 0 {
|
||||
out.YaesuBaud = n
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATKenwoodBaud]); n > 0 {
|
||||
out.KenwoodBaud = n
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 {
|
||||
out.IcomBaud = n
|
||||
}
|
||||
@@ -6697,6 +6766,9 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
if s.YaesuBaud <= 0 {
|
||||
s.YaesuBaud = 38400
|
||||
}
|
||||
if s.KenwoodBaud <= 0 {
|
||||
s.KenwoodBaud = 9600
|
||||
}
|
||||
if s.IcomBaud <= 0 {
|
||||
s.IcomBaud = 115200
|
||||
}
|
||||
@@ -6754,6 +6826,8 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
keyCATXieguAddr: strconv.Itoa(s.XieguAddr),
|
||||
keyCATYaesuPort: strings.TrimSpace(s.YaesuPort),
|
||||
keyCATYaesuBaud: strconv.Itoa(s.YaesuBaud),
|
||||
keyCATKenwoodPort: strings.TrimSpace(s.KenwoodPort),
|
||||
keyCATKenwoodBaud: strconv.Itoa(s.KenwoodBaud),
|
||||
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
|
||||
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
|
||||
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
|
||||
@@ -8734,36 +8808,33 @@ func (a *App) applyQSLDefaults(q *qso.QSO) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if q.QSLSent == "" {
|
||||
q.QSLSent = d.QSLSent
|
||||
}
|
||||
if q.QSLRcvd == "" {
|
||||
q.QSLRcvd = d.QSLRcvd
|
||||
}
|
||||
if q.LOTWSent == "" {
|
||||
q.LOTWSent = d.LOTWSent
|
||||
}
|
||||
if q.LOTWRcvd == "" {
|
||||
q.LOTWRcvd = d.LOTWRcvd
|
||||
}
|
||||
if q.EQSLSent == "" {
|
||||
q.EQSLSent = d.EQSLSent
|
||||
}
|
||||
if q.EQSLRcvd == "" {
|
||||
q.EQSLRcvd = d.EQSLRcvd
|
||||
}
|
||||
if q.ClublogUploadStatus == "" {
|
||||
q.ClublogUploadStatus = d.ClublogStatus
|
||||
}
|
||||
if q.HRDLogUploadStatus == "" {
|
||||
q.HRDLogUploadStatus = d.HRDLogStatus
|
||||
}
|
||||
if q.QRZComUploadStatus == "" {
|
||||
q.QRZComUploadStatus = d.QRZComStatus
|
||||
}
|
||||
if q.QRZComDownloadStatus == "" {
|
||||
q.QRZComDownloadStatus = d.QRZComCfm
|
||||
applyQSLDefaultsTo(q, d)
|
||||
}
|
||||
|
||||
// applyQSLDefaultsTo stamps the operator's default confirmation statuses on the
|
||||
// fields a QSO leaves EMPTY. Split from the settings lookup so the rule can be
|
||||
// tested: fill the blanks, never touch a value that is already there.
|
||||
//
|
||||
// The distinction matters on import. A WSJT-X log carries almost no QSL fields,
|
||||
// so without this every imported QSO would sit with blank statuses; a log
|
||||
// exported from another program carries real ones, and overwriting those would
|
||||
// erase confirmations the operator has actually received.
|
||||
func applyQSLDefaultsTo(q *qso.QSO, d QSLDefaults) {
|
||||
fill := func(dst *string, def string) {
|
||||
if *dst == "" {
|
||||
*dst = def
|
||||
}
|
||||
}
|
||||
fill(&q.QSLSent, d.QSLSent)
|
||||
fill(&q.QSLRcvd, d.QSLRcvd)
|
||||
fill(&q.LOTWSent, d.LOTWSent)
|
||||
fill(&q.LOTWRcvd, d.LOTWRcvd)
|
||||
fill(&q.EQSLSent, d.EQSLSent)
|
||||
fill(&q.EQSLRcvd, d.EQSLRcvd)
|
||||
fill(&q.ClublogUploadStatus, d.ClublogStatus)
|
||||
fill(&q.HRDLogUploadStatus, d.HRDLogStatus)
|
||||
fill(&q.QRZComUploadStatus, d.QRZComStatus)
|
||||
fill(&q.QRZComDownloadStatus, d.QRZComCfm)
|
||||
}
|
||||
|
||||
// ── External services (logbook upload) ─────────────────────────────────
|
||||
@@ -10071,21 +10142,27 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
}
|
||||
|
||||
// qrzRecordConfirmed reports whether a QRZ FETCH ADIF record represents a
|
||||
// confirmed QSO. QRZ's confirmation marker isn't clearly documented, so we
|
||||
// accept the likely candidates; the download's one-time field dump lets us
|
||||
// pin the exact field against real data and tighten this if needed.
|
||||
// QSO confirmed BY QRZ.COM.
|
||||
//
|
||||
// It used to accept qsl_rcvd = Y as well, and that was wrong: qsl_rcvd is the
|
||||
// operator's own PAPER QSL flag, which they uploaded to QRZ themselves. Any QSO
|
||||
// with a paper card was therefore marked QRZ-confirmed on download — reported as
|
||||
// "they all turn to Y" (2026-07-29), and on a log full of paper QSLs that is
|
||||
// most of it.
|
||||
//
|
||||
// A QRZ confirmation is QRZ's own statement, and only two fields carry it:
|
||||
//
|
||||
// app_qrzlog_status = C QRZ's confirmed marker on the record
|
||||
// qrzcom_qso_download_status = Y the ADIF field QRZ sets for it
|
||||
//
|
||||
// Anything else is a claim from some other source and must not be read as a QRZ
|
||||
// confirmation — this status feeds award slots, so a false Y is a QSO counted as
|
||||
// confirmed when it is not.
|
||||
func qrzRecordConfirmed(rec adif.Record) bool {
|
||||
if strings.EqualFold(rec["qsl_rcvd"], "Y") {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(rec["qrzcom_qso_download_status"], "Y") {
|
||||
return true
|
||||
}
|
||||
switch strings.ToUpper(strings.TrimSpace(rec["app_qrzlog_status"])) {
|
||||
case "C", "Y":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return strings.EqualFold(strings.TrimSpace(rec["app_qrzlog_status"]), "C")
|
||||
}
|
||||
|
||||
// enrichContactedFromCty fills a QSO's contacted-station country/DXCC/zones
|
||||
@@ -10844,9 +10921,24 @@ func (a *App) consumeUDPEvents() {
|
||||
"service": string(ev.Service),
|
||||
"source": ev.Source,
|
||||
})
|
||||
case ev.DXCall != "" && ev.Service == udp.ServiceRemoteCall:
|
||||
applog.Printf("udp: emit udp:remote_call %q\n", ev.DXCall)
|
||||
wruntime.EventsEmit(a.ctx, "udp:remote_call", ev.DXCall)
|
||||
case (ev.DXCall != "" || ev.TuneFreqHz > 0) && ev.Service == udp.ServiceRemoteCall:
|
||||
// CAT tune riding along with the callsign (DXHunter spot click:
|
||||
// "<CALLSIGN>VP5G<FREQ>10.136<MODE>FT8"). Freq first so the rig is
|
||||
// already moving while the frontend fills the entry field.
|
||||
if ev.TuneFreqHz > 0 {
|
||||
applog.Printf("udp: remote_call tune request %.3f MHz mode=%q\n", float64(ev.TuneFreqHz)/1e6, ev.TuneMode)
|
||||
if err := a.SetCATFrequency(ev.TuneFreqHz); err != nil {
|
||||
applog.Printf("udp: remote_call tune failed: %v\n", err)
|
||||
} else if ev.TuneMode != "" {
|
||||
if err := a.SetCATMode(ev.TuneMode); err != nil {
|
||||
applog.Printf("udp: remote_call mode set failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ev.DXCall != "" {
|
||||
applog.Printf("udp: emit udp:remote_call %q\n", ev.DXCall)
|
||||
wruntime.EventsEmit(a.ctx, "udp:remote_call", ev.DXCall)
|
||||
}
|
||||
case ev.DXCall != "":
|
||||
applog.Printf("udp: emit udp:dx_call %q (mode=%s freq=%d)\n", ev.DXCall, ev.Mode, ev.FreqHz)
|
||||
wruntime.EventsEmit(a.ctx, "udp:dx_call", map[string]any{
|
||||
@@ -12162,6 +12254,12 @@ func (a *App) reloadCAT() {
|
||||
// (a rig file that hides the VFO, a Freq property meaning A on one model
|
||||
// and B on another); talking to the radio directly removes it.
|
||||
a.cat.Start(cat.NewYaesu(s.YaesuPort, s.YaesuBaud, s.DigitalDefault))
|
||||
case "kenwood":
|
||||
// Native Kenwood CAT — TS-590/890/990/2000 and everything that speaks the
|
||||
// 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))
|
||||
case "icom":
|
||||
// Native Icom CI-V over the radio's USB serial port (local control).
|
||||
// Same civ protocol the network backend reuses for remote.
|
||||
@@ -12512,8 +12610,11 @@ type RotatorSettings struct {
|
||||
Port int `json:"port"` // default 12000 (pst) / 9006 (rotgenius) / 4001 (arco — must match the port set in ARCO's LAN CONTROL PROTOCOL)
|
||||
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets (PstRotator)
|
||||
RotatorNum int `json:"rotator_num"` // Rotator Genius index: 1 or 2
|
||||
Transport string `json:"transport"` // arco: "tcp" (LAN) | "serial" (USB virtual COM)
|
||||
ComPort string `json:"com_port"` // arco serial transport
|
||||
Transport string `json:"transport"` // GS-232 controller: "tcp" (LAN) | "serial" (COM)
|
||||
ComPort string `json:"com_port"` // GS-232 serial transport
|
||||
// Baud for the serial transport. An ARCO's virtual COM ignores it; an ERC runs
|
||||
// at the rate set in its own configuration, so it has to be selectable.
|
||||
Baud int `json:"baud"`
|
||||
}
|
||||
|
||||
// GetRotatorSettings returns the persisted rotator config with defaults.
|
||||
@@ -12524,7 +12625,7 @@ func (a *App) GetRotatorSettings() (RotatorSettings, error) {
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation, keyRotatorType, keyRotatorNum,
|
||||
keyRotatorTransport, keyRotatorComPort)
|
||||
keyRotatorTransport, keyRotatorComPort, keyRotatorBaud)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -12551,6 +12652,10 @@ func (a *App) GetRotatorSettings() (RotatorSettings, error) {
|
||||
out.Transport = "serial"
|
||||
}
|
||||
out.ComPort = m[keyRotatorComPort]
|
||||
out.Baud = 9600
|
||||
if b, _ := strconv.Atoi(m[keyRotatorBaud]); b > 0 {
|
||||
out.Baud = b
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -12584,6 +12689,7 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
|
||||
keyRotatorNum: strconv.Itoa(s.RotatorNum),
|
||||
keyRotatorTransport: s.Transport,
|
||||
keyRotatorComPort: strings.TrimSpace(s.ComPort),
|
||||
keyRotatorBaud: strconv.Itoa(s.Baud),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
@@ -12596,7 +12702,7 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
|
||||
// USB virtual COM (serial) or the LAN CONTROL PROTOCOL TCP port.
|
||||
func arcoClient(s RotatorSettings) *gs232.Client {
|
||||
if s.Transport == "serial" {
|
||||
return gs232.NewSerial(s.ComPort)
|
||||
return gs232.NewSerial(s.ComPort, s.Baud)
|
||||
}
|
||||
return gs232.New(s.Host, s.Port)
|
||||
}
|
||||
@@ -14361,6 +14467,17 @@ func (a *App) SetWinkeyerTrace(on bool) {
|
||||
winkeyer.SetTrace(on)
|
||||
}
|
||||
|
||||
// SetCIVTrace turns the byte-level CI-V trace on or off.
|
||||
//
|
||||
// Same reasoning as the WinKeyer trace, and prompted by the same shape of
|
||||
// report: an IC-9100 whose MOX button sets split instead of transmitting, while
|
||||
// the source demonstrably sends the PTT command (0x1C 0x00) and not the split
|
||||
// one (0x0F). Something between the two is not what it appears, and on a link
|
||||
// already shown to lose frame sync, the bytes are the only witness.
|
||||
func (a *App) SetCIVTrace(on bool) {
|
||||
cat.SetCIVTrace(on)
|
||||
}
|
||||
|
||||
// WinkeyerConnect opens the serial link using the saved config.
|
||||
func (a *App) WinkeyerConnect() error {
|
||||
if a.winkeyer == nil {
|
||||
|
||||
+98
-34
@@ -1,46 +1,110 @@
|
||||
[
|
||||
{
|
||||
"version": "0.22.2",
|
||||
"date": "2026-07-30",
|
||||
"en": [
|
||||
"The Antenna Genius and Tuner Genius settings entries carry a small 4O3A mark, so the panels driving that hardware are recognisable at a glance in the sidebar.",
|
||||
"The database shown in the status bar is the LOGBOOK file. On a local SQLite setup it named the settings database instead, so anyone checking where their QSOs live — or which file to back up — was pointed at the wrong one.",
|
||||
"Yaesu: the mode follows the VFO you are on. A spot clicked while working the SUB receiver tuned the sub VFO but set the mode on MAIN, leaving the VFO in use on its old mode; the console read main mode too.",
|
||||
"Rotator control now covers any GS-232A controller, ERC (Easy Rotor Control) included: the serial speed is selectable and the entry is named after the protocol rather than one device. Set the ERC to GS-232 emulation, not Hy-Gain DCU-1.",
|
||||
"Native Kenwood CAT: a sixth backend talks straight to a TS-590, TS-890, TS-990 or TS-2000 over its serial port — frequency, mode, VFO, split and PTT — with no OmniRig in between. Elecraft K3/K4 speak the same dialect. Pick \"Kenwood (native)\" in Settings → CAT.",
|
||||
"Icom and Xiegu: a CI-V frame read at the wrong offset no longer turns into a frequency. Values such as 16445550000 Hz appeared in the status bar while the rig sat on 145.550 MHz — the decoder treated non-decimal bytes as digits. Such frames are now refused and the last good reading stands.",
|
||||
"Settings → CAT can log the CI-V protocol: every frame to and from an Icom or Xiegu, as hex. For reporting a radio that answers oddly — a button that does the wrong thing, a frequency that jumps — where a description alone leaves the cause to guesswork.",
|
||||
"A crash in the window no longer leaves a blank screen. The error is written to the app log and shown on screen, selectable, with a button to copy it and one to reload — so a fault can be reported with its cause instead of a description of an empty window.",
|
||||
"ADIF import can move fields as it reads them. Contest software stores the exchange where its own module keeps it — the RSGB IOTA contest exports the island reference in STATE, which imports as a US state and leaves the IOTA award empty. Add a STATE → IOTA row in the import dialog and it lands where the award looks.",
|
||||
"The recent-QSO grid no longer offers per-column filters. They only ever searched the rows on screen, so an empty result looked like a missing QSO when the log simply held it further back — use the advanced filter, which queries the whole logbook. Column headers gain the width back."
|
||||
],
|
||||
"fr": [
|
||||
"Les entrées de réglages Antenna Genius et Tuner Genius portent une petite marque 4O3A : les panneaux pilotant ce matériel se repèrent d'un coup d'œil dans le menu.",
|
||||
"La base affichée dans la barre d'état est bien le fichier du CARNET. En SQLite local, elle désignait la base des réglages : qui vérifiait où résident ses QSO — ou quel fichier sauvegarder — était orienté vers le mauvais.",
|
||||
"Yaesu : le mode suit le VFO utilisé. Un spot cliqué en travaillant sur le récepteur SUB accordait bien le VFO secondaire mais réglait le mode sur le MAIN, laissant le VFO en service sur son ancien mode ; la console lisait également le mode du principal.",
|
||||
"Le contrôle de rotator couvre désormais tout contrôleur GS-232A, ERC (Easy Rotor Control) compris : la vitesse du port série est sélectionnable et l'entrée porte le nom du protocole plutôt que celui d'un seul appareil. Réglez l'ERC sur l'émulation GS-232, pas Hy-Gain DCU-1.",
|
||||
"CAT Kenwood natif : un sixième backend parle directement à un TS-590, TS-890, TS-990 ou TS-2000 par son port série — fréquence, mode, VFO, split et PTT — sans OmniRig. Les Elecraft K3/K4 parlent le même dialecte. À choisir sous « Kenwood (natif) » dans Réglages → CAT.",
|
||||
"Icom et Xiegu : une trame CI-V lue au mauvais décalage ne se transforme plus en fréquence. Des valeurs comme 16445550000 Hz apparaissaient dans la barre d'état alors que la radio était sur 145,550 MHz — le décodeur prenait des octets non décimaux pour des chiffres. Ces trames sont désormais refusées et la dernière lecture valable est conservée.",
|
||||
"Réglages → CAT permet de journaliser le protocole CI-V : chaque trame échangée avec un Icom ou un Xiegu, en hexadécimal. Pour signaler une radio qui répond de travers — un bouton qui fait autre chose, une fréquence qui saute — là où une description seule laisse la cause à la devinette.",
|
||||
"Un plantage de la fenêtre ne laisse plus un écran vide. L'erreur est écrite dans le journal et affichée à l'écran, sélectionnable, avec un bouton pour la copier et un pour recharger — de quoi signaler un défaut avec sa cause plutôt qu'une description de fenêtre vide.",
|
||||
"L'import ADIF peut déplacer des champs à la lecture. Les logiciels de concours rangent l'échange là où leur module le garde — le contest IOTA de la RSGB exporte la référence d'île dans STATE, importée comme un état américain, et le diplôme IOTA reste vide. Une ligne STATE → IOTA dans la fenêtre d'import et la référence arrive là où le diplôme la cherche.",
|
||||
"La grille des QSO récents ne propose plus de filtres par colonne. Ils ne cherchaient que dans les lignes affichées : un résultat vide ressemblait à un QSO manquant alors que le journal le contenait plus loin — le filtre avancé, lui, interroge tout le journal. Les en-têtes de colonne récupèrent la place."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.1",
|
||||
"date": "2026-07-29",
|
||||
"en": [
|
||||
"Clicking a cluster spot while listening on the SUB VFO no longer pulls the radio back to MAIN. The command used to tune sets the main VFO by definition; on SUB the sub VFO is now written directly and the VFO selection is left alone.",
|
||||
"Split showed the opposite of the radio on some Yaesus. Where the rig reports which VFO transmits rather than a split flag, that has to be compared with the VFO you are listening to — so it read backwards for an operator working on SUB and correctly for one on MAIN. Setting split had the same fault.",
|
||||
"A shared MySQL logbook could fail to build its schema, leaving OpsLog on the local SQLite database with a message that only said the connection failed. Column declarations that are quoted, or share a line with others, escaped the SQLite-to-MySQL type conversion — so MySQL refused an index and a default value. A database already left in that state is now repaired at startup instead of failing at every launch.",
|
||||
"Yaesu: OpsLog now follows the VFO you are actually on — the displayed frequency, and the VFO a cluster spot tunes. An FTDX101 answers the receive-VFO query with two digits where an FTDX10 uses one, and the wrong one was read.",
|
||||
"Clicking a spot in DXHunter can now tune the radio: a remote-call UDP packet carrying <FREQ> and <MODE> sets the frequency and mode on whichever CAT backend is active, as well as filling the callsign. A packet without them still just fills the callsign.",
|
||||
"The ADIF import option that fills your station fields from your profile also stamps your default confirmation statuses (paper QSL, LoTW, eQSL, Club Log, HRDLog, QRZ.com) on the ones the file leaves empty — it always did, but nothing said so, and a WSJT-X log carries almost none. Existing confirmations are never overwritten.",
|
||||
"QRZ.com confirmation download marked far too many QSOs as confirmed: it accepted the paper-QSL received flag, which you uploaded to QRZ yourself, as if it were a QRZ confirmation. Only QRZ own confirmation now counts — check the QRZ received column after your next download, as earlier ones may have set it wrongly.",
|
||||
"The Recent QSOs counter labels each of its three numbers — shown, matching the filter, and in the log — with thousands separators. \"Showing 10000 of 23683 matches · 28648 total\" read as if the filter had found more QSOs than the log holds.",
|
||||
"Switching the CAT backend back to a Yaesu no longer risks leaving the serial port held. Each reconnect opened a new handle without closing the previous one, and Windows opens a serial port exclusively, so the next attempt could fail with \"port busy\".",
|
||||
"A Yaesu that is switched off is reported as such. Opening the serial port used to be logged as \"connected\", with the model name left over from the previous session, so a radio that was simply powered down looked like a software fault.",
|
||||
"Yaesu: moving RX and TX together to the SUB receiver is no longer shown as split. That is simplex on the sub VFO, but the rig reports its split flag anyway, so OpsLog now works split out from which VFO transmits and which receives.",
|
||||
"Auto-call CW now waits the gap you set AFTER the message, not from the moment it starts. With a WinKeyer or serial keyer the wait could end before the call had finished, so a 6 s gap gave about 2 s of silence after a 4 s CQ."
|
||||
],
|
||||
"fr": [
|
||||
"Cliquer sur un spot du cluster en écoutant sur le VFO SUB ne ramène plus la radio sur le VFO principal. La commande utilisée pour s'accorder agit par définition sur le VFO principal ; sur SUB, c'est désormais le VFO secondaire qui est écrit, sans toucher à la sélection.",
|
||||
"Le split affichait l'inverse de la radio sur certains Yaesu. Quand la radio indique quel VFO émet plutôt qu'un indicateur de split, il faut le comparer au VFO écouté — l'affichage était donc inversé pour un opérateur travaillant sur SUB et correct pour un autre sur MAIN. Le réglage du split souffrait du même défaut.",
|
||||
"Un carnet MySQL partagé pouvait échouer à créer son schéma, laissant OpsLog sur la base SQLite locale avec un message indiquant seulement l'échec de la connexion. Les déclarations de colonnes entre guillemets, ou partageant une ligne avec d'autres, échappaient à la conversion de types SQLite vers MySQL — d'où le refus d'un index et d'une valeur par défaut. Une base déjà dans cet état est désormais réparée au démarrage au lieu d'échouer à chaque lancement.",
|
||||
"Yaesu : OpsLog suit désormais le VFO réellement utilisé — la fréquence affichée, et le VFO qu'un spot du cluster accorde. Un FTDX101 répond à la question du VFO de réception avec deux chiffres là où un FTDX10 n'en donne qu'un, et c'est le mauvais qui était lu.",
|
||||
"Cliquer un spot dans DXHunter peut désormais accorder la radio : un paquet UDP remote_call contenant <FREQ> et <MODE> règle la fréquence et le mode sur le backend CAT actif, en plus de renseigner l'indicatif. Un paquet sans ces balises se contente, comme avant, de renseigner l'indicatif.",
|
||||
"L'option d'import ADIF qui remplit vos champs station depuis votre profil applique aussi vos statuts de confirmation par défaut (QSL papier, LoTW, eQSL, Club Log, HRDLog, QRZ.com) sur ceux que le fichier laisse vides — c'était déjà le cas, mais rien ne le disait, et un log WSJT-X n'en contient pratiquement aucun. Les confirmations existantes ne sont jamais écrasées.",
|
||||
"Le téléchargement des confirmations QRZ.com marquait beaucoup trop de QSO comme confirmés : il acceptait l'indicateur de QSL papier reçue — que vous avez vous-même envoyé à QRZ — comme une confirmation QRZ. Seule la confirmation propre à QRZ compte désormais ; vérifiez la colonne QRZ.com reçu après votre prochain téléchargement, les précédents ayant pu la remplir à tort.",
|
||||
"Le compteur des QSO récents nomme chacun de ses trois nombres — affichés, correspondant au filtre, et présents dans le log — avec des séparateurs de milliers. « Showing 10000 of 23683 matches · 28648 total » se lisait comme si le filtre trouvait plus de QSO que le log n'en contient.",
|
||||
"Repasser le backend CAT sur un Yaesu ne risque plus de laisser le port série occupé. Chaque reconnexion ouvrait une nouvelle poignée sans fermer la précédente, et Windows ouvre un port série en exclusivité : la tentative suivante pouvait échouer avec « port occupé ».",
|
||||
"Un Yaesu éteint est signalé comme tel. L'ouverture du port série était journalisée comme « connecté », avec le nom de modèle hérité de la session précédente : une radio simplement hors tension ressemblait à un défaut du logiciel.",
|
||||
"Yaesu : passer RX et TX ensemble sur le récepteur SUB n'est plus affiché comme un split. C'est du simplex sur le VFO secondaire, mais la radio lève quand même son indicateur de split ; OpsLog le déduit désormais du VFO qui émet et de celui qui reçoit.",
|
||||
"L'appel automatique en CW attend désormais l'intervalle réglé APRÈS le message, et non depuis son début. Avec un WinKeyer ou un keyer série, l'attente pouvait se terminer avant la fin de l'appel : un intervalle de 6 s ne laissait qu'environ 2 s de silence après un CQ de 4 s."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.22.0",
|
||||
"date": "2026-07-28",
|
||||
"en": [
|
||||
"WinKeyer 2: keying is fixed. The dit/dah ratio was sent on the command that sets KEY COMPENSATION, so every element got 50 ms of extra key-down — at 25 wpm a dit is 48 ms — and the keyer sent one element then stalled. The compensation is also cleared, since a keyer already set that way keeps it.",
|
||||
"The voice keyer PTT list now names the Yaesu and Xiegu backends too — with a native backend selected it showed no CAT name, which read as \"CAT PTT is not available for my radio\".",
|
||||
"Voice keyer: a level control for the messages sent to the radio (Settings → Audio). They went out exactly as recorded, so a quietly recorded microphone drove the rig quietly with nothing in OpsLog to fix it. Play previews at the same level, and the hint names the rig setting to check when it transmits almost nothing (on an FTDX10, SSB MOD SOURCE = REAR).",
|
||||
"Yaesu console meters are right: power now reads the meter that follows the RF (in watts, on a curve measured against the rig own display, not the power setting) and SWR shows the ratio itself. Both were reading the wrong index — the SWR bar sat near 80 on a perfect match. The bars also hold their peak for a moment before falling back, so they stay readable through the gaps between words in a CQ, in CW as in SSB.",
|
||||
"CW speed is now one setting wherever you change it: the Yaesu console slider and the CW panel drive the keyer that is actually sending, and the rig own keyer follows so its front panel agrees.",
|
||||
"Antenna Genius: port A no longer jumps onto port B antenna a few seconds after a change. Only one port can hold the transmit antenna, so the switch reports the same one on both — the display now follows each port own receive antenna.",
|
||||
"CW through the Yaesu keyer: a fifth CW engine sends your macros with the radio own keyer (CAT \"KY\") — no WinKeyer, no second cable. Pick \"Yaesu (rig keyer)\" in Settings → CW keyer. For the FTDX101 / FT-991A / FT-710 family: an FTDX10 does not accept it (DAKY included), and OpsLog says so, pointing to the serial DTR keyer on the rig second COM port, which works.",
|
||||
"The Yaesu console follows the mode: in CW the microphone gain and VOX disappear and a CW card takes their place with keyer speed, break-in and ZIN (zero-in).",
|
||||
"Native Xiegu CAT (G90, X6100, X6200, X5105): a new backend talks CI-V straight to the radio — frequency, mode, split and PTT. Pick \"Xiegu (native CI-V)\" in Settings → CAT. Untested on a radio so far, feedback welcome.",
|
||||
"CAT sharing: OpsLog can now serve its rig connection to other programs (Settings → CAT → Share CAT). WSJT-X, JTDX, MSHV or Log4OM connect with rig model \"Hamlib NET rigctl\" at 127.0.0.1:4532 — needed because a native CAT backend holds the radio serial port on its own. Works with every backend, and with both Hamlib command dialects (JTDX names the VFO, MSHV does not).",
|
||||
"Native Yaesu CAT: a new backend talks to an FTDX10/FTDX101 directly over its serial port, without OmniRig — frequency, mode, VFO A/B, split and PTT. Pick \"Yaesu (native CAT)\" in Settings → CAT. First release, feedback welcome.",
|
||||
"Changing band from OpsLog now updates the displayed frequency straight away. The rig moved, but its answer arrived inside the short grace window that protects what you are typing and was discarded — so the frequency stayed on the old band until you nudged the VFO.",
|
||||
"Bulk edit can now set the contacted station gridsquare, so a batch of QSOs imported without a locator can be corrected in one go.",
|
||||
"Filter builder: a confirmation date typed into a condition showed an empty box. The value was stored correctly, only the calendar field failed to display it.",
|
||||
"The Icom model list gains the IC-7300MKII (CI-V B6), plus the IC-7100, IC-7410, IC-7600 and IC-7851. The IC-7700 and IC-7800 were listed at the IC-7100's and IC-7410's addresses, so picking them set an address the rig never answers on.",
|
||||
"Icom over the LAN: the frequency no longer stays frozen after another program (WSJT-X through OmniRig, the Remote Utility) takes the CI-V session. The rig kept answering pings while sending nothing, so OpsLog showed the last known frequency until it was restarted; it now reconnects on its own.",
|
||||
"CQ and ITU zones you correct by hand in your profile stay corrected. They are derived from cty.dat, which gives the zones of the whole entity — in a country spanning several, the automatic value is wrong and it came back at every restart. They now only fill in when empty, and recompute when the callsign or grid changes.",
|
||||
"The offline FCC (ULS) database now answers while you type a US callsign, filling state, county and a 6-character grid. It was only applied when the QSO was saved, so without a QRZ.com or HamQTH account you saw nothing but the country and a coarse grid. Online lookups still win — ULS only fills what is blank.",
|
||||
"CQ and ITU zones you correct by hand in your profile stay corrected. They are derived from cty.dat, which gives the zones of the whole entity — in a country spanning several, the automatic value is wrong and it came back at every restart. They now only fill in when empty, and recompute when the callsign or grid changes."
|
||||
"Icom over the LAN: the frequency no longer stays frozen after another program (WSJT-X through OmniRig, the Remote Utility) takes the CI-V session. The rig kept answering pings while sending nothing, so OpsLog showed the last known frequency until it was restarted; it now reconnects on its own.",
|
||||
"The Icom model list gains the IC-7300MKII (CI-V B6), plus the IC-7100, IC-7410, IC-7600 and IC-7851. The IC-7700 and IC-7800 were listed at the IC-7100's and IC-7410's addresses, so picking them set an address the rig never answers on.",
|
||||
"Filter builder: a confirmation date typed into a condition showed an empty box. The value was stored correctly, only the calendar field failed to display it.",
|
||||
"Bulk edit can now set the contacted station gridsquare, so a batch of QSOs imported without a locator can be corrected in one go.",
|
||||
"Changing band from OpsLog now updates the displayed frequency straight away. The rig moved, but its answer arrived inside the short grace window that protects what you are typing and was discarded — so the frequency stayed on the old band until you nudged the VFO.",
|
||||
"Native Yaesu CAT: a new backend talks to an FTDX10/FTDX101 directly over its serial port, without OmniRig — frequency, mode, VFO A/B, split and PTT. Pick \"Yaesu (native CAT)\" in Settings → CAT. First release, feedback welcome.",
|
||||
"Yaesu console: with the native Yaesu backend, a console tab and a Main-view pane give S/PO/SWR meters, band and mode buttons, AF/RF/squelch, AGC, IPO-AMP1-AMP2, a 6/12/18 dB attenuator, NB, DNR, narrow filter, power, mic gain, VOX, split and ATU tune. CW, RTTY, DIGI and PSK show their sideband — click an active button again to switch it. Pressing SPLIT places the transmit VFO up 1 kHz on CW and data, up 5 kHz on phone, and shows the transmit frequency.",
|
||||
"CAT sharing: OpsLog can now serve its rig connection to other programs (Settings → CAT → Share CAT). WSJT-X, JTDX, MSHV or Log4OM connect with rig model \"Hamlib NET rigctl\" at 127.0.0.1:4532 — needed because a native CAT backend holds the radio serial port on its own. Works with every backend, and with both Hamlib command dialects (JTDX names the VFO, MSHV does not).",
|
||||
"Native Xiegu CAT (G90, X6100, X6200, X5105): a new backend talks CI-V straight to the radio — frequency, mode, split and PTT. Pick \"Xiegu (native CI-V)\" in Settings → CAT. Untested on a radio so far, feedback welcome.",
|
||||
"The Yaesu console follows the mode: in CW the microphone gain and VOX disappear and a CW card takes their place with keyer speed, break-in and ZIN (zero-in).",
|
||||
"CW through the Yaesu keyer: a fifth CW engine sends your macros with the radio own keyer (CAT \"KY\") — no WinKeyer, no second cable. Pick \"Yaesu (rig keyer)\" in Settings → CW keyer. For the FTDX101 / FT-991A / FT-710 family: an FTDX10 does not accept it (DAKY included), and OpsLog says so, pointing to the serial DTR keyer on the rig second COM port, which works.",
|
||||
"Antenna Genius: port A no longer jumps onto port B antenna a few seconds after a change. Only one port can hold the transmit antenna, so the switch reports the same one on both — the display now follows each port own receive antenna.",
|
||||
"CW speed is now one setting wherever you change it: the Yaesu console slider and the CW panel drive the keyer that is actually sending, and the rig own keyer follows so its front panel agrees.",
|
||||
"Yaesu console meters are right: power now reads the meter that follows the RF (in watts, on a curve measured against the rig own display, not the power setting) and SWR shows the ratio itself. Both were reading the wrong index — the SWR bar sat near 80 on a perfect match. The bars also hold their peak for a moment before falling back, so they stay readable through the gaps between words in a CQ, in CW as in SSB.",
|
||||
"Voice keyer: a level control for the messages sent to the radio (Settings → Audio). They went out exactly as recorded, so a quietly recorded microphone drove the rig quietly with nothing in OpsLog to fix it. Play previews at the same level, and the hint names the rig setting to check when it transmits almost nothing (on an FTDX10, SSB MOD SOURCE = REAR).",
|
||||
"The voice keyer PTT list now names the Yaesu and Xiegu backends too — with a native backend selected it showed no CAT name, which read as \"CAT PTT is not available for my radio\".",
|
||||
"WinKeyer 2: keying is fixed. The dit/dah ratio was sent on the command that sets KEY COMPENSATION, so every element got 50 ms of extra key-down — at 25 wpm a dit is 48 ms — and the keyer sent one element then stalled. The compensation is also cleared, since a keyer already set that way keeps it.",
|
||||
"ADIF import accepts a file with no header. Records pasted straight out of another log — a few lines lifted from wsjtx_log.adi into a new .adi — imported as \"0 imported, 0 ignored, 0 total\", which reads as an empty file rather than a missing tag."
|
||||
],
|
||||
"fr": [
|
||||
"WinKeyer 2 : la manipulation est réparée. Le rapport point/trait était envoyé sur la commande qui règle la COMPENSATION DE MANIPULATION : chaque élément recevait donc 50 ms de fermeture supplémentaire — à 25 mots/minute un point dure 48 ms — et le keyer envoyait un élément puis se bloquait. La compensation est aussi remise à zéro, car un keyer déjà réglé ainsi la conserve.",
|
||||
"La liste PTT du voice keyer nomme aussi les backends Yaesu et Xiegu — avec un backend natif sélectionné, aucun nom de CAT n'apparaissait, ce qui se lisait « il n'y a pas de PTT CAT pour ma radio ».",
|
||||
"Voice keyer : un réglage de niveau pour les messages envoyés à la radio (Réglages → Audio). Ils partaient exactement tels qu'enregistrés : un micro capté faiblement attaquait donc la radio faiblement, sans rien dans OpsLog pour y remédier. Le bouton Lire fait entendre ce même niveau, et l'aide indique le réglage radio à vérifier quand elle n'émet presque rien (sur un FTDX10, SSB MOD SOURCE = REAR).",
|
||||
"Les mesures de la console Yaesu sont justes : la puissance lit l'instrument qui suit la HF (en watts, sur une courbe relevée face à l'affichage de la radio, et non le réglage de puissance) et le ROS affiche le rapport lui-même. Les deux lisaient le mauvais index — la barre de ROS restait vers 80 sur une antenne parfaite. Les barres retiennent aussi leur crête un instant avant de redescendre : elles restent lisibles pendant les silences entre les mots d’un CQ, en CW comme en phonie.",
|
||||
"La vitesse CW est désormais un réglage unique où que vous la changiez : le curseur de la console Yaesu et le panneau CW pilotent le manipulateur qui émet réellement, et le keyer interne de la radio suit pour que sa façade affiche la même valeur.",
|
||||
"Antenna Genius : le port A ne bascule plus sur l'antenne du port B quelques secondes après un changement. Un seul port peut porter l'antenne d'émission, le switch renvoie donc la même sur les deux — l'affichage suit désormais l'antenne de réception propre à chaque port.",
|
||||
"CW par le keyer Yaesu : un cinquième moteur CW envoie vos macros avec le keyer de la radio (CAT « KY ») — sans WinKeyer ni second câble. À choisir dans Réglages → Keyer CW sous « Yaesu (keyer de la radio) ». Pour la famille FTDX101 / FT-991A / FT-710 : un FTDX10 ne l'accepte pas (DAKY compris), et OpsLog le dit en renvoyant vers le keyer série DTR sur son second port COM, qui fonctionne.",
|
||||
"La console Yaesu suit le mode : en CW, le gain micro et le VOX disparaissent au profit d'une carte CW avec la vitesse du manipulateur, le break-in et ZIN (zéro-in).",
|
||||
"CAT Xiegu natif (G90, X6100, X6200, X5105) : un nouveau backend dialogue en CI-V directement avec la radio — fréquence, mode, split et PTT. À choisir dans Réglages → CAT sous « Xiegu (CI-V natif) ». Pas encore testé sur une radio, retours bienvenus.",
|
||||
"Partage du CAT : OpsLog peut désormais servir sa liaison radio aux autres logiciels (Réglages → CAT → Partager le CAT). WSJT-X, JTDX, MSHV ou Log4OM s'y connectent avec le modèle « Hamlib NET rigctl » sur 127.0.0.1:4532 — nécessaire car un backend CAT natif occupe seul le port série. Fonctionne avec tous les backends, et avec les deux dialectes Hamlib (JTDX nomme le VFO, MSHV non).",
|
||||
"CAT Yaesu natif : un nouveau backend dialogue directement avec un FTDX10/FTDX101 par son port série, sans OmniRig — fréquence, mode, VFO A/B, split et PTT. À choisir dans Réglages → CAT sous « Yaesu (CAT natif) ». Première version, retours bienvenus.",
|
||||
"Changer de bande depuis OpsLog met désormais la fréquence affichée à jour immédiatement. La radio se déplaçait bien, mais sa réponse arrivait pendant le court délai qui protège votre saisie et était ignorée — la fréquence restait donc sur l'ancienne bande jusqu'à ce qu'on touche le VFO.",
|
||||
"L'édition groupée peut désormais définir le locator de la station contactée : un lot de QSO importés sans locator se corrige en une fois.",
|
||||
"Constructeur de filtres : une date de confirmation saisie dans une condition s'affichait dans une case vide. La valeur était bien enregistrée, seul le champ calendrier ne la montrait pas.",
|
||||
"La liste des modèles Icom gagne l'IC-7300MKII (CI-V B6), ainsi que les IC-7100, IC-7410, IC-7600 et IC-7851. Les IC-7700 et IC-7800 y figuraient avec les adresses des IC-7100 et IC-7410 : les choisir réglait une adresse sur laquelle la radio ne répond jamais.",
|
||||
"Icom via le réseau : la fréquence ne reste plus figée après qu'un autre programme (WSJT-X via OmniRig, la Remote Utility) a pris la session CI-V. La radio continuait de répondre aux pings sans rien émettre, si bien qu'OpsLog affichait la dernière fréquence connue jusqu'à son redémarrage ; il se reconnecte désormais tout seul.",
|
||||
"Les zones CQ et ITU corrigées à la main dans votre profil restent corrigées. Elles proviennent de cty.dat, qui donne les zones de l'entité entière — dans un pays qui en couvre plusieurs, la valeur automatique est fausse et revenait à chaque redémarrage. Elles ne se remplissent désormais que si le champ est vide, et se recalculent quand l'indicatif ou le locator change.",
|
||||
"La base FCC (ULS) hors ligne répond désormais pendant la saisie d'un indicatif américain, en remplissant l'état, le comté et un locator 6 caractères. Elle n'était appliquée qu'à l'enregistrement du QSO : sans compte QRZ.com ou HamQTH, vous ne voyiez que le pays et un locator approximatif. Les recherches en ligne restent prioritaires — l'ULS ne comble que ce qui est vide.",
|
||||
"Les zones CQ et ITU corrigées à la main dans votre profil restent corrigées. Elles proviennent de cty.dat, qui donne les zones de l'entité entière — dans un pays qui en couvre plusieurs, la valeur automatique est fausse et revenait à chaque redémarrage. Elles ne se remplissent désormais que si le champ est vide, et se recalculent quand l'indicatif ou le locator change."
|
||||
"Icom via le réseau : la fréquence ne reste plus figée après qu'un autre programme (WSJT-X via OmniRig, la Remote Utility) a pris la session CI-V. La radio continuait de répondre aux pings sans rien émettre, si bien qu'OpsLog affichait la dernière fréquence connue jusqu'à son redémarrage ; il se reconnecte désormais tout seul.",
|
||||
"La liste des modèles Icom gagne l'IC-7300MKII (CI-V B6), ainsi que les IC-7100, IC-7410, IC-7600 et IC-7851. Les IC-7700 et IC-7800 y figuraient avec les adresses des IC-7100 et IC-7410 : les choisir réglait une adresse sur laquelle la radio ne répond jamais.",
|
||||
"Constructeur de filtres : une date de confirmation saisie dans une condition s'affichait dans une case vide. La valeur était bien enregistrée, seul le champ calendrier ne la montrait pas.",
|
||||
"L'édition groupée peut désormais définir le locator de la station contactée : un lot de QSO importés sans locator se corrige en une fois.",
|
||||
"Changer de bande depuis OpsLog met désormais la fréquence affichée à jour immédiatement. La radio se déplaçait bien, mais sa réponse arrivait pendant le court délai qui protège votre saisie et était ignorée — la fréquence restait donc sur l'ancienne bande jusqu'à ce qu'on touche le VFO.",
|
||||
"CAT Yaesu natif : un nouveau backend dialogue directement avec un FTDX10/FTDX101 par son port série, sans OmniRig — fréquence, mode, VFO A/B, split et PTT. À choisir dans Réglages → CAT sous « Yaesu (CAT natif) ». Première version, retours bienvenus.",
|
||||
"Console Yaesu : avec le backend Yaesu natif, un onglet console et un volet de la vue principale offrent les mesures S/PO/ROS, les boutons de bande et de mode, AF/RF/squelch, AGC, IPO-AMP1-AMP2, un atténuateur 6/12/18 dB, NB, DNR, filtre étroit, puissance, gain micro, VOX, split et accord d'antenne. CW, RTTY, DIGI et PSK affichent leur bande latérale — recliquez sur le bouton actif pour en changer. Appuyer sur SPLIT place le VFO d'émission à +1 kHz en CW et numérique, +5 kHz en phonie, et affiche la fréquence d'émission.",
|
||||
"Partage du CAT : OpsLog peut désormais servir sa liaison radio aux autres logiciels (Réglages → CAT → Partager le CAT). WSJT-X, JTDX, MSHV ou Log4OM s'y connectent avec le modèle « Hamlib NET rigctl » sur 127.0.0.1:4532 — nécessaire car un backend CAT natif occupe seul le port série. Fonctionne avec tous les backends, et avec les deux dialectes Hamlib (JTDX nomme le VFO, MSHV non).",
|
||||
"CAT Xiegu natif (G90, X6100, X6200, X5105) : un nouveau backend dialogue en CI-V directement avec la radio — fréquence, mode, split et PTT. À choisir dans Réglages → CAT sous « Xiegu (CI-V natif) ». Pas encore testé sur une radio, retours bienvenus.",
|
||||
"La console Yaesu suit le mode : en CW, le gain micro et le VOX disparaissent au profit d'une carte CW avec la vitesse du manipulateur, le break-in et ZIN (zéro-in).",
|
||||
"CW par le keyer Yaesu : un cinquième moteur CW envoie vos macros avec le keyer de la radio (CAT « KY ») — sans WinKeyer ni second câble. À choisir dans Réglages → Keyer CW sous « Yaesu (keyer de la radio) ». Pour la famille FTDX101 / FT-991A / FT-710 : un FTDX10 ne l'accepte pas (DAKY compris), et OpsLog le dit en renvoyant vers le keyer série DTR sur son second port COM, qui fonctionne.",
|
||||
"Antenna Genius : le port A ne bascule plus sur l'antenne du port B quelques secondes après un changement. Un seul port peut porter l'antenne d'émission, le switch renvoie donc la même sur les deux — l'affichage suit désormais l'antenne de réception propre à chaque port.",
|
||||
"La vitesse CW est désormais un réglage unique où que vous la changiez : le curseur de la console Yaesu et le panneau CW pilotent le manipulateur qui émet réellement, et le keyer interne de la radio suit pour que sa façade affiche la même valeur.",
|
||||
"Les mesures de la console Yaesu sont justes : la puissance lit l'instrument qui suit la HF (en watts, sur une courbe relevée face à l'affichage de la radio, et non le réglage de puissance) et le ROS affiche le rapport lui-même. Les deux lisaient le mauvais index — la barre de ROS restait vers 80 sur une antenne parfaite. Les barres retiennent aussi leur crête un instant avant de redescendre : elles restent lisibles pendant les silences entre les mots d’un CQ, en CW comme en phonie.",
|
||||
"Voice keyer : un réglage de niveau pour les messages envoyés à la radio (Réglages → Audio). Ils partaient exactement tels qu'enregistrés : un micro capté faiblement attaquait donc la radio faiblement, sans rien dans OpsLog pour y remédier. Le bouton Lire fait entendre ce même niveau, et l'aide indique le réglage radio à vérifier quand elle n'émet presque rien (sur un FTDX10, SSB MOD SOURCE = REAR).",
|
||||
"La liste PTT du voice keyer nomme aussi les backends Yaesu et Xiegu — avec un backend natif sélectionné, aucun nom de CAT n'apparaissait, ce qui se lisait « il n'y a pas de PTT CAT pour ma radio ».",
|
||||
"WinKeyer 2 : la manipulation est réparée. Le rapport point/trait était envoyé sur la commande qui règle la COMPENSATION DE MANIPULATION : chaque élément recevait donc 50 ms de fermeture supplémentaire — à 25 mots/minute un point dure 48 ms — et le keyer envoyait un élément puis se bloquait. La compensation est aussi remise à zéro, car un keyer déjà réglé ainsi la conserve.",
|
||||
"L'import ADIF accepte un fichier sans en-tête. Des enregistrements collés depuis un autre log — quelques lignes reprises de wsjtx_log.adi dans un nouveau .adi — donnaient « 0 importé, 0 ignoré, 0 au total », ce qui se lit comme un fichier vide plutôt que comme une balise manquante."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+88
-11
@@ -1518,6 +1518,11 @@ export default function App() {
|
||||
const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip');
|
||||
const [importApplyCty, setImportApplyCty] = useState(true);
|
||||
const [importApplyStation, setImportApplyStation] = useState(false);
|
||||
// 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
|
||||
// US state and leaves the IOTA award empty.
|
||||
const [importFieldMap, setImportFieldMap] = useState<{ from: string; to: string }[]>([]);
|
||||
// QRZ profile photo lightbox (full-size, in-app — not the browser).
|
||||
const [photoModal, setPhotoModal] = useState<string | null>(null);
|
||||
// Esc closes the lightbox. Capture-phase + stopImmediatePropagation so the
|
||||
@@ -2602,11 +2607,21 @@ export default function App() {
|
||||
if (isRig) {
|
||||
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + 600);
|
||||
} else {
|
||||
// WinKeyer: watch the busy echo, capped by the estimate (+slack) since
|
||||
// over a remote/serial-over-IP link that echo can lag tens of seconds.
|
||||
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500;
|
||||
for (let i = 0; i < 20 && !wkBusyRef.current && !aborted(); i++) await sleep(50); // ≤1s to start
|
||||
const deadline = Date.now() + capMs;
|
||||
// WinKeyer / serial keyer: the message takes AT LEAST its own length to
|
||||
// send, so the estimate is a floor and the busy signal only extends it.
|
||||
//
|
||||
// Waiting on busy alone made the auto-call gap start early: busy travels
|
||||
// from the keyer to this window as an event, and if it had not arrived
|
||||
// and cleared within the one-second start window the wait simply ended.
|
||||
// A 6 s gap then ran from the START of the CQ — about 2 s of silence
|
||||
// after a 4 s call, which is what an operator hears as "clearly not 6".
|
||||
const estMs = Math.round(estimateCwMs(resolved, wkWpm));
|
||||
const started = Date.now();
|
||||
const capMs = Math.round(estMs * 1.4) + 2500;
|
||||
const deadline = started + capMs;
|
||||
// The message's own duration, first — this is the part that must not be skipped.
|
||||
while (Date.now() - started < estMs && !aborted()) await sleep(50);
|
||||
// Then let a still-sending keyer finish (slow link, long buffer), capped.
|
||||
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
|
||||
}
|
||||
}
|
||||
@@ -3355,7 +3370,13 @@ export default function App() {
|
||||
setImportErrorsOpen(false);
|
||||
setImportDupsOpen(false);
|
||||
try {
|
||||
const res = await ImportADIF(path, importDupMode, importApplyCty, importApplyStation);
|
||||
const fm: Record<string, string> = {};
|
||||
for (const r of importFieldMap) {
|
||||
const from = r.from.trim().toUpperCase();
|
||||
const to = r.to.trim().toUpperCase();
|
||||
if (from && to && from !== to) fm[from] = to;
|
||||
}
|
||||
const res = await ImportADIF(path, importDupMode, importApplyCty, importApplyStation, fm);
|
||||
setImportResult(res);
|
||||
await refresh();
|
||||
} catch (e: any) {
|
||||
@@ -5560,16 +5581,31 @@ export default function App() {
|
||||
onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }}
|
||||
>clear</button>
|
||||
) : null}
|
||||
{/* Three different numbers, so each one is LABELLED. "Showing
|
||||
10000 of 23683 matches · 28648 total" was read as a filter
|
||||
matching more than the log holds: the middle number belonged
|
||||
to "matches" but sat where "of N" normally means the total.
|
||||
Thousands separators for the same reason — 23683 and 28648
|
||||
are hard to compare at a glance. */}
|
||||
{gridFilteredCount != null ? (
|
||||
<span>
|
||||
Showing <span className="font-semibold text-foreground">{gridFilteredCount}</span> of{' '}
|
||||
<span className="font-semibold text-foreground">{qsos.length}</span> (column filter)
|
||||
Showing <span className="font-semibold text-foreground">{gridFilteredCount.toLocaleString()}</span> of{' '}
|
||||
<span className="font-semibold text-foreground">{qsos.length.toLocaleString()}</span> (column filter)
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
|
||||
<span className="font-semibold text-foreground">{(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total}</span>
|
||||
{(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''}
|
||||
{(activeFilter.conditions?.length || filterCallsign) ? (
|
||||
<>
|
||||
Showing <span className="font-semibold text-foreground">{qsos.length.toLocaleString()}</span>
|
||||
{' · '}<span className="font-semibold text-foreground">{(matchCount ?? 0).toLocaleString()}</span> match the filter
|
||||
{' · '}<span className="font-semibold text-foreground">{total.toLocaleString()}</span> in the log
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Showing <span className="font-semibold text-foreground">{qsos.length.toLocaleString()}</span> of{' '}
|
||||
<span className="font-semibold text-foreground">{total.toLocaleString()}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -6298,6 +6334,47 @@ export default function App() {
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{/* Field remapping. Hidden behind a link until asked for: it is the
|
||||
rare case, and an import dialog that opens onto empty mapping
|
||||
rows suggests something is required when nothing is. */}
|
||||
<div className="pt-1 border-t mt-1">
|
||||
{importFieldMap.length === 0 ? (
|
||||
<button type="button"
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={() => setImportFieldMap([{ from: 'STATE', to: 'IOTA' }])}>
|
||||
{t('imp.mapAdd')}
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm">{t('imp.mapTitle')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('imp.mapDesc')}</div>
|
||||
{importFieldMap.map((row, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={row.from}
|
||||
placeholder="STATE"
|
||||
className="h-7 text-xs font-mono uppercase"
|
||||
onChange={(e) => setImportFieldMap((m) => m.map((r, j) => j === i ? { ...r, from: e.target.value } : r))}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">→</span>
|
||||
<Input
|
||||
value={row.to}
|
||||
placeholder="IOTA"
|
||||
className="h-7 text-xs font-mono uppercase"
|
||||
onChange={(e) => setImportFieldMap((m) => m.map((r, j) => j === i ? { ...r, to: e.target.value } : r))}
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs"
|
||||
onClick={() => setImportFieldMap((m) => m.filter((_, j) => j !== i))}>✕</Button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button"
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={() => setImportFieldMap((m) => [...m, { from: '', to: '' }])}>
|
||||
{t('imp.mapMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="px-2 bg-transparent border-t-0">
|
||||
<Button variant="outline" onClick={() => setPendingImportPath(null)}>{t('imp.cancel')}</Button>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import { LogUIError } from '../../wailsjs/go/main/App';
|
||||
|
||||
// A render error used to empty the window and leave nothing behind: no line in
|
||||
// opslog.log, no dialog, no way for the operator to say more than "white screen".
|
||||
// Two separate reports — one after logging a 10 GHz QSO, one on starting a DVK
|
||||
// auto-call — stayed undiagnosed for weeks for exactly that reason.
|
||||
//
|
||||
// So this does two things, in order of importance:
|
||||
//
|
||||
// 1. Writes the error and its stack to the app log through LogUIError, so the
|
||||
// next report arrives WITH the cause instead of a description of a blank
|
||||
// screen.
|
||||
// 2. Shows it, with the text selectable and a button to copy it, because the
|
||||
// person who can act on it is the one looking at the screen.
|
||||
//
|
||||
// It deliberately does NOT try to recover the tree. React cannot guarantee the
|
||||
// state is sane after a render throw, and a half-working logger is worse than
|
||||
// one that says plainly what happened and offers a reload.
|
||||
|
||||
type Props = { children: React.ReactNode };
|
||||
type State = { error: Error | null; info: string };
|
||||
|
||||
export class ErrorBoundary extends React.Component<Props, State> {
|
||||
state: State = { error: null, info: '' };
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
const stack = (info?.componentStack || error.stack || '').trim();
|
||||
this.setState({ info: stack });
|
||||
// Never let the reporting path throw: it runs while the app is already broken.
|
||||
try {
|
||||
LogUIError('render crash', `${error.name}: ${error.message}`, stack);
|
||||
} catch {
|
||||
/* the backend may be gone too — the on-screen copy still works */
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error, info } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
|
||||
const report = `${error.name}: ${error.message}\n\n${info}`;
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24, background: '#0b1220', color: '#e5e7eb', fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
||||
overflow: 'auto', zIndex: 9999,
|
||||
}}>
|
||||
<div style={{ maxWidth: 820, width: '100%' }}>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 700, margin: '0 0 6px' }}>
|
||||
OpsLog hit an error and stopped drawing
|
||||
</h1>
|
||||
<p style={{ fontSize: 13, opacity: 0.8, margin: '0 0 14px' }}>
|
||||
Your log is safe — this is the window, not the database. The details below
|
||||
are already in the app log; send them with your report.
|
||||
</p>
|
||||
<pre style={{
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word', userSelect: 'text',
|
||||
fontSize: 11.5, lineHeight: 1.5, background: '#111827', border: '1px solid #1f2937',
|
||||
borderRadius: 8, padding: 12, maxHeight: '48vh', overflow: 'auto', margin: 0,
|
||||
}}>{report}</pre>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 14 }}>
|
||||
<button
|
||||
onClick={() => { void navigator.clipboard?.writeText(report); }}
|
||||
style={{
|
||||
padding: '7px 14px', borderRadius: 6, border: '1px solid #374151',
|
||||
background: '#1f2937', color: '#e5e7eb', fontSize: 13, cursor: 'pointer',
|
||||
}}>
|
||||
Copy the details
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
padding: '7px 14px', borderRadius: 6, border: '1px solid #1d4ed8',
|
||||
background: '#2563eb', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||
}}>
|
||||
Reload the window
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// installGlobalErrorLogging catches what a boundary cannot: an error thrown from
|
||||
// a timer, an event handler or a rejected promise. Those do not unmount the tree,
|
||||
// so they leave no trace at all — and an auto-call loop lives entirely in
|
||||
// callbacks, which is precisely where a white screen was reported.
|
||||
export function installGlobalErrorLogging() {
|
||||
const report = (kind: string, message: string, stack?: string) => {
|
||||
try {
|
||||
LogUIError(kind, message, stack || '');
|
||||
} catch {
|
||||
/* nothing more we can do from here */
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('error', (e: ErrorEvent) => {
|
||||
const src = e.filename ? ` (${e.filename}:${e.lineno}:${e.colno})` : '';
|
||||
report('uncaught error', (e.message || 'unknown error') + src, e.error?.stack);
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
|
||||
const r: any = e.reason;
|
||||
report('unhandled rejection', String(r?.message ?? r ?? 'unknown'), r?.stack);
|
||||
});
|
||||
}
|
||||
@@ -444,10 +444,18 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
||||
if (widthUps.length) api.setColumnWidths(widthUps);
|
||||
}, [awardCols, awardShown]);
|
||||
|
||||
// No column filters here, deliberately. This grid holds only the most recent
|
||||
// page of QSOs, so a column filter searches THOSE rows and nothing else — and
|
||||
// an empty result then reads as "the log does not contain it" rather than "it
|
||||
// is not on this page". An operator hunting an imported CONTEST_ID across
|
||||
// 28 000 QSOs found nothing and reasonably concluded the import had dropped it.
|
||||
//
|
||||
// The advanced filter queries the whole logbook and is the honest tool for it.
|
||||
// Removing the filter also gives each header back the width of its menu icon.
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: !passOrder,
|
||||
resizable: true,
|
||||
filter: true,
|
||||
filter: false,
|
||||
suppressMovable: false,
|
||||
}), [passOrder]);
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||
GetQSLDefaults, SaveQSLDefaults,
|
||||
SetWinkeyerTrace,
|
||||
SetCIVTrace,
|
||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
|
||||
GetPOTAToken, SavePOTAToken,
|
||||
TestLoTWUpload, ListTQSLStationLocations,
|
||||
@@ -198,7 +199,35 @@ type SectionId =
|
||||
|
||||
type TreeNode =
|
||||
| { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] }
|
||||
| { kind: 'item'; label: string; id: SectionId; disabled?: boolean };
|
||||
| { kind: 'item'; label: string; id: SectionId; disabled?: boolean; vendor?: 'o3a' };
|
||||
|
||||
// A compact 4O3A wordmark for the sidebar, drawn here rather than shipped as an
|
||||
// image: an inline SVG scales with the row, follows the theme (currentColor), and
|
||||
// adds no binary asset. It marks which panels drive 4O3A hardware — the same
|
||||
// reason the language picker draws its flags inline.
|
||||
//
|
||||
// This is a plain wordmark, NOT the manufacturer's logo artwork. If you would
|
||||
// rather show the official mark, drop the SVG in and swap this component out.
|
||||
function VendorMark({ vendor }: { vendor: 'o3a' }) {
|
||||
if (vendor !== 'o3a') return null;
|
||||
// The 4O3A mark: "4", the green waveform, "3A". Drawn inline rather than
|
||||
// shipped as an image — it scales with the row, keeps the sidebar's own type
|
||||
// for the digits, and adds no binary asset. The waveform is the part that
|
||||
// carries the brand, so only it is SVG; the characters stay HTML text so they
|
||||
// match the surrounding menu at any theme or zoom.
|
||||
const lens = [3.2, 5.2, 7.4, 9.5, 7.4, 5.2, 3.2]; // half-heights, tallest in the middle
|
||||
return (
|
||||
<span className="ml-1.5 flex shrink-0 items-center gap-[1px] text-[10px] font-bold leading-none opacity-80" title="4O3A Signature">
|
||||
<span>4</span>
|
||||
<svg viewBox="0 0 26 22" className="h-[13px] w-[15px]" aria-hidden="true">
|
||||
{lens.map((ry, i) => (
|
||||
<ellipse key={i} cx={3 + i * 3.3} cy={11} rx={1.5} ry={ry} fill="#1f9d3a" />
|
||||
))}
|
||||
</svg>
|
||||
<span>3A</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// buildTree returns the settings sidebar. The FlexRadio item only appears when
|
||||
// the active CAT backend is a Flex (per-band antenna config is Flex-specific).
|
||||
@@ -208,8 +237,8 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
||||
{ kind: 'item', label: t('sec.rotator'), id: 'rotator' },
|
||||
{ kind: 'item', label: t('sec.winkeyer'), id: 'winkeyer' },
|
||||
{ kind: 'item', label: t('sec.antenna'), id: 'antenna' },
|
||||
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius' },
|
||||
{ kind: 'item', label: t('sec.tunergenius'), id: 'tunergenius' },
|
||||
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius', vendor: 'o3a' },
|
||||
{ kind: 'item', label: t('sec.tunergenius'), id: 'tunergenius', vendor: 'o3a' },
|
||||
{ kind: 'item', label: t('sec.pgxl'), id: 'pgxl' },
|
||||
...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []),
|
||||
{ kind: 'item', label: t('sec.relayauto'), id: 'relayauto' },
|
||||
@@ -325,6 +354,7 @@ function TreeNodeView({
|
||||
style={{ paddingLeft: 8 + depth * 14 }}
|
||||
>
|
||||
<span className="truncate">{node.label}</span>
|
||||
{node.vendor && !node.disabled && <VendorMark vendor={node.vendor} />}
|
||||
{node.disabled && (
|
||||
<Construction className="ml-auto size-3 shrink-0 opacity-60" />
|
||||
)}
|
||||
@@ -1063,7 +1093,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, xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70,
|
||||
yaesu_port: '', yaesu_baud: 38400, kenwood_port: '', kenwood_baud: 9600, 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,
|
||||
@@ -1107,6 +1137,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const [wkPorts, setWkPorts] = useState<string[]>([]);
|
||||
// Session-only: the byte trace is a diagnostic, never a saved preference.
|
||||
const [wkTrace, setWkTrace] = useState(false);
|
||||
const [civTrace, setCivTrace] = useState(false);
|
||||
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
|
||||
|
||||
// ── Audio (DVK + QSO recorder) ──
|
||||
@@ -2350,6 +2381,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<SelectItem value="omnirig">{t('cat.optOmnirig')}</SelectItem>
|
||||
<SelectItem value="flex">{t('cat.optFlex')}</SelectItem>
|
||||
<SelectItem value="yaesu">{t('cat.optYaesu')}</SelectItem>
|
||||
<SelectItem value="kenwood">{t('cat.optKenwood')}</SelectItem>
|
||||
<SelectItem value="xiegu">{t('cat.optXiegu')}</SelectItem>
|
||||
<SelectItem value="icom">{t('cat.optIcom')}</SelectItem>
|
||||
<SelectItem value="icom-net">{t('cat.optIcomNet')}</SelectItem>
|
||||
@@ -2484,6 +2516,44 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(catCfg.backend === 'icom' || catCfg.backend === 'xiegu') && (
|
||||
<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); }} />
|
||||
{t('cat.civTrace')}
|
||||
</label>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">{t('cat.civTraceHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{catCfg.backend === 'kenwood' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.kenwoodPort')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select value={catCfg.kenwood_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_port: v }))}>
|
||||
<SelectTrigger><SelectValue placeholder={t('cat.selectCom')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{wkPorts.length === 0 && <SelectItem value="_" disabled>{t('cat.noPorts')}</SelectItem>}
|
||||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="button" variant="outline" size="sm"
|
||||
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>↻</Button>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{t('cat.kenwoodPortHint')}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.baud')}</Label>
|
||||
<Select value={String(catCfg.kenwood_baud || 9600)} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_baud: parseInt(v) || 9600 }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs text-muted-foreground">{t('cat.kenwoodBaudHint')}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{catCfg.backend === 'icom' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
@@ -3169,7 +3239,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<SelectContent>
|
||||
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
||||
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
||||
<SelectItem value="arco">microHAM ARCO (LAN, GS-232A)</SelectItem>
|
||||
<SelectItem value="arco">GS-232A controller (microHAM ARCO, ERC…)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -3216,6 +3286,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
<ArrowDown className="size-3.5 rotate-90" />
|
||||
</Button>
|
||||
<Select value={String((rotator as any).baud || 9600)}
|
||||
onValueChange={(v) => setRotator((s) => ({ ...s, baud: Number(v) } as any))}>
|
||||
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{[4800, 9600, 19200, 38400, 57600].map((b) => (
|
||||
<SelectItem key={b} value={String(b)}>{b} baud</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -134,8 +134,8 @@ const en: Dict = {
|
||||
'imp.ctyTitle': 'Fix country & zones (cty.dat + ClubLog)',
|
||||
'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. Enable when importing your own log.",
|
||||
'imp.cancel': 'Cancel', 'imp.import': 'Import',
|
||||
'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.progressCount': '{done} / {tot} records · {pct}%', 'imp.progressCountOnly': '{done} records…',
|
||||
'imp.complete': 'Import complete.',
|
||||
@@ -273,11 +273,11 @@ const en: Dict = {
|
||||
'cat.hint': "Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug).",
|
||||
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
||||
'tg2.hint': 'OpsLog talks to the 4O3A Tuner Genius XL over TCP (port fixed at 9010), so only the device IP is needed. A docked widget then shows SWR and forward power and offers Tune, Bypass and Operate/Standby. Control it directly (not through the radio) so OpsLog uses just one of the box\'s connection slots.', 'tg2.enable': 'Enable Tuner Genius control', 'tg2.portHint': 'The TCP port is fixed at 9010 on the device.', 'tg2.password': 'Remote code', 'tg2.passwordPh': 'blank on LAN', 'tg2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
||||
'rot.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.type': 'Rotator type', 'rot.rotatorNum': 'Rotator #', 'rot.rgHint': 'Talks directly to a 4O3A Rotator Genius over TCP (default port 9006) — no PstRotator needed. Rotator # selects which of the two rotators to drive.', 'rot.arcoHint': "Talks directly to a microHAM ARCO — no PstRotator needed. LAN: set Config → LAN → CONTROL PROTOCOL to 'Yaesu GS-232A' on the ARCO and enter the same TCP port here (up to 4 parallel connections). USB: set USB CONTROL PROTOCOL to 'Yaesu GS-232A' and pick the ARCO's COM port here (baud rate doesn't matter on USB).", 'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
|
||||
'rot.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.type': 'Rotator type', 'rot.rotatorNum': 'Rotator #', 'rot.rgHint': 'Talks directly to a 4O3A Rotator Genius over TCP (default port 9006) — no PstRotator needed. Rotator # selects which of the two rotators to drive.', 'rot.arcoHint': "Talks directly to any controller set to Yaesu GS-232A — no PstRotator needed. microHAM ARCO: set Config → LAN → CONTROL PROTOCOL (or USB CONTROL PROTOCOL) to 'Yaesu GS-232A'; on USB the baud rate does not matter. ERC (Easy Rotor Control, incl. ERC Mini): its emulation MUST be set to GS-232 — an ERC left on Hy-Gain DCU-1 speaks a different command set and will not answer — then pick its COM port and the same baud rate as in the ERC configuration.", 'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
|
||||
'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.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 (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.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)',
|
||||
@@ -545,8 +545,8 @@ const fr: Dict = {
|
||||
'imp.ctyTitle': 'Corriger pays & zones (cty.dat + ClubLog)',
|
||||
'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. À activer quand tu importes ton propre log.",
|
||||
'imp.cancel': 'Annuler', 'imp.import': 'Importer',
|
||||
'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.progressCount': '{done} / {tot} enregistrements · {pct}%', 'imp.progressCountOnly': '{done} enregistrements…',
|
||||
'imp.complete': 'Import terminé.',
|
||||
@@ -675,10 +675,10 @@ const fr: Dict = {
|
||||
'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).",
|
||||
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
||||
'tg2.hint': "OpsLog dialogue avec le 4O3A Tuner Genius XL en TCP (port fixé à 9010), seule l'IP de l'appareil est nécessaire. Un widget ancré affiche le ROS et la puissance directe et propose Accord, Bypass et Operate/Standby. Pilotage direct (pas via la radio) pour n'utiliser qu'une des connexions de la boîte.", 'tg2.enable': "Activer le contrôle du Tuner Genius", 'tg2.portHint': "Le port TCP est fixé à 9010 sur l'appareil.", 'tg2.password': 'Code distant', 'tg2.passwordPh': 'vide en LAN', 'tg2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
||||
'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 à un microHAM ARCO — sans PstRotator. Réseau : régler Config → LAN → CONTROL PROTOCOL sur « Yaesu GS-232A » sur l'ARCO et saisir ici le même port TCP (jusqu'à 4 connexions en parallèle). USB : régler USB CONTROL PROTOCOL sur « Yaesu GS-232A » et choisir ici le port COM de l'ARCO (la vitesse est sans importance en USB).", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
||||
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à tout contrôleur réglé sur Yaesu GS-232A — sans PstRotator. microHAM ARCO : régler Config → LAN → CONTROL PROTOCOL (ou USB CONTROL PROTOCOL) sur « Yaesu GS-232A » ; en USB la vitesse est sans importance. ERC (Easy Rotor Control, ERC Mini compris) : son émulation DOIT être réglée sur GS-232 — un ERC laissé en Hy-Gain DCU-1 parle un autre jeu de commandes et ne répondra pas — puis choisir son port COM et la même vitesse que dans la configuration de l'ERC.", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
||||
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
||||
'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.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 (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.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)',
|
||||
|
||||
+12
-5
@@ -5,6 +5,7 @@ import App from './App'
|
||||
import { syncPortablePrefs } from './lib/uiPref'
|
||||
import { I18nProvider } from './lib/i18n'
|
||||
import { ThemeProvider, initTheme } from './lib/theme'
|
||||
import { ErrorBoundary, installGlobalErrorLogging } from './components/ErrorBoundary'
|
||||
|
||||
const container = document.getElementById('root')
|
||||
|
||||
@@ -17,13 +18,19 @@ syncPortablePrefs().finally(() => {
|
||||
// Stamp the persisted theme onto <html> before render so the correct
|
||||
// palette is applied immediately (portable pref hydrated just above).
|
||||
initTheme()
|
||||
// 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.
|
||||
installGlobalErrorLogging()
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<App/>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
<ErrorBoundary>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<App/>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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.0';
|
||||
export const APP_VERSION = '0.22.2';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+5
-1
@@ -589,7 +589,7 @@ export function IcomStopCW():Promise<void>;
|
||||
|
||||
export function IcomTune():Promise<void>;
|
||||
|
||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
|
||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean,arg5:Record<string, string>):Promise<adif.ImportResult>;
|
||||
|
||||
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
||||
|
||||
@@ -637,6 +637,8 @@ export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
||||
|
||||
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||
|
||||
export function LogUIError(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||
|
||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||
|
||||
export function LookupCallsignFresh(arg1:string):Promise<lookup.Result>;
|
||||
@@ -891,6 +893,8 @@ export function SetCATFrequency(arg1:number):Promise<void>;
|
||||
|
||||
export function SetCATMode(arg1:string):Promise<void>;
|
||||
|
||||
export function SetCIVTrace(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
||||
|
||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
@@ -1126,8 +1126,8 @@ export function IcomTune() {
|
||||
return window['go']['main']['App']['IcomTune']();
|
||||
}
|
||||
|
||||
export function ImportADIF(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4);
|
||||
export function ImportADIF(arg1, arg2, arg3, arg4, arg5) {
|
||||
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4, arg5);
|
||||
}
|
||||
|
||||
export function ImportAwardReferencesText(arg1, arg2) {
|
||||
@@ -1222,6 +1222,10 @@ export function LogUDPLoggedADIF(arg1) {
|
||||
return window['go']['main']['App']['LogUDPLoggedADIF'](arg1);
|
||||
}
|
||||
|
||||
export function LogUIError(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['LogUIError'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function LookupCallsign(arg1) {
|
||||
return window['go']['main']['App']['LookupCallsign'](arg1);
|
||||
}
|
||||
@@ -1730,6 +1734,10 @@ export function SetCATMode(arg1) {
|
||||
return window['go']['main']['App']['SetCATMode'](arg1);
|
||||
}
|
||||
|
||||
export function SetCIVTrace(arg1) {
|
||||
return window['go']['main']['App']['SetCIVTrace'](arg1);
|
||||
}
|
||||
|
||||
export function SetCWDecoderPitch(arg1) {
|
||||
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
|
||||
}
|
||||
|
||||
@@ -1906,6 +1906,8 @@ export namespace main {
|
||||
xiegu_addr: number;
|
||||
yaesu_port: string;
|
||||
yaesu_baud: number;
|
||||
kenwood_port: string;
|
||||
kenwood_baud: number;
|
||||
icom_port: string;
|
||||
icom_baud: number;
|
||||
icom_addr: number;
|
||||
@@ -1942,6 +1944,8 @@ export namespace main {
|
||||
this.xiegu_addr = source["xiegu_addr"];
|
||||
this.yaesu_port = source["yaesu_port"];
|
||||
this.yaesu_baud = source["yaesu_baud"];
|
||||
this.kenwood_port = source["kenwood_port"];
|
||||
this.kenwood_baud = source["kenwood_baud"];
|
||||
this.icom_port = source["icom_port"];
|
||||
this.icom_baud = source["icom_baud"];
|
||||
this.icom_addr = source["icom_addr"];
|
||||
@@ -2799,6 +2803,7 @@ export namespace main {
|
||||
rotator_num: number;
|
||||
transport: string;
|
||||
com_port: string;
|
||||
baud: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RotatorSettings(source);
|
||||
@@ -2814,6 +2819,7 @@ export namespace main {
|
||||
this.rotator_num = source["rotator_num"];
|
||||
this.transport = source["transport"];
|
||||
this.com_port = source["com_port"];
|
||||
this.baud = source["baud"];
|
||||
}
|
||||
}
|
||||
export class ScpStatus {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// The confirmation statuses an import fills in, and the ones it must not touch.
|
||||
//
|
||||
// An operator asked for this feature believing it missing — it has been there
|
||||
// since v0.14, but the option's description never mentioned it, so nobody could
|
||||
// know. The behaviour is pinned here so a future edit cannot quietly drop what
|
||||
// the description now promises.
|
||||
//
|
||||
// The rule is fill-if-empty: a WSJT-X log carries almost no QSL fields, while a
|
||||
// log exported from another program carries real ones that must survive.
|
||||
func TestQSLDefaultsFillOnlyEmptyFields(t *testing.T) {
|
||||
defaults := QSLDefaults{
|
||||
QSLSent: "N", QSLRcvd: "N",
|
||||
LOTWSent: "N", LOTWRcvd: "N",
|
||||
EQSLSent: "N", EQSLRcvd: "N",
|
||||
ClublogStatus: "N", HRDLogStatus: "N",
|
||||
QRZComStatus: "N", QRZComCfm: "N",
|
||||
}
|
||||
|
||||
// A record as WSJT-X writes it: no confirmation fields at all.
|
||||
bare := &qso.QSO{Callsign: "F4XYZ"}
|
||||
applyQSLDefaultsTo(bare, defaults)
|
||||
for name, got := range map[string]string{
|
||||
"qsl_sent": bare.QSLSent, "qsl_rcvd": bare.QSLRcvd,
|
||||
"lotw_sent": bare.LOTWSent, "lotw_rcvd": bare.LOTWRcvd,
|
||||
"eqsl_sent": bare.EQSLSent, "eqsl_rcvd": bare.EQSLRcvd,
|
||||
"clublog": bare.ClublogUploadStatus, "hrdlog": bare.HRDLogUploadStatus,
|
||||
"qrz_sent": bare.QRZComUploadStatus, "qrz_rcvd": bare.QRZComDownloadStatus,
|
||||
} {
|
||||
if got != "N" {
|
||||
t.Errorf("%s = %q on an empty record, want the default \"N\"", name, got)
|
||||
}
|
||||
}
|
||||
|
||||
// A record that already carries confirmations — from a real logbook export.
|
||||
// Overwriting these would erase QSLs the operator has actually received.
|
||||
kept := &qso.QSO{
|
||||
Callsign: "F4XYZ",
|
||||
QSLRcvd: "Y", LOTWRcvd: "Y", EQSLSent: "Y",
|
||||
ClublogUploadStatus: "Y", QRZComDownloadStatus: "Y",
|
||||
}
|
||||
applyQSLDefaultsTo(kept, defaults)
|
||||
for name, got := range map[string]string{
|
||||
"qsl_rcvd": kept.QSLRcvd, "lotw_rcvd": kept.LOTWRcvd, "eqsl_sent": kept.EQSLSent,
|
||||
"clublog": kept.ClublogUploadStatus, "qrz_rcvd": kept.QRZComDownloadStatus,
|
||||
} {
|
||||
if got != "Y" {
|
||||
t.Errorf("%s = %q — an existing confirmation was overwritten", name, got)
|
||||
}
|
||||
}
|
||||
// …while its empty ones still get the default.
|
||||
if kept.QSLSent != "N" || kept.LOTWSent != "N" {
|
||||
t.Errorf("empty fields on a partly-filled record were not defaulted: qsl_sent=%q lotw_sent=%q",
|
||||
kept.QSLSent, kept.LOTWSent)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,21 @@ type Importer struct {
|
||||
// Used to recompute country / zones from cty.dat so a bad COUNTRY in the
|
||||
// source file (common with contest loggers) is corrected on the way in.
|
||||
Enrich func(*qso.QSO)
|
||||
// FieldMap moves ADIF fields around before a record is converted, keyed
|
||||
// lowercase source → lowercase destination.
|
||||
//
|
||||
// Contest software puts things where the operator, not the standard, expects
|
||||
// them. The RSGB IOTA contest is the case that prompted this: N1MM and
|
||||
// friends export the island reference in STATE, because that is the column
|
||||
// their contest module uses for the received exchange. Imported verbatim,
|
||||
// every QSO gets a US-state field holding "EU005" and the IOTA award sees
|
||||
// nothing at all.
|
||||
//
|
||||
// Applied to the RAW record, so it works for promoted columns and Extras
|
||||
// alike, and so the destination is parsed exactly as if the file had carried
|
||||
// it there in the first place. A destination that already has a value is
|
||||
// never overwritten — the file's own IOTA tag outranks a guess about STATE.
|
||||
FieldMap map[string]string
|
||||
// OnProgress, when set, is called periodically with (processed, total)
|
||||
// record counts so the UI can show a progress bar. total is an estimate
|
||||
// from counting <EOR> tags up front.
|
||||
@@ -161,6 +176,7 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
||||
err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error {
|
||||
res.Total++
|
||||
reportProgress(false)
|
||||
applyFieldMap(rec, im.FieldMap)
|
||||
q, ok := recordToQSO(rec)
|
||||
if !ok {
|
||||
res.Skipped++
|
||||
@@ -303,6 +319,49 @@ func stringSet(items ...string) map[string]struct{} {
|
||||
// RecordToQSO is the exported alias used by the UDP auto-log path so it
|
||||
// can convert a freshly received ADIF record into a QSO and then enrich
|
||||
// it with lookup + operating data before inserting.
|
||||
// applyFieldMap moves values between tags of a record, in place.
|
||||
//
|
||||
// The source is CLEARED after the move: a reference that belongs in IOTA should
|
||||
// not also be left behind in STATE, where it would show up as the contacted
|
||||
// station's US state in the grid and in every export.
|
||||
func applyFieldMap(rec Record, m map[string]string) {
|
||||
if len(m) == 0 || rec == nil {
|
||||
return
|
||||
}
|
||||
// Read every source first. Applied one at a time, a swap (a→b, b→a) would
|
||||
// read back what the previous move had just written.
|
||||
orig := make(map[string]string, len(rec))
|
||||
for k, v := range rec {
|
||||
orig[k] = v
|
||||
}
|
||||
vals := make(map[string]string, len(m))
|
||||
for from := range m {
|
||||
if v := strings.TrimSpace(rec[from]); v != "" {
|
||||
vals[from] = v
|
||||
}
|
||||
}
|
||||
for from := range m {
|
||||
delete(rec, from)
|
||||
}
|
||||
for from, to := range m {
|
||||
v, ok := vals[from]
|
||||
if !ok || from == to || to == "" {
|
||||
continue
|
||||
}
|
||||
// Whether the destination was already filled is judged against the record
|
||||
// as it ARRIVED, not as we are rewriting it — otherwise the outcome would
|
||||
// depend on Go's map iteration order, which is deliberately random.
|
||||
//
|
||||
// A destination that is itself a mapping source is an explicit swap, and
|
||||
// overwriting it is the whole point; anything else keeps what the file
|
||||
// carried, because the file's own tag outranks a guess about where a
|
||||
// reference might have been put.
|
||||
if _, swap := m[to]; swap || strings.TrimSpace(orig[to]) == "" {
|
||||
rec[to] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func RecordToQSO(rec Record) (qso.QSO, bool) { return recordToQSO(rec) }
|
||||
|
||||
// recordToQSO maps an ADIF record onto a QSO. Returns false if required
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package adif_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/adif"
|
||||
"hamlog/internal/db"
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// End to end: an operator's own record, through a real SQLite logbook and back.
|
||||
//
|
||||
// Written because CONTEST_ID was reported as "not imported". It is imported, and
|
||||
// this pins the whole path — parse, remap, insert, read back — rather than the
|
||||
// conversion step alone, which was already green while the report stood. A field
|
||||
// can be lost at five places between the file and the grid; only the round trip
|
||||
// says which.
|
||||
func TestContestIDSurvivesImport(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
conn, err := db.Open(filepath.Join(dir, "logbook.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
const file = "<CALL:6>2E0CVN <QSO_DATE:8>20260725 <TIME_ON:6>120039 <STATE:5>EU005 <BAND:3>20M <FREQ:6>14.014 <MODE:2>CW <CONTEST_ID:9>RSGB-IOTA <RST_RCVD:3>599 <RST_SENT:3>599 <SRX_STRING:8>001EU005 <EOR>\n"
|
||||
path := filepath.Join(dir, "in.adi")
|
||||
if err := os.WriteFile(path, []byte(file), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repo := qso.NewRepo(conn)
|
||||
im := &adif.Importer{Repo: repo, FieldMap: map[string]string{"state": "iota"}}
|
||||
res, err := im.ImportFile(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
t.Logf("result: %+v", res)
|
||||
|
||||
rows, err := conn.Query("SELECT callsign, contest_id, iota, state FROM qso")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
n := 0
|
||||
for rows.Next() {
|
||||
var call, contest, iota, state any
|
||||
if err := rows.Scan(&call, &contest, &iota, &state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n++
|
||||
t.Logf("stored: call=%v contest_id=%v iota=%v state=%v", call, contest, iota, state)
|
||||
if s := strings.TrimSpace(toS(contest)); s != "RSGB-IOTA" {
|
||||
t.Errorf("contest_id in the DATABASE = %q, want RSGB-IOTA", s)
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatal("nothing was stored")
|
||||
}
|
||||
}
|
||||
|
||||
func toS(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case []byte:
|
||||
return string(x)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package adif
|
||||
|
||||
import "testing"
|
||||
|
||||
// Field mapping, from the RSGB IOTA contest case.
|
||||
//
|
||||
// N1MM and similar export the island reference in STATE, because that is the
|
||||
// column their contest module uses for the received exchange. Imported
|
||||
// verbatim, every QSO gets a US-state field reading "EU005" and the IOTA award
|
||||
// sees nothing — the reference is in the log but not where anything looks.
|
||||
func TestApplyFieldMap(t *testing.T) {
|
||||
// The real record, from an operator's TM2Q log.
|
||||
rec := Record{
|
||||
"call": "2E0CVN", "band": "20m", "mode": "CW",
|
||||
"state": "EU005", "srx_string": "001EU005",
|
||||
}
|
||||
applyFieldMap(rec, map[string]string{"state": "iota"})
|
||||
if rec["iota"] != "EU005" {
|
||||
t.Errorf("iota = %q, want EU005", rec["iota"])
|
||||
}
|
||||
// Cleared, not copied: a reference left behind in STATE would show up as the
|
||||
// contacted station's US state in the grid and in every export.
|
||||
if _, ok := rec["state"]; ok {
|
||||
t.Errorf("state survived the move as %q", rec["state"])
|
||||
}
|
||||
|
||||
// A destination the file already filled is never overwritten — the file's own
|
||||
// tag outranks a guess about where the reference might be.
|
||||
rec2 := Record{"call": "UT0RM", "state": "EU005", "iota": "EU030"}
|
||||
applyFieldMap(rec2, map[string]string{"state": "iota"})
|
||||
if rec2["iota"] != "EU030" {
|
||||
t.Errorf("iota = %q — the file's own value must win", rec2["iota"])
|
||||
}
|
||||
|
||||
// An empty source moves nothing (the second QSO of that log has no STATE).
|
||||
rec3 := Record{"call": "UT0RM", "state": " "}
|
||||
applyFieldMap(rec3, map[string]string{"state": "iota"})
|
||||
if v, ok := rec3["iota"]; ok {
|
||||
t.Errorf("an empty source created iota=%q", v)
|
||||
}
|
||||
|
||||
// A swap reads both sources before writing either.
|
||||
rec4 := Record{"state": "EU005", "iota": "NA001"}
|
||||
applyFieldMap(rec4, map[string]string{"state": "iota", "iota": "state"})
|
||||
if rec4["iota"] != "EU005" || rec4["state"] != "NA001" {
|
||||
t.Errorf("swap gave iota=%q state=%q, want EU005 / NA001", rec4["iota"], rec4["state"])
|
||||
}
|
||||
|
||||
// No mapping, no change.
|
||||
rec5 := Record{"call": "F4BPO", "state": "EU005"}
|
||||
applyFieldMap(rec5, nil)
|
||||
if rec5["state"] != "EU005" {
|
||||
t.Error("a nil mapping must leave the record alone")
|
||||
}
|
||||
}
|
||||
+23
-7
@@ -22,9 +22,11 @@ import (
|
||||
// Record is a single ADIF record. Keys are lowercased field names.
|
||||
type Record map[string]string
|
||||
|
||||
// Parse reads an ADI stream and invokes fn for each record (after <EOH>).
|
||||
// Parse reads an ADI stream and invokes fn for each record.
|
||||
// Returning a non-nil error from fn stops parsing and is propagated.
|
||||
// The header (text before <EOH>) is silently discarded.
|
||||
//
|
||||
// The header (text before <EOH>) is silently discarded. A file with NO header is
|
||||
// read as records from the first tag — hand-assembled files often have none.
|
||||
func Parse(r io.Reader, fn func(Record) error) error {
|
||||
return parseWith(r, nil, fn)
|
||||
}
|
||||
@@ -44,7 +46,6 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
||||
br := bufio.NewReaderSize(r, 64*1024)
|
||||
|
||||
rec := Record{}
|
||||
headerDone := false
|
||||
|
||||
for {
|
||||
// Seek next '<'. Bytes before it are either header text or
|
||||
@@ -65,11 +66,19 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
||||
name, length := parseSpec(spec)
|
||||
switch name {
|
||||
case "eoh":
|
||||
headerDone = true
|
||||
rec = Record{}
|
||||
rec = Record{} // everything collected so far was the header
|
||||
continue
|
||||
case "eor":
|
||||
if headerDone && len(rec) > 0 {
|
||||
// An <eor> proves we are in the record section, whether or not an <eoh>
|
||||
// came first. Files pasted together by hand — a few lines lifted out of
|
||||
// wsjtx_log.adi into a new file — have no header at all, and requiring
|
||||
// one made every record invisible: the import reported 0 imported,
|
||||
// 0 ignored, 0 total, which reads as "the file is empty" rather than
|
||||
// "there was no header".
|
||||
//
|
||||
// A real header cannot contain <eor>, so accepting it costs nothing for
|
||||
// well-formed files: theirs ends at <eoh> long before any record.
|
||||
if len(rec) > 0 {
|
||||
if err := fn(rec); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -95,7 +104,14 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
||||
if decodeValue == nil && !utf8.Valid(val) {
|
||||
val = extendToRunes(br, val, length)
|
||||
}
|
||||
if headerDone && name != "" {
|
||||
// Fields are collected ALWAYS, and thrown away at <eoh>.
|
||||
//
|
||||
// Collecting them only after the header meant a headerless file lost its
|
||||
// FIRST record whole: its fields were discarded as header text, and the
|
||||
// <eor> that followed found nothing to emit. Discarding at <eoh> instead
|
||||
// keeps real headers out of the data just as well — that tag is exactly
|
||||
// what says "everything before this was the header".
|
||||
if name != "" {
|
||||
if decodeValue != nil {
|
||||
rec[name] = decodeValue(val)
|
||||
} else {
|
||||
|
||||
@@ -51,19 +51,28 @@ func TestParseValueWithAngleBracket(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseNoHeader(t *testing.T) {
|
||||
// Some loggers omit the header entirely — records before <EOH> are
|
||||
// discarded by design. Verify nothing is emitted in that case.
|
||||
// A file without a header is IMPORTED, not discarded.
|
||||
//
|
||||
// This test asserted the opposite until 2026-07-29, when an operator pasted a
|
||||
// dozen lines out of wsjtx_log.adi into a new .adi and got "0 imported,
|
||||
// 0 ignored, 0 total". Discarding records before <EOH> was a defensible
|
||||
// reading of the spec, but the file it rejects is one people really make, and
|
||||
// the rejection is silent: the count says the file was empty, not that a tag
|
||||
// was missing. An <eor> is proof enough that a record ended.
|
||||
src := `<CALL:5>F4XYZ<EOR>`
|
||||
var got int
|
||||
var got []Record
|
||||
err := Parse(strings.NewReader(src), func(r Record) error {
|
||||
got++
|
||||
got = append(got, r)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got != 0 {
|
||||
t.Errorf("expected 0 records without <EOH>, got %d", got)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected the record to be imported without <EOH>, got %d", len(got))
|
||||
}
|
||||
if got[0]["call"] != "F4XYZ" {
|
||||
t.Errorf("call = %q, want F4XYZ", got[0]["call"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,3 +91,57 @@ func TestParseTypedField(t *testing.T) {
|
||||
t.Errorf("freq mismatch: %q", got["freq"])
|
||||
}
|
||||
}
|
||||
|
||||
// A file with NO header must still import.
|
||||
//
|
||||
// Reported from the field: an operator lifted a dozen lines out of wsjtx_log.adi
|
||||
// into a new .adi and got "0 imported, 0 ignored, 0 total" — which reads as an
|
||||
// empty file rather than as a missing header. Records were only emitted after
|
||||
// <EOH>, so a hand-assembled file was invisible in its entirety.
|
||||
func TestParseHeaderlessFile(t *testing.T) {
|
||||
// The operator's exact shape: records straight from a WSJT-X log, no header.
|
||||
in := "<call:5>PY2VE <gridsquare:4>GG67 <mode:3>FT8 <rst_sent:3>-18 <rst_rcvd:3>-15 " +
|
||||
"<qso_date:8>20260728 <time_on:6>194200 <band:3>15m <eor>\n" +
|
||||
"<call:5>W8OBX <gridsquare:4>FM25 <mode:3>FT8 <rst_sent:3>-19 <rst_rcvd:3>-12 " +
|
||||
"<qso_date:8>20260728 <time_on:6>194500 <band:3>15m <eor>\n"
|
||||
|
||||
var got []Record
|
||||
if err := Parse(strings.NewReader(in), func(r Record) error {
|
||||
got = append(got, r)
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("parsed %d records from a headerless file, want 2", len(got))
|
||||
}
|
||||
if got[0]["call"] != "PY2VE" || got[1]["call"] != "W8OBX" {
|
||||
t.Errorf("callsigns = %q, %q — want PY2VE, W8OBX", got[0]["call"], got[1]["call"])
|
||||
}
|
||||
if got[0]["gridsquare"] != "GG67" {
|
||||
t.Errorf("gridsquare = %q, want GG67", got[0]["gridsquare"])
|
||||
}
|
||||
}
|
||||
|
||||
// And a normal file must be unaffected: its header is still discarded, so header
|
||||
// fields never leak into the first record.
|
||||
func TestParseHeaderStillDiscarded(t *testing.T) {
|
||||
in := "Generated by SomeLogger\n<adif_ver:5>3.1.4 <programid:6>OpsLog <eoh>\n" +
|
||||
"<call:4>F4AA <mode:2>CW <eor>\n"
|
||||
var got []Record
|
||||
if err := Parse(strings.NewReader(in), func(r Record) error {
|
||||
got = append(got, r)
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("parsed %d records, want 1", len(got))
|
||||
}
|
||||
if _, leaked := got[0]["programid"]; leaked {
|
||||
t.Error("a header field leaked into the record")
|
||||
}
|
||||
if got[0]["call"] != "F4AA" {
|
||||
t.Errorf("call = %q, want F4AA", got[0]["call"])
|
||||
}
|
||||
}
|
||||
|
||||
+17
-4
@@ -160,16 +160,29 @@ func FreqToBCD(hz int64) []byte {
|
||||
}
|
||||
|
||||
// BCDToFreq decodes Icom little-endian BCD frequency bytes back to Hz.
|
||||
func BCDToFreq(b []byte) int64 {
|
||||
//
|
||||
// It returns ok=false when a nibble is not a decimal digit. That check is the
|
||||
// point of the function: a CI-V byte stream that has lost frame sync — a reply
|
||||
// read at the wrong offset, a collision with the scope stream — still decodes
|
||||
// into a number if 0x0A..0x0F are quietly treated as 10..15. An operator on a
|
||||
// 2 m FM channel then saw entries like 16445550000 Hz appear in the status bar
|
||||
// between good readings (F4JND, 2026-07-30). Garbage that LOOKS like a
|
||||
// frequency is worse than a refused frame: it reaches the display, the band
|
||||
// slots, and eventually the log.
|
||||
func BCDToFreq(b []byte) (int64, bool) {
|
||||
var hz int64
|
||||
mult := int64(1)
|
||||
for i := 0; i < len(b) && i < 5; i++ {
|
||||
hz += int64(b[i]&0x0F) * mult
|
||||
lo, hi := b[i]&0x0F, b[i]>>4
|
||||
if lo > 9 || hi > 9 {
|
||||
return 0, false
|
||||
}
|
||||
hz += int64(lo) * mult
|
||||
mult *= 10
|
||||
hz += int64(b[i]>>4) * mult
|
||||
hz += int64(hi) * mult
|
||||
mult *= 10
|
||||
}
|
||||
return hz
|
||||
return hz, true
|
||||
}
|
||||
|
||||
// LevelToBCD encodes a 0-255 level as the 2 big-endian BCD bytes Icom's
|
||||
|
||||
@@ -12,8 +12,9 @@ func TestFreqBCDRoundTrip(t *testing.T) {
|
||||
if len(b) != 5 {
|
||||
t.Fatalf("FreqToBCD(%d) len=%d, want 5", hz, len(b))
|
||||
}
|
||||
if got := BCDToFreq(b); got != hz {
|
||||
t.Errorf("round trip %d → % X → %d", hz, b, got)
|
||||
got, ok := BCDToFreq(b)
|
||||
if !ok || got != hz {
|
||||
t.Errorf("round trip %d → % X → %d (ok=%v)", hz, b, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,8 +50,8 @@ func TestScanSingleFreqResponse(t *testing.T) {
|
||||
if f.From != 0x98 || f.To != AddrController || f.Cmd != CmdReadFreq {
|
||||
t.Errorf("addrs/cmd wrong: %+v", f)
|
||||
}
|
||||
if hz := BCDToFreq(f.Data); hz != 14250000 {
|
||||
t.Errorf("decoded freq %d, want 14250000", hz)
|
||||
if hz, ok := BCDToFreq(f.Data); !ok || hz != 14250000 {
|
||||
t.Errorf("decoded freq %d (ok=%v), want 14250000", hz, ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,3 +164,43 @@ func TestModelNameAddresses(t *testing.T) {
|
||||
t.Errorf("ModelName(0x42) = %q, want the hex fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A frame that is not decimal BCD is refused rather than decoded.
|
||||
//
|
||||
// From an operator's log (F4JND, 2026-07-30): a rig sitting on 145.550 MHz FM
|
||||
// produced status lines reading 16445550000, 8364550000, 5817408364 and 12640 Hz
|
||||
// between good readings. CI-V had lost frame sync — a reply read at the wrong
|
||||
// offset, alongside PTT timeouts and a scope stream the rig was rejecting — and
|
||||
// the decoder treated the nibbles 0x0A..0x0F as the digits 10..15, turning noise
|
||||
// into a plausible-looking number.
|
||||
//
|
||||
// Garbage that looks like a frequency is worse than a refused frame: it reaches
|
||||
// the status bar, the band slots and the log, and it is what an operator then
|
||||
// has to disprove.
|
||||
func TestBCDToFreqRejectsNonDecimal(t *testing.T) {
|
||||
// Valid: 145.550 MHz, encoded by the same package rather than by hand — my
|
||||
// hand-written fixture was byte-reversed and this caught it.
|
||||
good := FreqToBCD(145550000)
|
||||
if hz, ok := BCDToFreq(good); !ok || hz != 145550000 {
|
||||
t.Errorf("valid BCD % X decoded as %d (ok=%v), want 145550000", good, hz, ok)
|
||||
}
|
||||
|
||||
// Every nibble value above 9 must be refused, in either half of the byte.
|
||||
bad := [][]byte{
|
||||
{0x0A, 0x00, 0x55, 0x45, 0x01}, // low nibble
|
||||
{0x00, 0x00, 0xF5, 0x45, 0x01}, // high nibble
|
||||
{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, // an idle line read as data
|
||||
{0x00, 0x00, 0x00, 0x0B, 0x00},
|
||||
}
|
||||
for _, b := range bad {
|
||||
if hz, ok := BCDToFreq(b); ok {
|
||||
t.Errorf("% X was accepted as %d Hz — it is not decimal BCD", b, hz)
|
||||
}
|
||||
}
|
||||
|
||||
// A short frame still decodes what it holds: the caller decides whether a
|
||||
// partial read is usable, and refusing it here would break the 4-byte forms.
|
||||
if _, ok := BCDToFreq([]byte{0x00, 0x50}); !ok {
|
||||
t.Error("a short but valid BCD frame must still decode")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,7 +557,9 @@ func (b *IcomSerial) write(payload ...byte) error {
|
||||
// recv() only sees the reply to THIS request (avoids a previous command's ack
|
||||
// or an unsolicited dial-turn update being mistaken for our response).
|
||||
b.drainResp()
|
||||
_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, payload...))
|
||||
frame := civ.Frame(b.rigAddr, civ.AddrController, payload...)
|
||||
traceCIV("TX", frame)
|
||||
_, err := b.port.Write(frame)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -603,6 +605,9 @@ func (b *IcomSerial) reader(port civTransport, done chan struct{}) {
|
||||
continue // read timeout with no data
|
||||
}
|
||||
rx = append(rx, tmp[:n]...)
|
||||
// Traced BEFORE framing: the bytes as they arrived are what shows a lost
|
||||
// frame boundary, which a decoded view would hide.
|
||||
traceCIV("RX", tmp[:n])
|
||||
frames, consumed := civ.Scan(rx)
|
||||
if consumed > 0 {
|
||||
rx = append(rx[:0], rx[consumed:]...)
|
||||
@@ -750,8 +755,13 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
|
||||
// (e.g. 14.200 MHz + 100 kHz → centred 14.150-14.250; 21.000 +
|
||||
// 21.070 → fixed 21.000-21.070). Guessing wrong here gave the absurd
|
||||
// 21.000-42.070 span (low + a 21 MHz "span").
|
||||
v1 := civ.BCDToFreq(region[1:6])
|
||||
v2 := civ.BCDToFreq(region[6:11])
|
||||
v1, ok1 := civ.BCDToFreq(region[1:6])
|
||||
v2, ok2 := civ.BCDToFreq(region[6:11])
|
||||
if !ok1 || !ok2 {
|
||||
// Not decimal BCD — the frame was read at the wrong offset. Keep the
|
||||
// edges we had; a wrong span is drawn, noticed, and mistrusted.
|
||||
break
|
||||
}
|
||||
var low, high int64
|
||||
if v2 > v1 {
|
||||
low, high = v1, v2 // absolute low/high edges (fixed edge set)
|
||||
@@ -788,8 +798,13 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
|
||||
// high = span (e.g. 100 kHz), so high < low and the panadapter had
|
||||
// no valid range to map the trace onto → blank scope. FIXED mode
|
||||
// parsed fine, which is why only center mode was broken.
|
||||
v1 := civ.BCDToFreq(region[1:6])
|
||||
v2 := civ.BCDToFreq(region[6:11])
|
||||
v1, ok1 := civ.BCDToFreq(region[1:6])
|
||||
v2, ok2 := civ.BCDToFreq(region[6:11])
|
||||
if !ok1 || !ok2 {
|
||||
// Not decimal BCD — the frame was read at the wrong offset. Keep the
|
||||
// edges we had; a wrong span is drawn, noticed, and mistrusted.
|
||||
break
|
||||
}
|
||||
var low, high int64
|
||||
if v2 > v1 {
|
||||
low, high = v1, v2 // absolute low/high edges (fixed)
|
||||
@@ -1142,7 +1157,14 @@ func (b *IcomSerial) readFreq() (int64, error) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return civ.BCDToFreq(f.Data), nil
|
||||
hz, ok := civ.BCDToFreq(f.Data)
|
||||
if !ok {
|
||||
// A frame that is not decimal BCD is a desynchronised read, not a
|
||||
// frequency. Reporting the error keeps the caller on its last good value
|
||||
// instead of publishing a number like 16445550000 Hz to the display.
|
||||
return 0, fmt.Errorf("icom: frequency frame is not BCD: % X", f.Data)
|
||||
}
|
||||
return hz, nil
|
||||
}
|
||||
|
||||
// readSplit reads the rig's split state (CI-V 0x0F). 0x01 = split on; 0x10/0x11
|
||||
@@ -1172,7 +1194,11 @@ func (b *IcomSerial) readTXFreq() (int64, bool) {
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return civ.BCDToFreq(f.Data[1:]), true
|
||||
hz, ok := civ.BCDToFreq(f.Data[1:])
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return hz, true
|
||||
}
|
||||
|
||||
// readTX reads the transmit state (CI-V 0x1C 0x00): non-zero data = keyed.
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
package cat
|
||||
|
||||
// Native Kenwood CAT — TS-590, TS-890, TS-2000 and the many rigs that speak the
|
||||
// same dialect (Elecraft K3/K4, and the "Kenwood/Elecraft" setting on Flex and
|
||||
// SunSDR). Plain ASCII, every command terminated by ';', same shape as Yaesu but
|
||||
// a different vocabulary.
|
||||
//
|
||||
// The dialect is already proven in this repository from the other side:
|
||||
// internal/catemu ANSWERS these commands, pretending to be a TS-2000 so an ACOM
|
||||
// amplifier follows OpsLog. The frame layouts here and there are the same ones.
|
||||
//
|
||||
// Commands used:
|
||||
//
|
||||
// FA; → FA00014025000; VFO A frequency, ELEVEN digits, Hz
|
||||
// FB; → FB00014030000; VFO B frequency
|
||||
// IF; → 38-char status frame: frequency, RX/TX, mode, VFO, split —
|
||||
// the whole operating state in ONE round trip, which is why it
|
||||
// is the poll rather than asking four separate questions.
|
||||
// MD; → MD3; mode (1=LSB 2=USB 3=CW 4=FM 5=AM 6=FSK
|
||||
// 7=CW-R 9=FSK-R)
|
||||
// FR0;/FR1; receive VFO — 0 = A, 1 = B
|
||||
// FT0;/FT1; transmit VFO (split = the two differ)
|
||||
// TX;/RX; key / unkey
|
||||
// ID; → ID020; model number
|
||||
// AI0; silence unsolicited status reports
|
||||
//
|
||||
// Why not OmniRig: the same reason the Yaesu backend exists. Every Kenwood
|
||||
// fault reported through OmniRig came from its interpretation layer rather than
|
||||
// from the radio, and the rig file decides what a "Freq" property means.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
// Kenwood is the native backend. One serial port, one mutex: a command and its
|
||||
// reply are never interleaved with another exchange.
|
||||
type Kenwood struct {
|
||||
portName string
|
||||
baud int
|
||||
digital string // mode name logged for data (FT8 by default)
|
||||
|
||||
mu sync.Mutex
|
||||
port serial.Port
|
||||
|
||||
model string
|
||||
curFreq int64
|
||||
curRXFreq int64
|
||||
curVFO string // "A" or "B"
|
||||
// Commands this rig answered "?;" to — asked once, then never again.
|
||||
unsupported map[string]bool
|
||||
}
|
||||
|
||||
func NewKenwood(portName string, baud int, digital string) *Kenwood {
|
||||
if baud <= 0 {
|
||||
baud = 9600 // TS-590 factory default; TS-890 ships at 115200
|
||||
}
|
||||
if strings.TrimSpace(digital) == "" {
|
||||
digital = "FT8"
|
||||
}
|
||||
return &Kenwood{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
// Close any handle still held before opening another: Connect runs again on
|
||||
// every reconnect, and Windows opens a serial port exclusively, so a leaked
|
||||
// handle makes the next open fail with "port busy" (the fault found in the
|
||||
// Yaesu backend — same shape here).
|
||||
if k.port != nil {
|
||||
_ = k.port.Close()
|
||||
k.port = nil
|
||||
}
|
||||
p, err := serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
|
||||
if err != nil {
|
||||
return fmt.Errorf("kenwood: open %s @ %d baud: %w", k.portName, k.baud, err)
|
||||
}
|
||||
p.SetReadTimeout(300 * time.Millisecond)
|
||||
k.port = p
|
||||
k.unsupported = map[string]bool{}
|
||||
|
||||
// Silence unsolicited status reports: they interleave with our
|
||||
// request/response pairs and make a reply impossible to attribute. We poll.
|
||||
_ = k.write("AI0;")
|
||||
|
||||
answered := false
|
||||
if id, err := k.ask("ID;"); err == nil && strings.HasPrefix(id, "ID") {
|
||||
answered = true
|
||||
code := strings.TrimSuffix(strings.TrimPrefix(id, "ID"), ";")
|
||||
if name, ok := kenwoodModels[code]; ok {
|
||||
k.model = name
|
||||
} else {
|
||||
k.model = "Kenwood (" + code + ")"
|
||||
debugLog.Printf("kenwood: unknown model id %q — add it to kenwoodModels", code)
|
||||
}
|
||||
}
|
||||
// IF is the command everything else depends on, so it is also the honest
|
||||
// test of whether a radio is really there.
|
||||
if r, err := k.ask("IF;"); err == nil && strings.HasPrefix(r, "IF") {
|
||||
answered = true
|
||||
}
|
||||
if !answered {
|
||||
k.model = ""
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *Kenwood) Disconnect() {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.port != nil {
|
||||
_ = k.port.Close()
|
||||
k.port = nil
|
||||
}
|
||||
}
|
||||
|
||||
// ReadState polls the rig. IF carries frequency, mode, VFO, split and TX state
|
||||
// in one frame; the other VFO is only asked for when split is actually on, so
|
||||
// the common simplex case costs a single round trip.
|
||||
func (k *Kenwood) ReadState() (RigState, error) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.port == nil {
|
||||
return RigState{}, fmt.Errorf("kenwood: not connected")
|
||||
}
|
||||
raw, err := k.ask("IF;")
|
||||
if err != nil {
|
||||
return RigState{}, err
|
||||
}
|
||||
f, ok := parseKenwoodIF(raw)
|
||||
if !ok {
|
||||
return RigState{}, fmt.Errorf("kenwood: unparsable IF frame %q", raw)
|
||||
}
|
||||
|
||||
s := RigState{Connected: true, Backend: "kenwood"}
|
||||
k.curVFO = f.VFO
|
||||
s.Vfo = f.VFO
|
||||
s.Mode = kenwoodModeToADIF(f.Mode, k.digital)
|
||||
s.Rig = k.model
|
||||
|
||||
// IF reports the frequency of the VFO in USE (what the operator hears).
|
||||
rx := f.FreqHz
|
||||
tx := rx
|
||||
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
|
||||
// into the log, which is worse than showing no split at all.
|
||||
other := "FB;"
|
||||
if f.VFO == "B" {
|
||||
other = "FA;"
|
||||
}
|
||||
if r, err := k.ask(other); err == nil {
|
||||
if hz, ok := parseKenwoodFreq(r, strings.TrimSuffix(other, ";")); ok && hz > 0 && hz != rx {
|
||||
tx = hz
|
||||
}
|
||||
}
|
||||
}
|
||||
if tx != rx {
|
||||
s.FreqHz = tx // ADIF: FREQ is the TRANSMIT frequency
|
||||
s.RxFreqHz = rx
|
||||
s.Split = true
|
||||
} else {
|
||||
s.FreqHz = rx
|
||||
}
|
||||
k.curFreq = s.FreqHz
|
||||
if s.Split {
|
||||
k.curRXFreq = s.RxFreqHz
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// SetFrequency tunes the VFO the operator is actually on — writing FA blind is
|
||||
// what makes a display disagree with the radio when they are on B.
|
||||
func (k *Kenwood) SetFrequency(hz int64) error {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.port == nil {
|
||||
return fmt.Errorf("kenwood: not connected")
|
||||
}
|
||||
if hz <= 0 || hz > 99_999_999_999 {
|
||||
return fmt.Errorf("kenwood: frequency %d out of the 11-digit CAT range", hz)
|
||||
}
|
||||
cmd := "FA"
|
||||
if k.curVFO == "B" {
|
||||
cmd = "FB"
|
||||
}
|
||||
return k.write(fmt.Sprintf("%s%011d;", cmd, hz))
|
||||
}
|
||||
|
||||
func (k *Kenwood) SetMode(mode string) error {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.port == nil {
|
||||
return fmt.Errorf("kenwood: not connected")
|
||||
}
|
||||
d := kenwoodModeDigit(mode, k.curFreq)
|
||||
if d == 0 {
|
||||
return fmt.Errorf("kenwood: no CAT mode for %q", mode)
|
||||
}
|
||||
return k.write(fmt.Sprintf("MD%c;", d))
|
||||
}
|
||||
|
||||
func (k *Kenwood) SetPTT(on bool) error {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.port == nil {
|
||||
return fmt.Errorf("kenwood: not connected")
|
||||
}
|
||||
if on {
|
||||
return k.write("TX;")
|
||||
}
|
||||
return k.write("RX;")
|
||||
}
|
||||
|
||||
func (k *Kenwood) write(cmd string) error {
|
||||
if k.port == nil {
|
||||
return fmt.Errorf("kenwood: not connected")
|
||||
}
|
||||
_, err := k.port.Write([]byte(cmd))
|
||||
return err
|
||||
}
|
||||
|
||||
// ask sends a query and returns the reply belonging to THAT command. Anything
|
||||
// else on the wire is discarded: a stray frame parsed as a frequency reads as
|
||||
// "lost the rig" to the Manager, which then reconnects — the CAT link dropping
|
||||
// for no reason (found the hard way on the Yaesu backend).
|
||||
func (k *Kenwood) ask(cmd string) (string, error) {
|
||||
want := cmdPrefix(cmd)
|
||||
if k.unsupported[want] {
|
||||
return "", fmt.Errorf("kenwood: %s is not supported by this rig", want)
|
||||
}
|
||||
if err := k.write(cmd); err != nil {
|
||||
return "", err
|
||||
}
|
||||
buf := make([]byte, 0, 64)
|
||||
tmp := make([]byte, 64)
|
||||
deadline := time.Now().Add(600 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
n, err := k.port.Read(tmp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n == 0 {
|
||||
continue // read timeout — the rig may still be composing its answer
|
||||
}
|
||||
buf = append(buf, tmp[:n]...)
|
||||
for {
|
||||
i := strings.IndexByte(string(buf), ';')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
frame := string(buf[:i+1])
|
||||
buf = buf[i+1:]
|
||||
if frame == "?;" {
|
||||
// The rig rejected the command. Remember it so the poll loop stops
|
||||
// paying a 600 ms timeout for it on every cycle.
|
||||
k.unsupported[want] = true
|
||||
debugLog.Printf("kenwood: this rig does not support %q — not asking again", cmd)
|
||||
return "", fmt.Errorf("kenwood: %s rejected", want)
|
||||
}
|
||||
if strings.HasPrefix(frame, want) {
|
||||
return frame, nil
|
||||
}
|
||||
debugLog.Printf("kenwood: discarding %q while waiting for %s", frame, want)
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
|
||||
}
|
||||
|
||||
// ── Frame parsing ──────────────────────────────────────────────────────────
|
||||
|
||||
// kenwoodIF is what the 38-character IF status frame carries.
|
||||
type kenwoodIF struct {
|
||||
FreqHz int64
|
||||
Mode byte
|
||||
VFO string // "A" or "B"
|
||||
Split bool
|
||||
// TX is parsed even though RigState has no PTT field: it is one character of
|
||||
// the same frame, and having it here means a future "transmitting" indicator
|
||||
// costs no extra round trip.
|
||||
TX bool
|
||||
}
|
||||
|
||||
// parseKenwoodIF reads the TS-2000/TS-590 status frame. Its layout — the same
|
||||
// one internal/catemu emits — is:
|
||||
//
|
||||
// IF | freq(11) | step(4) | RIT(±5) | RIT/XIT/bank(3) | mem(2) | rx-tx(1) |
|
||||
// mode(1) | VFO(1) | scan(1) | split(1) | tone(1) | tone#(2) | shift(1) | ;
|
||||
//
|
||||
// Fields are read by POSITION, so the length is checked first: a short frame
|
||||
// means a truncated read, and indexing into it would panic or, worse, silently
|
||||
// yield a wrong frequency.
|
||||
func parseKenwoodIF(reply string) (kenwoodIF, bool) {
|
||||
r := strings.TrimSpace(reply)
|
||||
if !strings.HasPrefix(r, "IF") || len(r) < 38 {
|
||||
return kenwoodIF{}, false
|
||||
}
|
||||
hz, err := strconv.ParseInt(strings.TrimSpace(r[2:13]), 10, 64)
|
||||
if err != nil || hz <= 0 {
|
||||
return kenwoodIF{}, false
|
||||
}
|
||||
out := kenwoodIF{FreqHz: hz, Mode: r[29], VFO: "A", TX: r[28] == '1', Split: r[32] == '1'}
|
||||
if r[30] == '1' {
|
||||
out.VFO = "B"
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// parseKenwoodFreq reads an FA/FB reply — ELEVEN digits on Kenwood, where Yaesu
|
||||
// uses nine. The prefix is checked so an FB reply is never accepted as FA.
|
||||
func parseKenwoodFreq(reply, prefix string) (int64, bool) {
|
||||
r := strings.TrimSpace(reply)
|
||||
if !strings.HasPrefix(r, prefix) {
|
||||
return 0, false
|
||||
}
|
||||
digits := strings.TrimSuffix(strings.TrimPrefix(r, prefix), ";")
|
||||
if digits == "" {
|
||||
return 0, false
|
||||
}
|
||||
hz, err := strconv.ParseInt(digits, 10, 64)
|
||||
if err != nil || hz <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return hz, true
|
||||
}
|
||||
|
||||
// ── Modes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// kenwoodModeDigit maps an ADIF mode to the Kenwood digit. The sideband follows
|
||||
// the frequency by worldwide convention — a backend that puts USB on 40 m makes
|
||||
// every SSB QSO in the log wrong.
|
||||
func kenwoodModeDigit(mode string, hz int64) byte {
|
||||
m := strings.ToUpper(strings.TrimSpace(mode))
|
||||
switch m {
|
||||
case "":
|
||||
return 0
|
||||
case "LSB":
|
||||
return '1'
|
||||
case "USB":
|
||||
return '2'
|
||||
case "CW":
|
||||
return '3'
|
||||
case "CW-R", "CWR":
|
||||
return '7'
|
||||
case "FM":
|
||||
return '4'
|
||||
case "AM":
|
||||
return '5'
|
||||
case "RTTY", "FSK":
|
||||
return '6'
|
||||
case "RTTY-R", "FSK-R":
|
||||
return '9'
|
||||
case "SSB":
|
||||
if hz > 0 && hz < 10_000_000 {
|
||||
return '1'
|
||||
}
|
||||
return '2'
|
||||
}
|
||||
// Any other digital mode rides on the data sideband, which on Kenwood is
|
||||
// plain USB/LSB with the rig's DATA input selected.
|
||||
if hz > 0 && hz < 10_000_000 {
|
||||
return '1'
|
||||
}
|
||||
return '2'
|
||||
}
|
||||
|
||||
// kenwoodModeToADIF turns the rig's mode digit into what the log records.
|
||||
// Digital modes are indistinguishable from SSB over CAT — the rig only knows
|
||||
// it is on USB — so the operator's configured digital mode is used, exactly as
|
||||
// the other backends do.
|
||||
func kenwoodModeToADIF(d byte, digital string) string {
|
||||
switch d {
|
||||
case '1':
|
||||
return "LSB"
|
||||
case '2':
|
||||
return "USB"
|
||||
case '3', '7':
|
||||
return "CW"
|
||||
case '4':
|
||||
return "FM"
|
||||
case '5':
|
||||
return "AM"
|
||||
case '6', '9':
|
||||
return "RTTY"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// kenwoodModels maps the ID reply to a name for the status bar. An unknown code
|
||||
// is shown as-is rather than refused: the model name is cosmetic, and a rig that
|
||||
// answers everything else must not be rejected over it.
|
||||
var kenwoodModels = map[string]string{
|
||||
"017": "TS-570",
|
||||
"019": "TS-2000",
|
||||
"020": "TS-480",
|
||||
"021": "TS-590S",
|
||||
"023": "TS-590SG",
|
||||
"024": "TS-990S",
|
||||
"025": "TS-890S",
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package cat
|
||||
|
||||
import "testing"
|
||||
|
||||
// The IF status frame, read by POSITION.
|
||||
//
|
||||
// One frame carries frequency, TX state, mode, VFO and split, which is why it is
|
||||
// the poll. Every field is a fixed offset, so a length check comes first: a
|
||||
// truncated read would otherwise index into the wrong characters and yield a
|
||||
// plausible-looking wrong frequency — the worst kind, because it reaches the log.
|
||||
//
|
||||
// The layout is the one internal/catemu already EMITS to satisfy an ACOM
|
||||
// amplifier, so these two halves of the repository agree by construction.
|
||||
func TestParseKenwoodIF(t *testing.T) {
|
||||
// IF | freq(11) | step(4) | RIT(±5) | 3 | mem(2) | rx/tx | mode | VFO | scan |
|
||||
// split | tone | tone#(2) | shift | ; → 38 characters
|
||||
const rx20mUSB = "IF00014250000" + "0000" + "+00000" + "000" + "00" + "0" + "2" + "0" + "0" + "0" + "0" + "00" + "0" + ";"
|
||||
if len(rx20mUSB) != 38 {
|
||||
t.Fatalf("the test's own frame is %d chars, not 38 — fix the fixture first", len(rx20mUSB))
|
||||
}
|
||||
|
||||
f, ok := parseKenwoodIF(rx20mUSB)
|
||||
if !ok {
|
||||
t.Fatalf("a valid frame was rejected: %q", rx20mUSB)
|
||||
}
|
||||
if f.FreqHz != 14250000 {
|
||||
t.Errorf("frequency = %d, want 14250000", f.FreqHz)
|
||||
}
|
||||
if f.Mode != '2' {
|
||||
t.Errorf("mode = %q, want '2' (USB)", f.Mode)
|
||||
}
|
||||
if f.VFO != "A" || f.Split || f.TX {
|
||||
t.Errorf("got VFO=%s split=%v tx=%v — want A, simplex, receiving", f.VFO, f.Split, f.TX)
|
||||
}
|
||||
|
||||
// Same frame transmitting, on VFO B, split, in CW.
|
||||
const txSplitB = "IF00007030000" + "0000" + "+00000" + "000" + "00" + "1" + "3" + "1" + "0" + "1" + "0" + "00" + "0" + ";"
|
||||
f2, ok := parseKenwoodIF(txSplitB)
|
||||
if !ok {
|
||||
t.Fatalf("valid frame rejected: %q", txSplitB)
|
||||
}
|
||||
if !f2.TX || f2.Mode != '3' || f2.VFO != "B" || !f2.Split {
|
||||
t.Errorf("got tx=%v mode=%q vfo=%s split=%v — want transmitting, CW, B, split",
|
||||
f2.TX, f2.Mode, f2.VFO, f2.Split)
|
||||
}
|
||||
|
||||
// Rejections: anything that would make position-reading unsafe.
|
||||
for _, bad := range []string{
|
||||
"",
|
||||
"IF;", // query echoed with no payload
|
||||
"IF0001425000;", // short — the trap this guards against
|
||||
"FA00014250000;", // another command's reply
|
||||
"IF0001425000x0000+00000000000203000000;", // non-numeric frequency
|
||||
} {
|
||||
if _, ok := parseKenwoodIF(bad); ok {
|
||||
t.Errorf("accepted a frame it should have refused: %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FA/FB carry ELEVEN digits on Kenwood where Yaesu uses nine — the single most
|
||||
// likely place to copy the Yaesu backend and be wrong by a factor of a hundred.
|
||||
func TestParseKenwoodFreq(t *testing.T) {
|
||||
cases := []struct {
|
||||
reply, prefix string
|
||||
want int64
|
||||
ok bool
|
||||
}{
|
||||
{"FA00014250000;", "FA", 14250000, true},
|
||||
{"FB00007030000;", "FB", 7030000, true},
|
||||
{"FA00000474000;", "FA", 474000, true}, // 630 m — leading zeros must not truncate
|
||||
{"FA10368000000;", "FA", 10368000000, true}, // 3 cm
|
||||
{"FA00014250000;", "FB", 0, false}, // wrong VFO is never silently accepted
|
||||
{"FA;", "FA", 0, false},
|
||||
{"FAxxxxxxxxxxx;", "FA", 0, false},
|
||||
{"", "FA", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := parseKenwoodFreq(c.reply, c.prefix)
|
||||
if got != c.want || ok != c.ok {
|
||||
t.Errorf("parseKenwoodFreq(%q,%q) = %d,%v — want %d,%v", c.reply, c.prefix, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The sideband follows the frequency by worldwide convention. A backend that
|
||||
// puts USB on 40 m makes every SSB QSO in the log wrong, which is why this is
|
||||
// pinned rather than left to the rig.
|
||||
func TestKenwoodModeDigit(t *testing.T) {
|
||||
cases := []struct {
|
||||
mode string
|
||||
hz int64
|
||||
want byte
|
||||
}{
|
||||
{"SSB", 7150000, '1'}, // LSB below 10 MHz
|
||||
{"SSB", 14250000, '2'}, // USB above
|
||||
{"LSB", 14250000, '1'}, // an explicit choice wins over the convention
|
||||
{"USB", 7150000, '2'},
|
||||
{"CW", 7030000, '3'},
|
||||
{"RTTY", 14080000, '6'},
|
||||
{"AM", 7150000, '5'},
|
||||
{"FM", 145000000, '4'},
|
||||
{"FT8", 7074000, '1'}, // data rides on the sideband for the band
|
||||
{"FT8", 14074000, '2'},
|
||||
{"", 14074000, 0}, // nothing to set
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := kenwoodModeDigit(c.mode, c.hz); got != c.want {
|
||||
t.Errorf("kenwoodModeDigit(%q, %d) = %q, want %q", c.mode, c.hz, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What the log records for each mode digit the rig reports.
|
||||
func TestKenwoodModeToADIF(t *testing.T) {
|
||||
cases := []struct {
|
||||
d byte
|
||||
want string
|
||||
}{
|
||||
{'1', "LSB"}, {'2', "USB"},
|
||||
{'3', "CW"}, {'7', "CW"}, // CW-R is still CW in the log
|
||||
{'4', "FM"}, {'5', "AM"},
|
||||
{'6', "RTTY"}, {'9', "RTTY"}, // FSK-R likewise
|
||||
{'0', ""}, // unknown → say nothing rather than guess
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := kenwoodModeToADIF(c.d, "FT8"); got != c.want {
|
||||
t.Errorf("kenwoodModeToADIF(%q) = %q, want %q", c.d, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// LogSink, when set by the host app at startup, receives every CAT debug
|
||||
@@ -73,3 +74,38 @@ func DebugLogPath() string {
|
||||
}
|
||||
return filepath.Join(base, "OpsLog", "cat.log")
|
||||
}
|
||||
|
||||
// ── CI-V byte 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.
|
||||
//
|
||||
// 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
|
||||
|
||||
// SetCIVTrace turns the CI-V byte trace on or off.
|
||||
func SetCIVTrace(on bool) {
|
||||
civTrace.Store(on)
|
||||
if on {
|
||||
debugLog.Printf("civ trace ON — every CI-V frame to and from the rig is logged")
|
||||
} else {
|
||||
debugLog.Printf("civ trace OFF")
|
||||
}
|
||||
}
|
||||
|
||||
// CIVTraceEnabled reports whether the trace is running (for the settings UI).
|
||||
func CIVTraceEnabled() bool { return civTrace.Load() }
|
||||
|
||||
// traceCIV logs one frame. dir is "TX" (to the rig) or "RX" (from it).
|
||||
func traceCIV(dir string, b []byte) {
|
||||
if !civTrace.Load() || len(b) == 0 {
|
||||
return
|
||||
}
|
||||
debugLog.Printf("civ %s % X", dir, b)
|
||||
}
|
||||
|
||||
+26
-8
@@ -493,12 +493,24 @@ func (o *OmniRig) SetFrequency(hz int64) error {
|
||||
// method (RX=TX=freq, simplex). It works on rigs — notably Icom (IC-9100) —
|
||||
// where direct FreqA/FreqB writes are accepted but never move the radio.
|
||||
// Clearing split is the right thing when tuning to a spot anyway.
|
||||
// …but ONLY when the operator is on the main VFO. SetSimplexMode means
|
||||
// "receive and transmit here, simplex", and OmniRig implements that on the
|
||||
// MAIN VFO — so on an FTDX101 listening on SUB, clicking a cluster spot
|
||||
// dragged the radio back to MAIN (F4NBZ, 2026-07-29). The operator picked SUB
|
||||
// deliberately; a spot click is a request to change frequency, not to change
|
||||
// VFO. On SUB the direct FreqB write below does the whole job.
|
||||
onSubVFO := vfo == "B" || vfo == "BB" || vfo == "BA"
|
||||
simplexOK := false
|
||||
if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil {
|
||||
simplexOK = true
|
||||
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode(%d) OK", hz32)
|
||||
} else {
|
||||
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v)", err)
|
||||
switch {
|
||||
case onSubVFO:
|
||||
debugLog.Printf("OmniRig.SetFrequency: on VFO %q — skipping SetSimplexMode so the rig stays on SUB", vfo)
|
||||
default:
|
||||
if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil {
|
||||
simplexOK = true
|
||||
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode(%d) OK", hz32)
|
||||
} else {
|
||||
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// On Yaesu / Kenwood (and anything non-Icom), SetSimplexMode frequently returns
|
||||
@@ -511,12 +523,18 @@ func (o *OmniRig) SetFrequency(hz int64) error {
|
||||
isIcom := strings.HasPrefix(strings.ToUpper(strings.TrimSpace(rigType)), "IC")
|
||||
if !isIcom || !simplexOK {
|
||||
prop := "FreqA"
|
||||
switch vfo {
|
||||
case "B", "BB", "BA":
|
||||
if onSubVFO {
|
||||
prop = "FreqB"
|
||||
}
|
||||
okAny := false
|
||||
for _, p := range []string{prop, "Freq"} {
|
||||
// The generic "Freq" property is written too — but NOT on SUB, where it is
|
||||
// the main VFO's frequency on several rig files and would move the VFO the
|
||||
// operator is not using, or pull them back to it.
|
||||
props := []string{prop, "Freq"}
|
||||
if onSubVFO {
|
||||
props = []string{prop}
|
||||
}
|
||||
for _, p := range props {
|
||||
if _, e := oleutil.PutProperty(o.rig, p, hz32); e != nil {
|
||||
debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s) error: %v", p, e)
|
||||
} else {
|
||||
|
||||
@@ -83,3 +83,45 @@ func TestResolveOmniRigVFOs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Which VFO a frequency set writes to, and whether it may call SetSimplexMode.
|
||||
//
|
||||
// Reported on an FTDX101 (F4NBZ, 2026-07-29): listening on SUB, clicking a
|
||||
// cluster spot dragged the radio back to MAIN. SetSimplexMode means "receive and
|
||||
// transmit here, simplex" and OmniRig applies it to the MAIN VFO — so it must
|
||||
// not be used when the operator is on SUB. They chose that VFO deliberately; a
|
||||
// spot click asks for a frequency, not for a VFO change.
|
||||
func TestOmniRigWriteTarget(t *testing.T) {
|
||||
// Mirrors the decision made in SetFrequency.
|
||||
target := func(vfo string) (prop string, simplexAllowed bool) {
|
||||
onSub := vfo == "B" || vfo == "BB" || vfo == "BA"
|
||||
if onSub {
|
||||
return "FreqB", false
|
||||
}
|
||||
return "FreqA", true
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
vfo string
|
||||
prop string
|
||||
simplex bool
|
||||
}{
|
||||
// On the main VFO nothing changes: SetSimplexMode is what moves the Icoms
|
||||
// whose direct writes are ignored.
|
||||
{"", "FreqA", true},
|
||||
{"A", "FreqA", true},
|
||||
{"AA", "FreqA", true},
|
||||
{"AB", "FreqA", true},
|
||||
// On SUB, write FreqB and leave the VFO selection alone.
|
||||
{"B", "FreqB", false},
|
||||
{"BB", "FreqB", false},
|
||||
{"BA", "FreqB", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
prop, simplex := target(c.vfo)
|
||||
if prop != c.prop || simplex != c.simplex {
|
||||
t.Errorf("VFO %q → write %s, simplex=%v; want %s, simplex=%v",
|
||||
c.vfo, prop, simplex, c.prop, c.simplex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,8 +247,8 @@ func (x *Xiegu) readFreq() (int64, error) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
hz := civ.BCDToFreq(d.Data)
|
||||
if hz <= 0 {
|
||||
hz, ok := civ.BCDToFreq(d.Data)
|
||||
if !ok || hz <= 0 {
|
||||
return 0, fmt.Errorf("xiegu: unusable frequency % X", d.Data)
|
||||
}
|
||||
return hz, nil
|
||||
|
||||
@@ -76,8 +76,9 @@ func TestXieguFrequencyEncoding(t *testing.T) {
|
||||
if len(b) != 5 {
|
||||
t.Fatalf("FreqToBCD(%d) produced %d bytes, want 5", hz, len(b))
|
||||
}
|
||||
if got := civ.BCDToFreq(b); got != hz {
|
||||
t.Errorf("round trip %d → % X → %d", hz, b, got)
|
||||
got, ok := civ.BCDToFreq(b)
|
||||
if !ok || got != hz {
|
||||
t.Errorf("round trip %d → % X → %d (ok=%v)", hz, b, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+140
-19
@@ -17,8 +17,9 @@ package cat
|
||||
//
|
||||
// FA; → FA014074000; VFO A frequency, 9 digits, Hz
|
||||
// FB; → FB014100000; VFO B frequency
|
||||
// MD0; → MD02; operating mode of the main receiver
|
||||
// VS; → VS0; which VFO is selected (0=A, 1=B)
|
||||
// MD0;/MD1; → MD02; operating mode — 0 = main receiver, 1 = sub
|
||||
// FR; → FR1; RECEIVE VFO (0=main/A, 1=sub/B)
|
||||
// VS; → VS0; selected VFO, on models without FR
|
||||
// ST; → ST1; split (FTDX10/FTDX101)
|
||||
// FT; → FT1; TX VFO (FT-991A/FT-710/FT-891 family)
|
||||
// TX1; / TX0; key / unkey
|
||||
@@ -93,6 +94,9 @@ type Yaesu struct {
|
||||
// answers. Empty means the rig answered neither, and split is reported as
|
||||
// off rather than invented.
|
||||
splitCmd string
|
||||
// rxVFOCmd is "FR" when the rig reports its receive VFO that way, else empty
|
||||
// and VS is used — see ReadState.
|
||||
rxVFOCmd string
|
||||
|
||||
curFreq int64
|
||||
curRXFreq int64
|
||||
@@ -131,6 +135,17 @@ func (y *Yaesu) Connect() error {
|
||||
if y.portName == "" {
|
||||
return fmt.Errorf("yaesu: no serial port configured")
|
||||
}
|
||||
// Close any handle we still hold before opening another.
|
||||
//
|
||||
// Connect is called again on every reconnect, and it used to overwrite y.port
|
||||
// with a fresh handle and leak the old one. Windows opens a serial port
|
||||
// EXCLUSIVELY, so a leaked handle makes the next open fail with "Serial port
|
||||
// busy" — seen in the field — and the retry loop then leaks one handle per
|
||||
// attempt, once every few seconds, for as long as it keeps failing.
|
||||
if y.port != nil {
|
||||
_ = y.port.Close()
|
||||
y.port = nil
|
||||
}
|
||||
p, err := serial.Open(y.portName, &serial.Mode{BaudRate: y.baud})
|
||||
if err != nil {
|
||||
return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err)
|
||||
@@ -143,7 +158,9 @@ func (y *Yaesu) Connect() error {
|
||||
// a reply impossible to attribute — we poll instead, so the traffic is ours.
|
||||
_ = y.write("AI0;")
|
||||
|
||||
answered := false
|
||||
if id, err := y.ask("ID;"); err == nil {
|
||||
answered = true
|
||||
code := strings.TrimSuffix(strings.TrimPrefix(id, "ID"), ";")
|
||||
if name, ok := yaesuModels[code]; ok {
|
||||
y.model = name
|
||||
@@ -159,8 +176,34 @@ func (y *Yaesu) Connect() error {
|
||||
// remembering the answer keeps the poll loop from paying for two round trips
|
||||
// per cycle, and makes "neither answered" an explicit, logged state instead
|
||||
// of a silent assumption that split is off.
|
||||
for _, c := range []string{"ST", "FT"} {
|
||||
// Which command reports the RECEIVE VFO. FR is the right one where it exists;
|
||||
// VS is a weaker substitute that an FTDX101 answers with the main VFO even
|
||||
// when the operator has moved both RX and TX to sub.
|
||||
if r, err := y.ask("FR;"); err == nil && strings.HasPrefix(r, "FR") {
|
||||
answered = true
|
||||
y.rxVFOCmd = "FR"
|
||||
debugLog.Printf("yaesu: receive VFO is read through FR (answered %q)", r)
|
||||
} else {
|
||||
debugLog.Printf("yaesu: no FR; — falling back to VS for the receive VFO")
|
||||
}
|
||||
|
||||
// Which command carries split — and the ORDER matters.
|
||||
//
|
||||
// ST is a bare flag: it says "split", not which VFO transmits. On an FTDX101
|
||||
// with RX and TX both moved to SUB, the rig reports ST1 even though that is
|
||||
// plain simplex on the sub VFO, and OpsLog showed split with the main
|
||||
// frequency as TX (F4NBZ, 2026-07-29). FT names the TRANSMIT VFO, so where the
|
||||
// receive VFO is also known (FR), "split" can be derived from the two: it is
|
||||
// on when they differ. That is a statement about the rig's actual state rather
|
||||
// than a flag whose meaning varies by model, so FT is asked FIRST when FR
|
||||
// answered, and ST remains the fallback for rigs without either.
|
||||
splitProbes := []string{"ST", "FT"}
|
||||
if y.rxVFOCmd != "" {
|
||||
splitProbes = []string{"FT", "ST"}
|
||||
}
|
||||
for _, c := range splitProbes {
|
||||
if r, err := y.ask(c + ";"); err == nil && strings.HasPrefix(r, c) {
|
||||
answered = true
|
||||
y.splitCmd = c
|
||||
debugLog.Printf("yaesu: split is read through %s (answered %q)", c, r)
|
||||
break
|
||||
@@ -169,6 +212,18 @@ func (y *Yaesu) Connect() error {
|
||||
if y.splitCmd == "" {
|
||||
debugLog.Printf("yaesu: neither ST; nor FT; answered — split will be reported as OFF. Send this log if the rig does have split.")
|
||||
}
|
||||
// A port that opens is not a rig that is there.
|
||||
//
|
||||
// The log used to announce "connected … model=FTDX101D" after every probe had
|
||||
// timed out, with the model left over from the previous session — an operator
|
||||
// whose radio was simply switched OFF got a line saying it was connected, and
|
||||
// the real cause took a log study to find. Say what actually happened.
|
||||
if !answered {
|
||||
y.model = ""
|
||||
debugLog.Printf("yaesu: %s opens at %d baud but the rig answers nothing — is it powered on, and is its CAT rate %d?",
|
||||
y.portName, y.baud, y.baud)
|
||||
return fmt.Errorf("yaesu: %s opened but the rig is not answering — check that it is switched on", y.portName)
|
||||
}
|
||||
debugLog.Printf("yaesu: connected on %s @ %d baud, model=%q", y.portName, y.baud, y.model)
|
||||
return nil
|
||||
}
|
||||
@@ -212,18 +267,30 @@ func (y *Yaesu) ReadState() (RigState, error) {
|
||||
freqB, _ = parseYaesuFreq(r, "FB")
|
||||
}
|
||||
|
||||
// Which VFO the operator is listening on. Unlike OmniRig there is no
|
||||
// interpretation to do: VS answers 0 or 1.
|
||||
// Which VFO the operator is LISTENING on.
|
||||
//
|
||||
// FR is the command that answers that — it selects the receive VFO — and VS
|
||||
// does not: on an FTDX101 with both RX and TX moved to SUB, VS still reported
|
||||
// the main VFO, so OpsLog displayed VFO A while the operator was entirely on
|
||||
// B. Reported 2026-07-29. FR is asked first and VS is the fallback for models
|
||||
// that do not implement it.
|
||||
vfo := "A"
|
||||
if r, err := y.ask("VS;"); err == nil && len(r) >= 3 && r[2] == '1' {
|
||||
vfo = "B"
|
||||
switch {
|
||||
case y.rxVFOCmd != "":
|
||||
if r, err := y.ask(y.rxVFOCmd + ";"); err == nil && yaesuStateDigit(r, y.rxVFOCmd) == '1' {
|
||||
vfo = "B"
|
||||
}
|
||||
default:
|
||||
if r, err := y.ask("VS;"); err == nil && yaesuStateDigit(r, "VS") == '1' {
|
||||
vfo = "B"
|
||||
}
|
||||
}
|
||||
y.curVFO = vfo
|
||||
|
||||
split := false
|
||||
if y.splitCmd != "" {
|
||||
if r, err := y.ask(y.splitCmd + ";"); err == nil {
|
||||
split = yaesuSplitOn(r, y.splitCmd)
|
||||
split = yaesuSplitFromReply(r, y.splitCmd, vfo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +304,7 @@ func (y *Yaesu) ReadState() (RigState, error) {
|
||||
y.curRXFreq = s.RxFreqHz
|
||||
}
|
||||
|
||||
if r, err := y.ask("MD0;"); err == nil && len(r) >= 4 {
|
||||
if r, err := y.ask("MD" + y.modeVFOSuffix() + ";"); err == nil && len(r) >= 4 {
|
||||
// Keep the RAW mode too: ADIF folds CW-U/CW-L and DATA-U/DATA-L together,
|
||||
// but the panel has to show which sideband the rig is actually on.
|
||||
y.panel.RawMode = yaesuRawModeName(r[3])
|
||||
@@ -284,7 +351,25 @@ func (y *Yaesu) SetMode(mode string) error {
|
||||
if d == 0 {
|
||||
return fmt.Errorf("yaesu: no CAT mode for %q", mode)
|
||||
}
|
||||
return y.write(fmt.Sprintf("MD0%c;", d))
|
||||
return y.write(fmt.Sprintf("MD%s%c;", y.modeVFOSuffix(), d))
|
||||
}
|
||||
|
||||
// modeVFOSuffix picks which receiver the mode commands address: "0" is main,
|
||||
// "1" is sub.
|
||||
//
|
||||
// Both the read and the write used 0 unconditionally. A spot click already tuned
|
||||
// the right VFO — that part was fixed — but then set the MODE on main, so an
|
||||
// operator working from the sub receiver had a spot change the mode of the VFO
|
||||
// they were not using and leave the one they were on untouched (F4NBZ,
|
||||
// 2026-07-29). The rig reads the same way, so the panel showed main's mode too.
|
||||
//
|
||||
// Rigs without a sub receiver simply never see "1": curVFO only becomes B on a
|
||||
// rig that reports its receive VFO.
|
||||
func (y *Yaesu) modeVFOSuffix() string {
|
||||
if y.curVFO == "B" {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
func (y *Yaesu) SetPTT(on bool) error {
|
||||
@@ -396,18 +481,32 @@ func parseYaesuFreq(reply, prefix string) (int64, bool) {
|
||||
return hz, true
|
||||
}
|
||||
|
||||
// yaesuSplitOn reads the split reply for whichever command the rig answers.
|
||||
// yaesuSplitFromReply turns the split reply into a yes or no.
|
||||
//
|
||||
// ST is a split flag: ST1 means split. FT names the TX VFO: FT1 means transmit
|
||||
// on VFO B, which IS split when the operator is listening on A. The two are not
|
||||
// the same statement, which is why the command in use is remembered rather than
|
||||
// both being tried and merged.
|
||||
func yaesuSplitOn(reply, cmd string) bool {
|
||||
r := strings.TrimSpace(reply)
|
||||
if !strings.HasPrefix(r, cmd) || len(r) < len(cmd)+1 {
|
||||
// The two commands say DIFFERENT things and reading them alike is a real fault,
|
||||
// reported on an FTDX101 (F4NBZ, 2026-07-29) where the panel showed split ON with
|
||||
// the radio OFF and the reverse:
|
||||
//
|
||||
// ST is a split FLAG — ST1 means split, whatever VFO is in use.
|
||||
// FT names the TX VFO — FT0 = transmit on A, FT1 = transmit on B.
|
||||
//
|
||||
// Split is on when the rig TRANSMITS on a different VFO from the one it is
|
||||
// LISTENING to. Reading FT1 as "split" is therefore only right for an operator
|
||||
// on VFO A: on SUB it is exactly inverted, which is why the same model behaved
|
||||
// correctly for one operator and backwards for another — one was on MAIN, the
|
||||
// other on SUB.
|
||||
func yaesuSplitFromReply(reply, cmd, vfo string) bool {
|
||||
d := yaesuStateDigit(reply, cmd)
|
||||
if d == 0 {
|
||||
return false
|
||||
}
|
||||
return r[len(cmd)] == '1'
|
||||
if cmd == "ST" {
|
||||
return d == '1'
|
||||
}
|
||||
// FT: compare the transmit VFO with the one being listened to.
|
||||
txOnB := d == '1'
|
||||
rxOnB := strings.HasPrefix(strings.ToUpper(vfo), "B")
|
||||
return txOnB != rxOnB
|
||||
}
|
||||
|
||||
// resolveYaesuVFOs turns the two frequencies plus the VFO and split flags into
|
||||
@@ -465,3 +564,25 @@ func yaesuModeDigit(mode string, freqHz int64) byte {
|
||||
return 'C' // DATA-USB
|
||||
}
|
||||
}
|
||||
|
||||
// yaesuStateDigit returns the STATE digit of a reply — the FIRST digit after
|
||||
// the command — or 0 if the reply does not belong to this command.
|
||||
//
|
||||
// The parameter is not always one character: an FTDX101 answers "FR01;" where an
|
||||
// FTDX10 answers "FR0;". The state is the FIRST digit in both; the second is a
|
||||
// separate parameter. Reading the LAST digit inverted it — with RX and TX on
|
||||
// MAIN the rig answered FR01, OpsLog concluded SUB, the main frequency stopped
|
||||
// updating and a spot click tuned VFO B (F4NBZ, 2026-07-29). That was my own
|
||||
// correction of the previous evening, made the wrong way round: the earlier
|
||||
// "SUB shows MAIN" fault came from reading VS, not from this digit.
|
||||
func yaesuStateDigit(reply, cmd string) byte {
|
||||
r := strings.TrimSpace(reply)
|
||||
if !strings.HasPrefix(r, cmd) || len(r) <= len(cmd) {
|
||||
return 0
|
||||
}
|
||||
d := r[len(cmd)]
|
||||
if d < '0' || d > '9' {
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
@@ -397,12 +397,12 @@ func (y *Yaesu) SetYaesuSplit(on bool) error {
|
||||
return y.SetYaesuSplitOffset(y.defaultSplitOffset())
|
||||
}
|
||||
y.mu.Lock()
|
||||
cmd := y.splitCmd
|
||||
cmd, vfo := y.splitCmd, y.curVFO
|
||||
y.mu.Unlock()
|
||||
if cmd == "" {
|
||||
return fmt.Errorf("yaesu: this rig answered neither ST; nor FT; — split cannot be set")
|
||||
}
|
||||
return y.setAndRefresh(fmt.Sprintf("%s0;", cmd))
|
||||
return y.setAndRefresh(yaesuSplitCommand(cmd, vfo, false))
|
||||
}
|
||||
|
||||
// defaultSplitOffset is what SPLIT means on this mode: up 1 kHz on CW and the
|
||||
@@ -430,7 +430,9 @@ func (y *Yaesu) SetYaesuModeRaw(name string) error {
|
||||
if !ok {
|
||||
return fmt.Errorf("yaesu: unknown rig mode %q", name)
|
||||
}
|
||||
return y.setAndRefresh(fmt.Sprintf("MD0%c;", d))
|
||||
// Same receiver as everywhere else — the console must not set main's mode
|
||||
// while the operator is working the sub VFO.
|
||||
return y.setAndRefresh(fmt.Sprintf("MD%s%c;", y.modeVFOSuffix(), d))
|
||||
}
|
||||
|
||||
// yaesuRawModeDigit maps a rig-mode name to its MD digit.
|
||||
@@ -662,7 +664,7 @@ func (y *Yaesu) SetYaesuSplitOffset(offsetHz int64) error {
|
||||
return fmt.Errorf("yaesu: this rig answered neither ST; nor FT; — split cannot be set")
|
||||
}
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
if err := y.write(fmt.Sprintf("%s1;", y.splitCmd)); err != nil {
|
||||
if err := y.write(yaesuSplitCommand(y.splitCmd, y.curVFO, true)); err != nil {
|
||||
return err
|
||||
}
|
||||
y.panel.Split = true
|
||||
@@ -808,3 +810,28 @@ func (m *meterPeak) update(sample int, now time.Time) int {
|
||||
}
|
||||
return m.val
|
||||
}
|
||||
|
||||
// yaesuSplitCommand builds the command that turns split on or off.
|
||||
//
|
||||
// Writing is as asymmetric as reading. ST takes the state directly — ST1 is
|
||||
// split on. FT sets which VFO TRANSMITS, so "split on" means transmit on the
|
||||
// OTHER VFO from the one being listened to, and "split off" means transmit on
|
||||
// the same one. Sending FT1 for "on" regardless is right only for an operator on
|
||||
// VFO A; on SUB it would clear the split it was asked to set.
|
||||
func yaesuSplitCommand(cmd, vfo string, on bool) string {
|
||||
if cmd == "ST" {
|
||||
if on {
|
||||
return "ST1;"
|
||||
}
|
||||
return "ST0;"
|
||||
}
|
||||
onSub := strings.HasPrefix(strings.ToUpper(vfo), "B")
|
||||
txOnB := onSub // split off = transmit where we listen
|
||||
if on {
|
||||
txOnB = !onSub
|
||||
}
|
||||
if txOnB {
|
||||
return cmd + "1;"
|
||||
}
|
||||
return cmd + "0;"
|
||||
}
|
||||
|
||||
+263
-13
@@ -54,24 +54,46 @@ func TestResolveYaesuVFOs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ST and FT say different things and must not be read the same way — see the
|
||||
// comment on yaesuSplitOn.
|
||||
// ST and FT say different things, and reading them alike inverted the split
|
||||
// display on an FTDX101 (F4NBZ, 2026-07-29): the panel showed split ON with the
|
||||
// radio OFF and the reverse.
|
||||
//
|
||||
// ST is a split FLAG — ST1 means split, whatever VFO is in use.
|
||||
// FT names the TX VFO — FT0 = transmit on A, FT1 = transmit on B.
|
||||
//
|
||||
// Split is on when the rig transmits on a DIFFERENT VFO from the one it listens
|
||||
// to, so FT has to be compared with the current VFO. That is why the same model
|
||||
// behaved correctly for one operator and backwards for another: one was on MAIN,
|
||||
// the other on SUB.
|
||||
func TestYaesuSplitReply(t *testing.T) {
|
||||
cases := []struct {
|
||||
reply, cmd string
|
||||
want bool
|
||||
reply, cmd, vfo string
|
||||
want bool
|
||||
}{
|
||||
{"ST1;", "ST", true},
|
||||
{"ST0;", "ST", false},
|
||||
{"FT1;", "FT", true},
|
||||
{"FT0;", "FT", false},
|
||||
{"ST1;", "FT", false}, // reply for the other command — not accepted
|
||||
{"ST", "ST", false}, // truncated
|
||||
{"", "ST", false},
|
||||
// The flag is absolute.
|
||||
{"ST1;", "ST", "A", true},
|
||||
{"ST0;", "ST", "A", false},
|
||||
{"ST1;", "ST", "B", true},
|
||||
{"ST0;", "ST", "B", false},
|
||||
|
||||
// Listening on A: transmit on B is split, transmit on A is not.
|
||||
{"FT1;", "FT", "A", true},
|
||||
{"FT0;", "FT", "A", false},
|
||||
// Listening on B: exactly the opposite — the reported inversion.
|
||||
{"FT0;", "FT", "B", true},
|
||||
{"FT1;", "FT", "B", false},
|
||||
// The pair enums the Yaesus also report start with the listening VFO.
|
||||
{"FT0;", "FT", "BA", true},
|
||||
{"FT1;", "FT", "AB", true},
|
||||
|
||||
{"ST1;", "FT", "A", false}, // reply for the other command — not accepted
|
||||
{"ST", "ST", "A", false}, // truncated
|
||||
{"", "ST", "A", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := yaesuSplitOn(c.reply, c.cmd); got != c.want {
|
||||
t.Errorf("yaesuSplitOn(%q,%q) = %v, want %v", c.reply, c.cmd, got, c.want)
|
||||
if got := yaesuSplitFromReply(c.reply, c.cmd, c.vfo); got != c.want {
|
||||
t.Errorf("yaesuSplitFromReply(%q, %q, vfo=%q) = %v, want %v",
|
||||
c.reply, c.cmd, c.vfo, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,3 +152,231 @@ func TestYaesuCmdPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Writing split is as asymmetric as reading it.
|
||||
//
|
||||
// ST takes the state directly. FT sets which VFO TRANSMITS, so "split on" means
|
||||
// transmit on the OTHER VFO from the one being listened to. Sending FT1 for "on"
|
||||
// regardless is right only on VFO A — on SUB it would CLEAR the split it was
|
||||
// asked to set, which is the same inversion that showed on the FTDX101 panel.
|
||||
func TestYaesuSplitCommand(t *testing.T) {
|
||||
cases := []struct {
|
||||
cmd, vfo string
|
||||
on bool
|
||||
want string
|
||||
}{
|
||||
{"ST", "A", true, "ST1;"},
|
||||
{"ST", "B", true, "ST1;"}, // the flag does not care which VFO
|
||||
{"ST", "B", false, "ST0;"},
|
||||
|
||||
// Listening on A: split means transmit on B.
|
||||
{"FT", "A", true, "FT1;"},
|
||||
{"FT", "A", false, "FT0;"},
|
||||
// Listening on B: split means transmit on A — the reverse.
|
||||
{"FT", "B", true, "FT0;"},
|
||||
{"FT", "B", false, "FT1;"},
|
||||
// Pair enums start with the listening VFO.
|
||||
{"FT", "BA", true, "FT0;"},
|
||||
{"FT", "AB", true, "FT1;"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := yaesuSplitCommand(c.cmd, c.vfo, c.on); got != c.want {
|
||||
t.Errorf("yaesuSplitCommand(%q, vfo=%q, on=%v) = %q, want %q",
|
||||
c.cmd, c.vfo, c.on, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Which VFO the operator is listening on, on a rig with separate RX and TX
|
||||
// selection.
|
||||
//
|
||||
// Reported on an FTDX101 (2026-07-29): moving RX alone to SUB displayed VFO B
|
||||
// correctly, but moving BOTH RX and TX to SUB displayed VFO A — the operator was
|
||||
// entirely on B and OpsLog showed the other one. FR reports the receive VFO; VS
|
||||
// does not answer that question on this rig.
|
||||
//
|
||||
// With FR read correctly the split follows too, since split is "transmit VFO
|
||||
// differs from receive VFO".
|
||||
func TestYaesuReceiveVFOAndSplit(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fr, ft string // replies
|
||||
wantVFO string
|
||||
wantSplit bool
|
||||
}{
|
||||
{"everything on main", "FR0;", "FT0;", "A", false},
|
||||
{"RX on sub, TX still on main — split", "FR1;", "FT0;", "B", true},
|
||||
{"RX and TX both on sub — NOT split", "FR1;", "FT1;", "B", false},
|
||||
{"RX on main, TX on sub — split", "FR0;", "FT1;", "A", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
vfo := "A"
|
||||
if len(c.fr) >= 3 && c.fr[2] == '1' {
|
||||
vfo = "B"
|
||||
}
|
||||
if vfo != c.wantVFO {
|
||||
t.Errorf("%s: receive VFO = %s, want %s", c.name, vfo, c.wantVFO)
|
||||
}
|
||||
if got := yaesuSplitFromReply(c.ft, "FT", vfo); got != c.wantSplit {
|
||||
t.Errorf("%s: split = %v, want %v", c.name, got, c.wantSplit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The state digit of a reply, when the parameter is not one character.
|
||||
//
|
||||
// An FTDX101 answers "FR01;" where an FTDX10 answers "FR0;". The state is the
|
||||
// FIRST digit on both — the second is a separate parameter.
|
||||
//
|
||||
// This test asserted the opposite for one evening. Reading the LAST digit turned
|
||||
// "FR01" into SUB, so an operator with RX and TX on MAIN saw the main frequency
|
||||
// freeze and a spot click tune VFO B (F4NBZ, 2026-07-29). The fault it was meant
|
||||
// to fix — "SUB shows MAIN" — came from reading VS, not from this digit, and the
|
||||
// FR probe alone had already fixed it.
|
||||
func TestYaesuStateDigit(t *testing.T) {
|
||||
cases := []struct {
|
||||
reply, cmd string
|
||||
want byte
|
||||
}{
|
||||
{"FR0;", "FR", '0'}, // FTDX10 form
|
||||
{"FR1;", "FR", '1'},
|
||||
{"FR01;", "FR", '0'}, // FTDX101 form: MAIN — the reported bug read this as SUB
|
||||
{"FR11;", "FR", '1'}, // …and this is SUB
|
||||
{"ST1;", "ST", '1'},
|
||||
{"FT0;", "FT", '0'},
|
||||
{"VS1;", "VS", '1'},
|
||||
{"FR1", "FR", '1'}, // terminator already stripped
|
||||
|
||||
{"ST1;", "FR", 0}, // another command's reply is never accepted
|
||||
{"FR;", "FR", 0}, // query echoed with no value
|
||||
{"FRx;", "FR", 0}, // not a digit
|
||||
{"", "FR", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := yaesuStateDigit(c.reply, c.cmd); got != c.want {
|
||||
t.Errorf("yaesuStateDigit(%q, %q) = %q, want %q", c.reply, c.cmd, got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// End to end, both directions of the reported fault:
|
||||
if d := yaesuStateDigit("FR01;", "FR"); d != '0' {
|
||||
t.Fatalf("FR01 read as %q — the operator is on MAIN and must be seen there", d)
|
||||
}
|
||||
if d := yaesuStateDigit("FR11;", "FR"); d != '1' {
|
||||
t.Fatalf("FR11 read as %q — the operator is on SUB", d)
|
||||
}
|
||||
}
|
||||
|
||||
// A spot click tunes the VFO the operator is ON, and the display reads that same
|
||||
// VFO. Both follow from the receive-VFO digit, which is why it is pinned here in
|
||||
// the operator's terms rather than only as a byte.
|
||||
//
|
||||
// Reported both ways round on an FTDX101 (F4NBZ, 2026-07-29): with RX and TX on
|
||||
// SUB everything worked, and with them on MAIN the frequency froze and a spot
|
||||
// clicked tuned the sub VFO.
|
||||
func TestYaesuActiveVFOFollowsReceiveVFO(t *testing.T) {
|
||||
// Mirrors ReadState's choice of VFO and SetFrequency's choice of command.
|
||||
activeVFO := func(frReply string) string {
|
||||
if yaesuStateDigit(frReply, "FR") == '1' {
|
||||
return "B"
|
||||
}
|
||||
return "A"
|
||||
}
|
||||
tuneCmd := func(vfo string) string {
|
||||
if vfo == "B" {
|
||||
return "FB" // sub
|
||||
}
|
||||
return "FA" // main
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name, fr, wantVFO, wantCmd string
|
||||
}{
|
||||
{"RX and TX on MAIN (FTDX101 two-digit reply)", "FR01;", "A", "FA"},
|
||||
{"RX and TX on SUB (FTDX101)", "FR11;", "B", "FB"},
|
||||
{"MAIN on a one-digit rig", "FR0;", "A", "FA"},
|
||||
{"SUB on a one-digit rig", "FR1;", "B", "FB"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
vfo := activeVFO(c.fr)
|
||||
if vfo != c.wantVFO {
|
||||
t.Errorf("%s: active VFO = %s, want %s", c.name, vfo, c.wantVFO)
|
||||
}
|
||||
if cmd := tuneCmd(vfo); cmd != c.wantCmd {
|
||||
t.Errorf("%s: a spot click would write %s, want %s", c.name, cmd, c.wantCmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Which split command to ASK, given what else the rig answers.
|
||||
//
|
||||
// ST is a bare flag and its meaning varies: an FTDX101 with RX and TX both on
|
||||
// SUB reports ST1, which is plain simplex on the sub VFO, and OpsLog showed
|
||||
// split with the main frequency as the transmit one (F4NBZ, 2026-07-29).
|
||||
//
|
||||
// FT names the transmit VFO. Where the receive VFO is known too (FR), split is
|
||||
// derived from the pair — they differ or they do not — which is a fact about the
|
||||
// rig rather than a flag to be interpreted. So FT is preferred when FR answered.
|
||||
func TestYaesuSplitProbeOrder(t *testing.T) {
|
||||
order := func(rxVFOCmd string) []string {
|
||||
if rxVFOCmd != "" {
|
||||
return []string{"FT", "ST"}
|
||||
}
|
||||
return []string{"ST", "FT"}
|
||||
}
|
||||
|
||||
if got := order("FR")[0]; got != "FT" {
|
||||
t.Errorf("with FR available the first split probe is %q, want FT", got)
|
||||
}
|
||||
if got := order("")[0]; got != "ST" {
|
||||
t.Errorf("without FR the first split probe is %q, want ST", got)
|
||||
}
|
||||
// Both remain available: a rig answering only one must still be handled.
|
||||
for _, rx := range []string{"FR", ""} {
|
||||
if len(order(rx)) != 2 {
|
||||
t.Errorf("rxVFOCmd=%q: both probes must remain, got %v", rx, order(rx))
|
||||
}
|
||||
}
|
||||
|
||||
// The case that was reported, end to end: RX and TX both on sub is NOT split.
|
||||
if yaesuSplitFromReply("FT1;", "FT", "B") {
|
||||
t.Error("RX and TX both on SUB reported as split")
|
||||
}
|
||||
// And the flag alone would have got it wrong, which is why the order changed.
|
||||
if !yaesuSplitFromReply("ST1;", "ST", "B") {
|
||||
t.Error("ST1 is a flag and reads as split whatever the VFO — that is the trap")
|
||||
}
|
||||
}
|
||||
|
||||
// The mode commands address the receiver the operator is ON.
|
||||
//
|
||||
// Reported on an FTDX101 (F4NBZ, 2026-07-29): once a spot click tuned the right
|
||||
// VFO, it still changed the mode of MAIN while the operator worked from SUB — so
|
||||
// the VFO in use kept its old mode and the idle one was altered. The read had the
|
||||
// same fault, so the panel showed main's mode as well.
|
||||
func TestYaesuModeVFOSuffix(t *testing.T) {
|
||||
cases := []struct{ vfo, want string }{
|
||||
{"A", "0"}, // main
|
||||
{"", "0"}, // not yet read — main is the safe assumption
|
||||
{"B", "1"}, // sub
|
||||
{"AB", "0"}, // pair enums start with the receive VFO
|
||||
}
|
||||
for _, c := range cases {
|
||||
y := &Yaesu{}
|
||||
y.curVFO = c.vfo
|
||||
if got := y.modeVFOSuffix(); got != c.want {
|
||||
t.Errorf("curVFO=%q → MD%s, want MD%s", c.vfo, got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Spelled out as the commands actually sent, which is what the rig sees.
|
||||
y := &Yaesu{}
|
||||
y.curVFO = "B"
|
||||
if cmd := "MD" + y.modeVFOSuffix() + "3;"; cmd != "MD13;" {
|
||||
t.Errorf("setting CW on the sub receiver sends %q, want MD13;", cmd)
|
||||
}
|
||||
y.curVFO = "A"
|
||||
if cmd := "MD" + y.modeVFOSuffix() + "3;"; cmd != "MD03;" {
|
||||
t.Errorf("setting CW on main sends %q, want MD03;", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
+113
-14
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -35,8 +36,14 @@ import (
|
||||
var (
|
||||
reStrftimeDefault = regexp.MustCompile(`\(strftime\('%Y-%m-%dT%H:%M:%fZ',\s*'now'\)\)`)
|
||||
reBareInteger = regexp.MustCompile(`\bINTEGER\b`)
|
||||
reColText = regexp.MustCompile(`(\w+)\s+TEXT\b`)
|
||||
reKeyColumn = regexp.MustCompile(`(?m)^(\s*)key\b`)
|
||||
// The column name may be bare or quoted. The SQLite baseline dump quotes its
|
||||
// identifiers, and those double quotes are already BACKTICKS by the time this
|
||||
// runs — so a pattern matching only a bare name left every column in the
|
||||
// baseline as TEXT. That produced both "BLOB/TEXT column 'state' used in key
|
||||
// specification without a key length" and "column 'op_name' can't have a
|
||||
// default value" on a MySQL logbook, neither of which names the real cause.
|
||||
reColText = regexp.MustCompile("(`?)(\\w+)(`?)\\s+TEXT\\b")
|
||||
reKeyColumn = regexp.MustCompile(`(?m)^(\s*)key\b`)
|
||||
// SQLite quotes identifiers with double quotes — e.g. a table renamed by
|
||||
// ALTER … RENAME TO ends up as CREATE TABLE "name" in sqlite_master. MySQL
|
||||
// uses backticks. Our schema has no double-quoted string literals, so it's
|
||||
@@ -103,27 +110,59 @@ func mysqlDDL(stmt string) string {
|
||||
// right MySQL type, deciding per line so it can see whether the line carries a
|
||||
// DEFAULT or an inline PRIMARY KEY. See varcharColumns / longTextColumns.
|
||||
func translateTextColumns(s string) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
for i, line := range lines {
|
||||
m := reColText.FindStringSubmatchIndex(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
col := line[m[2]:m[3]]
|
||||
// EVERY column declaration is handled, wherever it sits.
|
||||
//
|
||||
// This used to work line by line and convert only the FIRST column on each
|
||||
// line. That is fine for our hand-written migrations, which put one column
|
||||
// per line, and wrong for the SQLite baseline: sqlite_master stores a table's
|
||||
// CREATE statement with every ALTER-added column appended to the SAME line,
|
||||
// so on a real logbook only one of them was converted and MySQL rejected the
|
||||
// rest — "column 'op_name' can't have a default value".
|
||||
//
|
||||
// Each match is also decided from ITS OWN declaration — the text between the
|
||||
// surrounding commas — rather than from the whole line, so a DEFAULT
|
||||
// belonging to a neighbouring column cannot decide this one's type.
|
||||
out := make([]byte, 0, len(s)+64)
|
||||
last := 0
|
||||
for _, m := range reColText.FindAllStringSubmatchIndex(s, -1) {
|
||||
openQ, col, closeQ := s[m[2]:m[3]], s[m[4]:m[5]], s[m[6]:m[7]]
|
||||
decl := declarationAround(s, m[0], m[1])
|
||||
var typ string
|
||||
switch {
|
||||
case longTextColumns[col] != "":
|
||||
typ = longTextColumns[col]
|
||||
case varcharColumns[col],
|
||||
strings.Contains(line, "DEFAULT"),
|
||||
strings.Contains(line, "PRIMARY KEY"):
|
||||
strings.Contains(decl, "DEFAULT"),
|
||||
strings.Contains(decl, "PRIMARY KEY"):
|
||||
typ = "VARCHAR(255)"
|
||||
default:
|
||||
typ = "TEXT"
|
||||
}
|
||||
lines[i] = line[:m[0]] + col + " " + typ + line[m[1]:]
|
||||
out = append(out, s[last:m[0]]...)
|
||||
out = append(out, (openQ + col + closeQ + " " + typ)...)
|
||||
last = m[1]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
return string(append(out, s[last:]...))
|
||||
}
|
||||
|
||||
// declarationAround returns the single column declaration containing [start,end)
|
||||
// — the text between the commas, brackets or newlines either side.
|
||||
//
|
||||
// Types are decided from this rather than from the whole line because several
|
||||
// declarations share a line in the SQLite baseline, and a neighbour's DEFAULT
|
||||
// must not decide our column's type.
|
||||
func declarationAround(s string, start, end int) string {
|
||||
from := strings.LastIndexAny(s[:start], ",(\n")
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
to := strings.IndexAny(s[end:], ",)\n")
|
||||
if to < 0 {
|
||||
to = len(s)
|
||||
} else {
|
||||
to += end
|
||||
}
|
||||
return s[from:to]
|
||||
}
|
||||
|
||||
// OpenMySQL opens the shared MySQL logbook, creating the database if needed,
|
||||
@@ -200,7 +239,15 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
|
||||
// especially on a server with slow DDL.
|
||||
err = applyMySQLBaseline(conn)
|
||||
} else {
|
||||
// Existing database: apply only the migrations it's missing.
|
||||
// Existing database: repair any column that must be VARCHAR before the
|
||||
// migrations run — an index on a TEXT column fails, and that failure
|
||||
// otherwise repeats at every start with no way out from the UI.
|
||||
if rerr := repairMySQLTextColumns(context.Background(), conn); rerr != nil {
|
||||
_ = conn.Close()
|
||||
Dialect = "sqlite"
|
||||
return nil, rerr
|
||||
}
|
||||
// Then apply only the migrations it's missing.
|
||||
err = migrate(conn, mysqlDDL, "", "mysql:"+name)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -534,3 +581,55 @@ func isIgnorableDDLError(err error) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// repairMySQLTextColumns converts columns that MUST be VARCHAR but are TEXT.
|
||||
//
|
||||
// Translating the schema correctly does not help a database that already exists.
|
||||
// A logbook created by an earlier version — or left half-built by a migration
|
||||
// that failed partway — keeps its TEXT columns, and every later run dies on the
|
||||
// same statement: "CREATE INDEX … ON qso(state)" → 1170, because MySQL cannot
|
||||
// index a TEXT column without a prefix length. The operator sees the connection
|
||||
// fail at startup, for ever, with no way forward from the UI.
|
||||
//
|
||||
// So the schema is repaired before the migrations run. Only the columns in
|
||||
// varcharColumns are touched — the ones that are indexed or part of a key — and
|
||||
// only when they are actually TEXT, so this is a no-op on a healthy database.
|
||||
func repairMySQLTextColumns(ctx context.Context, conn *sql.DB) error {
|
||||
rows, err := conn.QueryContext(ctx, `
|
||||
SELECT TABLE_NAME, COLUMN_NAME, IS_NULLABLE
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND DATA_TYPE IN ('text','mediumtext','longtext')`)
|
||||
if err != nil {
|
||||
// Not fatal: a server that will not answer information_schema can still
|
||||
// run a healthy schema, and failing here would block a working logbook.
|
||||
return nil
|
||||
}
|
||||
type col struct{ table, name, nullable string }
|
||||
var todo []col
|
||||
for rows.Next() {
|
||||
var c col
|
||||
if err := rows.Scan(&c.table, &c.name, &c.nullable); err != nil {
|
||||
break
|
||||
}
|
||||
if varcharColumns[c.name] {
|
||||
todo = append(todo, c)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, c := range todo {
|
||||
null := "NULL"
|
||||
if c.nullable == "NO" {
|
||||
null = "NOT NULL"
|
||||
}
|
||||
stmt := fmt.Sprintf("ALTER TABLE `%s` MODIFY `%s` VARCHAR(255) %s", c.table, c.name, null)
|
||||
if _, err := conn.ExecContext(ctx, stmt); err != nil {
|
||||
// Report it: this is the difference between a logbook that opens and
|
||||
// one that does not, so a failure here must not be silent.
|
||||
return fmt.Errorf("repair %s.%s to VARCHAR: %w", c.table, c.name, err)
|
||||
}
|
||||
log.Printf("db[mysql]: repaired %s.%s TEXT → VARCHAR(255) (it is indexed)", c.table, c.name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -151,3 +151,102 @@ func TestMySQLDDL_NoLeftoverSQLiteisms(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A QUOTED column declaration must be translated like a bare one.
|
||||
//
|
||||
// The SQLite baseline dump quotes its identifiers, and mysqlDDL turns those
|
||||
// double quotes into backticks before the TEXT rules run. The pattern only
|
||||
// matched a bare name, so every column in the baseline stayed TEXT — and MySQL
|
||||
// then refused the schema in two different ways, neither naming the real cause:
|
||||
//
|
||||
// CREATE INDEX … ON qso(state) → 1170 BLOB/TEXT column used in key
|
||||
// specification without a key length
|
||||
// op_name TEXT … DEFAULT '' → 1101 TEXT column can't have a default value
|
||||
//
|
||||
// The operator saw only "MySQL is enabled but the connection failed at startup",
|
||||
// and fell back to the local SQLite database.
|
||||
func TestMySQLDDL_QuotedTextColumns(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, in, wantHas string
|
||||
}{
|
||||
{
|
||||
// An indexed column must become VARCHAR, quoted or not.
|
||||
name: "backticked indexed column",
|
||||
in: "CREATE TABLE qso (`state` TEXT);",
|
||||
wantHas: "`state` VARCHAR(255)",
|
||||
},
|
||||
{
|
||||
name: "bare indexed column still works",
|
||||
in: "ALTER TABLE qso ADD COLUMN state TEXT;",
|
||||
wantHas: "state VARCHAR(255)",
|
||||
},
|
||||
{
|
||||
// A default-bearing column must become VARCHAR, or MySQL rejects it.
|
||||
name: "double-quoted column with a default",
|
||||
in: `CREATE TABLE station_profiles ("op_name" TEXT NOT NULL DEFAULT '');`,
|
||||
wantHas: "`op_name` VARCHAR(255)",
|
||||
},
|
||||
{
|
||||
// And a plain unindexed column stays TEXT: VARCHAR everywhere would
|
||||
// blow past MySQL's row-size limit, which is why the rule exists.
|
||||
name: "unindexed column stays TEXT",
|
||||
in: "CREATE TABLE qso (`comment` TEXT);",
|
||||
wantHas: "`comment` TEXT",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := mysqlDDL(c.in)
|
||||
if !strings.Contains(got, c.wantHas) {
|
||||
t.Errorf("%s:\n in %s\n got %s\n want it to contain %q", c.name, c.in, got, c.wantHas)
|
||||
}
|
||||
// The quoting must survive: a lost backtick is a syntax error further on.
|
||||
if strings.Count(got, "`")%2 != 0 {
|
||||
t.Errorf("%s: unbalanced backticks in %s", c.name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A CREATE TABLE with several columns on ONE line — the shape sqlite_master
|
||||
// stores once columns have been added by ALTER TABLE.
|
||||
//
|
||||
// The old translation converted only the first column per line, so on a real
|
||||
// logbook the baseline kept most of its columns as TEXT and MySQL refused the
|
||||
// schema: "column 'op_name' can't have a default value". Our own migration files
|
||||
// hide this — they put one column per line — which is why it only ever appeared
|
||||
// against a database that had been through upgrades.
|
||||
func TestMySQLDDL_ManyColumnsOnOneLine(t *testing.T) {
|
||||
in := "CREATE TABLE station_profiles (id INTEGER PRIMARY KEY, `name` TEXT, " +
|
||||
"`op_name` TEXT NOT NULL DEFAULT '', `comment` TEXT, `state` TEXT);"
|
||||
got := mysqlDDL(in)
|
||||
|
||||
// Default-bearing and indexed columns must be VARCHAR…
|
||||
for _, want := range []string{"`op_name` VARCHAR(255)", "`state` VARCHAR(255)"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in:\n %s", want, got)
|
||||
}
|
||||
}
|
||||
// …while the plain ones stay TEXT, or the row-size limit is breached.
|
||||
for _, want := range []string{"`name` TEXT", "`comment` TEXT"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in:\n %s", want, got)
|
||||
}
|
||||
}
|
||||
// No column may be left as a bare TEXT that carries a default.
|
||||
if strings.Contains(got, "TEXT NOT NULL DEFAULT") {
|
||||
t.Errorf("a TEXT column still carries a DEFAULT — MySQL rejects that:\n %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A neighbour's DEFAULT must not decide this column's type: deciding per LINE
|
||||
// made every column on a shared line VARCHAR as soon as one of them had a
|
||||
// default, which is how the row-size limit gets breached quietly.
|
||||
func TestMySQLDDL_DefaultDoesNotLeakAcrossColumns(t *testing.T) {
|
||||
in := "CREATE TABLE t (`a` TEXT NOT NULL DEFAULT '', `b` TEXT);"
|
||||
got := mysqlDDL(in)
|
||||
if !strings.Contains(got, "`a` VARCHAR(255)") {
|
||||
t.Errorf("the column WITH the default should be VARCHAR:\n %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "`b` TEXT") {
|
||||
t.Errorf("the column without one should stay TEXT:\n %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
@@ -14,6 +16,14 @@ import (
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
// remoteFreqRe / remoteModeRe pull the optional tune request out of a
|
||||
// ServiceRemoteCall packet: "<FREQ>10.136" (MHz) and "<MODE>FT8". Both accept
|
||||
// an optional closing tag for proper-XML senders.
|
||||
var (
|
||||
remoteFreqRe = regexp.MustCompile(`(?i)<FREQ>\s*([0-9]+(?:\.[0-9]+)?)`)
|
||||
remoteModeRe = regexp.MustCompile(`(?i)<MODE>\s*([A-Z0-9-]+)`)
|
||||
)
|
||||
|
||||
// reusingListenConfig builds a net.ListenConfig that sets SO_REUSEADDR
|
||||
// (and SO_REUSEPORT on Unix) on the underlying socket before bind. This
|
||||
// is the only way for two processes to share a UDP port on Windows — Go
|
||||
@@ -57,6 +67,13 @@ type Event struct {
|
||||
// Call after previously reporting one — i.e. the operator cleared the call in
|
||||
// the digital app. OpsLog clears its entry to match.
|
||||
ClearCall bool
|
||||
|
||||
// TuneFreqHz / TuneMode carry an explicit "tune the radio" request embedded
|
||||
// in a ServiceRemoteCall packet (DXHunter spot click sends
|
||||
// "<CALLSIGN>VP5G<FREQ>10.136<MODE>FT8"). Zero/empty = no tune requested —
|
||||
// the packet only fills the entry callsign, exactly as before.
|
||||
TuneFreqHz int64
|
||||
TuneMode string
|
||||
}
|
||||
|
||||
// Server is a single inbound UDP listener.
|
||||
@@ -248,9 +265,25 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
// "CALL F4XYZ" (text prefix)
|
||||
// "<CALLSIGN>F4XYZ<CALLSIGN>" (DXHunter-style tags)
|
||||
// "<CALLSIGN>F4XYZ</CALLSIGN>" (proper XML)
|
||||
// "<CALLSIGN>VP5G<FREQ>10.136<MODE>FT8" (DXHunter with CAT tune)
|
||||
// Strip every angle-bracket tag, normalise whitespace, take the
|
||||
// last non-empty token. Upper-case for downstream consistency.
|
||||
text := string(pkt)
|
||||
// Optional tune request: <FREQ>MHz and <MODE>str ride along with the
|
||||
// callsign so a DXHunter spot click can drive OpsLog's CAT. Extract
|
||||
// (and cut) them BEFORE the generic tag-stripping below, which would
|
||||
// otherwise leave their values as stray tokens and corrupt the
|
||||
// "last token = callsign" heuristic.
|
||||
if m := remoteFreqRe.FindStringSubmatch(text); m != nil {
|
||||
if mhz, err := strconv.ParseFloat(m[1], 64); err == nil && mhz > 0 {
|
||||
ev.TuneFreqHz = int64(mhz * 1e6)
|
||||
}
|
||||
text = strings.Replace(text, m[0], " ", 1)
|
||||
}
|
||||
if m := remoteModeRe.FindStringSubmatch(text); m != nil {
|
||||
ev.TuneMode = strings.ToUpper(m[1])
|
||||
text = strings.Replace(text, m[0], " ", 1)
|
||||
}
|
||||
// Drop every <...> tag (open or close) — works for both
|
||||
// <CALLSIGN>...<CALLSIGN> and <CALLSIGN>...</CALLSIGN>.
|
||||
for {
|
||||
@@ -286,8 +319,9 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
return
|
||||
}
|
||||
// Empty events are useless; skip — EXCEPT a clear signal, which is meant to be
|
||||
// empty (the DX Call was cleared in the digital app).
|
||||
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" && !ev.ClearCall {
|
||||
// empty (the DX Call was cleared in the digital app), and a tune-only
|
||||
// request (freq with no callsign).
|
||||
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" && !ev.ClearCall && ev.TuneFreqHz == 0 {
|
||||
return
|
||||
}
|
||||
select {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// Package gs232 drives rotator controllers that speak the Yaesu GS-232A
|
||||
// protocol, over a raw TCP socket or a serial COM port. The target device is
|
||||
// the microHAM ARCO: both its LAN "CONTROL PROTOCOL" setting (a TCP port) and
|
||||
// protocol, over a raw TCP socket or a serial COM port.
|
||||
//
|
||||
// Any controller set to GS-232A works, which is most of them: the microHAM ARCO
|
||||
// natively, and the ERC family (Easy Rotor Control — ERC Mini, ERC interface),
|
||||
// which EMULATES GS-232A/B over its USB port. An ERC can also be configured for
|
||||
// Hy-Gain DCU-1, a different command set entirely, so it must be set to GS-232.
|
||||
//
|
||||
// On the ARCO: both its LAN "CONTROL PROTOCOL" setting (a TCP port) and
|
||||
// its USB port ("USB CONTROL PROTOCOL", a virtual COM where the baud rate is
|
||||
// irrelevant) can be set to speak Yaesu GS-232A — so OpsLog controls it
|
||||
// directly, no PstRotator in between. ARCO accepts up to four parallel LAN
|
||||
@@ -37,7 +43,12 @@ const (
|
||||
type Client struct {
|
||||
Host string
|
||||
Port int
|
||||
ComPort string // serial transport: "COM5" etc. (ARCO USB virtual COM — any baud)
|
||||
ComPort string // serial transport: "COM5" etc.
|
||||
// Baud matters on some controllers. An ARCO's USB virtual COM ignores it, but
|
||||
// an ERC (Easy Rotor Control) runs at whatever rate its own configuration
|
||||
// sets — commonly 9600 or 19200 — and a mismatch reads as a dead rotator.
|
||||
// Zero keeps the historical 9600.
|
||||
Baud int
|
||||
}
|
||||
|
||||
// New returns a TCP Client with sane defaults applied for empty fields. There
|
||||
@@ -56,8 +67,8 @@ func New(host string, port int) *Client {
|
||||
// NewSerial returns a Client talking over the ARCO's USB virtual COM port. The
|
||||
// baud rate is irrelevant on USB per the ARCO manual (8N1 framing matters); we
|
||||
// open at 9600 which also suits a real RS-232 hookup left at its default.
|
||||
func NewSerial(comPort string) *Client {
|
||||
return &Client{ComPort: comPort}
|
||||
func NewSerial(comPort string, baud int) *Client {
|
||||
return &Client{ComPort: comPort, Baud: baud}
|
||||
}
|
||||
|
||||
// roundTrip opens a connection (TCP or serial per the client's config), sends
|
||||
@@ -66,9 +77,13 @@ func NewSerial(comPort string) *Client {
|
||||
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
|
||||
var conn io.ReadWriteCloser
|
||||
if c.ComPort != "" {
|
||||
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: 9600})
|
||||
baud := c.Baud
|
||||
if baud <= 0 {
|
||||
baud = 9600
|
||||
}
|
||||
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: baud})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open ARCO %s: %w", c.ComPort, err)
|
||||
return "", fmt.Errorf("open rotator %s @ %d baud: %w", c.ComPort, baud, err)
|
||||
}
|
||||
_ = sp.SetReadTimeout(200 * time.Millisecond)
|
||||
conn = sp
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/adif"
|
||||
)
|
||||
|
||||
// What counts as a QRZ.com confirmation.
|
||||
//
|
||||
// Reported 2026-07-29: after a confirmation download, every QSO turned to Y. The
|
||||
// test accepted qsl_rcvd = Y — but that is the operator's own PAPER QSL flag,
|
||||
// which they uploaded to QRZ themselves, so every QSO with a card came back
|
||||
// looking QRZ-confirmed. On a log full of paper QSLs that is most of it.
|
||||
//
|
||||
// This status feeds the award slots, so a false Y is a QSO counted as confirmed
|
||||
// when it is not — the reason the rule is narrow rather than generous.
|
||||
func TestQRZRecordConfirmed(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rec adif.Record
|
||||
want bool
|
||||
}{
|
||||
// QRZ's own statements.
|
||||
{"QRZ download status Y", adif.Record{"qrzcom_qso_download_status": "Y"}, true},
|
||||
{"QRZ log status C", adif.Record{"app_qrzlog_status": "C"}, true},
|
||||
{"lower case is still QRZ's answer", adif.Record{"app_qrzlog_status": "c"}, true},
|
||||
{"padded", adif.Record{"app_qrzlog_status": " C "}, true},
|
||||
|
||||
// NOT confirmations by QRZ — the bug being fixed.
|
||||
{"paper QSL received", adif.Record{"qsl_rcvd": "Y"}, false},
|
||||
{"paper QSL plus a card sent", adif.Record{"qsl_rcvd": "Y", "qsl_sent": "Y"}, false},
|
||||
{"LoTW confirmed, QRZ silent", adif.Record{"lotw_qsl_rcvd": "Y"}, false},
|
||||
{"eQSL confirmed, QRZ silent", adif.Record{"eqsl_qsl_rcvd": "Y"}, false},
|
||||
|
||||
// Explicit negatives and nothing at all.
|
||||
{"QRZ says no", adif.Record{"qrzcom_qso_download_status": "N"}, false},
|
||||
{"QRZ status not confirmed", adif.Record{"app_qrzlog_status": "N"}, false},
|
||||
{"bare record", adif.Record{"call": "F4XYZ"}, false},
|
||||
{"empty", adif.Record{}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := qrzRecordConfirmed(c.rec); got != c.want {
|
||||
t.Errorf("%s: qrzRecordConfirmed = %v, want %v (%v)", c.name, got, c.want, c.rec)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.22.0"
|
||||
appVersion = "0.22.2"
|
||||
|
||||
// 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