Compare commits

..
17 Commits
Author SHA1 Message Date
rouggy db87ef39c0 chore: release v0.25.1 2026-08-14 13:06:44 +02:00
rouggy 3ce930e9cc refactor(winkeyer): learn the slow boot instead of naming the keyer
The K3NG entry added an hour ago is gone. It named one clone among many —
WKmini, home-built Arduinos, unbranded boxes — for hardware that speaks
exactly the same protocol, and it was the only line in the engine list that
picked a boot delay rather than a protocol. An operator with an unlabelled
clone would have had to guess.

The delay is now learnt per port. The first connect finds out by failing the
quick attempt and succeeding on the slow one; that fact is written to a
global setting keyed by the port, and every connect afterwards goes straight
to the slow attempt. Global rather than per profile on purpose: which keyer
is plugged into COM3 belongs to the computer, and switching profiles for a
different rig does not change the keyer on the desk.

Two tests hold the contract from both sides — a slow keyer must be reported
as slow, and a keyer that answers at once must not be, or every K1EL connect
would inherit seconds it never needed.
2026-08-14 13:05:13 +02:00
rouggy aca4dc5678 feat(winkeyer): K3NG keyers, and wait out their reboot
The operator's keyer is a K3NG — an Arduino running an emulation of the
WinKeyer protocol — which changes the diagnosis and nearly broke it.

Checked against K3NG's own source before anything else, because yesterday's
handshake now FAILS a connect where it used to press on regardless. It is
safe: k3ng_keyer.ino implements admin echo (0x04) and echoes the byte back,
0x13 is a documented no-op, and OPTION_WINKEY_STRICT_HOST_OPEN — on by
default — ignores every byte except 0x00 before host open, so the resync
nulls are dropped harmlessly and the echo probe still gets through.

The real cause is in K3NG's options file, beside the feature itself:
"disabling Automatic Software Reset is highly recommended", and an option
to "discard errant serial port bytes at startup" for when it is not. On an
Arduino, DTR is wired to reset through a capacitor: opening the port reboots
the board into its bootloader. Ours spoke 400 ms later, to a keyer that was
not running yet.

So the retry now waits 2.5 s, which recovers it without an operator pressing
connect twice, and the engine list gains a K3NG entry that skips the doomed
fast attempt altogether. Everything else is identical to a K1EL — same
settings panel, same protocol.
2026-08-14 12:40:42 +02:00
rouggy c495dced0f chore(changelog): one entry per subject in 0.25.1
Fifteen entries for seven subjects — the block had "WinKeyer:" twice, "QSL:"
and "QSL designer:" four times between them, "US counties:" three times.
That is one line per commit, which is how it was written, not one line per
thing an operator notices. Merged, in both languages.
2026-08-14 12:28:05 +02:00
rouggy 1b736272c9 fix(winkeyer): perform K1EL's full opening handshake
An operator's log showed the whole fault in its first line: "connected on
COM3 — no reply — the keyer did not answer Host Open", followed by seven
configuration commands and two calls sent as Morse. Nothing was listening.
Reporting a link as up and then writing to it regardless is the part worth
fixing; the handshake is why it was down.

K1EL's Application Interface Guide gives the sequence, and we did one step
of it. Now all of it:

  - DTR on, RTS OFF. K1EL's own init sets DTR_CONTROL_ENABLE with
    RTS_CONTROL_DISABLE, and on a serial WinKeyer those lines ARE the power
    supply — DTR feeds the 3.3 V regulator, RTS provides the negative rail.
    go.bug.st/serial defaults both to true, so we drove RTS high on every
    connect without a line of code saying so.
  - 400 ms after the lines come up, for a WK1 still booting off DTR.
  - Three 0x13 nulls to resync the command parser. A keyer left part-way
    through a command by whoever spoke to it last would absorb Host Open as
    a parameter — the everyday cause of a silent WinKeyer, and one the
    operator can do nothing about from the outside.
  - An echo test (0x00 0x04 0x55) before trusting the port at all. This is
    the step that answers "is there a keyer here", and connecting now fails
    on it, with the byte that came back when something else replied.

The whole handshake is retried once, since the first attempt's nulls are
what clear a confused parser. Tested against a fake port that reproduces
each failure: absent, mid-command, and echoing but versionless.
2026-08-14 12:07:58 +02:00
rouggy 05fc8ad80c fix(qsl): copy a design's pictures, not just its row
copyDirContents wrote into a destination it never created, so the first
os.Create returned ENOENT. The caller was written as "ignore os.IsNotExist"
— meant for a design with no pictures at all — and that guard matched the
failed write exactly: nothing was copied, no error surfaced, and validation
two lines later reported "copied design is incomplete: hero photo file
img_… not found" with no way to tell why.

Three fixes, one cause:

  - copyDirContents creates its destination;
  - the caller stats the source folder instead of pattern-matching an error,
    so a genuine copy failure is reported and rolls back both the row and
    the folder;
  - DuplicateProfile had the same defect in its own form — an INSERT … SELECT
    that cloned the template rows and left their photos behind, giving the
    new profile designs with no thumbnail and nothing to print. It now copies
    row and folder together, per template, since each needs its id first.

A test reproduces the original failure: it fails with the exact ENOENT that
was being swallowed.
2026-08-14 11:57:35 +02:00
rouggy 4e88bdfaa7 fix(qsl): separate the QSL manager from the routing method (#16)
QSL_VIA is the manager. QSL_SENT_VIA and QSL_RCVD_VIA are the ADIF "QSL Via"
enumeration — B bureau, D direct, E electronic, M manager (import-only) —
and say how a card travelled. OpsLog had one column for all three:

  - the import folded QSL_SENT_VIA into QSL_VIA whenever QSL_VIA was empty,
    which is exactly a Log4OM export (it defaults QSL_SENT_VIA to E), so
    OE6CLD saw "E" everywhere OpsLog shows the manager;
  - QSL_RCVD_VIA was listed in adifPromoted with no column behind it, so it
    was not stored, not kept among the extras, and not exported — dropped
    outright on import;
  - neither was ever written on export, so an import followed by an export
    destroyed both;
  - and OpsLog polluted the field itself: the QSL Manager panel wrote
    "Bureau" / "Direct" / "Electronic", in full words, into QSL_VIA.

Two columns added (migration 0027), carried through the five places a
promoted ADIF field has to touch, with round-trip tests pinning the reported
case. The QSL panel now offers Bureau / Direct / Electronic for each
direction and stores the enumeration; the manager field is labelled as the
manager and holds only that. M is kept when a file gives it and never
written back out.

Existing logs hold a mixture of the two in one column. The repair is offered,
not performed: the count is shown once per log with a plain question, and a
"no" is remembered. It moves only where QSL_SENT_VIA is still empty, and only
values that normalise to the enumeration — a manager is a callsign and can
never be one of those six words, which a test pins against real manager calls.
2026-08-14 11:50:25 +02:00
rouggy 30143b01bf fix(counties): make the cluster and the entry panel agree on a US county
An operator asked why K1SEI showed "Middlesex" in Info (F2) and "Lower
Connecticut River Valley" in the cluster. Two sources: the entry panel has
a callbook answer, a spot carries only a callsign so the cluster derives one
from the FCC licence ZIP through GeoNames — and GeoNames has followed the
Census in replacing Connecticut's counties with the 2022 planning regions.
No award, callbook or log uses those, so every CT station matched nothing:
new county for ever, and counting toward nothing.

Measured against a full ULS import (1 556 444 US callsigns), 23 223 resolved
to a name the USA-CA reference does not contain. Three causes, three fixes:

  - Spelling. "City and County of San Francisco", "Baltimore (city)",
    "Nome (CA)", plus counties renamed since the award list was drawn
    (Kusilvak, Oglala Lakota, the Valdez-Cordova split) and Alaska's four
    "X City and Borough", whose reference codes read "JUNEAUCITYAND" because
    the county-type suffix strip eats the wrong end. Normalised in
    award.USCountyKey, which both sides already go through. 7 515 callsigns,
    no re-download needed.

  - Doña Ana, NM shipped into the reference as "NM/DO̱AANA" — mangled by a
    non-UTF-8 CSV line, a code nothing could ever produce, so that county was
    unwinnable and silent about it. Row repaired, cntygen now refuses such a
    line, and a test makes every one of the 3 102 references reproduce its own
    code from its own name.

  - Connecticut. A planning region is drawn from towns in several counties, so
    no name maps to a name — only the ZIP can resolve it. cmd/ctzipgen builds
    the table from the Census 2020 crosswalk, filling PO-box-only ZIPs from the
    nearest resolved centroid; all 11 ZIPs GeoNames still labels with a real
    county agree with the result. 15 037 callsigns, applied at import, so the
    store now carries a rules version and Settings says when a re-download is
    needed.

Alignment itself is the last piece: a spot now shows the county the station is
logged with when we have one, and falls back to the ZIP-derived county only for
stations never worked.
2026-08-14 00:42:27 +02:00
rouggy fbd62fda30 fix(appearance): make Sahara actually differ from Warm light
The sampled colours were Warm light's own (#faf6ea / #e8dfc9 / #ddd2b8) —
the annotated screenshot was that theme, and the request written on it was
"just a little darker". Keep the cream on panels and the entry strip, take
the page, tab bar, toolbars and table headers a shade deeper, and move the
accent from burnt orange to ochre so the two are told apart in the picker.
2026-08-14 00:07:50 +02:00
rouggy ab0db0ef3a feat(appearance): Sahara theme
Built from the three sands an operator sent: #faf6eb for panels, #e8dfca
for the page behind them, #ddd2ba for toolbars, table headers and rows.
The ordering is what makes it work — a panel reads as lifted off the page
and the log grid as settled into it, so rows stay scannable without extra
rules. Ink is a deep warm brown; pure black on sand glares.
2026-08-13 18:18:52 +02:00
rouggy 380472bda8 feat(qsl): copy a card design from another profile
A second profile is usually the same operator with a different rig or a
different locator — same callsign, same cards. Having to redraw a design
because the antenna changed is work nobody should do.

The designer now lists the designs the active profile cannot see and copies a
chosen one into it. Designs it already sees — its own, and the shared ones —
are left out: they are not something to copy.

A COPY, not a move or a share. The original profile keeps its design untouched
and the duplicate is free to diverge, which it usually will: a second profile
exists because something differs, and that something often ends up on the card.

The PICTURES are copied too, and that is the part worth getting right. Stored
documents reference photos by name relative to the template's own asset folder,
so duplicating the JSON alone would point the new design at files that are not
in its folder. It is validated against its own folder afterwards, and a failure
rolls the row back rather than leaving a design whose pictures are missing.
Sharing the source's folder was the other option and a worse one: deleting
either design would then have emptied the other.
2026-08-13 18:08:14 +02:00
rouggy 4e2a5877d6 fix(rigctld): stop refusing two commands JTDX sends in Fake It
From a user's log, repeating throughout the session:

	rigctld: split off failed: cat: this radio's backend cannot set split
	rigctld: unimplemented command "X PKTUSB -1"

Both are answered with an error, and JTDX treats a rig-control error as fatal:
it abandoned a transmission 0.86 seconds into a 13.8-second frame.

"S 0" — split OFF. Fake It uses no split, and JTDX still sends this to be sure.
A backend that cannot SET split was refusing a request that was ALREADY
satisfied. It now succeeds when the rig is simplex, without touching the rig at
all. Arming still fails loudly: there the request is real and unmet, and a
client that believes it transmits up the band while the radio sits on the DX's
frequency is exactly what that refusal exists to prevent.

"X <mode>" — set_split_mode. Sent during ordinary setup, Fake It included,
where there is no second VFO to give a mode to. Accepted now: applied when a
split is in force, and a plain success otherwise, because the transmit VFO
already has that mode — it is the same VFO. get_split_mode answers to match.

Both regressions fail without the fix with the exact codes from that log,
RPRT -9 and RPRT -11.
2026-08-13 15:57:58 +02:00
rouggy d44a971acf chore(rigctld): trace the client exchange
Only PTT transitions were recorded. So when JTDX aborted a transmission
0.86 seconds into a 13.8-second frame, the log showed the abort and the Fake It
frequency restore around it — and not the command that preceded them, which is
the one thing needed to tell whether OpsLog answered something the client could
not accept.

Behind the existing CAT wire-trace switch rather than a new one: this is a line
per poll and would drown an ordinary log, and an operator chasing a rig-control
fault turns that trace on already.
2026-08-13 15:48:05 +02:00
rouggy fabd1becce fix(amp): couple a chosen GROUP, not every amplifier
The first version was a single switch meaning "command them all", and that is
wrong the moment a station has three: two SPE on a combiner and a PowerGenius
on another antenna would all go into OPERATE together, keying an amplifier that
has nothing to do with the pair.

It is now a set. Each amplifier is ticked into the group or not, the group is
stored as a list of ids, and an amplifier outside it keeps its own buttons —
which is the entire point of it being a set.

A group of fewer than two members is stored as none: one amplifier coupled to
itself would make every command fan out to a single member for ever.

A remembered member that is no longer running is skipped rather than failing
the command — deleting one amplifier must not break the button on the other.

An amplifier saved without an id cannot join, and the panel says so instead of
quietly omitting it from the list.
2026-08-13 15:24:43 +02:00
rouggy 345be94c65 fix(amp): put the coupling switch where it can be seen
It sat above the first amplifier card. With three cards configured — the case
it exists for — that is a scroll away from anything the operator is looking at,
and it was reported as missing rather than misplaced.

Moved next to Add amplifier: coupling belongs to the SET of amplifiers, not to
any one of them, so it belongs with the other set-level control.
2026-08-13 15:03:46 +02:00
rouggy d0c6e420d2 feat(amp): command linked amplifiers together for a combiner
The SPE CO1-2 combiner is an RF device: it sums two amplifiers and commands
nothing. So "combined" operation is really two amplifiers that must be held in
the same state, and one left in STANDBY while the other keys means the combiner
sees power on a single input.

A switch in Settings → Amplifier makes ON, OFF and OPERATE act on every
configured amplifier. Offered only with two or more: coupling one amplifier to
itself is a switch that cannot do anything.

The METERS stay per amplifier, deliberately. Two amps combined are still two
amps, and an operator watching for one of them to run away needs to see them
apart — a summed bar would hide exactly the fault worth catching.

Fanned out in AmpOperate/AmpPower rather than in the UI: the card and the docked
widget both call these, and a coupling built into one would be missing from the
other, which on a combiner means one amplifier keyed and one not. The clicked
amplifier goes first, so a partial failure still did what the operator asked
before it stopped.

While linked, an amplifier with no power command — a PGXL on its direct link —
is skipped silently rather than reported: it would make a successful pair look
broken.
2026-08-13 14:02:34 +02:00
rouggy f0026b8bd3 chore: open 0.25.1 2026-08-13 13:51:38 +02:00
44 changed files with 2817 additions and 18975 deletions
+63
View File
@@ -0,0 +1,63 @@
package main
import "testing"
// The coupling is a SET, not a global switch.
//
// A station can run a combiner pair AND a third amplifier that has nothing to
// do with it — two SPE on the combiner, a PowerGenius on another antenna. A
// global flag would send that third one into OPERATE alongside them.
func TestAmpTargetsFollowTheGroup(t *testing.T) {
a := &App{}
a.ampInsts = map[string]*ampInst{"spe1": {}, "spe2": {}, "pgxl": {}}
group := []string{"spe1", "spe2"}
// A member commands the whole group, itself first.
got := a.ampTargets("spe2", group)
if len(got) != 2 || got[0] != "spe2" || got[1] != "spe1" {
t.Errorf("member = %v, want [spe2 spe1] — the clicked one first", got)
}
// The amplifier OUTSIDE the group keeps its buttons to itself.
if got := a.ampTargets("pgxl", group); len(got) != 1 || got[0] != "pgxl" {
t.Errorf("outsider = %v, want just [pgxl]", got)
}
// No group at all: everyone is on their own.
if got := a.ampTargets("spe1", nil); len(got) != 1 || got[0] != "spe1" {
t.Errorf("no group = %v, want just [spe1]", got)
}
// A group remembering an amplifier that is gone must not carry it: it would
// fail the command for a member that no longer exists.
if got := a.ampTargets("spe1", []string{"spe1", "deleted"}); len(got) != 1 || got[0] != "spe1" {
t.Errorf("stale member = %v, want it dropped", got)
}
}
// One amplifier coupled to itself is not a group — storing it would make every
// command fan out to a single member for ever, which is just noise.
func TestLinkedAmpsNeedsTwo(t *testing.T) {
for _, tc := range []struct {
in []string
want int
}{
{[]string{"a", "b"}, 2},
{[]string{"a"}, 0},
{[]string{" ", "a"}, 0}, // blanks are not members
{nil, 0},
} {
clean := make([]string, 0, len(tc.in))
for _, id := range tc.in {
if id != "" && id != " " {
clean = append(clean, id)
}
}
if len(clean) < 2 {
clean = nil
}
if len(clean) != tc.want {
t.Errorf("SetLinkedAmps(%v) would keep %d, want %d", tc.in, len(clean), tc.want)
}
}
}
+277 -11
View File
@@ -289,7 +289,7 @@ const (
keyWKUsePTT = "winkeyer.use_ptt" keyWKUsePTT = "winkeyer.use_ptt"
keyWKSerialEcho = "winkeyer.serial_echo" keyWKSerialEcho = "winkeyer.serial_echo"
keyWKMacros = "winkeyer.macros" // JSON array of {label,text} keyWKMacros = "winkeyer.macros" // JSON array of {label,text}
keyWKEngine = "winkeyer.engine" // "winkeyer" | "serial" | "icom" | "flex" | "tci" keyWKEngine = "winkeyer.engine" // "winkeyer" | "k3ng" | "serial" | "icom" | "flex" | "tci"
keyWKEscClears = "winkeyer.esc_clears_call" // ESC also clears the callsign keyWKEscClears = "winkeyer.esc_clears_call" // ESC also clears the callsign
keyWKSendOnType = "winkeyer.send_on_type" // key characters live as typed keyWKSendOnType = "winkeyer.send_on_type" // key characters live as typed
keyWKEsm = "winkeyer.esm" // Enter-Sends-Message (N1MM-style CW flow) keyWKEsm = "winkeyer.esm" // Enter-Sends-Message (N1MM-style CW flow)
@@ -1326,6 +1326,7 @@ func (a *App) startup(ctx context.Context) {
} }
}, },
) )
a.winkeyer.OnSlowBoot(a.noteKeyerSlowBoot)
// Live space-weather (solar flux, sunspots, A/K indices) for the header strip // Live space-weather (solar flux, sunspots, A/K indices) for the header strip
// and per-QSO stamping. Refreshes in the background; pushes a UI event on each // and per-QSO stamping. Refreshes in the background; pushes a UI event on each
@@ -1704,6 +1705,13 @@ func copyDirContents(src, dst string) error {
if err != nil { if err != nil {
return err return err
} }
// Create the destination BEFORE writing into it. Without this the first
// os.Create failed with ENOENT, which reads as "not exist" — indistinguishable
// from a source folder that simply has nothing to copy, and callers testing
// for that swallowed it.
if err := os.MkdirAll(dst, 0o755); err != nil {
return err
}
for _, e := range entries { for _, e := range entries {
srcPath := filepath.Join(src, e.Name()) srcPath := filepath.Join(src, e.Name())
dstPath := filepath.Join(dst, e.Name()) dstPath := filepath.Join(dst, e.Name())
@@ -6223,8 +6231,12 @@ func (a *App) BulkUpdateQSL(ids []int64, u QSLBulkUpdate) (int, error) {
if v := strings.TrimSpace(u.RcvdDate); v != "" { if v := strings.TrimSpace(u.RcvdDate); v != "" {
q.QSLRcvdDate, changed = v, true q.QSLRcvdDate, changed = v, true
} }
if v := strings.TrimSpace(u.Via); v != "" { // The QSL Manager panel's "Via" is a routing method (bureau / direct /
q.QSLVia, changed = v, true // electronic), so it belongs in QSL_SENT_VIA, not in QSL_VIA — which
// holds the manager. It used to write the manager field, in full words,
// which is half of why the two got mixed up in operators' logs.
if v := adif.NormaliseQSLVia(u.Via); v != "" {
q.QSLSentVia, changed = v, true
} }
if v := strings.TrimSpace(u.Notes); v != "" { if v := strings.TrimSpace(u.Notes); v != "" {
q.Notes, changed = v, true q.Notes, changed = v, true
@@ -6255,6 +6267,8 @@ var bulkFieldColumns = map[string]string{
"qsl_sent": "qsl_sent", "qsl_sent": "qsl_sent",
"qsl_rcvd": "qsl_rcvd", "qsl_rcvd": "qsl_rcvd",
"qsl_via": "qsl_via", "qsl_via": "qsl_via",
"qsl_sent_via": "qsl_sent_via",
"qsl_rcvd_via": "qsl_rcvd_via",
"qrz_sent": "qrzcom_qso_upload_status", "qrz_sent": "qrzcom_qso_upload_status",
"qrz_rcvd": "qrzcom_qso_download_status", "qrz_rcvd": "qrzcom_qso_download_status",
"clublog_sent": "clublog_qso_upload_status", "clublog_sent": "clublog_qso_upload_status",
@@ -10495,6 +10509,11 @@ func (a *App) applyULSCounty(q *qso.QSO) {
type ULSStatusResult struct { type ULSStatusResult struct {
Count int `json:"count"` Count int `json:"count"`
UpdatedAt string `json:"updated_at"` // RFC3339, empty if never downloaded UpdatedAt string `json:"updated_at"` // RFC3339, empty if never downloaded
// NeedsRefresh is set when the stored data predates a correction to how a
// callsign's county is derived. The download date alone cannot show this —
// a database fetched yesterday by an older OpsLog still holds the wrong
// Connecticut counties — so it is reported as its own flag.
NeedsRefresh bool `json:"needs_refresh"`
} }
// ULSStatus returns the state of the offline US county database. // ULSStatus returns the state of the offline US county database.
@@ -10506,7 +10525,7 @@ func (a *App) ULSStatus() ULSStatusResult {
if t := a.uls.UpdatedAt(); !t.IsZero() { if t := a.uls.UpdatedAt(); !t.IsZero() {
updated = t.Format(time.RFC3339) updated = t.Format(time.RFC3339)
} }
return ULSStatusResult{Count: a.uls.Count(), UpdatedAt: updated} return ULSStatusResult{Count: a.uls.Count(), UpdatedAt: updated, NeedsRefresh: a.uls.NeedsRefresh()}
} }
// DownloadULSCounties downloads and (re)builds the offline US county database in // DownloadULSCounties downloads and (re)builds the offline US county database in
@@ -10539,6 +10558,84 @@ func (a *App) DownloadULSCounties() error {
} }
// BackfillUSCountiesResult summarises a bulk county/grid backfill over the log. // BackfillUSCountiesResult summarises a bulk county/grid backfill over the log.
// ── QSL routing repair (QSL_VIA vs QSL_SENT_VIA) ───────────────────────
// keyQSLViaRepairDone records that the operator has answered the offer to move
// routing words out of the manager field — whether they accepted or declined.
// Asked once per log, never again: a repair that keeps proposing itself after a
// "no" is a nag, and the answer does not change.
const keyQSLViaRepairDone = "migr.qsl_via_routing.v1"
// QSLViaRepairStatus counts the QSOs whose QSL_VIA holds a routing method
// rather than a manager, so the operator can be shown a number before anything
// is touched.
type QSLViaRepairStatus struct {
Affected int `json:"affected"` // QSOs holding a routing word in qsl_via
Asked bool `json:"asked"` // the offer has already been answered
}
// QSLViaRepairStatus reports whether this log needs the QSL_VIA repair.
//
// OpsLog wrote "Bureau" / "Direct" / "Electronic" into QSL_VIA from its own QSL
// Manager panel, and ADIF imports folded QSL_SENT_VIA there as well, so the
// manager column in an existing log is a mixture of the two. Moving them is
// safe — a manager is a callsign and can never be one of those words — but it
// rewrites what an operator sees in their own log, so it is counted and offered
// rather than done.
func (a *App) QSLViaRepairStatus() (QSLViaRepairStatus, error) {
var res QSLViaRepairStatus
if a.qso == nil {
return res, fmt.Errorf("db not initialized")
}
if a.settings != nil {
if v, _ := a.settings.GetGlobal(a.ctx, keyQSLViaRepairDone); v == "1" {
res.Asked = true
}
}
n, err := a.qso.CountQSLViaRouting(a.ctx, adif.IsQSLViaRouting)
if err != nil {
return res, err
}
res.Affected = n
return res, nil
}
// QSLViaRepairResult reports what the repair moved.
type QSLViaRepairResult struct {
Moved int `json:"moved"`
}
// RepairQSLVia moves routing words out of QSL_VIA into QSL_SENT_VIA.
//
// Only where QSL_SENT_VIA is still empty: a QSO that already carries a real
// sent-via — from an import made after this was fixed — knows better than a
// word left in the manager column, and must not be overwritten by it.
func (a *App) RepairQSLVia() (QSLViaRepairResult, error) {
var res QSLViaRepairResult
if a.qso == nil {
return res, fmt.Errorf("db not initialized")
}
n, err := a.qso.RepairQSLViaRouting(a.ctx, adif.NormaliseQSLVia)
res.Moved = n
if err != nil {
return res, err
}
a.markQSLViaRepairAsked()
applog.Printf("qsl via repair: moved %d routing values out of the manager field", n)
return res, nil
}
// DismissQSLViaRepair records a "no" so the offer is not made again.
func (a *App) DismissQSLViaRepair() {
a.markQSLViaRepairAsked()
}
func (a *App) markQSLViaRepairAsked() {
if a.settings != nil {
_ = a.settings.SetGlobal(a.ctx, keyQSLViaRepairDone, "1")
}
}
// keyDistanceBackfilled marks the one-time distance fill as done. // keyDistanceBackfilled marks the one-time distance fill as done.
// //
// A migration, not a setting. It was briefly a button in Preferences, which was // A migration, not a setting. It was briefly a button in Preferences, which was
@@ -14010,10 +14107,7 @@ func (a *App) DuplicateProfile(id int64, newName string) (profile.Profile, error
applog.Printf("duplicate profile: copy operating: %v", err) applog.Printf("duplicate profile: copy operating: %v", err)
} }
} }
if _, err := a.db.ExecContext(a.ctx, if err := a.copyQSLTemplatesToProfile(id, p.ID); err != nil {
`INSERT INTO qsl_templates (name, profile_id, json, is_default, created_at, updated_at)
SELECT name, ?, json, is_default, created_at, updated_at
FROM qsl_templates WHERE profile_id = ?`, p.ID, id); err != nil {
applog.Printf("duplicate profile: copy qsl templates: %v", err) applog.Printf("duplicate profile: copy qsl templates: %v", err)
} }
return p, nil return p, nil
@@ -16226,6 +16320,19 @@ func (a *App) ampInstByID(id string) *ampInst {
// AmpOperate puts the given amp in OPERATE (true) or STANDBY (false). // AmpOperate puts the given amp in OPERATE (true) or STANDBY (false).
func (a *App) AmpOperate(id string, on bool) error { func (a *App) AmpOperate(id string, on bool) error {
// Fan out here rather than in the UI: the card and the docked widget both
// call this, and a coupling implemented in one of them would be missing from
// the other — which on a combiner means one amplifier keyed and one not.
var firstErr error
for _, tid := range a.ampTargets(id, a.GetLinkedAmps()) {
if err := a.ampOperateOne(tid, on); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func (a *App) ampOperateOne(id string, on bool) error {
inst := a.ampInstByID(id) inst := a.ampInstByID(id)
if inst == nil { if inst == nil {
return fmt.Errorf("amplifier not running — check Settings → Amplifier") return fmt.Errorf("amplifier not running — check Settings → Amplifier")
@@ -16250,6 +16357,18 @@ func (a *App) AmpPower(id string, on bool) (err error) {
applog.Printf("amp %s: power %v failed: %v", id, on, err) applog.Printf("amp %s: power %v failed: %v", id, on, err)
} }
}() }()
linked := a.GetLinkedAmps()
targets := a.ampTargets(id, linked)
var firstErr error
for _, tid := range targets {
if e := a.ampPowerOne(tid, on, len(targets) > 1); e != nil && firstErr == nil {
firstErr = e
}
}
return firstErr
}
func (a *App) ampPowerOne(id string, on, linked bool) error {
inst := a.ampInstByID(id) inst := a.ampInstByID(id)
if inst == nil { if inst == nil {
return fmt.Errorf("amplifier not running — check Settings → Amplifier") return fmt.Errorf("amplifier not running — check Settings → Amplifier")
@@ -16266,6 +16385,12 @@ func (a *App) AmpPower(id string, on bool) (err error) {
} }
return inst.acom.PowerOff() return inst.acom.PowerOff()
} }
// Not an error worth surfacing when linked: a PGXL alongside two SPEs simply
// has no power command on its direct link, and reporting that as a failure
// would make a successful pair look broken.
if linked {
return nil
}
return fmt.Errorf("power on/off is not available for this amplifier") return fmt.Errorf("power on/off is not available for this amplifier")
} }
@@ -16553,6 +16678,43 @@ func (a *App) CIVTraceEnabled() bool { return cat.CIVTraceEnabled() }
// WinkeyerTraceEnabled — same, for the keyer trace. // WinkeyerTraceEnabled — same, for the keyer trace.
func (a *App) WinkeyerTraceEnabled() bool { return winkeyer.TraceEnabled() } func (a *App) WinkeyerTraceEnabled() bool { return winkeyer.TraceEnabled() }
// keyerSlowBootKey names the "this port holds a keyer that reboots when opened"
// flag. GLOBAL, not per profile: which keyer is plugged into COM3 is a property
// of the computer, and an operator who switches profiles for a different rig has
// not changed the keyer on the desk.
func keyerSlowBootKey(port string) string {
return "winkeyer.slow_boot." + strings.ToUpper(strings.TrimSpace(port))
}
// keyerNeedsSlowBoot reports whether this port has already been seen to need the
// long opening wait.
//
// A K3NG keyer — an Arduino running the WinKeyer protocol — reboots when the
// port opens, because DTR is wired to its reset pin. The first connect finds
// that out by failing the quick attempt and succeeding on the slow one; every
// connect afterwards reads this and goes straight to the slow one.
//
// Learnt rather than configured. The alternative was a "K3NG" entry beside
// "WinKeyer" in the engine list, which would name one clone among many — WKmini,
// home-built Arduinos, unbranded boxes — and leave every other operator guessing
// which of the two to pick for hardware that speaks exactly the same protocol.
func (a *App) keyerNeedsSlowBoot(port string) bool {
if a.settings == nil || strings.TrimSpace(port) == "" {
return false
}
v, _ := a.settings.GetGlobal(a.ctx, keyerSlowBootKey(port))
return v == "1"
}
// noteKeyerSlowBoot records that this port's keyer needed the long wait.
func (a *App) noteKeyerSlowBoot(port string) {
if a.settings == nil || strings.TrimSpace(port) == "" {
return
}
_ = a.settings.SetGlobal(a.ctx, keyerSlowBootKey(port), "1")
applog.Printf("winkeyer: %s remembered as a slow-booting keyer — it will open directly next time", port)
}
// WinkeyerConnect opens the serial link using the saved config. // WinkeyerConnect opens the serial link using the saved config.
func (a *App) WinkeyerConnect() error { func (a *App) WinkeyerConnect() error {
if a.winkeyer == nil { if a.winkeyer == nil {
@@ -16568,6 +16730,7 @@ func (a *App) WinkeyerConnect() error {
if s.Engine == "serial" { if s.Engine == "serial" {
cfg.Type = "serial" cfg.Type = "serial"
} }
cfg.SlowBoot = a.keyerNeedsSlowBoot(cfg.Port)
return a.winkeyer.Connect(cfg) return a.winkeyer.Connect(cfg)
} }
@@ -17093,6 +17256,10 @@ type clusterStatusCache struct {
workedCalls map[string]struct{} workedCalls map[string]struct{}
workedCallSlots map[string]struct{} // nil unless the "same slot" option is on workedCallSlots map[string]struct{} // nil unless the "same slot" option is on
workedCounties map[string]struct{} workedCounties map[string]struct{}
// callCounties holds callsign → "STATE,County" for stations already logged
// with a county, so a spot shows the county the entry panel showed rather
// than the one derived from the licence ZIP. See qso.CallCounties.
callCounties map[string]string
workedPOTA map[string]struct{} workedPOTA map[string]struct{}
workedPfx map[string]struct{} workedPfx map[string]struct{}
workedGrids map[string]struct{} // "GRID|MODE", mode normalised like the rest workedGrids map[string]struct{} // "GRID|MODE", mode normalised like the rest
@@ -17161,6 +17328,7 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
// Orthogonal dimensions: worked US counties (for the ULS callsign→county // Orthogonal dimensions: worked US counties (for the ULS callsign→county
// lookup) and worked POTA parks. // lookup) and worked POTA parks.
c.workedCounties, _ = a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey) c.workedCounties, _ = a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
c.callCounties, _ = a.qso.CallCounties(a.ctx)
c.workedPOTA, _ = a.qso.WorkedPOTARefs(a.ctx) c.workedPOTA, _ = a.qso.WorkedPOTARefs(a.ctx)
// One more DISTINCT scan when the snapshot is rebuilt, then pure map lookups // One more DISTINCT scan when the snapshot is rebuilt, then pure map lookups
// per spot — the same shape as the county and POTA sets beside it, which is // per spot — the same shape as the county and POTA sets beside it, which is
@@ -17437,9 +17605,27 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
} }
} }
} }
// NEW COUNTY: resolve the callsign's home county from the offline ULS // NEW COUNTY. Two sources, better one first:
// store (US only; inert until downloaded) and flag if never worked. //
if a.uls != nil { // 1. the county this station was logged with — a callbook's answer,
// the same one the entry panel shows;
// 2. the offline ULS store, which derives a county from the licence
// ZIP (US only; inert until downloaded, ~98% for fixed stations).
//
// Preferring the log is what keeps the cluster column and the entry
// panel from disagreeing about the same callsign, which was impossible
// to explain and made both look wrong.
if cnty, ok := idx.callCounties[q.Call]; ok {
st, name, _ := strings.Cut(cnty, ",")
out[i].State, out[i].County = st, name
// Logged means worked, so this can never be a new county — but say
// so through the same key the flag below uses, not by assumption.
if key := award.USCountyKey(st, name); key != "" {
if _, done := workedCounties[key]; !done {
out[i].NewCounty = true
}
}
} else if a.uls != nil {
if loc, ok := a.uls.Resolve(q.Call); ok { if loc, ok := a.uls.Resolve(q.Call); ok {
out[i].County, out[i].State = loc.County, loc.State out[i].County, out[i].State = loc.County, loc.State
if key := award.USCountyKey(loc.State, loc.County); key != "" { if key := award.USCountyKey(loc.State, loc.County); key != "" {
@@ -17810,3 +17996,83 @@ func (a *App) SetCompactHeight(h int) {
wruntime.WindowSetMinSize(a.ctx, 900, h) wruntime.WindowSetMinSize(a.ctx, 900, h)
wruntime.WindowSetSize(a.ctx, w, h) wruntime.WindowSetSize(a.ctx, w, h)
} }
// keyAmpsLinked couples the configured amplifiers so ON, OFF and OPERATE act on
// all of them at once — the SPE CO1-2 combiner case.
//
// The combiner is an RF device: it sums two amplifiers and commands nothing. So
// "combined" operation is really two amplifiers that must be kept in the same
// state, and leaving one in OPERATE while the other sits in STANDBY is exactly
// what must not happen — the combiner would see power on one input only.
const keyAmpsLinked = "amps.linked"
// GetLinkedAmps returns the ids of the amplifiers commanded together.
//
// A SET, not a global flag. A station can have a combiner pair AND a third
// amplifier that has nothing to do with it — two SPE on the combiner and a
// PowerGenius on another antenna — and a global switch would send that third
// one into OPERATE alongside them.
func (a *App) GetLinkedAmps() []string {
out := []string{}
for _, id := range strings.Split(a.settingOr(keyAmpsLinked, ""), ",") {
if id = strings.TrimSpace(id); id != "" {
out = append(out, id)
}
}
return out
}
// SetLinkedAmps records which amplifiers are coupled.
func (a *App) SetLinkedAmps(ids []string) error {
clean := make([]string, 0, len(ids))
for _, id := range ids {
if id = strings.TrimSpace(id); id != "" {
clean = append(clean, id)
}
}
// One amplifier coupled to itself is not a group; store nothing rather than
// a set that would make ampTargets fan out to a single member for ever.
if len(clean) < 2 {
clean = nil
}
a.setSetting(keyAmpsLinked, strings.Join(clean, ","))
return nil
}
// ampTargets returns the amplifier ids a command should reach: the one asked
// for, or every running amplifier when they are linked.
//
// The METERS are deliberately untouched by this — each amplifier keeps its own
// bars. Two amps combined are still two amps, and an operator watching for one
// of them to run away needs to see them apart.
func (a *App) ampTargets(id string, linked []string) []string {
inGroup := false
for _, l := range linked {
if l == id {
inGroup = true
break
}
}
// An amplifier outside the group keeps its own buttons to itself — that is
// the whole point of the group being a set.
if !inGroup {
return []string{id}
}
a.ampsMu.Lock()
defer a.ampsMu.Unlock()
out := make([]string, 0, len(linked))
// The one that was asked for goes FIRST, so a partial failure still did what
// the operator clicked before it stopped.
out = append(out, id)
for _, l := range linked {
if l == id {
continue
}
// Only amplifiers actually running: a group remembering one that has been
// deleted or switched off must not fail the whole command for it.
if _, ok := a.ampInsts[l]; ok {
out = append(out, l)
}
}
return out
}
+162
View File
@@ -748,3 +748,165 @@ func sampleQSO() qso.QSO {
QSLMsg: "TNX FB QSO — 73!", QSLMsg: "TNX FB QSO — 73!",
} }
} }
// QSLForeignTemplate is one card design belonging to ANOTHER profile, offered
// for copying into the active one.
type QSLForeignTemplate struct {
ID int64 `json:"id"`
Name string `json:"name"`
ProfileID int64 `json:"profile_id"`
ProfileName string `json:"profile_name"`
}
// QSLListOtherProfileTemplates lists the designs the ACTIVE profile cannot see.
//
// A second profile is usually the same operator with a different rig or a
// different locator — same callsign, same cards. Redrawing a design because the
// antenna changed is work nobody should have to do, and the designs a profile
// already sees (its own, and the shared ones) are deliberately left out: they
// are not something to copy.
func (a *App) QSLListOtherProfileTemplates() ([]QSLForeignTemplate, error) {
if a.qslTemplates == nil || a.profiles == nil {
return nil, fmt.Errorf("db not initialized")
}
active := int64(-1)
if p, err := a.profiles.Active(a.ctx); err == nil {
active = p.ID
}
names := map[int64]string{}
if list, err := a.ListProfiles(); err == nil {
for _, p := range list {
names[p.ID] = p.Name
}
}
all, err := a.qslTemplates.List(a.ctx)
if err != nil {
return nil, err
}
out := []QSLForeignTemplate{}
for _, r := range all {
// Shared designs (no profile) already appear in every profile's list.
if r.ProfileID == nil || *r.ProfileID == active {
continue
}
out = append(out, QSLForeignTemplate{
ID: r.ID, Name: r.Name, ProfileID: *r.ProfileID,
ProfileName: firstNonEmptyStr(names[*r.ProfileID], fmt.Sprintf("profile %d", *r.ProfileID)),
})
}
return out, nil
}
// QSLCopyTemplateToActiveProfile duplicates another profile's design into the
// active one and returns the new id.
//
// A COPY, not a move or a share. The original profile keeps its design
// untouched, and the copy is free to diverge — a second profile usually exists
// because something differs, and that something often ends up on the card.
func (a *App) QSLCopyTemplateToActiveProfile(id int64) (int64, error) {
if a.qslTemplates == nil {
return 0, fmt.Errorf("db not initialized")
}
src, err := a.qslTemplates.Get(a.ctx, id)
if err != nil {
return 0, err
}
// The stored document references photos by NAME, relative to the template's
// own asset folder — so copying the JSON alone would point the new design at
// files that are not in its folder, and the save would fail validation.
//
// The files are copied too, giving the duplicate its own folder. Sharing the
// source's would mean deleting one design silently emptied the other.
rec := qslcard.Record{Name: src.Name + " (copy)", JSON: src.JSON}
if p, e := a.profiles.Active(a.ctx); e == nil {
rec.ProfileID = &p.ID
}
// Saved first: a new template needs its id before it can own a folder.
if err := a.qslTemplates.Save(a.ctx, &rec); err != nil {
return 0, err
}
srcDir := qslcard.TemplateDir(a.qslDir(), id)
dstDir := qslcard.TemplateDir(a.qslDir(), rec.ID)
// A design with no pictures at all has no folder, and that is not a failure.
// Anything else IS: this used to be written as "ignore os.IsNotExist", which
// also swallowed every ENOENT raised while copying — so a failed copy looked
// like a design that had nothing to copy, and the operator got "hero photo
// not found" with no clue why.
if _, statErr := os.Stat(srcDir); statErr == nil {
if err := copyDirContents(srcDir, dstDir); err != nil {
// Roll back rather than leave a design whose pictures are missing.
_ = a.qslTemplates.Delete(a.ctx, rec.ID)
_ = qslcard.RemoveTemplateDir(a.qslDir(), rec.ID)
return 0, fmt.Errorf("copy template assets: %w", err)
}
}
// Every failure from here rolls back BOTH halves: deleting only the row would
// leave the copied pictures on disk under an id nothing refers to.
rollback := func() {
_ = a.qslTemplates.Delete(a.ctx, rec.ID)
_ = qslcard.RemoveTemplateDir(a.qslDir(), rec.ID)
}
t, err := qslcard.Parse([]byte(rec.JSON))
if err != nil {
rollback()
return 0, err
}
if err := qslcard.Validate(t, qslcard.PhotoExistsIn(dstDir)); err != nil {
rollback()
return 0, fmt.Errorf("copied design is incomplete: %w", err)
}
applog.Printf("qsl: copied template %q (id %d) into the active profile as %q (id %d)",
src.Name, id, rec.Name, rec.ID)
return rec.ID, nil
}
// copyQSLTemplatesToProfile duplicates one profile's QSL designs into another,
// PICTURES INCLUDED.
//
// This was an INSERT … SELECT, which cloned the rows and nothing else. A design
// references its photos by name, relative to its own asset folder, so every
// duplicated design pointed at a folder that did not exist: no thumbnail in the
// designer, and nothing to print. The rows looked right, which is why it went
// unnoticed — the damage is entirely on disk.
//
// Row by row rather than in one statement, because each new design needs its id
// before it can own a folder. A design whose files cannot be copied is dropped
// rather than kept empty: an operator who sees the design listed will believe
// it works.
func (a *App) copyQSLTemplatesToProfile(fromProfile, toProfile int64) error {
if a.qslTemplates == nil {
return fmt.Errorf("db not initialized")
}
all, err := a.qslTemplates.List(a.ctx)
if err != nil {
return err
}
for _, src := range all {
if src.ProfileID == nil || *src.ProfileID != fromProfile {
continue
}
rec := qslcard.Record{Name: src.Name, JSON: src.JSON, IsDefault: src.IsDefault}
to := toProfile
rec.ProfileID = &to
if err := a.qslTemplates.Save(a.ctx, &rec); err != nil {
return err
}
srcDir := qslcard.TemplateDir(a.qslDir(), src.ID)
if _, statErr := os.Stat(srcDir); statErr != nil {
continue // a design with no pictures at all — nothing to carry over
}
if err := copyDirContents(srcDir, qslcard.TemplateDir(a.qslDir(), rec.ID)); err != nil {
_ = a.qslTemplates.Delete(a.ctx, rec.ID)
_ = qslcard.RemoveTemplateDir(a.qslDir(), rec.ID)
return fmt.Errorf("copy assets of %q: %w", src.Name, err)
}
}
return nil
}
func firstNonEmptyStr(a, b string) string {
if strings.TrimSpace(a) != "" {
return a
}
return b
}
-18902
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -1,4 +1,26 @@
[ [
{
"version": "0.25.1",
"date": "",
"en": [
"Amplifiers: tick the ones sharing a combiner and ON, OFF and OPERATE act on all of them at once. Each keeps its own meters.",
"Shared CAT: JTDX and WSJT-X no longer get an error for turning split off or setting the transmit mode — which made JTDX abandon a transmission — and the wire trace now logs every exchange.",
"QSL designer: a card design can be copied from another profile, pictures included, and duplicating a profile now carries its designs pictures too instead of leaving cards that cannot be printed.",
"Appearance: new Sahara theme, warm sand tones for long sessions in daylight.",
"US counties: Connecticut returned the census planning regions, and San Francisco, Doña Ana, Baltimore city and several Alaska boroughs matched nothing at all. The cluster now shows the same county as the Info panel. Re-download the county database.",
"QSL: the manager (QSL_VIA) and the sending method (QSL_SENT_VIA / QSL_RCVD_VIA) are separate fields at last, as ADIF defines them. Imports keep both, exports write both, and a log where they were mixed is offered a correction at startup.",
"WinKeyer: the opening handshake follows K1ELs own sequence (RTS off, parser resync, echo test), which wakes keyers that stayed silent, and a port where nothing answers now says so instead of showing “connected”. A keyer that reboots when the port opens — K3NG and other Arduino builds — is waited out and remembered, so it opens directly next time."
],
"fr": [
"Amplificateurs : cochez ceux qui partagent un combineur et ON, OFF et OPERATE agissent sur tous à la fois. Chacun garde ses propres mesures.",
"CAT partagé : JTDX et WSJT-X ne reçoivent plus derreur en coupant le split ou en réglant le mode d’émission — ce qui faisait abandonner une transmission à JTDX — et la trace consigne désormais chaque échange.",
"Concepteur QSL : un design peut être copié depuis un autre profil, images comprises, et dupliquer un profil emporte aussi les images de ses designs au lieu de laisser des cartes impossibles à imprimer.",
"Apparence : nouveau thème Sahara, tons sable chauds pour les longues sessions en plein jour.",
"Comtés US : le Connecticut renvoyait les planning regions du recensement, et San Francisco, Doña Ana, Baltimore city et plusieurs districts dAlaska ne correspondaient à rien. Le cluster affiche maintenant le même comté que le panneau Info. Rechargez la base des comtés.",
"QSL : le manager (QSL_VIA) et le mode denvoi (QSL_SENT_VIA / QSL_RCVD_VIA) sont enfin deux champs distincts, comme le veut lADIF. Les imports gardent les deux, les exports les écrivent, et un log où ils étaient mélangés se voit proposer une correction au démarrage.",
"WinKeyer : la séquence douverture suit celle de K1EL (RTS bas, resynchronisation, test d’écho), ce qui réveille les manipulateurs muets, et un port où rien ne répond le dit au lieu dafficher « connecté ». Un manipulateur qui redémarre à louverture du port — K3NG et autres montages Arduino — est attendu puis mémorisé, pour souvrir directement ensuite."
]
},
{ {
"version": "0.25.0", "version": "0.25.0",
"date": "", "date": "",
+10
View File
@@ -9,6 +9,7 @@ import (
"os" "os"
"sort" "sort"
"strings" "strings"
"unicode/utf8"
"hamlog/internal/award" "hamlog/internal/award"
) )
@@ -58,6 +59,15 @@ func main() {
sc.Scan() // header sc.Scan() // header
for sc.Scan() { for sc.Scan() {
line := sc.Text() line := sc.Text()
// The FIPS CSV is not reliably UTF-8, and the one county whose name is
// not ASCII (Doña Ana, NM) came through mangled once already — it landed
// in the reference as "NM/DO̱AANA", a code no log could ever match,
// silently costing that county for every operator. Refuse the row rather
// than emit a broken one.
if !utf8.ValidString(line) {
fmt.Fprintf(os.Stderr, "cntygen: skipping non-UTF-8 line: %q\n", line)
continue
}
parts := strings.SplitN(line, ",", 3) parts := strings.SplitN(line, ",", 3)
if len(parts) < 3 { if len(parts) < 3 {
continue continue
+203
View File
@@ -0,0 +1,203 @@
package main
// One-shot generator: emits internal/uls/ctcounty_gen.go, the Connecticut
// ZIP → legal county table. Not part of the build.
//
// Why it has to exist
// -------------------
// internal/uls resolves a US callsign to a county through GeoNames' ZIP table.
// The Census replaced Connecticut's eight counties with nine PLANNING REGIONS
// as county-equivalents in 2022, and GeoNames followed: 418 of Connecticut's
// 429 ZIPs now report "Capitol Region", "Lower Connecticut River Valley" and
// so on. CQ's USA-CA award did not follow, callbooks did not follow, and no
// operator's log did either — so every Connecticut station resolved to a name
// nothing could match, showed as a new county for ever, and counted for no
// award. Roughly 15 000 US callsigns.
//
// A planning region is NOT a renamed county — it is built from towns drawn
// from several different counties — so there is no name-to-name mapping to be
// had. The ZIP is the only handle, hence this table.
//
// Sources (both public, both free)
// --------------------------------
// geonames US.txt from https://download.geonames.org/export/zip/US.zip
// — the ZIP list itself, and each ZIP's centroid.
// crosswalk https://www2.census.gov/geo/docs/maps-data/data/rel2020/
// zcta520/tab20_zcta520_county20_natl.txt
// — the 2020 ZCTA↔county relationship file, which predates the
// change and therefore still carries the eight real counties.
//
// The crosswalk covers 278 of the 418 affected ZIPs. The rest are PO-box-only
// ZIPs, which have no ZCTA at all; each is given the county of the nearest
// resolved ZIP centroid. That is sound here because a PO-box ZIP sits inside
// the town it serves, and Connecticut's counties are tens of kilometres across
// — the fallback can only err on a ZIP that straddles a county line, which the
// ZIP-to-county approach is already documented as accepting (~98%).
//
// Usage:
//
// go run ./cmd/ctzipgen US.txt tab20_zcta520_county20_natl.txt > internal/uls/ctcounty_gen.go
import (
"bufio"
"fmt"
"math"
"os"
"sort"
"strconv"
"strings"
)
type zipPt struct {
zip string
lat, lon float64
county string // from the crosswalk, "" if the ZIP has no ZCTA
geoName string // what GeoNames says today
}
func main() {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage: ctzipgen US.txt tab20_zcta520_county20_natl.txt")
os.Exit(2)
}
// 1. Every Connecticut ZIP, with its centroid, from GeoNames.
var pts []*zipPt
byZip := map[string]*zipPt{}
f, err := os.Open(os.Args[1])
must(err)
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 256*1024)
for sc.Scan() {
c := strings.Split(sc.Text(), "\t")
if len(c) < 11 || strings.ToUpper(strings.TrimSpace(c[4])) != "CT" {
continue
}
z := strings.TrimSpace(c[1])
if z == "" || byZip[z] != nil {
continue
}
p := &zipPt{zip: z, lat: atof(c[9]), lon: atof(c[10]), geoName: strings.TrimSpace(c[5])}
byZip[z] = p
pts = append(pts, p)
}
must(sc.Err())
f.Close()
// 2. The 2020 county for each ZCTA. A ZCTA can straddle a county line, so
// keep the county holding the largest share of its land area.
best := map[string]float64{}
f2, err := os.Open(os.Args[2])
must(err)
sc2 := bufio.NewScanner(f2)
sc2.Buffer(make([]byte, 0, 64*1024), 1<<20)
sc2.Scan() // header
for sc2.Scan() {
c := strings.Split(sc2.Text(), "|")
if len(c) < 18 || !strings.HasPrefix(c[9], "09") {
continue
}
p := byZip[strings.TrimSpace(c[1])]
if p == nil {
continue
}
area := atof(c[16])
if p.county != "" && area <= best[p.zip] {
continue
}
best[p.zip] = area
p.county = strings.TrimSuffix(strings.TrimSpace(c[10]), " County")
}
must(sc2.Err())
f2.Close()
// 3. Fill the PO-box-only ZIPs from the nearest ZIP the crosswalk resolved.
var anchors []*zipPt
for _, p := range pts {
if p.county != "" {
anchors = append(anchors, p)
}
}
if len(anchors) == 0 {
fmt.Fprintln(os.Stderr, "ctzipgen: crosswalk resolved nothing — wrong file?")
os.Exit(1)
}
filled := 0
for _, p := range pts {
if p.county != "" {
continue
}
nearest, bestD := "", math.MaxFloat64
for _, a := range anchors {
if d := haversine(p.lat, p.lon, a.lat, a.lon); d < bestD {
bestD, nearest = d, a.county
}
}
p.county = nearest
filled++
}
// 4. Self-check: the ZIPs GeoNames still labels with a real county must
// agree with what we derived, or the derivation is wrong.
real := map[string]bool{
"Fairfield": true, "Hartford": true, "Litchfield": true, "Middlesex": true,
"New Haven": true, "New London": true, "Tolland": true, "Windham": true,
}
checked, bad := 0, 0
for _, p := range pts {
if !real[p.geoName] {
continue
}
checked++
if p.geoName != p.county {
bad++
fmt.Fprintf(os.Stderr, "MISMATCH %s: geonames %q, derived %q\n", p.zip, p.geoName, p.county)
}
}
fmt.Fprintf(os.Stderr, "ctzipgen: %d zips, %d from crosswalk, %d by nearest; cross-check %d/%d agree\n",
len(pts), len(pts)-filled, filled, checked-bad, checked)
if bad > 0 {
os.Exit(1)
}
sort.Slice(pts, func(i, j int) bool { return pts[i].zip < pts[j].zip })
var b strings.Builder
b.WriteString("// Code generated by cmd/ctzipgen. DO NOT EDIT.\n\n")
b.WriteString("package uls\n\n")
b.WriteString("// ctCounty maps a Connecticut ZIP to its legal county.\n")
b.WriteString("//\n")
b.WriteString("// GeoNames reports Connecticut's 2022 planning regions instead, which no\n")
b.WriteString("// award, callbook or log uses. See cmd/ctzipgen for how this was built and\n")
b.WriteString("// why a name-to-name mapping cannot work.\n")
fmt.Fprintf(&b, "var ctCounty = map[string]string{\n")
for _, p := range pts {
fmt.Fprintf(&b, "\t%q: %q,\n", p.zip, p.county)
}
b.WriteString("}\n")
fmt.Print(b.String())
}
func atof(s string) float64 {
v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
return v
}
// haversine returns the great-circle distance in km. Connecticut is small
// enough that a flat approximation would do, but this costs nothing and cannot
// be wrong near the state's edges.
func haversine(lat1, lon1, lat2, lon2 float64) float64 {
const r = 6371.0
dLat := (lat2 - lat1) * math.Pi / 180
dLon := (lon2 - lon1) * math.Pi / 180
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*math.Sin(dLon/2)*math.Sin(dLon/2)
return 2 * r * math.Asin(math.Sqrt(a))
}
func must(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, "ctzipgen:", err)
os.Exit(1)
}
}
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// TestCopyDirContentsCreatesDestination pins the bug that broke copying a QSL
// design between profiles.
//
// copyDirContents wrote straight into dst without creating it, so the first
// os.Create failed with ENOENT. The caller was written as "ignore
// os.IsNotExist", meaning "the source has no assets, that is fine" — and ENOENT
// from the failed write matched it exactly. Nothing was copied, no error was
// reported, and the operator saw "copied design is incomplete: hero photo file
// … not found" from the validation two lines later.
func TestCopyDirContentsCreatesDestination(t *testing.T) {
base := t.TempDir()
src := filepath.Join(base, "src")
if err := os.MkdirAll(filepath.Join(src, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "img_25c06bda.jpg"), []byte("hero"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "sub", "back.png"), []byte("back"), 0o644); err != nil {
t.Fatal(err)
}
// dst does not exist — the case of every first copy.
dst := filepath.Join(base, "dst")
if err := copyDirContents(src, dst); err != nil {
t.Fatalf("copyDirContents: %v", err)
}
for name, want := range map[string]string{
"img_25c06bda.jpg": "hero",
"sub/back.png": "back",
} {
got, err := os.ReadFile(filepath.Join(dst, filepath.FromSlash(name)))
if err != nil {
t.Errorf("%s: %v", name, err)
continue
}
if string(got) != want {
t.Errorf("%s = %q, want %q", name, got, want)
}
}
}
// A missing SOURCE is still an error from copyDirContents itself — it is the
// caller that decides whether a design with no pictures is acceptable, and it
// can only decide that if this does not quietly succeed.
func TestCopyDirContentsMissingSource(t *testing.T) {
base := t.TempDir()
if err := copyDirContents(filepath.Join(base, "nope"), filepath.Join(base, "dst")); err == nil {
t.Fatal("copying a missing source returned no error")
} else if !os.IsNotExist(err) {
t.Fatalf("want a not-exist error, got %v", err)
}
}
+36
View File
@@ -52,6 +52,7 @@ import {
GetAmpStatuses, AmpOperate, GetAmpStatuses, AmpOperate,
GetFlexState, FlexAmpOperate, GetFlexState, FlexAmpOperate,
GetPSKReporterStatus, GetLiveOpenings, GetPSKReporterStatus, GetLiveOpenings,
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
} from '../wailsjs/go/main/App'; } from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs'; import { applyAwardRefs } from '@/lib/awardRefs';
@@ -1793,6 +1794,20 @@ export default function App() {
// close so the next plain "Preferences" launch reverts to default. // close so the next plain "Preferences" launch reverts to default.
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined); const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
const [showDeleteAll, setShowDeleteAll] = useState(false); const [showDeleteAll, setShowDeleteAll] = useState(false);
// How many QSOs hold a QSL routing word where the manager belongs — null when
// there is nothing to offer, or the operator has already answered. Asked once
// per log, a moment after startup so it does not race the first paint.
const [qslViaRepair, setQslViaRepair] = useState<number | null>(null);
useEffect(() => {
let alive = true;
const id = window.setTimeout(async () => {
try {
const s: any = await QSLViaRepairStatus();
if (alive && !s?.asked && (s?.affected ?? 0) > 0) setQslViaRepair(s.affected);
} catch { /* a log we cannot query yet will be offered next start */ }
}, 4000);
return () => { alive = false; window.clearTimeout(id); };
}, []);
const [showAbout, setShowAbout] = useState(false); const [showAbout, setShowAbout] = useState(false);
// "What's new": the changelog for the version(s) since the operator last ran, // "What's new": the changelog for the version(s) since the operator last ran,
// shown once on the first launch after an update (EN/FR per the UI language). // shown once on the first launch after an update (EN/FR per the UI language).
@@ -7275,6 +7290,27 @@ export default function App() {
/> />
); );
})()} })()}
{/* One-time offer to move QSL routing words out of the manager field.
Shown with a count and never acted on without an answer: it rewrites
what the operator sees in their own log. Either answer is final
asking again after a "no" would be a nag. */}
{qslViaRepair !== null && (
<ConfirmDialog
title={t('qslvia.title')}
message={t('qslvia.body', { n: qslViaRepair.toLocaleString() })}
confirmLabel={t('qslvia.confirm')}
cancelLabel={t('qslvia.cancel')}
onConfirm={async () => {
const n = qslViaRepair;
setQslViaRepair(null);
try {
const r: any = await RepairQSLVia();
showToast(t('qslvia.done', { n: (r?.moved ?? n).toLocaleString() }));
} catch { /* the log keeps the reason; nothing to undo */ }
}}
onCancel={() => { setQslViaRepair(null); DismissQSLViaRepair().catch(() => {}); }}
/>
)}
{showDeleteAll && ( {showDeleteAll && (
<ConfirmDialog <ConfirmDialog
title="Delete ALL QSOs?" title="Delete ALL QSOs?"
@@ -30,6 +30,8 @@ const FIELDS: FieldDef[] = [
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' }, { id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_rcvd_date', label: 'bulk.fQslRcvdDate', group: 'QSL / upload', kind: 'date' }, { id: 'qsl_rcvd_date', label: 'bulk.fQslRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' }, { id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
{ id: 'qsl_sent_via', label: 'bulk.fQslSentVia', group: 'QSL / upload', kind: 'text' },
{ id: 'qsl_rcvd_via', label: 'bulk.fQslRcvdVia', group: 'QSL / upload', kind: 'text' },
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' }, { id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
{ id: 'lotw_sent_date', label: 'bulk.fLotwSentDate', group: 'QSL / upload', kind: 'date' }, { id: 'lotw_sent_date', label: 'bulk.fLotwSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' }, { id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
@@ -68,6 +68,8 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' }, { value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
{ value: 'qsl_rcvd_date', label: 'fltb.fQslRcvdDate', type: 'adifdate' }, { value: 'qsl_rcvd_date', label: 'fltb.fQslRcvdDate', type: 'adifdate' },
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' }, { value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
{ value: 'qsl_sent_via', label: 'fltb.fQslSentVia', type: 'text' },
{ value: 'qsl_rcvd_via', label: 'fltb.fQslRcvdVia', type: 'text' },
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' }, { value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
{ value: 'lotw_sent_date', label: 'fltb.fLotwSentDate', type: 'adifdate' }, { value: 'lotw_sent_date', label: 'fltb.fLotwSentDate', type: 'adifdate' },
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' }, { value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
+3 -3
View File
@@ -57,9 +57,9 @@ const QSL_STATUSES = [
// QSL routing methods for the paper-QSL "Via" dropdown (was free text). // QSL routing methods for the paper-QSL "Via" dropdown (was free text).
const QSL_VIA_OPTIONS = [ const QSL_VIA_OPTIONS = [
{ v: '_', label: 'qslm.leave' }, { v: '_', label: 'qslm.leave' },
{ v: 'Bureau', label: 'qslm.viaBureau' }, { v: 'B', label: 'qslm.viaBureau' },
{ v: 'Direct', label: 'qslm.viaDirect' }, { v: 'D', label: 'qslm.viaDirect' },
{ v: 'Electronic', label: 'qslm.viaElectronic' }, { v: 'E', label: 'qslm.viaElectronic' },
]; ];
// Maps a service value → its i18n label key (only for services with // Maps a service value → its i18n label key (only for services with
+40 -5
View File
@@ -57,10 +57,12 @@ type ConfDef = {
key: string; label: string; key: string; label: string;
sent?: keyof QSOForm; rcvd?: keyof QSOForm; sent?: keyof QSOForm; rcvd?: keyof QSOForm;
sentDate?: keyof QSOForm; rcvdDate?: keyof QSOForm; sentDate?: keyof QSOForm; rcvdDate?: keyof QSOForm;
via?: keyof QSOForm; // How the card travelled, each way (ADIF QSL_SENT_VIA / QSL_RCVD_VIA). Paper
// only — the electronic channels below ARE the route.
sentVia?: keyof QSOForm; rcvdVia?: keyof QSOForm;
}; };
const CONFIRMATIONS: ConfDef[] = [ const CONFIRMATIONS: ConfDef[] = [
{ key: 'QSL', label: 'QSL (paper)', sent: 'qsl_sent', rcvd: 'qsl_rcvd', sentDate: 'qsl_sent_date', rcvdDate: 'qsl_rcvd_date', via: 'qsl_via' }, { key: 'QSL', label: 'QSL (paper)', sent: 'qsl_sent', rcvd: 'qsl_rcvd', sentDate: 'qsl_sent_date', rcvdDate: 'qsl_rcvd_date', sentVia: 'qsl_sent_via', rcvdVia: 'qsl_rcvd_via' },
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' }, { key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' }, { key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any }, { key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
@@ -216,6 +218,28 @@ function QslSelect({ value, onChange }: { value?: string; onChange: (v: string)
); );
} }
// The ADIF QSL Via enumeration. M (manager) is import-only in the standard, so
// it is not offered here — a file that arrives carrying it keeps it, and this
// list is what an operator may choose.
const QSL_VIA_CHOICES = [
{ value: '_', label: 'qedit.qslDash' },
{ value: 'B', label: 'qedit.viaBureau' },
{ value: 'D', label: 'qedit.viaDirect' },
{ value: 'E', label: 'qedit.viaElectronic' },
];
function QslViaSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
const { t } = useI18n();
return (
<Select value={value || '_'} onValueChange={(v) => onChange(v === '_' ? '' : v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{QSL_VIA_CHOICES.map((s) => <SelectItem key={s.value} value={s.value}>{t(s.label)}</SelectItem>)}
</SelectContent>
</Select>
);
}
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) { export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
const { t } = useI18n(); const { t } = useI18n();
// Use the operator's configured band/mode lists (incl. custom ones like 13cm); // Use the operator's configured band/mode lists (incl. custom ones like 13cm);
@@ -633,7 +657,12 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<Label>{t('qedit.qslMsg')}</Label> <Label>{t('qedit.qslMsg')}</Label>
<Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /> <Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} />
</div> </div>
<div><Label>{t('qedit.qslVia')}</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div> {/* The manager, and only the manager — ADIF QSL_VIA. How the
card travelled is a separate field, on the QSL Info tab. */}
<div>
<Label>{t('qedit.qslVia')}</Label>
<Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} placeholder={t('qedit.qslViaPlaceholder')} />
</div>
</div> </div>
</div> </div>
</TabsContent> </TabsContent>
@@ -734,8 +763,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</div> </div>
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></div> <div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></div>
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div> <div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div>
{def.via && ( {/* How the card travelled, each way — ADIF's QSL Via
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div> enumeration. Not the manager: that is a callsign
and lives on the Contact's details tab. */}
{def.sentVia && (
<div><Label>{t('qedit.sentVia')}</Label><QslViaSelect value={val(def.sentVia)} onChange={(v) => put(def.sentVia, v)} /></div>
)}
{def.rcvdVia && (
<div><Label>{t('qedit.rcvdVia')}</Label><QslViaSelect value={val(def.rcvdVia)} onChange={(v) => put(def.rcvdVia, v)} /></div>
)} )}
</div> </div>
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
@@ -186,6 +186,8 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 }, { group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
{ group: 'QSL', label: t('rqg.c.qsl_sent_via'), colId: 'qsl_sent_via', headerName: t('rqg.h.qsl_sent_via'), field: 'qsl_sent_via' as any, width: 110 },
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_via'), colId: 'qsl_rcvd_via', headerName: t('rqg.h.qsl_rcvd_via'), field: 'qsl_rcvd_via' as any, width: 110 },
{ group: 'QSL', label: t('rqg.c.qsl_msg'), colId: 'qsl_msg', headerName: t('rqg.c.qsl_msg'), field: 'qsl_msg' as any, width: 200 }, { group: 'QSL', label: t('rqg.c.qsl_msg'), colId: 'qsl_msg', headerName: t('rqg.c.qsl_msg'), field: 'qsl_msg' as any, width: 200 },
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 }, { group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
+46 -2
View File
@@ -52,7 +52,7 @@ import {
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile, GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
GetRelayAuto, SaveRelayAuto, GetStationDevices, GetRelayAuto, SaveRelayAuto, GetStationDevices,
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards, GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetGridCacheStatus, GetSpotTTLMinutes, SetSpotTTLMinutes, GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models'; import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -412,6 +412,7 @@ const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { bg: string; card: str
'light-warm': { bg: '#e8dfc9', card: '#faf6ea', accent: '#b8410c' }, 'light-warm': { bg: '#e8dfc9', card: '#faf6ea', accent: '#b8410c' },
'light-cool': { bg: '#f4f6f8', card: '#ffffff', accent: '#2563eb' }, 'light-cool': { bg: '#f4f6f8', card: '#ffffff', accent: '#2563eb' },
'light-sage': { bg: '#eef1ec', card: '#f8faf6', accent: '#2f855a' }, 'light-sage': { bg: '#eef1ec', card: '#f8faf6', accent: '#2f855a' },
'sahara': { bg: '#dcceab', card: '#faf6eb', accent: '#a55f14' },
'light-nordic': { bg: '#eef1f7', card: '#ffffff', accent: '#4f46e5' }, 'light-nordic': { bg: '#eef1f7', card: '#ffffff', accent: '#4f46e5' },
'dim-slate': { bg: '#343b47', card: '#3d4552', accent: '#fb923c' }, 'dim-slate': { bg: '#343b47', card: '#3d4552', accent: '#fb923c' },
'dark-warm': { bg: '#221d18', card: '#2e2820', accent: '#e07a2e' }, 'dark-warm': { bg: '#221d18', card: '#2e2820', accent: '#e07a2e' },
@@ -1274,6 +1275,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
// Amplifier list — operators can run SEVERAL amps (even two SPEs combined), // Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
// each with its own connection. Saved as a whole via SaveAmplifiers. // each with its own connection. Saved as a whole via SaveAmplifiers.
const [amps, setAmps] = useState<AmpUI[]>([]); const [amps, setAmps] = useState<AmpUI[]>([]);
const [linkedAmps, setLinkedAmps] = useState<string[]>([]);
// WinKeyer CW keyer settings + macro editor. // WinKeyer CW keyer settings + macro editor.
type WKMac = { label: string; text: string }; type WKMac = { label: string; text: string };
@@ -1487,7 +1489,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
finally { setScpBusy(false); } finally { setScpBusy(false); }
}; };
// US Counties (offline FCC ULS) — download progress arrives via events. // US Counties (offline FCC ULS) — download progress arrives via events.
const [ulsStatus, setUlsStatus] = useState<{ count: number; updated_at?: string }>({ count: 0 }); const [ulsStatus, setUlsStatus] = useState<{ count: number; updated_at?: string; needs_refresh?: boolean }>({ count: 0 });
const [ulsBusy, setUlsBusy] = useState(false); const [ulsBusy, setUlsBusy] = useState(false);
const [ulsProgress, setUlsProgress] = useState<{ stage: string; pct: number } | null>(null); const [ulsProgress, setUlsProgress] = useState<{ stage: string; pct: number } | null>(null);
const [ulsMsg, setUlsMsg] = useState<{ ok: boolean; text: string } | null>(null); const [ulsMsg, setUlsMsg] = useState<{ ok: boolean; text: string } | null>(null);
@@ -1571,6 +1573,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
(async () => { (async () => {
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ } try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
try { setChaseGrids(await GetChaseNewGrids()); } catch { /* defaults stand */ } try { setChaseGrids(await GetChaseNewGrids()); } catch { /* defaults stand */ }
try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ } try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
})(); })();
// Poll the feed while the panel is open: a live count is the only thing that // Poll the feed while the panel is open: a live count is the only thing that
@@ -3602,6 +3605,38 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Plus className="size-3.5 mr-1" /> {t('amp.add')} <Plus className="size-3.5 mr-1" /> {t('amp.add')}
</Button> </Button>
{/* WHICH amplifiers are coupled, not whether coupling is on.
A station can run a combiner pair AND a third amplifier that has
nothing to do with it two SPE on the combiner, a PowerGenius on
another antenna so a single switch would send that third one into
OPERATE alongside them. Shown from two amplifiers up. */}
{amps.length > 1 && (
<div className="border-t border-border/60 pt-3 space-y-2">
<div className="text-sm">{t('amp.linked')}</div>
<p className="text-xs text-muted-foreground">{t('amp.linkedHint')}</p>
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
{amps.filter((x) => (x.id ?? '') !== '').map((x) => {
const on = linkedAmps.includes(x.id!);
return (
<label key={x.id} className="flex items-center gap-1.5 text-sm cursor-pointer">
<Checkbox checked={on} onCheckedChange={(c) => {
const next = c ? [...linkedAmps, x.id!] : linkedAmps.filter((v) => v !== x.id);
setLinkedAmps(next);
SetLinkedAmps(next).catch(() => {});
}} />
{x.name?.trim() || x.type}
</label>
);
})}
</div>
{/* An amplifier saved without an id cannot be a member say so
rather than silently leaving it out of the list. */}
{amps.some((x) => (x.id ?? '') === '') && (
<p className="text-xs text-muted-foreground">{t('amp.linkedSaveFirst')}</p>
)}
</div>
)}
</div> </div>
</> </>
); );
@@ -6198,6 +6233,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</Button> </Button>
</div> </div>
{/* A database downloaded by an older OpsLog still holds Connecticut's
planning regions instead of its counties. The download date cannot
show that, so say it outright. */}
{loaded && ulsStatus.needs_refresh && !ulsBusy && (
<div className="text-[11px] rounded border border-warning-border bg-warning-muted text-warning-muted-foreground px-2 py-1.5 leading-relaxed">
{t('uscty.stale')}
</div>
)}
{ulsProgress && ( {ulsProgress && (
<div className="space-y-1"> <div className="space-y-1">
<div className="text-[11px] text-muted-foreground flex justify-between"> <div className="text-[11px] text-muted-foreground flex justify-between">
@@ -13,6 +13,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { import {
QSLPickPhotos, QSLGenerateProposals, QSLListTemplates, QSLGetTemplate, QSLPickPhotos, QSLGenerateProposals, QSLListTemplates, QSLGetTemplate,
QSLListOtherProfileTemplates, QSLCopyTemplateToActiveProfile,
QSLSaveTemplate, QSLSetDefaultTemplate, QSLDeleteTemplate, QSLSavePreview, QSLSaveTemplate, QSLSetDefaultTemplate, QSLDeleteTemplate, QSLSavePreview,
QSLPreviewDataURL, QSLResolvePreview, QSLStylePresets, QSLPreviewDataURL, QSLResolvePreview, QSLStylePresets,
} from '../../../wailsjs/go/main/App'; } from '../../../wailsjs/go/main/App';
@@ -59,6 +60,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [saved, setSaved] = useState<QSLTemplateInfo[]>([]); const [saved, setSaved] = useState<QSLTemplateInfo[]>([]);
const [foreign, setForeign] = useState<any[]>([]);
const [previews, setPreviews] = useState<Record<number, string>>({}); const [previews, setPreviews] = useState<Record<number, string>>({});
const [presets, setPresets] = useState<QSLPresetInfo[]>([]); const [presets, setPresets] = useState<QSLPresetInfo[]>([]);
const [fontFamilies, setFontFamilies] = useState<string[]>([]); const [fontFamilies, setFontFamilies] = useState<string[]>([]);
@@ -88,6 +90,8 @@ export function QslDesignerModal({ open, onClose }: Props) {
if (url) p[t.id] = url; if (url) p[t.id] = url;
})); }));
setPreviews(p); setPreviews(p);
// The designs this profile cannot see, offered for copying.
try { setForeign(((await QSLListOtherProfileTemplates()) ?? []) as any[]); } catch { /* nothing to offer */ }
} catch (e) { } catch (e) {
setError(String(e)); setError(String(e));
} }
@@ -274,7 +278,29 @@ export function QslDesignerModal({ open, onClose }: Props) {
</section> </section>
<section className="space-y-2"> <section className="space-y-2">
<div className="flex items-center gap-3">
<h3 className="text-sm font-semibold">Saved templates</h3> <h3 className="text-sm font-semibold">Saved templates</h3>
{/* Designs owned by another profile. A second profile is usually
the same operator with a different rig or locator — same
callsign, same cards — and redrawing one because the antenna
changed is work nobody should have to do. */}
{!!foreign.length && (
<select
value=""
onChange={async (e) => {
const id = parseInt(e.target.value, 10);
if (!id) return;
try { await QSLCopyTemplateToActiveProfile(id); await refreshSaved(); }
catch (err) { setError(String(err)); }
}}
className="h-7 rounded-md border border-border bg-background px-2 text-xs">
<option value="">Copy from another profile</option>
{foreign.map((f) => (
<option key={f.id} value={f.id}>{f.profile_name} {f.name}</option>
))}
</select>
)}
</div>
{!saved.length && <p className="text-xs text-muted-foreground">None yet.</p>} {!saved.length && <p className="text-xs text-muted-foreground">None yet.</p>}
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{saved.map((t) => ( {saved.map((t) => (
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,14 +6,14 @@ import { GetUIPref } from '../../wailsjs/go/main/App';
// CSS variables in style.css key off of. 'auto' follows the OS light/dark // CSS variables in style.css key off of. 'auto' follows the OS light/dark
// preference. The choice is persisted (localStorage + portable UI pref, so it // preference. The choice is persisted (localStorage + portable UI pref, so it
// travels with the data/ folder like the language). // travels with the data/ folder like the language).
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'light-nordic' export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'light-nordic' | 'sahara'
| 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'dark-indigo' | 'dark-teal' | 'dark-plum' | 'high-contrast'; | 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'dark-indigo' | 'dark-teal' | 'dark-plum' | 'high-contrast';
// Selectable, concrete themes (excludes 'auto') in display order: lights first, // Selectable, concrete themes (excludes 'auto') in display order: lights first,
// then darks, with high-contrast last — it is an accessibility choice, not a // then darks, with high-contrast last — it is an accessibility choice, not a
// taste one, and listing it among the moods buries it. // taste one, and listing it among the moods buries it.
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [ export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
'light-warm', 'light-cool', 'light-sage', 'light-nordic', 'light-warm', 'light-cool', 'light-sage', 'light-nordic', 'sahara',
'dim-slate', 'dark-warm', 'dark-graphite', 'dark-indigo', 'dark-teal', 'dark-plum', 'dim-slate', 'dark-warm', 'dark-graphite', 'dark-indigo', 'dark-teal', 'dark-plum',
'high-contrast', 'high-contrast',
]; ];
+78 -1
View File
@@ -775,6 +775,82 @@
color-scheme: light; color-scheme: light;
} }
/* ---- Theme 1e: Sahara — Warm light, taken a step deeper --------------------
Warm light's cream is kept for panels and the entry strip (#faf6eb, the one
surface an operator stares at while typing a call), but the page behind them
and every toolbar, tab bar and table header go one shade further into the
sand. That was the whole request: same family, just a little darker, so the
cream panels lift clearly instead of blending into the page.
Ink is a deep warm brown rather than black: pure black on sand glares.
The accent moves from Warm light's burnt orange to ochre to keep the two
themes apart at a glance in the theme picker. ---------------------------- */
[data-theme="sahara"] {
--background: #dcceab; /* deep sand — the page */
--foreground: #2b2318; /* deep warm brown-ink */
--card: #faf6eb; /* lifted panel / entry strip — kept cream */
--card-foreground: #2b2318;
--popover: #faf6eb;
--popover-foreground: #2b2318;
--primary: #a55f14; /* ochre */
--primary-foreground: #fffbf2;
--secondary: #cdbb92;
--secondary-foreground: #2b2318;
--muted: #cdbb92; /* toolbars / tab bar / table headers */
--muted-foreground: #544731; /* readable on both sands */
--accent: #e9d3a6; /* warm highlight */
--accent-foreground: #6b3a06;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--destructive-muted: #fdecec;
--destructive-muted-foreground: #b91c1c;
--border: #b6a179; /* sand a step down again, so edges read */
--input: #b6a179;
--ring: #c2760c; /* amber focus ring */
--success: #15803d;
--success-foreground: #ffffff;
--success-muted: #dcf3e1;
--success-muted-foreground: #14532d;
--success-border: #86d99f;
--warning: #b45309;
--warning-foreground: #ffffff;
--warning-muted: #fbeacd;
--warning-muted-foreground: #7c3d05;
--warning-border: #e8b96a;
--caution: #a16207;
--caution-foreground: #1c1917;
--caution-muted: #f7edcb;
--caution-muted-foreground: #713f12;
--caution-border: #e0c069;
--danger: #be123c;
--danger-foreground: #ffffff;
--danger-muted: #fbe3e8;
--danger-muted-foreground: #9f1239;
--danger-border: #ee9aac;
--info: #0369a1;
--info-foreground: #ffffff;
--info-muted: #e0eef6;
--info-muted-foreground: #075985;
--info-border: #86c5e4;
/* Band/mode matrix — the app's blues kept, on a sand neutral so an empty slot
recedes into the grid instead of punching a hole in it. */
--mx-call-conf: #15803d;
--mx-call-work: #7fd6a4;
--mx-dx-conf: #3730a3;
--mx-dx-work: #a5b4fc;
--mx-none: #cdbb92;
--scrollbar-thumb: #ac9868;
--scrollbar-thumb-hover: #927d4e;
--card-shadow: 0 1px 2px rgba(43, 35, 24, 0.07), 0 0 0 1px rgba(43, 35, 24, 0.03);
color-scheme: light;
}
/* ── Data-viz palette (Statistics dashboard) ──────────────────────────────── /* ── Data-viz palette (Statistics dashboard) ────────────────────────────────
A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes
it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never
@@ -793,7 +869,8 @@
[data-theme="light-warm"], [data-theme="light-warm"],
[data-theme="light-cool"], [data-theme="light-cool"],
[data-theme="light-sage"], [data-theme="light-sage"],
[data-theme="light-nordic"] { [data-theme="light-nordic"],
[data-theme="sahara"] {
--chart-1: #2a78d6; /* blue — default single-series hue */ --chart-1: #2a78d6; /* blue — default single-series hue */
--chart-2: #1baf7a; /* aqua */ --chart-2: #1baf7a; /* aqua */
--chart-3: #eda100; /* yellow */ --chart-3: #eda100; /* yellow */
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.25.0'; export const APP_VERSION = '0.25.1';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+14
View File
@@ -176,6 +176,8 @@ export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
export function DismissAwardUpdate(arg1:string):Promise<void>; export function DismissAwardUpdate(arg1:string):Promise<void>;
export function DismissQSLViaRepair():Promise<void>;
export function DownloadAllReferenceLists():Promise<string>; export function DownloadAllReferenceLists():Promise<string>;
export function DownloadAndApplyUpdate(arg1:string):Promise<void>; export function DownloadAndApplyUpdate(arg1:string):Promise<void>;
@@ -446,6 +448,8 @@ export function GetGridCacheStatus():Promise<main.GridCacheStatus>;
export function GetIcomState():Promise<cat.IcomTXState>; export function GetIcomState():Promise<cat.IcomTXState>;
export function GetLinkedAmps():Promise<Array<string>>;
export function GetListsSettings():Promise<main.ListsSettings>; export function GetListsSettings():Promise<main.ListsSettings>;
export function GetLiveOpenings():Promise<Array<bandopen.Opening>>; export function GetLiveOpenings():Promise<Array<bandopen.Opening>>;
@@ -768,6 +772,8 @@ export function PopulateBuiltinReferences(arg1:string):Promise<number>;
export function PublishLogNow():Promise<string>; export function PublishLogNow():Promise<string>;
export function QSLCopyTemplateToActiveProfile(arg1:number):Promise<number>;
export function QSLDefaultTemplateID():Promise<number>; export function QSLDefaultTemplateID():Promise<number>;
export function QSLDeleteTemplate(arg1:number):Promise<void>; export function QSLDeleteTemplate(arg1:number):Promise<void>;
@@ -782,6 +788,8 @@ export function QSLGetEmailTemplates():Promise<main.QSLEmailTemplates>;
export function QSLGetTemplate(arg1:number):Promise<string>; export function QSLGetTemplate(arg1:number):Promise<string>;
export function QSLListOtherProfileTemplates():Promise<Array<main.QSLForeignTemplate>>;
export function QSLListTemplates():Promise<Array<main.QSLTemplateInfo>>; export function QSLListTemplates():Promise<Array<main.QSLTemplateInfo>>;
export function QSLPhotoDataURL(arg1:number,arg2:string):Promise<string>; export function QSLPhotoDataURL(arg1:number,arg2:string):Promise<string>;
@@ -802,6 +810,8 @@ export function QSLSetDefaultTemplate(arg1:number):Promise<void>;
export function QSLStylePresets():Promise<Array<main.QSLPresetInfo>>; export function QSLStylePresets():Promise<Array<main.QSLPresetInfo>>;
export function QSLViaRepairStatus():Promise<main.QSLViaRepairStatus>;
export function QSOAudioBegin():Promise<boolean>; export function QSOAudioBegin():Promise<boolean>;
export function QSOAudioCancel():Promise<void>; export function QSOAudioCancel():Promise<void>;
@@ -840,6 +850,8 @@ export function RenameLogbook(arg1:string):Promise<void>;
export function RenderEQSL(arg1:number,arg2:number):Promise<string>; export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
export function RepairQSLVia():Promise<main.QSLViaRepairResult>;
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>; export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
export function ReportLiveActivity(arg1:number,arg2:string,arg3:string):Promise<void>; export function ReportLiveActivity(arg1:number,arg2:string,arg3:string):Promise<void>;
@@ -996,6 +1008,8 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
export function SetKenwoodKeySpeed(arg1:number):Promise<void>; export function SetKenwoodKeySpeed(arg1:number):Promise<void>;
export function SetLinkedAmps(arg1:Array<string>):Promise<void>;
export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<void>; export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<void>;
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>; export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
+28
View File
@@ -294,6 +294,10 @@ export function DismissAwardUpdate(arg1) {
return window['go']['main']['App']['DismissAwardUpdate'](arg1); return window['go']['main']['App']['DismissAwardUpdate'](arg1);
} }
export function DismissQSLViaRepair() {
return window['go']['main']['App']['DismissQSLViaRepair']();
}
export function DownloadAllReferenceLists() { export function DownloadAllReferenceLists() {
return window['go']['main']['App']['DownloadAllReferenceLists'](); return window['go']['main']['App']['DownloadAllReferenceLists']();
} }
@@ -834,6 +838,10 @@ export function GetIcomState() {
return window['go']['main']['App']['GetIcomState'](); return window['go']['main']['App']['GetIcomState']();
} }
export function GetLinkedAmps() {
return window['go']['main']['App']['GetLinkedAmps']();
}
export function GetListsSettings() { export function GetListsSettings() {
return window['go']['main']['App']['GetListsSettings'](); return window['go']['main']['App']['GetListsSettings']();
} }
@@ -1478,6 +1486,10 @@ export function PublishLogNow() {
return window['go']['main']['App']['PublishLogNow'](); return window['go']['main']['App']['PublishLogNow']();
} }
export function QSLCopyTemplateToActiveProfile(arg1) {
return window['go']['main']['App']['QSLCopyTemplateToActiveProfile'](arg1);
}
export function QSLDefaultTemplateID() { export function QSLDefaultTemplateID() {
return window['go']['main']['App']['QSLDefaultTemplateID'](); return window['go']['main']['App']['QSLDefaultTemplateID']();
} }
@@ -1506,6 +1518,10 @@ export function QSLGetTemplate(arg1) {
return window['go']['main']['App']['QSLGetTemplate'](arg1); return window['go']['main']['App']['QSLGetTemplate'](arg1);
} }
export function QSLListOtherProfileTemplates() {
return window['go']['main']['App']['QSLListOtherProfileTemplates']();
}
export function QSLListTemplates() { export function QSLListTemplates() {
return window['go']['main']['App']['QSLListTemplates'](); return window['go']['main']['App']['QSLListTemplates']();
} }
@@ -1546,6 +1562,10 @@ export function QSLStylePresets() {
return window['go']['main']['App']['QSLStylePresets'](); return window['go']['main']['App']['QSLStylePresets']();
} }
export function QSLViaRepairStatus() {
return window['go']['main']['App']['QSLViaRepairStatus']();
}
export function QSOAudioBegin() { export function QSOAudioBegin() {
return window['go']['main']['App']['QSOAudioBegin'](); return window['go']['main']['App']['QSOAudioBegin']();
} }
@@ -1622,6 +1642,10 @@ export function RenderEQSL(arg1, arg2) {
return window['go']['main']['App']['RenderEQSL'](arg1, arg2); return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
} }
export function RepairQSLVia() {
return window['go']['main']['App']['RepairQSLVia']();
}
export function ReplaceAwardReferences(arg1, arg2) { export function ReplaceAwardReferences(arg1, arg2) {
return window['go']['main']['App']['ReplaceAwardReferences'](arg1, arg2); return window['go']['main']['App']['ReplaceAwardReferences'](arg1, arg2);
} }
@@ -1934,6 +1958,10 @@ export function SetKenwoodKeySpeed(arg1) {
return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1); return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1);
} }
export function SetLinkedAmps(arg1) {
return window['go']['main']['App']['SetLinkedAmps'](arg1);
}
export function SetMotorFollow(arg1, arg2, arg3) { export function SetMotorFollow(arg1, arg2, arg3) {
return window['go']['main']['App']['SetMotorFollow'](arg1, arg2, arg3); return window['go']['main']['App']['SetMotorFollow'](arg1, arg2, arg3);
} }
+50
View File
@@ -2785,6 +2785,24 @@ export namespace main {
this.data_b64 = source["data_b64"]; this.data_b64 = source["data_b64"];
} }
} }
export class QSLForeignTemplate {
id: number;
name: string;
profile_id: number;
profile_name: string;
static createFrom(source: any = {}) {
return new QSLForeignTemplate(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.profile_id = source["profile_id"];
this.profile_name = source["profile_name"];
}
}
export class QSLPresetInfo { export class QSLPresetInfo {
name: string; name: string;
label: string; label: string;
@@ -2841,6 +2859,32 @@ export namespace main {
this.updated_at = source["updated_at"]; this.updated_at = source["updated_at"];
} }
} }
export class QSLViaRepairResult {
moved: number;
static createFrom(source: any = {}) {
return new QSLViaRepairResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.moved = source["moved"];
}
}
export class QSLViaRepairStatus {
affected: number;
asked: boolean;
static createFrom(source: any = {}) {
return new QSLViaRepairStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.affected = source["affected"];
this.asked = source["asked"];
}
}
export class QSOAwardRef { export class QSOAwardRef {
code: string; code: string;
ref: string; ref: string;
@@ -3344,6 +3388,7 @@ export namespace main {
export class ULSStatusResult { export class ULSStatusResult {
count: number; count: number;
updated_at: string; updated_at: string;
needs_refresh: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new ULSStatusResult(source); return new ULSStatusResult(source);
@@ -3353,6 +3398,7 @@ export namespace main {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.count = source["count"]; this.count = source["count"];
this.updated_at = source["updated_at"]; this.updated_at = source["updated_at"];
this.needs_refresh = source["needs_refresh"];
} }
} }
export class UltrabeamSettings { export class UltrabeamSettings {
@@ -4310,6 +4356,8 @@ export namespace qso {
qsl_sent_date?: string; qsl_sent_date?: string;
qsl_rcvd_date?: string; qsl_rcvd_date?: string;
qsl_via?: string; qsl_via?: string;
qsl_sent_via?: string;
qsl_rcvd_via?: string;
qsl_msg?: string; qsl_msg?: string;
qslmsg_rcvd?: string; qslmsg_rcvd?: string;
lotw_sent?: string; lotw_sent?: string;
@@ -4449,6 +4497,8 @@ export namespace qso {
this.qsl_sent_date = source["qsl_sent_date"]; this.qsl_sent_date = source["qsl_sent_date"];
this.qsl_rcvd_date = source["qsl_rcvd_date"]; this.qsl_rcvd_date = source["qsl_rcvd_date"];
this.qsl_via = source["qsl_via"]; this.qsl_via = source["qsl_via"];
this.qsl_sent_via = source["qsl_sent_via"];
this.qsl_rcvd_via = source["qsl_rcvd_via"];
this.qsl_msg = source["qsl_msg"]; this.qsl_msg = source["qsl_msg"];
this.qslmsg_rcvd = source["qslmsg_rcvd"]; this.qslmsg_rcvd = source["qslmsg_rcvd"];
this.lotw_sent = source["lotw_sent"]; this.lotw_sent = source["lotw_sent"];
+8
View File
@@ -241,6 +241,14 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
w("QSLSDATE", q.QSLSentDate) w("QSLSDATE", q.QSLSentDate)
w("QSLRDATE", q.QSLRcvdDate) w("QSLRDATE", q.QSLRcvdDate)
w("QSL_VIA", q.QSLVia) w("QSL_VIA", q.QSLVia)
// M (manager) is import-only in the QSL Via enumeration: we keep it when a
// file gives it to us, but a file we write must not carry it.
if q.QSLSentVia != QSLViaManager {
w("QSL_SENT_VIA", q.QSLSentVia)
}
if q.QSLRcvdVia != QSLViaManager {
w("QSL_RCVD_VIA", q.QSLRcvdVia)
}
w("QSLMSG", q.QSLMsg) w("QSLMSG", q.QSLMsg)
w("QSLMSG_RCVD", q.QSLMsgRcvd) w("QSLMSG_RCVD", q.QSLMsgRcvd)
w("LOTW_QSL_SENT", q.LOTWSent) w("LOTW_QSL_SENT", q.LOTWSent)
+2 -2
View File
@@ -135,8 +135,8 @@ var Fields = []FieldDef{
{Name: "QSLSDATE", Kind: KindDate, Category: "QSL", Promoted: true}, {Name: "QSLSDATE", Kind: KindDate, Category: "QSL", Promoted: true},
{Name: "QSLRDATE", Kind: KindDate, Category: "QSL", Promoted: true}, {Name: "QSLRDATE", Kind: KindDate, Category: "QSL", Promoted: true},
{Name: "QSL_VIA", Kind: KindText, Category: "QSL", Promoted: true}, {Name: "QSL_VIA", Kind: KindText, Category: "QSL", Promoted: true},
{Name: "QSL_SENT_VIA", Kind: KindEnum, Category: "QSL"}, {Name: "QSL_SENT_VIA", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "QSL_RCVD_VIA", Kind: KindEnum, Category: "QSL"}, {Name: "QSL_RCVD_VIA", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "QSLMSG", Kind: KindText, Category: "QSL", Promoted: true}, {Name: "QSLMSG", Kind: KindText, Category: "QSL", Promoted: true},
{Name: "QSLMSG_INTL", Kind: KindText, Category: "QSL", Intl: true}, {Name: "QSLMSG_INTL", Kind: KindText, Category: "QSL", Intl: true},
{Name: "QSLMSG_RCVD", Kind: KindText, Category: "QSL", Promoted: true}, {Name: "QSLMSG_RCVD", Kind: KindText, Category: "QSL", Promoted: true},
+10 -3
View File
@@ -464,10 +464,17 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
q.QSLRcvd = rec["qsl_rcvd"] q.QSLRcvd = rec["qsl_rcvd"]
q.QSLSentDate = rec["qslsdate"] q.QSLSentDate = rec["qslsdate"]
q.QSLRcvdDate = rec["qslrdate"] q.QSLRcvdDate = rec["qslrdate"]
// QSL_VIA is the manager. QSL_SENT_VIA / QSL_RCVD_VIA are the routing
// method, an enumeration of their own.
//
// These used to be one field here: an empty QSL_VIA was filled from
// QSL_SENT_VIA, on the theory that loggers writing one meant the other.
// They do not — Log4OM defaults QSL_SENT_VIA to E, and the import put "E"
// where every panel in OpsLog shows the manager's callsign. Keeping them
// apart is also what lets an export give them back.
q.QSLVia = rec["qsl_via"] q.QSLVia = rec["qsl_via"]
if q.QSLVia == "" { // many loggers (Log4OM) write QSL_SENT_VIA instead q.QSLSentVia = NormaliseQSLVia(rec["qsl_sent_via"])
q.QSLVia = rec["qsl_sent_via"] q.QSLRcvdVia = NormaliseQSLVia(rec["qsl_rcvd_via"])
}
q.QSLMsg = rec["qslmsg"] q.QSLMsg = rec["qslmsg"]
q.QSLMsgRcvd = rec["qslmsg_rcvd"] q.QSLMsgRcvd = rec["qslmsg_rcvd"]
q.LOTWSent = rec["lotw_qsl_sent"] q.LOTWSent = rec["lotw_qsl_sent"]
+53
View File
@@ -0,0 +1,53 @@
package adif
import "strings"
// The ADIF "QSL Via" enumeration, used by QSL_SENT_VIA and QSL_RCVD_VIA. It
// says how a card travelled, and is a different thing entirely from QSL_VIA,
// which is the manager's callsign.
const (
QSLViaBureau = "B"
QSLViaDirect = "D"
QSLViaElectronic = "E"
// QSLViaManager is import-only in the standard: it may be read from another
// logger's file, never written to one. OpsLog keeps it when it arrives so
// the operator's own data is not silently altered, and NormaliseQSLVia is
// the only place that decides so.
QSLViaManager = "M"
)
// NormaliseQSLVia folds what other loggers and OpsLog's own older versions put
// in a routing field down to the ADIF enumeration.
//
// It accepts the letter, the English word, and the French one — OpsLog wrote
// "Bureau", "Direct" and "Electronic" in full for a long time, and the QSL
// Manager panel still shows those words to a French operator. Anything it does
// not recognise comes back empty rather than being passed through: this feeds
// an enumerated ADIF field, and inventing a value there breaks the file for
// every other logger that reads it.
func NormaliseQSLVia(s string) string {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "B", "BUREAU", "BURO", "VIA BUREAU":
return QSLViaBureau
case "D", "DIRECT":
return QSLViaDirect
case "E", "ELECTRONIC", "ELECTRONIQUE", "ÉLECTRONIQUE", "OQRS":
return QSLViaElectronic
case "M", "MANAGER":
return QSLViaManager
}
return ""
}
// IsQSLViaRouting reports whether a QSL_VIA value is in fact a routing method
// that ended up in the manager field.
//
// It exists for one repair: OpsLog's QSL Manager panel wrote "Bureau",
// "Direct" and "Electronic" into QSL_VIA, and imports folded QSL_SENT_VIA
// there too, so logs hold a mixture of managers and routing words in one
// column. A manager is a callsign, never one of these six words, so the test
// is exact — but it is deliberately narrow: anything else, including a manager
// whose callsign happens to be unusual, is left alone.
func IsQSLViaRouting(s string) bool {
return NormaliseQSLVia(s) != ""
}
+37
View File
@@ -0,0 +1,37 @@
package adif
import "testing"
func TestNormaliseQSLVia(t *testing.T) {
for in, want := range map[string]string{
"B": "B", "b": "B", "Bureau": "B", "BUREAU": "B", " buro ": "B",
"D": "D", "Direct": "D", "direct": "D",
"E": "E", "Electronic": "E", "électronique": "E", "OQRS": "E",
"M": "M", "Manager": "M",
// A manager's callsign is not a routing method, and neither is noise.
"M0OXO": "", "EA5GL": "", "": "", "Bureau via M0OXO": "", "X": "",
} {
if got := NormaliseQSLVia(in); got != want {
t.Errorf("NormaliseQSLVia(%q) = %q, want %q", in, got, want)
}
}
}
// The repair moves values out of the manager column. A false positive would
// erase a real manager, so the guard is worth its own test: every callsign-like
// value must be refused.
func TestIsQSLViaRoutingRefusesManagers(t *testing.T) {
for _, call := range []string{
"M0OXO", "EA5GL", "F5CWU", "DJ9ZB", "W3HNK", "IK2DUW", "N7RO",
"BUREAU M0OXO", "via bureau DL1XYZ", "QSL DIRECT ONLY",
} {
if IsQSLViaRouting(call) {
t.Errorf("%q was taken for a routing method — the repair would erase it", call)
}
}
for _, v := range []string{"B", "D", "E", "M", "Bureau", "Direct", "Electronic"} {
if !IsQSLViaRouting(v) {
t.Errorf("%q should be recognised as a routing method", v)
}
}
}
+82
View File
@@ -119,3 +119,85 @@ func renderRecord(q qso.QSO, includeApp bool) string {
bw.Flush() bw.Flush()
return buf.String() return buf.String()
} }
// TestQSLViaFieldsRoundTrip covers the case reported as issue #16: a log
// exported by another logger carries a manager in QSL_VIA and a routing method
// in QSL_SENT_VIA, and the two must stay apart.
//
// Before this, QSL_SENT_VIA was folded into QSL_VIA whenever QSL_VIA was empty
// — so a Log4OM log, which defaults QSL_SENT_VIA to E, showed "E" wherever
// OpsLog displays the manager — and QSL_RCVD_VIA was thrown away outright: it
// was listed as a promoted field with no column behind it, so it was not even
// kept among the extras. Neither was ever exported, which made an import
// followed by an export destroy both.
func TestQSLViaFieldsRoundTrip(t *testing.T) {
in := qso.QSO{
Callsign: "3B9FR", Band: "20m", Mode: "CW",
QSODate: time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC),
QSLVia: "M0OXO", // the manager
QSLSentVia: "D", // sent direct
QSLRcvdVia: "B", // came back via the bureau
}
var buf bytes.Buffer
bw := bufio.NewWriter(&buf)
bw.WriteString("<EOH>\n")
writeRecord(bw, in, true, nil)
bw.Flush()
var rec Record
if err := Parse(strings.NewReader(buf.String()), func(r Record) error { rec = r; return nil }); err != nil {
t.Fatalf("parse: %v", err)
}
out, ok := recordToQSO(rec)
if !ok {
t.Fatal("recordToQSO returned !ok")
}
for name, c := range map[string]struct{ got, want string }{
"QSL_VIA": {out.QSLVia, in.QSLVia},
"QSL_SENT_VIA": {out.QSLSentVia, in.QSLSentVia},
"QSL_RCVD_VIA": {out.QSLRcvdVia, in.QSLRcvdVia},
} {
if c.got != c.want {
t.Errorf("%s: got %q, want %q", name, c.got, c.want)
}
}
}
// TestQSLSentViaDoesNotBecomeManager pins the exact shape a Log4OM export has:
// no QSL_VIA at all, QSL_SENT_VIA defaulted to E. The manager field must come
// back empty rather than holding "E".
func TestQSLSentViaDoesNotBecomeManager(t *testing.T) {
const rec = "<CALL:5>OE6CLD<QSO_DATE:8>20260606<TIME_ON:4>1200<BAND:3>20m<MODE:2>CW" +
"<QSL_SENT_VIA:1>E<EOR>\n"
var got Record
if err := Parse(strings.NewReader("<EOH>\n"+rec), func(r Record) error { got = r; return nil }); err != nil {
t.Fatalf("parse: %v", err)
}
q, ok := recordToQSO(got)
if !ok {
t.Fatal("recordToQSO returned !ok")
}
if q.QSLVia != "" {
t.Errorf("QSL_VIA = %q — the routing method leaked into the manager field again", q.QSLVia)
}
if q.QSLSentVia != "E" {
t.Errorf("QSL_SENT_VIA = %q, want %q", q.QSLSentVia, "E")
}
}
// M is import-only in the ADIF QSL Via enumeration: keep it when given, never
// write it back out.
func TestQSLViaManagerIsImportOnly(t *testing.T) {
in := qso.QSO{
Callsign: "3B9FR", Band: "20m", Mode: "CW",
QSODate: time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC),
QSLSentVia: "M", QSLRcvdVia: "M",
}
var buf bytes.Buffer
bw := bufio.NewWriter(&buf)
writeRecord(bw, in, true, nil)
bw.Flush()
if s := buf.String(); strings.Contains(s, "QSL_SENT_VIA") || strings.Contains(s, "QSL_RCVD_VIA") {
t.Errorf("exported an import-only value:\n%s", s)
}
}
+13 -4
View File
@@ -749,9 +749,14 @@ func EmissionOf(mode string) string { return emissionOf(mode) }
// abbreviating "Saint"→"St." and hyphenating "Matanuska-Susitna". The rules: // abbreviating "Saint"→"St." and hyphenating "Matanuska-Susitna". The rules:
// - if cnty already carries "ST,County", split on the first comma; else take // - if cnty already carries "ST,County", split on the first comma; else take
// the state from the STATE column; // the state from the STATE column;
// - upper-case; drop periods and apostrophes; hyphens→space; strip a trailing // - upper-case; fold accents (the FIPS list writes "Doña Ana", callbooks and
// County/Parish/Borough/Census Area/Municipality; fold Saint(e)→St(e); // logs write "Dona Ana"); drop periods and apostrophes; hyphens→space;
// collapse whitespace; // expand the parenthetical shapes GeoNames uses for county-equivalents
// ("Baltimore (city)", "Nome (CA)") and drop its "City and County of"
// style prefixes; strip a trailing County/Parish/Borough/Census Area/
// Municipality; fold Saint(e)→St(e); collapse whitespace;
// - fold the counties renamed since the USA-CA list was drawn onto the name
// the award still uses, so a QSO there counts for something (see renamed);
// - require a 2-letter state and a non-empty county, else no match (""). // - require a 2-letter state and a non-empty county, else no match ("").
func USCountyKey(state, cnty string) string { func USCountyKey(state, cnty string) string {
s := strings.TrimSpace(cnty) s := strings.TrimSpace(cnty)
@@ -764,10 +769,11 @@ func USCountyKey(state, cnty string) string {
} else { } else {
st, co = strings.TrimSpace(state), s st, co = strings.TrimSpace(state), s
} }
co = strings.ToUpper(co) co = strings.ToUpper(foldAccents(co))
co = strings.ReplaceAll(co, ".", "") co = strings.ReplaceAll(co, ".", "")
co = strings.ReplaceAll(co, "'", "") co = strings.ReplaceAll(co, "'", "")
co = strings.ReplaceAll(co, "-", " ") co = strings.ReplaceAll(co, "-", " ")
co = expandCountyEquivalent(co)
for _, suf := range []string{" COUNTY", " PARISH", " BOROUGH", " CENSUS AREA", " MUNICIPALITY"} { for _, suf := range []string{" COUNTY", " PARISH", " BOROUGH", " CENSUS AREA", " MUNICIPALITY"} {
if strings.HasSuffix(co, suf) { if strings.HasSuffix(co, suf) {
co = strings.TrimSuffix(co, suf) co = strings.TrimSuffix(co, suf)
@@ -788,6 +794,9 @@ func USCountyKey(state, cnty string) string {
if len(st) != 2 || co == "" { if len(st) != 2 || co == "" {
return "" return ""
} }
if alias, ok := renamed[st+"/"+co]; ok {
co = alias
}
// Separator is "/", NOT ",": the QSOFIELDS matcher splits a field value on // Separator is "/", NOT ",": the QSOFIELDS matcher splits a field value on
// commas/semicolons (n-fer POTA "US-1,US-2"), which would shatter "AL,AUTAUGA" // commas/semicolons (n-fer POTA "US-1,US-2"), which would shatter "AL,AUTAUGA"
// into two non-matching tokens. The stored ADIF cnty keeps its comma; only // into two non-matching tokens. The stored ADIF cnty keeps its comma; only
+120
View File
@@ -0,0 +1,120 @@
package award
import "strings"
// Name normalisation for USCountyKey. It exists because the county a QSO
// carries and the county the USA-CA reference list carries come from different
// hands and rarely agree letter for letter:
//
// - the reference is the FIPS list ("Doña Ana County", "Baltimore city");
// - a callbook writes what the licensee typed;
// - and OpsLog's own offline resolver reads GeoNames, which spells the
// county-equivalents its own way ("Baltimore (city)", "Nome (CA)",
// "City and County of San Francisco").
//
// Measured against a full FCC ULS import (1.56 M US callsigns), the shapes
// handled here account for ~7 300 stations whose county matched nothing at all
// — they showed as a new county for ever and counted for no award.
//
// Everything here is SPELLING. A county that genuinely no longer exists under
// that name is a different problem, handled by renamed below.
// foldAccents strips the diacritics that separate a FIPS name from the ASCII
// every log and callbook actually holds. Only the letters that occur in US
// place names are listed — this is not a general Unicode folder, and it must
// not become one: silently folding arbitrary marks would let two distinct
// references collapse onto one key.
var accents = strings.NewReplacer(
"ñ", "n", "Ñ", "N",
"á", "a", "Á", "A",
"é", "e", "É", "E",
"í", "i", "Í", "I",
"ó", "o", "Ó", "O",
"ú", "u", "Ú", "U",
"ü", "u", "Ü", "U",
"ç", "c", "Ç", "C",
)
func foldAccents(s string) string {
// Fast path: US county names are ASCII with a single exception (Doña Ana),
// so the common case must not pay for the replacer.
for i := 0; i < len(s); i++ {
if s[i] >= 0x80 {
return accents.Replace(s)
}
}
return s
}
// expandCountyEquivalent rewrites GeoNames' parenthetical and prefixed spellings
// of county-equivalents into the plain FIPS form, which the rest of USCountyKey
// then normalises as usual.
//
// Input is already upper-cased with periods and hyphens removed.
//
// BALTIMORE (CITY) → BALTIMORE CITY (FIPS "Baltimore city")
// NOME (CA) → NOME CENSUS AREA (suffix stripped after)
// CITY AND COUNTY OF SAN FRANCISCO → SAN FRANCISCO
//
// The truncated "(CITY" is not a typo here: GeoNames really does ship
// "Colonial Heights (city" with the closing parenthesis missing.
func expandCountyEquivalent(co string) string {
switch {
case strings.HasSuffix(co, " (CITY)"):
co = strings.TrimSuffix(co, " (CITY)") + " CITY"
case strings.HasSuffix(co, " (CITY"):
co = strings.TrimSuffix(co, " (CITY") + " CITY"
case strings.HasSuffix(co, " (CA)"):
co = strings.TrimSuffix(co, " (CA)") + " CENSUS AREA"
}
// "City and County of X" and "City and Borough of X" are the same place as
// FIPS's bare "X" (San Francisco, Denver, Honolulu, Juneau, Sitka). Note the
// order: the longer prefixes must be tried before "CITY OF ".
for _, p := range []string{
"CITY AND COUNTY OF ",
"CITY AND BOROUGH OF ",
"MUNICIPALITY OF ",
"BOROUGH OF ",
"CITY OF ",
} {
if strings.HasPrefix(co, p) {
return strings.TrimPrefix(co, p)
}
}
return co
}
// renamed folds a county renamed or re-drawn since the CQ USA-CA list was
// published onto the name the award still counts. Keys and values are finished
// USCountyKey county parts (upper-case, no spaces), looked up as "ST/COUNTY".
//
// This deliberately does NOT track the FIPS list. USA-CA is a fixed target of
// ~3 100 counties; when a state renames one, the award does not reissue its
// list, so a QSO with the new name would otherwise match nothing and count for
// nothing. Folding it onto the old name is the only outcome that scores.
//
// Connecticut is absent on purpose. Its 2022 planning regions are not renamed
// counties: a region is drawn from towns belonging to several different
// counties, so there is no name that maps to another name. That one is resolved
// from the station's ZIP, in internal/uls.
var renamed = map[string]string{
// Wade Hampton Census Area, AK → Kusilvak Census Area (2015).
"AK/KUSILVAK": "WADEHAMPTON",
// Shannon County, SD → Oglala Lakota County (2015).
"SD/OGLALALAKOTA": "SHANNON",
// Valdez-Cordova Census Area, AK was split in two (2019). Both halves fold
// back to the parent the award still lists.
"AK/CHUGACH": "VALDEZCORDOVA",
"AK/COPPERRIVER": "VALDEZCORDOVA",
// Alaska's four "X City and Borough". The reference codes for these look
// wrong and are not: the trailing " BOROUGH" is stripped as a county type,
// leaving "JUNEAU CITY AND". Nothing else in the chain produces that shape
// — a log that simply says "Juneau", and GeoNames' "City and Borough of
// Juneau", both arrive here as "JUNEAU" and would match nothing. Rather
// than re-key four published references, bend the bare name onto them.
"AK/JUNEAU": "JUNEAUCITYAND",
"AK/SITKA": "SITKACITYAND",
"AK/WRANGELL": "WRANGELLCITYAND",
"AK/YAKUTAT": "YAKUTATCITYAND",
}
+1 -1
View File
@@ -1943,7 +1943,7 @@ func usCounties() []Ref {
ref("NM/COLFAX", "Colfax County, NM", 291), ref("NM/COLFAX", "Colfax County, NM", 291),
ref("NM/CURRY", "Curry County, NM", 291), ref("NM/CURRY", "Curry County, NM", 291),
ref("NM/DEBACA", "De Baca County, NM", 291), ref("NM/DEBACA", "De Baca County, NM", 291),
ref("NM/DO̱AANA", "Do̱a Ana County, NM", 291), ref("NM/DONAANA", "Doña Ana County, NM", 291),
ref("NM/EDDY", "Eddy County, NM", 291), ref("NM/EDDY", "Eddy County, NM", 291),
ref("NM/GRANT", "Grant County, NM", 291), ref("NM/GRANT", "Grant County, NM", 291),
ref("NM/GUADALUPE", "Guadalupe County, NM", 291), ref("NM/GUADALUPE", "Guadalupe County, NM", 291),
+118
View File
@@ -0,0 +1,118 @@
package awardref
import (
"strings"
"testing"
"unicode/utf8"
"hamlog/internal/award"
)
// TestUSCountyRefsSelfConsistent is the guard that was missing when the Doña Ana
// row shipped mangled: its code was "NM/DO̱AANA", which no log, no callbook and
// no county database could ever produce, so that county was unwinnable and
// nothing said so.
//
// Every reference must reproduce its own code from its own display name through
// award.USCountyKey — the same function cmd/cntygen used to build it. That ties
// the generated file to the matcher: change the normalisation rules and this
// fails the moment one of the 3 102 references stops matching itself.
func TestUSCountyRefsSelfConsistent(t *testing.T) {
refs := usCounties()
if len(refs) < 3000 {
t.Fatalf("only %d counties — the generated list looks truncated", len(refs))
}
for _, r := range refs {
if !utf8.ValidString(r.Name) || !utf8.ValidString(r.Code) {
t.Errorf("%s (%q): not valid UTF-8", r.Code, r.Name)
continue
}
// Name is "County Name, ST".
i := strings.LastIndex(r.Name, ", ")
if i < 0 {
t.Errorf("%s: name %q is not \"County, ST\"", r.Code, r.Name)
continue
}
name, st := r.Name[:i], r.Name[i+2:]
if got := award.USCountyKey(st, name); got != r.Code {
t.Errorf("USCountyKey(%q, %q) = %q, want %q", st, name, got, r.Code)
}
}
}
// TestUSCountyRefsUnique catches a normalisation rule loose enough to collapse
// two real counties onto one key — the failure mode that would silently merge
// them in every award total.
func TestUSCountyRefsUnique(t *testing.T) {
seen := map[string]string{}
for _, r := range usCounties() {
if prev, dup := seen[r.Code]; dup {
t.Errorf("code %s is shared by %q and %q", r.Code, prev, r.Name)
continue
}
seen[r.Code] = r.Name
}
}
// TestUSCountyResolverShapes pins the spellings OpsLog's own offline resolver
// produces (GeoNames, via internal/uls) against the reference. These are the
// real strings that were failing to match, with the number of US callsigns each
// covers in a full FCC ULS import.
func TestUSCountyResolverShapes(t *testing.T) {
valid := map[string]bool{}
for _, r := range usCounties() {
valid[r.Code] = true
}
cases := []struct {
st, county, want string
}{
{"CA", "City and County of San Francisco", "CA/SANFRANCISCO"}, // 4 217 calls
{"NM", "Doña Ana", "NM/DONAANA"}, // 1 214
{"NM", "Dona Ana", "NM/DONAANA"}, // what logs hold
{"MD", "Baltimore (city)", "MD/BALTIMORECITY"}, // 885
{"MO", "St. Louis (city)", "MO/STLOUISCITY"}, // 574
{"MO", "St. Louis", "MO/STLOUIS"}, // the county, distinct
{"AK", "Nome (CA)", "AK/NOME"}, // 252
{"AK", "Yukon-Koyukuk (CA)", "AK/YUKONKOYUKUK"}, // 84
{"AK", "City and Borough of Wrangell", "AK/WRANGELLCITYAND"}, // 27
{"AK", "Juneau", "AK/JUNEAUCITYAND"}, // a log that just says Juneau
{"AK", "Kusilvak", "AK/WADEHAMPTON"}, // renamed 2015
{"SD", "Oglala Lakota County", "SD/SHANNON"}, // renamed 2015
{"AK", "Chugach Census Area", "AK/VALDEZCORDOVA"}, // split 2019
{"AK", "Copper River Census Area", "AK/VALDEZCORDOVA"},
{"CO", "City and County of Denver", "CO/DENVER"},
{"HI", "City and County of Honolulu", "HI/HONOLULU"},
}
for _, c := range cases {
got := award.USCountyKey(c.st, c.county)
if got != c.want {
t.Errorf("USCountyKey(%q, %q) = %q, want %q", c.st, c.county, got, c.want)
continue
}
if !valid[got] {
t.Errorf("USCountyKey(%q, %q) = %q, which is not in the reference list", c.st, c.county, got)
}
}
}
// TestUSCountyKeepsVirginiaCitiesOut guards the other direction: cmd/cntygen
// drops Virginia's independent cities and Carson City on purpose, because CQ's
// USA-CA does not count them. Normalising their names must not smuggle them
// back in as some neighbouring county.
func TestUSCountyKeepsVirginiaCitiesOut(t *testing.T) {
valid := map[string]bool{}
for _, r := range usCounties() {
valid[r.Code] = true
}
for _, c := range []string{"Virginia Beach (city)", "City of Alexandria", "Lynchburg (city)"} {
if k := award.USCountyKey("VA", c); valid[k] {
t.Errorf("%q resolved to %s, which USA-CA does not count", c, k)
}
}
// Charles City and James City ARE counties, despite the name.
for _, c := range []string{"Charles City", "James City"} {
if k := award.USCountyKey("VA", c); !valid[k] {
t.Errorf("%q resolved to %s, which is missing from the reference", c, k)
}
}
}
@@ -0,0 +1,18 @@
-- QSL_SENT_VIA / QSL_RCVD_VIA — the ADIF fields that say HOW a card travelled.
--
-- Until now OpsLog had one column, qsl_via, and used it for two unrelated
-- things: the QSL manager (what ADIF's QSL_VIA holds) and the routing method.
-- Imports made it worse — QSL_SENT_VIA was folded into qsl_via when qsl_via was
-- empty, so a Log4OM log arrived with "E" sitting where the manager belongs —
-- and QSL_RCVD_VIA was dropped outright, listed as a promoted field with no
-- column behind it, so it was not even kept among the extras.
--
-- These two columns hold the ADIF enumeration: B (bureau), D (direct),
-- E (electronic). M (manager) is accepted on import only, per the standard.
--
-- Adding the columns is all that happens here. Existing qsl_via values are NOT
-- touched: separating a manager from a routing word rewrites what an operator
-- can see in their own log, so it is offered once, with a count, and only runs
-- when they say yes.
ALTER TABLE qso ADD COLUMN qsl_sent_via TEXT;
ALTER TABLE qso ADD COLUMN qsl_rcvd_via TEXT;
+139 -6
View File
@@ -99,7 +99,9 @@ type QSO struct {
QSLRcvd string `json:"qsl_rcvd,omitempty"` QSLRcvd string `json:"qsl_rcvd,omitempty"`
QSLSentDate string `json:"qsl_sent_date,omitempty"` QSLSentDate string `json:"qsl_sent_date,omitempty"`
QSLRcvdDate string `json:"qsl_rcvd_date,omitempty"` QSLRcvdDate string `json:"qsl_rcvd_date,omitempty"`
QSLVia string `json:"qsl_via,omitempty"` QSLVia string `json:"qsl_via,omitempty"` // ADIF QSL_VIA — the QSL manager
QSLSentVia string `json:"qsl_sent_via,omitempty"` // ADIF enumeration B/D/E — how the card was sent
QSLRcvdVia string `json:"qsl_rcvd_via,omitempty"` // same enumeration, for the card received
QSLMsg string `json:"qsl_msg,omitempty"` QSLMsg string `json:"qsl_msg,omitempty"`
QSLMsgRcvd string `json:"qslmsg_rcvd,omitempty"` QSLMsgRcvd string `json:"qslmsg_rcvd,omitempty"`
@@ -246,7 +248,7 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
grid, gridsquare_ext, vucc_grids, grid, gridsquare_ext, vucc_grids,
country, state, cnty, dxcc, cont, cqz, ituz, iota, sota_ref, pota_ref, country, state, cnty, dxcc, cont, cqz, ituz, iota, sota_ref, pota_ref,
age, lat, lon, rig, ant, age, lat, lon, rig, ant,
qsl_sent, qsl_rcvd, qsl_sent_date, qsl_rcvd_date, qsl_via, qsl_msg, qslmsg_rcvd, qsl_sent, qsl_rcvd, qsl_sent_date, qsl_rcvd_date, qsl_via, qsl_sent_via, qsl_rcvd_via, qsl_msg, qslmsg_rcvd,
lotw_sent, lotw_rcvd, lotw_sent_date, lotw_rcvd_date, lotw_sent, lotw_rcvd, lotw_sent_date, lotw_rcvd_date,
eqsl_sent, eqsl_rcvd, eqsl_sent_date, eqsl_rcvd_date, eqsl_sent, eqsl_rcvd, eqsl_sent_date, eqsl_rcvd_date,
clublog_qso_upload_date, clublog_qso_upload_status, clublog_qso_upload_date, clublog_qso_upload_status,
@@ -321,7 +323,7 @@ func (q *QSO) args() []any {
q.Grid, q.GridExt, q.VUCCGrids, q.Grid, q.GridExt, q.VUCCGrids,
q.Country, q.State, q.County, q.DXCC, q.Continent, q.CQZ, q.ITUZ, q.IOTA, q.SOTARef, q.POTARef, q.Country, q.State, q.County, q.DXCC, q.Continent, q.CQZ, q.ITUZ, q.IOTA, q.SOTARef, q.POTARef,
q.Age, q.Lat, q.Lon, q.Rig, q.Ant, q.Age, q.Lat, q.Lon, q.Rig, q.Ant,
q.QSLSent, q.QSLRcvd, q.QSLSentDate, q.QSLRcvdDate, q.QSLVia, q.QSLMsg, q.QSLMsgRcvd, q.QSLSent, q.QSLRcvd, q.QSLSentDate, q.QSLRcvdDate, q.QSLVia, q.QSLSentVia, q.QSLRcvdVia, q.QSLMsg, q.QSLMsgRcvd,
q.LOTWSent, q.LOTWRcvd, q.LOTWSentDate, q.LOTWRcvdDate, q.LOTWSent, q.LOTWRcvd, q.LOTWSentDate, q.LOTWRcvdDate,
q.EQSLSent, q.EQSLRcvd, q.EQSLSentDate, q.EQSLRcvdDate, q.EQSLSent, q.EQSLRcvd, q.EQSLSentDate, q.EQSLRcvdDate,
q.ClublogUploadDate, q.ClublogUploadStatus, q.ClublogUploadDate, q.ClublogUploadStatus,
@@ -771,6 +773,8 @@ var bulkEditableCols = map[string]bool{
"qsl_sent": true, "qsl_sent": true,
"qsl_rcvd": true, "qsl_rcvd": true,
"qsl_via": true, "qsl_via": true,
"qsl_sent_via": true,
"qsl_rcvd_via": true,
"qrzcom_qso_upload_status": true, "qrzcom_qso_upload_status": true,
"qrzcom_qso_download_status": true, "qrzcom_qso_download_status": true,
"clublog_qso_upload_status": true, "clublog_qso_upload_status": true,
@@ -1250,7 +1254,7 @@ var filterableColumns = map[string]bool{
"grid": true, "country": true, "state": true, "cnty": true, "grid": true, "country": true, "state": true, "cnty": true,
"dxcc": true, "cont": true, "cqz": true, "ituz": true, "dxcc": true, "cont": true, "cqz": true, "ituz": true,
"iota": true, "sota_ref": true, "pota_ref": true, "wwff_ref": true, "rig": true, "ant": true, "iota": true, "sota_ref": true, "pota_ref": true, "wwff_ref": true, "rig": true, "ant": true,
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true, "qsl_sent": true, "qsl_rcvd": true, "qsl_via": true, "qsl_sent_via": true, "qsl_rcvd_via": true,
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true, "lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
"qrzcom_qso_upload_status": true, "qrzcom_qso_download_status": true, "qrzcom_qso_upload_status": true, "qrzcom_qso_download_status": true,
"clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true, "clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
@@ -2265,6 +2269,132 @@ func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty stri
return out, rows.Err() return out, rows.Err()
} }
// CountQSLViaRouting counts the QSOs whose qsl_via holds a routing method
// instead of a manager, per isRouting.
//
// The test is applied in Go rather than in SQL because it has to hold the same
// vocabulary as the import and the QSL panel — one list of accepted spellings,
// in internal/adif, not a LIKE pattern drifting apart from it here. Only the
// distinct values are examined, so the log is scanned once whatever its size.
func (r *Repo) CountQSLViaRouting(ctx context.Context, isRouting func(string) bool) (int, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT qsl_via, COUNT(*) FROM qso
WHERE qsl_via IS NOT NULL AND qsl_via != ''
AND (qsl_sent_via IS NULL OR qsl_sent_via = '')
GROUP BY qsl_via`)
if err != nil {
return 0, err
}
defer rows.Close()
n := 0
for rows.Next() {
var via string
var c int
if err := rows.Scan(&via, &c); err != nil {
return 0, err
}
if isRouting(via) {
n += c
}
}
return n, rows.Err()
}
// RepairQSLViaRouting moves routing words out of qsl_via into qsl_sent_via,
// returning how many QSOs were changed.
//
// One UPDATE per distinct spelling, not per QSO: a log holds a handful of them
// ("Bureau", "E", "Direct"…), and a remote MySQL logbook must not be made to
// carry one round trip per contact for a tidy-up. Rows that already have a
// sent-via are left alone by the same condition the count uses, so running this
// twice cannot undo a later import.
func (r *Repo) RepairQSLViaRouting(ctx context.Context, normalise func(string) string) (int, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT qsl_via FROM qso
WHERE qsl_via IS NOT NULL AND qsl_via != ''
AND (qsl_sent_via IS NULL OR qsl_sent_via = '')`)
if err != nil {
return 0, err
}
type move struct{ from, to string }
var moves []move
for rows.Next() {
var via string
if err := rows.Scan(&via); err != nil {
rows.Close()
return 0, err
}
if to := normalise(via); to != "" {
moves = append(moves, move{from: via, to: to})
}
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
total := 0
for _, m := range moves {
res, err := r.db.ExecContext(ctx,
`UPDATE qso SET qsl_sent_via = ?, qsl_via = ''
WHERE qsl_via = ? AND (qsl_sent_via IS NULL OR qsl_sent_via = '')`,
m.to, m.from)
if err != nil {
return total, err
}
if n, err := res.RowsAffected(); err == nil {
total += int(n)
}
}
return total, nil
}
// CallCounties returns callsign → "STATE,County" for every US station already
// logged with a county, newest QSO winning.
//
// It exists so the cluster shows the SAME county the entry panel does. The
// entry panel gets its county from a callbook, which knows where the licensee
// actually lives; a spot carries only a callsign, so the cluster falls back to
// deriving one from the FCC licence address, which is right about 98% of the
// time. Where the log already holds a callbook's answer for that station, the
// two panels disagreeing is pure noise — this is what lets the better answer
// win in both.
//
// One DISTINCT scan when the cluster snapshot is rebuilt, then map lookups per
// spot, like the worked-county and worked-grid sets beside it.
func (r *Repo) CallCounties(ctx context.Context) (map[string]string, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT callsign, COALESCE(state,''), COALESCE(cnty,'') FROM qso
WHERE dxcc IN (291,110,6) AND cnty IS NOT NULL AND cnty != ''
ORDER BY qso_date, time_on`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]string, 1024)
for rows.Next() {
var call, state, cnty string
if err := rows.Scan(&call, &state, &cnty); err != nil {
return nil, err
}
call = strings.ToUpper(strings.TrimSpace(call))
cnty = strings.TrimSpace(cnty)
if call == "" || cnty == "" {
continue
}
// Store the ADIF "STATE,County" shape whatever the log holds, so the
// caller has both halves without a second column.
if !strings.Contains(cnty, ",") {
if state = strings.TrimSpace(state); state == "" {
continue
}
cnty = state + "," + cnty
}
out[call] = cnty
}
return out, rows.Err()
}
// CountyWorked reports whether one US county has already been worked. // CountyWorked reports whether one US county has already been worked.
// //
// It exists so the entry panel can flag a new county without loading the whole // It exists so the entry panel can flag a new county without loading the whole
@@ -2843,7 +2973,8 @@ func scanQSO(s scanner) (QSO, error) {
rig, ant sql.NullString rig, ant sql.NullString
qslSent, qslRcvd sql.NullString qslSent, qslRcvd sql.NullString
qslSentDate, qslRcvdDate sql.NullString qslSentDate, qslRcvdDate sql.NullString
qslVia, qslMsg, qslMsgRcvd sql.NullString qslVia, qslSentVia, qslRcvdVia sql.NullString
qslMsg, qslMsgRcvd sql.NullString
lotwSent, lotwRcvd sql.NullString lotwSent, lotwRcvd sql.NullString
lotwSentDate, lotwRcvdDate sql.NullString lotwSentDate, lotwRcvdDate sql.NullString
eqslSent, eqslRcvd sql.NullString eqslSent, eqslRcvd sql.NullString
@@ -2888,7 +3019,7 @@ func scanQSO(s scanner) (QSO, error) {
&grid, &gridExt, &vucc, &grid, &gridExt, &vucc,
&country, &state, &cnty, &dxcc, &cont, &cqz, &ituz, &iota, &sota, &pota, &country, &state, &cnty, &dxcc, &cont, &cqz, &ituz, &iota, &sota, &pota,
&age, &lat, &lon, &rig, &ant, &age, &lat, &lon, &rig, &ant,
&qslSent, &qslRcvd, &qslSentDate, &qslRcvdDate, &qslVia, &qslMsg, &qslMsgRcvd, &qslSent, &qslRcvd, &qslSentDate, &qslRcvdDate, &qslVia, &qslSentVia, &qslRcvdVia, &qslMsg, &qslMsgRcvd,
&lotwSent, &lotwRcvd, &lotwSentDate, &lotwRcvdDate, &lotwSent, &lotwRcvd, &lotwSentDate, &lotwRcvdDate,
&eqslSent, &eqslRcvd, &eqslSentDate, &eqslRcvdDate, &eqslSent, &eqslRcvd, &eqslSentDate, &eqslRcvdDate,
&clublogDate, &clublogStatus, &clublogDate, &clublogStatus,
@@ -2972,6 +3103,8 @@ func scanQSO(s scanner) (QSO, error) {
q.QSLSentDate = qslSentDate.String q.QSLSentDate = qslSentDate.String
q.QSLRcvdDate = qslRcvdDate.String q.QSLRcvdDate = qslRcvdDate.String
q.QSLVia = qslVia.String q.QSLVia = qslVia.String
q.QSLSentVia = qslSentVia.String
q.QSLRcvdVia = qslRcvdVia.String
q.QSLMsg = qslMsg.String q.QSLMsg = qslMsg.String
q.QSLMsgRcvd = qslMsgRcvd.String q.QSLMsgRcvd = qslMsgRcvd.String
q.LOTWSent = lotwSent.String q.LOTWSent = lotwSent.String
+52 -1
View File
@@ -32,6 +32,7 @@ package rigctld
import ( import (
"bufio" "bufio"
"fmt" "fmt"
"hamlog/internal/cat"
"net" "net"
"strconv" "strconv"
"strings" "strings"
@@ -244,7 +245,18 @@ func (s *Server) serve(c net.Conn) {
s.log("rigctld: client %s disconnected", c.RemoteAddr()) s.log("rigctld: client %s disconnected", c.RemoteAddr())
return return
} }
resp, quit := s.handle(strings.TrimSpace(line)) req := strings.TrimSpace(line)
resp, quit := s.handle(req)
// The whole exchange, when tracing is on.
//
// Only PTT transitions were ever recorded, so when JTDX aborted a
// transmission mid-frame the log showed the abort and not the command
// that preceded it — the one thing needed to tell whether OpsLog answered
// something the client could not accept. Behind the same switch as the CAT
// wire trace: this is one line per poll and would drown an ordinary log.
if req != "" && cat.CIVTraceEnabled() {
s.log("rigctld: %s → %q ⇒ %q", c.RemoteAddr(), req, strings.TrimRight(resp, "\r\n"))
}
if resp != "" { if resp != "" {
if _, err := w.WriteString(resp); err != nil { if _, err := w.WriteString(resp); err != nil {
return return
@@ -409,6 +421,17 @@ func (s *Server) handle(line string) (resp string, quit bool) {
return rprt(0), false return rprt(0), false
} }
s.splitWanted.Store(false) s.splitWanted.Store(false)
// Already simplex? Then there is nothing to do and the request is
// satisfied. Reporting a failure here is what broke JTDX in "Fake It":
// Fake It uses no split, JTDX still sends "S 0" to be sure, and a backend
// that cannot SET split answered an error to a request that was already
// true. JTDX read that as rig control failing and abandoned the
// transmission a second into the frame.
//
// A refusal is only honest when something actually needed doing.
if on, _ := s.rig.Split(); !on {
return rprt(0), false
}
if err := s.rig.SetSplit(false, 0); err != nil { if err := s.rig.SetSplit(false, 0); err != nil {
s.log("rigctld: split off failed: %v", err) s.log("rigctld: split off failed: %v", err)
return rprt(-9), false return rprt(-9), false
@@ -438,6 +461,34 @@ func (s *Server) handle(line string) (resp string, quit bool) {
s.log("rigctld: split ON, TX %.0f Hz", hz) s.log("rigctld: split ON, TX %.0f Hz", hz)
return rprt(0), false return rprt(0), false
case "X", "\\set_split_mode":
// "X <mode> <passband>" — the mode of the TRANSMIT VFO.
//
// Accepted rather than refused. WSJT-X and JTDX send it as part of their
// normal setup even in "Fake It", where there is no split and therefore no
// second VFO to give a mode to; answering "not implemented" made JTDX give
// up on rig control mid-transmission.
//
// The mode is applied when there IS a split — the transmit VFO is a real
// one then. Without split the request has no target and succeeding is the
// honest answer: the transmit VFO already has that mode, because it is the
// same VFO.
if len(args) < 1 {
return rprt(-1), false
}
if on, _ := s.rig.Split(); !on {
return rprt(0), false
}
if err := s.rig.SetMode(args[0]); err != nil {
s.log("rigctld: split mode %q failed: %v", args[0], err)
return rprt(-9), false
}
return rprt(0), false
case "x", "\\get_split_mode":
// Mirrors X: the transmit VFO's mode and passband. Without split that is
// simply the current mode.
return s.rig.Mode() + "\n2400\n", false
default: default:
// A frame ending in ';' is not a rigctl command at all — it is raw rig // A frame ending in ';' is not a rigctl command at all — it is raw rig
// dialect (Kenwood/Elecraft/Yaesu), which means the client is configured // dialect (Kenwood/Elecraft/Yaesu), which means the client is configured
+52
View File
@@ -50,3 +50,55 @@ func TestSetSplitRefusalIsReported(t *testing.T) {
t.Errorf("a rig that cannot split answered %q — the client will transmit on the wrong frequency", got) t.Errorf("a rig that cannot split answered %q — the client will transmit on the wrong frequency", got)
} }
} }
// "Fake It" uses NO split, and JTDX still sends "S 0" to make sure. A backend
// that cannot SET split used to answer an error to that — a refusal of a
// request that was already satisfied — and JTDX read it as rig control failing
// and abandoned the transmission a second into a 13.8-second frame.
//
// Reported against a Yaesu whose backend cannot arm split from software.
func TestSplitOffOnASimplexRigSucceeds(t *testing.T) {
rig := &fakeRig{freq: 14074000, mode: "FT8", noSplit: true}
s := New(0, rig, func(string, ...any) {})
if got, _ := s.handle("S 0 VFOA"); !strings.HasPrefix(got, "RPRT 0") {
t.Errorf("split off on a simplex rig answered %q — nothing needed doing", got)
}
// And it must not have bothered the rig at all.
if len(rig.splitCalls) != 0 {
t.Errorf("rig was asked to clear a split it did not have: %v", rig.splitCalls)
}
// Arming, however, must still fail loudly: there the request is real and
// unmet, and a client that believes it is transmitting up the band while the
// radio sits on the DX's frequency is the bug this refusal exists to prevent.
if got, _ := s.handle("S 1 VFOB"); !strings.HasPrefix(got, "RPRT 0") {
t.Fatalf("arming is deferred to set_split_freq: %q", got)
}
if got, _ := s.handle("I 14075300.000000"); strings.HasPrefix(got, "RPRT 0") {
t.Error("arming split on a backend that cannot must report a failure")
}
}
// X (set_split_mode) is sent by WSJT-X and JTDX during ordinary setup, Fake It
// included — where there is no second VFO to give a mode to. Answering "not
// implemented" made JTDX give up on rig control mid-transmission.
func TestSplitModeIsAccepted(t *testing.T) {
rig := &fakeRig{freq: 14074000, mode: "FT8"}
s := New(0, rig, func(string, ...any) {})
// No split: nothing to target, and succeeding is honest — the transmit VFO
// already has that mode because it is the same VFO.
if got, _ := s.handle("X PKTUSB -1"); !strings.HasPrefix(got, "RPRT 0") {
t.Errorf("set_split_mode answered %q", got)
}
if rig.mode != "FT8" {
t.Errorf("the mode was changed with no split in force: %q", rig.mode)
}
// Reading it back must answer a mode and a passband, never an error.
got, _ := s.handle("x")
if !strings.Contains(got, "FT8") {
t.Errorf("get_split_mode = %q, want the current mode", got)
}
}
+440
View File
@@ -0,0 +1,440 @@
// Code generated by cmd/ctzipgen. DO NOT EDIT.
package uls
// ctCounty maps a Connecticut ZIP to its legal county.
//
// GeoNames reports Connecticut's 2022 planning regions instead, which no
// award, callbook or log uses. See cmd/ctzipgen for how this was built and
// why a name-to-name mapping cannot work.
var ctCounty = map[string]string{
"06001": "Hartford",
"06002": "Hartford",
"06006": "Hartford",
"06010": "Hartford",
"06011": "Hartford",
"06013": "Hartford",
"06016": "Hartford",
"06018": "Litchfield",
"06019": "Hartford",
"06020": "Hartford",
"06021": "Litchfield",
"06022": "Hartford",
"06023": "Hartford",
"06024": "Litchfield",
"06025": "Hartford",
"06026": "Hartford",
"06027": "Hartford",
"06028": "Hartford",
"06029": "Tolland",
"06030": "Hartford",
"06031": "Litchfield",
"06032": "Hartford",
"06033": "Hartford",
"06034": "Hartford",
"06035": "Hartford",
"06037": "Hartford",
"06039": "Litchfield",
"06040": "Hartford",
"06041": "Hartford",
"06042": "Hartford",
"06043": "Tolland",
"06045": "Hartford",
"06050": "Hartford",
"06051": "Hartford",
"06052": "Hartford",
"06053": "Hartford",
"06057": "Litchfield",
"06058": "Litchfield",
"06059": "Hartford",
"06060": "Hartford",
"06061": "Litchfield",
"06062": "Hartford",
"06063": "Litchfield",
"06064": "Hartford",
"06065": "Hartford",
"06066": "Tolland",
"06067": "Hartford",
"06068": "Litchfield",
"06069": "Litchfield",
"06070": "Hartford",
"06071": "Tolland",
"06072": "Tolland",
"06073": "Hartford",
"06074": "Hartford",
"06075": "Tolland",
"06076": "Tolland",
"06077": "Tolland",
"06078": "Hartford",
"06079": "Litchfield",
"06080": "Hartford",
"06081": "Hartford",
"06082": "Hartford",
"06083": "Hartford",
"06084": "Tolland",
"06085": "Hartford",
"06087": "Hartford",
"06088": "Hartford",
"06089": "Hartford",
"06090": "Hartford",
"06091": "Hartford",
"06092": "Hartford",
"06093": "Hartford",
"06094": "Litchfield",
"06095": "Hartford",
"06096": "Hartford",
"06098": "Litchfield",
"06101": "Hartford",
"06102": "Hartford",
"06103": "Hartford",
"06104": "Hartford",
"06105": "Hartford",
"06106": "Hartford",
"06107": "Hartford",
"06108": "Hartford",
"06109": "Hartford",
"06110": "Hartford",
"06111": "Hartford",
"06112": "Hartford",
"06114": "Hartford",
"06115": "Hartford",
"06117": "Hartford",
"06118": "Hartford",
"06119": "Hartford",
"06120": "Hartford",
"06123": "Hartford",
"06126": "Hartford",
"06127": "Hartford",
"06128": "Hartford",
"06129": "Hartford",
"06131": "Hartford",
"06132": "Hartford",
"06133": "Hartford",
"06134": "Hartford",
"06137": "Hartford",
"06138": "Hartford",
"06140": "Hartford",
"06141": "Hartford",
"06142": "Hartford",
"06143": "Hartford",
"06144": "Hartford",
"06145": "Hartford",
"06146": "Hartford",
"06147": "Hartford",
"06150": "Hartford",
"06151": "Hartford",
"06152": "Hartford",
"06153": "Hartford",
"06154": "Hartford",
"06155": "Hartford",
"06156": "Hartford",
"06160": "Hartford",
"06161": "Hartford",
"06167": "Hartford",
"06176": "Hartford",
"06180": "Hartford",
"06183": "Hartford",
"06199": "Hartford",
"06226": "Windham",
"06230": "Windham",
"06231": "Tolland",
"06232": "Tolland",
"06233": "Windham",
"06234": "Windham",
"06235": "Windham",
"06237": "Tolland",
"06238": "Tolland",
"06239": "Windham",
"06241": "Windham",
"06242": "Windham",
"06243": "Windham",
"06244": "Windham",
"06245": "Windham",
"06246": "Windham",
"06247": "Windham",
"06248": "Tolland",
"06249": "New London",
"06250": "Tolland",
"06251": "Tolland",
"06254": "New London",
"06255": "Windham",
"06256": "Windham",
"06258": "Windham",
"06259": "Windham",
"06260": "Windham",
"06262": "Windham",
"06263": "Windham",
"06264": "Windham",
"06265": "Tolland",
"06266": "Windham",
"06267": "Windham",
"06268": "Tolland",
"06269": "Tolland",
"06277": "Windham",
"06278": "Windham",
"06279": "Tolland",
"06280": "Windham",
"06281": "Windham",
"06282": "Windham",
"06320": "New London",
"06330": "New London",
"06331": "Windham",
"06332": "Windham",
"06333": "New London",
"06334": "New London",
"06335": "New London",
"06336": "New London",
"06338": "New London",
"06339": "New London",
"06340": "New London",
"06349": "New London",
"06350": "New London",
"06351": "New London",
"06353": "New London",
"06354": "Windham",
"06355": "New London",
"06357": "New London",
"06359": "New London",
"06360": "New London",
"06365": "New London",
"06370": "New London",
"06371": "New London",
"06372": "New London",
"06373": "Windham",
"06374": "Windham",
"06375": "New London",
"06376": "New London",
"06377": "Windham",
"06378": "New London",
"06379": "New London",
"06380": "New London",
"06382": "New London",
"06383": "New London",
"06384": "New London",
"06385": "New London",
"06387": "Windham",
"06388": "New London",
"06389": "New London",
"06401": "New Haven",
"06403": "New Haven",
"06404": "Fairfield",
"06405": "New Haven",
"06408": "New Haven",
"06409": "Middlesex",
"06410": "New Haven",
"06411": "New Haven",
"06412": "Middlesex",
"06413": "Middlesex",
"06414": "Middlesex",
"06415": "New London",
"06416": "Middlesex",
"06417": "Middlesex",
"06418": "New Haven",
"06419": "Middlesex",
"06420": "New London",
"06422": "Middlesex",
"06423": "Middlesex",
"06424": "Middlesex",
"06426": "Middlesex",
"06437": "New Haven",
"06438": "Middlesex",
"06439": "New London",
"06440": "Fairfield",
"06441": "Middlesex",
"06442": "Middlesex",
"06443": "New Haven",
"06444": "Hartford",
"06447": "Hartford",
"06450": "New Haven",
"06451": "New Haven",
"06455": "Middlesex",
"06456": "Middlesex",
"06457": "Middlesex",
"06459": "Middlesex",
"06460": "New Haven",
"06461": "New Haven",
"06467": "Hartford",
"06468": "Fairfield",
"06469": "Middlesex",
"06470": "Fairfield",
"06471": "New Haven",
"06472": "New Haven",
"06473": "New Haven",
"06474": "New London",
"06475": "Middlesex",
"06477": "New Haven",
"06478": "New Haven",
"06479": "Hartford",
"06480": "Middlesex",
"06481": "Middlesex",
"06482": "Fairfield",
"06483": "New Haven",
"06484": "Fairfield",
"06487": "New Haven",
"06488": "New Haven",
"06489": "Hartford",
"06491": "Fairfield",
"06492": "New Haven",
"06493": "New Haven",
"06494": "New Haven",
"06495": "New Haven",
"06498": "Middlesex",
"06501": "New Haven",
"06502": "New Haven",
"06503": "New Haven",
"06504": "New Haven",
"06505": "New Haven",
"06506": "New Haven",
"06507": "New Haven",
"06508": "New Haven",
"06509": "New Haven",
"06510": "New Haven",
"06511": "New Haven",
"06512": "New Haven",
"06513": "New Haven",
"06514": "New Haven",
"06515": "New Haven",
"06516": "New Haven",
"06517": "New Haven",
"06518": "New Haven",
"06519": "New Haven",
"06520": "New Haven",
"06521": "New Haven",
"06524": "New Haven",
"06525": "New Haven",
"06530": "New Haven",
"06531": "New Haven",
"06532": "New Haven",
"06533": "New Haven",
"06534": "New Haven",
"06535": "New Haven",
"06536": "New Haven",
"06537": "New Haven",
"06538": "New Haven",
"06540": "New Haven",
"06601": "Fairfield",
"06602": "Fairfield",
"06604": "Fairfield",
"06605": "Fairfield",
"06606": "Fairfield",
"06607": "Fairfield",
"06608": "Fairfield",
"06610": "Fairfield",
"06611": "Fairfield",
"06612": "Fairfield",
"06614": "Fairfield",
"06615": "Fairfield",
"06673": "Fairfield",
"06699": "Fairfield",
"06701": "New Haven",
"06702": "New Haven",
"06703": "New Haven",
"06704": "New Haven",
"06705": "New Haven",
"06706": "New Haven",
"06708": "New Haven",
"06710": "New Haven",
"06712": "New Haven",
"06716": "New Haven",
"06720": "New Haven",
"06721": "New Haven",
"06722": "New Haven",
"06723": "New Haven",
"06724": "New Haven",
"06725": "New Haven",
"06726": "New Haven",
"06749": "New Haven",
"06750": "Litchfield",
"06751": "Litchfield",
"06752": "Litchfield",
"06753": "Litchfield",
"06754": "Litchfield",
"06755": "Litchfield",
"06756": "Litchfield",
"06757": "Litchfield",
"06758": "Litchfield",
"06759": "Litchfield",
"06762": "New Haven",
"06763": "Litchfield",
"06770": "New Haven",
"06776": "Litchfield",
"06777": "Litchfield",
"06778": "Litchfield",
"06779": "Litchfield",
"06781": "Litchfield",
"06782": "Litchfield",
"06783": "Litchfield",
"06784": "Fairfield",
"06785": "Litchfield",
"06786": "Litchfield",
"06787": "Litchfield",
"06790": "Litchfield",
"06791": "Litchfield",
"06792": "Litchfield",
"06793": "Litchfield",
"06794": "Litchfield",
"06795": "Litchfield",
"06796": "Litchfield",
"06798": "Litchfield",
"06801": "Fairfield",
"06804": "Fairfield",
"06807": "Fairfield",
"06810": "Fairfield",
"06811": "Fairfield",
"06812": "Fairfield",
"06813": "Fairfield",
"06814": "Fairfield",
"06816": "Fairfield",
"06817": "Fairfield",
"06820": "Fairfield",
"06824": "Fairfield",
"06825": "Fairfield",
"06828": "Fairfield",
"06829": "Fairfield",
"06830": "Fairfield",
"06831": "Fairfield",
"06836": "Fairfield",
"06838": "Fairfield",
"06840": "Fairfield",
"06850": "Fairfield",
"06851": "Fairfield",
"06852": "Fairfield",
"06853": "Fairfield",
"06854": "Fairfield",
"06855": "Fairfield",
"06856": "Fairfield",
"06857": "Fairfield",
"06858": "Fairfield",
"06860": "Fairfield",
"06870": "Fairfield",
"06875": "Fairfield",
"06876": "Fairfield",
"06877": "Fairfield",
"06878": "Fairfield",
"06879": "Fairfield",
"06880": "Fairfield",
"06881": "Fairfield",
"06883": "Fairfield",
"06888": "Fairfield",
"06889": "Fairfield",
"06890": "Fairfield",
"06896": "Fairfield",
"06897": "Fairfield",
"06901": "Fairfield",
"06902": "Fairfield",
"06903": "Fairfield",
"06904": "Fairfield",
"06905": "Fairfield",
"06906": "Fairfield",
"06907": "Fairfield",
"06910": "Fairfield",
"06911": "Fairfield",
"06912": "Fairfield",
"06913": "Fairfield",
"06914": "Fairfield",
"06920": "Fairfield",
"06921": "Fairfield",
"06922": "Fairfield",
"06926": "Fairfield",
"06927": "Fairfield",
}
+70
View File
@@ -0,0 +1,70 @@
package uls
import (
"testing"
"hamlog/internal/award"
)
// The eight counties Connecticut still has for every purpose except the Census.
var ctLegalCounties = map[string]bool{
"Fairfield": true, "Hartford": true, "Litchfield": true, "Middlesex": true,
"New Haven": true, "New London": true, "Tolland": true, "Windham": true,
}
// TestCTCountyOnlyLegalCounties makes sure no planning region ever creeps back
// into the table — a regenerated file that quietly picked up "Capitol Region"
// would put every Hartford-area station back out of reach of the award, which
// is exactly the failure this table exists to end.
func TestCTCountyOnlyLegalCounties(t *testing.T) {
if len(ctCounty) < 400 {
t.Fatalf("only %d Connecticut ZIPs — the table looks truncated", len(ctCounty))
}
for zip, county := range ctCounty {
if !ctLegalCounties[county] {
t.Errorf("ZIP %s → %q, which is not one of Connecticut's eight counties", zip, county)
}
if len(zip) != 5 {
t.Errorf("ZIP %q is not five digits", zip)
}
}
}
// TestCTCountyMatchesAward closes the loop: every county in the table has to
// produce a key the USA-CA reference actually lists. A county the resolver can
// name but the award cannot match is worth no more than the planning region it
// replaced.
func TestCTCountyMatchesAward(t *testing.T) {
want := map[string]bool{
"CT/FAIRFIELD": true, "CT/HARTFORD": true, "CT/LITCHFIELD": true,
"CT/MIDDLESEX": true, "CT/NEWHAVEN": true, "CT/NEWLONDON": true,
"CT/TOLLAND": true, "CT/WINDHAM": true,
}
for zip, county := range ctCounty {
if k := award.USCountyKey("CT", county); !want[k] {
t.Errorf("ZIP %s → %q → key %q, not a USA-CA county", zip, county, k)
}
}
}
// TestCTCountyKnownZips pins a ZIP in each county against ground truth, so a
// regeneration that silently shifted the whole table is caught. 06419 is the
// case that started this: Killingworth is in Middlesex, and GeoNames calls it
// "Lower Connecticut River Valley".
func TestCTCountyKnownZips(t *testing.T) {
for zip, want := range map[string]string{
"06419": "Middlesex", // Killingworth
"06001": "Hartford", // Avon
"06106": "Hartford", // Hartford
"06510": "New Haven", // New Haven
"06880": "Fairfield", // Westport
"06340": "New London", // Groton
"06226": "Windham", // Willimantic
"06238": "Tolland", // Coventry
"06759": "Litchfield", // Litchfield
} {
if got := ctCounty[zip]; got != want {
t.Errorf("ctCounty[%s] = %q, want %q", zip, got, want)
}
}
}
+46 -2
View File
@@ -34,6 +34,7 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort" "sort"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -114,6 +115,33 @@ func (s *Store) Count() int {
return n return n
} }
// RulesVersion is bumped whenever a correction applied DURING import changes
// the county a callsign resolves to. The value is stamped into uls_meta, so a
// database built by an older OpsLog can be told apart from one built by this
// one — the rows themselves carry no clue, and the fix cannot be applied
// retroactively because the ZIP each county was derived from is not stored.
//
// 1 — original.
// 2 — Connecticut ZIPs mapped back to their legal county instead of the
// 2022 planning region GeoNames now reports (see ctcounty_gen.go).
const RulesVersion = 2
// NeedsRefresh reports that the store was built before a correction that
// changes its answers, so the operator should download it again. False for an
// empty store: there is nothing stale about data that was never fetched.
func (s *Store) NeedsRefresh() bool {
s.mu.RLock()
defer s.mu.RUnlock()
var n int
if s.db.QueryRow(`SELECT COUNT(*) FROM uls_callsign`).Scan(&n); n == 0 {
return false
}
var v string
s.db.QueryRow(`SELECT value FROM uls_meta WHERE key='rules_version'`).Scan(&v)
got, _ := strconv.Atoi(v)
return got < RulesVersion
}
// UpdatedAt returns when the store was last imported (zero if never). // UpdatedAt returns when the store was last imported (zero if never).
func (s *Store) UpdatedAt() time.Time { func (s *Store) UpdatedAt() time.Time {
s.mu.RLock() s.mu.RLock()
@@ -285,6 +313,10 @@ func (s *Store) rebuild(ctx context.Context, amatZip string, zipmap map[string]z
time.Now().UTC().Format(time.RFC3339)); err != nil { time.Now().UTC().Format(time.RFC3339)); err != nil {
return err return err
} }
if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO uls_meta(key,value) VALUES('rules_version',?)`,
strconv.Itoa(RulesVersion)); err != nil {
return err
}
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return err return err
} }
@@ -333,9 +365,21 @@ func parseGeoNames(zipPath string) (map[string]zipRow, error) {
} }
lat := parseFloat(f[9]) lat := parseFloat(f[9])
lon := parseFloat(f[10]) lon := parseFloat(f[10])
state := strings.ToUpper(strings.TrimSpace(f[4]))
county := strings.TrimSpace(f[5])
// Connecticut: GeoNames reports the 2022 planning regions, which are
// county-equivalents for the Census and nothing at all for amateur
// radio — no award, no callbook and no log uses them, so every CT
// station resolved to a name that matched nothing. Substitute the legal
// county for the ZIP. See cmd/ctzipgen.
if state == "CT" {
if c, ok := ctCounty[zip5]; ok {
county = c
}
}
out[zip5] = zipRow{ out[zip5] = zipRow{
state: strings.ToUpper(strings.TrimSpace(f[4])), state: state,
county: strings.TrimSpace(f[5]), county: county,
lat: lat, lat: lat,
lon: lon, lon: lon,
} }
+159
View File
@@ -0,0 +1,159 @@
package winkeyer
import (
"errors"
"fmt"
"time"
"go.bug.st/serial"
"hamlog/internal/applog"
)
// The opening handshake, as K1EL specifies it in the WinKeyer2 Application
// Interface Guide ("WK Init Psuedo Code"). OpsLog used to send Host Open alone
// and carry on whatever came back, which is how an operator ended up with a
// keyer reported as connected, a full set of settings written to it, and not
// one character keyed — the log said "no reply" and then behaved as if there
// had been one.
//
// The steps exist for reasons that are not obvious from the byte values:
//
// 400 ms a WK1 is still powering up off the DTR line when the port opens;
// WK2 and later do not need it, and it costs nothing once.
// 0x13 ×3 null commands. WinKey's parser may be part-way through a command
// left over from whoever spoke to it last — another logger, or us
// before a crash. A command byte expecting parameters would swallow
// Host Open whole. Three nulls flush that state out.
// echo ask the keyer to send one known byte back. This is the only step
// that answers "is there really a WinKeyer on this port", and it is
// the one to fail on: everything after it assumes a listener.
// open 0x00 0x02, and the keyer returns its firmware version.
const (
cmdNull = 0x13
cmdAdmin = 0x00
adminOpen = 0x02
adminEcho = 0x04
echoProbe = 0x55 // K1EL's own choice; any byte works, this one is 0b01010101
bootDelay = 400 * time.Millisecond
echoTimeout = 2 * time.Second // K1EL: "if a WK doesn't respond within 2 seconds abort"
openTimeout = 2 * time.Second
// resetDelay is the second attempt's wait, and it is not there for a K1EL.
//
// Plenty of "WinKeyers" are a K3NG keyer — an Arduino running an emulation
// of the same protocol. On an Arduino, DTR is wired to the reset pin through
// a capacitor: raising it when the port opens REBOOTS the board, which then
// sits in its bootloader before the sketch even starts. K3NG's own options
// file says as much ("disabling Automatic Software Reset is highly
// recommended", and an option to "discard errant serial port bytes at
// startup" for when it is not). 400 ms is nowhere near long enough, so the
// keyer misses the whole handshake and looks absent.
//
// Rather than make every operator wait for the slowest possible device, the
// first attempt stays quick and only the retry allows for a reboot.
resetDelay = 2500 * time.Millisecond
handshakeTry = 2
)
// errNoKeyer is returned when nothing answers the echo probe.
var errNoKeyer = errors.New("no WinKeyer answered on this port — check the cable, the port, and that no other program holds the keyer")
// hostOpen runs the full documented handshake and returns the firmware version
// byte. It is tried twice, and the two attempts cover the two ways a keyer that
// is plugged in and working can miss being spoken to: a parser left mid-command
// by whoever talked to it last (the first attempt's nulls clear that), and an
// Arduino-based keyer still rebooting from the DTR edge (the second attempt
// waits long enough for it).
// slowBoot skips straight to the long wait, set when this port has already been
// seen to need it. The second return value says whether the long wait is what
// worked, so the caller can remember it and open quickly next time.
func hostOpen(p serial.Port, slowBoot bool) (ver int, needsSlowBoot bool, err error) {
var lastErr error
for attempt := 1; attempt <= handshakeTry; attempt++ {
wait := bootDelay
if attempt > 1 || slowBoot {
wait = resetDelay
}
ver, err := hostOpenOnce(p, wait)
if err == nil {
if attempt > 1 {
applog.Printf("winkeyer: answered on attempt %d — this keyer needs %s to boot (a K3NG or another Arduino keyer with auto-reset on); remembering that for this port", attempt, wait)
}
return ver, wait == resetDelay, nil
}
lastErr = err
if attempt < handshakeTry {
applog.Printf("winkeyer: handshake attempt %d failed (%v) — retrying after %s in case the keyer is rebooting", attempt, err, resetDelay)
}
}
return 0, false, lastErr
}
func hostOpenOnce(p serial.Port, boot time.Duration) (int, error) {
// The keyer may still be booting off the DTR line we just raised.
time.Sleep(boot)
drain(p)
// Resync the command parser before asking it anything.
if _, err := p.Write([]byte{cmdNull, cmdNull, cmdNull}); err != nil {
return 0, fmt.Errorf("resync: %w", err)
}
time.Sleep(50 * time.Millisecond)
drain(p)
// Is anything actually there?
if _, err := p.Write([]byte{cmdAdmin, adminEcho, echoProbe}); err != nil {
return 0, fmt.Errorf("echo test: %w", err)
}
b, ok := readByte(p, echoTimeout)
if !ok {
return 0, errNoKeyer
}
if b != echoProbe {
// Something replied, but not what we asked for. Say what came back —
// on a wrong port that byte is the only clue to what is on the other end.
return 0, fmt.Errorf("echo test: expected 0x%02X, got 0x%02X — is this the keyer's port?", echoProbe, b)
}
if _, err := p.Write([]byte{cmdAdmin, adminOpen}); err != nil {
return 0, fmt.Errorf("host open: %w", err)
}
ver, ok := readByte(p, openTimeout)
if !ok {
return 0, errors.New("host open: the keyer echoed but did not return its firmware version")
}
return int(ver), nil
}
// readByte waits up to d for one byte. The serial read timeout is per-call and
// can return 0 bytes without an error, so this loops until the deadline rather
// than trusting a single Read.
func readByte(p serial.Port, d time.Duration) (byte, bool) {
_ = p.SetReadTimeout(200 * time.Millisecond)
deadline := time.Now().Add(d)
buf := make([]byte, 1)
for time.Now().Before(deadline) {
n, err := p.Read(buf)
if n > 0 {
return buf[0], true
}
if err != nil {
return 0, false
}
}
return 0, false
}
// drain throws away anything already waiting — a status byte from a previous
// session, or the tail of a reply we are no longer interested in.
func drain(p serial.Port) {
_ = p.SetReadTimeout(20 * time.Millisecond)
buf := make([]byte, 64)
for i := 0; i < 16; i++ {
n, err := p.Read(buf)
if n == 0 || err != nil {
return
}
}
}
+216
View File
@@ -0,0 +1,216 @@
package winkeyer
import (
"errors"
"sync"
"testing"
"time"
"go.bug.st/serial"
)
// fakeKeyer is a serial.Port that behaves like a WinKeyer: it answers the echo
// probe and Host Open, and records everything the host sent so the handshake
// can be checked byte for byte against K1EL's documented sequence.
type fakeKeyer struct {
mu sync.Mutex
written []byte
toRead []byte
version byte
// deaf drops every command — the keyer that is not there, or is not
// listening because RTS starved it.
deaf bool
// mute answers the echo but never returns a version.
mute bool
// needsResync ignores commands until three nulls have been seen, standing
// in for a keyer left mid-command by another program.
needsResync bool
nulls int
}
func (f *fakeKeyer) Write(p []byte) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.written = append(f.written, p...)
if f.deaf {
return len(p), nil
}
for i := 0; i < len(p); i++ {
switch {
case p[i] == cmdNull:
f.nulls++
case f.needsResync && f.nulls < 3:
// still confused — swallow it
case p[i] == cmdAdmin && i+1 < len(p):
i++
switch p[i] {
case adminEcho:
if i+1 < len(p) {
i++
f.toRead = append(f.toRead, p[i])
}
case adminOpen:
if !f.mute {
f.toRead = append(f.toRead, f.version)
}
}
}
}
return len(p), nil
}
func (f *fakeKeyer) Read(p []byte) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.toRead) == 0 {
return 0, nil // a timeout, not an error — what a real port does
}
n := copy(p, f.toRead)
f.toRead = f.toRead[n:]
return n, nil
}
func (f *fakeKeyer) sent() []byte {
f.mu.Lock()
defer f.mu.Unlock()
return append([]byte(nil), f.written...)
}
func (f *fakeKeyer) Drain() error { return nil }
func (f *fakeKeyer) ResetInputBuffer() error { return nil }
func (f *fakeKeyer) ResetOutputBuffer() error { return nil }
func (f *fakeKeyer) SetDTR(bool) error { return nil }
func (f *fakeKeyer) SetRTS(bool) error { return nil }
func (f *fakeKeyer) GetModemStatusBits() (*serial.ModemStatusBits, error) {
return &serial.ModemStatusBits{}, nil
}
func (f *fakeKeyer) SetReadTimeout(time.Duration) error { return nil }
func (f *fakeKeyer) Close() error { return nil }
func (f *fakeKeyer) Break(time.Duration) error { return nil }
func (f *fakeKeyer) SetMode(*serial.Mode) error { return nil }
// TestHostOpenFollowsK1ELSequence checks the handshake against the order K1EL
// publishes: three nulls to resync the parser, an echo probe to prove there is
// a keyer, then Host Open. OpsLog used to send Host Open alone, which a keyer
// left mid-command simply absorbed.
func TestHostOpenFollowsK1ELSequence(t *testing.T) {
f := &fakeKeyer{version: 23}
ver, _, err := hostOpen(f, false)
if err != nil {
t.Fatalf("hostOpen: %v", err)
}
if ver != 23 {
t.Errorf("version = %d, want 23", ver)
}
want := []byte{
cmdNull, cmdNull, cmdNull,
cmdAdmin, adminEcho, echoProbe,
cmdAdmin, adminOpen,
}
got := f.sent()
if len(got) != len(want) {
t.Fatalf("sent % X, want % X", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("sent % X, want % X", got, want)
}
}
}
// A keyer left part-way through a command by another program is the everyday
// cause of a silent WinKeyer. The nulls must recover it without the operator
// having to unplug anything.
func TestHostOpenRecoversAConfusedParser(t *testing.T) {
f := &fakeKeyer{version: 30, needsResync: true}
ver, _, err := hostOpen(f, false)
if err != nil {
t.Fatalf("hostOpen: %v", err)
}
if ver != 30 {
t.Errorf("version = %d, want 30", ver)
}
}
// Nothing on the port must FAIL the connection. Reporting success and then
// writing settings and text into the void is what produced a log full of
// commands and a keyer that never made a sound.
func TestHostOpenFailsWhenNothingAnswers(t *testing.T) {
f := &fakeKeyer{deaf: true}
if _, _, err := hostOpen(f, false); !errors.Is(err, errNoKeyer) {
t.Fatalf("want errNoKeyer, got %v", err)
}
}
// Echoing but not returning a version is a different fault and must not be
// reported as "no keyer".
func TestHostOpenReportsMissingVersion(t *testing.T) {
f := &fakeKeyer{mute: true}
_, _, err := hostOpen(f, false)
if err == nil {
t.Fatal("want an error")
}
if errors.Is(err, errNoKeyer) {
t.Fatalf("a keyer that echoed was reported as absent: %v", err)
}
}
// slowKeyer answers nothing until it has been "powered up" for d — a K3NG on an
// Arduino, which the DTR edge from opening the port drops into its bootloader.
type slowKeyer struct {
fakeKeyer
ready time.Time
}
func (s *slowKeyer) Write(p []byte) (int, error) {
if time.Now().Before(s.ready) {
return len(p), nil // still in the bootloader — the bytes are lost
}
return s.fakeKeyer.Write(p)
}
// TestHostOpenWaitsOutAnArduinoReboot is the case that started this: a K3NG
// keyer reboots when the port opens, so it misses a handshake sent 400 ms
// later. The retry has to wait long enough, and must not need the operator to
// press connect twice.
func TestHostOpenWaitsOutAnArduinoReboot(t *testing.T) {
f := &slowKeyer{ready: time.Now().Add(1500 * time.Millisecond)}
f.version = 23
ver, slow, err := hostOpen(f, false)
if err != nil {
t.Fatalf("hostOpen: %v", err)
}
if ver != 23 {
t.Errorf("version = %d, want 23", ver)
}
// This second value is what gets remembered for the port, and it is the
// whole reason the operator is never asked what kind of keyer they own.
// Lose it and every later connect pays the same doomed quick attempt.
if !slow {
t.Error("the long wait is what worked, but it was not reported as needed")
}
}
// A keyer that answers straight away must NOT be remembered as slow — that
// would add seconds to every connect for a K1EL that never needed them.
func TestHostOpenDoesNotMarkAFastKeyerSlow(t *testing.T) {
f := &fakeKeyer{version: 23}
if _, slow, err := hostOpen(f, false); err != nil || slow {
t.Fatalf("hostOpen = slow %v, err %v — want a fast keyer left alone", slow, err)
}
}
// A port already known to hold a slow keyer skips the doomed fast attempt, so
// the second connect is as quick as a K1EL's.
func TestHostOpenSlowBootSucceedsFirstTry(t *testing.T) {
f := &slowKeyer{ready: time.Now().Add(1500 * time.Millisecond)}
f.version = 23
if _, _, err := hostOpen(f, true); err != nil {
t.Fatalf("hostOpen: %v", err)
}
// One attempt: exactly one handshake on the wire, not two.
want := len([]byte{cmdNull, cmdNull, cmdNull, cmdAdmin, adminEcho, echoProbe, cmdAdmin, adminOpen})
if got := len(f.sent()); got != want {
t.Errorf("sent %d bytes, want %d — the fast attempt was not skipped", got, want)
}
}
+32 -10
View File
@@ -51,6 +51,13 @@ type Config struct {
UsePTT bool `json:"use_ptt"` // key PTT (Key/PTT output) UsePTT bool `json:"use_ptt"` // key PTT (Key/PTT output)
SerialEcho bool `json:"serial_echo"` // device echoes sent chars back to host SerialEcho bool `json:"serial_echo"` // device echoes sent chars back to host
// SlowBoot skips the quick opening attempt and waits for a keyer that reboots
// when the port opens — a K3NG or any other Arduino-based WinKeyer with its
// auto-reset still enabled. Not an operator setting: it is remembered per port
// the first time a keyer only answers the long attempt, so the second connect
// is as quick as a K1EL's and nobody has to know what is inside the box.
SlowBoot bool `json:"-"`
// Type selects the keyer engine on this serial port: // Type selects the keyer engine on this serial port:
// "" / "k1el" → a K1EL WinKeyer chip (the default, everything above applies) // "" / "k1el" → a K1EL WinKeyer chip (the default, everything above applies)
// "serial" → the PC bit-bangs Morse on a control line (no WinKeyer chip): // "serial" → the PC bit-bangs Morse on a control line (no WinKeyer chip):
@@ -108,12 +115,20 @@ type Manager struct {
onStatus func(Status) onStatus func(Status)
onEcho func(string) // chars the device echoes back as it keys them onEcho func(string) // chars the device echoes back as it keys them
// onSlowBoot fires when a keyer answered only after the long wait, so the
// caller can persist that for this port. See Config.SlowBoot.
onSlowBoot func(port string)
} }
func NewManager(onStatus func(Status), onEcho func(string)) *Manager { func NewManager(onStatus func(Status), onEcho func(string)) *Manager {
return &Manager{onStatus: onStatus, onEcho: onEcho} return &Manager{onStatus: onStatus, onEcho: onEcho}
} }
// OnSlowBoot registers the callback that persists "this port needs the long
// opening wait". Optional: without it a slow keyer still connects, it just
// pays the failed quick attempt on every connect.
func (m *Manager) OnSlowBoot(fn func(port string)) { m.onSlowBoot = fn }
// ListPorts returns the available serial port names (COM3, COM6, …). // ListPorts returns the available serial port names (COM3, COM6, …).
func ListPorts() ([]string, error) { func ListPorts() ([]string, error) {
ports, err := serial.GetPortsList() ports, err := serial.GetPortsList()
@@ -152,22 +167,26 @@ func (m *Manager) Connect(cfg Config) error {
DataBits: 8, DataBits: 8,
Parity: serial.NoParity, Parity: serial.NoParity,
StopBits: serial.OneStopBit, StopBits: serial.OneStopBit,
// DTR on, RTS OFF. K1EL's own init code sets exactly this
// (DTR_CONTROL_ENABLE / RTS_CONTROL_DISABLE), and on a serial WinKeyer
// the two lines are the chip's power supply: DTR feeds the 3.3 V
// regulator, RTS provides the negative rail for the RS-232 swing.
// Driving RTS high starves that rail. The serial library defaults BOTH
// to true, which is how this was wrong without anyone writing it.
InitialStatusBits: &serial.ModemOutputBits{DTR: true, RTS: false},
}) })
if err != nil { if err != nil {
return fmt.Errorf("winkeyer: open %s: %w", cfg.Port, err) return fmt.Errorf("winkeyer: open %s: %w", cfg.Port, err)
} }
_ = p.SetReadTimeout(200 * time.Millisecond)
// Host Open: <0x00 0x02>. Device replies with its firmware version byte. ver, slowBoot, err := hostOpen(p, cfg.SlowBoot)
if _, err := p.Write([]byte{0x00, 0x02}); err != nil { if err != nil {
_ = p.Close() _ = p.Close()
return fmt.Errorf("winkeyer: host open: %w", err) return fmt.Errorf("winkeyer: %s: %w", cfg.Port, err)
} }
ver := 0 // Tell the caller to remember it, so this port opens quickly next time.
buf := make([]byte, 16) if slowBoot && !cfg.SlowBoot && m.onSlowBoot != nil {
_ = p.SetReadTimeout(1 * time.Second) m.onSlowBoot(cfg.Port)
if n, _ := p.Read(buf); n > 0 {
ver = int(buf[0])
} }
_ = p.SetReadTimeout(200 * time.Millisecond) _ = p.SetReadTimeout(200 * time.Millisecond)
@@ -515,7 +534,10 @@ func cmdName(b []byte) string {
func firmwareFamily(ver int) string { func firmwareFamily(ver int) string {
switch { switch {
case ver == 0: case ver == 0:
return "no reply — the keyer did not answer Host Open" // Unreachable from a successful connect — the handshake fails rather
// than returning a version of zero. Kept so a future caller that skips
// hostOpen cannot print "WK1 (v0)" and be believed.
return "unknown firmware (no version returned)"
case ver < 20: case ver < 20:
return fmt.Sprintf("WK1 (v%d)", ver) return fmt.Sprintf("WK1 (v%d)", ver)
case ver < 30: case ver < 30:
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.25.0" appVersion = "0.25.1"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.