Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8f0fd7b4f | ||
|
|
4a942a04c3 | ||
|
|
ecae68ca79 | ||
|
|
9270b227b0 | ||
|
|
cff590fe8a | ||
|
|
4366083152 | ||
|
|
8865af35fd | ||
|
|
bfad51268b | ||
|
|
0e77e8d61f | ||
|
|
08d316b1e0 | ||
|
|
b8796369ba | ||
|
|
fa1c41388d | ||
|
|
d2cfad490a | ||
|
|
69d78ba8c0 | ||
|
|
9cf1984fb2 | ||
|
|
ee8fd32bf7 | ||
|
|
f357dad629 | ||
|
|
b3547364d4 | ||
|
|
965ed4c792 | ||
|
|
32dbfbd04e | ||
|
|
a930a4f02d | ||
|
|
f98a831195 |
@@ -402,6 +402,7 @@ const (
|
||||
|
||||
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
|
||||
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)
|
||||
keyExtLoTWKeyPassword = "extsvc.lotw.key_password"
|
||||
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
|
||||
// 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.
|
||||
// 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 d, ok, _ := a.operating.BandDefault(a.ctx, p.ID, q.Band); ok {
|
||||
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.
|
||||
if a.pota != nil {
|
||||
if info, ok := a.pota.Lookup(s.DXCall); ok {
|
||||
@@ -12087,6 +12097,17 @@ func manualRefFor(existing, code string) string {
|
||||
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
|
||||
// matching local QSOs' received status. LoTW only for now (the canonical
|
||||
// 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:
|
||||
sinceDate := resolveSince(keyExtLoTWLastDownload)
|
||||
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
|
||||
// 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 == "" {
|
||||
callLabel = "all callsigns"
|
||||
}
|
||||
@@ -12175,6 +12219,19 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
|
||||
return
|
||||
}
|
||||
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)
|
||||
if kerr != nil {
|
||||
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"})
|
||||
var items []ConfirmationItem
|
||||
var unmatched []string
|
||||
skippedOtherStation := 0
|
||||
perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err() // window closed / superseded
|
||||
@@ -12200,6 +12258,16 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
|
||||
if !ok {
|
||||
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++
|
||||
date := rec["qslrdate"]
|
||||
if date == "" {
|
||||
@@ -12280,6 +12348,9 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
|
||||
} else {
|
||||
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 {
|
||||
// Said out loud rather than folded silently into the total: these
|
||||
// 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 ──
|
||||
// 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 p, err := a.profiles.Active(a.ctx); err == nil {
|
||||
if d, ok2, _ := a.operating.BandDefault(a.ctx, p.ID, q.Band); ok2 {
|
||||
|
||||
@@ -43,6 +43,14 @@ type RadioConfig struct {
|
||||
// the CAT panel has always edited, so a saved radio is exactly "what the
|
||||
// settings said the day it was saved".
|
||||
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.
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -174,6 +174,11 @@ type MatrixColors struct {
|
||||
EntityWorked string `json:"entity_worked"`
|
||||
NotWorked string `json:"not_worked"`
|
||||
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.
|
||||
@@ -196,6 +201,8 @@ func normMatrixColors(c MatrixColors) MatrixColors {
|
||||
EntityWorked: clean(c.EntityWorked),
|
||||
NotWorked: clean(c.NotWorked),
|
||||
CurrentEntry: clean(c.CurrentEntry),
|
||||
MarkWorked: clean(c.MarkWorked),
|
||||
MarkConfirmed: clean(c.MarkConfirmed),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-1
@@ -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 radio’s 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 QSO’s 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 profile’s 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",
|
||||
"date": "",
|
||||
@@ -1919,4 +1945,4 @@
|
||||
"Ce résumé « Nouveautés » s'affiche désormais au premier lancement après chaque mise à jour."
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
+22
-14
@@ -3275,7 +3275,7 @@ export default function App() {
|
||||
function handleSpotSelect(s: any) {
|
||||
if (!s?.dx_call?.trim()) return;
|
||||
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) {
|
||||
@@ -3300,7 +3300,7 @@ export default function App() {
|
||||
FlexZoomForSpot(m ?? '', s.freq_hz ?? 0).catch(() => {});
|
||||
if (m) applyModeFromSpot(m);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -3761,14 +3761,14 @@ export default function App() {
|
||||
restartRecordingForNewTarget(call);
|
||||
// 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.
|
||||
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.
|
||||
const unsubTciSpot = EventsOn('tci:spot_clicked', (p: any) => {
|
||||
const call = String(p?.call ?? '');
|
||||
if (!applyUdpCall(call, true)) return;
|
||||
restartRecordingForNewTarget(call);
|
||||
applySpotPOTA(String(p?.pota_ref ?? ''));
|
||||
applySpotRefs(String(p?.pota_ref ?? ''), String(p?.sota_ref ?? ''));
|
||||
});
|
||||
const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => {
|
||||
const total = Number(p?.total ?? 0);
|
||||
@@ -4796,13 +4796,17 @@ export default function App() {
|
||||
}
|
||||
wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150);
|
||||
}
|
||||
// applySpotPOTA sets the QSO's POTA award reference(s) from a clicked spot's
|
||||
// park ref ("US-4164" or n-fer "US-1,US-2"). Empty ref clears it (fresh
|
||||
// target). Routed to the pota_ref column at save via applyAwardRefs.
|
||||
function applySpotPOTA(potaRef?: string) {
|
||||
const refs = String(potaRef || '')
|
||||
.split(/[,;]/).map((x) => x.trim().toUpperCase()).filter(Boolean);
|
||||
setDetails((d) => ({ ...d, award_refs: refs.map((r) => `POTA@${r}`).join(';') }));
|
||||
// Award references carried by the spot itself: the park a station is
|
||||
// activating, the summit it is on, or both. Written into award_refs the same
|
||||
// way for each, so logging the contact credits it without retyping a
|
||||
// reference that was on screen.
|
||||
function applySpotRefs(potaRef?: string, sotaRef?: string) {
|
||||
const split = (v?: string) => String(v || '').split(/[,;]/).map((x) => x.trim().toUpperCase()).filter(Boolean);
|
||||
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 }) {
|
||||
// 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
|
||||
flip, the 2s poll reconciles); offline → click opens the settings. */}
|
||||
{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 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 operate = viaFlex ? !!flexAmp.amp_operate : !!raw.operate;
|
||||
const dot = !connected ? 'bg-danger' : operate ? 'bg-success' : 'bg-warning';
|
||||
@@ -8435,7 +8443,7 @@ export default function App() {
|
||||
const want = !operate;
|
||||
if (viaFlex) setFlexAmp((f: any) => ({ ...f, amp_operate: want }));
|
||||
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));
|
||||
(viaFlex ? FlexAmpOperate(want) : AmpOperate(a.id, want)).catch(() => {});
|
||||
};
|
||||
|
||||
@@ -194,7 +194,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
||||
</div>
|
||||
<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')} />
|
||||
{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>
|
||||
{kpa.connected && (
|
||||
<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-none" />
|
||||
<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>
|
||||
|
||||
<button type="button" onClick={reset}
|
||||
|
||||
@@ -65,6 +65,19 @@ function cellStatus(r: AwardRef, band: string): CellStatus {
|
||||
if (r.bands?.includes(band)) return 'worked';
|
||||
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> = {
|
||||
validated: 'bg-success text-success-foreground',
|
||||
confirmed: 'bg-warning text-warning-foreground',
|
||||
@@ -112,7 +125,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
const [refSearch, setRefSearch] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
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
|
||||
// confirmed" is two questions at once, and answering only one of them is what
|
||||
// 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 === 'notworked' && r.worked) 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') {
|
||||
// A reference never worked has no mode, so "not worked" plus a mode is
|
||||
// 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;
|
||||
});
|
||||
}, [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
|
||||
// (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)} />
|
||||
</div>
|
||||
<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)}
|
||||
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||
{label}
|
||||
@@ -484,6 +508,11 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
))}
|
||||
</div>
|
||||
<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
|
||||
scope but with no reference" needs a scope to be in: on a
|
||||
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">
|
||||
{s === 'none' ? <span className="block w-11 h-7" /> : (
|
||||
<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 })}
|
||||
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
|
||||
>{CELL_LABEL[s]}</button>
|
||||
|
||||
@@ -81,23 +81,44 @@ const STATUS_CLASSES: Record<string, string> = {
|
||||
// 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
|
||||
// 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-work', label: 'mx.callWork' },
|
||||
{ swatch: 'bg-mx-dx-conf', label: 'mx.dxConf' },
|
||||
{ swatch: 'bg-mx-dx-work', label: 'mx.dxWork' },
|
||||
{ swatch: 'bg-mx-none', label: 'mx.none' },
|
||||
{ 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 =
|
||||
status === 'call_c' ? t('mx.tipCallConf') :
|
||||
status === 'call_w' ? t('mx.tipCallWork') :
|
||||
status === 'dxcc_c' ? t('mx.tipDxConf') :
|
||||
status === 'dxcc_w' ? t('mx.tipDxWork') :
|
||||
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) {
|
||||
@@ -126,6 +147,16 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
return m;
|
||||
}, [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.
|
||||
// Derived straight from the entity's real band_status (all bands it was
|
||||
// worked on — not just the operator's configured column list).
|
||||
@@ -308,21 +339,29 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
</th>
|
||||
{cols.map((b) => {
|
||||
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;
|
||||
return (
|
||||
<td
|
||||
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}
|
||||
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',
|
||||
// Only a filled cell has anything to show — an empty one
|
||||
// stays inert rather than opening a "no QSOs" dialog.
|
||||
st && 'cursor-pointer hover:brightness-110',
|
||||
isCurrent && 'ring-2 ring-mx-cur ring-inset',
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{mine ? <CallMark state={mine} /> : null}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</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
|
||||
className={cn(
|
||||
'inline-block size-3 rounded shrink-0',
|
||||
'relative inline-block size-3 rounded shrink-0',
|
||||
l.swatch,
|
||||
l.ring && 'ring-2 ring-mx-cur ring-inset',
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{l.mark ? <CallMark state={l.mark} /> : null}
|
||||
</span>
|
||||
{t(l.label)}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -45,6 +45,7 @@ export type ClusterSpot = {
|
||||
raw: string;
|
||||
repeats?: number;
|
||||
pota_ref?: string;
|
||||
sota_ref?: string;
|
||||
pota_name?: string;
|
||||
};
|
||||
|
||||
@@ -336,6 +337,16 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
: { color: 'var(--success)' }) as any,
|
||||
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',
|
||||
headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
||||
|
||||
@@ -292,6 +292,9 @@ function ScopePanadapter() {
|
||||
const wfRef = useRef<HTMLCanvasElement>(null); // waterfall
|
||||
const peakRef = useRef(160); // running amplitude ceiling for auto-scale
|
||||
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 vfoRef = useRef(0); // latest VFO frequency, for wheel-tune
|
||||
const centerRef = useRef(0); // scope centre we last set (for pan ◀/▶)
|
||||
@@ -333,6 +336,7 @@ function ScopePanadapter() {
|
||||
if (!alive) return;
|
||||
try {
|
||||
const sw = await IcomScopeData();
|
||||
if (sw?.unsupported) setUnsupported(true);
|
||||
if (sw && sw.seq !== lastSeq && sw.amp && sw.amp.length) {
|
||||
lastSeq = sw.seq;
|
||||
setFixed(sw.fixed);
|
||||
@@ -543,7 +547,10 @@ function ScopePanadapter() {
|
||||
<Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} />
|
||||
</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="rounded-xl overflow-hidden ring-1 ring-info/20 shadow-lg shadow-sky-500/5 bg-[#05070e]">
|
||||
<canvas ref={canvasRef} onDoubleClick={onDblClick}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
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 { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
@@ -255,6 +255,9 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
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
|
||||
// chosen date, 'all' = everything.
|
||||
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" />}
|
||||
{t('qslm.search')}
|
||||
</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>
|
||||
</>
|
||||
) : (
|
||||
@@ -736,6 +751,12 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
||||
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
||||
{t('qslm.addNotFound')}
|
||||
</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>
|
||||
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
|
||||
|
||||
@@ -3266,7 +3266,19 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</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.radioMyRigHint')}</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -14,9 +14,11 @@ export type MatrixColors = {
|
||||
entity_worked: string;
|
||||
not_worked: 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
|
||||
// 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 }[] = [
|
||||
@@ -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: 'not_worked', cssVar: '--mx-none', label: 'mx.none' },
|
||||
{ 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 => ({
|
||||
enabled: false,
|
||||
call_confirmed: '', call_worked: '', entity_confirmed: '',
|
||||
entity_worked: '', not_worked: '', current_entry: '',
|
||||
mark_worked: '', mark_confirmed: '',
|
||||
});
|
||||
|
||||
// applyMatrixColors stamps (or clears) the overrides on <html>. Safe to call as
|
||||
|
||||
@@ -91,6 +91,11 @@
|
||||
be recoloured on its own without dragging every other warning in the app
|
||||
with it (Appearance → matrix colours). */
|
||||
--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-hover: #968455;
|
||||
@@ -981,6 +986,8 @@
|
||||
--color-mx-dx-work: var(--mx-dx-work);
|
||||
--color-mx-none: var(--mx-none);
|
||||
--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;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.26.19';
|
||||
export const APP_VERSION = '0.26.20';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+4
@@ -503,6 +503,8 @@ export function GetLiveOpenings():Promise<Array<bandopen.Opening>>;
|
||||
|
||||
export function GetLiveStations():Promise<Array<main.LiveStation>>;
|
||||
|
||||
export function GetLoTWDownloadAllCalls():Promise<boolean>;
|
||||
|
||||
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
||||
|
||||
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 SetLoTWDownloadAllCalls(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<void>;
|
||||
|
||||
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
|
||||
|
||||
@@ -946,6 +946,10 @@ export function GetLiveStations() {
|
||||
return window['go']['main']['App']['GetLiveStations']();
|
||||
}
|
||||
|
||||
export function GetLoTWDownloadAllCalls() {
|
||||
return window['go']['main']['App']['GetLoTWDownloadAllCalls']();
|
||||
}
|
||||
|
||||
export function GetLoTWUsersStatus() {
|
||||
return window['go']['main']['App']['GetLoTWUsersStatus']();
|
||||
}
|
||||
@@ -2254,6 +2258,10 @@ export function 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) {
|
||||
return window['go']['main']['App']['SetMotorFollow'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
@@ -1196,6 +1196,7 @@ export namespace cat {
|
||||
low_hz: number;
|
||||
high_hz: number;
|
||||
fixed: boolean;
|
||||
unsupported: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ScopeSweep(source);
|
||||
@@ -1208,6 +1209,7 @@ export namespace cat {
|
||||
this.low_hz = source["low_hz"];
|
||||
this.high_hz = source["high_hz"];
|
||||
this.fixed = source["fixed"];
|
||||
this.unsupported = source["unsupported"];
|
||||
}
|
||||
}
|
||||
export class TCIPanelState {
|
||||
@@ -3066,6 +3068,8 @@ export namespace main {
|
||||
entity_worked: string;
|
||||
not_worked: string;
|
||||
current_entry: string;
|
||||
mark_worked: string;
|
||||
mark_confirmed: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new MatrixColors(source);
|
||||
@@ -3080,6 +3084,8 @@ export namespace main {
|
||||
this.entity_worked = source["entity_worked"];
|
||||
this.not_worked = source["not_worked"];
|
||||
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;
|
||||
name: string;
|
||||
settings: CATSettings;
|
||||
my_rig: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RadioConfig(source);
|
||||
@@ -3583,6 +3590,7 @@ export namespace main {
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.settings = this.convertValues(source["settings"], CATSettings);
|
||||
this.my_rig = source["my_rig"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
@@ -5025,6 +5033,7 @@ export namespace qso {
|
||||
band: string;
|
||||
class: string;
|
||||
status: string;
|
||||
call?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new BandStatus(source);
|
||||
@@ -5035,6 +5044,7 @@ export namespace qso {
|
||||
this.band = source["band"];
|
||||
this.class = source["class"];
|
||||
this.status = source["status"];
|
||||
this.call = source["call"];
|
||||
}
|
||||
}
|
||||
export class Bucket {
|
||||
|
||||
@@ -674,6 +674,12 @@ type ScopeSweep struct {
|
||||
LowHz int64 `json:"low_hz"` // left 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
|
||||
// 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
|
||||
|
||||
+79
-26
@@ -93,15 +93,18 @@ type IcomSerial struct {
|
||||
// leading main/sub selector byte (IC-7610/9700). scopeAmp is the latest
|
||||
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read
|
||||
// via ScopeData from the binding goroutine).
|
||||
dualScope bool
|
||||
scopeMu sync.Mutex
|
||||
scopeAmp []byte
|
||||
scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame)
|
||||
scopeHigh int64 // spectrum right-edge frequency
|
||||
scopeSeq int
|
||||
scopeOn bool
|
||||
scopeFixed bool // true = fixed-span mode (tracked optimistically)
|
||||
scopeSeen bool // logged the first sweep's structure once (on-rig verification)
|
||||
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
|
||||
scopeAmp []byte
|
||||
scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame)
|
||||
scopeHigh int64 // spectrum right-edge frequency
|
||||
scopeSeq int
|
||||
scopeOn bool
|
||||
scopeFixed bool // true = fixed-span mode (tracked optimistically)
|
||||
scopeSeen bool // logged the first sweep's structure once (on-rig verification)
|
||||
|
||||
curFreq int64 // last frequency read (for sideband choice)
|
||||
curModeByte byte // last raw Icom mode byte (for filter re-send)
|
||||
@@ -848,6 +851,19 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
|
||||
loggedCfg[f.Data[0]] = true
|
||||
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
|
||||
}
|
||||
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
|
||||
// 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),
|
||||
@@ -1000,15 +1053,25 @@ func (b *IcomSerial) SetScope(on bool) error {
|
||||
// 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
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
// 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
|
||||
// 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)
|
||||
// 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.scopeOn = on
|
||||
@@ -1041,13 +1104,7 @@ func (b *IcomSerial) scopeReadCfg() {
|
||||
// makes the scope follow the VFO, so tuning pans the view left/right.
|
||||
func (b *IcomSerial) SetScopeMode(fixed bool) error {
|
||||
mode := boolByte(fixed) // 0 = center, 1 = fixed (verify on rig via the cfg log)
|
||||
var payload []byte
|
||||
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 {
|
||||
if err := b.execScope("set mode", civ.SubScopeMode, mode); err != nil {
|
||||
applog.Printf("icom scope: set mode fixed=%v ack: %v", fixed, err)
|
||||
}
|
||||
b.scopeMu.Lock()
|
||||
@@ -1093,13 +1150,8 @@ func (b *IcomSerial) SetScopeEdges(low, high int64) error {
|
||||
if rangeID == 0 {
|
||||
return fmt.Errorf("icom scope: freq out of range")
|
||||
}
|
||||
if b.dualScope {
|
||||
_ = b.exec(civ.CmdScope, civ.SubScopeMode, 0x00, 0x01) // fixed mode (main)
|
||||
_ = 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)
|
||||
}
|
||||
_ = b.execScope("fixed mode", civ.SubScopeMode, 0x01)
|
||||
_ = b.execScope("edge set 1", civ.SubScopeEdge, 0x01)
|
||||
payload := append([]byte{civ.CmdScope, civ.SubScopeFixEdge, rangeID, 0x01}, civ.FreqToBCD(low)...)
|
||||
payload = append(payload, civ.FreqToBCD(high)...)
|
||||
b.scopeMu.Lock()
|
||||
@@ -1263,7 +1315,8 @@ func (b *IcomSerial) ScopeData() ScopeSweep {
|
||||
for i, v := range b.scopeAmp {
|
||||
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.
|
||||
|
||||
+24
-20
@@ -44,30 +44,30 @@ type ServerConfig struct {
|
||||
// is emitted to the UI, so the table never has empty country cells
|
||||
// flickering in for a few hundred ms.
|
||||
type Spot struct {
|
||||
SourceID int64 `json:"source_id"` // ID of the cluster server this came from
|
||||
SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers)
|
||||
Spotter string `json:"spotter"` // DE field
|
||||
SourceID int64 `json:"source_id"` // ID of the cluster server this came from
|
||||
SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers)
|
||||
Spotter string `json:"spotter"` // DE field
|
||||
// SpotterContinent belongs to the SPOT, not to the DX station: one call is
|
||||
// spotted by dozens of skimmers on every continent within a minute. It is
|
||||
// resolved per spot at ingest for exactly that reason — see the note on the
|
||||
// spotter-continent filter in App.tsx.
|
||||
SpotterContinent string `json:"spotter_continent,omitempty"`
|
||||
DXCall string `json:"dx_call"` // the DX station heard
|
||||
FreqKHz float64 `json:"freq_khz"`
|
||||
FreqHz int64 `json:"freq_hz"`
|
||||
Band string `json:"band,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Locator string `json:"locator,omitempty"` // spotter grid (optional)
|
||||
TimeUTC string `json:"time_utc,omitempty"`
|
||||
Country string `json:"country,omitempty"` // DXCC entity name (cty.dat)
|
||||
Continent string `json:"continent,omitempty"` // 2-letter continent
|
||||
CQZone int `json:"cqz,omitempty"` // DXCC entity CQ zone
|
||||
ITUZone int `json:"ituz,omitempty"` // DXCC entity ITU zone
|
||||
DistanceKm int `json:"distance_km,omitempty"` // great-circle km from operator's grid
|
||||
ShortPath int `json:"sp_deg,omitempty"` // azimuth (deg) short path from operator
|
||||
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
Raw string `json:"raw"`
|
||||
SpotterContinent string `json:"spotter_continent,omitempty"`
|
||||
DXCall string `json:"dx_call"` // the DX station heard
|
||||
FreqKHz float64 `json:"freq_khz"`
|
||||
FreqHz int64 `json:"freq_hz"`
|
||||
Band string `json:"band,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Locator string `json:"locator,omitempty"` // spotter grid (optional)
|
||||
TimeUTC string `json:"time_utc,omitempty"`
|
||||
Country string `json:"country,omitempty"` // DXCC entity name (cty.dat)
|
||||
Continent string `json:"continent,omitempty"` // 2-letter continent
|
||||
CQZone int `json:"cqz,omitempty"` // DXCC entity CQ zone
|
||||
ITUZone int `json:"ituz,omitempty"` // DXCC entity ITU zone
|
||||
DistanceKm int `json:"distance_km,omitempty"` // great-circle km from operator's grid
|
||||
ShortPath int `json:"sp_deg,omitempty"` // azimuth (deg) short path from operator
|
||||
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
Raw string `json:"raw"`
|
||||
// Historical marks a spot recovered from a SH/DX table rather than heard live.
|
||||
// It belongs in the grid, but must NOT fire alerts or reach the panadapter:
|
||||
// replaying 100 past spots would spam both, and a station spotted three hours
|
||||
@@ -75,6 +75,10 @@ type Spot struct {
|
||||
Historical bool `json:"historical,omitempty"`
|
||||
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
|
||||
POTAName string `json:"pota_name,omitempty"` // park name
|
||||
// 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.
|
||||
|
||||
@@ -110,7 +110,7 @@ func TestParseShowDX(t *testing.T) {
|
||||
// chatter turned into fake spots would be worse than no parser at all.
|
||||
func TestParseShowDXRejectsNoise(t *testing.T) {
|
||||
noise := []string{
|
||||
"DX de F5ABC: 14195.0 EA8DHH CQ DX 1234Z", // the broadcast form: spotRE owns it
|
||||
"DX de F5ABC: 14195.0 EA8DHH CQ DX 1234Z", // the broadcast form: spotRE owns it
|
||||
"Hello and welcome to the DXSpider cluster",
|
||||
"WWV de VE7CC <18Z> : SFI=110, A=16, K=2",
|
||||
"F4BPO de GB7DXC 12-Jul-2026 2130Z dxspider >",
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
@@ -41,23 +41,36 @@ func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg Ser
|
||||
if c := strings.TrimSpace(ownCall); c != "" {
|
||||
q.Set("qso_owncall", c) // restrict to this station callsign
|
||||
}
|
||||
if s := strings.TrimSpace(since); s != "" {
|
||||
q.Set("qso_qslsince", s)
|
||||
// qso_qslsince is ALWAYS sent, even for "everything".
|
||||
//
|
||||
// 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)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("lotw: build request: %w", err)
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("lotw: request failed: %w", err)
|
||||
}
|
||||
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 {
|
||||
return "", fmt.Errorf("lotw: read response: %w", err)
|
||||
}
|
||||
|
||||
+59
-3
@@ -1901,10 +1901,24 @@ type WorkedBefore struct {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Band string `json:"band"` // ADIF lowercase band, e.g. "20m"
|
||||
Class string `json:"class"` // "PH" | "CW" | "DIG"
|
||||
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
|
||||
@@ -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
|
||||
// that question confirmation is the axis that matters: a confirmed entity
|
||||
// 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
|
||||
// that Scan into *string can't handle and would error out the whole
|
||||
// WorkedBefore call, blanking the matrix in the UI.
|
||||
statusRows, err := r.db.QueryContext(ctx, `
|
||||
SELECT band, mode,
|
||||
MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END),
|
||||
MAX(CASE WHEN callsign = ?
|
||||
MAX(CASE WHEN `+pred+` THEN 1 ELSE 0 END),
|
||||
MAX(CASE WHEN `+pred+`
|
||||
AND (lotw_rcvd IN `+ConfirmedValues+` OR qsl_rcvd IN `+ConfirmedValues+` OR eqsl_rcvd IN `+ConfirmedValues+`)
|
||||
THEN 1 ELSE 0 END),
|
||||
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 = ?
|
||||
AND band IS NOT NULL AND band != ''
|
||||
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 {
|
||||
return wb, fmt.Errorf("band status: %w", err)
|
||||
}
|
||||
type cellKey struct{ band, class string }
|
||||
best := map[cellKey]int{}
|
||||
// The call's own answer per cell, independent of the ladder above.
|
||||
callByCell := map[cellKey]string{}
|
||||
for statusRows.Next() {
|
||||
var band, mode string
|
||||
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 {
|
||||
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()
|
||||
codeStr := bandStatusNames
|
||||
for k, code := range best {
|
||||
wb.BandStatus = append(wb.BandStatus, BandStatus{
|
||||
Band: k.band, Class: k.class, Status: codeStr[code],
|
||||
Call: callByCell[k],
|
||||
})
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// confirmations back to local QSOs.
|
||||
func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// 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
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user