Compare commits

..
22 Commits
Author SHA1 Message Date
rouggy d8f0fd7b4f chore: release v0.26.20 2026-08-27 23:47:14 +02:00
rouggy 4a942a04c3 docs(changelog): name the SOTA cluster server in the SOTA entry 2026-08-27 23:27:26 +02:00
rouggy ecae68ca79 merge: matrix call mark, Icom scope, KPA chip, SOTA column, awards slots, LoTW download fixes 2026-08-27 23:24:34 +02:00
rouggy 9270b227b0 fix(lotw): send a date even for 'all', and give the report time to arrive
Without qso_qslsince LoTW does not answer 'every confirmation' — it answers
with a few recent ones, as a 200 and a valid ADIF. 'All' therefore downloaded a
nearly empty report and said 'matched 1 of 1' on an account holding twelve
thousand. It now asks from 1945-11-15, older than any QSO LoTW accepts.

And the request had two minutes: LoTW spends several of them building a full
account before the first byte, so asking for everything ended in 'context
deadline exceeded while reading body' — a network-shaped message for a
too-short deadline. Twenty minutes, and the read cap raised past the 18 MB such
a report actually weighs.
2026-08-27 23:15:54 +02:00
rouggy cff590fe8a fix(lotw): keep another station's confirmations out, and show a suspect report
Unscoped, the report carries every station on the account — including the calls
belonging to another profile's logbook. Those match nothing here, and with 'add
the ones not found' ticked they would pour a second log into this one. The
station callsigns this logbook actually holds are now the filter: a portable
worked here is kept, an expedition call never used here is skipped and counted.

A near-empty report is also the failure that reads as success — 'matched 1 of 1'
where the account holds twelve thousand. Under 4 KB, what LoTW answered is shown
verbatim instead.
2026-08-27 23:12:44 +02:00
rouggy 4366083152 feat(lotw): let the download cover every callsign on the account
The download passes qso_owncall so one profile does not pull another's
confirmations. That scope also hides them: a QSO made as F4BPO/P is confirmed at
LoTW and can never come back to an F4BPO profile, so it stays unconfirmed here
for good while the ARRL counts it — found while reconciling a DXCC account
against the panel, where it was the whole of the CW gap (Jersey, worked as
F4BPO/P).

Off by default: the scope is right for anyone whose profiles are separate
stations.
2026-08-27 22:43:46 +02:00
rouggy 8865af35fd feat(qsl): a QRZ button beside the paper-QSL search 2026-08-27 21:53:59 +02:00
rouggy bfad51268b style(awards): fade the confirmed cells while chasing the ones to confirm
The filter keeps a REFERENCE for its unconfirmed slots, so its confirmed ones
come along — and green on grey means the answer is the quietest thing in the
row. Faded to a quarter under that filter only, not hidden: which band is
already done is part of deciding which one to chase.
2026-08-27 21:45:46 +02:00
rouggy 0e77e8d61f feat(awards): find the slots worked but not confirmed
The Challenge line says 1832 worked against 1554 confirmed and stops there —
278 band-slots waiting for a card with nothing pointing at them. Every existing
filter works per REFERENCE, so an entity confirmed on 20 m answers 'confirmed'
and its unconfirmed 15 m contact stays invisible.

The new filter keeps the references holding at least one such slot, and the
count beside the reference total is the gap itself, recomputed over whatever the
other filters left on screen and over the bands actually displayed — so it
always adds up to the columns in front of the operator.
2026-08-27 21:38:25 +02:00
rouggy 08d316b1e0 feat(cluster): a SOTA column, and a spot click that credits the summit
The SOTA feeds put the summit in the spot's own text, so there is nothing to
poll: a regex on the comment gives the reference, and clicking the spot writes
SOTA@<ref> into the award references the way a park already wrote POTA@<ref>.

The regex is narrow on purpose — association/region-NNN, anchored — because
POTA (US-4475), WWFF (DLFF-0001) and portable callsigns (DL/SP9DPM/P) share
that field and none of them is a summit. Pinned by a table test.

Off by default: the reference is only there on the summit feeds, and an empty
column for every operator who does not chase them is worse than a checkbox.
2026-08-27 21:33:17 +02:00
rouggy b8796369ba fix(amp): the KPA was missing from the status-bar chip
The chip read a.spe ?? a.acom ?? a.pgxl, so a KPA fell through to the
not-connected fallback: red on a working amplifier, and a click that opened the
settings instead of switching to STANDBY. Reported from a KPA500 whose panel
showed band, SWR and temperature at the same time.

The card's offline text also borrowed the Acom's string, so a KPA that dropped
its link announced itself as an Acom.
2026-08-27 18:55:55 +02:00
rouggy fa1c41388d fix(icom): say when a radio has no scope stream instead of drawing nothing
The CI-V trace settles it: the IC-7851 answers 27 10 01 with FB and 27 14 00
with its mode, but refuses 27 11 in BOTH shapes — it controls its scope over
CI-V and never streams it. Its last firmware is from 2016, older than the
waveform stream itself; Hamlib's caps claim otherwise, the radio is the
authority.

Rejected both ways is a permanent answer, so it is remembered and reported:
the panadapter says the radio does not send its scope, which is the one thing a
black rectangle could not say.
2026-08-27 16:30:39 +02:00
rouggy d2cfad490a revert(icom): keep 0x27 0x11 as the scope data switch, log what the retry got
Hamlib documents the spectrum scope on the IC-785x, so a rejected 0x27 0x11 is
not proof the command is missing — and taking 0x27 0x10 for the data switch
would have blanked that operator's own scope screen when OpsLog disabled it.

Both shapes were refused within 96 ms, so neither was a timeout, and guessing
the next subcommand is how the last two rounds went. The retry now logs the
second attempt's own error and the failure points at the CI-V trace.
2026-08-27 16:25:29 +02:00
rouggy 69d78ba8c0 fix(icom): fall back to 0x27 0x10 when a rig has no 0x27 0x11
The IC-7851 rejects 0x27 0x11 in BOTH shapes: on that radio 0x27 0x10 is the
waveform-output switch itself, not a display switch. Rejected both ways, take
0x10 as the output command and switch it off on disable too — there it stops a
stream rather than blanking the operator's own scope screen.

A mode/span/edge answer also settles the selector question directly: three bytes
or more means the rig wants the main/sub form. Worth reading, because a
wrong-shaped SET is answered with silence on this firmware, and a timeout is
something the rejection retry cannot act on — which is why the fixed/centre
sets were timing out.
2026-08-27 16:18:55 +02:00
rouggy 9cf1984fb2 fix(icom): ask the radio which shape its scope commands take
The IC-7851 rejects "27 11 01" and wants "27 11 00 01" — the main/sub selector
its two scopes need. The shape was picked from a list of CI-V addresses, so an
address not on it got the wrong one and the scope stayed blank with a rejection
in the log and nothing on screen.

A rejection is an unambiguous answer, so a rejected 0x27 set is now re-sent in
the other shape and the winner remembered. A TIMEOUT deliberately is not
retried: several firmwares never ack a 0x27 set, and treating silence as a
wrong guess would flip a rig that was already working.
2026-08-27 16:14:53 +02:00
rouggy ee8fd32bf7 fix(matrix): dot the portable variants too
The header counts QSOs with the callsign through callMatch — RI1FJL/1 and
ZA/RI1FJL included — while the grid's own per-callsign columns matched the bare
string. A cell could hold one of the QSOs the header had counted and show
nothing.
2026-08-27 15:57:41 +02:00
rouggy f357dad629 feat(appearance): give the worked-with-this-call dot its own two colours
The dot is drawn over all five cell backgrounds, so it cannot borrow one of
their colours; it defaults to the theme foreground with a background ring and
now takes two overrides of its own, worked and confirmed, alongside the rest of
the matrix palette.
2026-08-27 15:29:01 +02:00
rouggy b3547364d4 style(matrix): move the worked-with-this-call dot in from the corner 2026-08-27 09:01:31 +02:00
rouggy 965ed4c792 feat(matrix): mark the slots already worked with the callsign in hand
The worked-before cell answered one question with two facts fused into it.
bandStatusCode takes the highest of them, so a confirmed entity outranks a
worked callsign — right for awards, wrong while chasing: a slot worked with the
DXpedition this morning reads "entity confirmed" and says nothing about it.

BandStatus now carries the callsign's own answer alongside the entity's, and the
cell draws a dot for it. The dot is deliberately identical everywhere — same
shape, same colour, over all five backgrounds — so it is read as one thing
rather than as five variants of the colour underneath it.
2026-08-27 08:55:37 +02:00
rouggy 32dbfbd04e chore(changelog): open 0.26.20 with the per-radio MY_RIG 2026-08-27 01:09:09 +02:00
rouggy a930a4f02d merge: MY_RIG follows the connected radio
Operating conditions describe a plan; a logged QSO asks which radio was
keying. With one rig those were the same answer, with several they are
not — so each saved radio carries its own MY_RIG, ahead of the per-band
station and doing nothing at all when left empty.
2026-08-27 01:08:19 +02:00
rouggy f98a831195 feat(cat): MY_RIG follows the radio that is connected
With one rig, MY_RIG belonged in Operating conditions and nowhere else.
With several, that becomes the wrong place: operating conditions describe
a PLAN — 'on 20 m I use the beam and the 7300' — while the question a
logged QSO asks is which radio was keying.

So each saved radio carries its own MY_RIG, consulted BEFORE the per-band
station. When the two disagree — a second rig borrowed for one evening on
20 m — the one that is transmitting is the true answer.

Left empty it changes nothing at all: the old chain (band default, then
the profile's rig) answers exactly as it did, which is what every
single-radio station will keep doing without touching a setting.
2026-08-27 01:03:27 +02:00
29 changed files with 606 additions and 94 deletions
+77 -1
View File
@@ -402,6 +402,7 @@ const (
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path" keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
keyExtLoTWStationLoc = "extsvc.lotw.station_location" keyExtLoTWStationLoc = "extsvc.lotw.station_location"
keyExtLoTWAllCalls = "extsvc.lotw.download_all_calls" // download confirmations for EVERY call on the account, not just this profile's
keyExtLoTWForceCall = "extsvc.lotw.force_station_callsign" // override STATION_CALLSIGN at sign time (e.g. F4BPO/P on the F4BPO cert) keyExtLoTWForceCall = "extsvc.lotw.force_station_callsign" // override STATION_CALLSIGN at sign time (e.g. F4BPO/P on the F4BPO cert)
keyExtLoTWKeyPassword = "extsvc.lotw.key_password" keyExtLoTWKeyPassword = "extsvc.lotw.key_password"
keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" // legacy single flag (migrated to upload_flags) keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" // legacy single flag (migrated to upload_flags)
@@ -3338,6 +3339,10 @@ func (a *App) applyStationDefaults(q *qso.QSO, includeIdentity bool) {
// Per-band rig/antenna from Operating conditions (the antenna ticked as // Per-band rig/antenna from Operating conditions (the antenna ticked as
// DEFAULT for this band) — the same auto-fill the entry strip does, applied // DEFAULT for this band) — the same auto-fill the entry strip does, applied
// here so imported QSOs get MY_RIG / MY_ANTENNA from the band defaults. // here so imported QSOs get MY_RIG / MY_ANTENNA from the band defaults.
// The radio that is CONNECTED names itself first — see activeRadioMyRig.
if q.MyRig == "" {
q.MyRig = a.activeRadioMyRig()
}
if a.operating != nil && q.Band != "" && (q.MyRig == "" || q.MyAntenna == "") { if a.operating != nil && q.Band != "" && (q.MyRig == "" || q.MyAntenna == "") {
if d, ok, _ := a.operating.BandDefault(a.ctx, p.ID, q.Band); ok { if d, ok, _ := a.operating.BandDefault(a.ctx, p.ID, q.Band); ok {
if q.MyRig == "" { if q.MyRig == "" {
@@ -8927,6 +8932,11 @@ func (a *App) clusterEventWorker() {
} }
} }
} }
// SOTA: the summit is in the spot's own text — the SOTA feeds put it
// there — so it costs a regex rather than an API lookup.
if s.SOTARef == "" {
s.SOTARef = cluster.SOTARefFrom(s.Comment)
}
// POTA: tag the spot when the DX station is currently activating a park. // POTA: tag the spot when the DX station is currently activating a park.
if a.pota != nil { if a.pota != nil {
if info, ok := a.pota.Lookup(s.DXCall); ok { if info, ok := a.pota.Lookup(s.DXCall); ok {
@@ -12087,6 +12097,17 @@ func manualRefFor(existing, code string) string {
return "" return ""
} }
// GetLoTWDownloadAllCalls reports whether the LoTW download ignores the
// profile's own call and pulls every callsign on the account.
func (a *App) GetLoTWDownloadAllCalls() bool {
return a.settingOr(keyExtLoTWAllCalls, "") == "1"
}
// SetLoTWDownloadAllCalls stores that choice.
func (a *App) SetLoTWDownloadAllCalls(on bool) {
a.setSetting(keyExtLoTWAllCalls, map[bool]string{true: "1", false: "0"}[on])
}
// DownloadConfirmations pulls confirmed QSOs from a service and updates the // DownloadConfirmations pulls confirmed QSOs from a service and updates the
// matching local QSOs' received status. LoTW only for now (the canonical // matching local QSOs' received status. LoTW only for now (the canonical
// confirmation system); runs in the background emitting the same // confirmation system); runs in the background emitting the same
@@ -12158,7 +12179,30 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
case extsvc.ServiceLoTW: case extsvc.ServiceLoTW:
sinceDate := resolveSince(keyExtLoTWLastDownload) sinceDate := resolveSince(keyExtLoTWLastDownload)
ownCall := a.uploadOwnerCall(extsvc.ServiceLoTW) ownCall := a.uploadOwnerCall(extsvc.ServiceLoTW)
// A LoTW account holds every call its owner operates — F4BPO, F4BPO/P,
// TM2Q — and the download is normally scoped to the profile's own call so
// one profile does not pull another's confirmations. That scope also
// silently hides them: a QSO made as F4BPO/P is confirmed at LoTW and can
// never be downloaded from the F4BPO profile, so it stays unconfirmed here
// for good while the ARRL counts it. Off by default, because the scope is
// right for anyone whose profiles are separate stations.
if a.settingOr(keyExtLoTWAllCalls, "") == "1" {
ownCall = ""
}
callLabel := ownCall callLabel := ownCall
// Unscoped, the report carries every station on the account — including
// the ones belonging to ANOTHER profile's logbook (a Vietnam expedition,
// say). Those confirmations have nothing to match here, and with "add the
// ones not found" ticked they would pour a second log into this one. So
// the station callsigns this logbook actually holds become the filter:
// F4BPO/P is kept because it was worked here, XV9Q is skipped because it
// never was.
var ownStations map[string]bool
if ownCall == "" {
if st, e := a.qso.StationCallsigns(ctx); e == nil && len(st) > 0 {
ownStations = st
}
}
if callLabel == "" { if callLabel == "" {
callLabel = "all callsigns" callLabel = "all callsigns"
} }
@@ -12175,6 +12219,19 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
return return
} }
emit(fmt.Sprintf("LoTW returned %d KB of ADIF", len(adifText)/1024)) emit(fmt.Sprintf("LoTW returned %d KB of ADIF", len(adifText)/1024))
// A report far smaller than the account justifies is the one failure that
// looks like success: LoTW answers 200 with a near-empty ADIF when it
// disagrees with the query (an unknown callsign in qso_owncall, a login
// that half-worked). Show its own header rather than leaving "matched 1 of
// 1" to be read as "you have one confirmation".
if len(adifText) < 4096 {
head := strings.TrimSpace(adifText)
if len(head) > 400 {
head = head[:400]
}
emit("The report is unexpectedly small — what LoTW actually sent:")
emit(" " + strings.Join(strings.Fields(head), " "))
}
keyIDs, kerr := a.qso.DedupeKeyIDs(ctx) keyIDs, kerr := a.qso.DedupeKeyIDs(ctx)
if kerr != nil { if kerr != nil {
emit("Error reading local log: " + kerr.Error()) emit("Error reading local log: " + kerr.Error())
@@ -12192,6 +12249,7 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"}) sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"})
var items []ConfirmationItem var items []ConfirmationItem
var unmatched []string var unmatched []string
skippedOtherStation := 0
perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error { perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
if ctx.Err() != nil { if ctx.Err() != nil {
return ctx.Err() // window closed / superseded return ctx.Err() // window closed / superseded
@@ -12200,6 +12258,16 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
if !ok { if !ok {
return nil return nil
} }
// Another station's confirmation — see ownStations above. Counted so
// the report says how many were left alone rather than silently
// dropping a third of the file.
if ownStations != nil {
st := strings.ToUpper(strings.TrimSpace(rec["station_callsign"]))
if st != "" && !ownStations[st] {
skippedOtherStation++
return nil
}
}
total++ total++
date := rec["qslrdate"] date := rec["qslrdate"]
if date == "" { if date == "" {
@@ -12280,6 +12348,9 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
} else { } else {
emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total)) emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total))
} }
if skippedOtherStation > 0 {
emit(fmt.Sprintf(" (%d confirmation(s) skipped — made under a callsign this logbook has never used)", skippedOtherStation))
}
if byClass > 0 { if byClass > 0 {
// Said out loud rather than folded silently into the total: these // Said out loud rather than folded silently into the total: these
// matched on the mode CLASS, not the mode. LoTW hands back "DATA" for // matched on the mode CLASS, not the mode. LoTW hands back "DATA" for
@@ -13401,7 +13472,12 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
// ── Operating-conditions stamp ── // ── Operating-conditions stamp ──
// Pre-fill MY_RIG / MY_ANTENNA / TX_PWR from the default antenna for // Pre-fill MY_RIG / MY_ANTENNA / TX_PWR from the default antenna for
// this band (if the user has configured Operating conditions). // this band (if the user has configured Operating conditions) — after the
// connected radio has had its say, since it knows which rig is keying and
// the band default only knows which one was planned.
if q.MyRig == "" {
q.MyRig = a.activeRadioMyRig()
}
if a.operating != nil && a.profiles != nil { if a.operating != nil && a.profiles != nil {
if p, err := a.profiles.Active(a.ctx); err == nil { if p, err := a.profiles.Active(a.ctx); err == nil {
if d, ok2, _ := a.operating.BandDefault(a.ctx, p.ID, q.Band); ok2 { if d, ok2, _ := a.operating.BandDefault(a.ctx, p.ID, q.Band); ok2 {
+31
View File
@@ -43,6 +43,14 @@ type RadioConfig struct {
// the CAT panel has always edited, so a saved radio is exactly "what the // the CAT panel has always edited, so a saved radio is exactly "what the
// settings said the day it was saved". // settings said the day it was saved".
Settings CATSettings `json:"settings"` Settings CATSettings `json:"settings"`
// MyRig is what goes into MY_RIG on a QSO made with this radio.
//
// It belongs here rather than in Operating conditions once there is more
// than one rig: the operating conditions describe a PLAN — "on 20 m I use
// the beam and the 7300" — while this is the fact of which radio is keying.
// Left empty, nothing changes: the old chain (band default, then the
// profile's rig) answers exactly as it did.
MyRig string `json:"my_rig"`
} }
// radioLabel is what the status bar shows when the operator never named one. // radioLabel is what the status bar shows when the operator never named one.
@@ -204,3 +212,26 @@ func (a *App) syncActiveRadio(s CATSettings) {
} }
} }
} }
// activeRadioMyRig is the MY_RIG of the radio currently connected, or "".
//
// Consulted BEFORE the per-band default, and deliberately: the band default is
// what the operator planned to use on that band, this is which radio is
// actually on the air. When they disagree — a second rig borrowed for one
// evening on 20 m — the one that is transmitting is the true answer.
func (a *App) activeRadioMyRig() string {
if a.settings == nil || strings.TrimSpace(a.settingOr(keyRadiosList, "")) == "" {
return "" // no list was ever made: nothing to say, nothing changes
}
list, err := a.GetRadios()
if err != nil {
return ""
}
id := a.ActiveRadioID()
for _, r := range list {
if r.ID == id {
return strings.TrimSpace(r.MyRig)
}
}
return ""
}
+7
View File
@@ -174,6 +174,11 @@ type MatrixColors struct {
EntityWorked string `json:"entity_worked"` EntityWorked string `json:"entity_worked"`
NotWorked string `json:"not_worked"` NotWorked string `json:"not_worked"`
CurrentEntry string `json:"current_entry"` CurrentEntry string `json:"current_entry"`
// The dot marking a slot already worked with the callsign in hand. It is a
// mark rather than a background, so it needs its own two colours: it is
// drawn on top of all five of the above and must stay legible over each.
MarkWorked string `json:"mark_worked"`
MarkConfirmed string `json:"mark_confirmed"`
} }
// normMatrixColors keeps only plain hex values. Anything else becomes "" — i.e. // normMatrixColors keeps only plain hex values. Anything else becomes "" — i.e.
@@ -196,6 +201,8 @@ func normMatrixColors(c MatrixColors) MatrixColors {
EntityWorked: clean(c.EntityWorked), EntityWorked: clean(c.EntityWorked),
NotWorked: clean(c.NotWorked), NotWorked: clean(c.NotWorked),
CurrentEntry: clean(c.CurrentEntry), CurrentEntry: clean(c.CurrentEntry),
MarkWorked: clean(c.MarkWorked),
MarkConfirmed: clean(c.MarkConfirmed),
} }
} }
+26
View File
@@ -1,4 +1,30 @@
[ [
{
"version": "0.26.20",
"date": "",
"en": [
"Each radio carries its own MY_RIG (Settings → CAT), written on every QSO made with it — ahead of the per-band station in Operating conditions, which says what you planned to use rather than which radio is keying. Left empty, nothing changes.",
"Worked-before matrix: a dot in the corner of a cell now means the callsign you are working has already been worked on that slot, whatever colour the entity status gave the cell. Its two colours (worked / confirmed with this callsign) are in Appearance with the other matrix colours.",
"Icom spectrum scope: the command shape (with or without the main/sub selector) is now worked out from the radios own answers instead of a list of models. A radio that has scope control but no waveform stream over CI-V — the IC-7851 — says so in the panel rather than showing a black rectangle.",
"Elecraft KPA: the status-bar chip now shows the amplifier as connected and switches it between OPERATE and STANDBY like the other brands, and an offline KPA no longer calls itself an Acom.",
"Cluster: a SOTA column, read from the summit reference the SOTA feeds put in the spot comment. Clicking the spot fills the QSOs SOTA award reference, as a POTA spot already did. Turn the column on in Columns. Add cluster.sota.org.uk:7300 as a server to receive the summit spots.",
"Awards: a \"Slots to confirm\" filter and a running count beside the reference total, so the gap between worked and confirmed band-slots — the Challenge difference — can be seen reference by reference instead of only as two numbers.",
"QSL Manager: a QRZ button next to the Paper QSL search, opening the callsign on QRZ.com.",
"LoTW: an \"All my callsigns\" option beside the download. The download is scoped to the profiles own callsign, so a QSO made as a portable or contest call was confirmed at LoTW and never marked here. Confirmations made under a callsign this logbook has never used are skipped, and a suspiciously small report now shows what LoTW actually answered.",
"LoTW download: \"All\" really means all — without a date LoTW answered with a handful of recent confirmations, which looked like a successful download of an empty account. A full account also has time to arrive: the two-minute limit that ended in \"context deadline exceeded\" is now twenty."
],
"fr": [
"Chaque radio porte son propre MY_RIG (Réglages → CAT), inscrit sur chaque QSO fait avec elle — avant la station par bande des Conditions de trafic, qui dit ce qui était prévu et non quelle radio émet. Laissé vide, rien ne change.",
"Matrice des contacts : un point dans le coin d'une case indique que l'indicatif en cours a déjà été contacté sur ce créneau, quelle que soit la couleur donnée par le statut de l'entité. Ses deux couleurs (contacté / confirmé avec cet indicatif) se règlent dans Apparence avec les autres couleurs de la matrice.",
"Scope Icom : la forme des commandes (avec ou sans le sélecteur main/sub) est déduite des réponses de la radio au lieu d'une liste de modèles. Une radio qui pilote son scope mais ne l'envoie pas en CI-V — l'IC-7851 — l'indique dans le panneau au lieu d'afficher un rectangle noir.",
"Elecraft KPA : la pastille de la barre d'état montre enfin l'amplificateur comme connecté et bascule OPERATE / STANDBY comme les autres marques, et un KPA hors ligne ne s'annonce plus comme un Acom.",
"Cluster : une colonne SOTA, lue dans la référence de sommet que les flux SOTA mettent dans le commentaire du spot. Cliquer le spot remplit la référence SOTA du QSO, comme le faisait déjà un spot POTA. Colonne à activer dans Colonnes. Ajoutez le serveur cluster.sota.org.uk:7300 pour recevoir les spots de sommets.",
"Awards : un filtre « Slots à confirmer » et un compteur à côté du total de références, pour voir l'écart entre créneaux contactés et confirmés — la différence du Challenge — référence par référence et non plus seulement en deux chiffres.",
"Gestionnaire QSL : un bouton QRZ à côté de la recherche QSL papier, qui ouvre l'indicatif sur QRZ.com.",
"LoTW : une option « Tous mes indicatifs » à côté du téléchargement. Celui-ci est limité à l'indicatif du profil, si bien qu'un QSO fait sous un indicatif portable ou de contest était confirmé chez LoTW sans jamais être marqué ici. Les confirmations faites sous un indicatif que ce carnet n'a jamais utilisé sont ignorées, et un rapport anormalement petit affiche désormais ce que LoTW a réellement répondu.",
"Téléchargement LoTW : « Tout » veut enfin dire tout — sans date, LoTW ne renvoyait qu'une poignée de confirmations récentes, ce qui ressemblait à un téléchargement réussi d'un compte vide. Un compte complet a aussi le temps d'arriver : la limite de deux minutes, qui finissait en « context deadline exceeded », passe à vingt."
]
},
{ {
"version": "0.26.19", "version": "0.26.19",
"date": "", "date": "",
+22 -14
View File
@@ -3275,7 +3275,7 @@ export default function App() {
function handleSpotSelect(s: any) { function handleSpotSelect(s: any) {
if (!s?.dx_call?.trim()) return; if (!s?.dx_call?.trim()) return;
onCallsignInput(s.dx_call, { force: true }); onCallsignInput(s.dx_call, { force: true });
applySpotPOTA((s as any).pota_ref); applySpotRefs((s as any).pota_ref, (s as any).sota_ref);
} }
function handleSpotClick(s: any) { function handleSpotClick(s: any) {
@@ -3300,7 +3300,7 @@ export default function App() {
FlexZoomForSpot(m ?? '', s.freq_hz ?? 0).catch(() => {}); FlexZoomForSpot(m ?? '', s.freq_hz ?? 0).catch(() => {});
if (m) applyModeFromSpot(m); if (m) applyModeFromSpot(m);
onCallsignInput(s.dx_call, { force: true }); onCallsignInput(s.dx_call, { force: true });
applySpotPOTA((s as any).pota_ref); applySpotRefs((s as any).pota_ref, (s as any).sota_ref);
if (s.dx_call?.trim()) restartRecordingForNewTarget(s.dx_call); if (s.dx_call?.trim()) restartRecordingForNewTarget(s.dx_call);
} }
@@ -3761,14 +3761,14 @@ export default function App() {
restartRecordingForNewTarget(call); restartRecordingForNewTarget(call);
// The park, like a click in the band map: the radio reports only a // The park, like a click in the band map: the radio reports only a
// callsign, so the backend looks it up again before sending the event. // callsign, so the backend looks it up again before sending the event.
applySpotPOTA(String(p?.pota_ref ?? '')); applySpotRefs(String(p?.pota_ref ?? ''), String(p?.sota_ref ?? ''));
}); });
// Clicking a spot on the ExpertSDR (TCI) panorama fills the call, like Flex. // Clicking a spot on the ExpertSDR (TCI) panorama fills the call, like Flex.
const unsubTciSpot = EventsOn('tci:spot_clicked', (p: any) => { const unsubTciSpot = EventsOn('tci:spot_clicked', (p: any) => {
const call = String(p?.call ?? ''); const call = String(p?.call ?? '');
if (!applyUdpCall(call, true)) return; if (!applyUdpCall(call, true)) return;
restartRecordingForNewTarget(call); restartRecordingForNewTarget(call);
applySpotPOTA(String(p?.pota_ref ?? '')); applySpotRefs(String(p?.pota_ref ?? ''), String(p?.sota_ref ?? ''));
}); });
const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => { const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => {
const total = Number(p?.total ?? 0); const total = Number(p?.total ?? 0);
@@ -4796,13 +4796,17 @@ export default function App() {
} }
wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150); wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150);
} }
// applySpotPOTA sets the QSO's POTA award reference(s) from a clicked spot's // Award references carried by the spot itself: the park a station is
// park ref ("US-4164" or n-fer "US-1,US-2"). Empty ref clears it (fresh // activating, the summit it is on, or both. Written into award_refs the same
// target). Routed to the pota_ref column at save via applyAwardRefs. // way for each, so logging the contact credits it without retyping a
function applySpotPOTA(potaRef?: string) { // reference that was on screen.
const refs = String(potaRef || '') function applySpotRefs(potaRef?: string, sotaRef?: string) {
.split(/[,;]/).map((x) => x.trim().toUpperCase()).filter(Boolean); const split = (v?: string) => String(v || '').split(/[,;]/).map((x) => x.trim().toUpperCase()).filter(Boolean);
setDetails((d) => ({ ...d, award_refs: refs.map((r) => `POTA@${r}`).join(';') })); const refs = [
...split(potaRef).map((r) => `POTA@${r}`),
...split(sotaRef).map((r) => `SOTA@${r}`),
];
setDetails((d) => ({ ...d, award_refs: refs.join(';') }));
} }
function onCallsignInput(v: string, opts?: { force?: boolean }) { function onCallsignInput(v: string, opts?: { force?: boolean }) {
// Programmatic call-sets (force: spot click, UDP, external app) count as // Programmatic call-sets (force: spot click, UDP, external app) count as
@@ -8423,9 +8427,13 @@ export default function App() {
STANDBY, red = offline. CLICK toggles OPERATE STANDBY (optimistic STANDBY, red = offline. CLICK toggles OPERATE STANDBY (optimistic
flip, the 2s poll reconciles); offline click opens the settings. */} flip, the 2s poll reconciles); offline click opens the settings. */}
{ampSts.map((a: any) => { {ampSts.map((a: any) => {
const isPGXL = !a.spe && !a.acom; // Every brand must be listed here. A KPA was not, so its chip
// read the fallback — permanently red on a connected amplifier,
// and clicking it opened the settings instead of switching to
// STANDBY.
const isPGXL = !a.spe && !a.acom && !a.kpa;
const viaFlex = isPGXL && !!flexAmp?.amp_available; const viaFlex = isPGXL && !!flexAmp?.amp_available;
const raw = a.spe ?? a.acom ?? a.pgxl ?? { connected: false }; const raw = a.spe ?? a.acom ?? a.kpa ?? a.pgxl ?? { connected: false };
const connected = !!raw.connected || viaFlex; const connected = !!raw.connected || viaFlex;
const operate = viaFlex ? !!flexAmp.amp_operate : !!raw.operate; const operate = viaFlex ? !!flexAmp.amp_operate : !!raw.operate;
const dot = !connected ? 'bg-danger' : operate ? 'bg-success' : 'bg-warning'; const dot = !connected ? 'bg-danger' : operate ? 'bg-success' : 'bg-warning';
@@ -8435,7 +8443,7 @@ export default function App() {
const want = !operate; const want = !operate;
if (viaFlex) setFlexAmp((f: any) => ({ ...f, amp_operate: want })); if (viaFlex) setFlexAmp((f: any) => ({ ...f, amp_operate: want }));
else setAmpSts((l) => l.map((x: any) => x.id === a.id else setAmpSts((l) => l.map((x: any) => x.id === a.id
? { ...x, spe: x.spe && { ...x.spe, operate: want }, acom: x.acom && { ...x.acom, operate: want }, pgxl: x.pgxl && { ...x.pgxl, operate: want } } ? { ...x, spe: x.spe && { ...x.spe, operate: want }, acom: x.acom && { ...x.acom, operate: want }, kpa: x.kpa && { ...x.kpa, operate: want }, pgxl: x.pgxl && { ...x.pgxl, operate: want } }
: x)); : x));
(viaFlex ? FlexAmpOperate(want) : AmpOperate(a.id, want)).catch(() => {}); (viaFlex ? FlexAmpOperate(want) : AmpOperate(a.id, want)).catch(() => {});
}; };
+1 -1
View File
@@ -194,7 +194,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
</div> </div>
<span className={cn('inline-flex items-center gap-1.5 text-sm', kpa.connected ? 'text-muted-foreground' : 'text-danger')}> <span className={cn('inline-flex items-center gap-1.5 text-sm', kpa.connected ? 'text-muted-foreground' : 'text-danger')}>
<span className={cn('size-2 rounded-full', kpa.connected ? 'bg-success' : 'bg-danger')} /> <span className={cn('size-2 rounded-full', kpa.connected ? 'bg-success' : 'bg-danger')} />
{kpa.connected ? (kpa.tuning ? t('flxp.kpaTuning') : (kpa.power_on ? 'ON' : 'OFF')) : t('flxp.acomOffline')} {kpa.connected ? (kpa.tuning ? t('flxp.kpaTuning') : (kpa.power_on ? 'ON' : 'OFF')) : t('flxp.kpaOffline')}
</span> </span>
{kpa.connected && ( {kpa.connected && (
<span className="text-sm font-mono text-muted-foreground tabular-nums"> <span className="text-sm font-mono text-muted-foreground tabular-nums">
@@ -127,6 +127,12 @@ function MatrixColorsSection() {
<span className="inline-block w-7 h-5 rounded bg-mx-dx-work" /> <span className="inline-block w-7 h-5 rounded bg-mx-dx-work" />
<span className="inline-block w-7 h-5 rounded bg-mx-none" /> <span className="inline-block w-7 h-5 rounded bg-mx-none" />
<span className="inline-block w-7 h-5 rounded bg-mx-none ring-2 ring-mx-cur ring-inset" /> <span className="inline-block w-7 h-5 rounded bg-mx-none ring-2 ring-mx-cur ring-inset" />
<span className="relative inline-block w-7 h-5 rounded bg-mx-dx-conf">
<span className="absolute top-[4px] right-[4px] size-[5px] rounded-full bg-mx-mark-work ring-1 ring-background" />
</span>
<span className="relative inline-block w-7 h-5 rounded bg-mx-dx-conf">
<span className="absolute top-[4px] right-[4px] size-[5px] rounded-full bg-mx-mark-conf ring-1 ring-background" />
</span>
</div> </div>
<button type="button" onClick={reset} <button type="button" onClick={reset}
+40 -4
View File
@@ -65,6 +65,19 @@ function cellStatus(r: AwardRef, band: string): CellStatus {
if (r.bands?.includes(band)) return 'worked'; if (r.bands?.includes(band)) return 'worked';
return 'none'; return 'none';
} }
// slotsToConfirm counts the band-slots worked with this reference and not yet
// confirmed on any of them — the QSLs still outstanding, one per cell showing W.
//
// It is the difference the Challenge line makes visible in the aggregate (1832
// worked against 1554 confirmed) without saying WHERE it is. Counted over the
// bands actually on screen, so it always adds up to the columns in front of the
// operator rather than to a band set they filtered out.
function slotsToConfirm(r: AwardRef, bands: string[]): number {
let n = 0;
for (const b of bands) if (cellStatus(r, b) === 'worked') n++;
return n;
}
const CELL_STYLE: Record<CellStatus, string> = { const CELL_STYLE: Record<CellStatus, string> = {
validated: 'bg-success text-success-foreground', validated: 'bg-success text-success-foreground',
confirmed: 'bg-warning text-warning-foreground', confirmed: 'bg-warning text-warning-foreground',
@@ -112,7 +125,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
const [refSearch, setRefSearch] = useState(''); const [refSearch, setRefSearch] = useState('');
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid'); const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all'); const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf' | 'slots_notconf'>('all');
// Mode filter, stacked ON TOP of the status one. "Worked on CW but not // Mode filter, stacked ON TOP of the status one. "Worked on CW but not
// confirmed" is two questions at once, and answering only one of them is what // confirmed" is two questions at once, and answering only one of them is what
// sends an operator to a spreadsheet. // sends an operator to a spreadsheet.
@@ -304,6 +317,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
if (refFilter === 'worked' && !r.worked) return false; if (refFilter === 'worked' && !r.worked) return false;
if (refFilter === 'notworked' && r.worked) return false; if (refFilter === 'notworked' && r.worked) return false;
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false; if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
// Worked-not-confirmed by SLOT, not by reference: an entity confirmed on
// 20 m still has a 15 m contact waiting for its card, and every filter
// above answers "no" for it because the entity itself is confirmed.
if (refFilter === 'slots_notconf' && slotsToConfirm(r, gridBands) === 0) return false;
if (modeFilter !== 'all' && refFilter !== 'notworked') { if (modeFilter !== 'all' && refFilter !== 'notworked') {
// A reference never worked has no mode, so "not worked" plus a mode is // A reference never worked has no mode, so "not worked" plus a mode is
// a contradiction: the mode filter stands aside rather than emptying // a contradiction: the mode filter stands aside rather than emptying
@@ -339,7 +356,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
} }
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir; return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
}); });
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]); }, [current, refSearch, refFilter, modeFilter, refSort, refSortDir, gridBands]);
// The gap itself, over whatever the other filters left on screen: the number
// of cells an operator would have to turn green to close it.
const slotGap = useMemo(
() => filteredRefs.reduce((n, r) => n + slotsToConfirm(r, gridBands), 0),
[filteredRefs, gridBands],
);
// The group column earns its width only when the list actually carries one // The group column earns its width only when the list actually carries one
// (DXCC prefixes, POTA locations); most custom lists have none. For DXCC the // (DXCC prefixes, POTA locations); most custom lists have none. For DXCC the
@@ -468,7 +492,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
<Input className="h-8 w-56 pl-7 text-sm" placeholder={t('awp.filterReferences')} value={refSearch} onChange={(e) => setRefSearch(e.target.value)} /> <Input className="h-8 w-56 pl-7 text-sm" placeholder={t('awp.filterReferences')} value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
</div> </div>
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm"> <div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
{([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')]] as const).map(([k, label]) => ( {([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')], ['slots_notconf', t('awp.filterSlotsNotCfmd')]] as const).map(([k, label]) => (
<button key={k} onClick={() => setRefFilter(k)} <button key={k} onClick={() => setRefFilter(k)}
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}> className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
{label} {label}
@@ -484,6 +508,11 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
))} ))}
</div> </div>
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span> <span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
{slotGap > 0 && (
<span className="text-xs text-muted-foreground" title={t('awp.slotGapTip')}>
· <span className="font-semibold text-foreground">{slotGap}</span> {t('awp.slotGap')}
</span>
)}
{/* Only for an award scoped to a DXCC entity. "In this award's {/* Only for an award scoped to a DXCC entity. "In this award's
scope but with no reference" needs a scope to be in: on a scope but with no reference" needs a scope to be in: on a
worldwide reference award — POTA, SOTA, IOTA, WWFF — every worldwide reference award — POTA, SOTA, IOTA, WWFF — every
@@ -604,7 +633,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
<td key={b} className="border-b border-l border-border/30 p-0 text-center"> <td key={b} className="border-b border-l border-border/30 p-0 text-center">
{s === 'none' ? <span className="block w-11 h-7" /> : ( {s === 'none' ? <span className="block w-11 h-7" /> : (
<button <button
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110')} className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110',
// Chasing the slots still to confirm, the
// confirmed ones are context, not the answer:
// a row is kept for its W cells and its V
// cells would otherwise be the loudest thing
// on it. Faded rather than hidden — which
// band is already done is worth seeing.
refFilter === 'slots_notconf' && s !== 'worked' && 'opacity-25')}
title={t('awp.cellTitle', { ref: r.ref, band: b })} title={t('awp.cellTitle', { ref: r.ref, band: b })}
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })} onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
>{CELL_LABEL[s]}</button> >{CELL_LABEL[s]}</button>
+49 -8
View File
@@ -81,23 +81,44 @@ const STATUS_CLASSES: Record<string, string> = {
// i18n keys the Appearance panel's colour pickers use, so the two can never // i18n keys the Appearance panel's colour pickers use, so the two can never
// disagree about which green is which. swatch = the background class (or a // disagree about which green is which. swatch = the background class (or a
// special ring marker for the current-entry cell). // special ring marker for the current-entry cell).
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [ const LEGEND: { swatch: string; ring?: boolean; mark?: string; label: string }[] = [
{ swatch: 'bg-mx-call-conf', label: 'mx.callConf' }, { swatch: 'bg-mx-call-conf', label: 'mx.callConf' },
{ swatch: 'bg-mx-call-work', label: 'mx.callWork' }, { swatch: 'bg-mx-call-work', label: 'mx.callWork' },
{ swatch: 'bg-mx-dx-conf', label: 'mx.dxConf' }, { swatch: 'bg-mx-dx-conf', label: 'mx.dxConf' },
{ swatch: 'bg-mx-dx-work', label: 'mx.dxWork' }, { swatch: 'bg-mx-dx-work', label: 'mx.dxWork' },
{ swatch: 'bg-mx-none', label: 'mx.none' }, { swatch: 'bg-mx-none', label: 'mx.none' },
{ swatch: 'bg-mx-none', ring: true, label: 'mx.current' }, { swatch: 'bg-mx-none', ring: true, label: 'mx.current' },
{ swatch: 'bg-mx-none', mark: 'w', label: 'mx.markWork' },
{ swatch: 'bg-mx-none', mark: 'c', label: 'mx.markConf' },
]; ];
function cellTitle(t: (k: string) => string, band: string, cls: string, status: string, current: boolean): string { // CallMark — "this callsign has already been worked on this slot".
//
// Drawn the same way on every cell, whatever colour the entity status gave it:
// the operator learns one shape and reads it without first working out what the
// background means. Only the fill changes, and only with the callsign's own
// state (worked / confirmed) — never with the entity's. The ring is the theme
// background, which is what keeps the dot legible over all five cell colours.
function CallMark({ state = 'w' }: { state?: string }) {
return (
<span
className={cn(
'pointer-events-none absolute top-[4px] right-[4px] size-[5px] rounded-full ring-1 ring-background',
state === 'c' ? 'bg-mx-mark-conf' : 'bg-mx-mark-work',
)}
/>
);
}
function cellTitle(t: (k: string) => string, band: string, cls: string, status: string, current: boolean, call = ''): string {
const desc = const desc =
status === 'call_c' ? t('mx.tipCallConf') : status === 'call_c' ? t('mx.tipCallConf') :
status === 'call_w' ? t('mx.tipCallWork') : status === 'call_w' ? t('mx.tipCallWork') :
status === 'dxcc_c' ? t('mx.tipDxConf') : status === 'dxcc_c' ? t('mx.tipDxConf') :
status === 'dxcc_w' ? t('mx.tipDxWork') : status === 'dxcc_w' ? t('mx.tipDxWork') :
t('mx.tipNone'); t('mx.tipNone');
return `${band} ${cls}: ${desc}${current ? ' — ' + t('mx.current') : ''}`; const mine = call === 'c' ? t('mx.tipThisCallConf') : call === 'w' ? t('mx.tipThisCall') : '';
return `${band} ${cls}: ${desc}${mine ? ' — ' + mine : ''}${current ? ' — ' + t('mx.current') : ''}`;
} }
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) { export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
@@ -126,6 +147,16 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
return m; return m;
}, [wb]); }, [wb]);
// Worked-with-this-callsign, per cell — carried separately by the backend
// because collapsing it into the status is what hid it.
const callMap = useMemo(() => {
const m = new Map<string, string>();
for (const s of wb?.band_status ?? []) {
if ((s as any).call) m.set(`${s.band}|${s.class}`, (s as any).call);
}
return m;
}, [wb]);
// "Newness" of the current band+mode entry, for the award/DX-chase badges. // "Newness" of the current band+mode entry, for the award/DX-chase badges.
// Derived straight from the entity's real band_status (all bands it was // Derived straight from the entity's real band_status (all bands it was
// worked on — not just the operator's configured column list). // worked on — not just the operator's configured column list).
@@ -308,21 +339,29 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
</th> </th>
{cols.map((b) => { {cols.map((b) => {
const st = statusMap.get(`${b.tag}|${cls}`) ?? ''; const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
// The same cell's other answer: worked with THIS callsign
// here. The status above is the entity's, and a confirmed
// entity outranks a worked call — so chasing a DXpedition,
// the cell could say "confirmed" about a contact made years
// ago and nothing about the one made this morning.
const mine = callMap.get(`${b.tag}|${cls}`) ?? '';
const isCurrent = hasCall && b.tag === currentBand && classCurrent; const isCurrent = hasCall && b.tag === currentBand && classCurrent;
return ( return (
<td <td
key={b.tag} key={b.tag}
title={cellTitle(t, b.tag, cls, st, isCurrent) + (st ? ' — ' + t('mx.tipClick') : '')} title={cellTitle(t, b.tag, cls, st, isCurrent, mine) + (st ? ' — ' + t('mx.tipClick') : '')}
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined} onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
className={cn( className={cn(
'w-[28px] h-[24px] rounded transition-colors p-0', 'relative w-[28px] h-[24px] rounded transition-colors p-0',
st ? STATUS_CLASSES[st] : 'bg-mx-none', st ? STATUS_CLASSES[st] : 'bg-mx-none',
// Only a filled cell has anything to show — an empty one // Only a filled cell has anything to show — an empty one
// stays inert rather than opening a "no QSOs" dialog. // stays inert rather than opening a "no QSOs" dialog.
st && 'cursor-pointer hover:brightness-110', st && 'cursor-pointer hover:brightness-110',
isCurrent && 'ring-2 ring-mx-cur ring-inset', isCurrent && 'ring-2 ring-mx-cur ring-inset',
)} )}
/> >
{mine ? <CallMark state={mine} /> : null}
</td>
); );
})} })}
</tr> </tr>
@@ -337,11 +376,13 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
<span key={l.label} className="flex items-center gap-1.5 text-[10px] text-muted-foreground"> <span key={l.label} className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span <span
className={cn( className={cn(
'inline-block size-3 rounded shrink-0', 'relative inline-block size-3 rounded shrink-0',
l.swatch, l.swatch,
l.ring && 'ring-2 ring-mx-cur ring-inset', l.ring && 'ring-2 ring-mx-cur ring-inset',
)} )}
/> >
{l.mark ? <CallMark state={l.mark} /> : null}
</span>
{t(l.label)} {t(l.label)}
</span> </span>
))} ))}
+11
View File
@@ -45,6 +45,7 @@ export type ClusterSpot = {
raw: string; raw: string;
repeats?: number; repeats?: number;
pota_ref?: string; pota_ref?: string;
sota_ref?: string;
pota_name?: string; pota_name?: string;
}; };
@@ -336,6 +337,16 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
: { color: 'var(--success)' }) as any, : { color: 'var(--success)' }) as any,
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined), tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
}, },
{
// SOTA sits next to POTA and reads the same way. The reference comes from the
// spot's comment, so it is present on the SOTA feeds and empty elsewhere —
// which is why the column is off by default rather than an empty column for
// everyone who does not watch summits.
group: 'Spot', label: t('clg2.c.sota'), colId: 'sota',
headerName: t('clg2.c.sota'), field: 'sota_ref' as any, width: 100, cellClass: 'font-mono',
defaultVisible: false,
cellStyle: () => ({ color: 'var(--success)' }) as any,
},
{ {
group: 'Spot', label: t('clg2.c.freq'), colId: 'freq', group: 'Spot', label: t('clg2.c.freq'), colId: 'freq',
headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono', headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
+8 -1
View File
@@ -292,6 +292,9 @@ function ScopePanadapter() {
const wfRef = useRef<HTMLCanvasElement>(null); // waterfall const wfRef = useRef<HTMLCanvasElement>(null); // waterfall
const peakRef = useRef(160); // running amplitude ceiling for auto-scale const peakRef = useRef(160); // running amplitude ceiling for auto-scale
const holdRef = useRef<number[]>([]); // per-bin peak-hold line const holdRef = useRef<number[]>([]); // per-bin peak-hold line
// Some radios control their scope over CI-V but never stream it (IC-7851).
// Saying so beats a black rectangle, which reads as a bug in OpsLog.
const [unsupported, setUnsupported] = useState(false);
const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune
const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune
const centerRef = useRef(0); // scope centre we last set (for pan ◀/▶) const centerRef = useRef(0); // scope centre we last set (for pan ◀/▶)
@@ -333,6 +336,7 @@ function ScopePanadapter() {
if (!alive) return; if (!alive) return;
try { try {
const sw = await IcomScopeData(); const sw = await IcomScopeData();
if (sw?.unsupported) setUnsupported(true);
if (sw && sw.seq !== lastSeq && sw.amp && sw.amp.length) { if (sw && sw.seq !== lastSeq && sw.amp && sw.amp.length) {
lastSeq = sw.seq; lastSeq = sw.seq;
setFixed(sw.fixed); setFixed(sw.fixed);
@@ -543,7 +547,10 @@ function ScopePanadapter() {
<Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} /> <Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} />
</div> </div>
</div> </div>
{on && ( {on && unsupported && (
<div className="px-3 py-2 text-xs text-muted-foreground">{t('icmp.scopeNoStream')}</div>
)}
{on && !unsupported && (
<div className="p-3"> <div className="p-3">
<div className="rounded-xl overflow-hidden ring-1 ring-info/20 shadow-lg shadow-sky-500/5 bg-[#05070e]"> <div className="rounded-xl overflow-hidden ring-1 ring-info/20 shadow-lg shadow-sky-500/5 bg-[#05070e]">
<canvas ref={canvasRef} onDoubleClick={onDblClick} <canvas ref={canvasRef} onDoubleClick={onDblClick}
+22 -1
View File
@@ -8,7 +8,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App'; import { GetLoTWDownloadAllCalls, SetLoTWDownloadAllCalls, OpenExternalURL, FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime'; import { EventsOn } from '../../wailsjs/runtime/runtime';
@@ -255,6 +255,9 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [addNotFound, setAddNotFound] = useState(false); const [addNotFound, setAddNotFound] = useState(false);
// LoTW only: pull the whole account rather than this profile's callsign.
const [lotwAllCalls, setLotwAllCalls] = useState(false);
useEffect(() => { GetLoTWDownloadAllCalls().then((v: boolean) => setLotwAllCalls(!!v)).catch(() => {}); }, []);
// Download date window: 'last' = incremental since last pull, 'date' = from a // Download date window: 'last' = incremental since last pull, 'date' = from a
// chosen date, 'all' = everything. // chosen date, 'all' = everything.
const [sinceMode, setSinceMode] = useState<'last' | 'date' | 'all'>('last'); const [sinceMode, setSinceMode] = useState<'last' | 'date' | 'all'>('last');
@@ -434,6 +437,18 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
{paperBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />} {paperBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
{t('qslm.search')} {t('qslm.search')}
</Button> </Button>
{/* The station's QRZ page, one click away: writing a card means
reading the address, the manager and whether they even want
paper, and all three are on that page. */}
<Button size="sm" variant="outline" className="h-8" disabled={!paperCall.trim()}
title={t('qslm.qrzTitle')}
onClick={() => {
const c = paperCall.trim().toUpperCase();
if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch(() => {});
}}>
<ExternalLink className="size-3.5" />
QRZ
</Button>
<span className="text-[11px] text-muted-foreground self-center">{t('qslm.paperHint')}</span> <span className="text-[11px] text-muted-foreground self-center">{t('qslm.paperHint')}</span>
</> </>
) : ( ) : (
@@ -736,6 +751,12 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} /> <Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
{t('qslm.addNotFound')} {t('qslm.addNotFound')}
</label> </label>
{service === 'lotw' && (
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title={t('qslm.lotwAllCallsTitle')}>
<Checkbox checked={lotwAllCalls} onCheckedChange={(c) => { setLotwAllCalls(!!c); SetLoTWDownloadAllCalls(!!c); }} />
{t('qslm.lotwAllCalls')}
</label>
)}
</>)} </>)}
</div> </div>
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}> <Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
+12
View File
@@ -3266,7 +3266,19 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
</Button> </Button>
</div> </div>
{/* MY_RIG follows the radio, not the profile.
Operating conditions describe a PLAN "on 20 m I use the beam
and the 7300" while this is the fact of which rig is keying.
Empty leaves the old chain alone. */}
<div className="flex items-center gap-2">
<Label className="shrink-0 w-24">{t('cat.radioMyRig')}</Label>
<Input className="h-9 flex-1" placeholder={t('cat.radioMyRigPh')}
value={(radios.find((r: any) => r.id === activeRadio)?.my_rig) ?? ''}
onChange={(e) => setRadios((l) => l.map((r: any) => r.id === activeRadio ? { ...r, my_rig: e.target.value } : r))}
onBlur={() => { SaveRadios(radios as any).catch(() => {}); }} />
</div>
<p className="text-[11px] text-muted-foreground">{t('cat.radioHint')}</p> <p className="text-[11px] text-muted-foreground">{t('cat.radioHint')}</p>
<p className="text-[11px] text-muted-foreground">{t('cat.radioMyRigHint')}</p>
</div> </div>
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -14,9 +14,11 @@ export type MatrixColors = {
entity_worked: string; entity_worked: string;
not_worked: string; not_worked: string;
current_entry: string; current_entry: string;
mark_worked: string;
mark_confirmed: string;
}; };
// The six settings fields and the CSS custom property each one drives. Also the // The settings fields and the CSS custom property each one drives. Also the
// display order — the same order the legend under the matrix reads in, so the // display order — the same order the legend under the matrix reads in, so the
// settings panel and the grid can never disagree about which green is which. // settings panel and the grid can never disagree about which green is which.
export const MATRIX_VARS: { key: keyof Omit<MatrixColors, 'enabled'>; cssVar: string; label: string }[] = [ export const MATRIX_VARS: { key: keyof Omit<MatrixColors, 'enabled'>; cssVar: string; label: string }[] = [
@@ -26,12 +28,15 @@ export const MATRIX_VARS: { key: keyof Omit<MatrixColors, 'enabled'>; cssVar: st
{ key: 'entity_worked', cssVar: '--mx-dx-work', label: 'mx.dxWork' }, { key: 'entity_worked', cssVar: '--mx-dx-work', label: 'mx.dxWork' },
{ key: 'not_worked', cssVar: '--mx-none', label: 'mx.none' }, { key: 'not_worked', cssVar: '--mx-none', label: 'mx.none' },
{ key: 'current_entry', cssVar: '--mx-cur', label: 'mx.current' }, { key: 'current_entry', cssVar: '--mx-cur', label: 'mx.current' },
{ key: 'mark_worked', cssVar: '--mx-mark-work', label: 'mx.markWork' },
{ key: 'mark_confirmed', cssVar: '--mx-mark-conf', label: 'mx.markConf' },
]; ];
export const emptyMatrixColors = (): MatrixColors => ({ export const emptyMatrixColors = (): MatrixColors => ({
enabled: false, enabled: false,
call_confirmed: '', call_worked: '', entity_confirmed: '', call_confirmed: '', call_worked: '', entity_confirmed: '',
entity_worked: '', not_worked: '', current_entry: '', entity_worked: '', not_worked: '', current_entry: '',
mark_worked: '', mark_confirmed: '',
}); });
// applyMatrixColors stamps (or clears) the overrides on <html>. Safe to call as // applyMatrixColors stamps (or clears) the overrides on <html>. Safe to call as
+7
View File
@@ -91,6 +91,11 @@
be recoloured on its own without dragging every other warning in the app be recoloured on its own without dragging every other warning in the app
with it (Appearance → matrix colours). */ with it (Appearance → matrix colours). */
--mx-cur: var(--warning); --mx-cur: var(--warning);
/* The "worked with this callsign" dot. Declared ONCE, like --mx-cur: it is
drawn over every one of the five cell colours, so it follows the theme's own
foreground/background pair rather than a per-theme colour of its own. */
--mx-mark-work: var(--foreground);
--mx-mark-conf: var(--foreground);
--scrollbar-thumb: #b8a880; --scrollbar-thumb: #b8a880;
--scrollbar-thumb-hover: #968455; --scrollbar-thumb-hover: #968455;
@@ -981,6 +986,8 @@
--color-mx-dx-work: var(--mx-dx-work); --color-mx-dx-work: var(--mx-dx-work);
--color-mx-none: var(--mx-none); --color-mx-none: var(--mx-none);
--color-mx-cur: var(--mx-cur); --color-mx-cur: var(--mx-cur);
--color-mx-mark-work: var(--mx-mark-work);
--color-mx-mark-conf: var(--mx-mark-conf);
--radius: 0.5rem; --radius: 0.5rem;
+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.26.19'; export const APP_VERSION = '0.26.20';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+4
View File
@@ -503,6 +503,8 @@ export function GetLiveOpenings():Promise<Array<bandopen.Opening>>;
export function GetLiveStations():Promise<Array<main.LiveStation>>; export function GetLiveStations():Promise<Array<main.LiveStation>>;
export function GetLoTWDownloadAllCalls():Promise<boolean>;
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>; export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
export function GetLogFilePath():Promise<string>; export function GetLogFilePath():Promise<string>;
@@ -1157,6 +1159,8 @@ export function SetKenwoodXIT(arg1:boolean):Promise<void>;
export function SetLinkedAmps(arg1:Array<string>):Promise<void>; export function SetLinkedAmps(arg1:Array<string>):Promise<void>;
export function SetLoTWDownloadAllCalls(arg1:boolean):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>;
+8
View File
@@ -946,6 +946,10 @@ export function GetLiveStations() {
return window['go']['main']['App']['GetLiveStations'](); return window['go']['main']['App']['GetLiveStations']();
} }
export function GetLoTWDownloadAllCalls() {
return window['go']['main']['App']['GetLoTWDownloadAllCalls']();
}
export function GetLoTWUsersStatus() { export function GetLoTWUsersStatus() {
return window['go']['main']['App']['GetLoTWUsersStatus'](); return window['go']['main']['App']['GetLoTWUsersStatus']();
} }
@@ -2254,6 +2258,10 @@ export function SetLinkedAmps(arg1) {
return window['go']['main']['App']['SetLinkedAmps'](arg1); return window['go']['main']['App']['SetLinkedAmps'](arg1);
} }
export function SetLoTWDownloadAllCalls(arg1) {
return window['go']['main']['App']['SetLoTWDownloadAllCalls'](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);
} }
+10
View File
@@ -1196,6 +1196,7 @@ export namespace cat {
low_hz: number; low_hz: number;
high_hz: number; high_hz: number;
fixed: boolean; fixed: boolean;
unsupported: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new ScopeSweep(source); return new ScopeSweep(source);
@@ -1208,6 +1209,7 @@ export namespace cat {
this.low_hz = source["low_hz"]; this.low_hz = source["low_hz"];
this.high_hz = source["high_hz"]; this.high_hz = source["high_hz"];
this.fixed = source["fixed"]; this.fixed = source["fixed"];
this.unsupported = source["unsupported"];
} }
} }
export class TCIPanelState { export class TCIPanelState {
@@ -3066,6 +3068,8 @@ export namespace main {
entity_worked: string; entity_worked: string;
not_worked: string; not_worked: string;
current_entry: string; current_entry: string;
mark_worked: string;
mark_confirmed: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new MatrixColors(source); return new MatrixColors(source);
@@ -3080,6 +3084,8 @@ export namespace main {
this.entity_worked = source["entity_worked"]; this.entity_worked = source["entity_worked"];
this.not_worked = source["not_worked"]; this.not_worked = source["not_worked"];
this.current_entry = source["current_entry"]; this.current_entry = source["current_entry"];
this.mark_worked = source["mark_worked"];
this.mark_confirmed = source["mark_confirmed"];
} }
} }
@@ -3573,6 +3579,7 @@ export namespace main {
id: string; id: string;
name: string; name: string;
settings: CATSettings; settings: CATSettings;
my_rig: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new RadioConfig(source); return new RadioConfig(source);
@@ -3583,6 +3590,7 @@ export namespace main {
this.id = source["id"]; this.id = source["id"];
this.name = source["name"]; this.name = source["name"];
this.settings = this.convertValues(source["settings"], CATSettings); this.settings = this.convertValues(source["settings"], CATSettings);
this.my_rig = source["my_rig"];
} }
convertValues(a: any, classs: any, asMap: boolean = false): any { convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -5025,6 +5033,7 @@ export namespace qso {
band: string; band: string;
class: string; class: string;
status: string; status: string;
call?: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new BandStatus(source); return new BandStatus(source);
@@ -5035,6 +5044,7 @@ export namespace qso {
this.band = source["band"]; this.band = source["band"];
this.class = source["class"]; this.class = source["class"];
this.status = source["status"]; this.status = source["status"];
this.call = source["call"];
} }
} }
export class Bucket { export class Bucket {
+6
View File
@@ -674,6 +674,12 @@ type ScopeSweep struct {
LowHz int64 `json:"low_hz"` // left edge frequency (0 when unknown) LowHz int64 `json:"low_hz"` // left edge frequency (0 when unknown)
HighHz int64 `json:"high_hz"` // right edge frequency (0 when unknown) HighHz int64 `json:"high_hz"` // right edge frequency (0 when unknown)
Fixed bool `json:"fixed"` // true = fixed-span mode, false = center-on-VFO Fixed bool `json:"fixed"` // true = fixed-span mode, false = center-on-VFO
// Unsupported: this radio refuses the waveform-output command, so there will
// never be a sweep. The IC-7851 does — its last firmware is from 2016, older
// than the CI-V waveform stream — while still answering the scope's other
// commands. Reported so the panadapter can say so instead of showing a black
// rectangle that looks like a bug in OpsLog.
Unsupported bool `json:"unsupported"`
} }
// IcomState returns the current Icom DSP state, or (zero, false) when the active // IcomState returns the current Icom DSP state, or (zero, false) when the active
+70 -17
View File
@@ -94,6 +94,9 @@ type IcomSerial struct {
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read // reassembled sweep; scopeMu guards it (written by the scope goroutine, read
// via ScopeData from the binding goroutine). // via ScopeData from the binding goroutine).
dualScope bool dualScope bool
// Set when the rig rejects the waveform-output command in both shapes: it has
// no stream to give, and asking again on every enable is noise.
scopeUnsupported bool
scopeMu sync.Mutex scopeMu sync.Mutex
scopeAmp []byte scopeAmp []byte
scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame) scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame)
@@ -848,6 +851,19 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
loggedCfg[f.Data[0]] = true loggedCfg[f.Data[0]] = true
applog.Printf("icom scope cfg 0x%02X: data=[% X]", f.Data[0], f.Data) applog.Printf("icom scope cfg 0x%02X: data=[% X]", f.Data[0], f.Data)
} }
// The rig just told us its own layout: a mode/span/edge answer of
// three bytes or more carries the main/sub selector, one of two
// bytes does not. Worth reading, because the SET commands take the
// same shape and several firmwares answer a wrong-shaped set with
// silence rather than a rejection — which is not something the
// retry in execScope can act on.
if f.Data[0] == civ.SubScopeMode && len(f.Data) >= 2 {
if sel := len(f.Data) >= 3; sel != b.dualScope {
applog.Printf("icom scope: the rig answers 0x%02X with %d bytes — using the %s form",
f.Data[0], len(f.Data)-1, map[bool]string{true: "27 xx 00 …", false: "27 xx …"}[sel])
b.dualScope = sel
}
}
continue continue
} }
if rawN < 24 { if rawN < 24 {
@@ -982,6 +998,43 @@ func (b *IcomSerial) assembleSweep(regions map[byte][]byte, total byte) {
} }
} }
// execScope sends a 0x27 SET and, if the rig rejects it, sends it once more in
// the other shape — with or without the leading main/sub selector byte — and
// remembers which one this rig speaks.
//
// The shape used to be decided from the CI-V address, which meant every new
// model was a blank scope until someone reported it: the IC-7851 (0x8E) rejects
// "27 11 01" outright and wants "27 11 00 01", exactly as the IC-7610 does not.
// A rejection is a cheap and unambiguous answer, so ask the rig instead of
// keeping a list. Only the SET commands need this — the waveform parser already
// detects the selector per frame.
func (b *IcomSerial) execScope(what string, sub byte, args ...byte) error {
try := func(sel bool) error {
p := []byte{civ.CmdScope, sub}
if sel {
p = append(p, 0x00) // main scope
}
return b.exec(append(p, args...)...)
}
err := try(b.dualScope)
// Only a REJECTION means "wrong shape". A timeout says nothing (several
// firmwares simply don't ack a 0x27 set), and retrying it in the other shape
// would flip a working rig onto the wrong one.
if err == nil || !strings.Contains(err.Error(), "rejected") {
return err
}
err2 := try(!b.dualScope)
applog.Printf("icom scope: %s rejected in the %s form — the other form gave: %v",
what, map[bool]string{true: "27 xx 00 …", false: "27 xx …"}[b.dualScope], err2)
if err2 == nil {
b.dualScope = !b.dualScope
applog.Printf("icom scope: %s rejected — this rig wants the %s form (selector=%v)",
what, map[bool]string{true: "27 xx 00 …", false: "27 xx …"}[b.dualScope], b.dualScope)
return nil
}
return err
}
// SetScope enables or disables the spectrum scope. Two commands are needed and // SetScope enables or disables the spectrum scope. Two commands are needed and
// RS-BA1 sends both: 0x27 0x10 turns the scope DISPLAY on (without it the rig // RS-BA1 sends both: 0x27 0x10 turns the scope DISPLAY on (without it the rig
// streams nothing — the case when we're remote and can't touch the front panel), // streams nothing — the case when we're remote and can't touch the front panel),
@@ -1000,15 +1053,25 @@ func (b *IcomSerial) SetScope(on bool) error {
// radio, and closing OpsLog (SetScope(false)) blanking a local IC-7300's // radio, and closing OpsLog (SetScope(false)) blanking a local IC-7300's
// screen is exactly the regression this avoids. Some firmwares don't ack a // screen is exactly the regression this avoids. Some firmwares don't ack a
// 0x27 set; a timeout isn't fatal, so log and continue. // 0x27 set; a timeout isn't fatal, so log and continue.
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, 0x01); err != nil { if err := b.execScope("display on", civ.SubScopeOnOff, 0x01); err != nil {
applog.Printf("icom scope: display on ack: %v", err) applog.Printf("icom scope: display on ack: %v", err)
} }
} }
// Waveform data OUTPUT over CI-V: enabled with the scope, and — crucially — // Waveform data OUTPUT over CI-V: enabled with the scope, and — crucially —
// the ONLY thing we switch off on disable, so the radio's own scope display is // the ONLY thing we switch off on disable, so the radio's own scope display is
// left exactly as the operator had it. // left exactly as the operator had it.
if err := b.exec(civ.CmdScope, civ.SubScopeOn, boolByte(on)); err != nil { if err := b.execScope("data output", civ.SubScopeOn, boolByte(on)); err != nil {
applog.Printf("icom scope: output on=%v ack: %v", on, err) applog.Printf("icom scope: output on=%v ack: %v", on, err)
// Rejected in both shapes = the command does not exist on this rig, which
// is a permanent answer and not a bad guess on our part. Remember it: the
// panel can then say so, and we stop asking a radio that has already
// said no.
if strings.Contains(err.Error(), "rejected") {
applog.Printf("icom scope: %s does not stream its scope over CI-V — control commands only", b.model)
b.scopeMu.Lock()
b.scopeUnsupported = true
b.scopeMu.Unlock()
}
} }
b.scopeMu.Lock() b.scopeMu.Lock()
b.scopeOn = on b.scopeOn = on
@@ -1041,13 +1104,7 @@ func (b *IcomSerial) scopeReadCfg() {
// makes the scope follow the VFO, so tuning pans the view left/right. // makes the scope follow the VFO, so tuning pans the view left/right.
func (b *IcomSerial) SetScopeMode(fixed bool) error { func (b *IcomSerial) SetScopeMode(fixed bool) error {
mode := boolByte(fixed) // 0 = center, 1 = fixed (verify on rig via the cfg log) mode := boolByte(fixed) // 0 = center, 1 = fixed (verify on rig via the cfg log)
var payload []byte if err := b.execScope("set mode", civ.SubScopeMode, mode); err != nil {
if b.dualScope {
payload = []byte{civ.CmdScope, civ.SubScopeMode, 0x00, mode}
} else {
payload = []byte{civ.CmdScope, civ.SubScopeMode, mode}
}
if err := b.exec(payload...); err != nil {
applog.Printf("icom scope: set mode fixed=%v ack: %v", fixed, err) applog.Printf("icom scope: set mode fixed=%v ack: %v", fixed, err)
} }
b.scopeMu.Lock() b.scopeMu.Lock()
@@ -1093,13 +1150,8 @@ func (b *IcomSerial) SetScopeEdges(low, high int64) error {
if rangeID == 0 { if rangeID == 0 {
return fmt.Errorf("icom scope: freq out of range") return fmt.Errorf("icom scope: freq out of range")
} }
if b.dualScope { _ = b.execScope("fixed mode", civ.SubScopeMode, 0x01)
_ = b.exec(civ.CmdScope, civ.SubScopeMode, 0x00, 0x01) // fixed mode (main) _ = b.execScope("edge set 1", civ.SubScopeEdge, 0x01)
_ = b.exec(civ.CmdScope, civ.SubScopeEdge, 0x00, 0x01) // activate edge set 1
} else {
_ = b.exec(civ.CmdScope, civ.SubScopeMode, 0x01)
_ = b.exec(civ.CmdScope, civ.SubScopeEdge, 0x01)
}
payload := append([]byte{civ.CmdScope, civ.SubScopeFixEdge, rangeID, 0x01}, civ.FreqToBCD(low)...) payload := append([]byte{civ.CmdScope, civ.SubScopeFixEdge, rangeID, 0x01}, civ.FreqToBCD(low)...)
payload = append(payload, civ.FreqToBCD(high)...) payload = append(payload, civ.FreqToBCD(high)...)
b.scopeMu.Lock() b.scopeMu.Lock()
@@ -1263,7 +1315,8 @@ func (b *IcomSerial) ScopeData() ScopeSweep {
for i, v := range b.scopeAmp { for i, v := range b.scopeAmp {
amp[i] = int(v) amp[i] = int(v)
} }
return ScopeSweep{Amp: amp, Seq: b.scopeSeq, LowHz: b.scopeLow, HighHz: b.scopeHigh, Fixed: b.scopeFixed} return ScopeSweep{Amp: amp, Seq: b.scopeSeq, LowHz: b.scopeLow, HighHz: b.scopeHigh, Fixed: b.scopeFixed,
Unsupported: b.scopeUnsupported}
} }
// exec sends a set command and waits for the rig's OK (FB) / NG (FA) ack. // exec sends a set command and waits for the rig's OK (FB) / NG (FA) ack.
+4
View File
@@ -75,6 +75,10 @@ type Spot struct {
Historical bool `json:"historical,omitempty"` Historical bool `json:"historical,omitempty"`
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app) POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
POTAName string `json:"pota_name,omitempty"` // park name POTAName string `json:"pota_name,omitempty"` // park name
// SOTARef comes from the COMMENT, not from an API: the SOTA clusters put the
// summit in the text of the spot they send ("W9/WI-001"), and there is no
// per-callsign endpoint to ask the way POTA has one.
SOTARef string `json:"sota_ref,omitempty"`
} }
// State enumerates the per-server lifecycle. // State enumerates the per-server lifecycle.
+21
View File
@@ -0,0 +1,21 @@
package cluster
import "regexp"
// sotaRefRe matches a SOTA summit reference inside a spot comment.
//
// The shape is association/region-NNN — "W9/WI-001", "DM/BM-063", "VK3/VC-014",
// "F/AM-123" — and the association may carry digits. Anchored on both sides so
// a callsign like DL/SP9DPM/P can never be read as one, and deliberately
// narrower than "anything with a slash and a dash": POTA (US-4475) and WWFF
// (DLFF-0001) refs share the comment field and must not be caught here.
var sotaRefRe = regexp.MustCompile(`\b([A-Z0-9]{1,4}(?:/[A-Z0-9]{1,4})?/[A-Z]{2}-[0-9]{3})\b`)
// SOTARefFrom returns the first SOTA reference in a spot comment, or "".
func SOTARefFrom(comment string) string {
m := sotaRefRe.FindStringSubmatch(comment)
if m == nil {
return ""
}
return m[1]
}
+25
View File
@@ -0,0 +1,25 @@
package cluster
import "testing"
func TestSOTARefFrom(t *testing.T) {
// Left column: real comments seen on the SOTA cluster feed.
cases := []struct{ in, want string }{
{"W9/WI-001", "W9/WI-001"},
{"DM/BM-063", "DM/BM-063"},
{"W7Y/TT-122", "W7Y/TT-122"},
{"VK3/VC-014 s2s", "VK3/VC-014"},
{"[SOTA] F/AM-123 cq", "F/AM-123"},
{"", ""},
// The other reference schemes that share this field.
{"POTA US-4475", ""},
{"WWFF DLFF-0001", ""},
// A portable callsign is not a summit.
{"DL/SP9DPM/P calling", ""},
}
for _, c := range cases {
if got := SOTARefFrom(c.in); got != c.want {
t.Errorf("SOTARefFrom(%q) = %q, want %q", c.in, got, c.want)
}
}
}
+17 -4
View File
@@ -41,23 +41,36 @@ func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg Ser
if c := strings.TrimSpace(ownCall); c != "" { if c := strings.TrimSpace(ownCall); c != "" {
q.Set("qso_owncall", c) // restrict to this station callsign q.Set("qso_owncall", c) // restrict to this station callsign
} }
if s := strings.TrimSpace(since); s != "" { // qso_qslsince is ALWAYS sent, even for "everything".
q.Set("qso_qslsince", s) //
// Left out, LoTW does not answer "all confirmations" — it answers with a
// handful of recent ones, which arrives as a 200 and a valid ADIF and reads
// as a successful download of a nearly empty account. Asking from a date
// older than the service itself is the only way to mean "all".
sinceDate := strings.TrimSpace(since)
if sinceDate == "" {
sinceDate = "1945-11-15" // older than any QSO LoTW will accept
} }
q.Set("qso_qslsince", sinceDate)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, lotwReportURL+"?"+q.Encode(), nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, lotwReportURL+"?"+q.Encode(), nil)
if err != nil { if err != nil {
return "", fmt.Errorf("lotw: build request: %w", err) return "", fmt.Errorf("lotw: build request: %w", err)
} }
if client == nil { if client == nil {
client = &http.Client{Timeout: 120 * time.Second} // A full account is tens of megabytes and LoTW builds it slowly — several
// minutes for a log of 30 000 QSOs, all of it before the first byte. The
// old two-minute limit turned that into "context deadline exceeded while
// reading body", which reads as a network fault rather than as "ask for
// less at a time".
client = &http.Client{Timeout: 20 * time.Minute}
} }
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("lotw: request failed: %w", err) return "", fmt.Errorf("lotw: request failed: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024)) body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024*1024))
if err != nil { if err != nil {
return "", fmt.Errorf("lotw: read response: %w", err) return "", fmt.Errorf("lotw: read response: %w", err)
} }
+59 -3
View File
@@ -1901,10 +1901,24 @@ type WorkedBefore struct {
} }
// BandStatus is one cell in the worked-before grid. // BandStatus is one cell in the worked-before grid.
//
// Status is the single highest thing true of the cell, which is what colours
// it. Call is the SAME cell's answer to a different question — "have I worked
// THIS callsign here" — kept separately because the two are asked at the same
// moment and one was hiding the other.
//
// Chasing an expedition, an operator needs both: whether the slot is still
// missing for the entity (does this fill a DXCC hole) and whether this
// expedition has already been worked on it (would this be a dupe). A confirmed
// entity outranks a worked callsign in Status — correctly, for awards — so a
// slot worked with the DX yesterday can read "entity confirmed" and say nothing
// at all about yesterday.
type BandStatus struct { type BandStatus struct {
Band string `json:"band"` // ADIF lowercase band, e.g. "20m" Band string `json:"band"` // ADIF lowercase band, e.g. "20m"
Class string `json:"class"` // "PH" | "CW" | "DIG" Class string `json:"class"` // "PH" | "CW" | "DIG"
Status string `json:"status"` // "call_c" | "call_w" | "dxcc_c" | "dxcc_w" Status string `json:"status"` // "call_c" | "call_w" | "dxcc_c" | "dxcc_w"
// Call is "", "w" (worked with this callsign) or "c" (confirmed with it).
Call string `json:"call,omitempty"`
} }
// Band-status codes, lowest first. The ORDER is the rule: a cell shows the // Band-status codes, lowest first. The ORDER is the rule: a cell shows the
@@ -2222,13 +2236,17 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
// The grid answers "what do I still need on this band and mode", and for // The grid answers "what do I still need on this band and mode", and for
// that question confirmation is the axis that matters: a confirmed entity // that question confirmation is the axis that matters: a confirmed entity
// needs nothing, whoever else was worked afterwards. // needs nothing, whoever else was worked afterwards.
// The two per-callsign columns use the SAME predicate as the callsign count
// above, portable variants included: a cell that counts RI1FJL/1 in "worked
// with this call" and a header that does not would be two answers to one
// question.
// Filter NULL/empty band+mode rows — they'd create a NULL group key // Filter NULL/empty band+mode rows — they'd create a NULL group key
// that Scan into *string can't handle and would error out the whole // that Scan into *string can't handle and would error out the whole
// WorkedBefore call, blanking the matrix in the UI. // WorkedBefore call, blanking the matrix in the UI.
statusRows, err := r.db.QueryContext(ctx, ` statusRows, err := r.db.QueryContext(ctx, `
SELECT band, mode, SELECT band, mode,
MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END), MAX(CASE WHEN `+pred+` THEN 1 ELSE 0 END),
MAX(CASE WHEN callsign = ? MAX(CASE WHEN `+pred+`
AND (lotw_rcvd IN `+ConfirmedValues+` OR qsl_rcvd IN `+ConfirmedValues+` OR eqsl_rcvd IN `+ConfirmedValues+`) AND (lotw_rcvd IN `+ConfirmedValues+` OR qsl_rcvd IN `+ConfirmedValues+` OR eqsl_rcvd IN `+ConfirmedValues+`)
THEN 1 ELSE 0 END), THEN 1 ELSE 0 END),
MAX(CASE WHEN lotw_rcvd IN `+ConfirmedValues+` OR qsl_rcvd IN `+ConfirmedValues+` OR eqsl_rcvd IN `+ConfirmedValues+` MAX(CASE WHEN lotw_rcvd IN `+ConfirmedValues+` OR qsl_rcvd IN `+ConfirmedValues+` OR eqsl_rcvd IN `+ConfirmedValues+`
@@ -2237,12 +2255,14 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
WHERE dxcc = ? WHERE dxcc = ?
AND band IS NOT NULL AND band != '' AND band IS NOT NULL AND band != ''
AND mode IS NOT NULL AND mode != '' AND mode IS NOT NULL AND mode != ''
GROUP BY band, mode`, wb.Callsign, wb.Callsign, dxcc) GROUP BY band, mode`, append(append(append([]any{}, predArgs...), predArgs...), dxcc)...)
if err != nil { if err != nil {
return wb, fmt.Errorf("band status: %w", err) return wb, fmt.Errorf("band status: %w", err)
} }
type cellKey struct{ band, class string } type cellKey struct{ band, class string }
best := map[cellKey]int{} best := map[cellKey]int{}
// The call's own answer per cell, independent of the ladder above.
callByCell := map[cellKey]string{}
for statusRows.Next() { for statusRows.Next() {
var band, mode string var band, mode string
var callW, callC, dxccConfirmed int var callW, callC, dxccConfirmed int
@@ -2255,12 +2275,21 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
if cur, ok := best[k]; !ok || code > cur { if cur, ok := best[k]; !ok || code > cur {
best[k] = code best[k] = code
} }
// Confirmed beats worked here too, and neither is ever erased by the
// entity: this is only ever about the callsign.
switch {
case callC == 1:
callByCell[k] = "c"
case callW == 1 && callByCell[k] == "":
callByCell[k] = "w"
}
} }
statusRows.Close() statusRows.Close()
codeStr := bandStatusNames codeStr := bandStatusNames
for k, code := range best { for k, code := range best {
wb.BandStatus = append(wb.BandStatus, BandStatus{ wb.BandStatus = append(wb.BandStatus, BandStatus{
Band: k.band, Class: k.class, Status: codeStr[code], Band: k.band, Class: k.class, Status: codeStr[code],
Call: callByCell[k],
}) })
} }
return wb, nil return wb, nil
@@ -2955,6 +2984,33 @@ func DedupeKey(callsign, qsoDateMinute, band, mode string) string {
return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + strings.ToUpper(mode) return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + strings.ToUpper(mode)
} }
// StationCallsigns lists the distinct station callsigns the logbook was worked
// under, upper-cased and without the blanks.
//
// Used to decide whether a downloaded confirmation belongs to THIS log at all:
// one LoTW account can hold several stations (a home call, a portable, an
// expedition), and a confirmation for a station this logbook has never used is
// somebody else's log — here, another profile's.
func (r *Repo) StationCallsigns(ctx context.Context) (map[string]bool, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT COALESCE(station_callsign,'') FROM qso`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
return nil, err
}
if c = strings.ToUpper(strings.TrimSpace(c)); c != "" {
out[c] = true
}
}
return out, rows.Err()
}
// DedupeKeyIDs returns a map of dedupe key → QSO id, for matching downloaded // DedupeKeyIDs returns a map of dedupe key → QSO id, for matching downloaded
// confirmations back to local QSOs. // confirmations back to local QSOs.
func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) { func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
+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.26.19" appVersion = "0.26.20"
// 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.