Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d98bb2e929 | ||
|
|
3ab6b95ff8 | ||
|
|
b0da6a3d14 | ||
|
|
2d7469a7f7 | ||
|
|
775f411606 | ||
|
|
6501e97895 | ||
|
|
f3807c21ba | ||
|
|
d536c39ef5 | ||
|
|
284a7a18c7 | ||
|
|
482f81fe45 | ||
|
|
5d7a9a9562 | ||
|
|
a036120871 | ||
|
|
ccceab9d02 | ||
|
|
35db1440e4 | ||
|
|
e3aabc06a4 | ||
|
|
ab9d0bfe0f | ||
|
|
a3fd32ec91 | ||
|
|
c297f91ca8 | ||
|
|
ee1f9ccf35 | ||
|
|
6fb7f06375 | ||
|
|
54caee37c4 | ||
|
|
15fdbce22a | ||
|
|
c7737aabd6 | ||
|
|
6f5acc2eef | ||
|
|
67a03c3ddc | ||
|
|
8da3a27803 | ||
|
|
e0cefb5c41 | ||
|
|
113faede14 | ||
|
|
8e491544dd | ||
|
|
cbe571a742 | ||
|
|
bdda7ad07a | ||
|
|
3fd6763ff0 | ||
|
|
a89c6e0df8 | ||
|
|
48d92ff547 | ||
|
|
adadb632fa | ||
|
|
b0b25c7f1f | ||
|
|
e120bd4f04 | ||
|
|
40df4fe22f | ||
|
|
d114a73729 | ||
|
|
d0666581c3 | ||
|
|
37dc2a07e5 | ||
|
|
7424bc6e81 | ||
|
|
01c93d979f | ||
|
|
0190255762 | ||
|
|
0cc806722e | ||
|
|
08f3e54afb | ||
|
|
520d17810c | ||
|
|
c9218310ae | ||
|
|
8cc6997eec | ||
|
|
6d309cada1 | ||
|
|
7153768579 | ||
|
|
df4155108f | ||
|
|
9bc5a14c69 | ||
|
|
842d4708a7 | ||
|
|
e2aba828a9 | ||
|
|
753d8d2ffa | ||
|
|
38b480a985 | ||
|
|
67005a8d50 | ||
|
|
9cfa7a4dd1 | ||
|
|
a1c4305f20 | ||
|
|
3b296b19ab | ||
|
|
8a0d76fa0c | ||
|
|
bd5f9c0746 | ||
|
|
29591c5f0c | ||
|
|
01bcf256e2 | ||
|
|
c9d1fc1cc0 | ||
|
|
f43d41892e | ||
|
|
1f2e86c04d | ||
|
|
305da583e1 | ||
|
|
71bde30e24 | ||
|
|
31b13cfbc0 | ||
|
|
30d88276f3 | ||
|
|
b18db7acfc | ||
|
|
d303dee768 | ||
|
|
ad02583ef7 | ||
|
|
add4b175fc | ||
|
|
11940ae843 | ||
|
|
91f046444a | ||
|
|
386fb7f030 | ||
|
|
5a6db2b656 | ||
|
|
c3958be0b2 | ||
|
|
a42a3d5713 | ||
|
|
0a1569dbbd | ||
|
|
843dccf0c4 | ||
|
|
d49126d45c | ||
|
|
cc1ad06a9d |
@@ -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.
|
||||
|
||||
@@ -51,6 +51,7 @@ import (
|
||||
"hamlog/internal/qslcard"
|
||||
"hamlog/internal/qso"
|
||||
"hamlog/internal/relaydev"
|
||||
"hamlog/internal/rigctld"
|
||||
"hamlog/internal/rotator/gs232"
|
||||
"hamlog/internal/rotator/pst"
|
||||
"hamlog/internal/rotgenius"
|
||||
@@ -95,9 +96,11 @@ const (
|
||||
keyListsRSTCW = "lists.rst_cw"
|
||||
keyListsRSTDigital = "lists.rst_digital"
|
||||
|
||||
keyCATEnabled = "cat.enabled"
|
||||
keyCATBackend = "cat.backend" // "omnirig" | "flex"
|
||||
keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2
|
||||
keyCATEnabled = "cat.enabled"
|
||||
keyCATBackend = "cat.backend" // "omnirig" | "flex"
|
||||
keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2
|
||||
// Which VFO to believe when OmniRig names one. "" = trust the rig file.
|
||||
keyCATOmniRigVFO = "cat.omnirig.vfo" // "" | "A" | "B"
|
||||
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
|
||||
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
|
||||
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
|
||||
@@ -106,6 +109,13 @@ const (
|
||||
keyCATPollMs = "cat.poll_ms"
|
||||
keyCATDelayMs = "cat.delay_ms" // pause between commands
|
||||
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA
|
||||
keyCATShareEnabled = "cat.share.enabled" // expose CAT to other programs (Hamlib NET rigctl)
|
||||
keyCATSharePort = "cat.share.port" // TCP port for that server (rigctld default 4532)
|
||||
keyCATXieguPort = "cat.xiegu.port" // Xiegu CI-V serial port (G90/X6100…)
|
||||
keyCATXieguBaud = "cat.xiegu.baud" // Xiegu CI-V baud (G90 default 19200)
|
||||
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)
|
||||
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)
|
||||
@@ -127,6 +137,7 @@ const (
|
||||
keyAudioQSORecord = "audio.qso_record" // "1" → auto-record every QSO
|
||||
keyAudioQSODir = "audio.qso_dir" // folder for QSO recordings
|
||||
keyAudioPreroll = "audio.preroll_seconds" // rolling-buffer pre-roll length
|
||||
keyAudioTXGain = "audio.tx_gain" // voice-keyer playback level % (100 = as recorded)
|
||||
keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr"
|
||||
keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT
|
||||
keyAudioFormat = "audio.qso_format" // "wav" | "mp3"
|
||||
@@ -327,14 +338,24 @@ type QSLDefaults struct {
|
||||
// CATSettings is the user-tweakable rig-control configuration. Stored as
|
||||
// individual key/value pairs to keep the settings table flat.
|
||||
type CATSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Backend string `json:"backend"` // "omnirig" | "flex" | "icom" | "icom-net" | "tci"
|
||||
OmniRigNum int `json:"omnirig_rig"` // 1 or 2 (OmniRig "Rig1"/"Rig2" slot)
|
||||
Enabled bool `json:"enabled"`
|
||||
Backend string `json:"backend"` // "omnirig" | "flex" | "icom" | "icom-net" | "tci"
|
||||
OmniRigNum int `json:"omnirig_rig"` // 1 or 2 (OmniRig "Rig1"/"Rig2" slot)
|
||||
// OmniRigVFO overrides which VFO OpsLog reads: "" follows what the rig file
|
||||
// reports, "A"/"B" force one. Needed because that report is only as good as
|
||||
// the .ini: an IC-7610 file was seen declaring VFO B permanently while the
|
||||
// operator worked on the main VFO, so OpsLog wrote to A and read B.
|
||||
OmniRigVFO string `json:"omnirig_vfo"` // "" | "A" | "B"
|
||||
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
|
||||
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
|
||||
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
|
||||
FlexDecodeSpots bool `json:"flex_decode_spots"` // push WSJT-X decodes (heard stations) to the panadapter
|
||||
FlexDecodeSecs int `json:"flex_decode_secs"` // decode spot display duration (s) before removal (default 120)
|
||||
XieguPort string `json:"xiegu_port"` // Xiegu CI-V serial port (G90/X6100…)
|
||||
XieguBaud int `json:"xiegu_baud"` // Xiegu CI-V baud (G90 default 19200)
|
||||
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)
|
||||
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)
|
||||
@@ -348,6 +369,8 @@ type CATSettings struct {
|
||||
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
|
||||
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
|
||||
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
|
||||
ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs (Hamlib NET rigctl)
|
||||
SharePort int `json:"share_port"` // TCP port for it (default 4532)
|
||||
}
|
||||
|
||||
// ModePreset is a mode entry with default RST values to auto-populate
|
||||
@@ -463,6 +486,11 @@ type App struct {
|
||||
lookup *lookup.Manager
|
||||
cache *lookup.Cache
|
||||
cat *cat.Manager
|
||||
// catShare serves OpsLog's CAT link to other programs over the Hamlib NET
|
||||
// rigctl protocol. It exists because a native backend OWNS the rig's serial
|
||||
// port: without it, choosing native CAT locks WSJT-X and friends out of the
|
||||
// radio entirely. nil when the operator has not enabled sharing.
|
||||
catShare *rigctld.Server
|
||||
dxcc *dxcc.Manager
|
||||
cluster *cluster.Manager
|
||||
// Cluster spots/lines are processed OFF the socket-read goroutine. Enriching a
|
||||
@@ -1420,6 +1448,12 @@ func (a *App) shutdown(ctx context.Context) {
|
||||
// backend: without this the rig never gets a disconnect and holds its single
|
||||
// control session for minutes, refusing every new login (even from the Icom
|
||||
// Remote Utility) until it times out on its own.
|
||||
if a.catShare != nil {
|
||||
// Before the CAT stop, so no client is mid-command against a backend that
|
||||
// is disconnecting — and so the port is free for the next launch.
|
||||
a.catShare.Stop()
|
||||
a.catShare = nil
|
||||
}
|
||||
if a.cat != nil {
|
||||
a.cat.Stop()
|
||||
}
|
||||
@@ -1646,6 +1680,11 @@ func (a *App) saveWindowState() {
|
||||
x, y := wruntime.WindowGetPosition(a.ctx)
|
||||
ws.X, ws.Y = x, y
|
||||
}
|
||||
// Logged because this is the only evidence of WHAT was stored: an operator
|
||||
// reporting "it reopens on the wrong screen" cannot tell a position that was
|
||||
// never captured from one that was captured wrong, and the two need opposite
|
||||
// fixes.
|
||||
applog.Printf("window: saving %d,%d %dx%d maximised=%v compact=%v", ws.X, ws.Y, ws.Width, ws.Height, ws.Maximised, a.compact)
|
||||
writeWindowState(a.dataDir, ws)
|
||||
}
|
||||
|
||||
@@ -1660,15 +1699,20 @@ func (a *App) restoreWindowPosition() {
|
||||
}
|
||||
ws, ok := readWindowState(a.dataDir)
|
||||
if !ok {
|
||||
applog.Printf("window: no saved geometry — opening where Windows puts it")
|
||||
return
|
||||
}
|
||||
applog.Printf("window: restoring %d,%d %dx%d maximised=%v", ws.X, ws.Y, ws.Width, ws.Height, ws.Maximised)
|
||||
// Maximised: Windows reopens it on the PRIMARY screen unless we say otherwise,
|
||||
// so a two-screen operator lost OpsLog to the wrong monitor at every launch.
|
||||
// Un-maximise, move to the saved corner (which names the monitor), maximise
|
||||
// again — all while the window is still hidden, so nothing flickers.
|
||||
if ws.Maximised {
|
||||
if ws.X == 0 && ws.Y == 0 {
|
||||
return // never recorded (or genuinely the primary corner) — nothing to do
|
||||
// Either never recorded, or genuinely the primary monitor's corner —
|
||||
// indistinguishable, and both mean "leave it to Windows".
|
||||
applog.Printf("window: maximised with corner 0,0 — nothing to restore")
|
||||
return
|
||||
}
|
||||
if !onSomeMonitor(ws.X, ws.Y, normalMinW, normalMinH) {
|
||||
applog.Printf("window: saved maximised corner %d,%d is off every monitor — opening where Windows puts it", ws.X, ws.Y)
|
||||
@@ -1677,6 +1721,8 @@ func (a *App) restoreWindowPosition() {
|
||||
wruntime.WindowUnmaximise(a.ctx)
|
||||
wruntime.WindowSetPosition(a.ctx, ws.X, ws.Y)
|
||||
wruntime.WindowMaximise(a.ctx)
|
||||
gx, gy := wruntime.WindowGetPosition(a.ctx)
|
||||
applog.Printf("window: re-maximised at the saved corner — now at %d,%d", gx, gy)
|
||||
return
|
||||
}
|
||||
if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH {
|
||||
@@ -3839,7 +3885,9 @@ type AwardStatsResult struct {
|
||||
Rows []AwardStatRow `json:"rows"`
|
||||
}
|
||||
|
||||
var statsBands = []string{"160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "2m", "1.25m", "70cm", "23cm", "13cm"}
|
||||
var statsBands = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m",
|
||||
"6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm", "9cm", "6cm", "3cm", "1.25cm",
|
||||
"6mm", "4mm", "2.5mm", "2mm", "1mm"}
|
||||
|
||||
// awardSnapshot returns the logbook as a light-scanned, award-enriched slice,
|
||||
// reused across award computations. Pulling the whole logbook is the dominant
|
||||
@@ -4169,7 +4217,11 @@ var awardBandPlan = []struct {
|
||||
{"15m", 21000000, 21450000}, {"12m", 24890000, 24990000}, {"10m", 28000000, 29700000},
|
||||
{"6m", 50000000, 54000000}, {"4m", 70000000, 71000000}, {"2m", 144000000, 148000000},
|
||||
{"1.25m", 222000000, 225000000}, {"70cm", 420000000, 450000000}, {"23cm", 1240000000, 1300000000},
|
||||
{"13cm", 2300000000, 2450000000},
|
||||
{"13cm", 2300000000, 2450000000}, {"9cm", 3300000000, 3500000000},
|
||||
{"6cm", 5650000000, 5925000000}, {"3cm", 10000000000, 10500000000},
|
||||
{"1.25cm", 24000000000, 24250000000}, {"6mm", 47000000000, 47200000000},
|
||||
{"4mm", 75500000000, 81000000000}, {"2.5mm", 119980000000, 123000000000},
|
||||
{"2mm", 134000000000, 149000000000}, {"1mm", 241000000000, 250000000000},
|
||||
}
|
||||
|
||||
func bandForHz(hz int64) string {
|
||||
@@ -5623,6 +5675,7 @@ var bulkFieldColumns = map[string]string{
|
||||
"sat_name": "sat_name",
|
||||
"sat_mode": "sat_mode",
|
||||
// Contacted station location + activation refs / SIG
|
||||
"grid": "grid",
|
||||
"state": "state",
|
||||
"cnty": "cnty",
|
||||
"pota_ref": "pota_ref",
|
||||
@@ -6266,6 +6319,22 @@ func (a *App) SaveCabrilloFile() (string, error) {
|
||||
// Errors are returned as-is to the frontend; ErrNotFound surfaces as
|
||||
// "callsign not found".
|
||||
func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
|
||||
return a.lookupCallsign(callsign, false)
|
||||
}
|
||||
|
||||
// LookupCallsignFresh is the same, but SKIPS the cache and refreshes it.
|
||||
//
|
||||
// For a lookup the operator asked for by clicking, in the QSO editor. The cache
|
||||
// is right for the automatic lookup that fires while typing, but it also freezes
|
||||
// a wrong answer for its whole 30-day life: an operator who upgraded their QRZ
|
||||
// subscription went on getting the thin free-account record, and deleting the
|
||||
// cached row by hand was the only way out. A deliberate click must reach the
|
||||
// provider and overwrite what was stored.
|
||||
func (a *App) LookupCallsignFresh(callsign string) (lookup.Result, error) {
|
||||
return a.lookupCallsign(callsign, true)
|
||||
}
|
||||
|
||||
func (a *App) lookupCallsign(callsign string, force bool) (lookup.Result, error) {
|
||||
if a.lookup == nil {
|
||||
return lookup.Result{}, fmt.Errorf("lookup not initialized")
|
||||
}
|
||||
@@ -6287,6 +6356,16 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(a.ctx, budget)
|
||||
defer cancel()
|
||||
if force {
|
||||
// A forced lookup has to reach the network, so it gets a longer leash than
|
||||
// the type-ahead one: giving up at 2 s would just fall back to cty.dat and
|
||||
// look like the click did nothing.
|
||||
cancel()
|
||||
ctx, cancel = context.WithTimeout(a.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
ctx = lookup.WithForce(ctx)
|
||||
applog.Printf("lookup: FORCED (cache bypassed) for %s", strings.ToUpper(strings.TrimSpace(callsign)))
|
||||
}
|
||||
r, err := a.lookup.Lookup(ctx, callsign)
|
||||
if errors.Is(err, lookup.ErrNotFound) {
|
||||
return lookup.Result{}, fmt.Errorf("callsign not found")
|
||||
@@ -6319,9 +6398,75 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
a.enrichFromULS(&r, callsign)
|
||||
return r, err
|
||||
}
|
||||
|
||||
// enrichFromULS fills a US station's state, county and grid from the offline FCC
|
||||
// database while the operator is still typing.
|
||||
//
|
||||
// The ULS data was only ever applied at SAVE time (applyULSCounty), so an
|
||||
// operator with the database downloaded and no QRZ.com/HamQTH account saw
|
||||
// nothing but cty.dat during entry — country, zones, and a 4-character grid that
|
||||
// is the ENTITY centroid, thousands of km off. The county and a 6-character grid
|
||||
// were stamped silently after logging, too late to steer an antenna by.
|
||||
//
|
||||
// It runs LAST and only fills blanks, so an online provider always wins: ULS
|
||||
// holds no name and no address, so it can complete a QRZ record but never
|
||||
// replace one. The grid goes through refineGrid, which accepts the ULS square
|
||||
// only when it EXTENDS what is already there (JN → JN05JG) and never when it
|
||||
// would contradict it.
|
||||
func (a *App) enrichFromULS(r *lookup.Result, callsign string) {
|
||||
if a.uls == nil {
|
||||
return
|
||||
}
|
||||
switch r.DXCC {
|
||||
case 291, 110, 6: // United States, Hawaii, Alaska
|
||||
default:
|
||||
return
|
||||
}
|
||||
call := strings.ToUpper(strings.TrimSpace(callsign))
|
||||
if r.Callsign != "" {
|
||||
call = strings.ToUpper(strings.TrimSpace(r.Callsign))
|
||||
}
|
||||
if call == "" || strings.Contains(call, "/") {
|
||||
// A portable call is not what the FCC licensed — W1AW/4 is not a row.
|
||||
return
|
||||
}
|
||||
loc, ok := a.uls.Resolve(call)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(r.State) == "" {
|
||||
r.State = loc.State
|
||||
}
|
||||
if strings.TrimSpace(r.County) == "" {
|
||||
r.County = loc.CNTY()
|
||||
}
|
||||
// Grid. refineGrid keeps the ULS square when it extends what is there. It
|
||||
// does NOT cover the case this feature exists for: cty.dat's 4-character
|
||||
// square is the ENTITY centroid, so for a US call it is usually a different
|
||||
// square altogether — refineGrid would keep it, and the operator would go on
|
||||
// pointing an antenna at the middle of the country. A per-callsign ULS square
|
||||
// beats any 4-character one, whatever its letters; a 6-character grid already
|
||||
// present (QRZ) is left alone.
|
||||
newGrid := ""
|
||||
if g := refineGrid(r.Grid, loc.Grid); g != "" && g != r.Grid {
|
||||
newGrid = g
|
||||
} else if len(strings.TrimSpace(r.Grid)) <= 4 && len(strings.TrimSpace(loc.Grid)) >= 6 {
|
||||
newGrid = loc.Grid
|
||||
}
|
||||
if newGrid != "" {
|
||||
r.Grid = newGrid
|
||||
// Lat/lon follow, or distance and azimuth would still be computed from the
|
||||
// centroid the grid no longer says. Only when WE changed the grid: a
|
||||
// provider's own coordinates are more precise than a square's centre.
|
||||
if lat, lon, ok := gridToLatLon(newGrid); ok {
|
||||
r.Lat, r.Lon = lat, lon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OpenExternalURL opens a URL in the user's default browser. Wails ships
|
||||
// runtime.BrowserOpenURL for exactly this — used by the QRZ.com icon
|
||||
// next to the callsign field, the future Clublog/HamQTH shortcuts, etc.
|
||||
@@ -6444,7 +6589,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, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
|
||||
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)
|
||||
if err != nil {
|
||||
return CATSettings{}, err
|
||||
}
|
||||
@@ -6457,6 +6602,11 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
FlexSpots: m[keyCATFlexSpots] == "1",
|
||||
FlexDecodeSpots: m[keyCATFlexDecodeSpots] == "1",
|
||||
FlexDecodeSecs: 120,
|
||||
XieguPort: m[keyCATXieguPort],
|
||||
XieguBaud: 19200,
|
||||
XieguAddr: cat.XieguDefaultAddr,
|
||||
YaesuPort: m[keyCATYaesuPort],
|
||||
YaesuBaud: 38400,
|
||||
IcomPort: m[keyCATIcomPort],
|
||||
IcomBaud: 115200,
|
||||
IcomAddr: 0x98, // IC-7610 default
|
||||
@@ -6470,6 +6620,8 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
PollMs: 250,
|
||||
DelayMs: 0,
|
||||
DigitalDefault: m[keyCATDigitalDefault],
|
||||
ShareEnabled: m[keyCATShareEnabled] == "1",
|
||||
SharePort: 4532,
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
|
||||
out.FlexPort = n
|
||||
@@ -6480,6 +6632,18 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
if n, _ := strconv.Atoi(m[keyCATTCIPort]); n > 0 && n <= 65535 {
|
||||
out.TCIPort = n
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATSharePort]); n > 0 && n <= 65535 {
|
||||
out.SharePort = n
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATXieguBaud]); n > 0 {
|
||||
out.XieguBaud = n
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATXieguAddr]); n > 0 && n <= 0xFF {
|
||||
out.XieguAddr = n
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATYaesuBaud]); n > 0 {
|
||||
out.YaesuBaud = n
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 {
|
||||
out.IcomBaud = n
|
||||
}
|
||||
@@ -6492,6 +6656,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
if out.DigitalDefault == "" {
|
||||
out.DigitalDefault = "FT8"
|
||||
}
|
||||
if v := strings.ToUpper(strings.TrimSpace(m[keyCATOmniRigVFO])); v == "A" || v == "B" {
|
||||
out.OmniRigVFO = v
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATOmniRigNum]); n == 1 || n == 2 {
|
||||
out.OmniRigNum = n
|
||||
}
|
||||
@@ -6518,6 +6685,18 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
if s.FlexPort <= 0 || s.FlexPort > 65535 {
|
||||
s.FlexPort = 4992
|
||||
}
|
||||
if s.SharePort <= 0 || s.SharePort > 65535 {
|
||||
s.SharePort = 4532
|
||||
}
|
||||
if s.XieguBaud <= 0 {
|
||||
s.XieguBaud = 19200
|
||||
}
|
||||
if s.XieguAddr <= 0 || s.XieguAddr > 0xFF {
|
||||
s.XieguAddr = cat.XieguDefaultAddr
|
||||
}
|
||||
if s.YaesuBaud <= 0 {
|
||||
s.YaesuBaud = 38400
|
||||
}
|
||||
if s.IcomBaud <= 0 {
|
||||
s.IcomBaud = 115200
|
||||
}
|
||||
@@ -6556,15 +6735,25 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
if s.DigitalDefault == "" {
|
||||
s.DigitalDefault = "FT8"
|
||||
}
|
||||
shareEnabled := "0"
|
||||
if s.ShareEnabled {
|
||||
shareEnabled = "1"
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
keyCATEnabled: enabled,
|
||||
keyCATBackend: s.Backend,
|
||||
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
|
||||
keyCATOmniRigVFO: strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)),
|
||||
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
|
||||
keyCATFlexPort: strconv.Itoa(s.FlexPort),
|
||||
keyCATFlexSpots: flexSpots,
|
||||
keyCATFlexDecodeSpots: flexDecodeSpots,
|
||||
keyCATFlexDecodeSecs: strconv.Itoa(s.FlexDecodeSecs),
|
||||
keyCATXieguPort: strings.TrimSpace(s.XieguPort),
|
||||
keyCATXieguBaud: strconv.Itoa(s.XieguBaud),
|
||||
keyCATXieguAddr: strconv.Itoa(s.XieguAddr),
|
||||
keyCATYaesuPort: strings.TrimSpace(s.YaesuPort),
|
||||
keyCATYaesuBaud: strconv.Itoa(s.YaesuBaud),
|
||||
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
|
||||
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
|
||||
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
|
||||
@@ -6578,6 +6767,8 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
keyCATPollMs: strconv.Itoa(s.PollMs),
|
||||
keyCATDelayMs: strconv.Itoa(s.DelayMs),
|
||||
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
|
||||
keyCATShareEnabled: shareEnabled,
|
||||
keyCATSharePort: strconv.Itoa(s.SharePort),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
@@ -6604,6 +6795,7 @@ type AudioSettings struct {
|
||||
Format string `json:"format"` // "wav" | "mp3"
|
||||
FromGain int `json:"from_gain"` // From Radio (RX) mix level %, default 100
|
||||
MicGain int `json:"mic_gain"` // mic mix level %, default 100
|
||||
TXGain int `json:"tx_gain"` // voice-keyer playback level %, default 100
|
||||
}
|
||||
|
||||
// ListAudioInputDevices / ListAudioOutputDevices enumerate WASAPI endpoints
|
||||
@@ -6613,14 +6805,14 @@ func (a *App) ListAudioOutputDevices() ([]audio.Device, error) { return audio.Li
|
||||
|
||||
// GetAudioSettings returns the stored audio config (preroll defaults to 8s).
|
||||
func (a *App) GetAudioSettings() (AudioSettings, error) {
|
||||
out := AudioSettings{PrerollSeconds: 8, PTTMethod: "none", Format: "wav", FromGain: 100, MicGain: 100}
|
||||
out := AudioSettings{PrerollSeconds: 8, PTTMethod: "none", Format: "wav", FromGain: 100, MicGain: 100, TXGain: 100}
|
||||
if a.settings == nil {
|
||||
return out, nil
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice,
|
||||
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat,
|
||||
keyAudioFromGain, keyAudioMicGain)
|
||||
keyAudioFromGain, keyAudioMicGain, keyAudioTXGain)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -6648,6 +6840,9 @@ func (a *App) GetAudioSettings() (AudioSettings, error) {
|
||||
if n, _ := strconv.Atoi(m[keyAudioMicGain]); n > 0 && n <= 400 {
|
||||
out.MicGain = n
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyAudioTXGain]); n > 0 && n <= 400 {
|
||||
out.TXGain = n
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -6677,6 +6872,11 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
|
||||
if s.MicGain <= 0 || s.MicGain > 400 {
|
||||
s.MicGain = 100
|
||||
}
|
||||
// Up to 400 %: a mic recorded quietly needs real amplification, and the
|
||||
// clamp in the player keeps it from wrapping into noise.
|
||||
if s.TXGain <= 0 || s.TXGain > 400 {
|
||||
s.TXGain = 100
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
keyAudioFromRadio: s.FromRadio,
|
||||
keyAudioToRadio: s.ToRadio,
|
||||
@@ -6690,6 +6890,7 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
|
||||
keyAudioFormat: format,
|
||||
keyAudioFromGain: strconv.Itoa(s.FromGain),
|
||||
keyAudioMicGain: strconv.Itoa(s.MicGain),
|
||||
keyAudioTXGain: strconv.Itoa(s.TXGain),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
@@ -8166,7 +8367,7 @@ func (a *App) DVKPlay(slot int) error {
|
||||
a.dvkPttKeyed = true
|
||||
a.pttMu.Unlock()
|
||||
}
|
||||
if err := a.audioMgr.Play(cfg.ToRadio, path); err != nil {
|
||||
if err := a.audioMgr.Play(cfg.ToRadio, path, cfg.TXGain); err != nil {
|
||||
a.pttMu.Lock()
|
||||
keyed := a.dvkPttKeyed
|
||||
gen := a.pttGen
|
||||
@@ -8210,7 +8411,8 @@ func (a *App) unkeyIfCurrent(gen int64) {
|
||||
}
|
||||
|
||||
// pttKey keys the transmitter using the configured method:
|
||||
// - "cat" → OmniRig (sets the Tx parameter to PM_TX)
|
||||
// - "cat" → whichever CAT backend is active (OmniRig, Flex, Icom, TCI,
|
||||
// Yaesu, Xiegu — they all implement SetPTT)
|
||||
// - "rts"/"dtr" → open the COM port and assert that line, held during TX
|
||||
// - "none" → VOX, nothing to do
|
||||
func (a *App) pttKey(cfg AudioSettings) error {
|
||||
@@ -8300,7 +8502,16 @@ func (a *App) TestPTT(cfg AudioSettings) error {
|
||||
if cfg.PTTMethod == "rts" || cfg.PTTMethod == "dtr" {
|
||||
applog.Printf("ptt: TestPTT method=%q port=%q", cfg.PTTMethod, cfg.PTTPort)
|
||||
} else {
|
||||
applog.Printf("ptt: TestPTT method=%q (CAT via OmniRig — serial port not used)", cfg.PTTMethod)
|
||||
// Name the backend that will actually key — "via OmniRig" was left over
|
||||
// from when that was the only one, and it sent operators on a native
|
||||
// backend looking for a problem with OmniRig that they do not even run.
|
||||
backend := "no CAT backend"
|
||||
if a.cat != nil {
|
||||
if st := a.cat.State(); st.Backend != "" {
|
||||
backend = st.Backend
|
||||
}
|
||||
}
|
||||
applog.Printf("ptt: TestPTT method=%q (CAT via %s — serial port not used)", cfg.PTTMethod, backend)
|
||||
}
|
||||
if cfg.PTTMethod == "" || cfg.PTTMethod == "none" {
|
||||
return fmt.Errorf("PTT method is None (VOX) — pick CAT, RTS or DTR first")
|
||||
@@ -8334,7 +8545,9 @@ func (a *App) DVKPreview(slot int) error {
|
||||
return fmt.Errorf("audio not initialized")
|
||||
}
|
||||
cfg, _ := a.GetAudioSettings()
|
||||
return a.audioMgr.Play(cfg.ListeningDevice, a.dvkPath(slot))
|
||||
// Preview at the SAME level the rig will get, or the operator adjusts against
|
||||
// a sound that is not the one going on the air.
|
||||
return a.audioMgr.Play(cfg.ListeningDevice, a.dvkPath(slot), cfg.TXGain)
|
||||
}
|
||||
|
||||
// DVKStop halts any voice-keyer playback.
|
||||
@@ -8521,36 +8734,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) ─────────────────────────────────
|
||||
@@ -9520,12 +9730,52 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
// QSO date. sinceDate is "YYYY-MM-DD".
|
||||
sinceDate := resolveSince(keyExtQRZLastDownload)
|
||||
emit(fmt.Sprintf("Window: since=%q → resolved date=%q (key %s%s)", since, sinceDate, a.profileScope(), keyExtQRZLastDownload))
|
||||
if sinceDate != "" {
|
||||
emit("Fetching QRZ.com logbook (will skip QSOs before " + sinceDate + ")…")
|
||||
} else {
|
||||
// An automatic run ("last") and an operator-chosen date do not mean the
|
||||
// same thing, so they ask QRZ two different questions:
|
||||
// • "last" → MODSINCE: everything TOUCHED since the last run,
|
||||
// which is what catches a 2015 QSO confirmed yesterday.
|
||||
// • a typed date → BETWEEN: the QSOs MADE from that date to today. The
|
||||
// operator asked for a period of operating, not for
|
||||
// whatever QRZ happened to edit in that period.
|
||||
autoWindow := strings.EqualFold(strings.TrimSpace(since), "last")
|
||||
// Ask QRZ for the WINDOW rather than the whole logbook. "ALL" made the
|
||||
// server serialise every QSO — 56 MB for a 117k-record log — which it
|
||||
// truncates mid-record, so the parse died two thirds of the way through
|
||||
// and everything past that point was never seen. AFTER: narrows it at the
|
||||
// source; the client-side date filter below stays as the backstop.
|
||||
//
|
||||
// The option is MODSINCE (QRZ FETCH documentation) — "AFTER:" was a guess
|
||||
// and QRZ rejected the whole fetch, which broke the download outright.
|
||||
// MODSINCE is also the RIGHT window for this job: it selects records
|
||||
// MODIFIED since the date, so a ten-year-old QSO confirmed yesterday comes
|
||||
// back — a QSO-date window would never return it, and confirmations are
|
||||
// exactly what this download is for.
|
||||
//
|
||||
// It stays an optimisation, never a requirement: if QRZ refuses it, fall
|
||||
// back to ALL rather than leaving the operator with no download at all.
|
||||
//
|
||||
// serverWindowed is set only for MODSINCE, because it alone returns QSOs
|
||||
// OLDER than the date — the client-side date filter would drop exactly
|
||||
// those. Under BETWEEN the filter agrees with the server and stays on as a
|
||||
// backstop.
|
||||
fetchOpt, serverWindowed := "ALL", false
|
||||
switch {
|
||||
case sinceDate == "":
|
||||
emit("Fetching QRZ.com logbook (full — no since date)…")
|
||||
case autoWindow:
|
||||
fetchOpt, serverWindowed = "MODSINCE:"+sinceDate, true
|
||||
emit("Fetching QRZ.com logbook (records modified since " + sinceDate + ")…")
|
||||
default:
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
fetchOpt = "BETWEEN:" + sinceDate + "+" + today
|
||||
emit("Fetching QRZ.com logbook (QSOs from " + sinceDate + " to " + today + ")…")
|
||||
}
|
||||
fr, err := extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, fetchOpt)
|
||||
if err != nil && fetchOpt != "ALL" {
|
||||
emit("QRZ.com refused " + fetchOpt + " (" + err.Error() + ") — fetching the full logbook instead…")
|
||||
fetchOpt, serverWindowed = "ALL", false
|
||||
fr, err = extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, fetchOpt)
|
||||
}
|
||||
fr, err := extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, "ALL")
|
||||
if err != nil {
|
||||
emit("Fetch failed: " + err.Error())
|
||||
done(matched, total)
|
||||
@@ -9533,14 +9783,11 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
}
|
||||
adifText := fr.ADIF
|
||||
emit(fmt.Sprintf("QRZ RESULT=%s COUNT=%s, ADIF %d bytes", fr.Result, fr.Count, len(adifText)))
|
||||
// Persist the last-download date NOW (right after a successful fetch),
|
||||
// not at the end: the QRZ logbook can be huge (tens of thousands of
|
||||
// records) and the user may close the panel mid-processing — storing it
|
||||
// late meant the date was never saved, so "since last download" kept
|
||||
// resolving to empty and re-pulled everything.
|
||||
if a.settings != nil {
|
||||
a.setSetting(a.profileScope()+keyExtQRZLastDownload, time.Now().UTC().Format("2006-01-02"))
|
||||
}
|
||||
// The last-download date is stored at the END, and ONLY if the whole ADIF
|
||||
// parsed. It used to be written here, right after the fetch: when the
|
||||
// response was truncated (see above) the window advanced over records that
|
||||
// were never processed, and every later run skipped them for good. A
|
||||
// window that moves past unseen data is worse than one that re-reads.
|
||||
if snip := strings.TrimSpace(adifText); snip != "" {
|
||||
if len(snip) > 300 {
|
||||
snip = snip[:300]
|
||||
@@ -9588,7 +9835,10 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
return nil
|
||||
}
|
||||
// Date window (client-side): skip QSOs older than the requested date.
|
||||
if sinceDate != "" && !q.QSODate.IsZero() && q.QSODate.UTC().Format("2006-01-02") < sinceDate {
|
||||
// ONLY when the server did not window the fetch itself — MODSINCE
|
||||
// returns old QSOs whose CONFIRMATION is recent, and filtering those
|
||||
// out by QSO date would throw away the very records asked for.
|
||||
if !serverWindowed && sinceDate != "" && !q.QSODate.IsZero() && q.QSODate.UTC().Format("2006-01-02") < sinceDate {
|
||||
return nil
|
||||
}
|
||||
// Skip a QSO logged under a DIFFERENT one of the operator's callsigns.
|
||||
@@ -9672,10 +9922,15 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
sort.Strings(keys)
|
||||
emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", ")))
|
||||
emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total))
|
||||
if perr != nil {
|
||||
emit("The download was INCOMPLETE — QRZ.com cut the ADIF short, so the records after that point were not read.")
|
||||
emit("The last-download date has NOT been moved, so nothing is lost: run it again (a narrower window returns less data).")
|
||||
} else if a.settings != nil {
|
||||
a.setSetting(a.profileScope()+keyExtQRZLastDownload, time.Now().UTC().Format("2006-01-02"))
|
||||
}
|
||||
if qrzSkippedOtherCall > 0 {
|
||||
emit(fmt.Sprintf("Skipped %d QSO(s) logged under another of your callsigns (scoped to %s).", qrzSkippedOtherCall, qrzOwner))
|
||||
}
|
||||
// (last-download date already stored right after the fetch above)
|
||||
|
||||
case extsvc.ServiceEQSL:
|
||||
sinceDate := resolveSince(keyExtEQSLLastDownload)
|
||||
@@ -9813,21 +10068,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
|
||||
@@ -10290,6 +10551,31 @@ func (a *App) ReloadUDPIntegrations() []string {
|
||||
return a.udp.Reload(a.ctx)
|
||||
}
|
||||
|
||||
// refineGrid picks between the locator a QSO arrived with and the one the
|
||||
// callsign lookup returned.
|
||||
//
|
||||
// WSJT-X and MSHV always send a FOUR-character grid — that is all the FT8
|
||||
// protocol carries — so a QSO logged from them landed with JN05 while QRZ knew
|
||||
// JN05JG. The enrichment rule everywhere else on this path is "fill only what is
|
||||
// empty", which meant the coarse grid always won and the precise one was thrown
|
||||
// away, silently costing the operator ~100 km of accuracy on every digital QSO.
|
||||
//
|
||||
// The upgrade is only taken when the lookup grid EXTENDS the received one (same
|
||||
// first four characters). A lookup that disagrees outright — JN18 against JN05 —
|
||||
// is not a refinement: the station may be portable, and what came over the air
|
||||
// is then the better record. Case-insensitive, since ADIF grids arrive in both.
|
||||
func refineGrid(have, found string) string {
|
||||
h := strings.ToUpper(strings.TrimSpace(have))
|
||||
f := strings.ToUpper(strings.TrimSpace(found))
|
||||
if h == "" {
|
||||
return f
|
||||
}
|
||||
if len(f) > len(h) && strings.HasPrefix(f, h) {
|
||||
return f
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// LogUDPLoggedADIF takes an ADIF blob received over UDP and inserts the
|
||||
// first record into the local logbook. Returns the ID of the inserted
|
||||
// row. Used by the auto-log handler (WSJT-X / JTDX / MSHV / JTAlert /
|
||||
@@ -10357,9 +10643,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
if q.Country == "" {
|
||||
q.Country = lr.Country
|
||||
}
|
||||
if q.Grid == "" {
|
||||
q.Grid = lr.Grid
|
||||
}
|
||||
q.Grid = refineGrid(q.Grid, lr.Grid)
|
||||
if q.Continent == "" {
|
||||
q.Continent = lr.Continent
|
||||
}
|
||||
@@ -10563,9 +10847,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{
|
||||
@@ -11847,6 +12146,7 @@ func (a *App) reloadCAT() {
|
||||
a.catFlexSpots = s.Enabled && ((s.Backend == "flex" && s.FlexSpots) || (s.Backend == "tci" && s.TCISpots))
|
||||
a.catFlexDecodeSpots = s.Enabled && s.Backend == "flex" && s.FlexDecodeSpots
|
||||
a.catFlexDecodeSecs = s.FlexDecodeSecs
|
||||
a.reloadCATShare(s)
|
||||
if !s.Enabled {
|
||||
a.cat.Stop()
|
||||
return
|
||||
@@ -11858,7 +12158,7 @@ func (a *App) reloadCAT() {
|
||||
// Spawning OmniRig.exe ourselves (even with /Embedding) on every
|
||||
// reloadCAT raised the existing instance's window to the front,
|
||||
// which is what Log4OM avoids by relying entirely on COM activation.
|
||||
a.cat.Start(cat.NewOmniRig(s.OmniRigNum))
|
||||
a.cat.Start(cat.NewOmniRig(s.OmniRigNum, s.OmniRigVFO))
|
||||
case "flex":
|
||||
// Native FlexRadio (SmartSDR) TCP API — no OmniRig needed.
|
||||
fb := cat.NewFlex(s.FlexHost, s.FlexPort, s.FlexSpots)
|
||||
@@ -11869,6 +12169,17 @@ func (a *App) reloadCAT() {
|
||||
}
|
||||
}
|
||||
a.cat.Start(fb)
|
||||
case "xiegu":
|
||||
// Xiegu G90/X6100/X6200/X5105 — CI-V, but a REDUCED command set: no scope,
|
||||
// no DSP block, no data mode. Its own backend rather than the Icom one at
|
||||
// another address, so we never poll a G90 for answers it cannot give.
|
||||
a.cat.Start(cat.NewXiegu(s.XieguPort, s.XieguBaud, s.XieguAddr, s.DigitalDefault))
|
||||
case "yaesu":
|
||||
// Native Yaesu CAT over the rig's USB/serial port — no OmniRig. Every
|
||||
// Yaesu fault reported so far came from OmniRig's interpretation layer
|
||||
// (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 "icom":
|
||||
// Native Icom CI-V over the radio's USB serial port (local control).
|
||||
// Same civ protocol the network backend reuses for remote.
|
||||
@@ -14566,3 +14877,193 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── CAT sharing (Hamlib NET rigctl server) ────────────────────────────────
|
||||
|
||||
// catShareRig adapts the CAT manager to what the rigctld server needs. A thin
|
||||
// interface rather than a direct dependency, so the protocol stays testable
|
||||
// without a radio — and so sharing works with EVERY backend, not just the
|
||||
// native ones: an operator on OmniRig or Flex gets the same server.
|
||||
type catShareRig struct{ a *App }
|
||||
|
||||
func (r catShareRig) Freq() int64 { return r.a.cat.State().FreqHz }
|
||||
func (r catShareRig) Mode() string { return r.a.cat.State().Mode }
|
||||
|
||||
// Split reports the flag and the OTHER VFO's frequency. RigState follows ADIF —
|
||||
// FreqHz is where we TRANSMIT and RxFreqHz where we listen — while a rigctl
|
||||
// client asks for the split TX frequency, which is FreqHz. Getting this pair
|
||||
// backwards would make a client transmit on the listening frequency.
|
||||
func (r catShareRig) Split() (bool, int64) {
|
||||
st := r.a.cat.State()
|
||||
if !st.Split {
|
||||
return false, 0
|
||||
}
|
||||
return true, st.FreqHz
|
||||
}
|
||||
|
||||
func (r catShareRig) SetFreq(hz int64) error { return r.a.cat.SetFrequency(hz) }
|
||||
func (r catShareRig) SetMode(m string) error { return r.a.cat.SetMode(m) }
|
||||
func (r catShareRig) SetPTT(on bool) error { return r.a.cat.SetPTT(on) }
|
||||
|
||||
// reloadCATShare starts, stops or restarts the sharing server to match the
|
||||
// settings. Called from reloadCAT so one "Save & Close" settles both.
|
||||
func (a *App) reloadCATShare(s CATSettings) {
|
||||
want := s.Enabled && s.ShareEnabled
|
||||
// Always tear down first: the port may have changed, and a listener bound to
|
||||
// the old one would keep answering while the client is told to use the new.
|
||||
if a.catShare != nil {
|
||||
a.catShare.Stop()
|
||||
a.catShare = nil
|
||||
}
|
||||
if !want {
|
||||
return
|
||||
}
|
||||
srv := rigctld.New(s.SharePort, catShareRig{a: a}, applog.Printf)
|
||||
if err := srv.Start(); err != nil {
|
||||
// The usual cause is another rigctld — or a previous OpsLog — already on
|
||||
// the port. Logged rather than surfaced: CAT itself is unaffected, and the
|
||||
// operator finds it in the log the moment a client fails to connect.
|
||||
applog.Printf("cat share: %v", err)
|
||||
return
|
||||
}
|
||||
a.catShare = srv
|
||||
}
|
||||
|
||||
// ── Yaesu control panel bindings ──────────────────────────────────────────
|
||||
//
|
||||
// One thin binding per control, mirroring the Icom set. Each dispatches onto the
|
||||
// CAT goroutine, so a click never races the poll loop for the serial port.
|
||||
|
||||
func (a *App) GetYaesuState() cat.YaesuTXState {
|
||||
if a.cat == nil {
|
||||
return cat.YaesuTXState{}
|
||||
}
|
||||
st, _ := a.cat.YaesuState()
|
||||
return st
|
||||
}
|
||||
|
||||
func (a *App) RefreshYaesuPanel() error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("CAT not initialized")
|
||||
}
|
||||
return a.cat.YaesuDo(func(y cat.YaesuController) error { return y.RefreshYaesu() })
|
||||
}
|
||||
|
||||
func (a *App) SetYaesuPower(w int) error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuPower(w) })
|
||||
}
|
||||
|
||||
func (a *App) SetYaesuMicGain(p int) error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuMicGain(p) })
|
||||
}
|
||||
|
||||
func (a *App) SetYaesuAFGain(p int) error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuAFGain(p) })
|
||||
}
|
||||
|
||||
func (a *App) SetYaesuRFGain(p int) error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuRFGain(p) })
|
||||
}
|
||||
|
||||
func (a *App) SetYaesuSquelch(p int) error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuSquelch(p) })
|
||||
}
|
||||
|
||||
func (a *App) SetYaesuAGC(mode string) error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuAGC(mode) })
|
||||
}
|
||||
|
||||
func (a *App) SetYaesuPreamp(n int) error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuPreamp(n) })
|
||||
}
|
||||
|
||||
func (a *App) SetYaesuAtt(db int) error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuAtt(db) })
|
||||
}
|
||||
|
||||
func (a *App) SetYaesuNB(on bool) error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuNB(on) })
|
||||
}
|
||||
|
||||
func (a *App) SetYaesuNR(on bool) error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuNR(on) })
|
||||
}
|
||||
|
||||
func (a *App) SetYaesuNRLevel(n int) error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuNRLevel(n) })
|
||||
}
|
||||
|
||||
func (a *App) SetYaesuNarrow(on bool) error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuNarrow(on) })
|
||||
}
|
||||
|
||||
func (a *App) SetYaesuVOX(on bool) error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuVOX(on) })
|
||||
}
|
||||
|
||||
func (a *App) SetYaesuSplit(on bool) error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuSplit(on) })
|
||||
}
|
||||
|
||||
func (a *App) SetYaesuBand(band string) error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuBand(band) })
|
||||
}
|
||||
|
||||
func (a *App) TuneYaesuATU() error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.TuneYaesuATU() })
|
||||
}
|
||||
|
||||
func (a *App) yaesuDo(fn func(cat.YaesuController) error) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("CAT not initialized")
|
||||
}
|
||||
return a.cat.YaesuDo(fn)
|
||||
}
|
||||
|
||||
// SetYaesuModeRaw picks an exact rig mode, sideband included ("CW-L", "DATA-U").
|
||||
// The panel's mode buttons use this rather than SetCATMode, which takes an ADIF
|
||||
// mode and can only choose a sideband by convention.
|
||||
func (a *App) SetYaesuModeRaw(mode string) error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuModeRaw(mode) })
|
||||
}
|
||||
|
||||
// SetYaesuSplitOffset turns split on with the TX VFO a fixed distance up — the
|
||||
// classic "listen down, transmit up 5" of a phone pile-up, or up 1 on CW.
|
||||
func (a *App) SetYaesuSplitOffset(offsetHz int64) error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuSplitOffset(offsetHz) })
|
||||
}
|
||||
|
||||
func (a *App) SetYaesuKeySpeed(wpm int) error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuKeySpeed(wpm) })
|
||||
}
|
||||
|
||||
func (a *App) SetYaesuBreakIn(on bool) error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuBreakIn(on) })
|
||||
}
|
||||
|
||||
// YaesuZeroIn retunes the rig so the received CW signal lands on the operator's
|
||||
// own pitch — the radio's ZIN key.
|
||||
func (a *App) YaesuZeroIn() error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.YaesuZeroIn() })
|
||||
}
|
||||
|
||||
// YaesuSendCW keys a CW message through the Yaesu's own keyer (CAT "KY"), so an
|
||||
// FTDX10 needs no WinKeyer and no second cable.
|
||||
func (a *App) YaesuSendCW(text string) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
err := a.cat.YaesuDo(func(y cat.YaesuController) error { return y.SendCW(text) })
|
||||
if err != nil {
|
||||
applog.Printf("yaesu cw: YaesuSendCW(%q) failed: %v", text, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// YaesuStopCW aborts the message being sent.
|
||||
func (a *App) YaesuStopCW() error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.YaesuDo(func(y cat.YaesuController) error { return y.StopCW() })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/cat"
|
||||
)
|
||||
|
||||
// Three band tables exist in Go — cat.BandFromHz (the rig/CAT one), bandForHz
|
||||
// (awards) and the cluster's own — plus one in App.tsx. They must agree, and
|
||||
// nothing checked it: the CAT one stopped at 23 cm, so a 10 GHz QSO came back
|
||||
// with an EMPTY band. An empty band is not a cosmetic problem — it is not
|
||||
// logged, not counted in any award slot, and exports without a BAND field.
|
||||
//
|
||||
// One frequency per band, taken inside the range, plus the edges that used to be
|
||||
// missing entirely.
|
||||
func TestBandPlansAgree(t *testing.T) {
|
||||
cases := []struct {
|
||||
hz int64
|
||||
want string
|
||||
}{
|
||||
{137000, "2190m"},
|
||||
{475000, "630m"},
|
||||
{1840000, "160m"},
|
||||
{3650000, "80m"},
|
||||
{7100000, "40m"},
|
||||
{10120000, "30m"},
|
||||
{14200000, "20m"},
|
||||
{18100000, "17m"},
|
||||
{21200000, "15m"},
|
||||
{24940000, "12m"},
|
||||
{28400000, "10m"},
|
||||
{50150000, "6m"},
|
||||
{70200000, "4m"},
|
||||
{144300000, "2m"},
|
||||
{223500000, "1.25m"},
|
||||
{432000000, "70cm"},
|
||||
{1296000000, "23cm"},
|
||||
// The microwave bands that were missing. 3 cm is the one an operator
|
||||
// reported: 10 GHz is worked and spotted, and resolved to nothing.
|
||||
{2320000000, "13cm"},
|
||||
{3400000000, "9cm"},
|
||||
{5760000000, "6cm"},
|
||||
{10368000000, "3cm"},
|
||||
{24048000000, "1.25cm"},
|
||||
{47088000000, "6mm"},
|
||||
{76032000000, "4mm"},
|
||||
{122250000000, "2.5mm"},
|
||||
{134928000000, "2mm"},
|
||||
{241920000000, "1mm"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := cat.BandFromHz(c.hz); got != c.want {
|
||||
t.Errorf("cat.BandFromHz(%d) = %q, want %q", c.hz, got, c.want)
|
||||
}
|
||||
if got := bandForHz(c.hz); got != c.want {
|
||||
t.Errorf("bandForHz(%d) = %q, want %q — the award band plan disagrees with the CAT one", c.hz, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A frequency in no amateur band must resolve to "" everywhere, not to the
|
||||
// nearest band: guessing here would put a QSO in a band the operator never used.
|
||||
func TestBandPlanRejectsOutOfBand(t *testing.T) {
|
||||
for _, hz := range []int64{100_000_000, 1_000_000_000, 15_000_000_000} {
|
||||
if got := cat.BandFromHz(hz); got != "" {
|
||||
t.Errorf("cat.BandFromHz(%d) = %q, want empty", hz, got)
|
||||
}
|
||||
if got := bandForHz(hz); got != "" {
|
||||
t.Errorf("bandForHz(%d) = %q, want empty", hz, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// Every field the bulk-edit dialog OFFERS must be one the repository accepts.
|
||||
//
|
||||
// These two lists live in different packages — a TypeScript field table on one
|
||||
// side, a Go whitelist on the other — and they drifted apart the day the
|
||||
// confirmation dates were added: the new columns went into the QSL Manager's
|
||||
// filter whitelist instead of the bulk-edit one, so the dialog listed fields it
|
||||
// could not write and the operator got "field … is not bulk-editable" only after
|
||||
// selecting 54 QSOs and clicking Apply. Nothing in the build caught it.
|
||||
//
|
||||
// The test reads the ACTUAL field ids out of the .tsx rather than a copy, so a
|
||||
// field added to the UI alone fails here immediately.
|
||||
func TestBulkEditFieldsAreWritable(t *testing.T) {
|
||||
src, err := os.ReadFile("frontend/src/components/BulkEditModal.tsx")
|
||||
if err != nil {
|
||||
t.Skipf("frontend source not available: %v", err)
|
||||
}
|
||||
ids := regexp.MustCompile(`\{ id: '([a-z0-9_]+)'`).FindAllStringSubmatch(string(src), -1)
|
||||
if len(ids) < 10 {
|
||||
t.Fatalf("only %d field ids found — the parse is wrong, not the data", len(ids))
|
||||
}
|
||||
for _, m := range ids {
|
||||
id := m[1]
|
||||
// Handled before the column map: freq takes a numeric path (freq_hz +
|
||||
// band together) and the extras live in extras_json.
|
||||
if id == "freq" || qso.IsBulkEditableExtra(id) {
|
||||
continue
|
||||
}
|
||||
col, ok := bulkFieldColumns[id]
|
||||
if !ok {
|
||||
t.Errorf("bulk-edit UI offers %q, which maps to no column (bulkFieldColumns)", id)
|
||||
continue
|
||||
}
|
||||
// Extras live outside the qso table (owner_callsign and friends): they are
|
||||
// written into extras_json, not a column, so the column whitelist does not
|
||||
// apply to them.
|
||||
if strings.HasPrefix(col, "extras.") {
|
||||
continue
|
||||
}
|
||||
if !qso.IsBulkEditable(col) && !qso.IsBulkEditableExtra(col) {
|
||||
t.Errorf("bulk-edit UI offers %q → column %q, which the repository refuses", id, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Same contract for the filter builder: every field it offers must be one the
|
||||
// query layer will accept.
|
||||
func TestFilterFieldsAreQueryable(t *testing.T) {
|
||||
src, err := os.ReadFile("frontend/src/components/FilterBuilder.tsx")
|
||||
if err != nil {
|
||||
t.Skipf("frontend source not available: %v", err)
|
||||
}
|
||||
// Anchored on `type:` — the FIELDS entries carry one, the OPERATOR list does
|
||||
// not, and without it every operator (eq, contains, …) was read as a field.
|
||||
ids := regexp.MustCompile(`\{ value: '([a-z0-9_]+)', label: '[^']*', type:`).FindAllStringSubmatch(string(src), -1)
|
||||
if len(ids) < 10 {
|
||||
t.Fatalf("only %d field ids found — the parse is wrong, not the data", len(ids))
|
||||
}
|
||||
for _, m := range ids {
|
||||
col := m[1]
|
||||
if !qso.IsFilterable(col) && !qso.IsFilterableExtra(col) {
|
||||
t.Errorf("filter builder offers %q, which the query layer refuses", col)
|
||||
}
|
||||
}
|
||||
}
|
||||
+126
@@ -1,4 +1,130 @@
|
||||
[
|
||||
{
|
||||
"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": [
|
||||
"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.",
|
||||
"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": [
|
||||
"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.",
|
||||
"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."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.21.8",
|
||||
"date": "2026-07-28",
|
||||
"en": [
|
||||
"Microwave bands are recognised: 13cm, 9cm, 6cm, 3cm (10 GHz), 1.25cm and above, plus 33cm and the 2190m/630m/560m low bands. A 10 GHz QSO used to come back with NO band at all — so it was logged without one, counted in no award slot, and exported without a BAND field. Band pickers, statistics and award band columns follow.",
|
||||
"QRZ.com confirmation download works again: the date window used an option QRZ rejects, so every fetch failed. An automatic run now asks for everything modified since the last one — so an old QSO confirmed recently comes back — while a date you type fetches the QSOs made between that date and today.",
|
||||
"Port fields can be cleared. Deleting the contents put the default straight back, so entering a different port meant overwriting it digit by digit. Fixed on the cluster editor, where it was reported, and in the eight other places with the same fault (CAT, amplifier, antenna, rotator, database, SMTP)."
|
||||
],
|
||||
"fr": [
|
||||
"Les bandes hyperfréquences sont reconnues : 13cm, 9cm, 6cm, 3cm (10 GHz), 1.25cm et au-delà, ainsi que 33cm et les bandes basses 2190m/630m/560m. Un QSO à 10 GHz revenait sans AUCUNE bande — donc enregistré sans bande, compté dans aucun diplôme, et exporté sans champ BAND. Les listes de bandes, les statistiques et les colonnes de diplômes suivent.",
|
||||
"Le téléchargement des confirmations QRZ.com refonctionne : la fenêtre de date utilisait une option refusée par QRZ, donc chaque récupération échouait. Un téléchargement automatique demande désormais tout ce qui a été modifié depuis le précédent — un QSO ancien confirmé récemment revient donc — tandis qu'une date saisie récupère les QSO faits entre cette date et aujourd'hui.",
|
||||
"Les champs de port peuvent être vidés. Effacer le contenu réinscrivait aussitôt la valeur par défaut, si bien qu'entrer un autre port obligeait à l'écraser chiffre par chiffre. Corrigé dans l'éditeur de cluster, où c'était signalé, et dans les huit autres endroits présentant le même défaut (CAT, amplificateur, antenne, rotator, base de données, SMTP)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.21.7",
|
||||
"date": "2026-07-28",
|
||||
"en": [
|
||||
"The filter builder uses the same confirmation field names as bulk edit, gains the missing QRZ.com received status, and can filter on the sent and received dates (before / after).",
|
||||
"A fresh install now starts on the dark theme. Existing installs keep the theme they are on.",
|
||||
"A QSO logged from WSJT-X or MSHV now keeps the precise 6-character locator from QRZ/HamQTH instead of the 4-character one the digital protocol carries — about 100 km of accuracy that was being discarded on every digital QSO. A lookup that disagrees with the received square is not applied.",
|
||||
"Edit QSO: the QRZ/HamQTH fetch button now bypasses the cache, so it really re-reads the callsign — upgrading a QRZ subscription used to change nothing until the cached entry expired a month later. It also no longer blanks an existing value when the lookup comes back with that field empty.",
|
||||
"The diagnostic log no longer carries the FlexRadio meter readings, which drowned everything else.",
|
||||
"OmniRig: a new \"VFO to read\" setting. OmniRig reports the active VFO from the rig file, and some files get it wrong — an IC-7610 file declared VFO B while the operator was on the main VFO, so OpsLog wrote to one VFO and read the other, and the frequency looked frozen. Force VFO A or B when that happens.",
|
||||
"Bulk edit: fixed \"field is not bulk-editable\" on the QRZ.com received status and the confirmation dates added in 0.21.6 — they were declared in the wrong place and refused at Apply. Bulk-editing State and County, offered since the start, was refused the same way and now works.",
|
||||
"QRZ.com confirmation download: the whole logbook was requested every time, which QRZ cuts short on a large log — the read stopped two thirds of the way through and the rest was never seen. Only the window is requested now. And the last-download date is no longer moved when the download was incomplete: it used to advance over records that had never been read, which skipped them for good.",
|
||||
"The callsign in the top bar is now the station switcher: click it and pick a profile to activate it. Managing profiles is still there, at the bottom of the list.",
|
||||
"The UTC clock moved from the top bar to the status bar at the bottom, beside the database.",
|
||||
"FlexRadio panel: the built-in ATU is now controllable — Tune, Bypass, memories, and the tuner state in words (tuning, tuned, TUNE FAILED…), because a failed tune and one that never ran look identical otherwise.",
|
||||
"Diagnostic log: each database migration now names the database it runs on, and a frequency set through OmniRig is checked again 1.5 s later — both to make a reported problem readable from the log instead of guessed at."
|
||||
],
|
||||
"fr": [
|
||||
"Le constructeur de filtres reprend les mêmes noms de champs de confirmation que l'édition en lot, gagne le statut de réception QRZ.com manquant, et permet de filtrer sur les dates d'envoi et de réception (avant / après).",
|
||||
"Une nouvelle installation démarre désormais sur le thème sombre. Les installations existantes conservent le leur.",
|
||||
"Un QSO enregistré depuis WSJT-X ou MSHV conserve désormais le locator précis à 6 caractères de QRZ/HamQTH au lieu de celui à 4 caractères transporté par le protocole numérique — une centaine de kilomètres de précision perdus jusqu'ici à chaque QSO numérique. Une réponse qui contredit le carré reçu n'est pas appliquée.",
|
||||
"Édition QSO : le bouton de récupération QRZ/HamQTH contourne désormais le cache et relit donc réellement l'indicatif — passer à un abonnement QRZ payant ne changeait rien tant que l'entrée en cache n'avait pas expiré un mois plus tard. Il n'efface plus non plus une valeur existante lorsque la recherche revient sans ce champ.",
|
||||
"Le journal de diagnostic ne contient plus les mesures des instruments FlexRadio, qui noyaient tout le reste.",
|
||||
"OmniRig : nouveau réglage « VFO à lire ». OmniRig indique le VFO actif d'après le fichier radio, et certains fichiers se trompent — un fichier IC-7610 déclarait le VFO B alors que l'opérateur était sur le VFO principal, si bien qu'OpsLog écrivait dans l'un et lisait l'autre, et la fréquence semblait figée. Forcez le VFO A ou B le cas échéant.",
|
||||
"Édition en lot : correction du refus « field is not bulk-editable » sur le statut de réception QRZ.com et les dates de confirmation ajoutées en 0.21.6 — ils étaient déclarés au mauvais endroit et rejetés au moment d'appliquer. L'édition en lot de State et County, proposée depuis toujours, était refusée de la même façon et fonctionne désormais.",
|
||||
"Téléchargement des confirmations QRZ.com : le carnet entier était demandé à chaque fois, ce que QRZ tronque sur un grand log — la lecture s'arrêtait aux deux tiers et le reste n'était jamais vu. Seule la fenêtre est désormais demandée. Et la date du dernier téléchargement n'avance plus lorsque le téléchargement est incomplet : elle passait par-dessus des enregistrements jamais lus, qui étaient alors ignorés définitivement.",
|
||||
"L'indicatif en haut de la fenêtre est désormais le sélecteur de station : un clic, on choisit un profil et il devient actif. La gestion des profils reste accessible, en bas de la liste.",
|
||||
"L'horloge UTC est passée de la barre du haut à la barre d'état en bas, à côté de la base de données.",
|
||||
"Panneau FlexRadio : le coupleur intégré est désormais pilotable — Accord, Bypass, mémoires, et l'état du coupleur en clair (accord en cours, accordé, ÉCHEC ACCORD…), car sans ça un accord raté et un accord jamais lancé se ressemblent.",
|
||||
"Journal de diagnostic : chaque migration de base indique désormais sur quelle base elle tourne, et une fréquence envoyée via OmniRig est relue 1,5 s plus tard — de quoi lire un problème signalé dans le journal au lieu de le deviner."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.21.6",
|
||||
"date": "2026-07-27",
|
||||
|
||||
+224
-73
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Gauge, Hash, Loader2, Lock,
|
||||
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Gauge, Hash, Loader2, Lock,
|
||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -38,14 +38,14 @@ import {
|
||||
ListCountries,
|
||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
||||
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
|
||||
IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
|
||||
IcomSendCW, YaesuSendCW, YaesuStopCW, SetYaesuKeySpeed, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
|
||||
FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
|
||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
||||
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
||||
GetAwardDefs,
|
||||
GetUIPref, GetActiveProfile, QuitApp,
|
||||
GetUIPref, GetActiveProfile, ListProfiles, ActivateProfile, QuitApp,
|
||||
ReportLiveActivity, LiveLastQSOAgeSec,
|
||||
GetAmpStatuses, AmpOperate,
|
||||
GetFlexState, FlexAmpOperate,
|
||||
@@ -57,6 +57,9 @@ import type { adif as adifModels, lookup as lookupModels, cat as catModels } fro
|
||||
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
|
||||
import { Menubar, type Menu } from '@/components/Menubar';
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { APP_VERSION, APP_AUTHOR } from '@/version';
|
||||
import { QSLManagerPanel } from '@/components/QSLManagerModal';
|
||||
import { QslDesignerModal } from '@/components/qsl/QslDesignerModal';
|
||||
@@ -70,6 +73,7 @@ import { BandMap } from '@/components/BandMap';
|
||||
import { WorldMap, LocatorMap } from '@/components/MainMap';
|
||||
import { FlexPanel } from '@/components/FlexPanel';
|
||||
import { IcomPanel } from '@/components/IcomPanel';
|
||||
import { YaesuPanel } from '@/components/YaesuPanel';
|
||||
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
|
||||
import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel';
|
||||
import { ScpPanel, type ScpResult } from '@/components/ScpPanel';
|
||||
@@ -269,7 +273,11 @@ function bandForMHz(mhz: number): string {
|
||||
[1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
|
||||
[10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
|
||||
[24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
|
||||
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [1240, 1300, '23cm'],
|
||||
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [902, 928, '33cm'], [1240, 1300, '23cm'],
|
||||
// Microwave, ADIF 3.1.7 ranges — kept in step with BandFromHz on the Go side.
|
||||
[2300, 2450, '13cm'], [3300, 3500, '9cm'], [5650, 5925, '6cm'], [10000, 10500, '3cm'],
|
||||
[24000, 24250, '1.25cm'], [47000, 47200, '6mm'], [75500, 81000, '4mm'],
|
||||
[119980, 123000, '2.5mm'], [134000, 149000, '2mm'], [241000, 250000, '1mm'],
|
||||
];
|
||||
for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
|
||||
return '';
|
||||
@@ -495,6 +503,9 @@ export default function App() {
|
||||
|
||||
// CAT — receives live rig state via Wails events.
|
||||
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
|
||||
// Live copy for callbacks that must not close over a stale snapshot.
|
||||
const catStateRef = useRef(catState);
|
||||
useEffect(() => { catStateRef.current = catState; }, [catState]);
|
||||
// Configured CAT backend ('icom' USB vs 'icom-net'): the live catState.backend
|
||||
// is "icom" for BOTH, so we track the settings value to tell them apart (used to
|
||||
// hide the rig ON/OFF buttons on USB, where the interface is unpowered when the
|
||||
@@ -587,6 +598,10 @@ export default function App() {
|
||||
// window after manual edits and skip CAT updates during it.
|
||||
const catFreezeUntilRef = useRef<number>(0);
|
||||
function noteManualEdit() { catFreezeUntilRef.current = Date.now() + 1500; }
|
||||
// The last CAT snapshot that arrived while the freeze was open, replayed when
|
||||
// it closes — see applyCatState.
|
||||
const pendingCatRef = useRef<CATState | null>(null);
|
||||
const pendingCatTimerRef = useRef<number | undefined>(undefined);
|
||||
|
||||
// Suggested QSY frequency (Hz) for a given band + mode. Common phone /
|
||||
// CW / FT8 watering holes per IARU practice. Fallback = mid-band SSB freq.
|
||||
@@ -953,7 +968,26 @@ export default function App() {
|
||||
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
|
||||
// auto-call and <LOGQSO> are shared; only the transport differs.
|
||||
const [wkEngine, setWkEngine] = useState<string>('winkeyer');
|
||||
const cwSource: 'winkeyer' | 'icom' | 'flex' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : 'winkeyer';
|
||||
const cwSource: 'winkeyer' | 'icom' | 'flex' | 'yaesu' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : wkEngine === 'yaesu' ? 'yaesu' : 'winkeyer';
|
||||
// Setting the CW speed has to reach the keyer that is ACTUALLY sending, and
|
||||
// both the CW panel and the Yaesu console can ask for it. With DTR/RTS line
|
||||
// keying the PC does the timing, so the rig's internal keyer speed changes
|
||||
// nothing audible — the Yaesu console's slider looked broken because it drove
|
||||
// that one alone. It now drives both: the rig's keyer for its own front panel,
|
||||
// and the engine doing the work.
|
||||
const setCWSpeedEverywhere = useCallback((w: number) => {
|
||||
setWkWpm(w); saveWk({ wpm: w });
|
||||
const src = cwSourceRef.current;
|
||||
if (src === 'icom') IcomSetKeySpeed(w).catch(() => {});
|
||||
else if (src === 'flex') FlexSetKeySpeed(w).catch(() => {});
|
||||
else if (src === 'yaesu') SetYaesuKeySpeed(w).catch(() => {});
|
||||
else WinkeyerSetSpeed(w).catch(() => {});
|
||||
// The rig's own keyer follows too whenever a Yaesu is on CAT, even when it is
|
||||
// not the sending engine: its front panel and OpsLog then agree.
|
||||
if (src !== 'yaesu' && catStateRef.current?.backend === 'yaesu') {
|
||||
SetYaesuKeySpeed(w).catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
const cwSourceRef = useRef(cwSource);
|
||||
useEffect(() => { cwSourceRef.current = cwSource; }, [cwSource]);
|
||||
// CW break-in (0=OFF, 1=SEMI, 2=FULL) — must be on for the rig's 0x17 keyer to
|
||||
@@ -989,6 +1023,7 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected)
|
||||
: cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected)
|
||||
: cwSource === 'yaesu' ? (catState.backend === 'yaesu' && catState.connected)
|
||||
: wkStatus.connected;
|
||||
wkActiveRef.current = wkEnabled && connected;
|
||||
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
|
||||
@@ -1308,12 +1343,12 @@ export default function App() {
|
||||
// map ("map1"), the locator street map ("map2"), the cluster grid or the
|
||||
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
|
||||
// so it's loaded async on mount and re-read on profile:changed below.
|
||||
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'netcontrol';
|
||||
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'netcontrol';
|
||||
const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now
|
||||
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
|
||||
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
|
||||
const loadMainPanes = useCallback(async () => {
|
||||
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'netcontrol';
|
||||
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'yaesu' || v === 'netcontrol';
|
||||
const [l, r] = await Promise.all([
|
||||
GetUIPref('mainPaneLeft').catch(() => ''),
|
||||
GetUIPref('mainPaneRight').catch(() => ''),
|
||||
@@ -2115,7 +2150,26 @@ export default function App() {
|
||||
function applyCatState(s: CATState) {
|
||||
setCatState(s);
|
||||
if (!s?.connected) return;
|
||||
if (Date.now() < catFreezeUntilRef.current) return;
|
||||
// A snapshot arriving during the freeze used to be DROPPED, and that lost the
|
||||
// only one that mattered. Changing band from OpsLog does two things at once:
|
||||
// it opens the 1.5 s freeze (noteManualEdit) and it commands the rig. The
|
||||
// rig's answer comes back in ~170 ms — inside the freeze — so it was thrown
|
||||
// away, and since the backend only emits on CHANGE, nothing followed: the
|
||||
// frequency stayed on the old band until the operator nudged the VFO. From
|
||||
// the radio it always worked, because no freeze was open. Confirmed on an
|
||||
// FTDX10 (2026-07-29): the log showed the backend publishing the right
|
||||
// frequency every time while the display did not move.
|
||||
const now = Date.now();
|
||||
if (now < catFreezeUntilRef.current) {
|
||||
pendingCatRef.current = s;
|
||||
if (pendingCatTimerRef.current) window.clearTimeout(pendingCatTimerRef.current);
|
||||
pendingCatTimerRef.current = window.setTimeout(() => {
|
||||
const p = pendingCatRef.current;
|
||||
pendingCatRef.current = null;
|
||||
if (p) applyCatState(p); // re-checks the freeze, so more typing just defers it again
|
||||
}, catFreezeUntilRef.current - now + 50);
|
||||
return;
|
||||
}
|
||||
const lk = locksRef.current;
|
||||
if (!lk.freq && s.freq_hz && s.freq_hz > 0) {
|
||||
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
|
||||
@@ -2367,7 +2421,7 @@ export default function App() {
|
||||
if (n <= 0) return;
|
||||
await refresh();
|
||||
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
||||
showToast(`ADIF: ${n} QSO imported${file ? ` from ${file}` : ''}`);
|
||||
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
|
||||
});
|
||||
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); unsubAdifMon?.(); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -2389,7 +2443,16 @@ export default function App() {
|
||||
setWkEscClears(s.esc_clears_call !== false);
|
||||
setWkSendOnType(!!s.send_on_type);
|
||||
setWkEsm(!!s.esm);
|
||||
setWkEngine(s.engine === 'icom' ? 'icom' : s.engine === 'flex' ? 'flex' : s.engine === 'serial' ? 'serial' : 'winkeyer');
|
||||
// Every engine has to be named here. Anything unlisted silently became
|
||||
// "winkeyer", so choosing the Yaesu keyer in the settings still gave a
|
||||
// WinKeyer panel asking for a COM port — the setting was saved correctly,
|
||||
// it just never survived being read back.
|
||||
setWkEngine(
|
||||
s.engine === 'icom' ? 'icom'
|
||||
: s.engine === 'flex' ? 'flex'
|
||||
: s.engine === 'yaesu' ? 'yaesu'
|
||||
: s.engine === 'serial' ? 'serial'
|
||||
: 'winkeyer');
|
||||
} catch { /* keyer not configured */ }
|
||||
}, []);
|
||||
|
||||
@@ -2398,6 +2461,18 @@ export default function App() {
|
||||
// every profile switch; the grids take it as their React key so they remount
|
||||
// and re-read the now-correct per-profile layout.
|
||||
const [activeProfileId, setActiveProfileId] = useState<number | null>(null);
|
||||
// The station switcher in the header needs the whole list, not just the active
|
||||
// one. Reloaded on every profile change so a profile renamed or added in the
|
||||
// settings shows up without a restart.
|
||||
const [profileList, setProfileList] = useState<{ id: number; callsign: string; name: string }[]>([]);
|
||||
const loadProfileList = useCallback(() => {
|
||||
ListProfiles().then((ps: any) => {
|
||||
setProfileList((Array.isArray(ps) ? ps : []).map((p: any) => ({
|
||||
id: p.id, callsign: p.callsign ?? '', name: p.name ?? '',
|
||||
})));
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => { loadProfileList(); }, [loadProfileList]);
|
||||
useEffect(() => {
|
||||
GetActiveProfile().then((p: any) => {
|
||||
if (p && p.id != null) { setGridPrefsProfile(p.id); setActiveProfileId(p.id); }
|
||||
@@ -2420,7 +2495,7 @@ export default function App() {
|
||||
// Re-scope the grid column layout BEFORE the grids remount (key change).
|
||||
setGridPrefsProfile(id ?? null);
|
||||
setActiveProfileId(typeof id === 'number' ? id : null);
|
||||
loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes();
|
||||
loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes(); loadProfileList();
|
||||
// The chat is per shared logbook — clear the previous profile's messages
|
||||
// and reload for the new logbook (or hide if it isn't a MySQL log).
|
||||
setChatMsgs([]); chatSeen.current.clear(); setChatOnline([]); setChatUnread(0);
|
||||
@@ -2428,7 +2503,7 @@ export default function App() {
|
||||
setChatEpoch((e) => e + 1);
|
||||
});
|
||||
return () => { off(); };
|
||||
}, [loadStation, loadLists, loadCATCfg, reloadWk, loadMainPanes]);
|
||||
}, [loadStation, loadLists, loadCATCfg, reloadWk, loadMainPanes, loadProfileList]);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await reloadWk();
|
||||
@@ -2503,7 +2578,7 @@ export default function App() {
|
||||
// segment AFTER the <LOGQSO> (which logs and clears the form) still expands its
|
||||
// variables correctly.
|
||||
const parts = rawText.split(/<LOGQSO>/i).map((pt) => resolveCW(pt));
|
||||
const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex';
|
||||
const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex' || cwSourceRef.current === 'yaesu';
|
||||
for (let p = 0; p < parts.length; p++) {
|
||||
if (aborted()) return; // ESC / Stop before this segment → stop sending, don't log
|
||||
const resolved = parts[p];
|
||||
@@ -2515,7 +2590,7 @@ export default function App() {
|
||||
// current WPM, so it scales automatically.
|
||||
const keyed = resolved + ' ';
|
||||
setWkSent(resolved);
|
||||
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : null;
|
||||
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : cwSourceRef.current === 'yaesu' ? YaesuSendCW : null;
|
||||
if (sendFn) await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||
else await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||
// Only WAIT for the CW to finish on the FINAL segment — auto-call needs the
|
||||
@@ -2527,11 +2602,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);
|
||||
}
|
||||
}
|
||||
@@ -2548,6 +2633,7 @@ export default function App() {
|
||||
wkSendGenRef.current++; // cancel any in-flight macro send (and its pending <LOGQSO> log)
|
||||
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
||||
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
||||
else if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {});
|
||||
else WinkeyerStop().catch(() => {});
|
||||
}
|
||||
// runAutoCall sends macro i, waits for the keyer to finish, waits the chosen
|
||||
@@ -3452,6 +3538,7 @@ export default function App() {
|
||||
stopAutoCall();
|
||||
wkSendGenRef.current++; // abort an in-flight macro send so a pending <LOGQSO> won't fire
|
||||
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
||||
else if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {});
|
||||
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
||||
else WinkeyerStop().catch(() => {});
|
||||
}
|
||||
@@ -4237,6 +4324,12 @@ export default function App() {
|
||||
onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
||||
</div>
|
||||
);
|
||||
case 'yaesu':
|
||||
return (
|
||||
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
|
||||
<YaesuPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} onKeySpeed={setCWSpeedEverywhere} />
|
||||
</div>
|
||||
);
|
||||
case 'icom':
|
||||
return (
|
||||
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
|
||||
@@ -4650,24 +4743,48 @@ export default function App() {
|
||||
) : <span />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
|
||||
<Clock className="size-3" />
|
||||
{utcNow}<span className="text-[10px]">UTC</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{station.callsign ? (
|
||||
<button
|
||||
onClick={() => { setSettingsSection('profiles'); setShowSettings(true); }}
|
||||
className="flex items-center gap-1.5 bg-accent border border-accent-foreground/20 text-accent-foreground px-3 py-1 rounded-full font-mono text-xs font-bold hover:bg-accent/80 transition-colors"
|
||||
title="Click to switch / edit profiles"
|
||||
>
|
||||
<Antenna className="size-3" />
|
||||
{station.callsign}
|
||||
{station.my_grid && (
|
||||
<span className="bg-card/60 px-1.5 rounded text-[11px] text-primary">{station.my_grid}</span>
|
||||
)}
|
||||
</button>
|
||||
// Switching station is the frequent act; editing a profile is the
|
||||
// rare one. The callsign used to open the settings, so changing
|
||||
// station took four clicks and a scroll — it now IS the switcher,
|
||||
// with the settings kept at the bottom of the list.
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-1.5 bg-accent border border-accent-foreground/20 text-accent-foreground px-3 py-1 rounded-full font-mono text-xs font-bold hover:bg-accent/80 transition-colors"
|
||||
title={t('prof.switchTitle')}
|
||||
>
|
||||
<Antenna className="size-3" />
|
||||
{station.callsign}
|
||||
{station.my_grid && (
|
||||
<span className="bg-card/60 px-1.5 rounded text-[11px] text-primary">{station.my_grid}</span>
|
||||
)}
|
||||
<ChevronDown className="size-3 opacity-70" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-56">
|
||||
{profileList.map((p) => (
|
||||
<DropdownMenuItem
|
||||
key={p.id}
|
||||
disabled={p.id === activeProfileId}
|
||||
onSelect={() => { if (p.id !== activeProfileId) ActivateProfile(p.id).catch((e: any) => setError(String(e?.message ?? e))); }}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
{p.id === activeProfileId
|
||||
? <Check className="size-3.5 shrink-0 text-primary" />
|
||||
: <span className="size-3.5 shrink-0" />}
|
||||
<span className="font-mono font-semibold">{p.callsign || '—'}</span>
|
||||
{p.name && <span className="text-muted-foreground truncate">{p.name}</span>}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => { setSettingsSection('profiles'); setShowSettings(true); }}>
|
||||
{t('prof.manage')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => setShowSettings(true)}>
|
||||
<Settings className="size-3.5" /> Set station
|
||||
@@ -5137,8 +5254,16 @@ export default function App() {
|
||||
{wkEnabled && (
|
||||
<div className="w-[380px] shrink-0 min-h-0">
|
||||
<WinkeyerPanel
|
||||
status={cwSource === 'icom' || cwSource === 'flex'
|
||||
? { connected: catState.backend === cwSource && catState.connected, busy: false, wpm: wkWpm, version: 0, port: cwSource === 'flex' ? 'CWX' : 'CI-V' }
|
||||
// A rig keyer has no serial status of its own: it is connected
|
||||
// exactly when its CAT backend is. Yaesu was missing from this
|
||||
// list, so the panel fell back to the WinKeyer status — which
|
||||
// reported disconnected, no WinKeyer being attached.
|
||||
status={cwSource === 'icom' || cwSource === 'flex' || cwSource === 'yaesu'
|
||||
? {
|
||||
connected: catState.backend === cwSource && catState.connected,
|
||||
busy: false, wpm: wkWpm, version: 0,
|
||||
port: cwSource === 'flex' ? 'CWX' : cwSource === 'yaesu' ? 'CAT' : 'CI-V',
|
||||
}
|
||||
: wkStatus}
|
||||
ports={wkPorts}
|
||||
port={wkPort}
|
||||
@@ -5152,12 +5277,7 @@ export default function App() {
|
||||
onRefreshPorts={reloadWkPorts}
|
||||
onConnect={() => WinkeyerConnect().catch((e) => setError(String(e?.message ?? e)))}
|
||||
onDisconnect={() => WinkeyerDisconnect().catch(() => {})}
|
||||
onSetSpeed={(w) => {
|
||||
setWkWpm(w); saveWk({ wpm: w });
|
||||
if (cwSource === 'icom') IcomSetKeySpeed(w).catch(() => {});
|
||||
else if (cwSource === 'flex') FlexSetKeySpeed(w).catch(() => {});
|
||||
else WinkeyerSetSpeed(w).catch(() => {});
|
||||
}}
|
||||
onSetSpeed={setCWSpeedEverywhere}
|
||||
onSend={wkSend}
|
||||
onSendMacro={wkSendMacro}
|
||||
onStop={() => { stopAutoCall(); stopKeyerTx(); }}
|
||||
@@ -5298,8 +5418,9 @@ export default function App() {
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
|
||||
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>}
|
||||
{catState.backend === 'flex' && <TabsTrigger value="flex">Flex Console</TabsTrigger>}
|
||||
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom Console</TabsTrigger>}
|
||||
{catState.backend === 'yaesu' && <TabsTrigger value="yaesu">Yaesu Console</TabsTrigger>}
|
||||
{statsTabOpen && (
|
||||
<TabsTrigger value="stats" className="gap-1.5">
|
||||
{t('stats.tab')}
|
||||
@@ -5368,27 +5489,27 @@ export default function App() {
|
||||
: 'bg-success-muted border-success-border text-success-muted-foreground',
|
||||
)}>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<strong>Import complete.</strong>
|
||||
<Badge variant="outline" className="bg-card/60 font-mono text-success border-success-border">{importResult.imported} imported</Badge>
|
||||
<strong>{t('imp.complete')}</strong>
|
||||
<Badge variant="outline" className="bg-card/60 font-mono text-success border-success-border">{t('imp.imported', { n: importResult.imported })}</Badge>
|
||||
{importResult.updated > 0 && (
|
||||
<Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{importResult.updated} updated</Badge>
|
||||
<Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{t('imp.updated', { n: importResult.updated })}</Badge>
|
||||
)}
|
||||
{importResult.duplicates > 0 && (
|
||||
<Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{importResult.duplicates} duplicates</Badge>
|
||||
<Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{t('imp.duplicates', { n: importResult.duplicates })}</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="bg-card/60 font-mono text-warning border-warning-border">{importResult.skipped} skipped</Badge>
|
||||
<Badge variant="outline" className="bg-card/60 font-mono">{importResult.total} total</Badge>
|
||||
<Badge variant="outline" className="bg-card/60 font-mono text-warning border-warning-border">{t('imp.skipped', { n: importResult.skipped })}</Badge>
|
||||
<Badge variant="outline" className="bg-card/60 font-mono">{t('imp.total', { n: importResult.total })}</Badge>
|
||||
{importResult.duplicates > 0 && importResult.duplicate_samples && importResult.duplicate_samples.length > 0 && (
|
||||
<button className="underline text-xs" onClick={() => setImportDupsOpen((v) => !v)}>
|
||||
{importDupsOpen ? 'Hide' : 'Show'} duplicates
|
||||
{importDupsOpen ? t('imp.hideDups') : t('imp.showDups')}
|
||||
{importResult.duplicates > importResult.duplicate_samples.length
|
||||
? ` (first ${importResult.duplicate_samples.length} of ${importResult.duplicates})`
|
||||
? t('imp.dupsFirst', { shown: importResult.duplicate_samples.length, total: importResult.duplicates })
|
||||
: ''}
|
||||
</button>
|
||||
)}
|
||||
{importResult.errors && importResult.errors.length > 0 && (
|
||||
<button className="underline text-xs" onClick={() => setImportErrorsOpen((v) => !v)}>
|
||||
{importErrorsOpen ? 'Hide' : 'Show'} {importResult.errors.length} error{importResult.errors.length > 1 ? 's' : ''}
|
||||
{importErrorsOpen ? t('imp.hideErrors', { n: importResult.errors.length }) : t('imp.showErrors', { n: importResult.errors.length })}
|
||||
</button>
|
||||
)}
|
||||
<button className="ml-auto" onClick={() => setImportResult(null)}><X className="size-4" /></button>
|
||||
@@ -5449,16 +5570,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>
|
||||
@@ -5715,6 +5851,13 @@ export default function App() {
|
||||
|
||||
{/* Icom CI-V receive-DSP control panel — only when the CAT backend
|
||||
is an Icom. */}
|
||||
{/* Yaesu CAT control panel — only when the CAT backend is a Yaesu. */}
|
||||
{catState.backend === 'yaesu' && (
|
||||
<TabsContent value="yaesu" className="flex-1 min-h-0 p-0">
|
||||
<YaesuPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} onKeySpeed={setCWSpeedEverywhere} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{catState.backend === 'icom' && (
|
||||
<TabsContent value="icom" className="flex-1 min-h-0 p-0">
|
||||
<IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
||||
@@ -5930,6 +6073,13 @@ export default function App() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* UTC clock — moved out of the header, where it competed with the
|
||||
frequency for the eye. It belongs with the other passive
|
||||
indicators. */}
|
||||
<span className="inline-flex items-center gap-1 font-mono text-[11px] text-muted-foreground shrink-0" title="UTC">
|
||||
<Clock className="size-3" />
|
||||
{utcNow}<span className="text-[9px]">Z</span>
|
||||
</span>
|
||||
{dbConn && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -6001,6 +6151,7 @@ export default function App() {
|
||||
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
|
||||
flexAvailable={catState.backend === 'flex'}
|
||||
icomAvailable={catState.backend === 'icom'}
|
||||
yaesuAvailable={catState.backend === 'yaesu'}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -6113,19 +6264,19 @@ export default function App() {
|
||||
<Dialog open onOpenChange={(o) => { if (!o) setPendingImportPath(null); }}>
|
||||
<DialogContent className="max-w-lg px-6">
|
||||
<DialogHeader className="px-2">
|
||||
<DialogTitle>Import ADIF</DialogTitle>
|
||||
<DialogTitle>{t('imp.dialogTitle')}</DialogTitle>
|
||||
<DialogDescription className="text-xs break-all">
|
||||
{pendingImportPath}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2 px-2 space-y-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Duplicate = same <span className="font-medium text-foreground">callsign + UTC minute + band + mode</span> as a QSO already in the log.
|
||||
{t('imp.dupHintPre')}<span className="font-medium text-foreground">{t('imp.dupHintKey')}</span>{t('imp.dupHintPost')}
|
||||
</div>
|
||||
{([
|
||||
{ id: 'skip', title: 'Skip duplicates', desc: 'Leave existing QSOs untouched, only add new ones. Safe default.' },
|
||||
{ id: 'update', title: 'Update duplicates', desc: 'Refresh existing QSOs with this file — merges its non-empty fields (QSL/LoTW/eQSL/QRZ statuses & dates, etc.) onto the matching QSO. Use this to re-sync from Log4OM or LoTW. Fields the file omits are kept.' },
|
||||
{ id: 'all', title: 'Import everything', desc: 'Insert every record, duplicates included. For intentionally merging two overlapping logs.' },
|
||||
{ id: 'skip', title: t('imp.skipTitle'), desc: t('imp.skipDesc') },
|
||||
{ id: 'update', title: t('imp.updateTitle'), desc: t('imp.updateDesc') },
|
||||
{ id: 'all', title: t('imp.allTitle'), desc: t('imp.allDesc') },
|
||||
] as const).map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
@@ -6153,9 +6304,9 @@ export default function App() {
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
Fix country & zones (cty.dat + ClubLog)
|
||||
{t('imp.ctyTitle')}
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
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 <strong>Update duplicates</strong> to re-fix QSOs already in your log.
|
||||
{t('imp.ctyDesc')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
@@ -6166,16 +6317,16 @@ export default function App() {
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
Fill my station fields from my profile
|
||||
{t('imp.stationTitle')}
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
Backfill <strong>empty</strong> MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power…) plus <strong>Operator</strong> and <strong>Owner callsign</strong> from your active profile. Existing values are kept. Only <strong>STATION_CALLSIGN</strong> is left untouched so a mixed-call log isn't re-routed. Enable when importing <em>your own</em> log.
|
||||
{t('imp.stationDesc')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter className="px-2 bg-transparent border-t-0">
|
||||
<Button variant="outline" onClick={() => setPendingImportPath(null)}>Cancel</Button>
|
||||
<Button onClick={runImport}>Import</Button>
|
||||
<Button variant="outline" onClick={() => setPendingImportPath(null)}>{t('imp.cancel')}</Button>
|
||||
<Button onClick={runImport}>{t('imp.import')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -6185,7 +6336,7 @@ export default function App() {
|
||||
<Dialog open>
|
||||
<DialogContent className="max-w-sm px-6" hideClose>
|
||||
<DialogHeader className="px-2">
|
||||
<DialogTitle>Importing ADIF…</DialogTitle>
|
||||
<DialogTitle>{t('imp.progressTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="px-2 pb-4 space-y-2">
|
||||
{(() => {
|
||||
@@ -6202,8 +6353,8 @@ export default function App() {
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground text-center font-mono">
|
||||
{tot > 0
|
||||
? `${done.toLocaleString()} / ${tot.toLocaleString()} records · ${pct}%`
|
||||
: `${done.toLocaleString()} records…`}
|
||||
? t('imp.progressCount', { done: done.toLocaleString(), tot: tot.toLocaleString(), pct })
|
||||
: t('imp.progressCountOnly', { done: done.toLocaleString() })}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -63,7 +63,7 @@ const CONFIRM_SRC = [
|
||||
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
|
||||
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
|
||||
];
|
||||
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','23cm','13cm'];
|
||||
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
||||
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
|
||||
const EMISSIONS = ['CW', 'PHONE', 'DIGITAL'];
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ const FIELDS: FieldDef[] = [
|
||||
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
|
||||
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
|
||||
// Contacted station (location / activation refs / SIG)
|
||||
{ id: 'grid', label: 'bulk.fGrid', group: 'Contacted station', kind: 'text', upper: true },
|
||||
{ id: 'state', label: 'bulk.fState', group: 'Contacted station', kind: 'text', upper: true },
|
||||
{ id: 'cnty', label: 'bulk.fCnty', group: 'Contacted station', kind: 'text' },
|
||||
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||
|
||||
@@ -26,7 +26,9 @@ export interface QueryFilter {
|
||||
|
||||
// Curated field catalog. `value` MUST match a column in the backend whitelist
|
||||
// (qso.FilterableFields); `type` only drives which operators/value input we show.
|
||||
type FieldType = 'text' | 'number' | 'date';
|
||||
// 'date' is an ISO timestamp column (qso_date); 'adifdate' is an ADIF YYYYMMDD
|
||||
// string (the confirmation dates) — picked with a calendar, stored 8-digit.
|
||||
type FieldType = 'text' | 'number' | 'date' | 'adifdate';
|
||||
const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
|
||||
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
|
||||
@@ -57,16 +59,30 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||
{ value: 'wwff_ref', label: 'fltb.fWwff', type: 'text' },
|
||||
{ value: 'rig', label: 'fltb.fRig', type: 'text' },
|
||||
{ value: 'ant', label: 'fltb.fAntenna', type: 'text' },
|
||||
// Same naming as the bulk editor: each entry says whether it is a STATUS or a
|
||||
// DATE and in which direction, grouped by channel. "Upload" was wrong for half
|
||||
// of them — a paper QSL is not uploaded.
|
||||
{ value: 'qsl_sent', label: 'fltb.fQslSent', type: 'text' },
|
||||
{ value: 'qsl_sent_date', label: 'fltb.fQslSentDate', type: 'adifdate' },
|
||||
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
|
||||
{ value: 'qsl_rcvd_date', label: 'fltb.fQslRcvdDate', type: 'adifdate' },
|
||||
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
|
||||
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
|
||||
{ value: 'lotw_sent_date', label: 'fltb.fLotwSentDate', type: 'adifdate' },
|
||||
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
|
||||
{ value: 'lotw_rcvd_date', label: 'fltb.fLotwRcvdDate', type: 'adifdate' },
|
||||
{ value: 'eqsl_sent', label: 'fltb.fEqslSent', type: 'text' },
|
||||
{ value: 'eqsl_sent_date', label: 'fltb.fEqslSentDate', type: 'adifdate' },
|
||||
{ value: 'eqsl_rcvd', label: 'fltb.fEqslRcvd', type: 'text' },
|
||||
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzUpload', type: 'text' },
|
||||
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogUpload', type: 'text' },
|
||||
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogUpload', type: 'text' },
|
||||
{ value: 'eqsl_rcvd_date', label: 'fltb.fEqslRcvdDate', type: 'adifdate' },
|
||||
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzSent', type: 'text' },
|
||||
{ value: 'qrzcom_qso_upload_date', label: 'fltb.fQrzSentDate', type: 'adifdate' },
|
||||
{ value: 'qrzcom_qso_download_status', label: 'fltb.fQrzRcvd', type: 'text' },
|
||||
{ value: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' },
|
||||
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
|
||||
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
|
||||
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
|
||||
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
|
||||
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
||||
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
||||
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
||||
@@ -112,6 +128,8 @@ const NUM_OPS: FilterOp[] = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'empty', 'notem
|
||||
|
||||
function opsFor(field: string): { value: FilterOp; label: string }[] {
|
||||
const t = FIELDS.find((f) => f.value === field)?.type ?? 'text';
|
||||
// 'adifdate' is stored as a string but sorts chronologically, so it takes the
|
||||
// comparison operators (before/after), not the text ones.
|
||||
const allow = t === 'text' ? TEXT_OPS : NUM_OPS;
|
||||
return OPS.filter((o) => allow.includes(o.value));
|
||||
}
|
||||
@@ -253,12 +271,23 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
||||
// An ADIF date is picked with a calendar but STORED as the
|
||||
// 8-digit form the column holds, so the comparison stays a
|
||||
// plain string one on both sides.
|
||||
type={fieldType === 'date' || fieldType === 'adifdate' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
||||
className="h-8 flex-1 text-xs"
|
||||
disabled={!needsValue}
|
||||
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'}
|
||||
value={c.value}
|
||||
onChange={(e) => setCond(i, { value: e.target.value })}
|
||||
// \d, not d: the escape was missing, so the test never matched
|
||||
// an 8-digit ADIF date and the calendar input was handed
|
||||
// "20260728" — which type=date rejects, showing an empty box
|
||||
// over a value that was really there.
|
||||
value={fieldType === 'adifdate' && /^\d{8}$/.test(c.value)
|
||||
? `${c.value.slice(0, 4)}-${c.value.slice(4, 6)}-${c.value.slice(6, 8)}`
|
||||
: c.value}
|
||||
onChange={(e) => setCond(i, {
|
||||
value: fieldType === 'adifdate' ? e.target.value.replace(/-/g, '') : e.target.value,
|
||||
})}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
|
||||
/>
|
||||
<button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title={t('fltb.remove')}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX, ChevronDown, SlidersHorizontal } from 'lucide-react';
|
||||
import {
|
||||
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
||||
GetFlexState, FlexATUStart, FlexATUBypass, FlexSetATUMemories, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||
FlexMox, FlexAmpOperate,
|
||||
GetPGXLStatus, PGXLSetFanMode,
|
||||
@@ -125,8 +125,8 @@ function Segmented({ value, options, onChange, disabled }: {
|
||||
}
|
||||
|
||||
// Chip — a compact on/off pill (NB/NR/ANF/VOX/MON…).
|
||||
function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
|
||||
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber';
|
||||
function Chip({ on, onClick, label, disabled, accent = 'emerald', title }: {
|
||||
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; title?: string;
|
||||
}) {
|
||||
const onCls = {
|
||||
emerald: 'bg-success border-success text-success-foreground',
|
||||
@@ -135,7 +135,7 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
|
||||
amber: 'bg-warning border-warning text-warning-foreground',
|
||||
}[accent];
|
||||
return (
|
||||
<button type="button" onClick={onClick} disabled={disabled}
|
||||
<button type="button" onClick={onClick} disabled={disabled} title={title}
|
||||
className={cn(
|
||||
// min width (not fixed) so a longer label like STONE keeps symmetric
|
||||
// padding instead of overflowing; short labels (NB/NR/APF) stay aligned.
|
||||
@@ -146,6 +146,37 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
|
||||
);
|
||||
}
|
||||
|
||||
// SmartSDR reports the tuner as an enum. It is worth decoding rather than
|
||||
// showing raw: TUNE_FAIL and "never tuned" both leave the radio looking normal,
|
||||
// and the operator transmits into a bad match either way.
|
||||
function atuLabel(status: string | undefined, t: (k: string) => string): string {
|
||||
switch ((status || '').toUpperCase()) {
|
||||
case 'TUNE_IN_PROGRESS': return t('flxp.atuTuning');
|
||||
case 'TUNE_SUCCESSFUL':
|
||||
case 'TUNE_OK': return t('flxp.atuOk');
|
||||
case 'TUNE_FAIL':
|
||||
case 'TUNE_FAIL_BYPASS': return t('flxp.atuFail');
|
||||
case 'TUNE_BYPASS':
|
||||
case 'TUNE_MANUAL_BYPASS': return t('flxp.atuBypassed');
|
||||
case 'TUNE_ABORTED': return t('flxp.atuAborted');
|
||||
case 'TUNE_NOT_STARTED':
|
||||
case 'NONE':
|
||||
case '': return t('flxp.atuIdle');
|
||||
default: return status || '';
|
||||
}
|
||||
}
|
||||
|
||||
function atuTone(status: string | undefined): string {
|
||||
switch ((status || '').toUpperCase()) {
|
||||
case 'TUNE_SUCCESSFUL':
|
||||
case 'TUNE_OK': return 'text-success';
|
||||
case 'TUNE_FAIL':
|
||||
case 'TUNE_FAIL_BYPASS': return 'text-danger';
|
||||
case 'TUNE_IN_PROGRESS': return 'text-warning';
|
||||
default: return 'text-muted-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, sliderAccent }: {
|
||||
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
|
||||
disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; sliderAccent?: string;
|
||||
@@ -624,6 +655,33 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ATU — the radio's built-in tuner. Sits under TUNE because that is
|
||||
the order of operations: the tuner needs a carrier to measure
|
||||
against, so TUNE first, then start a tuning cycle. */}
|
||||
<div className="flex items-center gap-2 border-t border-border/60 pt-3">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">ATU</span>
|
||||
<button type="button" disabled={off}
|
||||
title={t('flxp.atuTuneHint')}
|
||||
onClick={() => FlexATUStart().catch(() => {})}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 border-warning text-warning bg-card hover:bg-warning-muted transition-all disabled:opacity-30">
|
||||
{t('flxp.atuTune')}
|
||||
</button>
|
||||
<button type="button" disabled={off}
|
||||
title={t('flxp.atuBypassHint')}
|
||||
onClick={() => FlexATUBypass().catch(() => {})}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 border-border text-muted-foreground bg-card hover:bg-muted transition-all disabled:opacity-30">
|
||||
{t('flxp.atuBypass')}
|
||||
</button>
|
||||
<Chip on={st.atu_memories} disabled={off} label={t('flxp.atuMem')} accent="cyan"
|
||||
title={t('flxp.atuMemHint')}
|
||||
onClick={() => change('atu_memories', !st.atu_memories, () => FlexSetATUMemories(!st.atu_memories))} />
|
||||
{/* The status is the whole point: a tune that FAILED and one that
|
||||
never ran look identical on the radio's own front panel. */}
|
||||
<span className={cn('ml-auto text-[11px] font-mono font-semibold whitespace-nowrap', atuTone(st.atu_status))}>
|
||||
{atuLabel(st.atu_status, t)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
{!isCW ? (
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
|
||||
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
|
||||
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
|
||||
import { rstOptions, type RSTLists } from '@/lib/rst';
|
||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
||||
@@ -37,7 +37,7 @@ function pfxOf(call: string): string {
|
||||
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
|
||||
}
|
||||
|
||||
const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm'];
|
||||
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
||||
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
|
||||
// label holds an i18n key (resolved with t() at render time).
|
||||
const QSL_STATUSES = [
|
||||
@@ -330,19 +330,28 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
setLooking(true);
|
||||
setLocalErr('');
|
||||
try {
|
||||
const r: any = await LookupCallsign(call);
|
||||
// FRESH: this fetch is a deliberate click, so it bypasses the cache and
|
||||
// refreshes it. A cached answer from a thinner QRZ subscription (or any
|
||||
// stale row) otherwise stayed for its whole 30-day life and the button
|
||||
// appeared to do nothing.
|
||||
const r: any = await LookupCallsignFresh(call);
|
||||
// The lookup WINS over what is in the record — that is the point of asking
|
||||
// for it. But an EMPTY result must never blank a good value: `??` only
|
||||
// guards against null, and Go marshals an unset string as "", so a QRZ
|
||||
// record with no grid used to wipe the grid that was already there.
|
||||
const keep = (found: any, cur: any) => (found === undefined || found === null || found === '' ? cur : found);
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
name: r.name ?? d.name,
|
||||
qth: r.qth ?? d.qth,
|
||||
address: r.address ?? (d as any).address,
|
||||
email: r.email ?? (d as any).email,
|
||||
country: r.country ?? d.country,
|
||||
grid: r.grid ?? d.grid,
|
||||
state: r.state ?? d.state,
|
||||
cnty: r.cnty ?? d.cnty,
|
||||
cont: r.cont ?? d.cont,
|
||||
qsl_via: r.qsl_via ?? d.qsl_via,
|
||||
name: keep(r.name, d.name),
|
||||
qth: keep(r.qth, d.qth),
|
||||
address: keep(r.address, (d as any).address),
|
||||
email: keep(r.email, (d as any).email),
|
||||
country: keep(r.country, d.country),
|
||||
grid: keep(r.grid, d.grid),
|
||||
state: keep(r.state, d.state),
|
||||
cnty: keep(r.cnty, d.cnty),
|
||||
cont: keep(r.cont, d.cont),
|
||||
qsl_via: keep(r.qsl_via, d.qsl_via),
|
||||
dxcc: r.dxcc || d.dxcc,
|
||||
cqz: r.cqz || d.cqz,
|
||||
ituz: r.ituz || d.ituz,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||
ChevronDown, ChevronRight,
|
||||
@@ -156,6 +156,7 @@ interface Props {
|
||||
onMainPaneChanged?: (side: 'left' | 'right', value: string) => void; // live Main-view layout update
|
||||
flexAvailable?: boolean; // CAT backend is FlexRadio → offer it as a Main pane
|
||||
icomAvailable?: boolean; // CAT backend is Icom → offer the Icom console as a Main pane
|
||||
yaesuAvailable?: boolean; // CAT backend is Yaesu → offer the Yaesu console as a Main pane
|
||||
}
|
||||
|
||||
// Pretty little card showing what OpsLog will stamp on each QSO based on
|
||||
@@ -817,7 +818,7 @@ function RelayAutoPanel() {
|
||||
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
|
||||
// which is profile-prefixed). Self-contained so it owns its async-loaded state.
|
||||
const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol'];
|
||||
function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean }) {
|
||||
function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean; yaesuAvailable?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const [left, setLeft] = useState('map1');
|
||||
const [right, setRight] = useState('map2');
|
||||
@@ -826,10 +827,11 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?
|
||||
...MAIN_PANE_VALUES,
|
||||
...(flexAvailable ? ['flex'] : []),
|
||||
...(icomAvailable ? ['icom'] : []),
|
||||
...(yaesuAvailable ? ['yaesu'] : []),
|
||||
].map((value) => ({ value, label: t(`settings.pane.${value}`) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
useEffect(() => {
|
||||
const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_VALUES.includes(v);
|
||||
const valid = (v: string) => v === 'flex' || v === 'icom' || v === 'yaesu' || MAIN_PANE_VALUES.includes(v);
|
||||
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
|
||||
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
|
||||
}, []);
|
||||
@@ -1006,17 +1008,25 @@ function FlexBandAntennasPanel({ bands }: { bands: string[] }) {
|
||||
// model sets icom_addr so the backend identifies it (civ.ModelName) and the UI
|
||||
// adapts (e.g. the attenuator steps differ by model). Keep in lockstep with
|
||||
// civ.ModelName in internal/cat/civ/civ.go. `addr` is decimal.
|
||||
// The IC-7700 and IC-7800 were listed at 0x88 and 0x80 — which are the IC-7100's
|
||||
// and the IC-7410's. Picking one of them set an address the rig never answers on,
|
||||
// and the backend then named it as the other model.
|
||||
const ICOM_MODELS: { name: string; addr: number }[] = [
|
||||
{ name: 'IC-705', addr: 0xA4 },
|
||||
{ name: 'IC-7100', addr: 0x88 },
|
||||
{ name: 'IC-7300', addr: 0x94 },
|
||||
{ name: 'IC-7300MKII', addr: 0xB6 },
|
||||
{ name: 'IC-7410', addr: 0x80 },
|
||||
{ name: 'IC-7600', addr: 0x7A },
|
||||
{ name: 'IC-7610', addr: 0x98 },
|
||||
{ name: 'IC-7700', addr: 0x88 },
|
||||
{ name: 'IC-7800', addr: 0x80 },
|
||||
{ name: 'IC-7700', addr: 0x74 },
|
||||
{ name: 'IC-7800', addr: 0x6A },
|
||||
{ name: 'IC-7851', addr: 0x8E },
|
||||
{ name: 'IC-9100', addr: 0x7C },
|
||||
{ name: 'IC-9700', addr: 0xA2 },
|
||||
];
|
||||
|
||||
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable }: Props) {
|
||||
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable, yaesuAvailable }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -1052,10 +1062,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const [bandDraft, setBandDraft] = useState('');
|
||||
const [modeDraft, setModeDraft] = useState('');
|
||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
|
||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
|
||||
yaesu_port: '', yaesu_baud: 38400, 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',
|
||||
digital_default: 'FT8', share_enabled: false, share_port: 4532,
|
||||
});
|
||||
const [rotator, setRotator] = useState<RotatorSettings>({
|
||||
enabled: false, type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false, rotator_num: 1,
|
||||
@@ -1103,13 +1114,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
|
||||
qso_record: boolean; qso_dir: string; preroll_seconds: number;
|
||||
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3';
|
||||
from_gain: number; mic_gain: number;
|
||||
from_gain: number; mic_gain: number; tx_gain: number;
|
||||
};
|
||||
type AudioDev = { id: string; name: string; default: boolean };
|
||||
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
|
||||
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
|
||||
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav',
|
||||
from_gain: 100, mic_gain: 100,
|
||||
from_gain: 100, mic_gain: 100, tx_gain: 100,
|
||||
});
|
||||
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
|
||||
const [audioOutputs, setAudioOutputs] = useState<AudioDev[]>([]);
|
||||
@@ -1483,27 +1494,49 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
return () => { off(); };
|
||||
}, []);
|
||||
|
||||
// Which (profile, callsign, grid) the auto-fill below last derived from.
|
||||
// Without it, simply OPENING the settings counted as "the source changed" and
|
||||
// overwrote the operator's own values.
|
||||
const derivedFrom = useRef<string>('');
|
||||
|
||||
// Auto-fill the active profile's MY_* DXCC metadata from the station
|
||||
// callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon). These
|
||||
// are derived values, so they always recompute when the callsign or grid
|
||||
// changes — the user can still edit a field, it just re-populates when the
|
||||
// source changes. Debounced so we don't hammer cty.dat while typing.
|
||||
// callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon).
|
||||
//
|
||||
// These are derived values, so they recompute when the callsign or grid
|
||||
// changes. What they must NOT do is recompute on a plain load: cty.dat gives
|
||||
// the zones of the ENTITY, and a large country spans several — an operator in
|
||||
// CQ 14 / ITU 27 was handed 15 and 28 and corrected them by hand, and every
|
||||
// restart put the wrong pair back. cty.dat is a starting point, not an
|
||||
// authority, so on a load we only fill fields that are EMPTY; a value the
|
||||
// operator typed stays until the callsign or grid itself changes.
|
||||
// Debounced so we don't hammer cty.dat while typing.
|
||||
useEffect(() => {
|
||||
const call = (activeProfile?.callsign ?? '').trim();
|
||||
if (!call) return;
|
||||
const grid = (activeProfile?.my_grid ?? '').trim();
|
||||
const source = `${activeProfile?.id ?? 0}|${call.toUpperCase()}|${grid.toUpperCase()}`;
|
||||
// First sight of this profile — a load, not an edit.
|
||||
const isLoad = derivedFrom.current === '' || derivedFrom.current.split('|')[0] !== String(activeProfile?.id ?? 0);
|
||||
const t = window.setTimeout(async () => {
|
||||
try {
|
||||
const i: any = await ComputeStationInfo(call, grid);
|
||||
derivedFrom.current = source;
|
||||
setActiveProfile((p) => {
|
||||
if (!p) return p;
|
||||
const patch: any = {};
|
||||
if (i.country) patch.my_country = i.country;
|
||||
if (i.dxcc) patch.my_dxcc = i.dxcc;
|
||||
if (i.cqz) patch.my_cqz = i.cqz;
|
||||
if (i.ituz) patch.my_ituz = i.ituz;
|
||||
if (i.lat) patch.my_lat = i.lat;
|
||||
if (i.lon) patch.my_lon = i.lon;
|
||||
// On a load, keep in patch only what the profile does not already have.
|
||||
const put = (k: string, v: any) => {
|
||||
if (!v) return;
|
||||
const cur = (p as any)[k];
|
||||
if (isLoad && cur !== undefined && cur !== null && cur !== '' && cur !== 0) return;
|
||||
patch[k] = v;
|
||||
};
|
||||
put('my_country', i.country);
|
||||
put('my_dxcc', i.dxcc);
|
||||
put('my_cqz', i.cqz);
|
||||
put('my_ituz', i.ituz);
|
||||
put('my_lat', i.lat);
|
||||
put('my_lon', i.lon);
|
||||
// Only re-render when a value actually changed (prevents loops).
|
||||
const changed = Object.keys(patch).some((k) => (p as any)[k] !== patch[k]);
|
||||
return changed ? { ...p, ...patch } : p;
|
||||
@@ -2316,6 +2349,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<SelectContent>
|
||||
<SelectItem value="omnirig">{t('cat.optOmnirig')}</SelectItem>
|
||||
<SelectItem value="flex">{t('cat.optFlex')}</SelectItem>
|
||||
<SelectItem value="yaesu">{t('cat.optYaesu')}</SelectItem>
|
||||
<SelectItem value="xiegu">{t('cat.optXiegu')}</SelectItem>
|
||||
<SelectItem value="icom">{t('cat.optIcom')}</SelectItem>
|
||||
<SelectItem value="icom-net">{t('cat.optIcomNet')}</SelectItem>
|
||||
<SelectItem value="tci">{t('cat.optTci')}</SelectItem>
|
||||
@@ -2334,6 +2369,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{catCfg.backend === 'omnirig' && (
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.omnirigVfo')}</Label>
|
||||
<Select value={catCfg.omnirig_vfo || 'auto'}
|
||||
onValueChange={(v) => setCatCfg((s) => ({ ...s, omnirig_vfo: v === 'auto' ? '' : v }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t('cat.omnirigVfoAuto')}</SelectItem>
|
||||
<SelectItem value="A">{t('cat.omnirigVfoA')}</SelectItem>
|
||||
<SelectItem value="B">{t('cat.omnirigVfoB')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">{t('cat.omnirigVfoHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{catCfg.backend === 'flex' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
@@ -2343,8 +2393,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.port')}</Label>
|
||||
<Input type="number" value={catCfg.flex_port || 4992}
|
||||
onChange={(e) => setCatCfg((s) => ({ ...s, flex_port: parseInt(e.target.value) || 4992 }))} />
|
||||
<PortInput value={catCfg.flex_port || 4992}
|
||||
onChange={(n) => setCatCfg((s) => ({ ...s, flex_port: n }))} fallback={4992} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} />
|
||||
@@ -2368,6 +2418,72 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{catCfg.backend === 'xiegu' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.xieguPort')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select value={catCfg.xiegu_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, xiegu_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>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.baud')}</Label>
|
||||
<Select value={String(catCfg.xiegu_baud || 19200)} onValueChange={(v) => setCatCfg((s) => ({ ...s, xiegu_baud: parseInt(v) || 19200 }))}>
|
||||
<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.xieguBaudHint')}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.civAddr')}</Label>
|
||||
<Input value={'0x' + (catCfg.xiegu_addr || 0x70).toString(16).toUpperCase().padStart(2, '0')}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value.replace(/^0x/i, ''), 16);
|
||||
if (!isNaN(n) && n > 0 && n <= 0xFF) setCatCfg((s) => ({ ...s, xiegu_addr: n }));
|
||||
}} />
|
||||
<span className="text-xs text-muted-foreground">{t('cat.xieguAddrHint')}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{catCfg.backend === 'yaesu' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.yaesuPort')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select value={catCfg.yaesu_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, yaesu_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.yaesuPortHint')}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.baud')}</Label>
|
||||
<Select value={String(catCfg.yaesu_baud || 38400)} onValueChange={(v) => setCatCfg((s) => ({ ...s, yaesu_baud: parseInt(v) || 38400 }))}>
|
||||
<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.yaesuBaudHint')}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{catCfg.backend === 'icom' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
@@ -2467,8 +2583,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.port')}</Label>
|
||||
<Input type="number" value={catCfg.tci_port || 40001}
|
||||
onChange={(e) => setCatCfg((s) => ({ ...s, tci_port: parseInt(e.target.value) || 40001 }))} />
|
||||
<PortInput value={catCfg.tci_port || 40001}
|
||||
onChange={(n) => setCatCfg((s) => ({ ...s, tci_port: n }))} fallback={40001} />
|
||||
</div>
|
||||
<p className="col-span-2 text-xs text-muted-foreground">
|
||||
{t('cat.tciHint')}
|
||||
@@ -2514,6 +2630,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{/* CAT sharing. A native backend owns the rig's serial port, so without
|
||||
this WSJT-X and friends are locked out of the radio entirely. */}
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={!!catCfg.share_enabled}
|
||||
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, share_enabled: !!c }))}
|
||||
/>
|
||||
{t('cat.share')}
|
||||
</label>
|
||||
<p className="text-[11px] text-muted-foreground">{t('cat.shareHint')}</p>
|
||||
{catCfg.share_enabled && (
|
||||
<div className="space-y-1 max-w-[200px]">
|
||||
<Label>{t('cat.sharePort')}</Label>
|
||||
<PortInput
|
||||
value={catCfg.share_port || 4532}
|
||||
fallback={4532}
|
||||
onChange={(n) => setCatCfg((s) => ({ ...s, share_port: n }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{catCfg.backend === 'omnirig' && (
|
||||
<>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
@@ -2528,11 +2666,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{catCfg.backend === 'flex' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('cat.flexHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -2639,10 +2772,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>TCP port</Label>
|
||||
<Input
|
||||
type="number" min={1} max={65535}
|
||||
<PortInput
|
||||
value={ultrabeam.port}
|
||||
onChange={(e) => setUltrabeam((s) => ({ ...s, port: parseInt(e.target.value) || 23 }))}
|
||||
onChange={(n) => setUltrabeam((s) => ({ ...s, port: n }))} fallback={23}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
@@ -2928,8 +3060,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>TCP port</Label>
|
||||
<Input type="number" min={1} max={65535} value={amp.port}
|
||||
onChange={(e) => patchAmp(i, { port: parseInt(e.target.value) || 9008 })} className="font-mono" />
|
||||
<PortInput value={amp.port}
|
||||
onChange={(n) => patchAmp(i, { port: n })} fallback={9008} className="font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -3099,10 +3231,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
|
||||
<Input
|
||||
type="number" min={1} max={65535}
|
||||
<PortInput
|
||||
value={rotator.port}
|
||||
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : isARCO ? 4001 : 12000) }))}
|
||||
onChange={(n) => setRotator((s) => ({ ...s, port: n }))} fallback={isRG ? 9006 : isARCO ? 4001 : 12000}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
@@ -3178,6 +3309,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<SelectItem value="winkeyer">{t('wk.engWinkeyer')}</SelectItem>
|
||||
<SelectItem value="serial">{t('wk.engSerial')}</SelectItem>
|
||||
<SelectItem value="icom">{t('wk.engIcom')}</SelectItem>
|
||||
<SelectItem value="yaesu">{t('wk.engYaesu')}</SelectItem>
|
||||
<SelectItem value="flex">{t('wk.engFlex')}</SelectItem>
|
||||
<SelectItem value="tci" disabled>{t('wk.engTci')}</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -3208,6 +3340,22 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : wk.engine === 'yaesu' ? (
|
||||
<>
|
||||
{(!catCfg.enabled || catCfg.backend !== 'yaesu') && (
|
||||
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
||||
<span aria-hidden>⚠</span>
|
||||
<span>{t('wk.catWarnYaesu', { backend: catCfg.enabled ? (catCfg.backend || 'none') : 'disabled' })}</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground -mt-1">{t('wk.yaesuHint')}</p>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('wk.speed')}</Label>
|
||||
<Input type="number" min={4} max={60} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : wk.engine === 'flex' ? (
|
||||
<>
|
||||
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
|
||||
@@ -4663,7 +4811,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Label className="text-sm">{t('db.host')}</Label>
|
||||
<Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} />
|
||||
<Label className="text-sm">{t('db.port')}</Label>
|
||||
<Input type="number" className="h-8 w-28 font-mono" value={mysqlCfg.port} onChange={(e) => setMysqlField({ port: parseInt(e.target.value, 10) || 0 })} />
|
||||
<PortInput className="h-8 w-28 font-mono" value={mysqlCfg.port} fallback={3306} onChange={(n) => setMysqlField({ port: n })} />
|
||||
<Label className="text-sm">{t('db.database')}</Label>
|
||||
<Input className="h-8" placeholder="opslog" value={mysqlCfg.database} onChange={(e) => setMysqlField({ database: e.target.value })} />
|
||||
<Label className="text-sm">{t('db.user')}</Label>
|
||||
@@ -4701,6 +4849,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
icom: t('cat.optIcom'),
|
||||
'icom-net': t('cat.optIcomNet'),
|
||||
tci: t('cat.optTci'),
|
||||
yaesu: t('cat.optYaesu'),
|
||||
xiegu: t('cat.optXiegu'),
|
||||
} as Record<string, string>)[catCfg.backend] ?? '';
|
||||
|
||||
const deviceSelect = (
|
||||
@@ -4874,6 +5024,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Playback level. Nothing else could raise it: the message went to the
|
||||
rig exactly as recorded, so a quietly recorded mic drove the radio
|
||||
quietly and the only remedies were the rig's own USB input menu or
|
||||
the Windows mixer. */}
|
||||
<div className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2">
|
||||
<Label className="text-sm">{t('aud.txLevel')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="range" min={10} max={400} step={5} value={audioCfg.tx_gain}
|
||||
onChange={(e) => setAudioField({ tx_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
|
||||
<span className="font-mono text-xs w-12 text-right">{audioCfg.tx_gain}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('aud.txLevelHint')}</p>
|
||||
{dvkErr && <p className="text-[11px] text-destructive">{dvkErr}</p>}
|
||||
<div className="space-y-1.5">
|
||||
{dvkMsgs.map((m) => {
|
||||
@@ -4999,7 +5162,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</label>
|
||||
<TelemetryToggle />
|
||||
|
||||
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} icomAvailable={icomAvailable} />
|
||||
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} icomAvailable={icomAvailable} yaesuAvailable={yaesuAvailable} />
|
||||
|
||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">{t('gen.pwEnc')}</h4>
|
||||
@@ -5139,7 +5302,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Input className="h-8" placeholder="ex5.mail.ovh.net" value={emailCfg.smtp_host} onChange={(e) => setEmailField({ smtp_host: e.target.value })} />
|
||||
<Label className="text-sm">Port / encryption</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input type="number" className="h-8 w-24 font-mono" value={emailCfg.smtp_port} onChange={(e) => setEmailField({ smtp_port: parseInt(e.target.value, 10) || 0 })} />
|
||||
<PortInput className="h-8 w-24 font-mono" value={emailCfg.smtp_port} fallback={587} onChange={(n) => setEmailField({ smtp_port: n })} />
|
||||
<Select value={emailCfg.encryption} onValueChange={(v) => setEmailField({ encryption: v as any })}>
|
||||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -5343,6 +5506,53 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
);
|
||||
}
|
||||
|
||||
// PortInput — a TCP port field you can actually clear.
|
||||
//
|
||||
// Every port box was written as `parseInt(e.target.value) || <default>`. Delete
|
||||
// the contents and parseInt gives NaN, so the default is written straight back:
|
||||
// the digits reappear under the cursor and the only way to enter a different
|
||||
// port is to overwrite character by character. Reported on the cluster editor;
|
||||
// the same line existed in eight other places.
|
||||
//
|
||||
// The raw TEXT is the state here, and the parent is only told about a value that
|
||||
// is actually a port. An empty box stays empty while you type; on blur it falls
|
||||
// back to the last good value, so nothing is ever saved as 0 or NaN.
|
||||
function PortInput({ value, onChange, fallback, className, min = 1, max = 65535 }: {
|
||||
value: number; onChange: (n: number) => void; fallback: number; className?: string; min?: number; max?: number;
|
||||
}) {
|
||||
const [text, setText] = useState(value ? String(value) : '');
|
||||
const typed = useRef(false);
|
||||
// Only adopt the incoming value while the operator has NOT started typing —
|
||||
// settings arrive from the backend after mount, and re-syncing mid-edit is
|
||||
// precisely the behaviour being fixed.
|
||||
useEffect(() => {
|
||||
if (!typed.current) setText(value ? String(value) : '');
|
||||
}, [value]);
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className={className}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
typed.current = true;
|
||||
const digits = e.target.value.replace(/[^0-9]/g, '').slice(0, 5);
|
||||
setText(digits);
|
||||
const n = parseInt(digits, 10);
|
||||
if (n >= min && n <= max) onChange(n);
|
||||
}}
|
||||
onBlur={() => {
|
||||
typed.current = false;
|
||||
const n = parseInt(text, 10);
|
||||
if (!(n >= min && n <= max)) {
|
||||
setText(String(fallback));
|
||||
onChange(fallback);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ClusterServerEditor edits one row of cluster_servers. Init commands are
|
||||
// free-form (one per line); the backend strips blanks and "//" comments.
|
||||
interface ClusterEditorProps {
|
||||
@@ -5374,7 +5584,7 @@ function ClusterServerEditor({ value, onCancel, onSave }: ClusterEditorProps) {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Port</Label>
|
||||
<Input type="number" min={1} max={65535} className="font-mono" value={s.port} onChange={(e) => update({ port: parseInt(e.target.value) || 7300 })} />
|
||||
<PortInput className="font-mono" value={s.port} fallback={7300} onChange={(n) => update({ port: n })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Login callsign (optional)</Label>
|
||||
|
||||
@@ -26,7 +26,7 @@ interface Props {
|
||||
wpm: number;
|
||||
macros: WKMacro[];
|
||||
sent: string; // text echoed back by the keyer as it transmits
|
||||
source: 'winkeyer' | 'icom' | 'flex'; // CW output engine (chosen in Settings → CW Keyer)
|
||||
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu'; // CW output engine (chosen in Settings → CW Keyer)
|
||||
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||
onSetBreakIn?: (mode: number) => void;
|
||||
onSelectPort: (p: string) => void;
|
||||
@@ -101,16 +101,18 @@ export function WinkeyerPanel({
|
||||
<Radio className="size-4 text-primary shrink-0" />
|
||||
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : 'WinKeyer'}
|
||||
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : 'WinKeyer'}
|
||||
</span>
|
||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
||||
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
||||
<div className="flex-1" />
|
||||
{source === 'icom' || source === 'flex' ? (
|
||||
{source === 'icom' || source === 'flex' || source === 'yaesu' ? (
|
||||
<span className="text-[11px] font-medium text-muted-foreground">
|
||||
{source === 'flex'
|
||||
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
||||
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
||||
: source === 'yaesu'
|
||||
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
|
||||
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
||||
</span>
|
||||
) : !connected ? (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna } from 'lucide-react';
|
||||
import {
|
||||
GetYaesuState, RefreshYaesuPanel,
|
||||
SetYaesuPower, SetYaesuMicGain, SetYaesuAFGain, SetYaesuRFGain, SetYaesuSquelch,
|
||||
SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel,
|
||||
SetYaesuNarrow, SetYaesuVOX, SetYaesuSplit, SetYaesuBand, TuneYaesuATU,
|
||||
SetYaesuModeRaw, SetYaesuSplitOffset, SetYaesuKeySpeed, SetYaesuBreakIn, YaesuZeroIn, GetCATState,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sMeterRST } from '@/lib/rst';
|
||||
import { MeterBar } from '@/components/MeterBar';
|
||||
|
||||
type YaesuState = {
|
||||
available: boolean; model?: string; mode?: string; raw_mode?: string;
|
||||
transmitting: boolean; split: boolean;
|
||||
s_meter: number; power_meter: number; swr_meter: number;
|
||||
rf_power: number; mic_gain: number; af_gain: number; rf_gain: number; squelch: number;
|
||||
agc?: string; preamp: number; att: number;
|
||||
nb: boolean; nr: boolean; nr_level: number; narrow: boolean; vox: boolean;
|
||||
split_tx_hz?: number; key_speed?: number; break_in?: boolean; swr?: number; power_w?: number;
|
||||
};
|
||||
|
||||
const ZERO: YaesuState = {
|
||||
available: false, transmitting: false, split: false,
|
||||
s_meter: 0, power_meter: 0, swr_meter: 0,
|
||||
rf_power: 0, mic_gain: 0, af_gain: 0, rf_gain: 0, squelch: 0,
|
||||
preamp: 0, att: 0, nb: false, nr: false, nr_level: 0, narrow: false, vox: false,
|
||||
};
|
||||
|
||||
// Band buttons use the rig's OWN band memory (CAT "BS"), not a frequency we
|
||||
// choose: pressing 20 m lands where the operator last was on 20 m, which is what
|
||||
// the radio's own band keys do. That is why these are band names, not Hz.
|
||||
const BANDS = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];
|
||||
|
||||
// Mode buttons. CW, RTTY, DIGI and PSK exist on BOTH sidebands on a Yaesu and
|
||||
// the operator is the one who knows which they want, so each shows its sideband
|
||||
// and CLICKING AN ACTIVE BUTTON AGAIN flips it: CW-U → CW-L → CW-U. One button,
|
||||
// one finger, no hidden gesture. SSB takes its sideband from the frequency, as
|
||||
// the band plan dictates, and AM/FM have none.
|
||||
//
|
||||
// PSK rides on the rig's DATA mode, like the other digital modes — the button
|
||||
// exists because the operator thinks in modes, not in what the radio calls them.
|
||||
type ModeBtn = { id: string; label: string; sideband: boolean; rig: (side: 'U' | 'L') => string };
|
||||
const MODES: ModeBtn[] = [
|
||||
{ id: 'SSB', label: 'SSB', sideband: false, rig: () => 'SSB' },
|
||||
{ id: 'CW', label: 'CW', sideband: true, rig: (s) => 'CW-' + s },
|
||||
{ id: 'RTTY', label: 'RTTY', sideband: true, rig: (s) => 'RTTY-' + s },
|
||||
{ id: 'DIGI', label: 'DIGI', sideband: true, rig: (s) => 'DATA-' + s },
|
||||
{ id: 'PSK', label: 'PSK', sideband: true, rig: (s) => 'DATA-' + s },
|
||||
{ id: 'AM', label: 'AM', sideband: false, rig: () => 'AM' },
|
||||
{ id: 'FM', label: 'FM', sideband: false, rig: () => 'FM' },
|
||||
];
|
||||
|
||||
// Which button the rig's current raw mode belongs to, and on which sideband.
|
||||
function activeMode(raw?: string): { id: string; side: 'U' | 'L' } | null {
|
||||
switch ((raw || '').toUpperCase()) {
|
||||
case 'USB': return { id: 'SSB', side: 'U' };
|
||||
case 'LSB': return { id: 'SSB', side: 'L' };
|
||||
case 'CW-U': return { id: 'CW', side: 'U' };
|
||||
case 'CW-L': return { id: 'CW', side: 'L' };
|
||||
case 'RTTY-U': return { id: 'RTTY', side: 'U' };
|
||||
case 'RTTY-L': return { id: 'RTTY', side: 'L' };
|
||||
case 'DATA-U': return { id: 'DIGI', side: 'U' };
|
||||
case 'DATA-L': return { id: 'DIGI', side: 'L' };
|
||||
case 'AM': return { id: 'AM', side: 'U' };
|
||||
case 'FM': return { id: 'FM', side: 'U' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The FTDX10/FTDX101 preamp is a three-way front-end selector, not an on/off:
|
||||
// IPO bypasses the preamp entirely (best on a quiet, high-signal band), AMP1 and
|
||||
// AMP2 add gain. Presenting it as a toggle would hide the middle position.
|
||||
const PREAMPS = [{ v: '0', l: 'IPO' }, { v: '1', l: 'AMP1' }, { v: '2', l: 'AMP2' }];
|
||||
const AGCS = [{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }, { v: 'AUTO', l: 'AUTO' }];
|
||||
// The attenuator is a three-step pad on these rigs (6/12/18 dB), not a toggle.
|
||||
const ATTS = [{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
|
||||
|
||||
function fmtVFO(hz?: number): string {
|
||||
if (!hz || hz <= 0) return '––.–––.––';
|
||||
const mhz = Math.floor(hz / 1_000_000);
|
||||
const khz = Math.floor((hz % 1_000_000) / 1000);
|
||||
const h2 = Math.floor((hz % 1000) / 10);
|
||||
return `${mhz}.${String(khz).padStart(3, '0')}.${String(h2).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function bandOfHz(hz?: number): string {
|
||||
if (!hz || hz <= 0) return '';
|
||||
const mhz = hz / 1_000_000;
|
||||
const bands: [string, number, number][] = [
|
||||
['160m', 1.8, 2.0], ['80m', 3.5, 4.0], ['60m', 5.25, 5.45], ['40m', 7.0, 7.3],
|
||||
['30m', 10.1, 10.15], ['20m', 14.0, 14.35], ['17m', 18.068, 18.168],
|
||||
['15m', 21.0, 21.45], ['12m', 24.89, 24.99], ['10m', 28.0, 29.7], ['6m', 50.0, 54.0],
|
||||
];
|
||||
for (const [name, lo, hi] of bands) if (mhz >= lo && mhz <= hi) return name;
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
// Split the 0-100 S-meter reading into S units + dB over S9.
|
||||
//
|
||||
// The FTDX10 answers SM0 on a 0-255 scale and its manual does not say where S9
|
||||
// falls; the front panel puts it at roughly half travel, which is the 50 used
|
||||
// here. That figure is a HYPOTHESIS — if reports come out consistently one S
|
||||
// unit off on a radio, this is the number to correct, not the RST helper.
|
||||
const S9_PCT = 50; // where S9 falls on the 0-100 reading (see above)
|
||||
const DB_PER_PCT = 60 / 50; // above S9 the scale runs to roughly +60 dB
|
||||
|
||||
function sParts(v: number): { s: number; over: number; label: string } {
|
||||
if (v >= S9_PCT) {
|
||||
const over = Math.max(0, Math.round((v - S9_PCT) * DB_PER_PCT));
|
||||
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
|
||||
}
|
||||
const s = Math.max(0, Math.min(9, Math.round(v / (S9_PCT / 9))));
|
||||
return { s, over: 0, label: `S${s}` };
|
||||
}
|
||||
|
||||
// Segment colour, the way a radio's own meter is printed: green up to S9, amber
|
||||
// through the S9+ range, red once the signal is strong enough to be reported as
|
||||
// 59+20 or more. Derived from the SAME S9 point as the label, so the colour
|
||||
// change always lands exactly where the numbers say it should — if the S9 point
|
||||
// is ever corrected, the colours follow on their own.
|
||||
const RED_OVER_DB = 20;
|
||||
function sSegColor(frac: number): string {
|
||||
const pct = frac * 100;
|
||||
if (pct < S9_PCT) return '#16a34a';
|
||||
if ((pct - S9_PCT) * DB_PER_PCT < RED_OVER_DB) return '#f59e0b';
|
||||
return '#dc2626';
|
||||
}
|
||||
|
||||
function Slider({ value, onChange, disabled, accent = 'var(--primary)', min = 0, max = 100 }: {
|
||||
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; min?: number; max?: number;
|
||||
}) {
|
||||
const v = Math.max(min, Math.min(max, value));
|
||||
const pct = max > min ? ((v - min) / (max - min)) * 100 : 0;
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
// React's onWheel is passive, so preventDefault is ignored there — attach a
|
||||
// native non-passive listener, and read live values through refs so the
|
||||
// handler never closes over a stale value.
|
||||
const valRef = useRef(value); valRef.current = value;
|
||||
const cbRef = useRef(onChange); cbRef.current = onChange;
|
||||
const disRef = useRef(disabled); disRef.current = disabled;
|
||||
const minRef = useRef(min); minRef.current = min;
|
||||
const maxRef = useRef(max); maxRef.current = max;
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (disRef.current) return;
|
||||
e.preventDefault();
|
||||
const nv = Math.max(minRef.current, Math.min(maxRef.current, valRef.current + (e.deltaY < 0 ? 1 : -1)));
|
||||
if (nv !== valRef.current) cbRef.current(nv);
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="range" min={min} max={max} value={v} disabled={disabled}
|
||||
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||
className={cn('flex-1 h-2 rounded-full appearance-none cursor-pointer disabled:opacity-40 disabled:cursor-default',
|
||||
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full',
|
||||
'[&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow',
|
||||
'[&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing')}
|
||||
// The filled side was invisible against the dark theme: --muted is barely
|
||||
// lighter than the card it sits on, so the whole track read as one bar.
|
||||
// An explicit translucent track keeps both halves distinct in either theme.
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${accent} 0%, ${accent} ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) 100%)`,
|
||||
borderColor: accent,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Segmented({ value, options, onChange }: {
|
||||
value: string; options: { v: string; l: string }[]; onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
|
||||
{options.map((o) => (
|
||||
<button key={o.v} type="button" onClick={() => onChange(o.v)}
|
||||
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors border-l border-border first:border-l-0',
|
||||
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{o.l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({ on, onClick, label, title }: { on: boolean; onClick: () => void; label: string; title?: string }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} title={title}
|
||||
className={cn('shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||
on ? 'bg-success border-success text-success-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||
</div>
|
||||
<div className="p-3 space-y-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-16 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function YaesuPanel({ onReportRST, onKeySpeed }: {
|
||||
onReportRST?: (rst: string) => void;
|
||||
// Told whenever the operator moves the CW speed here, so the app can keep the
|
||||
// keyer that is ACTUALLY sending in step. With DTR/RTS line keying the PC does
|
||||
// the timing and the rig's internal keyer speed changes nothing audible — the
|
||||
// slider looked broken because it was driving the wrong keyer.
|
||||
onKeySpeed?: (wpm: number) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [st, setSt] = useState<YaesuState>(ZERO);
|
||||
// The frequency being LISTENED to. RigState follows ADIF, where freq_hz is the
|
||||
// TRANSMIT frequency — under split that is the other VFO, so taking it as the
|
||||
// main display showed the operator the frequency they transmit on and an
|
||||
// offset of 0 kHz against itself.
|
||||
const [freqHz, setFreqHz] = useState(0);
|
||||
const [txHz, setTxHz] = useState(0);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
// Optimistic local values for the sliders. Without them a drag fights the
|
||||
// poll: the rig's older reading arrives mid-gesture and yanks the thumb back.
|
||||
const [local, setLocal] = useState<Partial<YaesuState>>({});
|
||||
const localAtRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const s = (await GetYaesuState()) as YaesuState;
|
||||
const c = await GetCATState();
|
||||
if (!alive) return;
|
||||
setSt(s);
|
||||
const cs = c as any;
|
||||
const tx = cs?.freq_hz ?? 0;
|
||||
const rx = cs?.split && cs?.freq_rx_hz > 0 ? cs.freq_rx_hz : tx;
|
||||
setFreqHz(rx);
|
||||
setTxHz(tx);
|
||||
// Drop the optimistic overlay once the rig has had time to answer with
|
||||
// the new value — 1.2 s covers the slow-beat settings read.
|
||||
if (Date.now() - localAtRef.current > 1200) setLocal({});
|
||||
setErr('');
|
||||
} catch (e: any) {
|
||||
if (alive) setErr(String(e?.message ?? e));
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 400);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
const view = { ...st, ...local };
|
||||
|
||||
// Every setter follows the same shape: show the value at once, remember when,
|
||||
// and let the poll take over. A rejected command surfaces as an error rather
|
||||
// than as a control that silently springs back.
|
||||
function push<K extends keyof YaesuState>(key: K, value: YaesuState[K], fn: () => Promise<void>) {
|
||||
setLocal((l) => ({ ...l, [key]: value }));
|
||||
localAtRef.current = Date.now();
|
||||
fn().catch((e) => setErr(String(e?.message ?? e)));
|
||||
}
|
||||
|
||||
const band = bandOfHz(freqHz);
|
||||
// CW changes what belongs on the panel: no microphone, no VOX, but a keyer
|
||||
// speed, break-in and ZIN. Driven by the RIG's mode, not the logged one.
|
||||
const isCW = (view.raw_mode || '').toUpperCase().startsWith('CW');
|
||||
|
||||
if (!st.available) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center p-6 text-center">
|
||||
<div className="space-y-1">
|
||||
<Radio className="size-8 mx-auto text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">{t('yaesu.notConnected')}</p>
|
||||
{err && <p className="text-xs text-destructive">{err}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||
{/* Same wrapper as the Icom and Flex panels: capped width, CENTRED. Capping
|
||||
it without mx-auto left the console pinned to the left edge with a
|
||||
window of empty space beside it. */}
|
||||
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||||
{/* VFO + status */}
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{st.model || 'Yaesu'}</div>
|
||||
<div className="text-2xl font-mono tabular-nums font-bold">{fmtVFO(freqHz)}</div>
|
||||
{view.split && txHz > 0 && (
|
||||
<div className="text-[11px] font-mono tabular-nums text-warning">
|
||||
{t('yaesu.txOn')} {fmtVFO(txHz)}
|
||||
{freqHz > 0 ? ' (' + (txHz > freqHz ? '+' : '') + Math.round((txHz - freqHz) / 100) / 10 + ' kHz)' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{st.transmitting && (
|
||||
<span className="px-2 py-1 rounded-md text-[11px] font-bold bg-destructive text-destructive-foreground">TX</span>
|
||||
)}
|
||||
<Chip on={view.split} onClick={() => push('split', !view.split, () => SetYaesuSplit(!view.split))} label="SPLIT" />
|
||||
{/* The usual pile-up offsets. Which one is idiomatic depends on the
|
||||
mode — up 5 on phone, up 1 on CW — so both are offered rather than
|
||||
guessed, and each turns split on in the same action. */}
|
||||
<button type="button" onClick={() => SetYaesuSplitOffset(1000).catch((e) => setErr(String(e?.message ?? e)))}
|
||||
title={t('yaesu.splitUpHint')}
|
||||
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">+1k</button>
|
||||
<button type="button" onClick={() => SetYaesuSplitOffset(5000).catch((e) => setErr(String(e?.message ?? e)))}
|
||||
title={t('yaesu.splitUpHint')}
|
||||
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">+5k</button>
|
||||
<button type="button" onClick={() => TuneYaesuATU().catch((e) => setErr(String(e?.message ?? e)))}
|
||||
title={t('yaesu.tuneHint')}
|
||||
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||||
TUNE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <p className="text-xs text-destructive px-1">{err}</p>}
|
||||
|
||||
{/* Meters — the SHARED MeterBar the Flex and Icom panels use, so the three
|
||||
consoles read alike instead of each having its own instrument style. */}
|
||||
<Card icon={Activity} title={t('yaesu.meters')}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<MeterBar label="S-METER" value={view.s_meter} lo={0} hi={100} accent="#16a34a" segColor={sSegColor}
|
||||
display={sParts(view.s_meter).label}
|
||||
onClick={onReportRST ? () => { const sp = sParts(view.s_meter); onReportRST(sMeterRST(sp.s, sp.over, view.mode)); } : undefined}
|
||||
title={onReportRST ? t('yaesu.sToRst') : undefined} />
|
||||
{/* Watts as MEASURED, not the power setting scaled by a percentage:
|
||||
the setting says what was asked for, the meter says what left. */}
|
||||
<MeterBar label="PWR" value={view.transmitting ? (view.power_w ?? 0) : 0} unit="W" lo={0} hi={Math.max(100, view.rf_power || 100)} accent="#0ea5e9"
|
||||
display={view.transmitting ? Math.round(view.power_w ?? 0) + 'W' : '—'} />
|
||||
{/* The RATIO, as the rig shows it — a percentage of meter travel is
|
||||
not something an operator can act on. The bar keeps the travel. */}
|
||||
<MeterBar label="SWR" value={view.transmitting ? view.swr_meter : 0} lo={0} hi={100} accent="#f59e0b"
|
||||
display={view.transmitting ? (view.swr && view.swr >= 1 ? view.swr.toFixed(1) : '1.0') : '—'} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Bands + modes */}
|
||||
<Card icon={Antenna} title={t('yaesu.bandMode')}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{BANDS.map((b) => (
|
||||
<button key={b} type="button"
|
||||
onClick={() => SetYaesuBand(b).catch((e) => setErr(String(e?.message ?? e)))}
|
||||
className={cn('px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||
band === b ? 'bg-primary border-primary text-primary-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
{b.replace('m', '')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{MODES.map((m) => {
|
||||
const act = activeMode(view.raw_mode);
|
||||
const on = act?.id === m.id;
|
||||
// The sideband shown is the rig's when this mode is active, else the
|
||||
// one the band plan implies — so a button says what pressing it will
|
||||
// actually do rather than a stale letter.
|
||||
const side: 'U' | 'L' = on && act ? act.side : (freqHz > 0 && freqHz < 10_000_000 ? 'L' : 'U');
|
||||
const flip: 'U' | 'L' = side === 'U' ? 'L' : 'U';
|
||||
return (
|
||||
<button key={m.id} type="button"
|
||||
onClick={() => {
|
||||
// Already on this mode → the click means "the other sideband".
|
||||
const target = m.id === 'SSB'
|
||||
? (freqHz > 0 && freqHz < 10_000_000 ? 'LSB' : 'USB')
|
||||
: m.rig(on && m.sideband ? flip : side);
|
||||
SetYaesuModeRaw(target).catch((e) => setErr(String(e?.message ?? e)));
|
||||
}}
|
||||
title={m.sideband ? t('yaesu.sidebandHint') : undefined}
|
||||
className={cn('px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||
on ? 'bg-primary border-primary text-primary-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
{m.sideband ? m.label + '-' + side : m.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Receive */}
|
||||
<Card icon={AudioLines} title={t('yaesu.receive')}>
|
||||
<Row label="AF">
|
||||
<Slider value={view.af_gain} onChange={(v) => push('af_gain', v, () => SetYaesuAFGain(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.af_gain}</span>
|
||||
</Row>
|
||||
<Row label="RF">
|
||||
<Slider value={view.rf_gain} onChange={(v) => push('rf_gain', v, () => SetYaesuRFGain(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_gain}</span>
|
||||
</Row>
|
||||
<Row label="SQL">
|
||||
<Slider value={view.squelch} onChange={(v) => push('squelch', v, () => SetYaesuSquelch(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.squelch}</span>
|
||||
</Row>
|
||||
<Row label="AGC">
|
||||
<Segmented value={view.agc ?? 'AUTO'} options={AGCS} onChange={(v) => push('agc', v, () => SetYaesuAGC(v))} />
|
||||
</Row>
|
||||
<Row label="FRONT">
|
||||
<Segmented value={String(view.preamp)} options={PREAMPS} onChange={(v) => push('preamp', parseInt(v, 10), () => SetYaesuPreamp(parseInt(v, 10)))} />
|
||||
</Row>
|
||||
<Row label="ATT">
|
||||
<Segmented value={String(view.att)} options={ATTS} onChange={(v) => push('att', parseInt(v, 10), () => SetYaesuAtt(parseInt(v, 10)))} />
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Noise + filter */}
|
||||
<Card icon={SlidersHorizontal} title={t('yaesu.noiseFilter')}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Chip on={view.nb} onClick={() => push('nb', !view.nb, () => SetYaesuNB(!view.nb))} label="NB" />
|
||||
<Chip on={view.nr} onClick={() => push('nr', !view.nr, () => SetYaesuNR(!view.nr))} label="DNR" />
|
||||
<Chip on={view.narrow} onClick={() => push('narrow', !view.narrow, () => SetYaesuNarrow(!view.narrow))} label="NAR" />
|
||||
</div>
|
||||
<Row label="DNR">
|
||||
{/* 1-15 on the rig, shown as-is rather than rescaled to a percentage:
|
||||
the radio's own display counts 1-15, and matching it is what makes
|
||||
the panel readable next to the front panel. */}
|
||||
<Slider value={view.nr_level || 1} min={1} max={15} disabled={!view.nr}
|
||||
onChange={(v) => push('nr_level', v, () => SetYaesuNRLevel(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.nr_level || 1}</span>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Transmit */}
|
||||
<Card icon={Mic} title={t('yaesu.transmit')}>
|
||||
<Row label="PWR">
|
||||
{/* Watts, not a percentage: the rig reports and takes watts, and a
|
||||
percentage would be a second unit to reconcile every time. */}
|
||||
<Slider value={view.rf_power || 5} min={5} max={100} accent="var(--destructive)"
|
||||
onChange={(v) => push('rf_power', v, () => SetYaesuPower(v))} />
|
||||
<span className="w-10 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_power}W</span>
|
||||
</Row>
|
||||
{/* Microphone gain and VOX are meaningless in CW — the rig ignores both
|
||||
— so they are hidden rather than shown as dead controls. */}
|
||||
{!isCW && (
|
||||
<>
|
||||
<Row label="MIC">
|
||||
<Slider value={view.mic_gain} onChange={(v) => push('mic_gain', v, () => SetYaesuMicGain(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.mic_gain}</span>
|
||||
</Row>
|
||||
<Row label="VOX">
|
||||
<Chip on={view.vox} onClick={() => push('vox', !view.vox, () => SetYaesuVOX(!view.vox))} label="VOX" />
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
<Row label="">
|
||||
<button type="button" onClick={() => RefreshYaesuPanel().catch((e) => setErr(String(e?.message ?? e)))}
|
||||
className="ml-auto px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||||
{t('yaesu.refresh')}
|
||||
</button>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* CW — only in CW, where these replace the phone controls above. */}
|
||||
{isCW && (
|
||||
<Card icon={Radio} title={t('yaesu.cw')}>
|
||||
<Row label="SPEED">
|
||||
<Slider value={view.key_speed || 20} min={4} max={60} accent="var(--primary)"
|
||||
onChange={(v) => { push('key_speed', v, () => SetYaesuKeySpeed(v)); onKeySpeed?.(v); }} />
|
||||
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.key_speed || 20} wpm</span>
|
||||
</Row>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Chip on={!!view.break_in} onClick={() => push('break_in', !view.break_in, () => SetYaesuBreakIn(!view.break_in))}
|
||||
label="BK-IN" title={t('yaesu.breakInHint')} />
|
||||
{/* ZIN is a one-shot: the rig retunes so the station being received
|
||||
lands on the operator's own CW pitch. Not a toggle, so it is a
|
||||
plain button rather than a chip that would look latched. */}
|
||||
<button type="button" onClick={() => YaesuZeroIn().catch((e) => setErr(String(e?.message ?? e)))}
|
||||
title={t('yaesu.zinHint')}
|
||||
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||||
ZIN
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+56
-22
File diff suppressed because one or more lines are too long
@@ -14,7 +14,11 @@ export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
||||
];
|
||||
|
||||
export const LS_KEY = 'opslog.theme';
|
||||
const DEFAULT: ThemeChoice = 'light-warm';
|
||||
// A fresh install starts DARK. A shack is usually a dim room and the screen is
|
||||
// looked at for hours; every other logger defaults the same way. Graphite
|
||||
// specifically, because that is what 'auto' already resolves to for a dark
|
||||
// system — so the two paths agree instead of landing on different darks.
|
||||
const DEFAULT: ThemeChoice = 'dark-graphite';
|
||||
const ALL: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
|
||||
|
||||
function systemDark(): boolean {
|
||||
|
||||
@@ -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.21.6';
|
||||
export const APP_VERSION = '0.22.1';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+52
@@ -499,6 +499,8 @@ export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
||||
|
||||
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||
|
||||
export function GetYaesuState():Promise<cat.YaesuTXState>;
|
||||
|
||||
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
||||
|
||||
export function IcomRefresh():Promise<void>;
|
||||
@@ -637,6 +639,8 @@ export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||
|
||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||
|
||||
export function LookupCallsignFresh(arg1:string):Promise<lookup.Result>;
|
||||
|
||||
export function MotorReadElements():Promise<Array<number>>;
|
||||
|
||||
export function MotorSetElement(arg1:number,arg2:number):Promise<void>;
|
||||
@@ -753,6 +757,8 @@ export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function RefreshSolar():Promise<void>;
|
||||
|
||||
export function RefreshYaesuPanel():Promise<void>;
|
||||
|
||||
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
||||
|
||||
export function RemovePassphrase(arg1:string):Promise<void>;
|
||||
@@ -909,6 +915,44 @@ export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||
|
||||
export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetYaesuAFGain(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuAGC(arg1:string):Promise<void>;
|
||||
|
||||
export function SetYaesuAtt(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuBand(arg1:string):Promise<void>;
|
||||
|
||||
export function SetYaesuBreakIn(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetYaesuKeySpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuMicGain(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuModeRaw(arg1:string):Promise<void>;
|
||||
|
||||
export function SetYaesuNB(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetYaesuNR(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetYaesuNRLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuNarrow(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetYaesuPower(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuPreamp(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuRFGain(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuSplit(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetYaesuSplitOffset(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuSquelch(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuVOX(arg1:boolean):Promise<void>;
|
||||
|
||||
export function StartCWDecoder():Promise<void>;
|
||||
|
||||
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||
@@ -945,6 +989,8 @@ export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationT
|
||||
|
||||
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
||||
|
||||
export function TuneYaesuATU():Promise<void>;
|
||||
|
||||
export function TunerGeniusActivate(arg1:number):Promise<void>;
|
||||
|
||||
export function TunerGeniusAutotune():Promise<void>;
|
||||
@@ -988,3 +1034,9 @@ export function WinkeyerSetSpeed(arg1:number):Promise<void>;
|
||||
export function WinkeyerStop():Promise<void>;
|
||||
|
||||
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
||||
|
||||
export function YaesuSendCW(arg1:string):Promise<void>;
|
||||
|
||||
export function YaesuStopCW():Promise<void>;
|
||||
|
||||
export function YaesuZeroIn():Promise<void>;
|
||||
|
||||
@@ -946,6 +946,10 @@ export function GetWinkeyerStatus() {
|
||||
return window['go']['main']['App']['GetWinkeyerStatus']();
|
||||
}
|
||||
|
||||
export function GetYaesuState() {
|
||||
return window['go']['main']['App']['GetYaesuState']();
|
||||
}
|
||||
|
||||
export function HasBuiltinReferences(arg1) {
|
||||
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
|
||||
}
|
||||
@@ -1222,6 +1226,10 @@ export function LookupCallsign(arg1) {
|
||||
return window['go']['main']['App']['LookupCallsign'](arg1);
|
||||
}
|
||||
|
||||
export function LookupCallsignFresh(arg1) {
|
||||
return window['go']['main']['App']['LookupCallsignFresh'](arg1);
|
||||
}
|
||||
|
||||
export function MotorReadElements() {
|
||||
return window['go']['main']['App']['MotorReadElements']();
|
||||
}
|
||||
@@ -1454,6 +1462,10 @@ export function RefreshSolar() {
|
||||
return window['go']['main']['App']['RefreshSolar']();
|
||||
}
|
||||
|
||||
export function RefreshYaesuPanel() {
|
||||
return window['go']['main']['App']['RefreshYaesuPanel']();
|
||||
}
|
||||
|
||||
export function ReloadUDPIntegrations() {
|
||||
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
||||
}
|
||||
@@ -1766,6 +1778,82 @@ export function SetWinkeyerTrace(arg1) {
|
||||
return window['go']['main']['App']['SetWinkeyerTrace'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuAFGain(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuAFGain'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuAGC(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuAGC'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuAtt(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuAtt'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuBand(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuBand'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuBreakIn(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuBreakIn'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuKeySpeed(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuKeySpeed'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuMicGain(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuMicGain'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuModeRaw(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuModeRaw'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuNB(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuNB'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuNR(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuNR'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuNRLevel(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuNRLevel'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuNarrow(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuNarrow'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuPower(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuPower'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuPreamp(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuPreamp'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuRFGain(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuRFGain'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuSplit(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuSplit'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuSplitOffset(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuSplitOffset'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuSquelch(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuSquelch'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuVOX(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuVOX'](arg1);
|
||||
}
|
||||
|
||||
export function StartCWDecoder() {
|
||||
return window['go']['main']['App']['StartCWDecoder']();
|
||||
}
|
||||
@@ -1838,6 +1926,10 @@ export function TestUltrabeam(arg1) {
|
||||
return window['go']['main']['App']['TestUltrabeam'](arg1);
|
||||
}
|
||||
|
||||
export function TuneYaesuATU() {
|
||||
return window['go']['main']['App']['TuneYaesuATU']();
|
||||
}
|
||||
|
||||
export function TunerGeniusActivate(arg1) {
|
||||
return window['go']['main']['App']['TunerGeniusActivate'](arg1);
|
||||
}
|
||||
@@ -1925,3 +2017,15 @@ export function WinkeyerStop() {
|
||||
export function WorkedBefore(arg1, arg2) {
|
||||
return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function YaesuSendCW(arg1) {
|
||||
return window['go']['main']['App']['YaesuSendCW'](arg1);
|
||||
}
|
||||
|
||||
export function YaesuStopCW() {
|
||||
return window['go']['main']['App']['YaesuStopCW']();
|
||||
}
|
||||
|
||||
export function YaesuZeroIn() {
|
||||
return window['go']['main']['App']['YaesuZeroIn']();
|
||||
}
|
||||
|
||||
@@ -1074,6 +1074,70 @@ export namespace cat {
|
||||
this.fixed = source["fixed"];
|
||||
}
|
||||
}
|
||||
export class YaesuTXState {
|
||||
available: boolean;
|
||||
model?: string;
|
||||
mode?: string;
|
||||
raw_mode?: string;
|
||||
transmitting: boolean;
|
||||
split: boolean;
|
||||
split_tx_hz: number;
|
||||
s_meter: number;
|
||||
power_meter: number;
|
||||
swr_meter: number;
|
||||
rf_power: number;
|
||||
mic_gain: number;
|
||||
af_gain: number;
|
||||
rf_gain: number;
|
||||
squelch: number;
|
||||
agc?: string;
|
||||
preamp: number;
|
||||
att: number;
|
||||
nb: boolean;
|
||||
nr: boolean;
|
||||
nr_level: number;
|
||||
narrow: boolean;
|
||||
swr: number;
|
||||
power_w: number;
|
||||
vox: boolean;
|
||||
key_speed: number;
|
||||
break_in: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new YaesuTXState(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.available = source["available"];
|
||||
this.model = source["model"];
|
||||
this.mode = source["mode"];
|
||||
this.raw_mode = source["raw_mode"];
|
||||
this.transmitting = source["transmitting"];
|
||||
this.split = source["split"];
|
||||
this.split_tx_hz = source["split_tx_hz"];
|
||||
this.s_meter = source["s_meter"];
|
||||
this.power_meter = source["power_meter"];
|
||||
this.swr_meter = source["swr_meter"];
|
||||
this.rf_power = source["rf_power"];
|
||||
this.mic_gain = source["mic_gain"];
|
||||
this.af_gain = source["af_gain"];
|
||||
this.rf_gain = source["rf_gain"];
|
||||
this.squelch = source["squelch"];
|
||||
this.agc = source["agc"];
|
||||
this.preamp = source["preamp"];
|
||||
this.att = source["att"];
|
||||
this.nb = source["nb"];
|
||||
this.nr = source["nr"];
|
||||
this.nr_level = source["nr_level"];
|
||||
this.narrow = source["narrow"];
|
||||
this.swr = source["swr"];
|
||||
this.power_w = source["power_w"];
|
||||
this.vox = source["vox"];
|
||||
this.key_speed = source["key_speed"];
|
||||
this.break_in = source["break_in"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1537,6 +1601,7 @@ export namespace main {
|
||||
format: string;
|
||||
from_gain: number;
|
||||
mic_gain: number;
|
||||
tx_gain: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AudioSettings(source);
|
||||
@@ -1556,6 +1621,7 @@ export namespace main {
|
||||
this.format = source["format"];
|
||||
this.from_gain = source["from_gain"];
|
||||
this.mic_gain = source["mic_gain"];
|
||||
this.tx_gain = source["tx_gain"];
|
||||
}
|
||||
}
|
||||
export class AutostartLaunchResult {
|
||||
@@ -1829,11 +1895,17 @@ export namespace main {
|
||||
enabled: boolean;
|
||||
backend: string;
|
||||
omnirig_rig: number;
|
||||
omnirig_vfo: string;
|
||||
flex_host: string;
|
||||
flex_port: number;
|
||||
flex_spots: boolean;
|
||||
flex_decode_spots: boolean;
|
||||
flex_decode_secs: number;
|
||||
xiegu_port: string;
|
||||
xiegu_baud: number;
|
||||
xiegu_addr: number;
|
||||
yaesu_port: string;
|
||||
yaesu_baud: number;
|
||||
icom_port: string;
|
||||
icom_baud: number;
|
||||
icom_addr: number;
|
||||
@@ -1847,6 +1919,8 @@ export namespace main {
|
||||
poll_ms: number;
|
||||
delay_ms: number;
|
||||
digital_default: string;
|
||||
share_enabled: boolean;
|
||||
share_port: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new CATSettings(source);
|
||||
@@ -1857,11 +1931,17 @@ export namespace main {
|
||||
this.enabled = source["enabled"];
|
||||
this.backend = source["backend"];
|
||||
this.omnirig_rig = source["omnirig_rig"];
|
||||
this.omnirig_vfo = source["omnirig_vfo"];
|
||||
this.flex_host = source["flex_host"];
|
||||
this.flex_port = source["flex_port"];
|
||||
this.flex_spots = source["flex_spots"];
|
||||
this.flex_decode_spots = source["flex_decode_spots"];
|
||||
this.flex_decode_secs = source["flex_decode_secs"];
|
||||
this.xiegu_port = source["xiegu_port"];
|
||||
this.xiegu_baud = source["xiegu_baud"];
|
||||
this.xiegu_addr = source["xiegu_addr"];
|
||||
this.yaesu_port = source["yaesu_port"];
|
||||
this.yaesu_baud = source["yaesu_baud"];
|
||||
this.icom_port = source["icom_port"];
|
||||
this.icom_baud = source["icom_baud"];
|
||||
this.icom_addr = source["icom_addr"];
|
||||
@@ -1875,6 +1955,8 @@ export namespace main {
|
||||
this.poll_ms = source["poll_ms"];
|
||||
this.delay_ms = source["delay_ms"];
|
||||
this.digital_default = source["digital_default"];
|
||||
this.share_enabled = source["share_enabled"];
|
||||
this.share_port = source["share_port"];
|
||||
}
|
||||
}
|
||||
export class CabrilloResult {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/cat/civ"
|
||||
)
|
||||
|
||||
// The Icom model picker in the settings and civ.ModelName are two hand-kept
|
||||
// copies of the same table, and they had drifted: the UI offered the IC-7700 at
|
||||
// 0x88 and the IC-7800 at 0x80, which are the IC-7100's and the IC-7410's
|
||||
// factory addresses. Picking either set an address the rig never answers on —
|
||||
// the symptom is a rig that simply never replies — and the backend then named it
|
||||
// as the other model.
|
||||
//
|
||||
// The list is read out of the .tsx itself, so a model added on one side alone
|
||||
// fails here rather than on someone's radio.
|
||||
func TestIcomModelAddressesMatch(t *testing.T) {
|
||||
src, err := os.ReadFile("frontend/src/components/SettingsModal.tsx")
|
||||
if err != nil {
|
||||
t.Skipf("frontend source not available: %v", err)
|
||||
}
|
||||
re := regexp.MustCompile(`\{ name: '(IC-[A-Za-z0-9-]+)', addr: 0x([0-9A-Fa-f]{2}) \}`)
|
||||
ms := re.FindAllStringSubmatch(string(src), -1)
|
||||
if len(ms) < 5 {
|
||||
t.Fatalf("only %d models found — the parse is wrong, not the data", len(ms))
|
||||
}
|
||||
for _, m := range ms {
|
||||
name := m[1]
|
||||
addr, err := strconv.ParseUint(m[2], 16, 8)
|
||||
if err != nil {
|
||||
t.Fatalf("bad address %q for %s", m[2], name)
|
||||
}
|
||||
if got := civ.ModelName(byte(addr)); got != name {
|
||||
t.Errorf("settings offer %s at 0x%02X, but civ.ModelName(0x%02X) = %q",
|
||||
name, addr, addr, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,11 +66,12 @@ type Client struct {
|
||||
authTries int
|
||||
ready atomic.Bool // init commands sent → keepalive may run
|
||||
|
||||
statusMu sync.RWMutex
|
||||
status Status
|
||||
antennas map[int]string // index → name (rebuilt into status.Antennas)
|
||||
antBands map[int]int // index → band bitmask (which bands the antenna covers)
|
||||
antRawN int // one-shot: how many raw antenna lines we've logged
|
||||
statusMu sync.RWMutex
|
||||
status Status
|
||||
antennas map[int]string // index → name (rebuilt into status.Antennas)
|
||||
antBands map[int]int // index → band bitmask (which bands the antenna covers)
|
||||
antRawN int // one-shot: how many raw antenna lines we've logged
|
||||
lastShown map[int]int // port id → antenna last reported, so only CHANGES are logged
|
||||
|
||||
stop chan struct{}
|
||||
running bool
|
||||
@@ -81,13 +82,14 @@ func New(host string, port int, password string) *Client {
|
||||
port = defaultPort
|
||||
}
|
||||
return &Client{
|
||||
host: host,
|
||||
port: port,
|
||||
password: strings.TrimSpace(password),
|
||||
stop: make(chan struct{}),
|
||||
antennas: map[int]string{},
|
||||
antBands: map[int]int{},
|
||||
status: Status{Host: host},
|
||||
host: host,
|
||||
port: port,
|
||||
password: strings.TrimSpace(password),
|
||||
stop: make(chan struct{}),
|
||||
antennas: map[int]string{},
|
||||
antBands: map[int]int{},
|
||||
lastShown: map[int]int{},
|
||||
status: Status{Host: host},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,9 +400,27 @@ func (c *Client) parsePort(msg string) {
|
||||
}
|
||||
tx := kvInt(msg, "txant")
|
||||
rx := kvInt(msg, "rxant")
|
||||
active := tx
|
||||
// The RX antenna is what identifies a port's selection, and it is the only
|
||||
// thing Activate sets. Preferring the TX antenna made both ports converge on
|
||||
// the same row a few seconds after a change: on an 8x2 only ONE port can hold
|
||||
// the transmit antenna, so the device reports the same txant on both, and a
|
||||
// port A genuinely on the 80 m vertical was redrawn as the beam that port B
|
||||
// transmits through.
|
||||
//
|
||||
// TX remains the fallback for a port that reports no RX antenna at all.
|
||||
active := rx
|
||||
if active == 0 {
|
||||
active = rx
|
||||
active = tx
|
||||
}
|
||||
// Logged only when the shown antenna CHANGES: the device pushes port state
|
||||
// every few seconds, and logging each one would bury everything else — but
|
||||
// without any trace, "port A jumped to the wrong antenna" is unfalsifiable.
|
||||
c.statusMu.Lock()
|
||||
prev, seen := c.lastShown[id]
|
||||
c.lastShown[id] = active
|
||||
c.statusMu.Unlock()
|
||||
if !seen || prev != active {
|
||||
applog.Printf("antgenius: port %d → antenna %d (rxant=%d txant=%d) raw=%q", id, active, rx, tx, msg)
|
||||
}
|
||||
txOn := kvInt(msg, "tx") != 0 // the standalone "tx=0|1" transmit flag
|
||||
c.setStatus(func(s *Status) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package antgenius
|
||||
|
||||
import "testing"
|
||||
|
||||
// Which antenna a port SHOWS.
|
||||
//
|
||||
// Reported on an 8x2: port A was correctly on the 80 m vertical, then a few
|
||||
// seconds later both A and B were drawn on the same beam. The cause is that only
|
||||
// ONE port can hold the transmit antenna, so the device reports the same txant
|
||||
// on both — and the display preferred txant. The RX antenna is the per-port
|
||||
// selection, and the only thing Activate sets, so it has to win.
|
||||
func TestPortShowsRXAntenna(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msg string
|
||||
wantPort int
|
||||
wantAnt int
|
||||
}{
|
||||
{
|
||||
// The reported case: port A listening on antenna 2 while the shared TX
|
||||
// antenna is 1. Before the fix this drew port A on antenna 1.
|
||||
name: "rx and tx disagree — rx wins",
|
||||
msg: "port 1 rxant=2 txant=1 tx=0",
|
||||
wantPort: 1, wantAnt: 2,
|
||||
},
|
||||
{
|
||||
name: "they agree",
|
||||
msg: "port 2 rxant=3 txant=3 tx=0",
|
||||
wantPort: 2, wantAnt: 3,
|
||||
},
|
||||
{
|
||||
// A port with no RX antenna still shows something meaningful.
|
||||
name: "no rx antenna — fall back to tx",
|
||||
msg: "port 1 rxant=0 txant=4 tx=1",
|
||||
wantPort: 1, wantAnt: 4,
|
||||
},
|
||||
{
|
||||
name: "nothing selected",
|
||||
msg: "port 2 rxant=0 txant=0 tx=0",
|
||||
wantPort: 2, wantAnt: 0,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
cl := New("host", 0, "")
|
||||
cl.parsePort(c.msg)
|
||||
st := cl.GetStatus()
|
||||
got := st.PortA
|
||||
if c.wantPort == 2 {
|
||||
got = st.PortB
|
||||
}
|
||||
if got != c.wantAnt {
|
||||
t.Errorf("%s: port %d shows antenna %d, want %d (%q)", c.name, c.wantPort, got, c.wantAnt, c.msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The two ports must stay independent: a message about one must never move the
|
||||
// other, which is the shape the operator actually saw on screen.
|
||||
func TestPortsAreIndependent(t *testing.T) {
|
||||
c := New("host", 0, "")
|
||||
c.parsePort("port 1 rxant=2 txant=0 tx=0")
|
||||
c.parsePort("port 2 rxant=1 txant=1 tx=1")
|
||||
st := c.GetStatus()
|
||||
if st.PortA != 2 {
|
||||
t.Errorf("port A = %d, want 2 — port B's message moved it", st.PortA)
|
||||
}
|
||||
if st.PortB != 1 {
|
||||
t.Errorf("port B = %d, want 1", st.PortB)
|
||||
}
|
||||
if !st.TxB || st.TxA {
|
||||
t.Errorf("transmit flags crossed: TxA=%v TxB=%v", st.TxA, st.TxB)
|
||||
}
|
||||
}
|
||||
@@ -104,11 +104,27 @@ func (m *Manager) IsPlaying() bool {
|
||||
|
||||
// Play renders a WAV file to deviceID. Any current playback is stopped first.
|
||||
// Returns immediately; playback runs in the background.
|
||||
func (m *Manager) Play(deviceID, path string) error {
|
||||
// Play sends a recorded message to a device, amplified by gainPct (100 = as
|
||||
// recorded).
|
||||
//
|
||||
// The gain exists because nothing else could raise the level: the message went
|
||||
// out exactly as captured, so a mic recorded quietly drove the rig quietly and
|
||||
// the operator had no control anywhere in OpsLog — only the radio's own USB
|
||||
// input level, buried in its menus, and the Windows mixer.
|
||||
func (m *Manager) Play(deviceID, path string, gainPct int) error {
|
||||
pcm, rate, ch, bits, err := readWAV(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if gainPct > 0 && gainPct != 100 && bits == 16 {
|
||||
g := float64(gainPct) / 100
|
||||
// In place: the buffer is this call's own copy of the file.
|
||||
for i := 0; i+1 < len(pcm); i += 2 {
|
||||
v := int16(uint16(pcm[i]) | uint16(pcm[i+1])<<8)
|
||||
v = scalePCM(v, g)
|
||||
pcm[i], pcm[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
|
||||
}
|
||||
}
|
||||
m.StopPlayback()
|
||||
stop := make(chan struct{})
|
||||
m.mu.Lock()
|
||||
@@ -265,3 +281,16 @@ func (m *Manager) TXAudioActive() bool {
|
||||
defer m.mu.Unlock()
|
||||
return m.txStop != nil
|
||||
}
|
||||
|
||||
// scalePCM applies a gain to one sample, clamping rather than wrapping — an
|
||||
// overflow that wraps turns loud speech into a burst of noise on the air.
|
||||
func scalePCM(s int16, g float64) int16 {
|
||||
v := float64(s) * g
|
||||
if v > 32767 {
|
||||
return 32767
|
||||
}
|
||||
if v < -32768 {
|
||||
return -32768
|
||||
}
|
||||
return int16(v)
|
||||
}
|
||||
|
||||
+22
-17
@@ -6,10 +6,11 @@
|
||||
// QSO may yield several references (e.g. a Note holding "D74 D73").
|
||||
//
|
||||
// Examples:
|
||||
// DXCC : field "dxcc" (no pattern) → entity number
|
||||
// WAS : field "state", DXCCFilter [291,110,6] → US state
|
||||
// DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes
|
||||
// WPX : field "prefix" (computed from callsign)
|
||||
//
|
||||
// DXCC : field "dxcc" (no pattern) → entity number
|
||||
// WAS : field "state", DXCCFilter [291,110,6] → US state
|
||||
// DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes
|
||||
// WPX : field "prefix" (computed from callsign)
|
||||
package award
|
||||
|
||||
import (
|
||||
@@ -48,16 +49,16 @@ const (
|
||||
// behaves as before.
|
||||
type Def struct {
|
||||
// --- Identity ---
|
||||
Code string `json:"code"` // unique key, e.g. "DXCC"
|
||||
Name string `json:"name"` // friendly name
|
||||
Code string `json:"code"` // unique key, e.g. "DXCC"
|
||||
Name string `json:"name"` // friendly name
|
||||
Description string `json:"description,omitempty"` // free text
|
||||
Valid bool `json:"valid"` // award enabled
|
||||
Protected bool `json:"protected,omitempty"` // shipped/locked award
|
||||
URL string `json:"url,omitempty"` // award home page
|
||||
Valid bool `json:"valid"` // award enabled
|
||||
Protected bool `json:"protected,omitempty"` // shipped/locked award
|
||||
URL string `json:"url,omitempty"` // award home page
|
||||
DownloadURL string `json:"download_url,omitempty"` // reference-list source
|
||||
RefURL string `json:"ref_url,omitempty"` // per-ref link, <REF> placeholder
|
||||
ValidFrom string `json:"valid_from,omitempty"` // ISO date (QSOs before don't count)
|
||||
ValidTo string `json:"valid_to,omitempty"` // ISO date (QSOs after don't count)
|
||||
RefURL string `json:"ref_url,omitempty"` // per-ref link, <REF> placeholder
|
||||
ValidFrom string `json:"valid_from,omitempty"` // ISO date (QSOs before don't count)
|
||||
ValidTo string `json:"valid_to,omitempty"` // ISO date (QSOs after don't count)
|
||||
Alias string `json:"alias,omitempty"`
|
||||
// RefDisplay picks what the grid's award column shows for a match: "" or "ref"
|
||||
// = the reference (e.g. WAJA "36"), "name" = the reference's description (e.g.
|
||||
@@ -66,7 +67,7 @@ type Def struct {
|
||||
RefDisplay string `json:"ref_display,omitempty"`
|
||||
|
||||
// --- Type & matching ---
|
||||
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
|
||||
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
|
||||
Field string `json:"field"` // QSO field to scan (see fieldRaw)
|
||||
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
|
||||
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
|
||||
@@ -89,14 +90,14 @@ type Def struct {
|
||||
OrRules []OrRule `json:"or_rules,omitempty"`
|
||||
|
||||
// --- Scope ---
|
||||
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
|
||||
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
|
||||
ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands
|
||||
ValidModes []string `json:"valid_modes,omitempty"` // empty = all modes
|
||||
Emission []string `json:"emission,omitempty"` // CW | DIGITAL | PHONE (empty = all)
|
||||
|
||||
// --- Confirmation ---
|
||||
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
|
||||
Validate []string `json:"validate,omitempty"` // validated/granted sources
|
||||
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
|
||||
Validate []string `json:"validate,omitempty"` // validated/granted sources
|
||||
// NOT IMPLEMENTED. Kept so the values operators already typed are not lost, but
|
||||
// nothing reads them: no ADIF export has ever written CREDIT_GRANTED. Their
|
||||
// controls have been removed from the editor — a checkbox that quietly does
|
||||
@@ -1326,7 +1327,11 @@ func setToSorted(m map[string]struct{}) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm"}
|
||||
var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m",
|
||||
"6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm",
|
||||
// Microwave. A band missing from this order sorts to the end of an award's
|
||||
// band columns rather than in frequency order — and 3 cm was missing.
|
||||
"9cm", "6cm", "3cm", "1.25cm", "6mm", "4mm", "2.5mm", "2mm", "1mm"}
|
||||
|
||||
func sortedBands(m map[string]int) []string {
|
||||
idx := map[string]int{}
|
||||
|
||||
@@ -776,6 +776,14 @@ func stateUserEqual(a, b RigState) bool {
|
||||
func BandFromHz(hz int64) string {
|
||||
mhz := float64(hz) / 1_000_000
|
||||
switch {
|
||||
// LF / MF, below 160 m. An operator on 630 m had their band come back empty,
|
||||
// which then cost them the band on the QSO and in every award count.
|
||||
case mhz >= 0.1357 && mhz <= 0.1378:
|
||||
return "2190m"
|
||||
case mhz >= 0.472 && mhz <= 0.479:
|
||||
return "630m"
|
||||
case mhz >= 0.501 && mhz <= 0.504:
|
||||
return "560m"
|
||||
case mhz >= 1.8 && mhz <= 2.0:
|
||||
return "160m"
|
||||
case mhz >= 3.5 && mhz <= 4.0:
|
||||
@@ -810,6 +818,54 @@ func BandFromHz(hz int64) string {
|
||||
return "33cm"
|
||||
case mhz >= 1240.0 && mhz <= 1300.0:
|
||||
return "23cm"
|
||||
// Microwave. The table used to stop at 23 cm, so 10 GHz — a band people
|
||||
// actually work, and spot — resolved to an empty band: no band on the QSO, no
|
||||
// band-slot, nothing in the awards. Ranges and names are the ADIF 3.1.7 ones,
|
||||
// which is what an export has to carry.
|
||||
case mhz >= 2300.0 && mhz <= 2450.0:
|
||||
return "13cm"
|
||||
case mhz >= 3300.0 && mhz <= 3500.0:
|
||||
return "9cm"
|
||||
case mhz >= 5650.0 && mhz <= 5925.0:
|
||||
return "6cm"
|
||||
case mhz >= 10000.0 && mhz <= 10500.0:
|
||||
return "3cm"
|
||||
case mhz >= 24000.0 && mhz <= 24250.0:
|
||||
return "1.25cm"
|
||||
case mhz >= 47000.0 && mhz <= 47200.0:
|
||||
return "6mm"
|
||||
case mhz >= 75500.0 && mhz <= 81000.0:
|
||||
return "4mm"
|
||||
case mhz >= 119980.0 && mhz <= 123000.0:
|
||||
return "2.5mm"
|
||||
case mhz >= 134000.0 && mhz <= 149000.0:
|
||||
return "2mm"
|
||||
case mhz >= 241000.0 && mhz <= 250000.0:
|
||||
return "1mm"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// YaesuState returns the panel snapshot, or (zero, false) when the active
|
||||
// backend isn't a Yaesu — same shape as IcomState.
|
||||
func (m *Manager) YaesuState() (YaesuTXState, bool) {
|
||||
m.mu.RLock()
|
||||
b := m.backend
|
||||
m.mu.RUnlock()
|
||||
if yc, ok := b.(YaesuController); ok {
|
||||
return yc.YaesuState(), true
|
||||
}
|
||||
return YaesuTXState{}, false
|
||||
}
|
||||
|
||||
// YaesuDo dispatches a Yaesu control onto the CAT goroutine, so a panel click
|
||||
// and the poll loop never share the serial port at the same instant.
|
||||
func (m *Manager) YaesuDo(fn func(YaesuController) error) error {
|
||||
return m.exec(func(b Backend) error {
|
||||
yc, ok := b.(YaesuController)
|
||||
if !ok {
|
||||
return fmt.Errorf("active CAT backend is not a Yaesu")
|
||||
}
|
||||
return fn(yc)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -344,6 +344,8 @@ func ModelName(addr byte) string {
|
||||
return "IC-9700"
|
||||
case 0xA4:
|
||||
return "IC-705"
|
||||
case 0xB6:
|
||||
return "IC-7300MKII"
|
||||
}
|
||||
return fmt.Sprintf("Icom (0x%02X)", addr)
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ func TestModelNameAddresses(t *testing.T) {
|
||||
0x98: "IC-7610",
|
||||
0xA2: "IC-9700",
|
||||
0xA4: "IC-705",
|
||||
0xB6: "IC-7300MKII",
|
||||
} {
|
||||
if got := ModelName(addr); got != want {
|
||||
t.Errorf("ModelName(0x%02X) = %q, want %q", addr, got, want)
|
||||
|
||||
+7
-11
@@ -50,7 +50,6 @@ type Flex struct {
|
||||
meterMeta map[int]meterInfo
|
||||
meterVal map[int]float64
|
||||
meterSub map[int]bool // ids we've already sent "sub meter <id>" for
|
||||
meterLogAt time.Time // throttle for value logging
|
||||
vitaSeen int // count of UDP datagrams (first few logged for diag)
|
||||
meterRawLogged bool // log the first raw meter-definition status once
|
||||
txRawLogged bool // log the first raw transmit status once (field-name audit)
|
||||
@@ -768,11 +767,17 @@ func (f *Flex) handleStatus(payload string) {
|
||||
f.meterMeta[num] = old
|
||||
}
|
||||
f.mu.Unlock()
|
||||
// One line for the whole batch, not one per meter: a Flex announces
|
||||
// dozens at connect and that was a wall of text every time.
|
||||
var names []string
|
||||
for _, id := range newIDs {
|
||||
mi := f.meterMeta[id]
|
||||
debugLog.Printf("Flex: meter def #%d %s/%s unit=%s lo=%g hi=%g → sub", id, mi.src, mi.name, mi.unit, mi.lo, mi.hi)
|
||||
names = append(names, nonEmpty(mi.name, strconv.Itoa(id)))
|
||||
f.subscribeMeter(id)
|
||||
}
|
||||
if len(names) > 0 {
|
||||
debugLog.Printf("Flex: subscribed to %d meter(s): %s", len(names), strings.Join(names, " "))
|
||||
}
|
||||
}
|
||||
// Spot status: "spot <index> …". Track the index so we can clear the
|
||||
// panadapter, and log it verbatim — a click on a panadapter spot pushes a
|
||||
@@ -2154,15 +2159,6 @@ func (f *Flex) parseVita(p []byte, seen int) {
|
||||
raw := int16(binary.BigEndian.Uint16(payload[i+2 : i+4]))
|
||||
f.meterVal[id] = scaleMeter(raw, f.meterMeta[id].unit)
|
||||
}
|
||||
if time.Since(f.meterLogAt) > 5*time.Second { // throttled dump to validate names
|
||||
f.meterLogAt = time.Now()
|
||||
var b strings.Builder
|
||||
for id, v := range f.meterVal {
|
||||
mi := f.meterMeta[id]
|
||||
fmt.Fprintf(&b, "%s=%.1f%s ", nonEmpty(mi.name, strconv.Itoa(id)), v, mi.unit)
|
||||
}
|
||||
debugLog.Printf("Flex: meters %s", strings.TrimSpace(b.String()))
|
||||
}
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The rule that decides whether a silent-but-alive Icom network session is
|
||||
// tolerated or torn down. It was "always tolerate", and that is what froze the
|
||||
// frequency display for ever when another program took the CI-V session.
|
||||
//
|
||||
// The decision is pinned here rather than left implicit, because the two cases
|
||||
// it separates pull in opposite directions: a rig in standby must NOT be
|
||||
// reconnected every few seconds (the panel and its ON button would blink away),
|
||||
// while a hijacked session must recover without restarting OpsLog.
|
||||
func TestIcomSilenceTolerance(t *testing.T) {
|
||||
// tolerate mirrors the condition in ReadState.
|
||||
tolerate := func(alive, readerGone bool, lastGood time.Time, silentFor, grace time.Duration) bool {
|
||||
return alive && !readerGone && (lastGood.IsZero() || silentFor < grace)
|
||||
}
|
||||
never := time.Time{}
|
||||
some := time.Now()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
alive bool
|
||||
readerGone bool
|
||||
lastGood time.Time
|
||||
silentFor time.Duration
|
||||
want bool
|
||||
}{
|
||||
{"never answered since connect — rig is off, keep the panel", true, false, never, time.Hour, true},
|
||||
{"brief silence during a band change", true, false, some, 2 * time.Second, true},
|
||||
{"silence past the grace — reconnect", true, false, some, icomSilentGrace + time.Second, false},
|
||||
{"reader goroutine gone — dead however alive the link looks", true, true, some, time.Second, false},
|
||||
{"control link itself dead", false, false, some, time.Second, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := tolerate(c.alive, c.readerGone, c.lastGood, c.silentFor, icomSilentGrace)
|
||||
if got != c.want {
|
||||
t.Errorf("%s: tolerate = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The backoff must actually converge on the ceiling: a rig left switched off is
|
||||
// silent for hours, and a window that stayed at 30 s would re-tear its session
|
||||
// about a hundred times an hour.
|
||||
func TestIcomSilenceBackoff(t *testing.T) {
|
||||
g := icomSilentGrace
|
||||
for i := 0; i < 20; i++ {
|
||||
if g < icomSilentGraceMax {
|
||||
g *= 2
|
||||
}
|
||||
}
|
||||
if g < icomSilentGraceMax {
|
||||
t.Fatalf("backoff never reached the ceiling: %s < %s", g, icomSilentGraceMax)
|
||||
}
|
||||
if g > 2*icomSilentGraceMax {
|
||||
t.Errorf("backoff overshot the ceiling: %s", g)
|
||||
}
|
||||
}
|
||||
+73
-10
@@ -37,6 +37,19 @@ type aliveTransport interface {
|
||||
Alive() bool
|
||||
}
|
||||
|
||||
const (
|
||||
// How long the network backend accepts "the control link answers but the rig
|
||||
// sends no CI-V" before declaring the session lost. Long enough to cover a
|
||||
// band change or a rig booting from standby; short enough that an operator
|
||||
// whose CI-V session was stolen (WSJT-X via OmniRig, the Remote Utility) gets
|
||||
// a live frequency back on his own rather than restarting OpsLog.
|
||||
icomSilentGrace = 30 * time.Second
|
||||
// Ceiling for the backoff applied when the silence persists — a rig switched
|
||||
// OFF is silent for hours, and re-tearing its session every 30 s would blink
|
||||
// the panel (and its ON button) away continuously.
|
||||
icomSilentGraceMax = 4 * time.Minute
|
||||
)
|
||||
|
||||
// scopeTransport is an OPTIONAL transport capability: deliver spectrum-scope
|
||||
// (0x27) frames on a SEPARATE channel from control replies. The network transport
|
||||
// implements it so the continuous panadapter stream can't crowd control replies
|
||||
@@ -89,13 +102,17 @@ type IcomSerial struct {
|
||||
scopeFixed bool // true = fixed-span mode (tracked optimistically)
|
||||
scopeSeen bool // logged the first sweep's structure once (on-rig verification)
|
||||
|
||||
curFreq int64 // last frequency read (for sideband choice)
|
||||
curModeByte byte // last raw Icom mode byte (for filter re-send)
|
||||
pollN int // ReadState cycle counter (staggers slow reads)
|
||||
splitOn bool // last read split state (refreshed every few cycles)
|
||||
splitTXFreq int64 // last read unselected/TX VFO freq while in split
|
||||
readFails int // consecutive ReadState freq-read failures (transient tolerance)
|
||||
dspLoaded bool // readDSP has run since the rig became responsive (loads all
|
||||
curFreq int64 // last frequency read (for sideband choice)
|
||||
curModeByte byte // last raw Icom mode byte (for filter re-send)
|
||||
pollN int // ReadState cycle counter (staggers slow reads)
|
||||
splitOn bool // last read split state (refreshed every few cycles)
|
||||
splitTXFreq int64 // last read unselected/TX VFO freq while in split
|
||||
readFails int // consecutive ReadState freq-read failures (transient tolerance)
|
||||
lastGoodAt time.Time // last SUCCESSFUL frequency read — bounds the network
|
||||
// "alive but silent" tolerance below, which used to be
|
||||
// unbounded and left the display frozen for ever
|
||||
silentGrace time.Duration // current width of that tolerance (backs off, see ReadState)
|
||||
dspLoaded bool // readDSP has run since the rig became responsive (loads all
|
||||
// the panel's set-once controls once the rig actually answers)
|
||||
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
|
||||
lastSetFreqAt time.Time
|
||||
@@ -267,7 +284,34 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
||||
// rig is switched on) rather than tearing the whole UDP session down and
|
||||
// flapping every few seconds. The panel stays up so the ON button works.
|
||||
if at, ok := b.port.(aliveTransport); ok {
|
||||
if at.Alive() {
|
||||
// The reader goroutine is what feeds every CI-V reply. If it has
|
||||
// exited, no read can ever succeed again, however healthy the control
|
||||
// link looks — that is not a silent rig, it is a dead connection.
|
||||
readerGone := false
|
||||
if b.readerDone != nil {
|
||||
select {
|
||||
case <-b.readerDone:
|
||||
readerGone = true
|
||||
default:
|
||||
}
|
||||
}
|
||||
// "Alive but silent" is the rig in standby, or mid band-change. It was
|
||||
// tolerated for ever, and that is the freeze: when ANOTHER program takes
|
||||
// the CI-V session (WSJT-X through OmniRig, say) the rig keeps answering
|
||||
// pings on the control stream while sending us no CI-V at all. Alive()
|
||||
// stayed true, the cached frequency was re-published with a fresh
|
||||
// timestamp on every poll, and the operator saw a confident, frozen
|
||||
// number until OpsLog was restarted. Bounded now: past the grace period
|
||||
// the error is reported so the Manager tears the session down and
|
||||
// reconnects, which re-takes the CI-V stream.
|
||||
silentFor := time.Duration(0)
|
||||
if !b.lastGoodAt.IsZero() {
|
||||
silentFor = time.Since(b.lastGoodAt)
|
||||
}
|
||||
if b.silentGrace <= 0 {
|
||||
b.silentGrace = icomSilentGrace
|
||||
}
|
||||
if at.Alive() && !readerGone && (b.lastGoodAt.IsZero() || silentFor < b.silentGrace) {
|
||||
b.readFails = 0
|
||||
s.FreqHz = b.curFreq // 0 until the rig is powered on and first read
|
||||
if b.curModeByte != 0 {
|
||||
@@ -286,8 +330,25 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
||||
b.dspMu.Unlock()
|
||||
return s, nil
|
||||
}
|
||||
debugLog.Printf("icom net: control link went quiet (no rig packets for >6 s) → reconnecting. If this recurs every ~2-3 min, the rig is invalidating the session (token renewal rejected).")
|
||||
return RigState{}, err // control link dead → let the Manager reconnect
|
||||
switch {
|
||||
case readerGone:
|
||||
debugLog.Printf("icom net: the CI-V reader has exited — the connection is dead however alive the control link looks → reconnecting")
|
||||
case at.Alive():
|
||||
debugLog.Printf("icom net: control link answers but no CI-V reply for %s → reconnecting. Another program (WSJT-X/OmniRig, the Remote Utility) has most likely taken the CI-V session.", silentFor.Round(time.Second))
|
||||
default:
|
||||
debugLog.Printf("icom net: control link went quiet (no rig packets for >6 s) → reconnecting. If this recurs every ~2-3 min, the rig is invalidating the session (token renewal rejected).")
|
||||
}
|
||||
// Restart the clock, and widen the window each time it fires without a
|
||||
// good read in between. A hijacked session recovers on the first shot;
|
||||
// a rig simply switched OFF never will, and re-tearing its session every
|
||||
// 30 s would make the panel — and its ON button — blink away
|
||||
// continuously. Backing off to minutes keeps the standby case quiet
|
||||
// while still recovering on its own.
|
||||
b.lastGoodAt = time.Now()
|
||||
if b.silentGrace < icomSilentGraceMax {
|
||||
b.silentGrace *= 2
|
||||
}
|
||||
return RigState{}, err // let the Manager reconnect
|
||||
}
|
||||
// USB (no liveness signal): the rig briefly stops answering CI-V while it
|
||||
// switches band/VFO. Tolerate a few consecutive misses as transient — keep
|
||||
@@ -308,6 +369,8 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
||||
return RigState{}, err
|
||||
}
|
||||
b.readFails = 0
|
||||
b.lastGoodAt = time.Now()
|
||||
b.silentGrace = icomSilentGrace // the rig answers: back to the short window
|
||||
s.FreqHz = hz
|
||||
b.curFreq = hz
|
||||
|
||||
|
||||
+85
-25
@@ -29,6 +29,14 @@ const (
|
||||
type OmniRig struct {
|
||||
RigNum int // 1 (Rig1) or 2 (Rig2)
|
||||
|
||||
// ForceVFO overrides which VFO to read: "" trusts the rig file, "A"/"B" do
|
||||
// not. OmniRig's VFO report is only as good as the .ini behind it, and a
|
||||
// wrong one is invisible: an IC-7610 file was seen declaring VFO B while the
|
||||
// operator worked on the main VFO, so OpsLog wrote to A (SetSimplexMode acts
|
||||
// on the main VFO) and read B — the frequency "never followed the knob",
|
||||
// while it was following the other one all along.
|
||||
ForceVFO string
|
||||
|
||||
omnirig *ole.IDispatch
|
||||
rig *ole.IDispatch
|
||||
lastSig string // last logged Split/VFO signature — only log on change
|
||||
@@ -59,14 +67,24 @@ type OmniRig struct {
|
||||
splitFlaky bool
|
||||
splitFlips int
|
||||
splitFlipWindow time.Time
|
||||
|
||||
// pendingReadback schedules one delayed frequency log after a Set — see
|
||||
// SetFrequency. Zero when nothing is pending.
|
||||
pendingReadback time.Time
|
||||
}
|
||||
|
||||
// NewOmniRig creates a non-connected backend. Call Connect before use.
|
||||
func NewOmniRig(rigNum int) *OmniRig {
|
||||
// NewOmniRig builds the backend. forceVFO is "" to follow whatever the rig file
|
||||
// reports, or "A"/"B" to override it — see the ForceVFO field.
|
||||
func NewOmniRig(rigNum int, forceVFO string) *OmniRig {
|
||||
if rigNum < 1 || rigNum > 2 {
|
||||
rigNum = 1
|
||||
}
|
||||
return &OmniRig{RigNum: rigNum}
|
||||
v := strings.ToUpper(strings.TrimSpace(forceVFO))
|
||||
if v != "A" && v != "B" {
|
||||
v = ""
|
||||
}
|
||||
return &OmniRig{RigNum: rigNum, ForceVFO: v}
|
||||
}
|
||||
|
||||
func (o *OmniRig) Name() string { return "omnirig" }
|
||||
@@ -306,7 +324,21 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
||||
}
|
||||
splitRecentOn := o.splitFlaky && !o.lastSplitOnAt.IsZero() && now.Sub(o.lastSplitOnAt) < 6*time.Second
|
||||
|
||||
s.FreqHz, s.RxFreqHz, s.Split = resolveOmniRigVFOs(o.rigType, freqMain, freqA, freqB, s.Vfo, splitRaw, splitRecentOn)
|
||||
// A forced VFO replaces whatever the rig file reported, BEFORE anything is
|
||||
// derived from it. Nothing downstream then has to know about the override.
|
||||
vfo := s.Vfo
|
||||
if o.ForceVFO != "" {
|
||||
vfo = o.ForceVFO
|
||||
}
|
||||
s.FreqHz, s.RxFreqHz, s.Split = resolveOmniRigVFOs(o.rigType, freqMain, freqA, freqB, vfo, splitRaw, splitRecentOn)
|
||||
|
||||
// Delayed readback after a Set (see SetFrequency): logged from HERE because
|
||||
// this runs on the COM-owning goroutine. One line, once per set.
|
||||
if !o.pendingReadback.IsZero() && time.Now().After(o.pendingReadback) {
|
||||
o.pendingReadback = time.Time{}
|
||||
debugLog.Printf("OmniRig.SetFrequency: readback +1.5s FreqA=%d FreqB=%d Freq=%d → shown %d",
|
||||
freqA, freqB, freqMain, s.FreqHz)
|
||||
}
|
||||
|
||||
// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so normal
|
||||
// operation stays quiet but toggling split or SUB VFO on the radio is
|
||||
@@ -461,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
|
||||
@@ -479,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 {
|
||||
@@ -497,21 +547,31 @@ func (o *OmniRig) SetFrequency(hz int64) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Read back all three immediately. OmniRig is async (the CAT command is
|
||||
// queued + sent over serial), so these may still show the OLD value for
|
||||
// one poll cycle — but if they NEVER change in the next poll, the rig
|
||||
// isn't honouring the write (wrong .ini WRITE command for this model).
|
||||
fa, fb, fg := int64(-1), int64(-1), int64(-1)
|
||||
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
|
||||
fa = v.Val
|
||||
// Read back all three, then AGAIN a moment later. OmniRig is async — the CAT
|
||||
// command is queued and sent over serial — so an immediate read always shows
|
||||
// the old value and proves nothing. The delayed one is the useful half: it
|
||||
// separates "the rig ignored the write" from "the rig moved but its .ini
|
||||
// never re-reads the frequency", which look identical to an operator whose
|
||||
// display stays on the old band until they nudge the VFO.
|
||||
//
|
||||
// Off the CAT goroutine so the poll loop is not held for a second. COM is
|
||||
// thread-affine, so the delayed read goes through the manager's own command
|
||||
// channel rather than touching o.rig from here.
|
||||
logFreqs := func(when string) {
|
||||
fa, fb, fg := int64(-1), int64(-1), int64(-1)
|
||||
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
|
||||
fa = v.Val
|
||||
}
|
||||
if v, err := oleutil.GetProperty(o.rig, "FreqB"); err == nil {
|
||||
fb = v.Val
|
||||
}
|
||||
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
||||
fg = v.Val
|
||||
}
|
||||
debugLog.Printf("OmniRig.SetFrequency: readback %s FreqA=%d FreqB=%d Freq=%d (target %d)", when, fa, fb, fg, hz)
|
||||
}
|
||||
if v, err := oleutil.GetProperty(o.rig, "FreqB"); err == nil {
|
||||
fb = v.Val
|
||||
}
|
||||
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
||||
fg = v.Val
|
||||
}
|
||||
debugLog.Printf("OmniRig.SetFrequency: readback FreqA=%d FreqB=%d Freq=%d (target %d)", fa, fb, fg, hz)
|
||||
logFreqs("now")
|
||||
o.pendingReadback = time.Now().Add(1500 * time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,14 @@ func TestResolveOmniRigVFOs(t *testing.T) {
|
||||
{"pair enum AB, simplex → listening on A", "", a14200, a14200, b14205, "AB", pmSplitOff, false, a14200, 0, false},
|
||||
{"pair enum AA, simplex → listening on A", "", a14200, a14200, b14205, "AA", pmSplitOff, false, a14200, 0, false},
|
||||
|
||||
// F4?? IC-7610 report, 2026-07-27: the operator's custom rig file declares
|
||||
// VFO B permanently while they work on the MAIN VFO. OpsLog then wrote to A
|
||||
// (SetSimplexMode acts on main) and read B — "the frequency never follows
|
||||
// the knob". Honouring the enum is right in general, which is why the cure
|
||||
// is an explicit override in the settings, applied before this function.
|
||||
{"IC-7610 file wrongly says B — enum honoured, hence the override", "IC-7610", 0, 7150000, 14295000, "B", pmSplitOff, false, 14295000, 0, false},
|
||||
{"same rig with the override forcing A", "IC-7610", 0, 7150000, 14295000, "A", pmSplitOff, false, 7150000, 0, false},
|
||||
|
||||
// Non-regression: a rig that does not report the VFO enum keeps the old
|
||||
// order — freqA, then the generic Freq, then freqB.
|
||||
{"no VFO enum, freqA populated (Yaesu/Kenwood)", "FT-891", a14200, a14200, 0, "", pmSplitOff, false, a14200, 0, false},
|
||||
@@ -75,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package cat
|
||||
|
||||
// Native Xiegu CAT (G90, X6100, X6200, X5105 and relatives) over the rig's
|
||||
// serial/USB port.
|
||||
//
|
||||
// Xiegu speaks CI-V — Icom's bus protocol — with a REDUCED command set. Frames,
|
||||
// BCD encoding, addressing and the opcodes for frequency (0x03/0x05), mode
|
||||
// (0x04/0x06), PTT (0x1C 0x00), split (0x0F) and the meters (0x15) are the same
|
||||
// as Icom's, which is why this backend reuses internal/cat/civ wholesale rather
|
||||
// than re-deriving it.
|
||||
//
|
||||
// It is a SEPARATE backend rather than the Icom one with another address,
|
||||
// because what the two rigs DON'T share is the important part. The Icom backend
|
||||
// reads the spectrum scope, the DSP block, data-mode via 0x1A 0x06, the model id
|
||||
// via 0x19 — none of which a Xiegu implements. Pointed at a G90 it would poll
|
||||
// for answers that never come on every cycle, and its silence tolerance would
|
||||
// spend itself on commands the radio was never going to support.
|
||||
//
|
||||
// Mode differences that matter: the Xiegu table lists LSB, USB, AM, CW and CWR
|
||||
// only — no FM, no RTTY, and no data mode. A digital QSO therefore runs in USB
|
||||
// (which is what the operator does on the radio anyway), and the mode is
|
||||
// reported as the operator's configured digital mode when they select one, not
|
||||
// invented from the rig.
|
||||
//
|
||||
// Verified on: nothing yet — written from the Xiegu CI-V command table. The
|
||||
// command table published by Xiegu has rows that clearly slipped during
|
||||
// typesetting (0x07 and 0x0F share a block), so where it contradicts itself the
|
||||
// Icom meaning is used, since the rest of the table matches Icom exactly. Every
|
||||
// unexpected reply is logged raw so a first on-air run settles it.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
|
||||
"hamlog/internal/cat/civ"
|
||||
)
|
||||
|
||||
// XieguDefaultAddr is the factory CI-V address of the G90/X6100 family.
|
||||
const XieguDefaultAddr = 0x70
|
||||
|
||||
type Xiegu struct {
|
||||
portName string
|
||||
baud int
|
||||
rigAddr byte
|
||||
digital string
|
||||
|
||||
mu sync.Mutex
|
||||
port serial.Port
|
||||
|
||||
curFreq int64
|
||||
// splitSupported is cleared when the rig ignores the split query, so we stop
|
||||
// asking every cycle — a Xiegu that has no split must not cost a timeout per
|
||||
// poll, which would slow the whole loop to a crawl.
|
||||
splitSupported bool
|
||||
}
|
||||
|
||||
func NewXiegu(portName string, baud int, addr int, digital string) *Xiegu {
|
||||
if baud <= 0 {
|
||||
baud = 19200 // G90 factory default
|
||||
}
|
||||
if addr <= 0 || addr > 0xFF {
|
||||
addr = XieguDefaultAddr
|
||||
}
|
||||
if strings.TrimSpace(digital) == "" {
|
||||
digital = "FT8"
|
||||
}
|
||||
return &Xiegu{
|
||||
portName: strings.TrimSpace(portName), baud: baud,
|
||||
rigAddr: byte(addr), digital: digital, splitSupported: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (x *Xiegu) Name() string { return "xiegu" }
|
||||
|
||||
func (x *Xiegu) Connect() error {
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
if x.portName == "" {
|
||||
return fmt.Errorf("xiegu: no serial port configured")
|
||||
}
|
||||
p, err := serial.Open(x.portName, &serial.Mode{BaudRate: x.baud})
|
||||
if err != nil {
|
||||
return fmt.Errorf("xiegu: open %s @ %d baud: %w", x.portName, x.baud, err)
|
||||
}
|
||||
p.SetReadTimeout(200 * time.Millisecond)
|
||||
x.port = p
|
||||
x.splitSupported = true
|
||||
|
||||
// Prove the link before declaring success: an open COM port says nothing
|
||||
// about a radio being on the other end, and a backend that reports
|
||||
// "connected" to a powered-off rig sends the operator hunting for a fault in
|
||||
// the wrong place.
|
||||
if _, err := x.readFreq(); err != nil {
|
||||
_ = p.Close()
|
||||
x.port = nil
|
||||
return fmt.Errorf("xiegu: no answer on %s @ %d baud (address 0x%02X): %w", x.portName, x.baud, x.rigAddr, err)
|
||||
}
|
||||
debugLog.Printf("xiegu: connected on %s @ %d baud, CI-V address 0x%02X", x.portName, x.baud, x.rigAddr)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Xiegu) Disconnect() {
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
if x.port != nil {
|
||||
_ = x.port.Close()
|
||||
x.port = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (x *Xiegu) ReadState() (RigState, error) {
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
if x.port == nil {
|
||||
return RigState{}, fmt.Errorf("xiegu: not connected")
|
||||
}
|
||||
s := RigState{Backend: x.Name(), Connected: true, Rig: "Xiegu"}
|
||||
|
||||
hz, err := x.readFreq()
|
||||
if err != nil {
|
||||
return RigState{}, err // let the Manager reconnect
|
||||
}
|
||||
s.FreqHz = hz
|
||||
x.curFreq = hz
|
||||
|
||||
if d, err := x.ask(civ.CmdReadMode); err == nil && len(d.Data) >= 1 {
|
||||
s.Mode = civ.ModeToADIF(d.Data[0], false)
|
||||
// The rig has no data mode, so it reports USB on the digital watering
|
||||
// holes. Naming the operator's digital mode there is the frontend's job
|
||||
// (it infers from frequency); reporting USB honestly is ours.
|
||||
}
|
||||
|
||||
if x.splitSupported {
|
||||
d, err := x.ask(civ.CmdSplit)
|
||||
switch {
|
||||
case err != nil:
|
||||
debugLog.Printf("xiegu: split query got no answer (%v) — not asking again this session", err)
|
||||
x.splitSupported = false
|
||||
case len(d.Data) >= 1:
|
||||
s.Split = d.Data[0] == 0x01
|
||||
}
|
||||
}
|
||||
// Split TX frequency is deliberately NOT reported. Reading the unselected
|
||||
// VFO needs 0x25, which the Xiegu table does not list — and a split flag with
|
||||
// a wrong TX frequency is worse than a split flag alone, because it is the
|
||||
// frequency that gets logged.
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (x *Xiegu) SetFrequency(hz int64) error {
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
if x.port == nil {
|
||||
return fmt.Errorf("xiegu: not connected")
|
||||
}
|
||||
if hz <= 0 {
|
||||
return fmt.Errorf("xiegu: invalid frequency %d", hz)
|
||||
}
|
||||
payload := append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)
|
||||
return x.send(payload...)
|
||||
}
|
||||
|
||||
func (x *Xiegu) SetMode(mode string) error {
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
if x.port == nil {
|
||||
return fmt.Errorf("xiegu: not connected")
|
||||
}
|
||||
m, ok := xieguModeByte(mode, x.curFreq)
|
||||
if !ok {
|
||||
return fmt.Errorf("xiegu: no CAT mode for %q", mode)
|
||||
}
|
||||
return x.send(civ.CmdSetMode, m)
|
||||
}
|
||||
|
||||
func (x *Xiegu) SetPTT(on bool) error {
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
if x.port == nil {
|
||||
return fmt.Errorf("xiegu: not connected")
|
||||
}
|
||||
v := byte(0x00)
|
||||
if on {
|
||||
v = 0x01
|
||||
}
|
||||
return x.send(civ.CmdPTT, 0x00, v)
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
func (x *Xiegu) send(payload ...byte) error {
|
||||
if x.port == nil {
|
||||
return fmt.Errorf("xiegu: not connected")
|
||||
}
|
||||
_, err := x.port.Write(civ.Frame(x.rigAddr, civ.AddrController, payload...))
|
||||
return err
|
||||
}
|
||||
|
||||
// ask sends a query and returns the rig's answer frame.
|
||||
//
|
||||
// CI-V is a shared bus: the rig echoes back what we sent before answering, so
|
||||
// our own frame has to be skipped. Matching on the SENDER (From == the rig)
|
||||
// rather than on position is what makes this robust when an echo is dropped or
|
||||
// an unsolicited frame arrives from the dial being turned.
|
||||
func (x *Xiegu) ask(payload ...byte) (civ.Decoded, error) {
|
||||
if err := x.send(payload...); err != nil {
|
||||
return civ.Decoded{}, err
|
||||
}
|
||||
buf := make([]byte, 0, 64)
|
||||
tmp := make([]byte, 64)
|
||||
deadline := time.Now().Add(600 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
n, err := x.port.Read(tmp)
|
||||
if err != nil {
|
||||
return civ.Decoded{}, err
|
||||
}
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
buf = append(buf, tmp[:n]...)
|
||||
frames, consumed := civ.Scan(buf)
|
||||
buf = buf[consumed:]
|
||||
for _, f := range frames {
|
||||
if f.From != x.rigAddr {
|
||||
continue // our own echo on the bus
|
||||
}
|
||||
if f.Cmd == 0xFA {
|
||||
return civ.Decoded{}, fmt.Errorf("xiegu: rig rejected command 0x%02X", payload[0])
|
||||
}
|
||||
if f.Cmd == payload[0] {
|
||||
return f, nil
|
||||
}
|
||||
// An unsolicited update (the operator turning the dial) — useful, but
|
||||
// not the answer we asked for.
|
||||
debugLog.Printf("xiegu: unsolicited frame cmd=0x%02X data=% X", f.Cmd, f.Data)
|
||||
}
|
||||
}
|
||||
return civ.Decoded{}, fmt.Errorf("xiegu: timeout answering 0x%02X", payload[0])
|
||||
}
|
||||
|
||||
func (x *Xiegu) readFreq() (int64, error) {
|
||||
d, err := x.ask(civ.CmdReadFreq)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
hz := civ.BCDToFreq(d.Data)
|
||||
if hz <= 0 {
|
||||
return 0, fmt.Errorf("xiegu: unusable frequency % X", d.Data)
|
||||
}
|
||||
return hz, nil
|
||||
}
|
||||
|
||||
// xieguModeByte maps an ADIF mode to the Xiegu's mode byte.
|
||||
//
|
||||
// The rig has LSB, USB, AM, CW and CWR — nothing else. A digital mode therefore
|
||||
// becomes USB (or LSB below 10 MHz), which is what the operator selects on the
|
||||
// radio; claiming a DATA mode it does not have would just be refused.
|
||||
func xieguModeByte(mode string, freqHz int64) (byte, bool) {
|
||||
lowBand := freqHz > 0 && freqHz < 10_000_000
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "":
|
||||
return 0, false
|
||||
case "LSB":
|
||||
return 0x00, true
|
||||
case "USB":
|
||||
return 0x01, true
|
||||
case "SSB":
|
||||
if lowBand {
|
||||
return 0x00, true
|
||||
}
|
||||
return 0x01, true
|
||||
case "AM":
|
||||
return 0x02, true
|
||||
case "CW":
|
||||
return 0x03, true
|
||||
case "CWR", "CW-R":
|
||||
return 0x07, true
|
||||
default:
|
||||
// Every digital sub-mode rides on plain sideband here.
|
||||
if lowBand {
|
||||
return 0x00, true
|
||||
}
|
||||
return 0x01, true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/cat/civ"
|
||||
)
|
||||
|
||||
// The Xiegu mode table is SHORTER than Icom's: LSB, USB, AM, CW, CWR and
|
||||
// nothing else. The interesting cases are the ones with no equivalent — a
|
||||
// digital mode must land on plain sideband rather than be refused, because
|
||||
// refusing it would leave the rig on whatever it was and silently log the wrong
|
||||
// mode.
|
||||
func TestXieguModeByte(t *testing.T) {
|
||||
cases := []struct {
|
||||
mode string
|
||||
hz int64
|
||||
want byte
|
||||
ok bool
|
||||
}{
|
||||
{"SSB", 7150000, 0x00, true}, // LSB below 10 MHz
|
||||
{"SSB", 14250000, 0x01, true}, // USB above
|
||||
{"LSB", 14250000, 0x00, true}, // explicit beats the convention
|
||||
{"USB", 7150000, 0x01, true},
|
||||
{"AM", 7150000, 0x02, true},
|
||||
{"CW", 7030000, 0x03, true},
|
||||
{"CWR", 7030000, 0x07, true},
|
||||
{"FT8", 7074000, 0x00, true}, // no data mode on this rig → LSB
|
||||
{"FT8", 14074000, 0x01, true}, // → USB
|
||||
{"RTTY", 14080000, 0x01, true},
|
||||
{"FM", 145000000, 0x01, true}, // no FM either; sideband is the honest fallback
|
||||
{"", 14074000, 0x00, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := xieguModeByte(c.mode, c.hz)
|
||||
if got != c.want || ok != c.ok {
|
||||
t.Errorf("xieguModeByte(%q, %d) = 0x%02X,%v — want 0x%02X,%v", c.mode, c.hz, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Xiegu mode bytes must decode through the shared Icom table, since that is
|
||||
// the whole reason this backend reuses internal/cat/civ instead of its own
|
||||
// codec. If the two ever disagree, the radio would be set to one mode and read
|
||||
// back as another.
|
||||
func TestXieguModesRoundTripThroughCIV(t *testing.T) {
|
||||
cases := []struct {
|
||||
mode string
|
||||
adif string
|
||||
}{
|
||||
// ADIF has no LSB/USB distinction — both sidebands ARE the SSB mode, and
|
||||
// that is what gets logged. The sideband still matters to the radio, which
|
||||
// is why xieguModeByte picks it from the frequency.
|
||||
{"LSB", "SSB"},
|
||||
{"USB", "SSB"},
|
||||
{"AM", "AM"},
|
||||
{"CW", "CW"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
b, ok := xieguModeByte(c.mode, 14200000)
|
||||
if !ok {
|
||||
t.Fatalf("xieguModeByte(%q) refused", c.mode)
|
||||
}
|
||||
if got := civ.ModeToADIF(b, false); got != c.adif {
|
||||
t.Errorf("%s → 0x%02X → %q, want %q", c.mode, b, got, c.adif)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Frequency encoding is shared with Icom, and it is the one field where a byte
|
||||
// out of place mistunes the radio silently. Pinned here at the boundaries a
|
||||
// Xiegu actually covers (it is an HF rig, so the 5-byte BCD upper bytes are 0).
|
||||
func TestXieguFrequencyEncoding(t *testing.T) {
|
||||
for _, hz := range []int64{1840000, 7074000, 14074000, 28500000, 50313000} {
|
||||
b := civ.FreqToBCD(hz)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
package cat
|
||||
|
||||
// Native Yaesu CAT over the rig's serial/USB port — no OmniRig.
|
||||
//
|
||||
// Why this exists: OmniRig sits between OpsLog and the radio and adds its own
|
||||
// rig-description files, its own VFO/split interpretation and its own polling.
|
||||
// Every Yaesu problem reported so far came from that layer disagreeing with the
|
||||
// radio — a .ini that never exposes the VFO, a Freq property that means A on one
|
||||
// model and B on another, a split flag that alternates. Talking to the rig
|
||||
// directly removes the disagreement: what the radio answers is what we show.
|
||||
//
|
||||
// ── The protocol ──────────────────────────────────────────────────────────
|
||||
// Modern Yaesu CAT is plain ASCII: a command, its arguments, and a ';'
|
||||
// terminator. A query is the command with no argument; the rig echoes the same
|
||||
// command with the value. It is the same shape as Kenwood's, which is why an
|
||||
// FTDX10 answers a Kenwood-speaking logger for the basics.
|
||||
//
|
||||
// FA; → FA014074000; VFO A frequency, 9 digits, Hz
|
||||
// FB; → FB014100000; VFO B frequency
|
||||
// MD0; → MD02; operating mode of the main receiver
|
||||
// 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
|
||||
// ID; → ID0761; model identifier
|
||||
//
|
||||
// Two of these are genuinely uncertain across the family and are treated as
|
||||
// such rather than guessed at: SPLIT is read through ST and, if the rig does not
|
||||
// answer that, through FT — whichever replies wins, and the choice is
|
||||
// remembered. Every unrecognised reply is logged raw, because that log is the
|
||||
// only way to learn a model's real behaviour from an operator's shack.
|
||||
//
|
||||
// Verified on: FTDX10, 2026-07-29 — frequency, mode, VFO and split all correct
|
||||
// against the radio. The other models are still inference from the same CAT
|
||||
// reference; anything this file asserts about a rig it has not met should be
|
||||
// read as a hypothesis with a log line attached.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
// yaesuModels maps the ID reply to a display name. An unknown id is shown as
|
||||
// itself rather than guessed — a wrong model name would be worse than a number,
|
||||
// because it silently implies capabilities the rig may not have.
|
||||
var yaesuModels = map[string]string{
|
||||
"0761": "FTDX10",
|
||||
"0681": "FTDX101D",
|
||||
"0682": "FTDX101MP",
|
||||
"0800": "FT-710",
|
||||
"0570": "FT-991A",
|
||||
"0650": "FT-891",
|
||||
"0670": "FT-DX3000",
|
||||
"0460": "FT-450D",
|
||||
}
|
||||
|
||||
// yaesuModeToADIF maps the MD digit to an ADIF mode. The DATA and RTTY variants
|
||||
// differ only by sideband, which ADIF does not record — they collapse to the
|
||||
// operator's configured digital mode and to RTTY respectively.
|
||||
var yaesuModeToADIF = map[byte]string{
|
||||
'1': "LSB",
|
||||
'2': "USB",
|
||||
'3': "CW",
|
||||
'4': "FM",
|
||||
'5': "AM",
|
||||
'6': "RTTY",
|
||||
'7': "CW",
|
||||
'8': "DATA",
|
||||
'9': "RTTY",
|
||||
'A': "FM",
|
||||
'B': "FM",
|
||||
'C': "DATA",
|
||||
'D': "AM",
|
||||
'E': "FM", // C4FM — digital voice, closest ADIF sense is FM
|
||||
}
|
||||
|
||||
type Yaesu struct {
|
||||
portName string
|
||||
baud int
|
||||
digital string // ADIF mode reported for DATA (FT8, RTTY…)
|
||||
|
||||
mu sync.Mutex
|
||||
port serial.Port
|
||||
|
||||
model string
|
||||
// splitCmd is learned at connect: "ST" or "FT" depending on which the rig
|
||||
// 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
|
||||
curVFO string // "A" or "B"
|
||||
|
||||
// Control-panel state and its slow-beat counter — see yaesu_panel.go.
|
||||
panel YaesuTXState
|
||||
panelCycle int
|
||||
panelLoaded bool
|
||||
// Commands this rig answered "?;" to — asked once, then never again.
|
||||
unsupported map[string]bool
|
||||
// Needle inertia for the TX meters — see meterPeak.
|
||||
powerPeak meterPeak
|
||||
powerWPeak meterPeak
|
||||
swrPeak meterPeak
|
||||
// metersLogged counts the RM1..RM6 samples taken during transmission, so the
|
||||
// survey follows a real carrier instead of catching one instant of it.
|
||||
metersLogged int
|
||||
}
|
||||
|
||||
func NewYaesu(portName string, baud int, digital string) *Yaesu {
|
||||
if baud <= 0 {
|
||||
baud = 38400 // FTDX10/FTDX101 factory default
|
||||
}
|
||||
if strings.TrimSpace(digital) == "" {
|
||||
digital = "FT8"
|
||||
}
|
||||
return &Yaesu{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
|
||||
}
|
||||
|
||||
func (y *Yaesu) Name() string { return "yaesu" }
|
||||
|
||||
func (y *Yaesu) Connect() error {
|
||||
y.mu.Lock()
|
||||
defer y.mu.Unlock()
|
||||
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)
|
||||
}
|
||||
p.SetReadTimeout(300 * time.Millisecond)
|
||||
y.port = p
|
||||
|
||||
// Silence unsolicited status reports. The rig can push them on every knob
|
||||
// movement (AI1), which interleaves with our request/response pairs and makes
|
||||
// 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
|
||||
} else {
|
||||
y.model = "Yaesu (" + code + ")"
|
||||
debugLog.Printf("yaesu: unknown model id %q — add it to yaesuModels", code)
|
||||
}
|
||||
} else {
|
||||
debugLog.Printf("yaesu: ID query failed (%v) — continuing, the model name is cosmetic", err)
|
||||
}
|
||||
|
||||
// Which command carries split on THIS rig. Asking once at connect and
|
||||
// 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.
|
||||
// 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
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (y *Yaesu) Disconnect() {
|
||||
y.mu.Lock()
|
||||
defer y.mu.Unlock()
|
||||
if y.port != nil {
|
||||
_ = y.port.Close()
|
||||
y.port = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (y *Yaesu) ReadState() (RigState, error) {
|
||||
y.mu.Lock()
|
||||
defer y.mu.Unlock()
|
||||
if y.port == nil {
|
||||
return RigState{}, fmt.Errorf("yaesu: not connected")
|
||||
}
|
||||
s := RigState{Backend: y.Name(), Connected: true, Rig: y.model}
|
||||
|
||||
faRaw, err := y.ask("FA;")
|
||||
if errors.Is(err, errYaesuUnsupported) {
|
||||
// A "?;" here is almost never about FA — the rig answers frequency queries
|
||||
// perfectly well. It is a rejection left over from the PREVIOUS command
|
||||
// that our read then attributed to this one. Retrying once costs a few
|
||||
// milliseconds; treating it as "lost the rig" tore the CAT link down and
|
||||
// reconnected it, which is what the operator saw on every CW macro.
|
||||
debugLog.Printf("yaesu: FA; got a stray rejection — retrying once")
|
||||
faRaw, err = y.ask("FA;")
|
||||
}
|
||||
if err != nil {
|
||||
return RigState{}, err // the rig stopped answering — let the Manager reconnect
|
||||
}
|
||||
freqA, ok := parseYaesuFreq(faRaw, "FA")
|
||||
if !ok {
|
||||
return RigState{}, fmt.Errorf("yaesu: unparsable FA reply %q", faRaw)
|
||||
}
|
||||
freqB := int64(0)
|
||||
if r, err := y.ask("FB;"); err == nil {
|
||||
freqB, _ = parseYaesuFreq(r, "FB")
|
||||
}
|
||||
|
||||
// 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"
|
||||
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 = yaesuSplitFromReply(r, y.splitCmd, vfo)
|
||||
}
|
||||
}
|
||||
|
||||
s.Vfo = vfo
|
||||
s.FreqHz, s.RxFreqHz, s.Split = resolveYaesuVFOs(freqA, freqB, vfo, split)
|
||||
y.curFreq = s.FreqHz
|
||||
// The frequency being LISTENED to, which is what a split offset is measured
|
||||
// from — under split that is RxFreqHz, not FreqHz.
|
||||
y.curRXFreq = s.FreqHz
|
||||
if s.Split && s.RxFreqHz > 0 {
|
||||
y.curRXFreq = s.RxFreqHz
|
||||
}
|
||||
|
||||
if r, err := y.ask("MD0;"); 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])
|
||||
if m, ok := yaesuModeToADIF[r[3]]; ok {
|
||||
if m == "DATA" {
|
||||
m = y.digital
|
||||
}
|
||||
s.Mode = m
|
||||
} else {
|
||||
debugLog.Printf("yaesu: unknown mode reply %q", r)
|
||||
}
|
||||
}
|
||||
// s.FreqHz is the TX frequency by the ADIF convention, so it IS the split
|
||||
// transmit frequency when split is on.
|
||||
y.readPanel(s.Mode, s.Split, s.FreqHz)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (y *Yaesu) SetFrequency(hz int64) error {
|
||||
y.mu.Lock()
|
||||
defer y.mu.Unlock()
|
||||
if y.port == nil {
|
||||
return fmt.Errorf("yaesu: not connected")
|
||||
}
|
||||
if hz <= 0 || hz > 999_999_999 {
|
||||
return fmt.Errorf("yaesu: frequency %d out of the 9-digit CAT range", hz)
|
||||
}
|
||||
// Write to the VFO the operator is ACTUALLY on. Always writing FA is what
|
||||
// makes a display disagree with the radio when the operator is on B.
|
||||
cmd := "FA"
|
||||
if y.curVFO == "B" {
|
||||
cmd = "FB"
|
||||
}
|
||||
return y.write(fmt.Sprintf("%s%09d;", cmd, hz))
|
||||
}
|
||||
|
||||
func (y *Yaesu) SetMode(mode string) error {
|
||||
y.mu.Lock()
|
||||
defer y.mu.Unlock()
|
||||
if y.port == nil {
|
||||
return fmt.Errorf("yaesu: not connected")
|
||||
}
|
||||
d := yaesuModeDigit(mode, y.curFreq)
|
||||
if d == 0 {
|
||||
return fmt.Errorf("yaesu: no CAT mode for %q", mode)
|
||||
}
|
||||
return y.write(fmt.Sprintf("MD0%c;", d))
|
||||
}
|
||||
|
||||
func (y *Yaesu) SetPTT(on bool) error {
|
||||
y.mu.Lock()
|
||||
defer y.mu.Unlock()
|
||||
if y.port == nil {
|
||||
return fmt.Errorf("yaesu: not connected")
|
||||
}
|
||||
if on {
|
||||
return y.write("TX1;")
|
||||
}
|
||||
return y.write("TX0;")
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
// write sends one command. The caller holds the mutex.
|
||||
func (y *Yaesu) write(cmd string) error {
|
||||
if y.port == nil {
|
||||
return fmt.Errorf("yaesu: not connected")
|
||||
}
|
||||
_, err := y.port.Write([]byte(cmd))
|
||||
return err
|
||||
}
|
||||
|
||||
// ask sends a query and reads the reply up to its ';'. The caller holds the
|
||||
// mutex, so a command and its answer are never interleaved with another's.
|
||||
func (y *Yaesu) ask(cmd string) (string, error) {
|
||||
if err := y.write(cmd); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Match the reply to the COMMAND, and drop anything else.
|
||||
//
|
||||
// Returning the first ';'-terminated string whatever it was is what made a CW
|
||||
// macro knock the CAT link over: KY produces no reply, so the next query —
|
||||
// FA; from the poll loop — collected a leftover frame, failed to parse as a
|
||||
// frequency, and the Manager treated that as "lost the rig" and reconnected.
|
||||
// The operator saw the CAT drop for a few seconds on every macro click.
|
||||
want := cmdPrefix(cmd)
|
||||
buf := make([]byte, 0, 64)
|
||||
tmp := make([]byte, 64)
|
||||
deadline := time.Now().Add(600 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
n, err := y.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:]
|
||||
// "?;" is the rig saying it does not know this command. Confirmed on an
|
||||
// FTDX10, which answers it to KY; and MG;. Reporting it as such — rather
|
||||
// than discarding it and waiting out the timeout — is what lets callers
|
||||
// stop asking instead of paying 600 ms per poll for ever.
|
||||
if strings.TrimSpace(frame) == "?;" {
|
||||
return "", errYaesuUnsupported
|
||||
}
|
||||
if want == "" || strings.HasPrefix(strings.ToUpper(frame), want) {
|
||||
return frame, nil
|
||||
}
|
||||
debugLog.Printf("yaesu: discarding %q while waiting for %s (asked %q)", frame, want, cmd)
|
||||
}
|
||||
if len(buf) > 512 {
|
||||
return "", fmt.Errorf("yaesu: no ';' in %d bytes answering %q", len(buf), cmd)
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("yaesu: timeout answering %q", cmd)
|
||||
}
|
||||
|
||||
// errYaesuUnsupported is returned when the rig answers "?;" — it does not know
|
||||
// the command. Different models implement different subsets, and the only
|
||||
// reliable way to learn which is to ask once and remember the refusal.
|
||||
var errYaesuUnsupported = errors.New("yaesu: command not supported by this rig")
|
||||
|
||||
// cmdPrefix is the leading letters of a command — what its reply starts with.
|
||||
// "FA;" → "FA", "MD0;" → "MD", "KY;" → "KY".
|
||||
func cmdPrefix(cmd string) string {
|
||||
c := strings.ToUpper(strings.TrimSpace(cmd))
|
||||
for i := 0; i < len(c); i++ {
|
||||
if c[i] < 'A' || c[i] > 'Z' {
|
||||
return c[:i]
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// parseYaesuFreq reads "FA014074000;" into Hz.
|
||||
func parseYaesuFreq(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
|
||||
}
|
||||
|
||||
// yaesuSplitFromReply turns the split reply into a yes or no.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
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
|
||||
// the ADIF pair: FreqHz is where we TRANSMIT, RxFreqHz only when split.
|
||||
//
|
||||
// Kept pure and separate from ReadState so the rules can be tested without a
|
||||
// radio — the equivalent OmniRig function is where every Yaesu bug lived.
|
||||
func resolveYaesuVFOs(freqA, freqB int64, vfo string, split bool) (tx, rx int64, isSplit bool) {
|
||||
listening, transmitting := freqA, freqB
|
||||
if vfo == "B" {
|
||||
listening, transmitting = freqB, freqA
|
||||
}
|
||||
if !split {
|
||||
return listening, 0, false
|
||||
}
|
||||
// Split with a missing or identical other VFO is not split: reporting it
|
||||
// would put a wrong TX frequency in the log, which is worse than ignoring a
|
||||
// flag the rig may have left set.
|
||||
if transmitting <= 0 || transmitting == listening {
|
||||
return listening, 0, false
|
||||
}
|
||||
return transmitting, listening, true
|
||||
}
|
||||
|
||||
// yaesuModeDigit maps an ADIF mode to the MD digit. SSB has no single digit —
|
||||
// the sideband follows the worldwide convention (LSB below 10 MHz, USB above),
|
||||
// which is why the current frequency is part of the decision.
|
||||
func yaesuModeDigit(mode string, freqHz int64) byte {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "SSB":
|
||||
if freqHz > 0 && freqHz < 10_000_000 {
|
||||
return '1' // LSB
|
||||
}
|
||||
return '2' // USB
|
||||
case "LSB":
|
||||
return '1'
|
||||
case "USB":
|
||||
return '2'
|
||||
case "CW":
|
||||
return '3'
|
||||
case "FM":
|
||||
return '4'
|
||||
case "AM":
|
||||
return '5'
|
||||
case "RTTY":
|
||||
return '6'
|
||||
case "":
|
||||
return 0
|
||||
default:
|
||||
// Everything else is a digital sub-mode (FT8, FT4, PSK31, JS8…). They all
|
||||
// ride on the rig's DATA mode; the sideband follows the same convention.
|
||||
if freqHz > 0 && freqHz < 10_000_000 {
|
||||
return '8' // DATA-LSB
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package cat
|
||||
|
||||
// CW keying through the Yaesu's own keyer — the KY command.
|
||||
//
|
||||
// This is the fifth CW engine, alongside WinKeyer, the DTR/RTS line keyer, the
|
||||
// Icom CI-V keyer and FlexRadio's CWX. The point is the same in each: no extra
|
||||
// hardware, no second cable. The radio holds the text and keys it with its own
|
||||
// timing, which is why the character spacing is perfect where a PC keying a line
|
||||
// through USB latency is not.
|
||||
//
|
||||
// KY; → KY0; buffer has room / KY1; buffer full
|
||||
// KY <text>; queue up to 24 characters
|
||||
//
|
||||
// The leading SPACE after KY is part of the command, not padding.
|
||||
//
|
||||
// CONFIRMED on an FTDX10 (2026-07-29): it refuses this outright, answering "?;"
|
||||
// to both KY; and KY <text>; — including with PC KEYING on DAKY, the obvious
|
||||
// candidate, which does NOT help. That model keys from a PC only through the DTR
|
||||
// line of its second (standard) COM port, which is OpsLog's "serial" engine and
|
||||
// is confirmed working.
|
||||
//
|
||||
// The code stays because KY is documented for the FTDX101 / FT-991A / FT-710
|
||||
// family. On a rig that refuses it, the error now names the route that works.
|
||||
//
|
||||
// STOP is the other uncertain half: Yaesu documents no buffer-clear, see StopCW.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// yaesuCWChunk is the most characters one KY command accepts. Longer text is
|
||||
// split and fed as the rig drains its buffer.
|
||||
const yaesuCWChunk = 24
|
||||
|
||||
// cwStatusLogged makes the KY status reply appear in the log once per run: it
|
||||
// is a line we have no verified sample of, so the first one is worth keeping.
|
||||
var cwStatusLogged bool
|
||||
|
||||
// yaesuCWAllowed is what the rig's keyer can actually send. Anything else is
|
||||
// dropped rather than passed through: an unsupported byte can abort the whole
|
||||
// buffer, losing the rest of the message with no error anywhere.
|
||||
const yaesuCWAllowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 /?.,-=+:;()"
|
||||
|
||||
// SendCW queues a message on the rig's keyer.
|
||||
//
|
||||
// Text is filtered, upper-cased and fed in 24-character pieces, waiting for room
|
||||
// between pieces. Without that wait a long macro would silently lose its tail:
|
||||
// the rig drops what does not fit rather than reporting an error.
|
||||
func (y *Yaesu) SendCW(text string) error {
|
||||
msg := filterYaesuCW(text)
|
||||
if msg == "" {
|
||||
return nil
|
||||
}
|
||||
for len(msg) > 0 {
|
||||
n := yaesuCWChunk
|
||||
if len(msg) < n {
|
||||
n = len(msg)
|
||||
}
|
||||
chunk := msg[:n]
|
||||
msg = msg[n:]
|
||||
if err := y.waitCWBuffer(4 * time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
y.mu.Lock()
|
||||
err := y.write("KY " + chunk + ";")
|
||||
if err == nil {
|
||||
// KY produces no reply when it is accepted — but a rig that REJECTS it
|
||||
// answers "?;", and that frame then sat in the buffer until the poll
|
||||
// loop's next query picked it up, failed, and the Manager tore the link
|
||||
// down. That is the CAT dropping for a few seconds on every macro click.
|
||||
//
|
||||
// Reading here does two things: it keeps the stray frame off the poll
|
||||
// loop, and it turns a silent non-transmission into a stated reason.
|
||||
err = y.drainCWReply()
|
||||
}
|
||||
y.mu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// When the rig will not tell us how full its buffer is (an FTDX10 answers
|
||||
// "?;" to KY;), pace the next piece by how long this one takes to key.
|
||||
// Sending everything at once overruns the 24-character buffer and the rig
|
||||
// silently drops the rest.
|
||||
if len(msg) > 0 && y.cwStatusUnsupported() {
|
||||
time.Sleep(yaesuCWDuration(chunk, y.keyerWPM()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// drainCWReply reads for a moment after a KY write.
|
||||
//
|
||||
// An accepted KY says nothing at all, so silence here is success. A rejection
|
||||
// answers "?;" — and left unread, that frame was picked up by the poll loop's
|
||||
// next query, which failed to parse it and took the whole CAT link down with it.
|
||||
// Reading it here keeps the link up AND turns "nothing was transmitted, no idea
|
||||
// why" into a stated reason.
|
||||
//
|
||||
// The caller holds the mutex.
|
||||
func (y *Yaesu) drainCWReply() error {
|
||||
if y.port == nil {
|
||||
return fmt.Errorf("yaesu: not connected")
|
||||
}
|
||||
deadline := time.Now().Add(200 * time.Millisecond)
|
||||
buf := make([]byte, 0, 32)
|
||||
tmp := make([]byte, 32)
|
||||
for time.Now().Before(deadline) {
|
||||
n, err := y.port.Read(tmp)
|
||||
if err != nil {
|
||||
return nil // a read problem here is the poll loop's business, not ours
|
||||
}
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
buf = append(buf, tmp[:n]...)
|
||||
for {
|
||||
i := strings.IndexByte(string(buf), ';')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
frame := strings.TrimSpace(string(buf[:i+1]))
|
||||
buf = buf[i+1:]
|
||||
if frame == "?;" {
|
||||
// An FTDX10 refuses KY whatever PC KEYING is set to — DAKY included,
|
||||
// tested on the radio 2026-07-29. The message therefore points
|
||||
// straight at what works, rather than at a setting that will not
|
||||
// help.
|
||||
return fmt.Errorf("this radio does not accept CW over CAT (it answered \"?;\" to KY). " +
|
||||
"Switch the keyer engine to \"Serial port (DTR=CW / RTS=PTT)\" on the rig's OTHER " +
|
||||
"COM port — the standard one, CAT keeping the enhanced one — with PC KEYING set " +
|
||||
"to DTR in the rig's CW menu")
|
||||
}
|
||||
debugLog.Printf("yaesu cw: rig answered %q to KY — ignoring", frame)
|
||||
}
|
||||
}
|
||||
return nil // silence = accepted
|
||||
}
|
||||
|
||||
// cwStatusUnsupported reports whether this rig refused the KY status query.
|
||||
func (y *Yaesu) cwStatusUnsupported() bool {
|
||||
y.mu.Lock()
|
||||
defer y.mu.Unlock()
|
||||
return y.unsupported["KY;"]
|
||||
}
|
||||
|
||||
// keyerWPM is the speed the rig is keying at, for pacing. Falls back to a
|
||||
// middling 20 wpm when the rig does not report it: too slow only wastes a
|
||||
// moment, too fast overruns the buffer.
|
||||
func (y *Yaesu) keyerWPM() int {
|
||||
y.mu.Lock()
|
||||
defer y.mu.Unlock()
|
||||
if y.panel.KeySpeed >= 4 {
|
||||
return y.panel.KeySpeed
|
||||
}
|
||||
return 20
|
||||
}
|
||||
|
||||
// yaesuCWDuration estimates how long a piece of text takes to key.
|
||||
//
|
||||
// PARIS timing: one word is 50 dit-lengths and a word is 5 characters, so a
|
||||
// character averages 10 dits and a dit is 1.2/wpm seconds.
|
||||
func yaesuCWDuration(text string, wpm int) time.Duration {
|
||||
if wpm < 4 {
|
||||
wpm = 20
|
||||
}
|
||||
ditMs := 1200.0 / float64(wpm)
|
||||
return time.Duration(float64(len(text))*10*ditMs) * time.Millisecond
|
||||
}
|
||||
|
||||
// StopCW aborts the message being sent.
|
||||
//
|
||||
// Yaesu documents no buffer-clear, so this drops the transmitter instead: TX0
|
||||
// takes the rig out of transmit, which is what an operator pressing Escape
|
||||
// actually wants. Anything still queued is not keyed on the air.
|
||||
//
|
||||
// It deliberately does NOT send "KY0;" — a plausible-looking clear that the rig
|
||||
// would read as the CHARACTER zero and dutifully send. A wrong guess here
|
||||
// transmits, which is worse than an imperfect abort.
|
||||
func (y *Yaesu) StopCW() error {
|
||||
y.mu.Lock()
|
||||
defer y.mu.Unlock()
|
||||
if y.port == nil {
|
||||
return fmt.Errorf("yaesu: not connected")
|
||||
}
|
||||
return y.write("TX0;")
|
||||
}
|
||||
|
||||
// waitCWBuffer blocks until the keyer reports room, or the deadline passes.
|
||||
// A rig that never answers the status query is not a reason to refuse to send —
|
||||
// we go ahead once, and the worst case is the truncation we were avoiding.
|
||||
func (y *Yaesu) waitCWBuffer(within time.Duration) error {
|
||||
deadline := time.Now().Add(within)
|
||||
for {
|
||||
y.mu.Lock()
|
||||
if y.port == nil {
|
||||
y.mu.Unlock()
|
||||
return fmt.Errorf("yaesu: not connected")
|
||||
}
|
||||
if y.unsupported["KY;"] {
|
||||
y.mu.Unlock()
|
||||
return nil // this rig does not report buffer state — pacing covers it
|
||||
}
|
||||
r, err := y.ask("KY;")
|
||||
if err != nil {
|
||||
if errors.Is(err, errYaesuUnsupported) {
|
||||
if y.unsupported == nil {
|
||||
y.unsupported = map[string]bool{}
|
||||
}
|
||||
y.unsupported["KY;"] = true
|
||||
debugLog.Printf("yaesu cw: this rig does not answer KY; — pacing the send by keying time instead")
|
||||
} else {
|
||||
debugLog.Printf("yaesu cw: KY status query failed (%v) — sending anyway", err)
|
||||
}
|
||||
y.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
y.mu.Unlock()
|
||||
// Only an explicit "full" holds us back.
|
||||
//
|
||||
// The first version required the reply to be exactly "KY0;" at byte 2, and
|
||||
// on a real FTDX10 that refused to send at all: anything the rig phrases
|
||||
// differently — a space before the digit, a longer status — read as "still
|
||||
// full" and the send died after four seconds having keyed nothing. The rig
|
||||
// is the one that knows; when its answer is not a clear "1", the right move
|
||||
// is to go ahead rather than to sit and time out.
|
||||
// Log the shape once per session: this is a reply we have no verified
|
||||
// sample of, and the log is what turns the next surprise into a fact.
|
||||
if !cwStatusLogged {
|
||||
cwStatusLogged = true
|
||||
debugLog.Printf("yaesu cw: KY status reply is %q (full=%v)", r, yaesuCWBufferFull(r))
|
||||
}
|
||||
if !yaesuCWBufferFull(r) {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("yaesu: the keyer buffer stayed full for %s (last reply %q)", within, r)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// yaesuCWBufferFull reads the KY status reply.
|
||||
//
|
||||
// Deliberately asymmetric: only a clear "1" means full. Anything else — an
|
||||
// unexpected shape, a reply from another command that arrived first, an empty
|
||||
// string — is treated as "go ahead". Refusing to send because a status line was
|
||||
// phrased unexpectedly is the worse failure: the operator gets silence with no
|
||||
// explanation, where at worst sending early truncates a long message.
|
||||
func yaesuCWBufferFull(reply string) bool {
|
||||
r := strings.TrimSpace(reply)
|
||||
if !strings.HasPrefix(strings.ToUpper(r), "KY") {
|
||||
return false
|
||||
}
|
||||
for _, c := range r[2:] {
|
||||
switch c {
|
||||
case '0':
|
||||
return false
|
||||
case '1':
|
||||
return true
|
||||
case ' ', ';':
|
||||
continue
|
||||
default:
|
||||
return false // not a status digit — don't block on it
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// filterYaesuCW upper-cases and strips what the keyer cannot send.
|
||||
func filterYaesuCW(text string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToUpper(text) {
|
||||
// Any whitespace becomes a word gap FIRST. Dropping tabs and newlines as
|
||||
// "not in the allowed set" glued the words either side together: a macro
|
||||
// written on two lines went out as CQCQ.
|
||||
if r == '\t' || r == '\n' || r == '\r' {
|
||||
b.WriteByte(' ')
|
||||
continue
|
||||
}
|
||||
if strings.ContainsRune(yaesuCWAllowed, r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
// Runs of spaces become one: the rig sends each as a word gap, so three in a
|
||||
// row is three gaps and the message crawls.
|
||||
return strings.Join(strings.Fields(b.String()), " ")
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What reaches the rig's keyer.
|
||||
//
|
||||
// An unsupported byte does not produce an error on a Yaesu — it can abort the
|
||||
// whole buffer, so the rest of the message is lost silently. Filtering is
|
||||
// therefore part of correctness, not tidiness.
|
||||
func TestFilterYaesuCW(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"cq cq de f4bpo", "CQ CQ DE F4BPO"},
|
||||
{"F4BPO/P", "F4BPO/P"},
|
||||
{"599 tu 73", "599 TU 73"},
|
||||
// Accents and symbols the keyer has no code for.
|
||||
{"héllo", "HLLO"},
|
||||
{"a*b#c", "ABC"},
|
||||
// Runs of whitespace collapse: the rig sends each space as a word gap, so
|
||||
// three in a row is three gaps and the message crawls.
|
||||
{"cq cq", "CQ CQ"},
|
||||
{" padded ", "PADDED"},
|
||||
{"\tcq\ncq\t", "CQ CQ"},
|
||||
{"", ""},
|
||||
{" ", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := filterYaesuCW(c.in); got != c.want {
|
||||
t.Errorf("filterYaesuCW(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Long macros have to be split: one KY command carries 24 characters and the rig
|
||||
// DROPS what does not fit rather than reporting an error, so a contest CQ would
|
||||
// lose its tail with no sign anywhere.
|
||||
func TestYaesuCWChunking(t *testing.T) {
|
||||
msg := filterYaesuCW("cq cq cq de f4bpo f4bpo f4bpo pse k")
|
||||
if len(msg) <= yaesuCWChunk {
|
||||
t.Fatalf("test message is %d chars — too short to exercise chunking", len(msg))
|
||||
}
|
||||
var chunks []string
|
||||
for rest := msg; len(rest) > 0; {
|
||||
n := yaesuCWChunk
|
||||
if len(rest) < n {
|
||||
n = len(rest)
|
||||
}
|
||||
chunks = append(chunks, rest[:n])
|
||||
rest = rest[n:]
|
||||
}
|
||||
for i, c := range chunks {
|
||||
if len(c) > yaesuCWChunk {
|
||||
t.Errorf("chunk %d is %d chars, over the %d limit", i, len(c), yaesuCWChunk)
|
||||
}
|
||||
}
|
||||
// Nothing added, nothing lost: the message the operator typed is what the
|
||||
// keyer receives.
|
||||
if joined := strings.Join(chunks, ""); joined != msg {
|
||||
t.Errorf("chunks rejoin to %q, want %q", joined, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Reading the KY status reply.
|
||||
//
|
||||
// The first version demanded exactly "KY0;" with the digit at byte 2, and on a
|
||||
// real FTDX10 that refused to send at all: the reply was phrased differently,
|
||||
// every poll read as "still full", and after four seconds the send gave up
|
||||
// having keyed nothing. Hence the asymmetry — only a clear "1" blocks.
|
||||
func TestYaesuCWBufferFull(t *testing.T) {
|
||||
cases := []struct {
|
||||
reply string
|
||||
full bool
|
||||
}{
|
||||
{"KY0;", false},
|
||||
{"KY1;", true},
|
||||
{"KY 0;", false}, // a space before the digit
|
||||
{"KY 1;", true},
|
||||
{"ky1;", true}, // lower case
|
||||
{" KY0; ", false},
|
||||
// Shapes we have no sample of must NOT block: silence with no explanation
|
||||
// is a worse failure than sending a moment early.
|
||||
{"KY;", false},
|
||||
{"", false},
|
||||
{"FA014074000;", false}, // another command's reply arriving first
|
||||
{"KYX;", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := yaesuCWBufferFull(c.reply); got != c.full {
|
||||
t.Errorf("yaesuCWBufferFull(%q) = %v, want %v", c.reply, got, c.full)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pacing when the rig will not report its buffer.
|
||||
//
|
||||
// An FTDX10 answers "?;" to KY;, so there is nothing to wait on and the send has
|
||||
// to be spaced by how long the text takes to key — otherwise the second piece
|
||||
// overruns the 24-character buffer and the rig drops it silently.
|
||||
func TestYaesuCWDuration(t *testing.T) {
|
||||
// PARIS: at 20 wpm a dit is 60 ms and a character averages 10 dits, so 24
|
||||
// characters take about 14.4 s.
|
||||
got := yaesuCWDuration("ABCDEFGHIJKLMNOPQRSTUVWX", 20)
|
||||
if got < 13*time.Second || got > 16*time.Second {
|
||||
t.Errorf("24 chars at 20 wpm = %s, expected about 14.4s", got)
|
||||
}
|
||||
// Twice the speed, half the time.
|
||||
fast := yaesuCWDuration("ABCDEFGHIJKLMNOPQRSTUVWX", 40)
|
||||
if fast >= got || fast < got/3 {
|
||||
t.Errorf("40 wpm = %s vs 20 wpm = %s — should be about half", fast, got)
|
||||
}
|
||||
// A nonsense speed must not produce a zero wait (send everything at once) nor
|
||||
// an absurd one (the operator waiting minutes).
|
||||
if d := yaesuCWDuration("TEST", 0); d <= 0 || d > 10*time.Second {
|
||||
t.Errorf("4 chars at an unknown speed = %s, want a sane fallback", d)
|
||||
}
|
||||
if d := yaesuCWDuration("", 20); d != 0 {
|
||||
t.Errorf("empty text = %s, want 0", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,835 @@
|
||||
package cat
|
||||
|
||||
// The Yaesu control panel: meters and the settings an operator reaches for
|
||||
// mid-QSO. Split out from yaesu.go, which stays the small fast path the whole
|
||||
// application depends on — a panel read that hangs must never delay the
|
||||
// frequency display.
|
||||
//
|
||||
// Reads are STAGGERED. Meters change constantly and are polled every cycle;
|
||||
// settings (power, gains, AGC, filters) only change when someone turns a knob,
|
||||
// so they are refreshed every 8th cycle and immediately after any set. At the
|
||||
// default 250 ms cycle that is a two-second worst case for a knob turned on the
|
||||
// radio, against a serial link that would otherwise carry twenty queries a
|
||||
// second and starve the frequency poll it shares.
|
||||
//
|
||||
// Verified on: FTDX10. Commands come from its CAT reference; a control a model
|
||||
// does not implement simply never answers, and askNum keeps the previous value
|
||||
// rather than showing a zero that reads as "it reset itself".
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// YaesuTXState is the panel snapshot handed to the frontend.
|
||||
type YaesuTXState struct {
|
||||
Available bool `json:"available"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
// RawMode is the rig mode with its sideband (CW-U, DATA-L…), which ADIF
|
||||
// deliberately does not record but the panel has to show.
|
||||
RawMode string `json:"raw_mode,omitempty"`
|
||||
|
||||
Transmitting bool `json:"transmitting"`
|
||||
Split bool `json:"split"`
|
||||
// SplitTXHz is where the rig will TRANSMIT while split — the panel showed a
|
||||
// lit SPLIT button and nothing else, which does not tell an operator whether
|
||||
// they are up 1 or up 5.
|
||||
SplitTXHz int64 `json:"split_tx_hz"`
|
||||
SMeter int `json:"s_meter"` // 0-100 (raw 0-255)
|
||||
PowerMeter int `json:"power_meter"` // 0-100, TX only
|
||||
SWRMeter int `json:"swr_meter"` // 0-100, TX only
|
||||
|
||||
RFPower int `json:"rf_power"` // watts
|
||||
MicGain int `json:"mic_gain"` // 0-100
|
||||
AFGain int `json:"af_gain"` // 0-100
|
||||
RFGain int `json:"rf_gain"` // 0-100
|
||||
Squelch int `json:"squelch"` // 0-100
|
||||
AGC string `json:"agc,omitempty"`
|
||||
Preamp int `json:"preamp"` // 0=IPO, 1=AMP1, 2=AMP2
|
||||
Att int `json:"att"` // 0=off, else dB
|
||||
NB bool `json:"nb"`
|
||||
NR bool `json:"nr"`
|
||||
NRLevel int `json:"nr_level"` // 1-15
|
||||
Narrow bool `json:"narrow"` // NAR filter
|
||||
// SWR is the RATIO (1.0, 1.5…), computed from the reflection coefficient —
|
||||
// what an operator reads on the rig, not a percentage of meter travel.
|
||||
SWR float64 `json:"swr"`
|
||||
// PowerW is the output in WATTS, taken from the METER rather than from the
|
||||
// power setting — the setting says what was asked for, not what came out.
|
||||
PowerW float64 `json:"power_w"`
|
||||
VOX bool `json:"vox"`
|
||||
// CW-only controls. Read (and shown) only in CW, where MIC and VOX mean
|
||||
// nothing and these are what an operator reaches for.
|
||||
KeySpeed int `json:"key_speed"` // WPM
|
||||
BreakIn bool `json:"break_in"`
|
||||
}
|
||||
|
||||
// YaesuController is the typed escape the Manager exposes for the panel, in the
|
||||
// same shape as FlexController and IcomController.
|
||||
type YaesuController interface {
|
||||
YaesuState() YaesuTXState
|
||||
RefreshYaesu() error
|
||||
SetYaesuPower(int) error
|
||||
SetYaesuMicGain(int) error
|
||||
SetYaesuAFGain(int) error
|
||||
SetYaesuRFGain(int) error
|
||||
SetYaesuSquelch(int) error
|
||||
SetYaesuAGC(string) error
|
||||
SetYaesuPreamp(int) error
|
||||
SetYaesuAtt(int) error
|
||||
SetYaesuNB(bool) error
|
||||
SetYaesuNR(bool) error
|
||||
SetYaesuNRLevel(int) error
|
||||
SetYaesuNarrow(bool) error
|
||||
SetYaesuVOX(bool) error
|
||||
SetYaesuKeySpeed(int) error
|
||||
SetYaesuBreakIn(bool) error
|
||||
YaesuZeroIn() error
|
||||
// CW keying through the rig's own keyer (KY) — the fifth CW engine.
|
||||
SendCW(string) error
|
||||
StopCW() error
|
||||
SetYaesuSplit(bool) error
|
||||
SetYaesuSplitOffset(int64) error
|
||||
SetYaesuBand(string) error
|
||||
SetYaesuModeRaw(string) error
|
||||
TuneYaesuATU() error
|
||||
}
|
||||
|
||||
func (y *Yaesu) YaesuState() YaesuTXState {
|
||||
y.mu.Lock()
|
||||
defer y.mu.Unlock()
|
||||
st := y.panel
|
||||
st.Available = y.port != nil
|
||||
st.Model = y.model
|
||||
return st
|
||||
}
|
||||
|
||||
// readPanel refreshes the meters, and the settings on the slower beat. Called
|
||||
// from ReadState with the mutex HELD, so it shares the same serialised link.
|
||||
func (y *Yaesu) readPanel(mode string, split bool, txHz int64) {
|
||||
y.panel.Mode = mode
|
||||
y.panel.Split = split
|
||||
y.panel.SplitTXHz = 0
|
||||
if split {
|
||||
y.panel.SplitTXHz = txHz
|
||||
}
|
||||
|
||||
// TX state first: which meters mean anything depends on it, and a power
|
||||
// reading shown while receiving is how a panel lies.
|
||||
if r, err := y.ask("TX;"); err == nil && len(r) >= 3 {
|
||||
y.panel.Transmitting = r[2] != '0'
|
||||
}
|
||||
if v, ok := y.askNum("SM0;", "SM0", 3); ok {
|
||||
y.panel.SMeter = scale255(v)
|
||||
}
|
||||
if y.panel.Transmitting {
|
||||
// One-shot survey of every meter while transmitting.
|
||||
//
|
||||
// Which RM index carries which meter is NOT the same across the family, and
|
||||
// an operator reported an SWR bar at 80 with a real SWR of 1.1 — the shape
|
||||
// of reading the wrong index (ALC, say) rather than of a scaling error.
|
||||
// Guessing again would just move the wrong number; this prints all six
|
||||
// once, and the log then says which is which on THIS radio.
|
||||
// Sampled on EVERY poll while transmitting, capped — one snapshot taken as
|
||||
// the transmission starts catches the meters still at rest (RM4=13, the
|
||||
// rest zero), which says nothing. What identifies a meter is which index
|
||||
// TRACKS the power over a few seconds of steady carrier.
|
||||
if y.metersLogged < 40 {
|
||||
y.metersLogged++
|
||||
raw := make([]string, 0, 6)
|
||||
for i := 1; i <= 6; i++ {
|
||||
cmd := fmt.Sprintf("RM%d;", i)
|
||||
if v, ok := y.askNum(cmd, fmt.Sprintf("RM%d", i), 3); ok {
|
||||
raw = append(raw, fmt.Sprintf("RM%d=%d", i, v))
|
||||
} else {
|
||||
raw = append(raw, fmt.Sprintf("RM%d=-", i))
|
||||
}
|
||||
}
|
||||
// The POWER SETTING goes on the same line. Which index is the wattmeter
|
||||
// cannot be read off one transmission — RM4 rose while RM5 sat still,
|
||||
// but RM5 differed BETWEEN transmissions (208 then 105), so both are
|
||||
// candidates. What settles it is transmitting at two different power
|
||||
// settings and seeing which index follows: printing the setting here
|
||||
// makes that a one-line comparison instead of a memory exercise.
|
||||
debugLog.Printf("yaesu: meters at PC=%dW: %s (compare two different power settings)",
|
||||
y.panel.RFPower, strings.Join(raw, " "))
|
||||
}
|
||||
// Measured on an FTDX10 (2026-07-29). The FM carrier was inconclusive —
|
||||
// constant by definition — so the answer came from CW at 100 W, where the
|
||||
// keying itself is the experiment:
|
||||
//
|
||||
// key down: RM4=25 RM5=207 RM6=13
|
||||
// key up: RM4=25 RM5=0 RM6=0
|
||||
//
|
||||
// RM5 follows the RF envelope exactly, so RM5 is the POWER meter. RM4 sits
|
||||
// near 25 whether the key is down or up, so it is not measuring output at
|
||||
// all — reading it as power is what showed 8 W on a 100 W transmission.
|
||||
// Peak-hold. In CW the key is up between elements and the meters genuinely
|
||||
// read 0 there — the log shows RM5 alternating 207, 0, 207 — so following
|
||||
// the raw value makes the bars flicker to nothing several times a second
|
||||
// and the number unreadable. A real meter has needle inertia; this is the
|
||||
// same idea, and it only ever holds a value the radio actually reported.
|
||||
now := time.Now()
|
||||
if v, ok := y.askNum("RM5;", "RM5", 3); ok {
|
||||
y.panel.PowerMeter = y.powerPeak.update(scale255(v), now)
|
||||
y.panel.PowerW = float64(y.powerWPeak.update(int(yaesuWatts(v)+0.5), now))
|
||||
}
|
||||
// SWR is RM6, and a second measurement at a KNOWN mismatch settled both the
|
||||
// index and the scale: 0 at SWR 1.1, then 52 at SWR 1.5. 52/255 = 0.204,
|
||||
// which is the reflection coefficient of a 1.5 SWR to three decimals — so
|
||||
// the raw value is rho scaled to 255, and the ratio follows from physics
|
||||
// rather than from a fitted curve.
|
||||
if v, ok := y.askNum("RM6;", "RM6", 3); ok {
|
||||
y.panel.SWRMeter = y.swrPeak.update(scale255(v), now)
|
||||
// The RATIO is only meaningful while power is going out: between words
|
||||
// the reading is 0, which would display as a perfect 1.0 match — worse
|
||||
// than a stale figure, because it looks like good news.
|
||||
if v > 0 {
|
||||
y.panel.SWR = swrFromReflection(v)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Zeroed rather than frozen: a stale reading from the last transmission
|
||||
// reads as a live one.
|
||||
//
|
||||
// EVERY transmit value has to be cleared here, and the peak-hold state with
|
||||
// them. Clearing only the two bar percentages left the watts and the SWR
|
||||
// ratio standing — the PWR bar stayed at full scale after the operator
|
||||
// stopped transmitting — and a peak left in the holder would have carried
|
||||
// the old reading into the next transmission.
|
||||
y.panel.PowerMeter, y.panel.SWRMeter = 0, 0
|
||||
y.panel.PowerW, y.panel.SWR = 0, 0
|
||||
y.powerPeak, y.powerWPeak, y.swrPeak = meterPeak{}, meterPeak{}, meterPeak{}
|
||||
}
|
||||
|
||||
y.panelCycle++
|
||||
if y.panelLoaded && y.panelCycle < 8 {
|
||||
return
|
||||
}
|
||||
y.panelCycle = 0
|
||||
y.panelLoaded = true
|
||||
y.readPanelSettings()
|
||||
}
|
||||
|
||||
// readPanelSettings re-reads everything a knob can change. Separate so a set can
|
||||
// force it without waiting for the slow beat.
|
||||
func (y *Yaesu) readPanelSettings() {
|
||||
if v, ok := y.askNum("PC;", "PC", 3); ok {
|
||||
y.panel.RFPower = v // watts, not a 0-255 scale
|
||||
}
|
||||
if v, ok := y.askNum("MG;", "MG", 3); ok {
|
||||
y.panel.MicGain = scale255(v)
|
||||
}
|
||||
if v, ok := y.askNum("AG0;", "AG0", 3); ok {
|
||||
y.panel.AFGain = scale255(v)
|
||||
}
|
||||
if v, ok := y.askNum("RG0;", "RG0", 3); ok {
|
||||
y.panel.RFGain = scale255(v)
|
||||
}
|
||||
if v, ok := y.askNum("SQ0;", "SQ0", 3); ok {
|
||||
y.panel.Squelch = scale255(v)
|
||||
}
|
||||
if v, ok := y.askNum("GT0;", "GT0", 1); ok {
|
||||
y.panel.AGC = yaesuAGCName(v)
|
||||
}
|
||||
if v, ok := y.askNum("PA0;", "PA0", 1); ok {
|
||||
y.panel.Preamp = v
|
||||
}
|
||||
if v, ok := y.askNum("RA0;", "RA0", 1); ok {
|
||||
y.panel.Att = yaesuAttDB(v)
|
||||
}
|
||||
if v, ok := y.askNum("NB0;", "NB0", 1); ok {
|
||||
y.panel.NB = v != 0
|
||||
}
|
||||
if v, ok := y.askNum("NR0;", "NR0", 1); ok {
|
||||
y.panel.NR = v != 0
|
||||
}
|
||||
if v, ok := y.askNum("RL0;", "RL0", 2); ok {
|
||||
y.panel.NRLevel = v
|
||||
}
|
||||
if v, ok := y.askNum("NA0;", "NA0", 1); ok {
|
||||
y.panel.Narrow = v != 0
|
||||
}
|
||||
if v, ok := y.askNum("VX;", "VX", 1); ok {
|
||||
y.panel.VOX = v != 0
|
||||
}
|
||||
// CW keyer. Read unconditionally — it is two more queries on the SLOW beat,
|
||||
// and having the value ready means the CW card is populated the instant the
|
||||
// operator switches mode rather than a poll cycle later.
|
||||
if v, ok := y.askNum("KS;", "KS", 3); ok {
|
||||
y.panel.KeySpeed = v
|
||||
}
|
||||
if v, ok := y.askNum("BI;", "BI", 1); ok {
|
||||
y.panel.BreakIn = v != 0
|
||||
}
|
||||
}
|
||||
|
||||
// askNum sends a query and reads a fixed-width decimal field out of the reply.
|
||||
// ok=false when the rig does not answer, or answers something else — a control
|
||||
// this model lacks then keeps its previous value instead of dropping to zero,
|
||||
// which would look like a setting that reset itself.
|
||||
func (y *Yaesu) askNum(cmd, prefix string, digits int) (int, bool) {
|
||||
// A command this model refused once is never asked again. Models implement
|
||||
// different subsets — an FTDX10 answers "?;" to MG; — and re-asking costs a
|
||||
// 600 ms timeout on every slow beat, for a control that will never answer.
|
||||
if y.unsupported[cmd] {
|
||||
return 0, false
|
||||
}
|
||||
r, err := y.ask(cmd)
|
||||
if err != nil {
|
||||
if errors.Is(err, errYaesuUnsupported) {
|
||||
if y.unsupported == nil {
|
||||
y.unsupported = map[string]bool{}
|
||||
}
|
||||
y.unsupported[cmd] = true
|
||||
debugLog.Printf("yaesu: this rig does not support %q — not asking again", cmd)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
if !strings.HasPrefix(r, prefix) || len(r) < len(prefix)+digits {
|
||||
debugLog.Printf("yaesu: unexpected reply %q to %q", r, cmd)
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(r[len(prefix) : len(prefix)+digits])
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// setAndRefresh writes a command then re-reads the settings, so the panel shows
|
||||
// what the RIG ended up with rather than what we asked for — the two differ
|
||||
// whenever a value is out of range or the current mode forbids the control.
|
||||
func (y *Yaesu) setAndRefresh(cmd string) error {
|
||||
y.mu.Lock()
|
||||
defer y.mu.Unlock()
|
||||
if y.port == nil {
|
||||
return fmt.Errorf("yaesu: not connected")
|
||||
}
|
||||
if err := y.write(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(30 * time.Millisecond) // let the rig apply it before reading back
|
||||
y.readPanelSettings()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (y *Yaesu) RefreshYaesu() error {
|
||||
y.mu.Lock()
|
||||
defer y.mu.Unlock()
|
||||
if y.port == nil {
|
||||
return fmt.Errorf("yaesu: not connected")
|
||||
}
|
||||
y.readPanelSettings()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (y *Yaesu) SetYaesuPower(w int) error {
|
||||
return y.setAndRefresh(fmt.Sprintf("PC%03d;", clampInt(w, 5, 100)))
|
||||
}
|
||||
|
||||
func (y *Yaesu) SetYaesuMicGain(p int) error {
|
||||
return y.setAndRefresh(fmt.Sprintf("MG%03d;", from100(p)))
|
||||
}
|
||||
|
||||
func (y *Yaesu) SetYaesuAFGain(p int) error {
|
||||
return y.setAndRefresh(fmt.Sprintf("AG0%03d;", from100(p)))
|
||||
}
|
||||
|
||||
func (y *Yaesu) SetYaesuRFGain(p int) error {
|
||||
return y.setAndRefresh(fmt.Sprintf("RG0%03d;", from100(p)))
|
||||
}
|
||||
|
||||
func (y *Yaesu) SetYaesuSquelch(p int) error {
|
||||
return y.setAndRefresh(fmt.Sprintf("SQ0%03d;", from100(p)))
|
||||
}
|
||||
|
||||
func (y *Yaesu) SetYaesuAGC(name string) error {
|
||||
return y.setAndRefresh(fmt.Sprintf("GT0%d;", yaesuAGCCode(name)))
|
||||
}
|
||||
|
||||
func (y *Yaesu) SetYaesuPreamp(n int) error {
|
||||
return y.setAndRefresh(fmt.Sprintf("PA0%d;", clampInt(n, 0, 2)))
|
||||
}
|
||||
|
||||
func (y *Yaesu) SetYaesuAtt(db int) error {
|
||||
return y.setAndRefresh(fmt.Sprintf("RA0%d;", yaesuAttCode(db)))
|
||||
}
|
||||
|
||||
func (y *Yaesu) SetYaesuNB(on bool) error {
|
||||
return y.setAndRefresh(fmt.Sprintf("NB0%d;", boolDigit(on)))
|
||||
}
|
||||
|
||||
func (y *Yaesu) SetYaesuNR(on bool) error {
|
||||
return y.setAndRefresh(fmt.Sprintf("NR0%d;", boolDigit(on)))
|
||||
}
|
||||
|
||||
func (y *Yaesu) SetYaesuNRLevel(n int) error {
|
||||
return y.setAndRefresh(fmt.Sprintf("RL0%02d;", clampInt(n, 1, 15)))
|
||||
}
|
||||
|
||||
func (y *Yaesu) SetYaesuNarrow(on bool) error {
|
||||
return y.setAndRefresh(fmt.Sprintf("NA0%d;", boolDigit(on)))
|
||||
}
|
||||
|
||||
func (y *Yaesu) SetYaesuVOX(on bool) error {
|
||||
return y.setAndRefresh(fmt.Sprintf("VX%d;", boolDigit(on)))
|
||||
}
|
||||
|
||||
// SetYaesuSplit uses whichever command this rig answered at connect. Sending the
|
||||
// other one would be silently ignored, and the operator would get a split button
|
||||
// that does nothing.
|
||||
func (y *Yaesu) SetYaesuSplit(on bool) error {
|
||||
// Turning split ON also PLACES the transmit VFO. Flipping the flag alone
|
||||
// transmits wherever the other VFO happens to sit — it held 18.115 from an
|
||||
// earlier session while the operator was listening on 14.244, so pressing
|
||||
// SPLIT threw them onto another band entirely. The other VFO is stale by
|
||||
// nature; the only frequency that makes sense is one derived from where the
|
||||
// operator is listening NOW.
|
||||
//
|
||||
// The distance is the mode's usual one: 1 kHz on CW and the data modes,
|
||||
// 5 kHz on phone. The +1k / +5k buttons remain for anything else.
|
||||
if on {
|
||||
return y.SetYaesuSplitOffset(y.defaultSplitOffset())
|
||||
}
|
||||
y.mu.Lock()
|
||||
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(yaesuSplitCommand(cmd, vfo, false))
|
||||
}
|
||||
|
||||
// defaultSplitOffset is what SPLIT means on this mode: up 1 kHz on CW and the
|
||||
// data modes, up 5 kHz on phone — the offsets operators actually call.
|
||||
func (y *Yaesu) defaultSplitOffset() int64 {
|
||||
y.mu.Lock()
|
||||
raw := strings.ToUpper(y.panel.RawMode)
|
||||
y.mu.Unlock()
|
||||
switch {
|
||||
case strings.HasPrefix(raw, "CW"), strings.HasPrefix(raw, "RTTY"), strings.HasPrefix(raw, "DATA"):
|
||||
return 1000
|
||||
}
|
||||
return 5000
|
||||
}
|
||||
|
||||
// SetYaesuBand switches band with BS, which lands the rig on ITS OWN last-used
|
||||
// frequency for that band — the radio's band memory, not a frequency we choose.
|
||||
// SetYaesuModeRaw selects an exact rig mode, sideband included — "CW-U",
|
||||
// "DATA-L", "RTTY-U"… The plain SetMode path takes an ADIF mode and can only
|
||||
// choose a sideband by convention, but CW, RTTY and the data modes are routinely
|
||||
// run on EITHER sideband and the operator is the one who knows which. This is
|
||||
// what the panel's mode buttons use.
|
||||
func (y *Yaesu) SetYaesuModeRaw(name string) error {
|
||||
d, ok := yaesuRawModeDigit(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("yaesu: unknown rig mode %q", name)
|
||||
}
|
||||
return y.setAndRefresh(fmt.Sprintf("MD0%c;", d))
|
||||
}
|
||||
|
||||
// yaesuRawModeDigit maps a rig-mode name to its MD digit.
|
||||
func yaesuRawModeDigit(name string) (byte, bool) {
|
||||
switch strings.ToUpper(strings.TrimSpace(name)) {
|
||||
case "LSB":
|
||||
return '1', true
|
||||
case "USB":
|
||||
return '2', true
|
||||
case "CW-U", "CWU":
|
||||
return '3', true
|
||||
case "CW-L", "CWL":
|
||||
return '7', true
|
||||
case "FM":
|
||||
return '4', true
|
||||
case "AM":
|
||||
return '5', true
|
||||
case "RTTY-L", "RTTYL":
|
||||
return '6', true
|
||||
case "RTTY-U", "RTTYU":
|
||||
return '9', true
|
||||
case "DATA-L", "DATAL", "DIGI-L":
|
||||
return '8', true
|
||||
case "DATA-U", "DATAU", "DIGI-U":
|
||||
return 'C', true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func (y *Yaesu) SetYaesuBand(band string) error {
|
||||
code, ok := yaesuBandCode(band)
|
||||
if !ok {
|
||||
return fmt.Errorf("yaesu: no band code for %q", band)
|
||||
}
|
||||
return y.setAndRefresh(fmt.Sprintf("BS%02d;", code))
|
||||
}
|
||||
|
||||
// TuneYaesuATU starts a tuning cycle. Deliberately not followed by a settings
|
||||
// read: the rig is transmitting into the tuner for several seconds and answers
|
||||
// little, so the reads would just time out one after another.
|
||||
func (y *Yaesu) TuneYaesuATU() error {
|
||||
y.mu.Lock()
|
||||
defer y.mu.Unlock()
|
||||
if y.port == nil {
|
||||
return fmt.Errorf("yaesu: not connected")
|
||||
}
|
||||
return y.write("AC002;")
|
||||
}
|
||||
|
||||
// ── small mappings ────────────────────────────────────────────────────────
|
||||
|
||||
func scale255(v int) int {
|
||||
if v <= 0 {
|
||||
return 0
|
||||
}
|
||||
if v >= 255 {
|
||||
return 100
|
||||
}
|
||||
return v * 100 / 255
|
||||
}
|
||||
|
||||
func from100(p int) int { return clampInt(p, 0, 100) * 255 / 100 }
|
||||
|
||||
func clampInt(v, lo, hi int) int {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func boolDigit(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func yaesuAGCName(code int) string {
|
||||
switch code {
|
||||
case 0:
|
||||
return "OFF"
|
||||
case 1:
|
||||
return "FAST"
|
||||
case 2:
|
||||
return "MID"
|
||||
case 3:
|
||||
return "SLOW"
|
||||
case 4:
|
||||
return "AUTO"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func yaesuAGCCode(name string) int {
|
||||
switch strings.ToUpper(strings.TrimSpace(name)) {
|
||||
case "OFF":
|
||||
return 0
|
||||
case "FAST":
|
||||
return 1
|
||||
case "MID", "MEDIUM":
|
||||
return 2
|
||||
case "SLOW":
|
||||
return 3
|
||||
}
|
||||
return 4 // AUTO — the safe default for anything unrecognised
|
||||
}
|
||||
|
||||
// The FTDX10/FTDX101 attenuator is a THREE-step pad (RA00..RA03 = off, 6, 12,
|
||||
// 18 dB), not the single step this first assumed — a panel offering only one
|
||||
// step hides two thirds of the control. Reporting the dB rather than the raw
|
||||
// code lets the buttons label themselves honestly.
|
||||
func yaesuAttDB(code int) int {
|
||||
switch code {
|
||||
case 1:
|
||||
return 6
|
||||
case 2:
|
||||
return 12
|
||||
case 3:
|
||||
return 18
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func yaesuAttCode(db int) int {
|
||||
switch {
|
||||
case db >= 18:
|
||||
return 3
|
||||
case db >= 12:
|
||||
return 2
|
||||
case db >= 6:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// yaesuBandCode maps an ADIF band to the BS command's band number.
|
||||
func yaesuBandCode(band string) (int, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(band)) {
|
||||
case "160m":
|
||||
return 0, true
|
||||
case "80m":
|
||||
return 1, true
|
||||
case "60m":
|
||||
return 2, true
|
||||
case "40m":
|
||||
return 3, true
|
||||
case "30m":
|
||||
return 4, true
|
||||
case "20m":
|
||||
return 5, true
|
||||
case "17m":
|
||||
return 6, true
|
||||
case "15m":
|
||||
return 7, true
|
||||
case "12m":
|
||||
return 8, true
|
||||
case "10m":
|
||||
return 9, true
|
||||
case "6m":
|
||||
return 10, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// yaesuRawModeName is the inverse of yaesuRawModeDigit: what the rig is on,
|
||||
// sideband included, for the panel's mode buttons to highlight.
|
||||
func yaesuRawModeName(d byte) string {
|
||||
switch d {
|
||||
case '1':
|
||||
return "LSB"
|
||||
case '2':
|
||||
return "USB"
|
||||
case '3':
|
||||
return "CW-U"
|
||||
case '4':
|
||||
return "FM"
|
||||
case '5':
|
||||
return "AM"
|
||||
case '6':
|
||||
return "RTTY-L"
|
||||
case '7':
|
||||
return "CW-L"
|
||||
case '8':
|
||||
return "DATA-L"
|
||||
case '9':
|
||||
return "RTTY-U"
|
||||
case 'C':
|
||||
return "DATA-U"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SetYaesuSplitOffset puts the transmit VFO a fixed distance above the receive
|
||||
// one and turns split on, in a single action.
|
||||
//
|
||||
// This is the split an operator actually uses when working a pile-up: listen on
|
||||
// the DX, transmit up 5 kHz on phone or up 1 kHz on CW. Doing it by hand means
|
||||
// swapping VFOs, retuning and swapping back, which is exactly the fumbling a
|
||||
// panel should remove.
|
||||
//
|
||||
// The offset is applied to the RECEIVE frequency and written to the OTHER VFO —
|
||||
// whichever that is. On VFO B the roles are mirrored, so listening on B writes A.
|
||||
func (y *Yaesu) SetYaesuSplitOffset(offsetHz int64) error {
|
||||
y.mu.Lock()
|
||||
defer y.mu.Unlock()
|
||||
if y.port == nil {
|
||||
return fmt.Errorf("yaesu: not connected")
|
||||
}
|
||||
rx := y.curRXFreq
|
||||
if rx <= 0 {
|
||||
return fmt.Errorf("yaesu: no receive frequency read yet")
|
||||
}
|
||||
tx := rx + offsetHz
|
||||
if tx <= 0 || tx > 999_999_999 {
|
||||
return fmt.Errorf("yaesu: split frequency %d out of the CAT range", tx)
|
||||
}
|
||||
// Write the VFO we are NOT listening on.
|
||||
cmd := "FB"
|
||||
if y.curVFO == "B" {
|
||||
cmd = "FA"
|
||||
}
|
||||
if err := y.write(fmt.Sprintf("%s%09d;", cmd, tx)); err != nil {
|
||||
return err
|
||||
}
|
||||
if y.splitCmd == "" {
|
||||
return fmt.Errorf("yaesu: this rig answered neither ST; nor FT; — split cannot be set")
|
||||
}
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
if err := y.write(yaesuSplitCommand(y.splitCmd, y.curVFO, true)); err != nil {
|
||||
return err
|
||||
}
|
||||
y.panel.Split = true
|
||||
y.panel.SplitTXHz = tx
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetYaesuKeySpeed sets the internal keyer speed in words per minute. The rig
|
||||
// clamps to its own 4-60 range; clamping here too keeps a slider from sending a
|
||||
// value that would simply be ignored, which reads as a dead control.
|
||||
func (y *Yaesu) SetYaesuKeySpeed(wpm int) error {
|
||||
return y.setAndRefresh(fmt.Sprintf("KS%03d;", clampInt(wpm, 4, 60)))
|
||||
}
|
||||
|
||||
// SetYaesuBreakIn toggles CW break-in (BK).
|
||||
func (y *Yaesu) SetYaesuBreakIn(on bool) error {
|
||||
return y.setAndRefresh(fmt.Sprintf("BI%d;", boolDigit(on)))
|
||||
}
|
||||
|
||||
// YaesuZeroIn is the CW ZIN function: the rig retunes itself so the station
|
||||
// being received lands exactly on the operator's CW pitch. It is a one-shot
|
||||
// action with no state to read back, so no settings refresh follows — and the
|
||||
// frequency change arrives through the normal poll like any other.
|
||||
func (y *Yaesu) YaesuZeroIn() error {
|
||||
y.mu.Lock()
|
||||
defer y.mu.Unlock()
|
||||
if y.port == nil {
|
||||
return fmt.Errorf("yaesu: not connected")
|
||||
}
|
||||
return y.write("ZI;")
|
||||
}
|
||||
|
||||
// swrFromReflection turns the rig's SWR meter reading into the ratio itself.
|
||||
//
|
||||
// The raw value is the reflection coefficient scaled to 255 — established on an
|
||||
// FTDX10 by measuring at two known matches (0 at 1.1, 52 at 1.5; 52/255 = 0.204,
|
||||
// which is rho for a 1.5 SWR). So SWR = (1+rho)/(1-rho), physics rather than a
|
||||
// curve fitted to two points.
|
||||
//
|
||||
// Capped at 9.9: past that the number stops meaning anything to an operator, and
|
||||
// rho approaching 1 sends the ratio to infinity.
|
||||
func swrFromReflection(raw int) float64 {
|
||||
if raw <= 0 {
|
||||
return 1.0
|
||||
}
|
||||
rho := float64(raw) / 255.0
|
||||
if rho >= 0.98 {
|
||||
return 9.9
|
||||
}
|
||||
swr := (1 + rho) / (1 - rho)
|
||||
if swr > 9.9 {
|
||||
return 9.9
|
||||
}
|
||||
return swr
|
||||
}
|
||||
|
||||
// yaesuWatts converts the power-meter reading to watts.
|
||||
//
|
||||
// The meter is NOT linear in power, which one calibration point could never
|
||||
// reveal: scaling 207 = 100 W straight down read 30 W where the rig showed 10,
|
||||
// and 75 where it showed 50. Three points measured against the radio's own
|
||||
// display settle the curve:
|
||||
//
|
||||
// raw 62 → 10 W
|
||||
// raw 155 → 50 W
|
||||
// raw 207 → 100 W
|
||||
//
|
||||
// Interpolating between them reproduces the rig exactly at those points and
|
||||
// stays close in between — better than fitting a formula to three samples and
|
||||
// pretending it holds everywhere. Above the last point it keeps the final
|
||||
// segment's slope, so an amplifier-driving rig does not flatten at 100 W.
|
||||
//
|
||||
// Measured on an FTDX10 (2026-07-29). Another model may well need its own row.
|
||||
var yaesuPowerCurve = []struct {
|
||||
raw int
|
||||
watts float64
|
||||
}{
|
||||
{0, 0},
|
||||
{62, 10},
|
||||
{155, 50},
|
||||
{207, 100},
|
||||
}
|
||||
|
||||
func yaesuWatts(raw int) float64 {
|
||||
if raw <= 0 {
|
||||
return 0
|
||||
}
|
||||
for i := 1; i < len(yaesuPowerCurve); i++ {
|
||||
hi := yaesuPowerCurve[i]
|
||||
if raw <= hi.raw {
|
||||
lo := yaesuPowerCurve[i-1]
|
||||
span := float64(hi.raw - lo.raw)
|
||||
if span <= 0 {
|
||||
return hi.watts
|
||||
}
|
||||
f := float64(raw-lo.raw) / span
|
||||
return lo.watts + f*(hi.watts-lo.watts)
|
||||
}
|
||||
}
|
||||
// Past the top of the curve: extend the last segment rather than clamp.
|
||||
n := len(yaesuPowerCurve)
|
||||
lo, hi := yaesuPowerCurve[n-2], yaesuPowerCurve[n-1]
|
||||
slope := (hi.watts - lo.watts) / float64(hi.raw-lo.raw)
|
||||
return hi.watts + float64(raw-hi.raw)*slope
|
||||
}
|
||||
|
||||
// meterPeak gives a meter the inertia a needle has: it jumps to a higher reading
|
||||
// at once, HOLDS it for a moment, then falls back gradually.
|
||||
//
|
||||
// The hold is the part that matters on the air. Between CW elements the gap is
|
||||
// milliseconds, but between the words of a CQ — in CW as in SSB — it is most of
|
||||
// a second, and a meter that decays straight away reads 0 in every one of those
|
||||
// gaps: the operator sees a bar flashing rather than the power they are running.
|
||||
type meterPeak struct {
|
||||
val int
|
||||
at time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
meterHold = 1500 * time.Millisecond // how long a peak stands before it starts to fall
|
||||
meterDecay = 4 // then a quarter of the remaining gap per poll
|
||||
)
|
||||
|
||||
// update returns the value to show for this sample.
|
||||
func (m *meterPeak) update(sample int, now time.Time) int {
|
||||
if sample >= m.val {
|
||||
m.val, m.at = sample, now
|
||||
return m.val
|
||||
}
|
||||
if now.Sub(m.at) < meterHold {
|
||||
return m.val // still inside the hold — the needle has not started to fall
|
||||
}
|
||||
// At least one step down, always. A proportional decay on integers stalls
|
||||
// near the end: with the needle at 13 and the truth at 10, a quarter of the
|
||||
// gap rounds to zero and the meter sits three units high for ever.
|
||||
step := (m.val - sample) / meterDecay
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
m.val -= step
|
||||
if m.val <= sample {
|
||||
m.val = sample
|
||||
}
|
||||
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;"
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What SPLIT means when the operator presses it.
|
||||
//
|
||||
// Flipping the rig's split flag alone transmits wherever the OTHER VFO happens
|
||||
// to sit — reported from a real FTDX10: listening on 14.244 with VFO B left on
|
||||
// 18.115 from an earlier session, pressing SPLIT threw the transmitter onto
|
||||
// another band. The other VFO is stale by nature, so the transmit frequency has
|
||||
// to be derived from where the operator is listening now.
|
||||
func TestYaesuDefaultSplitOffset(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
want int64
|
||||
}{
|
||||
// CW and the data modes work 1 kHz up.
|
||||
{"CW-U", 1000},
|
||||
{"CW-L", 1000},
|
||||
{"RTTY-U", 1000},
|
||||
{"RTTY-L", 1000},
|
||||
{"DATA-U", 1000},
|
||||
{"DATA-L", 1000},
|
||||
// Phone works 5 kHz up.
|
||||
{"USB", 5000},
|
||||
{"LSB", 5000},
|
||||
{"AM", 5000},
|
||||
{"FM", 5000},
|
||||
// Unknown or not yet read: the phone offset is the safer default — too
|
||||
// wide is audible and obvious, too narrow lands on top of the DX.
|
||||
{"", 5000},
|
||||
}
|
||||
for _, c := range cases {
|
||||
y := &Yaesu{}
|
||||
y.panel.RawMode = c.raw
|
||||
if got := y.defaultSplitOffset(); got != c.want {
|
||||
t.Errorf("mode %q → split offset %d Hz, want %d", c.raw, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The SWR scale, pinned to the two measurements it was derived from.
|
||||
//
|
||||
// Taken on an FTDX10 (2026-07-29) against an operator watching the rig's own
|
||||
// meter: raw 0 at SWR 1.1, raw 52 at SWR 1.5. The second point is what proved
|
||||
// the raw value is the reflection coefficient scaled to 255 — 52/255 = 0.204,
|
||||
// rho for a 1.5 SWR — rather than a percentage of meter travel, which is how the
|
||||
// bar came to read 81 on a perfect antenna.
|
||||
func TestSWRFromReflection(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw int
|
||||
want float64
|
||||
tol float64
|
||||
}{
|
||||
{0, 1.0, 0.01}, // no reflected power
|
||||
{52, 1.5, 0.02}, // the measured mismatch
|
||||
{85, 2.0, 0.05}, // rho = 1/3
|
||||
{128, 3.0, 0.1}, // rho = 0.5
|
||||
{-5, 1.0, 0.01}, // nonsense reading — never below 1.0, which is physical
|
||||
{255, 9.9, 0.01}, // full scale is capped rather than infinite
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := swrFromReflection(c.raw)
|
||||
if got < c.want-c.tol || got > c.want+c.tol {
|
||||
t.Errorf("swrFromReflection(%d) = %.2f, want %.2f ±%.2f", c.raw, got, c.want, c.tol)
|
||||
}
|
||||
}
|
||||
// It must rise with the reflected power, or a worsening match would read
|
||||
// better on the panel than on the rig.
|
||||
prev := 0.0
|
||||
for raw := 0; raw <= 200; raw += 20 {
|
||||
v := swrFromReflection(raw)
|
||||
if v < prev {
|
||||
t.Fatalf("SWR fell from %.2f to %.2f at raw=%d", prev, v, raw)
|
||||
}
|
||||
prev = v
|
||||
}
|
||||
}
|
||||
|
||||
// Needle inertia on the TX meters.
|
||||
//
|
||||
// The gaps are what this is for: milliseconds between CW elements, but most of a
|
||||
// second between the words of a CQ — in CW as in SSB. A meter that decays
|
||||
// immediately reads 0 in every one of those gaps, so the operator sees a bar
|
||||
// flashing instead of the power they are running.
|
||||
func TestMeterPeakHold(t *testing.T) {
|
||||
var m meterPeak
|
||||
t0 := time.Now()
|
||||
|
||||
if got := m.update(80, t0); got != 80 {
|
||||
t.Fatalf("first sample = %d, want 80 — a meter must show a reading at once", got)
|
||||
}
|
||||
// A gap between two words: still inside the hold, so the reading stands.
|
||||
if got := m.update(0, t0.Add(400*time.Millisecond)); got != 80 {
|
||||
t.Errorf("during a word gap = %d, want 80 held", got)
|
||||
}
|
||||
if got := m.update(0, t0.Add(1400*time.Millisecond)); got != 80 {
|
||||
t.Errorf("just before the hold expires = %d, want 80 held", got)
|
||||
}
|
||||
// Past the hold it falls — but gradually, not to zero in one step.
|
||||
after := m.update(0, t0.Add(1600*time.Millisecond))
|
||||
if after >= 80 || after <= 0 {
|
||||
t.Errorf("after the hold = %d, want a value falling between 80 and 0", after)
|
||||
}
|
||||
// A HIGHER reading is taken immediately: a needle rises fast and falls slow.
|
||||
if got := m.update(95, t0.Add(1700*time.Millisecond)); got != 95 {
|
||||
t.Errorf("rising sample = %d, want 95 straight away", got)
|
||||
}
|
||||
// And it does reach the real value eventually, or a power drop would never show.
|
||||
v := 0
|
||||
for i := 0; i < 60; i++ {
|
||||
v = m.update(10, t0.Add(time.Duration(2000+i*250)*time.Millisecond))
|
||||
}
|
||||
if v != 10 {
|
||||
t.Errorf("settled at %d, want 10 — the meter must converge on the truth", v)
|
||||
}
|
||||
}
|
||||
|
||||
// The power curve, pinned to the readings taken against the rig's own display.
|
||||
//
|
||||
// It is NOT linear, and one calibration point could not show that: scaling
|
||||
// 207 = 100 W straight down read 30 W where the radio showed 10, and 75 where it
|
||||
// showed 50. These are the three measured pairs, so a change to the curve that
|
||||
// breaks them is a regression against the radio, not against a preference.
|
||||
func TestYaesuPowerCurve(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw int
|
||||
watts float64
|
||||
tol float64
|
||||
}{
|
||||
{0, 0, 0.1},
|
||||
{62, 10, 0.5}, // measured
|
||||
{155, 50, 0.5}, // measured
|
||||
{207, 100, 0.5}, // measured
|
||||
// Between the measured points it interpolates, so it must land inside the
|
||||
// bracket rather than shooting past it.
|
||||
{100, 30, 10},
|
||||
{180, 75, 10},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := yaesuWatts(c.raw)
|
||||
if got < c.watts-c.tol || got > c.watts+c.tol {
|
||||
t.Errorf("yaesuWatts(%d) = %.1f W, want %.1f ±%.1f", c.raw, got, c.watts, c.tol)
|
||||
}
|
||||
}
|
||||
// Monotonic: more meter must never mean less power.
|
||||
prev := -1.0
|
||||
for raw := 0; raw <= 255; raw++ {
|
||||
v := yaesuWatts(raw)
|
||||
if v < prev {
|
||||
t.Fatalf("power fell from %.1f to %.1f at raw=%d", prev, v, raw)
|
||||
}
|
||||
prev = v
|
||||
}
|
||||
// Above the top of the curve it keeps rising rather than flattening at 100 W —
|
||||
// a rig driving an amplifier can read past its own full scale.
|
||||
if v := yaesuWatts(230); v <= 100 {
|
||||
t.Errorf("yaesuWatts(230) = %.1f, want more than 100 — the curve should extend", v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package cat
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseYaesuFreq(t *testing.T) {
|
||||
cases := []struct {
|
||||
reply, prefix string
|
||||
want int64
|
||||
ok bool
|
||||
}{
|
||||
{"FA014074000;", "FA", 14074000, true},
|
||||
{"FB007100000;", "FB", 7100000, true},
|
||||
{"FA000474000;", "FA", 474000, true}, // 630 m — leading zeros must not truncate
|
||||
{"FA010368000000;", "FA", 10368000000, true},
|
||||
{"FB;", "FB", 0, false}, // query echoed back with no value
|
||||
{"FA014074000;", "FB", 0, false}, // wrong VFO — never silently accepted
|
||||
{"", "FA", 0, false},
|
||||
{"FAxxxxxxxxx;", "FA", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := parseYaesuFreq(c.reply, c.prefix)
|
||||
if got != c.want || ok != c.ok {
|
||||
t.Errorf("parseYaesuFreq(%q,%q) = %d,%v — want %d,%v", c.reply, c.prefix, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The split rules. Getting these wrong writes a WRONG TX frequency into the log,
|
||||
// which is why an ambiguous state resolves to "not split" rather than to a
|
||||
// guess — the same principle the OmniRig backend arrived at the hard way.
|
||||
func TestResolveYaesuVFOs(t *testing.T) {
|
||||
const a, b = 14074000, 14100000
|
||||
cases := []struct {
|
||||
name string
|
||||
fa, fb int64
|
||||
vfo string
|
||||
split bool
|
||||
wantTX, wantRX int64
|
||||
wantSplit bool
|
||||
}{
|
||||
{"simplex on A", a, b, "A", false, a, 0, false},
|
||||
{"simplex on B", a, b, "B", false, b, 0, false},
|
||||
{"split, listening on A → TX on B", a, b, "A", true, b, a, true},
|
||||
{"split, listening on B → TX on A", a, b, "B", true, a, b, true},
|
||||
{"split flag but the other VFO is unread", a, 0, "A", true, a, 0, false},
|
||||
{"split flag but both VFOs identical", a, a, "A", true, a, 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
tx, rx, sp := resolveYaesuVFOs(c.fa, c.fb, c.vfo, c.split)
|
||||
if tx != c.wantTX || rx != c.wantRX || sp != c.wantSplit {
|
||||
t.Errorf("%s: got tx=%d rx=%d split=%v — want tx=%d rx=%d split=%v",
|
||||
c.name, tx, rx, sp, c.wantTX, c.wantRX, c.wantSplit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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, vfo string
|
||||
want bool
|
||||
}{
|
||||
// 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 := 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The sideband follows the frequency, worldwide convention — a CAT backend that
|
||||
// puts USB on 40 m makes every SSB QSO wrong.
|
||||
func TestYaesuModeDigit(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'}, // explicit wins over the convention
|
||||
{"USB", 7150000, '2'},
|
||||
{"CW", 7030000, '3'},
|
||||
{"RTTY", 14080000, '6'},
|
||||
{"AM", 7150000, '5'},
|
||||
{"FM", 145000000, '4'},
|
||||
{"FT8", 7074000, '8'}, // DATA-LSB
|
||||
{"FT8", 14074000, 'C'}, // DATA-USB
|
||||
{"JS8", 14078000, 'C'}, // any unknown digital rides on DATA
|
||||
{"", 14074000, 0}, // nothing to set
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := yaesuModeDigit(c.mode, c.hz); got != c.want {
|
||||
t.Errorf("yaesuModeDigit(%q, %d) = %q, want %q", c.mode, c.hz, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A reply belongs to the command that asked for it.
|
||||
//
|
||||
// Without this, a CW macro knocked the CAT link over: KY produces no reply, so
|
||||
// the poll loop's next FA; collected a leftover frame, failed to parse it as a
|
||||
// frequency, and the Manager treated that as "lost the rig" and reconnected —
|
||||
// the CAT dropping for a few seconds on every macro click.
|
||||
func TestYaesuCmdPrefix(t *testing.T) {
|
||||
cases := []struct{ cmd, want string }{
|
||||
{"FA;", "FA"},
|
||||
{"FB;", "FB"},
|
||||
{"MD0;", "MD"},
|
||||
{"KY;", "KY"},
|
||||
{"KY CQ TEST;", "KY"},
|
||||
{"SM0;", "SM"},
|
||||
{"RM4;", "RM"},
|
||||
{"AG0;", "AG"},
|
||||
{"TX;", "TX"},
|
||||
{"", ""},
|
||||
{";", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := cmdPrefix(c.cmd); got != c.want {
|
||||
t.Errorf("cmdPrefix(%q) = %q, want %q", c.cmd, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
+47
-25
@@ -42,32 +42,32 @@ type ServerConfig struct {
|
||||
// is emitted to the UI, so the table never has empty country cells
|
||||
// flickering in for a few hundred ms.
|
||||
type Spot struct {
|
||||
SourceID int64 `json:"source_id"` // ID of the cluster server this came from
|
||||
SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers)
|
||||
Spotter string `json:"spotter"` // DE field
|
||||
DXCall string `json:"dx_call"` // the DX station heard
|
||||
SourceID int64 `json:"source_id"` // ID of the cluster server this came from
|
||||
SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers)
|
||||
Spotter string `json:"spotter"` // DE field
|
||||
DXCall string `json:"dx_call"` // the DX station heard
|
||||
FreqKHz float64 `json:"freq_khz"`
|
||||
FreqHz int64 `json:"freq_hz"`
|
||||
Band string `json:"band,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Locator string `json:"locator,omitempty"` // spotter grid (optional)
|
||||
Locator string `json:"locator,omitempty"` // spotter grid (optional)
|
||||
TimeUTC string `json:"time_utc,omitempty"`
|
||||
Country string `json:"country,omitempty"` // DXCC entity name (cty.dat)
|
||||
Continent string `json:"continent,omitempty"` // 2-letter continent
|
||||
CQZone int `json:"cqz,omitempty"` // DXCC entity CQ zone
|
||||
ITUZone int `json:"ituz,omitempty"` // DXCC entity ITU zone
|
||||
DistanceKm int `json:"distance_km,omitempty"` // great-circle km from operator's grid
|
||||
ShortPath int `json:"sp_deg,omitempty"` // azimuth (deg) short path from operator
|
||||
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
|
||||
Country string `json:"country,omitempty"` // DXCC entity name (cty.dat)
|
||||
Continent string `json:"continent,omitempty"` // 2-letter continent
|
||||
CQZone int `json:"cqz,omitempty"` // DXCC entity CQ zone
|
||||
ITUZone int `json:"ituz,omitempty"` // DXCC entity ITU zone
|
||||
DistanceKm int `json:"distance_km,omitempty"` // great-circle km from operator's grid
|
||||
ShortPath int `json:"sp_deg,omitempty"` // azimuth (deg) short path from operator
|
||||
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
Raw string `json:"raw"`
|
||||
// Historical marks a spot recovered from a SH/DX table rather than heard live.
|
||||
// It belongs in the grid, but must NOT fire alerts or reach the panadapter:
|
||||
// replaying 100 past spots would spam both, and a station spotted three hours
|
||||
// ago is not on the air now.
|
||||
Historical bool `json:"historical,omitempty"`
|
||||
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
|
||||
POTAName string `json:"pota_name,omitempty"` // park name
|
||||
Historical bool `json:"historical,omitempty"`
|
||||
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
|
||||
POTAName string `json:"pota_name,omitempty"` // park name
|
||||
}
|
||||
|
||||
// State enumerates the per-server lifecycle.
|
||||
@@ -122,14 +122,14 @@ type session struct {
|
||||
onLine func(Line)
|
||||
onStatus func()
|
||||
|
||||
mu sync.RWMutex
|
||||
status ServerStatus
|
||||
conn net.Conn
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
stopped bool // guards against double-stop on the same session
|
||||
spotsCnt int
|
||||
dbgN int // diagnostic: how many raw lines logged this connection
|
||||
mu sync.RWMutex
|
||||
status ServerStatus
|
||||
conn net.Conn
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
stopped bool // guards against double-stop on the same session
|
||||
spotsCnt int
|
||||
dbgN int // diagnostic: how many raw lines logged this connection
|
||||
}
|
||||
|
||||
// Manager owns N sessions, one per enabled server. Safe for concurrent
|
||||
@@ -560,8 +560,8 @@ const (
|
||||
// showDXRE matches the reply to SH/DX — which is a TABLE, not the "DX de …"
|
||||
// broadcast format, and therefore matched nothing at all:
|
||||
//
|
||||
// 14195.0 EA8DHH 3-Jul-2026 1234Z CQ DX <F5ABC>
|
||||
// freq dxcall date time comment <spotter>
|
||||
// 14195.0 EA8DHH 3-Jul-2026 1234Z CQ DX <F5ABC>
|
||||
// freq dxcall date time comment <spotter>
|
||||
//
|
||||
// This is why "SH/DX/100 does nothing": the 100 lines arrive, fail the broadcast
|
||||
// regex, and get dropped. The decimal point in the frequency is required — it is
|
||||
@@ -682,6 +682,28 @@ func bandFromHz(hz int64) string {
|
||||
return "33cm"
|
||||
case mhz >= 1240.0 && mhz <= 1300.0:
|
||||
return "23cm"
|
||||
// Microwave — a 10 GHz spot used to land with no band at all, so it could
|
||||
// not be filtered, matched against the log, or shown on a band map.
|
||||
case mhz >= 2300.0 && mhz <= 2450.0:
|
||||
return "13cm"
|
||||
case mhz >= 3300.0 && mhz <= 3500.0:
|
||||
return "9cm"
|
||||
case mhz >= 5650.0 && mhz <= 5925.0:
|
||||
return "6cm"
|
||||
case mhz >= 10000.0 && mhz <= 10500.0:
|
||||
return "3cm"
|
||||
case mhz >= 24000.0 && mhz <= 24250.0:
|
||||
return "1.25cm"
|
||||
case mhz >= 47000.0 && mhz <= 47200.0:
|
||||
return "6mm"
|
||||
case mhz >= 75500.0 && mhz <= 81000.0:
|
||||
return "4mm"
|
||||
case mhz >= 119980.0 && mhz <= 123000.0:
|
||||
return "2.5mm"
|
||||
case mhz >= 134000.0 && mhz <= 149000.0:
|
||||
return "2mm"
|
||||
case mhz >= 241000.0 && mhz <= 250000.0:
|
||||
return "1mm"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
+21
-6
@@ -6,6 +6,7 @@ import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -161,7 +162,7 @@ func Open(path string) (*sql.DB, error) {
|
||||
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||
}
|
||||
Dialect = "sqlite"
|
||||
if err := migrate(conn, nil, path); err != nil {
|
||||
if err := migrate(conn, nil, path, filepath.Base(path)); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
@@ -175,8 +176,8 @@ var LogSink func(format string, args ...any)
|
||||
|
||||
// logMigration records a migration that has just been applied, and how long it
|
||||
// took — the only trace an operator has that a data-rewriting migration ran.
|
||||
func logMigration(name string, start time.Time) {
|
||||
logf("db: migration %s applied in %s", name, time.Since(start).Round(time.Millisecond))
|
||||
func logMigration(label, name string, start time.Time) {
|
||||
logf("db[%s]: migration %s applied in %s", label, name, time.Since(start).Round(time.Millisecond))
|
||||
}
|
||||
|
||||
func logf(format string, args ...any) {
|
||||
@@ -231,7 +232,11 @@ func backupBeforeRewrite(conn *sql.DB, dbPath, migration string) {
|
||||
// skipping those already applied. Intentionally minimal in-house system
|
||||
// (no external dependency). translate, when non-nil, rewrites each statement
|
||||
// for a non-SQLite backend (see mysqlDDL); nil means run the SQLite DDL as-is.
|
||||
func migrate(conn *sql.DB, translate func(string) string, dbPath string) error {
|
||||
// label names the database being migrated, for the log. Without it three
|
||||
// interleaved migration runs in one log were indistinguishable — an operator
|
||||
// reported "migrations are very slow" and the lines gave no way to tell one
|
||||
// database migrated three times from three databases migrated once.
|
||||
func migrate(conn *sql.DB, translate func(string) string, dbPath, label string) error {
|
||||
// A non-nil translator means this is the MySQL connection (use the
|
||||
// per-statement, FK-aware path); nil means a SQLite connection. This is
|
||||
// determined by the caller's argument, NOT the global Dialect, so the
|
||||
@@ -274,6 +279,16 @@ func migrate(conn *sql.DB, translate func(string) string, dbPath string) error {
|
||||
return fmt.Errorf("read applied migrations: %w", err)
|
||||
}
|
||||
|
||||
pending := 0
|
||||
for _, name := range names {
|
||||
if !applied[name] {
|
||||
pending++
|
||||
}
|
||||
}
|
||||
if pending > 0 {
|
||||
logf("db[%s]: %d migration(s) to apply", label, pending)
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
if applied[name] {
|
||||
continue // already applied
|
||||
@@ -307,7 +322,7 @@ func migrate(conn *sql.DB, translate func(string) string, dbPath string) error {
|
||||
if _, err := conn.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil {
|
||||
return fmt.Errorf("record migration %s: %w", name, err)
|
||||
}
|
||||
logMigration(name, start)
|
||||
logMigration(label, name, start)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -327,7 +342,7 @@ func migrate(conn *sql.DB, translate func(string) string, dbPath string) error {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", name, err)
|
||||
}
|
||||
logMigration(name, start)
|
||||
logMigration(label, name, start)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+120
-16
@@ -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,
|
||||
@@ -139,6 +178,7 @@ func translateTextColumns(s string) string {
|
||||
// app-lifetime ctx with no per-query timeout) hold a connection for the whole
|
||||
// chain of remote-MySQL latency, so a handful of concurrent UI bursts drained
|
||||
// the pool and "after a while nothing updated" — grid, chat, live_status froze.
|
||||
//
|
||||
// 40 fits the actual deployment (max 5 ops on a server with max_connections=300 →
|
||||
// 40*5 = 200, leaving ~100 for phpMyAdmin/server overhead). This is the per-INSTANCE
|
||||
// pool, so keep max_connections >= pool*ops + headroom.
|
||||
@@ -199,8 +239,16 @@ 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.
|
||||
err = migrate(conn, mysqlDDL, "")
|
||||
// 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 {
|
||||
_ = conn.Close()
|
||||
@@ -287,7 +335,11 @@ func applyMySQLBaseline(conn *sql.DB) error {
|
||||
return fmt.Errorf("open baseline sqlite: %w", err)
|
||||
}
|
||||
defer mem.Close()
|
||||
if err := migrate(mem, nil, ""); err != nil {
|
||||
// In-memory SQLite, used only to derive the final schema for a FRESH MySQL
|
||||
// database. Labelled so its (fast) migration lines are not mistaken for a
|
||||
// real database being migrated — in one operator's log this pass sat between
|
||||
// two slow MySQL runs and looked like a third database.
|
||||
if err := migrate(mem, nil, "", "baseline:memory"); err != nil {
|
||||
return fmt.Errorf("build baseline schema: %w", err)
|
||||
}
|
||||
|
||||
@@ -529,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 {
|
||||
|
||||
@@ -86,6 +86,22 @@ func (m *Manager) SetProviders(p ...Provider) {
|
||||
|
||||
// Lookup returns a Result for the callsign. Falls back through providers
|
||||
// when one returns ErrNotFound or fails.
|
||||
// forceKey marks a context as a FORCED (operator-requested) lookup, which
|
||||
// bypasses the cache on the way in and refreshes it on the way out. Carried on
|
||||
// the context rather than as a parameter so every existing caller — and the
|
||||
// Provider interface — stays untouched.
|
||||
type forceKey struct{}
|
||||
|
||||
// WithForce returns a context that makes Lookup skip the cache.
|
||||
func WithForce(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, forceKey{}, true)
|
||||
}
|
||||
|
||||
func isForced(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(forceKey{}).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
||||
call := strings.ToUpper(strings.TrimSpace(callsign))
|
||||
if call == "" {
|
||||
@@ -97,15 +113,23 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
||||
dxcc := m.dxcc
|
||||
m.mu.RUnlock()
|
||||
|
||||
if r, ok := m.cache.Get(ctx, call); ok {
|
||||
r.Source = "cache"
|
||||
// Re-assert the authoritative DXCC fields (country/zones/continent)
|
||||
// from cty.dat on every cache hit — cheap (in-memory) and lets a
|
||||
// corrected entity mapping (e.g. Sicily → Italy) heal stale cached
|
||||
// rows without waiting for the TTL to expire.
|
||||
fillFromDXCC(&r, dxcc)
|
||||
normalizeNames(&r)
|
||||
return r, nil
|
||||
// A FORCED lookup skips the cache. The cache is right for the automatic
|
||||
// lookup that fires as you type, but it also freezes a wrong answer for the
|
||||
// whole TTL: an operator who upgraded their QRZ subscription kept getting the
|
||||
// thin free-account record for a month, and clearing the cache by hand was
|
||||
// the only way out. A lookup the operator asked for by clicking is a
|
||||
// deliberate act and must reach the provider.
|
||||
if !isForced(ctx) {
|
||||
if r, ok := m.cache.Get(ctx, call); ok {
|
||||
r.Source = "cache"
|
||||
// Re-assert the authoritative DXCC fields (country/zones/continent)
|
||||
// from cty.dat on every cache hit — cheap (in-memory) and lets a
|
||||
// corrected entity mapping (e.g. Sicily → Italy) heal stale cached
|
||||
// rows without waiting for the TTL to expire.
|
||||
fillFromDXCC(&r, dxcc)
|
||||
normalizeNames(&r)
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package qso
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/db"
|
||||
)
|
||||
|
||||
// End-to-end proof that filtering a confirmation STATUS actually filters.
|
||||
//
|
||||
// Reported from the field: "QRZ.com received status = N" returned rows showing
|
||||
// both N and Y. The SQL is a plain `col = ?`, so either the query is right and
|
||||
// the fault is elsewhere (the UI keeping the previous rows on an error, say), or
|
||||
// it is wrong here. Reading the code cannot tell those apart — running it can.
|
||||
func TestFilterOnConfirmationStatus(t *testing.T) {
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "logbook.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
r := NewRepo(conn)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, c := range []struct{ call, status string }{
|
||||
{"W1AAA", "Y"},
|
||||
{"W1BBB", "N"},
|
||||
{"W1CCC", "Y"},
|
||||
{"W1DDD", "N"},
|
||||
{"W1EEE", ""}, // never touched by a download — NULL/empty, neither Y nor N
|
||||
} {
|
||||
q := QSO{Callsign: c.call, Band: "20m", Mode: "SSB", QRZComDownloadStatus: c.status}
|
||||
if _, err := r.Add(ctx, q); err != nil {
|
||||
t.Fatalf("insert %s: %v", c.call, err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := r.ListFiltered(ctx, QueryFilter{
|
||||
Conditions: []Condition{{Field: "qrzcom_qso_download_status", Op: "eq", Value: "N"}},
|
||||
Match: "AND",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListFiltered: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("filter = N returned %d rows, want 2", len(got))
|
||||
}
|
||||
for _, q := range got {
|
||||
if q.QRZComDownloadStatus != "N" {
|
||||
t.Errorf("filter = N returned %s with status %q", q.Callsign, q.QRZComDownloadStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// A count that disagreed with the list would show "2 matches" above a grid of
|
||||
// five rows — the exact shape of the report.
|
||||
n, err := r.CountFiltered(ctx, QueryFilter{
|
||||
Conditions: []Condition{{Field: "qrzcom_qso_download_status", Op: "eq", Value: "N"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CountFiltered: %v", err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Errorf("CountFiltered = %d, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown field must ERROR rather than be silently dropped: a dropped
|
||||
// condition returns the whole logbook, which reads as "the filter did nothing".
|
||||
func TestFilterRejectsUnknownField(t *testing.T) {
|
||||
if _, _, err := conditionSQL(Condition{Field: "not_a_column", Op: "eq", Value: "N"}); err == nil {
|
||||
t.Error("unknown filter field accepted — it would silently return every QSO")
|
||||
}
|
||||
}
|
||||
+66
-28
@@ -536,23 +536,11 @@ type UploadRow struct {
|
||||
// uploadStatusCols whitelists the per-service sent-status columns the QSL
|
||||
// Manager may filter on (guards the dynamic column name in the query).
|
||||
var uploadStatusCols = map[string]bool{
|
||||
"lotw_sent": true,
|
||||
"eqsl_sent": true,
|
||||
"qrzcom_qso_upload_status": true,
|
||||
"qrzcom_qso_download_status": true,
|
||||
"clublog_qso_upload_status": true,
|
||||
"hrdlog_qso_upload_status": true,
|
||||
// Confirmation dates (ADIF YYYYMMDD strings, so plain TEXT like the rest).
|
||||
"qsl_sent_date": true,
|
||||
"qsl_rcvd_date": true,
|
||||
"lotw_sent_date": true,
|
||||
"lotw_rcvd_date": true,
|
||||
"eqsl_sent_date": true,
|
||||
"eqsl_rcvd_date": true,
|
||||
"qrzcom_qso_upload_date": true,
|
||||
"qrzcom_qso_download_date": true,
|
||||
"clublog_qso_upload_date": true,
|
||||
"hrdlog_qso_upload_date": true,
|
||||
"lotw_sent": true,
|
||||
"eqsl_sent": true,
|
||||
"qrzcom_qso_upload_status": true,
|
||||
"clublog_qso_upload_status": true,
|
||||
"hrdlog_qso_upload_status": true,
|
||||
}
|
||||
|
||||
// ListForUpload returns QSOs whose per-service sent-status column equals
|
||||
@@ -755,16 +743,28 @@ func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
|
||||
// which would be corrupted or meaningless if bulk-set to a single value.
|
||||
var bulkEditableCols = map[string]bool{
|
||||
// QSL / upload status
|
||||
"lotw_sent": true,
|
||||
"lotw_rcvd": true,
|
||||
"eqsl_sent": true,
|
||||
"eqsl_rcvd": true,
|
||||
"qsl_sent": true,
|
||||
"qsl_rcvd": true,
|
||||
"qsl_via": true,
|
||||
"qrzcom_qso_upload_status": true,
|
||||
"clublog_qso_upload_status": true,
|
||||
"hrdlog_qso_upload_status": true,
|
||||
"lotw_sent": true,
|
||||
"lotw_rcvd": true,
|
||||
"eqsl_sent": true,
|
||||
"eqsl_rcvd": true,
|
||||
"qsl_sent": true,
|
||||
"qsl_rcvd": true,
|
||||
"qsl_via": true,
|
||||
"qrzcom_qso_upload_status": true,
|
||||
"qrzcom_qso_download_status": true,
|
||||
"clublog_qso_upload_status": true,
|
||||
"hrdlog_qso_upload_status": true,
|
||||
// Confirmation DATES. ADIF YYYYMMDD strings, so plain TEXT like the rest.
|
||||
"qsl_sent_date": true,
|
||||
"qsl_rcvd_date": true,
|
||||
"lotw_sent_date": true,
|
||||
"lotw_rcvd_date": true,
|
||||
"eqsl_sent_date": true,
|
||||
"eqsl_rcvd_date": true,
|
||||
"qrzcom_qso_upload_date": true,
|
||||
"qrzcom_qso_download_date": true,
|
||||
"clublog_qso_upload_date": true,
|
||||
"hrdlog_qso_upload_date": true,
|
||||
// My station / operator
|
||||
"station_callsign": true,
|
||||
"operator": true,
|
||||
@@ -787,6 +787,19 @@ var bulkEditableCols = map[string]bool{
|
||||
"my_arrl_sect": true,
|
||||
"my_darc_dok": true,
|
||||
"my_vucc_grids": true,
|
||||
// Contacted station: the LOCATION fields only. State and county are the ones
|
||||
// an import loses and a whole run shares (a park activation, a county line
|
||||
// operation), and the dialog has always offered them — but they were missing
|
||||
// here, so choosing one failed at Apply. The rest of the contacted station
|
||||
// (callsign, name, RST…) stays deliberately out: bulk-setting those would
|
||||
// corrupt the log.
|
||||
// grid: the locator an import loses wholesale. Correcting it one QSO at a
|
||||
// time is what the operator is trying to avoid, and unlike the callsign it
|
||||
// carries no risk of confusing two stations — a wrong value is simply
|
||||
// overwritten again.
|
||||
"grid": true,
|
||||
"state": true,
|
||||
"cnty": true,
|
||||
// Contest — the exchange/label fields that are constant across a run.
|
||||
// (srx/stx serial numbers stay excluded: they are per-QSO.)
|
||||
"contest_id": true,
|
||||
@@ -1185,7 +1198,15 @@ var filterableColumns = map[string]bool{
|
||||
"iota": true, "sota_ref": true, "pota_ref": true, "wwff_ref": true, "rig": true, "ant": true,
|
||||
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true,
|
||||
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
|
||||
"qrzcom_qso_upload_status": true, "clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
|
||||
"qrzcom_qso_upload_status": true, "qrzcom_qso_download_status": true,
|
||||
"clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
|
||||
// Confirmation DATES. ADIF YYYYMMDD strings, so a plain string comparison is
|
||||
// also chronological — "before 20240101" works with no date parsing.
|
||||
"qsl_sent_date": true, "qsl_rcvd_date": true,
|
||||
"lotw_sent_date": true, "lotw_rcvd_date": true,
|
||||
"eqsl_sent_date": true, "eqsl_rcvd_date": true,
|
||||
"qrzcom_qso_upload_date": true, "qrzcom_qso_download_date": true,
|
||||
"clublog_qso_upload_date": true, "hrdlog_qso_upload_date": true,
|
||||
"contest_id": true, "srx": true, "stx": true,
|
||||
"prop_mode": true, "sat_name": true,
|
||||
"station_callsign": true, "operator": true, "my_grid": true, "my_country": true,
|
||||
@@ -2812,3 +2833,20 @@ func parseTimeLoose(s string) time.Time {
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// IsBulkEditable reports whether a column may be written by a bulk edit. Used by
|
||||
// the app layer's own test to prove that every field the UI offers is actually
|
||||
// accepted here — the two lists live in different packages and drifted apart
|
||||
// once already: the new confirmation columns were added to the QSL Manager's
|
||||
// filter whitelist instead of this one, so the UI offered fields that the
|
||||
// repository then refused with "field … is not bulk-editable".
|
||||
func IsBulkEditable(column string) bool { return bulkEditableCols[column] }
|
||||
|
||||
// IsFilterable reports whether a column may appear in a query filter. Same
|
||||
// reason as IsBulkEditable.
|
||||
func IsFilterable(column string) bool { return filterableColumns[column] }
|
||||
|
||||
// IsBulkEditableExtra / IsFilterableExtra cover the ADIF fields that have no
|
||||
// column of their own and live in extras_json.
|
||||
func IsBulkEditableExtra(field string) bool { _, ok := bulkEditableExtras[field]; return ok }
|
||||
func IsFilterableExtra(field string) bool { _, ok := filterableExtras[field]; return ok }
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
// Package rigctld shares OpsLog's CAT link with other programs.
|
||||
//
|
||||
// A native CAT backend owns the rig's serial port, and Windows gives a COM port
|
||||
// to ONE process. So the moment OpsLog talks to the radio directly, WSJT-X,
|
||||
// MSHV or JTDX can no longer reach it — the cost of dropping OmniRig, which was
|
||||
// itself a sharing layer.
|
||||
//
|
||||
// The answer is the one wfview uses: OpsLog becomes the server. It speaks the
|
||||
// Hamlib "net rigctl" protocol, which WSJT-X, JTDX, MSHV, Log4OM and CQRLOG all
|
||||
// support natively (rig model "Hamlib NET rigctl", host:4532) with no driver to
|
||||
// install. The other program asks us, and we relay to whichever backend is
|
||||
// connected — OmniRig, Flex, Icom, TCI or Yaesu alike.
|
||||
//
|
||||
// ── The protocol ──────────────────────────────────────────────────────────
|
||||
// Line-based ASCII. A lowercase letter reads, its uppercase counterpart writes,
|
||||
// and long names are prefixed with a backslash. A write answers "RPRT 0" for
|
||||
// success or "RPRT -n" for an error; a read answers the value(s), one per line.
|
||||
//
|
||||
// f → 14074000 get_freq
|
||||
// F 14074000 → RPRT 0 set_freq
|
||||
// m → USB\n2400 get_mode (mode + passband)
|
||||
// M USB 2400 → RPRT 0 set_mode
|
||||
// t / T 1 → 0 get/set PTT
|
||||
// s → 0\nVFOB get_split_vfo
|
||||
// v → VFOA get_vfo
|
||||
// \dump_state → capability block asked once by WSJT-X at connect
|
||||
//
|
||||
// WSJT-X will not proceed past connect without a well-formed dump_state, which
|
||||
// is why that block is written out in full rather than stubbed.
|
||||
package rigctld
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Rig is what the server needs from OpsLog's CAT manager. An interface, so this
|
||||
// package stays testable without a radio and without importing internal/cat.
|
||||
type Rig interface {
|
||||
Freq() int64 // current TX frequency in Hz, 0 if unknown
|
||||
Mode() string // ADIF mode (SSB, CW, FT8…)
|
||||
Split() (bool, int64) // split on?, and the other VFO's frequency
|
||||
SetFreq(hz int64) error
|
||||
SetMode(mode string) error
|
||||
SetPTT(on bool) error
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
port int
|
||||
rig Rig
|
||||
log func(string, ...any)
|
||||
|
||||
mu sync.Mutex
|
||||
ln net.Listener
|
||||
conns map[net.Conn]struct{}
|
||||
closed bool
|
||||
}
|
||||
|
||||
func New(port int, rig Rig, logf func(string, ...any)) *Server {
|
||||
if port <= 0 || port > 65535 {
|
||||
port = 4532 // the rigctld default every client pre-fills
|
||||
}
|
||||
if logf == nil {
|
||||
logf = func(string, ...any) {}
|
||||
}
|
||||
return &Server{port: port, rig: rig, log: logf, conns: map[net.Conn]struct{}{}}
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
s.mu.Lock()
|
||||
if s.ln != nil {
|
||||
s.mu.Unlock()
|
||||
return nil // already listening
|
||||
}
|
||||
s.closed = false
|
||||
s.mu.Unlock()
|
||||
|
||||
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("rigctld: listen on %d: %w", s.port, err)
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.ln = ln
|
||||
s.mu.Unlock()
|
||||
s.log("rigctld: sharing CAT on port %d (Hamlib NET rigctl)", s.port)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
s.mu.Lock()
|
||||
closed := s.closed
|
||||
s.mu.Unlock()
|
||||
if !closed {
|
||||
s.log("rigctld: accept failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.conns[c] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
go s.serve(c)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Stop() {
|
||||
s.mu.Lock()
|
||||
s.closed = true
|
||||
ln := s.ln
|
||||
s.ln = nil
|
||||
conns := make([]net.Conn, 0, len(s.conns))
|
||||
for c := range s.conns {
|
||||
conns = append(conns, c)
|
||||
}
|
||||
s.conns = map[net.Conn]struct{}{}
|
||||
s.mu.Unlock()
|
||||
|
||||
if ln != nil {
|
||||
_ = ln.Close()
|
||||
}
|
||||
// Close the live sessions too. Leaving them open would keep a client happily
|
||||
// talking to a server the operator has switched off.
|
||||
for _, c := range conns {
|
||||
_ = c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) serve(c net.Conn) {
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.conns, c)
|
||||
s.mu.Unlock()
|
||||
_ = c.Close()
|
||||
}()
|
||||
s.log("rigctld: client connected from %s", c.RemoteAddr())
|
||||
r := bufio.NewReader(c)
|
||||
w := bufio.NewWriter(c)
|
||||
for {
|
||||
// No deadline: WSJT-X polls every few seconds but a client may legitimately
|
||||
// sit idle between band changes, and dropping it would look like a fault.
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
s.log("rigctld: client %s disconnected", c.RemoteAddr())
|
||||
return
|
||||
}
|
||||
resp, quit := s.handle(strings.TrimSpace(line))
|
||||
if resp != "" {
|
||||
if _, err := w.WriteString(resp); err != nil {
|
||||
return
|
||||
}
|
||||
if err := w.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if quit {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handle answers one command line. Pure apart from the Rig calls, so the whole
|
||||
// protocol is testable with a fake rig.
|
||||
func (s *Server) handle(line string) (resp string, quit bool) {
|
||||
if line == "" {
|
||||
return "", false
|
||||
}
|
||||
// Extended mode: clients may prefix a command with '+' or '-' to ask for a
|
||||
// verbose reply. We answer in the plain format, which every client also
|
||||
// accepts, so the prefix is simply stripped.
|
||||
line = strings.TrimLeft(line, "+-")
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
return "", false
|
||||
}
|
||||
cmd, args := fields[0], stripVFOArg(fields[1:])
|
||||
|
||||
switch cmd {
|
||||
case "\\dump_state", "dump_state":
|
||||
return dumpState, false
|
||||
case "\\chk_vfo", "chk_vfo":
|
||||
// "is VFO mode on?" — we answer for one VFO at a time, so: no.
|
||||
return "CHKVFO 0\n", false
|
||||
case "\\get_powerstat", "get_powerstat":
|
||||
return "1\n", false
|
||||
case "q", "Q", "\\quit":
|
||||
return "", true
|
||||
|
||||
case "f", "\\get_freq":
|
||||
return fmt.Sprintf("%d\n", s.rig.Freq()), false
|
||||
case "F", "\\set_freq":
|
||||
if len(args) < 1 {
|
||||
return rprt(-1), false
|
||||
}
|
||||
hz, err := parseFreq(args[0])
|
||||
if err != nil {
|
||||
// Logged with the RAW line: a client that phrases a command in a
|
||||
// dialect we don't accept shows only "Invalid parameter" on its side,
|
||||
// which says nothing about what it actually sent.
|
||||
s.log("rigctld: cannot read a frequency from %q — client dialect not handled", line)
|
||||
return rprt(-1), false
|
||||
}
|
||||
if err := s.rig.SetFreq(hz); err != nil {
|
||||
s.log("rigctld: set_freq %d failed: %v", hz, err)
|
||||
return rprt(-9), false
|
||||
}
|
||||
return rprt(0), false
|
||||
|
||||
case "m", "\\get_mode":
|
||||
// Passband width is required by the protocol. We do not read the rig's
|
||||
// filter, and a made-up number is harmless here: clients use it to display
|
||||
// a bandwidth, never to decide anything.
|
||||
return fmt.Sprintf("%s\n%d\n", adifToHamlib(s.rig.Mode()), passbandFor(s.rig.Mode())), false
|
||||
case "M", "\\set_mode":
|
||||
if len(args) < 1 {
|
||||
return rprt(-1), false
|
||||
}
|
||||
if err := s.rig.SetMode(hamlibToADIF(args[0])); err != nil {
|
||||
s.log("rigctld: set_mode %q failed: %v", args[0], err)
|
||||
return rprt(-9), false
|
||||
}
|
||||
return rprt(0), false
|
||||
|
||||
case "t", "\\get_ptt":
|
||||
// We do not read PTT back from every backend, and answering "transmitting"
|
||||
// wrongly would make a client hold off for ever. Reporting RX is the safe
|
||||
// direction: the worst case is a client that transmits when we said it
|
||||
// could, which is what it was going to do anyway.
|
||||
return "0\n", false
|
||||
case "T", "\\set_ptt":
|
||||
if len(args) < 1 {
|
||||
return rprt(-1), false
|
||||
}
|
||||
on := args[0] != "0"
|
||||
if err := s.rig.SetPTT(on); err != nil {
|
||||
s.log("rigctld: set_ptt %v failed: %v", on, err)
|
||||
return rprt(-9), false
|
||||
}
|
||||
return rprt(0), false
|
||||
|
||||
case "v", "\\get_vfo":
|
||||
return "VFOA\n", false
|
||||
case "V", "\\set_vfo":
|
||||
// Accepted and ignored: OpsLog follows the rig's own VFO selection, and
|
||||
// answering an error here makes WSJT-X abandon the connection entirely.
|
||||
return rprt(0), false
|
||||
|
||||
case "s", "\\get_split_vfo":
|
||||
on, _ := s.rig.Split()
|
||||
n := 0
|
||||
if on {
|
||||
n = 1
|
||||
}
|
||||
return fmt.Sprintf("%d\nVFOB\n", n), false
|
||||
case "S", "\\set_split_vfo":
|
||||
return rprt(0), false // see set_vfo — split is driven from the rig
|
||||
case "i", "\\get_split_freq":
|
||||
_, tx := s.rig.Split()
|
||||
if tx <= 0 {
|
||||
tx = s.rig.Freq()
|
||||
}
|
||||
return fmt.Sprintf("%d\n", tx), false
|
||||
case "I", "\\set_split_freq":
|
||||
return rprt(0), false
|
||||
|
||||
default:
|
||||
// RPRT -11 is "command not implemented". Answering something is essential:
|
||||
// a client waiting on a silent socket hangs rather than degrading.
|
||||
s.log("rigctld: unimplemented command %q", line)
|
||||
return rprt(-11), false
|
||||
}
|
||||
}
|
||||
|
||||
func rprt(code int) string { return fmt.Sprintf("RPRT %d\n", code) }
|
||||
|
||||
// stripVFOArg drops a leading VFO name from a command's arguments.
|
||||
//
|
||||
// Hamlib has two dialects. In the plain one a client sends "F 14074000"; in VFO
|
||||
// mode it names the target first — "F VFOA 14074000". MSHV uses the first and
|
||||
// worked immediately; JTDX uses the second, so the frequency landed in the
|
||||
// argument slot where a VFO was expected, the parse failed, and JTDX showed
|
||||
// "Hamlib error: Invalid parameter while setting frequency" (our RPRT -1).
|
||||
//
|
||||
// Accepting both costs nothing here: OpsLog follows the rig's own VFO
|
||||
// selection, so the name carries no information we act on — dropping it is not
|
||||
// losing anything, and refusing it locks out a whole family of clients.
|
||||
func stripVFOArg(args []string) []string {
|
||||
if len(args) == 0 {
|
||||
return args
|
||||
}
|
||||
switch strings.ToUpper(args[0]) {
|
||||
case "VFOA", "VFOB", "VFOC", "VFO", "CURRVFO", "CURR", "MAIN", "SUB", "MEM", "A", "B":
|
||||
return args[1:]
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// parseFreq accepts both the integer Hz and the "14074000.000000" form clients
|
||||
// send interchangeably.
|
||||
func parseFreq(s string) (int64, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexByte(s, '.'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
hz, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil || hz <= 0 {
|
||||
return 0, fmt.Errorf("rigctld: bad frequency %q", s)
|
||||
}
|
||||
return hz, nil
|
||||
}
|
||||
|
||||
// adifToHamlib maps our mode vocabulary to Hamlib's. Every digital sub-mode
|
||||
// becomes PKTUSB: that is what a client expects to see when the rig is in DATA,
|
||||
// and it is what WSJT-X sets when it takes control.
|
||||
func adifToHamlib(mode string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "SSB", "USB":
|
||||
return "USB"
|
||||
case "LSB":
|
||||
return "LSB"
|
||||
case "CW":
|
||||
return "CW"
|
||||
case "AM":
|
||||
return "AM"
|
||||
case "FM":
|
||||
return "FM"
|
||||
case "RTTY":
|
||||
return "RTTY"
|
||||
case "":
|
||||
return "USB"
|
||||
default:
|
||||
return "PKTUSB"
|
||||
}
|
||||
}
|
||||
|
||||
// hamlibToADIF is the reverse. PKTUSB/PKTLSB/DATA become "DATA": the CAT backend
|
||||
// then applies the operator's configured digital mode, so a client switching the
|
||||
// rig to data does not silently relabel their QSOs as FT8 when they run JS8.
|
||||
func hamlibToADIF(mode string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "USB":
|
||||
return "USB"
|
||||
case "LSB":
|
||||
return "LSB"
|
||||
case "CW", "CWR":
|
||||
return "CW"
|
||||
case "AM":
|
||||
return "AM"
|
||||
case "FM", "FMN", "WFM":
|
||||
return "FM"
|
||||
case "RTTY", "RTTYR":
|
||||
return "RTTY"
|
||||
case "PKTUSB", "PKTLSB", "PKTFM", "DATA", "DIGU", "DIGL":
|
||||
return "DATA"
|
||||
default:
|
||||
return strings.ToUpper(strings.TrimSpace(mode))
|
||||
}
|
||||
}
|
||||
|
||||
func passbandFor(mode string) int {
|
||||
switch adifToHamlib(mode) {
|
||||
case "CW":
|
||||
return 500
|
||||
case "RTTY", "PKTUSB":
|
||||
return 3000
|
||||
case "AM":
|
||||
return 6000
|
||||
case "FM":
|
||||
return 15000
|
||||
default:
|
||||
return 2400
|
||||
}
|
||||
}
|
||||
|
||||
// dumpState is the capability block Hamlib clients read once at connect. WSJT-X
|
||||
// refuses to go further without it, and parses it positionally — the field
|
||||
// ORDER is the contract, so this is kept as one literal rather than assembled.
|
||||
//
|
||||
// It declares protocol version 0, a generic rig, and one 150 kHz–1500 MHz range
|
||||
// with the common modes. The numbers are deliberately permissive: they say what
|
||||
// a client may ASK for, and OpsLog's backend refuses anything the radio cannot
|
||||
// really do.
|
||||
const dumpState = `0
|
||||
1
|
||||
2
|
||||
150000.000000 1500000000.000000 0x1ff -1 -1 0x10000003 0x3
|
||||
0 0 0 0 0 0 0
|
||||
150000.000000 1500000000.000000 0x1ff -1 -1 0x10000003 0x3
|
||||
0 0 0 0 0 0 0
|
||||
0 0
|
||||
0 0
|
||||
0x1ff 1
|
||||
0x1ff 0
|
||||
0 0
|
||||
0x1e 2400
|
||||
0x2 500
|
||||
0x1 8000
|
||||
0x1 2400
|
||||
0x20 15000
|
||||
0x20 8000
|
||||
0x40 230000
|
||||
0 0
|
||||
9990
|
||||
9990
|
||||
10000
|
||||
0
|
||||
10
|
||||
10 20 30
|
||||
0x3effffff
|
||||
0x3effffff
|
||||
0x7fffffff
|
||||
0x7fffffff
|
||||
0x7fffffff
|
||||
0x7fffffff
|
||||
`
|
||||
|
||||
// dialTimeout is only used by tests, kept here so the value is one place.
|
||||
const dialTimeout = 2 * time.Second
|
||||
@@ -0,0 +1,235 @@
|
||||
package rigctld
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeRig stands in for the CAT manager.
|
||||
type fakeRig struct {
|
||||
mu sync.Mutex
|
||||
freq int64
|
||||
mode string
|
||||
split bool
|
||||
txFreq int64
|
||||
ptt bool
|
||||
setFreqs []int64
|
||||
setModes []string
|
||||
failSet bool
|
||||
}
|
||||
|
||||
func (f *fakeRig) Freq() int64 { f.mu.Lock(); defer f.mu.Unlock(); return f.freq }
|
||||
func (f *fakeRig) Mode() string { f.mu.Lock(); defer f.mu.Unlock(); return f.mode }
|
||||
func (f *fakeRig) Split() (bool, int64) { f.mu.Lock(); defer f.mu.Unlock(); return f.split, f.txFreq }
|
||||
func (f *fakeRig) SetFreq(hz int64) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.failSet {
|
||||
return fmt.Errorf("rig refused")
|
||||
}
|
||||
f.freq = hz
|
||||
f.setFreqs = append(f.setFreqs, hz)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRig) SetMode(m string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.failSet {
|
||||
return fmt.Errorf("rig refused")
|
||||
}
|
||||
f.mode = m
|
||||
f.setModes = append(f.setModes, m)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRig) SetPTT(on bool) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.failSet {
|
||||
return fmt.Errorf("rig refused")
|
||||
}
|
||||
f.ptt = on
|
||||
return nil
|
||||
}
|
||||
|
||||
// The command table. These exact strings are what WSJT-X and MSHV put on the
|
||||
// wire, so they are the contract — a reply in the wrong shape does not degrade
|
||||
// gracefully, the client simply refuses to work with the rig.
|
||||
func TestHandleCommands(t *testing.T) {
|
||||
rig := &fakeRig{freq: 14074000, mode: "FT8", split: true, txFreq: 14100000}
|
||||
s := New(0, rig, nil)
|
||||
|
||||
cases := []struct{ in, want string }{
|
||||
{"f", "14074000\n"},
|
||||
{"\\get_freq", "14074000\n"},
|
||||
{"m", "PKTUSB\n3000\n"}, // a digital mode reads as PKTUSB
|
||||
{"t", "0\n"}, // PTT always reads RX — see the comment
|
||||
{"v", "VFOA\n"},
|
||||
{"s", "1\nVFOB\n"}, // split on, TX on B
|
||||
{"i", "14100000\n"}, // split TX frequency
|
||||
{"F 14200000", "RPRT 0\n"},
|
||||
{"F 14200000.000000", "RPRT 0\n"}, // the float form clients also send
|
||||
{"M USB 2400", "RPRT 0\n"},
|
||||
{"T 1", "RPRT 0\n"},
|
||||
{"V VFOB", "RPRT 0\n"}, // accepted and ignored, never an error
|
||||
{"S 1 VFOB", "RPRT 0\n"},
|
||||
{"\\chk_vfo", "CHKVFO 0\n"},
|
||||
{"F", "RPRT -1\n"}, // missing argument
|
||||
{"F not_a_number", "RPRT -1\n"},
|
||||
{"Z", "RPRT -11\n"}, // unknown → answered, never silence
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, _ := s.handle(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("handle(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
if q := func() bool { _, q := s.handle("q"); return q }(); !q {
|
||||
t.Error("q must end the session")
|
||||
}
|
||||
}
|
||||
|
||||
// Hamlib's VFO dialect. JTDX names the target VFO before the value — "F VFOA
|
||||
// 14074000" — where MSHV sends "F 14074000". Reading the VFO name as the
|
||||
// frequency is what produced "Hamlib error: Invalid parameter while setting
|
||||
// frequency" on JTDX while MSHV worked perfectly.
|
||||
func TestHandleAcceptsVFOPrefixedCommands(t *testing.T) {
|
||||
rig := &fakeRig{freq: 7074000, mode: "SSB"}
|
||||
s := New(0, rig, nil)
|
||||
|
||||
if got, _ := s.handle("F VFOA 14074000"); got != "RPRT 0\n" {
|
||||
t.Fatalf("handle(\"F VFOA 14074000\") = %q, want RPRT 0", got)
|
||||
}
|
||||
if got := rig.Freq(); got != 14074000 {
|
||||
t.Errorf("frequency = %d, want 14074000 — the VFO name swallowed the value", got)
|
||||
}
|
||||
if got, _ := s.handle("M VFOA USB 2400"); got != "RPRT 0\n" {
|
||||
t.Errorf("handle(\"M VFOA USB 2400\") = %q, want RPRT 0", got)
|
||||
}
|
||||
if got, _ := s.handle("T VFOA 1"); got != "RPRT 0\n" {
|
||||
t.Errorf("handle(\"T VFOA 1\") = %q, want RPRT 0", got)
|
||||
}
|
||||
// A read with the VFO named must still answer the value, not an error.
|
||||
if got, _ := s.handle("f VFOA"); got != "14074000\n" {
|
||||
t.Errorf("handle(\"f VFOA\") = %q, want the frequency", got)
|
||||
}
|
||||
// And the plain dialect must keep working — this is an ADDITION, not a swap.
|
||||
if got, _ := s.handle("F 21074000"); got != "RPRT 0\n" {
|
||||
t.Errorf("plain set_freq broke: %q", got)
|
||||
}
|
||||
// "S 1 VFOB" starts with the split flag, not a VFO: nothing must be eaten.
|
||||
if got, _ := s.handle("S 1 VFOB"); got != "RPRT 0\n" {
|
||||
t.Errorf("handle(\"S 1 VFOB\") = %q, want RPRT 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A rig that refuses must produce an error report, not a success — a client told
|
||||
// "RPRT 0" believes the radio moved and will log the wrong frequency.
|
||||
func TestHandleReportsBackendFailure(t *testing.T) {
|
||||
s := New(0, &fakeRig{failSet: true}, nil)
|
||||
for _, in := range []string{"F 14200000", "M USB 2400", "T 1"} {
|
||||
if got, _ := s.handle(in); got != "RPRT -9\n" {
|
||||
t.Errorf("handle(%q) with a failing rig = %q, want RPRT -9", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dump_state is parsed POSITIONALLY by Hamlib clients: WSJT-X reads the first
|
||||
// line as the protocol version and refuses to continue if the block is short or
|
||||
// misshapen. Pinning its shape is what stops a well-meaning edit from silently
|
||||
// breaking every client.
|
||||
func TestDumpStateShape(t *testing.T) {
|
||||
lines := strings.Split(strings.TrimRight(dumpState, "\n"), "\n")
|
||||
if len(lines) < 20 {
|
||||
t.Fatalf("dump_state has %d lines — clients expect the full capability block", len(lines))
|
||||
}
|
||||
if lines[0] != "0" {
|
||||
t.Errorf("dump_state protocol version = %q, want \"0\"", lines[0])
|
||||
}
|
||||
// The frequency-range lines must carry seven fields, or the client's parse
|
||||
// slides and every later capability is read from the wrong place.
|
||||
for _, i := range []int{3, 5} {
|
||||
if n := len(strings.Fields(lines[i])); n != 7 {
|
||||
t.Errorf("dump_state line %d has %d fields, want 7: %q", i, n, lines[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// End to end over a real socket, because the framing (one reply per line,
|
||||
// flushed immediately) is as much a part of the contract as the text.
|
||||
func TestServerOverTCP(t *testing.T) {
|
||||
rig := &fakeRig{freq: 7074000, mode: "SSB"}
|
||||
s := New(0, rig, nil)
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.ln = ln
|
||||
s.mu.Unlock()
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go s.serve(c)
|
||||
}
|
||||
}()
|
||||
defer s.Stop()
|
||||
|
||||
c, err := net.DialTimeout("tcp", ln.Addr().String(), dialTimeout)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
r := bufio.NewReader(c)
|
||||
|
||||
if _, err := c.Write([]byte("f\n")); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(line) != "7074000" {
|
||||
t.Errorf("get_freq over TCP = %q, want 7074000", strings.TrimSpace(line))
|
||||
}
|
||||
|
||||
if _, err := c.Write([]byte("F 14074000\n")); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
line, _ = r.ReadString('\n')
|
||||
if strings.TrimSpace(line) != "RPRT 0" {
|
||||
t.Errorf("set_freq over TCP = %q, want RPRT 0", strings.TrimSpace(line))
|
||||
}
|
||||
if got := rig.Freq(); got != 14074000 {
|
||||
t.Errorf("rig frequency = %d, want 14074000 — the command never reached it", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeMapping(t *testing.T) {
|
||||
for _, c := range []struct{ adif, hamlib string }{
|
||||
{"SSB", "USB"}, {"LSB", "LSB"}, {"CW", "CW"}, {"RTTY", "RTTY"},
|
||||
{"FT8", "PKTUSB"}, {"JS8", "PKTUSB"}, {"", "USB"},
|
||||
} {
|
||||
if got := adifToHamlib(c.adif); got != c.hamlib {
|
||||
t.Errorf("adifToHamlib(%q) = %q, want %q", c.adif, got, c.hamlib)
|
||||
}
|
||||
}
|
||||
// Digital comes back as DATA, never as a specific sub-mode: the CAT backend
|
||||
// applies the operator's own digital default, so a client that switches the
|
||||
// rig to data does not relabel a JS8 operator's QSOs as FT8.
|
||||
for _, c := range []struct{ hamlib, adif string }{
|
||||
{"PKTUSB", "DATA"}, {"PKTLSB", "DATA"}, {"DIGU", "DATA"},
|
||||
{"USB", "USB"}, {"CWR", "CW"}, {"FMN", "FM"},
|
||||
} {
|
||||
if got := hamlibToADIF(c.hamlib); got != c.adif {
|
||||
t.Errorf("hamlibToADIF(%q) = %q, want %q", c.hamlib, got, c.adif)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package winkeyer
|
||||
|
||||
import "testing"
|
||||
|
||||
// The init sequence sent to a K1EL keyer, byte for byte.
|
||||
//
|
||||
// This exists because of a bug that no amount of reading caught and that a
|
||||
// byte-level trace from a real WK2 settled in one line: the dit/dah ratio was
|
||||
// sent as command 0x11, which is SET KEY COMPENSATION in milliseconds. A
|
||||
// neutral ratio of 50 therefore asked for 50 ms of extra key-down on every
|
||||
// element — at 25 wpm a dit is 48 ms — so elements more than doubled and ran
|
||||
// into each other. The operator saw "one element, then it stalls".
|
||||
//
|
||||
// The command NUMBERS are the contract with the hardware. A test on the values
|
||||
// is the only thing standing between a typo here and a keyer that misbehaves in
|
||||
// a way nobody can debug from the UI.
|
||||
func TestApplyConfigCommands(t *testing.T) {
|
||||
cmds := configCommands(Config{
|
||||
WPM: 25, Weight: 50, Ratio: 50, LeadInMs: 0, TailMs: 0, Sidetone: 0,
|
||||
})
|
||||
|
||||
// Every command that must be present, with the value it must carry.
|
||||
want := map[byte][]byte{
|
||||
0x02: {25}, // speed
|
||||
0x03: {50}, // weighting — 50 is neutral, and this one WAS right
|
||||
0x17: {50}, // dit/dah ratio — the corrected opcode
|
||||
0x11: {0x00}, // key compensation explicitly cleared
|
||||
}
|
||||
seen := map[byte][]byte{}
|
||||
for _, c := range cmds {
|
||||
if len(c) < 2 {
|
||||
t.Fatalf("command %X has no argument", c)
|
||||
}
|
||||
seen[c[0]] = c[1:]
|
||||
}
|
||||
for op, args := range want {
|
||||
got, ok := seen[op]
|
||||
if !ok {
|
||||
t.Errorf("command 0x%02X is not sent at all", op)
|
||||
continue
|
||||
}
|
||||
if len(got) != len(args) || (len(args) > 0 && got[0] != args[0]) {
|
||||
t.Errorf("command 0x%02X carries % X, want % X", op, got, args)
|
||||
}
|
||||
}
|
||||
|
||||
// The ratio must NEVER go out as 0x11 again: that is the whole bug.
|
||||
if v, ok := seen[0x11]; ok && len(v) > 0 && v[0] != 0 {
|
||||
t.Errorf("0x11 (key compensation) carries %d — it must be 0, not the ratio", v[0])
|
||||
}
|
||||
}
|
||||
|
||||
// A keyer left with compensation by an earlier version keeps it in EEPROM, so
|
||||
// the fix has to CLEAR it rather than merely stop setting it.
|
||||
func TestKeyCompensationIsCleared(t *testing.T) {
|
||||
for _, ratio := range []int{33, 50, 66} {
|
||||
cmds := configCommands(Config{WPM: 20, Weight: 50, Ratio: ratio})
|
||||
found := false
|
||||
for _, c := range cmds {
|
||||
if c[0] == 0x11 {
|
||||
found = true
|
||||
if c[1] != 0 {
|
||||
t.Errorf("ratio %d: key compensation sent as %d, want 0", ratio, c[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("ratio %d: key compensation is never cleared", ratio)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,21 +243,39 @@ func (m *Manager) connectSerial(cfg Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyConfig pushes the keying parameters to the device.
|
||||
func (m *Manager) applyConfig(c Config) error {
|
||||
// configCommands is the init sequence for a config — separated from the sending
|
||||
// so the BYTES can be tested. The command numbers are the contract with the
|
||||
// hardware, and a wrong one produces a keyer that misbehaves in a way no amount
|
||||
// of reading the UI explains.
|
||||
func configCommands(c Config) [][]byte {
|
||||
cmds := [][]byte{
|
||||
{0x0E, modeRegister(c)}, // set mode register (paddle mode, swap, autospace…)
|
||||
{0x02, byte(c.WPM)}, // set speed (WPM)
|
||||
{0x03, byte(c.Weight)}, // set weighting
|
||||
{0x04, byte(c.LeadInMs / 10), byte(c.TailMs / 10)}, // PTT lead-in / tail (10 ms units)
|
||||
{0x11, byte(c.Ratio)}, // set dit/dah ratio
|
||||
// Dit/dah ratio is 0x17. It was being sent as 0x11, which on a WinKeyer 2
|
||||
// is SET KEY COMPENSATION — in milliseconds. So a neutral ratio of 50 was
|
||||
// read as 50 ms of extra key-down on every element: at 25 wpm a dit is
|
||||
// 48 ms, so each element more than doubled and ran into the next. That is
|
||||
// the reported "it sends one element then stalls", and it was in the trace
|
||||
// as "TX 11 32".
|
||||
{0x17, byte(c.Ratio)},
|
||||
// And clear the compensation explicitly. A keyer left at 50 ms by the
|
||||
// previous version — or by another program — keeps it in EEPROM, so
|
||||
// merely stopping the wrong command would not fix an affected keyer.
|
||||
{0x11, 0x00},
|
||||
}
|
||||
// Sidetone: <0x01 n>. Bit6 enables, low nibble selects the pitch divisor.
|
||||
cmds = append(cmds, []byte{0x01, sidetoneCode(c.Sidetone)})
|
||||
if c.Farnsworth > 0 {
|
||||
cmds = append(cmds, []byte{0x0D, byte(c.Farnsworth)}) // Farnsworth WPM
|
||||
}
|
||||
for _, cmd := range cmds {
|
||||
return cmds
|
||||
}
|
||||
|
||||
// applyConfig pushes the keying parameters to the device.
|
||||
func (m *Manager) applyConfig(c Config) error {
|
||||
for _, cmd := range configCommands(c) {
|
||||
if err := m.write(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -478,10 +496,12 @@ func cmdName(b []byte) string {
|
||||
return "clear buffer"
|
||||
case 0x0D:
|
||||
return "farnsworth wpm"
|
||||
case 0x17:
|
||||
return "set dit/dah ratio"
|
||||
case 0x0E:
|
||||
return "set mode register"
|
||||
case 0x11:
|
||||
return "0x11 (WK2: key compensation / WK3: see datasheet)"
|
||||
return "set key compensation (ms)"
|
||||
case 0x15:
|
||||
return "request status"
|
||||
default:
|
||||
|
||||
@@ -125,7 +125,10 @@ func main() {
|
||||
AssetServer: &assetserver.Options{
|
||||
Assets: assets,
|
||||
},
|
||||
BackgroundColour: &options.RGBA{R: 250, G: 250, B: 249, A: 1},
|
||||
// The colour the window is painted before the WebView draws. Matches the
|
||||
// graphite theme's background (#16181d), which is the default on a fresh
|
||||
// install — otherwise every launch flashes white first.
|
||||
BackgroundColour: &options.RGBA{R: 0x16, G: 0x18, B: 0x1d, A: 1},
|
||||
OnStartup: app.startup,
|
||||
OnDomReady: app.domReady,
|
||||
OnBeforeClose: app.beforeClose,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// The case that started this: WSJT-X / MSHV can only send a 4-character grid, so
|
||||
// a digital QSO arrived with JN05 while QRZ knew JN05JG — and the fill-if-empty
|
||||
// enrichment rule kept the coarse one. Roughly 100 km of accuracy, silently lost
|
||||
// on every digital QSO.
|
||||
func TestRefineGrid(t *testing.T) {
|
||||
cases := []struct{ have, found, want, why string }{
|
||||
{"JN05", "JN05JG", "JN05JG", "the lookup EXTENDS the received square — take the precise one"},
|
||||
{"jn05", "JN05JG", "JN05JG", "grids arrive in both cases"},
|
||||
{"", "JN05JG", "JN05JG", "nothing received — take whatever was found"},
|
||||
{"JN05JG", "", "JN05JG", "nothing found — keep what was received"},
|
||||
{"JN05JG", "JN05", "JN05JG", "never trade a precise grid for a coarse one"},
|
||||
{"JN05JG", "JN05JG", "JN05JG", "same grid"},
|
||||
// A DISAGREEMENT is not a refinement: the station may be portable, and
|
||||
// what came over the air is then the better record.
|
||||
{"JN05", "JN18AA", "JN05", "different square — keep what was actually received"},
|
||||
{"JN18AA", "JN05", "JN18AA", "different square, coarse lookup — keep the received one"},
|
||||
{"", "", "", "nothing either way"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := refineGrid(c.have, c.found); got != c.want {
|
||||
t.Errorf("refineGrid(%q, %q) = %q, want %q — %s", c.have, c.found, got, c.want, c.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.21.6"
|
||||
appVersion = "0.22.1"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/uls"
|
||||
)
|
||||
|
||||
// The ULS grid rules, which are NOT the same as refineGrid's.
|
||||
//
|
||||
// cty.dat answers a US call with the ENTITY centroid — a 4-character square in
|
||||
// the middle of the country. refineGrid keeps it (the ULS square is not an
|
||||
// extension of it, it is a different square), which is right for a QRZ grid and
|
||||
// wrong here: a per-callsign FCC square beats any entity centroid. But a
|
||||
// 6-character grid already present came from a provider and must survive.
|
||||
func TestULSGridPreference(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
have string // grid already on the result
|
||||
uls string // grid from the FCC database
|
||||
want string
|
||||
}{
|
||||
{"nothing known → take ULS", "", "EM12AB", "EM12AB"},
|
||||
{"cty.dat entity centroid → ULS wins", "EN90", "EM12AB", "EM12AB"},
|
||||
{"4-char square, same square → ULS extends it", "EM12", "EM12AB", "EM12AB"},
|
||||
{"provider gave 6 chars → untouched", "FN31PR", "EM12AB", "FN31PR"},
|
||||
{"ULS has only 4 chars, provider has 6 → untouched", "FN31PR", "EM12", "FN31PR"},
|
||||
{"ULS empty → untouched", "EN90", "", "EN90"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := c.have
|
||||
if g := refineGrid(got, c.uls); g != "" && g != got {
|
||||
got = g
|
||||
} else if len(got) <= 4 && len(c.uls) >= 6 {
|
||||
got = c.uls
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("%s: have=%q uls=%q → %q, want %q", c.name, c.have, c.uls, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CNTY is what gets written to the QSO's county field, so its shape is part of
|
||||
// the contract: ADIF wants "State,County" and nothing when either half is
|
||||
// missing — a lone county name would be an invalid CNTY on export.
|
||||
func TestULSCountyFormat(t *testing.T) {
|
||||
cases := []struct {
|
||||
loc uls.Location
|
||||
want string
|
||||
}{
|
||||
{uls.Location{State: "MA", County: "Middlesex"}, "MA,Middlesex"},
|
||||
{uls.Location{State: "", County: "Middlesex"}, ""},
|
||||
{uls.Location{State: "MA", County: ""}, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := c.loc.CNTY(); got != c.want {
|
||||
t.Errorf("Location%+v.CNTY() = %q, want %q", c.loc, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user