Compare commits

...
12 Commits
Author SHA1 Message Date
rouggy 0af6cdedcd chore: release v0.27.14 2026-09-06 14:58:42 +02:00
rouggy ab1b45da07 feat(ft map): click a station to take it, double-click to answer it
The map draws a dot per decoded station and they did nothing. They now
answer the same two gestures as the decodes list, because the two are
views of the same list and a click has to mean the same thing on both:
one takes the callsign into the entry (and points the PSK Reporter panel
at it), two answer it.

The handlers were written inline on the list, so the map could not offer
them without a second copy — they are named once now and given to both.
Leaflet leaves the telling apart of click and dblclick to the handler, an
invisible marker three times the dot's radius takes the clicks (a
three-pixel dot is a fine mark and a poor target), and the double click
is stopped rather than merely unpropagated: the map zooms on one, and
answering a station is not a request to zoom in on it.
2026-09-06 14:47:22 +02:00
rouggy ed66e9394b fix(autocall): rest in overs, no phantom miss, and a readable auto readout
The rest between two series was two minutes — four overs on FT8, by
which time the DX has worked four other callers and half the time has
gone. It is now counted in the station's OWN overs and is one by default:
seven calls, one over listened through, and it goes again if the station
is still there. The deadline lands mid-cycle on purpose, so "sit out one
over" means one over rather than depending on a millisecond of clock
skew.

A period the target was DECODED in is never counted as a miss. A period
is judged more than once — decodes arrive in a burst and stragglers
follow — and a later judgement holds a partial view of it, not evidence
of absence: D2ACE answered in the very period the counter then read as a
miss.

The readout moved out of the Auto button and beside it. "Auto D2ACE 1/7
·1/3" read as one number, and the control changed width every period.
The callsign is now the biggest thing on the row, the calls and the
missed periods each carry a label, the miss count appears only once there
is one, and a station being waited for gets its own amber chip.
2026-09-06 14:36:32 +02:00
rouggy f56630d7d6 feat(maps): remembered views for the FT and grid maps, and no wasted first paint
The world map was the only one that remembered anything. Panning and
zooming a map is the operator saying which part of the world they work,
and on the FT decodes map and the grid-square map that was thrown away on
every tab switch. Both now keep centre and zoom, through one shared
helper (lib/mapView) that the world map uses too, and the keys are
portable so a copied data folder brings the views with it.

The world map also waits for the station's square before it paints. It
drew the world at 0 degrees and then moved to the operator's longitude —
a screenful of Esri tiles fetched and discarded on every first run. It is
now built once, knowing where it is looking; a profile with no locator
gets the default view after two seconds rather than a blank panel.
2026-09-06 14:23:43 +02:00
rouggy 50e97a8e0d feat(map): the world map opens on the operator's own longitude
Centred on 0 degrees it left an Australian looking at their own country
in the bottom-right corner, with the paths to everywhere they work
running off both edges. Centred on their own longitude the same map reads
the way their antenna does: the Americas to the east, Europe and Africa
to the west. Leaflet repeats the world horizontally, so this is a choice
of viewpoint and costs nothing — the tiles either side are the same
world, and unwrapLon already draws the arcs across the seam.

The latitude is damped rather than followed (a station at 69 north
centred on itself would spend half the map on the Arctic), the station's
square arrives after the map is built so it re-centres once when it
lands, and a view the operator has panned to themselves still wins.
2026-09-06 14:14:36 +02:00
rouggy e0bba21ecc fix(rst): a change of mode changes the notation
An operator with a callsign in the field, switching FT8 to SSB, kept
"+00" as the report. The edited flag protects a report the operator
chose — 57 rather than 59 — and it was holding across a change of mode as
well, where it protects nothing: a decibel figure is not a weak SSB
report, it is not a report at all. Worse, anything that fills the field
from the rig sets that flag too (the S-meter readouts in the rig
consoles), so the field could be stuck in the wrong notation for the rest
of the QSO.

The flag now holds only while the report still belongs to the mode's
family. When it does not, the operator's own judgement is carried across
where it can be — 57 becomes 579, 599 becomes 59 — and the mode's preset
answers where it cannot.
2026-09-06 14:06:04 +02:00
rouggy 28f784fe88 fix(rst wheel): the S9+ ladder, not the S digit
Below 59+20 comes 59+15, then +10, +5, then a plain 59 and down through
58, 57. Stepping the S digit and leaving the suffix where it was turned
59+20 into 58+20, which nobody has ever said on the air. Five decibels at
a time above S9 — the same increment sMeterRST rounds an S-meter reading
to — and one S-unit below it.
2026-09-06 14:01:58 +02:00
rouggy b421da165a feat: wheel over RST; PSK Reporter keeps the band's window
The mouse wheel steps the RST fields — one S-unit on an RST or RS, one
decibel on a digital report, up for a better one — in the entry strip and
in the QSO editor. The dropdown beside them lists the reports worth
having to hand, not all 41 decibels, so the wheel works on the value
rather than walking the list. The listener is native and non-passive:
React attaches onWheel passively, where preventDefault does nothing and
the panel scrolls away under the field being adjusted.

PSK Reporter, whole-band scope: clicking a decode reset the report count
to zero. The window there is the BAND's — every FTx report on it,
filtered by target only when the analysis is drawn — and it was emptied
on every target change, throwing away an hour of evidence at the moment
the operator asked the question it answers. The narrow scope still clears
it: there the window is one station's, and keeping it would answer the
new question with the old station's evidence.
2026-09-06 13:58:25 +02:00
rouggy 8b59954ce0 fix: a watched callsign is never parked; refuse uploads to unconfigured services
ZD8GB — watch-listed, six streams a period, two of them RR73 — was
refused as "parked" for the rest of the session. Parking answers "it
will not answer, stop wasting the evening on it", which is a fair verdict
about a station the LOG picked out and the wrong one about a station the
OPERATOR named: a DXpedition running a pileup takes more than two series
of calls to get through to, which is exactly why it is on the list. The
rest between series still applies, so it cannot monopolise the
transmitter — it simply never becomes ineligible.

Send to (right-click) now refuses a service with no credentials and says
which are missing. The upload runs on its own goroutine and reports into
the QSL Manager's console, which is not open when the command came from
the QSO list, so an upload to an unconfigured service looked exactly like
one that worked. The toast is also raised only once the backend has
accepted the request, and Cloudlog / Wavelog and HamQTH name themselves
in it.
2026-09-06 13:45:35 +02:00
rouggy 70ada49776 fix: CI-V address 00, simplex uploaded as split, and S/F spots
Three field reports.

Icom CI-V address 00 could not be kept: zero was read as "not
configured" in all three places that validate it, so every save put the
rig back to the IC-7610's 0x98 — and the model dropdown followed, since
it is derived from the address rather than stored. Picking "Other
(custom address)" also had no effect of its own: the list re-derived
itself and snapped back to whatever rig matched. It now stays chosen.

Cloudlog/Wavelog showed "17m/17m" on ordinary FT8 contacts (OE6CLD).
Every QSO is stamped with a receive side equal to the transmit side, and
the uploaded record carried it; Wavelog draws band/band_rx whenever both
are there. In ADIF an absent BAND_RX means "same as transmit", so the
uploaded record now writes the receive side only when it differs. The
copy forwarded to another logger over UDP keeps writing it in full —
that is why it was stamped in the first place (Log4OM reads BAND_RX) —
through its own ForwardRecordADIF.

"S/F" in a spot comment joins superfox / sfox / F-H as FT8.
2026-09-06 13:12:00 +02:00
rouggy e20f32c918 chore(changelog): the late-decode fix opens 0.27.14
It went in after the 0.27.13 release commit, so the shipped build does not
contain it — and the entry had been folded into that block's fixes line,
where it claimed a fix nobody had. The 0.27.13 block is back to what was
released.
2026-09-06 06:56:01 +02:00
rouggy 71adbfd8ff fix(autocall): a late decode belongs to its own period
A decoder sends a period's decodes in a burst, and stragglers follow — a
deep decode a second behind the rest. The sweeper judged the burst and
CLEARED the buffer, so the straggler opened a fresh one under the same
period key and was judged on its own: the ladder applied to a handful of
late arrivals with the other thirty stations of that period nowhere in
sight, and often after the reply to the burst had already put us on the
air, where nothing can act on it at all.

The buffer now outlives the judgement. A period stays open until a decode
stamped with the NEXT slot arrives; a straggler appends to it and the
period is judged again, whole. Judged once per period otherwise — acDirty
says whether anything new has come in — and the same flag now answers the
dead-band case that the cleared buffer used to stand for.
2026-09-06 00:31:16 +02:00
28 changed files with 838 additions and 142 deletions
+60 -3
View File
@@ -789,6 +789,11 @@ type App struct {
acLastJudge time.Time acLastJudge time.Time
// acTXPeriod is the last transmit period already counted — see autoCallNoteTX. // acTXPeriod is the last transmit period already counted — see autoCallNoteTX.
acTXPeriod string acTXPeriod string
// acJudged is the last period key each receiver has been judged on, and
// acDirty says decodes have arrived for it since. Together they let a period
// be judged AGAIN when a straggler turns up, with the whole period in hand.
acJudged map[string]string
acDirty map[string]bool
// What the decodes panel is SHOWING, and whether it is publishing at all. // What the decodes panel is SHOWING, and whether it is publishing at all.
// The panel owns the filters; this is its answer, not a second copy of them. // The panel owns the filters; this is its answer, not a second copy of them.
acVisible map[string]bool acVisible map[string]bool
@@ -3149,7 +3154,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
// a few lines above and is not on the copy taken at insert time. // a few lines above and is not on the copy taken at insert time.
a.syncPublishAsync(syncfolder.OpAdd, id, nil) a.syncPublishAsync(syncfolder.OpAdd, id, nil)
if a.udp != nil { if a.udp != nil {
rec := adif.SingleRecordADIF(qc) rec := adif.ForwardRecordADIF(qc)
a.udp.EmitLoggedADIF(rec) a.udp.EmitLoggedADIF(rec)
a.udp.EmitLoggedQSOWSJT(wsjtLoggedQSO(qc), rec) a.udp.EmitLoggedQSOWSJT(wsjtLoggedQSO(qc), rec)
a.udpTriggerQSOLogged(qc) a.udpTriggerQSOLogged(qc)
@@ -8470,7 +8475,11 @@ func (a *App) SaveCATSettings(s CATSettings) error {
if s.IcomBaud <= 0 { if s.IcomBaud <= 0 {
s.IcomBaud = 115200 s.IcomBaud = 115200
} }
if s.IcomAddr <= 0 || s.IcomAddr > 0xFF { // 0x00 IS an address. Rejecting it as "unset" is what sent an operator's
// custom-address rig straight back to the IC-7610's 0x98 every time the
// settings were saved — the model dropdown snapping back with it, since it
// reads the address rather than a stored model.
if s.IcomAddr < 0 || s.IcomAddr > 0xFF {
s.IcomAddr = 0x98 s.IcomAddr = 0x98
} }
if s.PollMs < 50 || s.PollMs > 2000 { if s.PollMs < 50 || s.PollMs > 2000 {
@@ -11811,10 +11820,58 @@ func (a *App) UploadQSOsManual(service string, ids []int64) error {
return fmt.Errorf("unknown service %q", service) return fmt.Errorf("unknown service %q", service)
} }
cfg := a.loadExternalServices() cfg := a.loadExternalServices()
// NOT CONFIGURED IS AN ANSWER, AND IT BELONGS HERE.
//
// The upload runs on its own goroutine and reports through the QSL Manager's
// console, which is not open when the command was given from a right-click in
// the QSO list: an upload to a service with no credentials looked exactly
// like one that worked. Refused synchronously instead, where the caller can
// show it.
if err := uploadConfigured(svc, cfg); err != nil {
return err
}
go a.runManualUpload(svc, ids, cfg) go a.runManualUpload(svc, ids, cfg)
return nil return nil
} }
// uploadConfigured reports whether a service has what it needs to be uploaded
// to at all — the credentials it cannot work without, not a guarantee they are
// correct. The service says whether they are; this says whether to ask.
func uploadConfigured(svc extsvc.Service, cfg extsvc.ExternalServices) error {
has := func(v string) bool { return strings.TrimSpace(v) != "" }
switch svc {
case extsvc.ServiceCloudlog:
if !has(cfg.Cloudlog.URL) || !has(cfg.Cloudlog.APIKey) {
return fmt.Errorf("Cloudlog / Wavelog is not configured — set its URL and API key in Settings → External services")
}
case extsvc.ServiceQRZ:
if !has(cfg.QRZ.APIKey) {
return fmt.Errorf("QRZ.com is not configured — set the logbook API key in Settings → External services")
}
case extsvc.ServiceClublog:
if !has(cfg.Clublog.Email) || !has(cfg.Clublog.Password) || !has(cfg.Clublog.APIKey) {
return fmt.Errorf("Club Log is not configured — set the account email, password and API key in Settings → External services")
}
case extsvc.ServiceHRDLog:
if !has(cfg.HRDLog.Callsign) || !has(cfg.HRDLog.Code) {
return fmt.Errorf("HRDLog.net is not configured — set the callsign and upload code in Settings → External services")
}
case extsvc.ServiceEQSL:
if !has(cfg.EQSL.Username) || !has(cfg.EQSL.Password) {
return fmt.Errorf("eQSL.cc is not configured — set the username and password in Settings → External services")
}
case extsvc.ServiceHamQTH:
if !has(cfg.HamQTH.Username) || !has(cfg.HamQTH.Password) {
return fmt.Errorf("HamQTH is not configured — set the username and password in Settings → External services")
}
case extsvc.ServiceLoTW:
if !has(cfg.LoTW.StationLocation) {
return fmt.Errorf("LoTW is not configured — set the TQSL station location in Settings → External services")
}
}
return nil
}
func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.ExternalServices) { func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.ExternalServices) {
emit := func(line string) { emit := func(line string) {
if a.ctx != nil { if a.ctx != nil {
@@ -14314,7 +14371,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
// path — otherwise a QSO logged FROM WSJT-X/JTDX/MSHV was never re-emitted // path — otherwise a QSO logged FROM WSJT-X/JTDX/MSHV was never re-emitted
// to the outbound ADIF listeners (Log4OM, N1MM, gridtracker…). // to the outbound ADIF listeners (Log4OM, N1MM, gridtracker…).
if a.udp != nil { if a.udp != nil {
rec := adif.SingleRecordADIF(qc) rec := adif.ForwardRecordADIF(qc)
a.udp.EmitLoggedADIF(rec) a.udp.EmitLoggedADIF(rec)
a.udp.EmitLoggedQSOWSJT(wsjtLoggedQSO(qc), rec) a.udp.EmitLoggedQSOWSJT(wsjtLoggedQSO(qc), rec)
a.udpTriggerQSOLogged(qc) a.udpTriggerQSOLogged(qc)
+58 -10
View File
@@ -30,7 +30,10 @@ const (
keyAutoCallWatched = "autocall.watched_attempts" keyAutoCallWatched = "autocall.watched_attempts"
keyAutoCallMisses = "autocall.misses" keyAutoCallMisses = "autocall.misses"
keyAutoCallRounds = "autocall.max_rounds" keyAutoCallRounds = "autocall.max_rounds"
keyAutoCallRestMin = "autocall.rest_min" // Periods, not minutes — see autocall.Settings.RestPeriods. A new key rather
// than a reinterpreted one: the old value was in minutes, and reading "2" as
// two periods or as two minutes are eight periods apart.
keyAutoCallRest = "autocall.rest_periods"
keyAutoCallOnScreen = "autocall.on_screen_only" keyAutoCallOnScreen = "autocall.on_screen_only"
keyAutoCallTrace = "autocall.trace" keyAutoCallTrace = "autocall.trace"
) )
@@ -48,9 +51,10 @@ type AutoCallSettings struct {
// it, before it is given up on. // it, before it is given up on.
Misses int `json:"misses"` Misses int `json:"misses"`
// MaxRounds: how many series of calls one station gets in a session, and // MaxRounds: how many series of calls one station gets in a session, and
// RestMin the pause between two of them. // RestPeriods the pause between two of them, counted in the station's own
// transmit periods.
MaxRounds int `json:"max_rounds"` MaxRounds int `json:"max_rounds"`
RestMin int `json:"rest_min"` RestPeriods int `json:"rest_periods"`
// OnScreenOnly: call only what the decodes panel is showing, so its filters // OnScreenOnly: call only what the decodes panel is showing, so its filters
// steer the transmitter as well as the eye. // steer the transmitter as well as the eye.
OnScreenOnly bool `json:"on_screen_only"` OnScreenOnly bool `json:"on_screen_only"`
@@ -61,6 +65,16 @@ type AutoCallSettings struct {
Trace bool `json:"trace"` Trace bool `json:"trace"`
} }
// intOr reads a stored integer, keeping a stored ZERO — which num() cannot,
// since it treats zero as "nothing set".
func intOr(v string, def int) int {
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil || n < 0 {
return def
}
return n
}
func (a *App) GetAutoCallSettings() AutoCallSettings { func (a *App) GetAutoCallSettings() AutoCallSettings {
d := autocall.Defaults() d := autocall.Defaults()
num := func(key string, def int) int { num := func(key string, def int) int {
@@ -82,7 +96,9 @@ func (a *App) GetAutoCallSettings() AutoCallSettings {
Trace: a.settingOr(keyAutoCallTrace, "0") == "1", Trace: a.settingOr(keyAutoCallTrace, "0") == "1",
Misses: num(keyAutoCallMisses, d.Misses), Misses: num(keyAutoCallMisses, d.Misses),
MaxRounds: num(keyAutoCallRounds, d.MaxRounds), MaxRounds: num(keyAutoCallRounds, d.MaxRounds),
RestMin: num(keyAutoCallRestMin, int(d.Rest/time.Minute)), // Zero is meaningful (call it again next period), so it is read directly
// rather than through num(), which treats zero as "unset".
RestPeriods: intOr(a.settingOr(keyAutoCallRest, ""), d.RestPeriods),
} }
} }
@@ -94,12 +110,16 @@ func (a *App) SaveAutoCallSettings(s AutoCallSettings) error {
for key, v := range map[string]int{ for key, v := range map[string]int{
keyAutoCallAttempts: s.Attempts, keyAutoCallWatched: s.WatchedAttempts, keyAutoCallAttempts: s.Attempts, keyAutoCallWatched: s.WatchedAttempts,
keyAutoCallMisses: s.Misses, keyAutoCallRounds: s.MaxRounds, keyAutoCallMisses: s.Misses, keyAutoCallRounds: s.MaxRounds,
keyAutoCallRestMin: s.RestMin,
} { } {
if v > 0 { if v > 0 {
a.setSetting(key, strconv.Itoa(v)) a.setSetting(key, strconv.Itoa(v))
} }
} }
// Stored even at zero, unlike the counters above: no rest at all is a
// setting, not an empty field.
if s.RestPeriods >= 0 {
a.setSetting(keyAutoCallRest, strconv.Itoa(s.RestPeriods))
}
a.applyAutoCall() a.applyAutoCall()
applog.Printf("autocall: %v (only=%q, %d/%d calls, %d misses, %d rounds)", applog.Printf("autocall: %v (only=%q, %d/%d calls, %d misses, %d rounds)",
s.Enabled, s.Only, s.Attempts, s.WatchedAttempts, s.Misses, s.MaxRounds) s.Enabled, s.Only, s.Attempts, s.WatchedAttempts, s.Misses, s.MaxRounds)
@@ -140,7 +160,7 @@ func (a *App) autoCallSettings() autocall.Settings {
Enabled: s.Enabled, Only: s.Only, OnScreenOnly: s.OnScreenOnly, Enabled: s.Enabled, Only: s.Only, OnScreenOnly: s.OnScreenOnly,
Attempts: s.Attempts, WatchedAttempts: s.WatchedAttempts, Attempts: s.Attempts, WatchedAttempts: s.WatchedAttempts,
Misses: s.Misses, MaxRounds: s.MaxRounds, Misses: s.Misses, MaxRounds: s.MaxRounds,
Rest: time.Duration(s.RestMin) * time.Minute, RestPeriods: s.RestPeriods,
} }
} }
@@ -160,6 +180,7 @@ func (a *App) applyAutoCall() {
e.Reset() e.Reset()
a.acMu.Lock() a.acMu.Lock()
a.acPeriod, a.acBuf = nil, nil a.acPeriod, a.acBuf = nil, nil
a.acJudged, a.acDirty = nil, nil
a.acMu.Unlock() a.acMu.Unlock()
} }
a.emitAutoCall() a.emitAutoCall()
@@ -279,17 +300,37 @@ func (a *App) autoCallFeed(d autocall.Decode) {
if a.acPeriod == nil { if a.acPeriod == nil {
a.acPeriod, a.acAt, a.acTR, a.acBuf = map[string]string{}, map[string]time.Time{}, map[string]int{}, map[string][]acDecode{} a.acPeriod, a.acAt, a.acTR, a.acBuf = map[string]string{}, map[string]time.Time{}, map[string]int{}, map[string][]acDecode{}
a.acFed = map[string]time.Time{} a.acFed = map[string]time.Time{}
a.acJudged, a.acDirty = map[string]string{}, map[string]bool{}
} }
if prev := a.acPeriod[inst]; prev != "" && prev != key { if prev := a.acPeriod[inst]; prev != "" && prev != key {
prevAt, prevTR, buf := a.acAt[inst], a.acTR[inst], a.acBuf[inst] prevAt, prevTR, buf := a.acAt[inst], a.acTR[inst], a.acBuf[inst]
// Only if something in it has NOT been judged. The buffer now outlives
// the judgement (see below), so without this the arrival of the next
// period would judge the previous one a second time on the very same
// decodes — and act on them, a slot late.
pending := a.acDirty[inst] || a.acJudged[inst] != prev
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
a.acBuf[inst] = []acDecode{{d: d}} a.acBuf[inst] = []acDecode{{d: d}}
a.acFed[inst], a.acDirty[inst] = time.Now(), true
a.acMu.Unlock() a.acMu.Unlock()
// The previous period ends here whatever the sweeper was going to do: a
// decode stamped with the next slot is proof the old one is over.
if pending {
a.autoCallJudge(inst, prev, prevAt, prevTR, buf) a.autoCallJudge(inst, prev, prevAt, prevTR, buf)
}
return return
} }
// APPENDED, never restarted. The buffer survives the period being judged,
// so a decode that arrives after the others belongs to the same period and
// is weighed against all of them.
//
// It used to be dropped and then judged on its own: the sweeper cleared the
// buffer, a straggler opened a "new" one under the same key, and the ladder
// was applied to whatever handful had come late — with the other thirty
// stations of that period nowhere in sight. A deep decode arriving a second
// after the burst is exactly the station worth calling.
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
a.acFed[inst] = time.Now() a.acFed[inst], a.acDirty[inst] = time.Now(), true
a.acBuf[inst] = append(a.acBuf[inst], acDecode{d: d}) a.acBuf[inst] = append(a.acBuf[inst], acDecode{d: d})
a.acMu.Unlock() a.acMu.Unlock()
} }
@@ -344,9 +385,14 @@ func (a *App) autoCallSweep() {
if time.Since(a.acFed[inst]) < acQuiet { if time.Since(a.acFed[inst]) < acQuiet {
continue continue
} }
// Judged once per period, and again only when something new has come in
// for it. The period itself is kept open until a decode from the NEXT one
// arrives, so a straggler is judged with the whole period behind it.
if a.acJudged[inst] == key && !a.acDirty[inst] {
continue
}
ready = append(ready, due{inst, key, a.acAt[inst], tr, a.acBuf[inst]}) ready = append(ready, due{inst, key, a.acAt[inst], tr, a.acBuf[inst]})
delete(a.acPeriod, inst) a.acJudged[inst], a.acDirty[inst] = key, false
delete(a.acBuf, inst)
} }
a.acMu.Unlock() a.acMu.Unlock()
for _, d := range ready { for _, d := range ready {
@@ -369,7 +415,9 @@ func (a *App) autoCallSilence() {
return return
} }
a.acMu.Lock() a.acMu.Lock()
quiet := a.acPeriod[inst] == "" && time.Since(a.acLastJudge) > 20*time.Second // Nothing pending for this receiver, and nothing judged for a while: the
// band has gone quiet under it.
quiet := !a.acDirty[inst] && time.Since(a.acLastJudge) > 20*time.Second
tr := a.acTR[inst] tr := a.acTR[inst]
a.acMu.Unlock() a.acMu.Unlock()
if !quiet { if !quiet {
+40
View File
@@ -1,4 +1,44 @@
[ [
{
"version": "0.27.14",
"date": "",
"en": [
"Auto-call sees a decode that arrives after the others. A decoder sends a period in a burst and stragglers follow — a deep decode a second behind the rest — and the straggler was judged on its own, with the thirty stations of its own period nowhere in sight. The period now stays open until the next one starts, and a late arrival is weighed against all of it.",
"Icom CI-V: address 00 can be set, and “Other (custom address)” stays chosen. Zero was treated as “not configured” and every save put the rig back to the IC-7610s 98 — the model list following it, since it is derived from the address rather than stored.",
"Cloudlog / Wavelog upload: a simplex contact is no longer uploaded as split. Every QSO carried a receive band and frequency equal to the transmit side, and Wavelog draws both — an ordinary FT8 contact read “17m/17m”. In ADIF an absent BAND_RX means “same as transmit”, so they are now written only when they differ. The record forwarded to another logger on the UDP link still carries them in full (Log4OM reads BAND_RX).",
"Cluster: “S/F” in a spot comment is read as FT8, alongside “superfox”, “sfox” and “F/H”. They are all the same DXpedition transmit mode, and the comment was falling through to the band plan and coming out DATA.",
"Auto-call never parks a watched callsign. After a few series of unanswered calls a station is set aside for the session — the right answer for one the LOG picked out, the wrong one for a station YOU named: a DXpedition running a pileup takes more than two series to get through to, which is exactly why it is on the list. The rest between series still applies.",
"Right-click → Send to: an upload to a service with no credentials is refused, and says which ones are missing and where. It used to run on its own and report into the QSL Managers console, which is not open when the command came from the QSO list — so it looked exactly like an upload that worked. Cloudlog / Wavelog and HamQTH also name themselves properly in the toast.",
"[NEW] The mouse wheel steps the RST fields, in the entry strip and in the QSO editor. It counts the way an operator does — 57, 58, 59, 59+5, 59+10, 59+15, 59+20 — one S-unit up to nine and then five decibels at a time, and one decibel on a digital report. R and T do not move. The dropdown beside them lists the reports worth having to hand, not every legal one, so the wheel works on the value rather than walking the list.",
"PSK Reporter panel: with the whole-band scope, clicking a decode no longer resets the report count to zero. The window there belongs to the BAND — every FTx report on it, filtered by target only when the analysis is drawn — and it was being emptied on every target change, throwing away an hour of evidence at the exact moment it was worth something. The narrow scope still clears it, because there the window is one stations.",
"Changing mode with a callsign in the field now fixes the report. The “the operator chose this report” flag was holding across a change of mode, where it means nothing — “+00” is not a weak SSB report, it is not a report at all — and anything that fills the field from the rig (the S-meter readouts in the rig consoles) sets that flag too, so it could stay in the wrong notation for the whole QSO. A judgement that can be carried across is carried (57 → 579, 599 → 59); otherwise the modes preset answers.",
"[NEW] The world map opens centred on YOUR square, not on Greenwich. Centred on 0° it left an Australian looking at their own country in the bottom-right corner with every path running off both edges; centred on their own longitude the same map reads the way their antenna does — the Americas to the east, Europe and Africa to the west. The latitude leans towards your hemisphere without following you to the pole, and a view you have panned to yourself still wins.",
"The FT decodes map and the grid-square map remember where you left them — centre and zoom, portable with the data folder like the world maps own view. Panning a map is the operator saying which part of the world they are working, and it was being thrown away on every tab switch.",
"The world map now waits for the stations square before painting. It used to draw the world at 0° and then move to your longitude, fetching a screenful of tiles and discarding it on every first run; it is built once, knowing where it is looking. A profile with no locator still gets the default view after a moment rather than a blank panel.",
"Auto-call: the rest between two series is counted in the stations own overs, not in minutes, and is ONE by default. Two minutes is four overs on FT8 — by then the DX has worked four other callers and half the time it has gone. Seven calls, one over listened through, and it goes again if the station is still there (Settings → DXHunter, “Rest (overs)”).",
"Auto-call: a period the station was decoded in is never counted as a miss. A period is judged more than once — the decodes arrive in a burst and stragglers follow — and a later judgement holds a partial view of it, not evidence of absence: a station answering in that very period showed “1/3 missed” against it.",
"The auto-call readout moved out of the Auto button and beside it: the station being called is the biggest thing on the row, the calls and the missed periods each carry a label instead of reading as one number, and a station being waited for shows with an hourglass. The button had been changing width every period.",
"FT map: the station dots answer the same two gestures as the decodes list — one click takes the callsign into the entry, two answer it. The hit area is wider than the dot, and a double click no longer zooms the map on its way through."
],
"fr": [
"Lauto-call voit un décodage qui arrive après les autres. Un décodeur envoie une période en rafale, puis les retardataires — un décodage « deep » une seconde plus tard — et le retardataire était jugé tout seul, sans les trente stations de sa propre période. La période reste maintenant ouverte jusquau début de la suivante, et un arrivant tardif est pesé face à lensemble.",
"Icom CI-V : ladresse 00 peut être saisie, et « Other (custom address) » reste sélectionné. Le zéro était pris pour « non configuré » et chaque enregistrement remettait le poste sur le 98 de lIC-7610 — la liste des modèles suivant, puisquelle est déduite de ladresse et non enregistrée.",
"Upload Cloudlog / Wavelog : un contact simplex nest plus envoyé comme un split. Chaque QSO portait une bande et une fréquence de réception égales à l’émission, et Wavelog affiche les deux — un FT8 ordinaire se lisait « 17m/17m ». En ADIF, un BAND_RX absent signifie « identique à l’émission » : ils ne sont donc écrits que sils diffèrent. Lenregistrement transmis à un autre logiciel par UDP les porte toujours en entier (Log4OM lit BAND_RX).",
"Cluster : « S/F » dans un commentaire de spot est lu comme du FT8, au même titre que « superfox », « sfox » et « F/H ». Cest le même mode d’émission DXpédition, et le commentaire retombait sur le plan de bande pour ressortir en DATA.",
"Lauto-call ne met jamais de côté un indicatif de la watchlist. Après quelques séries dappels sans réponse, une station est écartée pour la session — la bonne réponse pour une station choisie par le CARNET, la mauvaise pour une station que VOUS avez nommée : un DX en pile-up demande plus de deux séries pour passer, et cest précisément pour ça quil est sur la liste. Le repos entre séries sapplique toujours.",
"Clic droit → Envoyer vers : un envoi vers un service non configuré est refusé, en disant ce qui manque et où. Il partait tout seul et rendait compte dans la console du gestionnaire QSL, qui nest pas ouverte quand la commande vient de la liste des QSO — ça ressemblait donc exactement à un envoi réussi. Cloudlog / Wavelog et HamQTH sannoncent aussi sous leur nom dans le message.",
"[NEW] La molette fait défiler les champs RST, dans la barre de saisie comme dans l’éditeur de QSO. Elle compte comme un opérateur — 57, 58, 59, 59+5, 59+10, 59+15, 59+20 — un point S jusqu’à neuf puis cinq décibels à la fois, et un décibel sur un report numérique. R et T ne bougent pas. La liste déroulante à côté contient les reports quon veut sous la main, pas tous les reports légaux : la molette agit donc sur la valeur plutôt que de parcourir la liste.",
"Panneau PSK Reporter : en portée « toute la bande », cliquer sur un décodage ne remet plus le nombre de reports à zéro. La fenêtre appartient là à la BANDE — tous les reports FTx qui y circulent, filtrés par cible seulement à laffichage — et elle était vidée à chaque changement de cible, jetant une heure dobservations au moment précis où elles servent. La portée étroite continue de la vider : là, la fenêtre est celle dune seule station.",
"Changer de mode avec un indicatif dans le champ corrige désormais le report. Le drapeau « lopérateur a choisi ce report » tenait au travers dun changement de mode, où il ne veut rien dire — « +00 » nest pas un report SSB faible, ce nest pas un report du tout — et tout ce qui remplit le champ depuis le poste (les lectures S-mètre des consoles) lève ce drapeau aussi : la notation pouvait rester fausse pour tout le QSO. Un jugement transposable lest (57 → 579, 599 → 59) ; sinon le préréglage du mode répond.",
"[NEW] La carte du monde souvre centrée sur VOTRE locator, plus sur Greenwich. Centrée sur 0°, elle laissait un Australien avec son pays dans le coin en bas à droite et tous les trajets qui sortaient des deux bords ; centrée sur sa longitude, la même carte se lit comme son antenne travaille — les Amériques à lest, lEurope et lAfrique à louest. La latitude penche vers votre hémisphère sans vous suivre jusquau pôle, et une vue que vous avez déplacée vous-même reste prioritaire.",
"La carte des décodages FTx et la carte des locators retiennent où vous les avez laissées — centre et zoom, portables avec le dossier de données comme la vue de la carte du monde. Déplacer une carte, cest dire quelle partie du monde on travaille, et c’était jeté à chaque changement donglet.",
"La carte du monde attend désormais le locator de la station avant de peindre. Elle dessinait le monde à 0° puis se déplaçait sur votre longitude, chargeant un écran de tuiles jeté aussitôt à chaque premier lancement ; elle est construite une fois, en sachant où elle regarde. Un profil sans locator obtient toujours la vue par défaut après un instant, pas un panneau vide.",
"Auto-call : le repos entre deux séries se compte en tours de la station, plus en minutes, et vaut UN par défaut. Deux minutes, cest quatre tours en FT8 — le DX a travaillé quatre autres appelants entre-temps, et la moitié du temps il est parti. Sept appels, un tour écouté, et ça repart si la station est toujours là (Réglages → DXHunter, « Repos (tours) »).",
"Auto-call : une période où la station a été décodée nest plus comptée comme un raté. Une période est jugée plusieurs fois — les décodages arrivent en rafale puis les retardataires — et un jugement tardif nen donne quune vue partielle, pas la preuve dune absence : une station qui répondait dans cette période exacte se voyait compter « 1/3 raté ».",
"Laffichage de lauto-call sort du bouton Auto pour se placer à côté : la station appelée est l’élément le plus lisible de la ligne, les appels et les périodes ratées portent chacun leur étiquette au lieu de se lire comme un seul nombre, et une station attendue saffiche avec un sablier. Le bouton changeait de largeur à chaque période.",
"Carte FTx : les points des stations répondent aux mêmes deux gestes que la liste des décodages — un clic met lindicatif dans la saisie, deux lappellent. La zone cliquable est plus large que le point, et un double clic ne zoome plus la carte au passage."
]
},
{ {
"version": "0.27.13", "version": "0.27.13",
"date": "", "date": "",
+76 -35
View File
@@ -150,6 +150,7 @@ import { cn } from '@/lib/utils';
import { pathBetween, pathBetweenLatLon, gridToLatLon, latLonToGrid } from '@/lib/maidenhead'; import { pathBetween, pathBetweenLatLon, gridToLatLon, latLonToGrid } from '@/lib/maidenhead';
import { flagURL } from '@/lib/flags'; import { flagURL } from '@/lib/flags';
import { LogViewer } from '@/components/LogViewer'; import { LogViewer } from '@/components/LogViewer';
import { convertRST, rstFitsMode, stepRST } from '@/lib/rst';
type QSO = QSOForm; type QSO = QSOForm;
type ImportResult = adifModels.ImportResult; type ImportResult = adifModels.ImportResult;
@@ -657,6 +658,11 @@ export default function App() {
useEffect(() => { rstListsRef.current = rstLists; }, [rstLists]); useEffect(() => { rstListsRef.current = rstLists; }, [rstLists]);
const [rstSent, setRstSent] = useState('59'); const [rstSent, setRstSent] = useState('59');
const [rstRcvd, setRstRcvd] = useState('59'); const [rstRcvd, setRstRcvd] = useState('59');
// Read by applyModePreset, which runs from those same long-lived closures.
const rstSentRef = useRef(rstSent);
const rstRcvdRef = useRef(rstRcvd);
useEffect(() => { rstSentRef.current = rstSent; }, [rstSent]);
useEffect(() => { rstRcvdRef.current = rstRcvd; }, [rstRcvd]);
const [grid, setGrid] = useState(''); const [grid, setGrid] = useState('');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [qth, setQth] = useState(''); const [qth, setQth] = useState('');
@@ -3340,15 +3346,31 @@ export default function App() {
return m; return m;
} }
function applyModePreset(m: string) { function applyModePreset(m: string) {
if (rstUserEditedRef.current) return; // AN EDIT IS ABOUT A SIGNAL, NOT ABOUT A NOTATION.
//
// The edited flag protects a report the operator chose — 57 rather than 59 —
// and it used to protect it across a change of mode as well, where it means
// nothing: "+00" is not a weak SSB report, it is not a report at all. An
// operator with a callsign in the field, switching from FT8 to SSB, was left
// with a decibel figure to log, and anything that fills the report from the
// rig (the S-meter readouts in the rig consoles) sets that flag too — so the
// field could be stuck in the wrong notation for the rest of the QSO.
//
// So the flag holds only while the report still belongs to the mode. When it
// does not, the judgement is carried across where it can be (57 → 579,
// 599 → 59) and the preset answers where it cannot.
const fits = rstFitsMode(rstSentRef.current, m) && rstFitsMode(rstRcvdRef.current, m);
if (rstUserEditedRef.current && fits) return;
// Prefer the user's configured preset RST; otherwise fall back to the mode // Prefer the user's configured preset RST; otherwise fall back to the mode
// category default (CW/RTTY/PSK → 599, phone → 59, digital → first option) // category default (CW/RTTY/PSK → 599, phone → 59, digital → first option)
// so switching SSB→CW flips 59→599 even without a configured preset. // so switching SSB→CW flips 59→599 even without a configured preset.
// Read through the refs, never the state: see their declaration. // Read through the refs, never the state: see their declaration.
const p = modePresetsRef.current.find((x) => x.name === m); const p = modePresetsRef.current.find((x) => x.name === m);
const fallback = rstOptions(m, rstListsRef.current)[0] || ''; const fallback = rstOptions(m, rstListsRef.current)[0] || '';
setRstSent(p?.default_rst_sent || fallback); const keep = (cur: string, preset: string) =>
setRstRcvd(p?.default_rst_rcvd || fallback); (rstUserEditedRef.current && convertRST(cur, m)) || preset || fallback;
setRstSent(keep(rstSentRef.current, p?.default_rst_sent || ''));
setRstRcvd(keep(rstRcvdRef.current, p?.default_rst_rcvd || ''));
} }
// Clicking a spot (cluster grid or any band map): tune the rig, set the mode, // Clicking a spot (cluster grid or any band map): tune the rig, set the mode,
// fill the call, pre-fill POTA, (re)start the recording. Shared so every spot // fill the call, pre-fill POTA, (re)start the recording. Shared so every spot
@@ -4559,11 +4581,16 @@ export default function App() {
const LABELS: Record<string, string> = { const LABELS: Record<string, string> = {
qrz: 'QRZ.com', clublog: 'Club Log', lotw: 'LoTW', qrz: 'QRZ.com', clublog: 'Club Log', lotw: 'LoTW',
hrdlog: 'HRDLog.net', eqsl: 'eQSL.cc', hamlog: 'HAMLOG.online', hrdlog: 'HRDLog.net', eqsl: 'eQSL.cc', hamlog: 'HAMLOG.online',
hamqth: 'HamQTH', cloudlog: 'Cloudlog / Wavelog',
}; };
const label = LABELS[service] ?? service; const label = LABELS[service] ?? service;
try {
// Awaited BEFORE the toast: the backend refuses a service with no
// credentials, and announcing an upload that was never started is how an
// operator concludes their QSOs are on a site they never signed up to.
await UploadQSOsManual(service, ids as any);
showToast(`Uploading ${ids.length} QSO${ids.length > 1 ? 's' : ''} to ${label}`); showToast(`Uploading ${ids.length} QSO${ids.length > 1 ? 's' : ''} to ${label}`);
try { await UploadQSOsManual(service, ids as any); } } catch (e: any) { setError(String(e?.message ?? e)); }
catch (e: any) { setError(String(e?.message ?? e)); }
} }
// Right-click "Export filtered to ADIF (no limit)": exports every QSO that // Right-click "Export filtered to ADIF (no limit)": exports every QSO that
// matches the current filter, bypassing the on-screen row threshold. // matches the current filter, bypassing the on-screen row threshold.
@@ -5475,12 +5502,19 @@ export default function App() {
); );
const rstTxBlock = ( const rstTxBlock = (
<div className="flex flex-col w-20" data-esm="rsttx"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label> <div className="flex flex-col w-20" data-esm="rsttx"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} /> {/* The wheel steps the report an S-unit on RST, a decibel on a digital
one. Wheeling is the same gesture as saying "he is a bit stronger than
that", and it beats retyping three characters between overs. */}
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} commitOnType
onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }}
onWheelStep={(d) => { setRstSent((v) => stepRST(v, d, mode)); rstUserEditedRef.current = true; }} />
</div> </div>
); );
const rstRxBlock = ( const rstRxBlock = (
<div className="flex flex-col w-20" data-esm="rstrx"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label> <div className="flex flex-col w-20" data-esm="rstrx"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} /> <Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType
onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }}
onWheelStep={(d) => { setRstRcvd((v) => stepRST(v, d, mode)); rstUserEditedRef.current = true; }} />
</div> </div>
); );
// DX country flag, shown large next to RST (moved here from the Country field). // DX country flag, shown large next to RST (moved here from the Country field).
@@ -6425,6 +6459,37 @@ export default function App() {
</div> </div>
); );
// ONE CLICK TAKES THE STATION, TWO ANSWER IT — and the FT map means the same
// by them as the list does. They were written inline on the list, so the map
// had no way to offer the same gestures without a second copy of them.
function selectDecode(d: any) {
setPskTarget((d.call ?? '').toUpperCase());
setPskTargetMode(d.mode ?? '');
onCallsignInput(d.call, { force: true });
}
function callDecode(d: any) {
// The station being answered is also the one worth analysing: the PSK
// Reporter panel follows the click rather than asking for a second one.
setPskTarget((d.call ?? '').toUpperCase());
setPskTargetMode(d.mode ?? '');
// Auto-call adopts the station: the click chooses WHO, the watchdogs still
// decide how long it is called for.
TakeAutoCallTarget(d.call ?? '', d.band ?? '', d.mode ?? '').catch(() => {});
onCallsignInput(d.call, { force: true });
// With two slices on two bands, the Reply reaches the right INSTANCE but the
// radio still transmits on whichever slice holds the TX flag. Move it to the
// decode's band first, or an 80 m answer goes out on 20 m. A no-op on any
// backend without slices.
FlexTXOnBand(d.band ?? '').catch(() => { /* not a Flex, or no such slice */ });
AnswerDecode(
d.instance ?? '', d.ms ?? 0, d.snr, d.dt ?? 0,
// The RAW mode marker, not the resolved name: the receiving application
// matches the Reply against its decode list field for field, and JTDX
// drops one that says "FT8" where it decoded "~".
d.audio_hz ?? 0, d.mode_raw || d.mode || '', d.msg_raw ?? d.msg ?? '', !!d.low_conf,
).catch((e: any) => setError(String(e?.message ?? e)));
}
const renderDecodesList = () => ( const renderDecodesList = () => (
<DecodesPanel <DecodesPanel
decodes={decodes} decodes={decodes}
@@ -6449,33 +6514,8 @@ export default function App() {
// One click: take the station, transmit nothing. The entry is filled and // One click: take the station, transmit nothing. The entry is filled and
// the panels follow it, exactly as clicking a cluster spot does — so a // the panels follow it, exactly as clicking a cluster spot does — so a
// row can be inspected, looked up and read about without keying up. // row can be inspected, looked up and read about without keying up.
onSelect={(d) => { onSelect={selectDecode}
setPskTarget((d.call ?? '').toUpperCase()); onCall={callDecode}
setPskTargetMode(d.mode ?? '');
onCallsignInput(d.call, { force: true });
}}
onCall={(d) => {
// The station being answered is also the one worth analysing: the PSK
// Reporter panel follows the click rather than asking for a second one.
setPskTarget((d.call ?? '').toUpperCase());
setPskTargetMode(d.mode ?? '');
// Auto-call adopts the station: the click chooses WHO, the watchdogs
// still decide how long it is called for.
TakeAutoCallTarget(d.call ?? '', d.band ?? '', d.mode ?? '').catch(() => {});
onCallsignInput(d.call, { force: true });
// With two slices on two bands, the Reply reaches the right INSTANCE but
// the radio still transmits on whichever slice holds the TX flag. Move
// it to the decode's band first, or an 80 m answer goes out on 20 m.
// A no-op on any backend without slices.
FlexTXOnBand(d.band ?? '').catch(() => { /* not a Flex, or no such slice */ });
AnswerDecode(
d.instance ?? '', d.ms ?? 0, d.snr, d.dt ?? 0,
// The RAW mode marker, not the resolved name: the receiving
// application matches the Reply against its decode list field for
// field, and JTDX drops one that says "FT8" where it decoded "~".
d.audio_hz ?? 0, d.mode_raw || d.mode || '', d.msg_raw ?? d.msg ?? '', !!d.low_conf,
).catch((e: any) => setError(String(e?.message ?? e)));
}}
// Wipes the live view only — nothing here is stored, and the staging // Wipes the live view only — nothing here is stored, and the staging
// buffer goes too or the next flush would put back what was just cleared. // buffer goes too or the next flush would put back what was just cleared.
onClear={(instance) => { onClear={(instance) => {
@@ -8699,7 +8739,8 @@ export default function App() {
<TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden"> <TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
{activeTab === 'ftmap' && ( {activeTab === 'ftmap' && (
<div className="h-full w-full min-h-0 bg-card border border-border rounded-lg overflow-hidden"> <div className="h-full w-full min-h-0 bg-card border border-border rounded-lg overflow-hidden">
<FTMapPanel decodes={decodes as any} myGrid={station.my_grid} /> <FTMapPanel decodes={decodes as any} myGrid={station.my_grid}
onSelect={selectDecode} onCall={callDecode} />
</div> </div>
)} )}
</TabsContent> </TabsContent>
+40 -14
View File
@@ -1009,22 +1009,48 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
> >
<Bot className="size-3.5" /> <Bot className="size-3.5" />
{t('dec.autoCall')} {t('dec.autoCall')}
{autoCallOn && autoCall?.target && (
<span className="font-mono text-xs">
{autoCall.target} {autoCall.calls}/{autoCall.max}
{autoCall.misses > 0 ? ` ·${autoCall.misses}/${autoCall.max_miss}` : ''}
</span>
)}
{/* Wanted, decoded, and in a QSO with somebody else. Holding fire
looks exactly like having nothing to do, and the operator had no
way to tell them apart. */}
{autoCallOn && !autoCall?.target && autoCall?.waiting && (
<span className="font-mono text-xs animate-pulse" title={t('dec.autoWaitTip', { call: autoCall.waiting })}>
{autoCall.waiting}
</span>
)}
</button> </button>
)} )}
{/* WHAT IT IS DOING, BESIDE THE SWITCH RATHER THAN ON IT.
A thing that keys the transmitter must never be a switch with no
readout — but the readout was crammed inside the button as
"Auto D2ACE 1/7 ·1/3", where the two counters read as one number and
the whole control changed width every period. Out here each figure
gets a label, and the station being called is the biggest thing on
the row. */}
{autoCallOn && autoCall?.target && (
<span className="h-8 inline-flex items-center gap-2 rounded-lg border border-success/50 bg-success/10 px-2"
title={t('dec.autoTargetTip', { call: autoCall.target })}>
<span className="font-mono text-sm font-bold text-success">{autoCall.target}</span>
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<span className="uppercase tracking-wide">{t('dec.autoCalls')}</span>
<span className="font-mono tabular-nums text-foreground">{autoCall.calls}/{autoCall.max}</span>
</span>
{/* Only once there is one to report: a zero that is always there is
a figure nobody reads, and it was the half of the pair that got
mistaken for the call count. */}
{autoCall.misses > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<span className="uppercase tracking-wide">{t('dec.autoMisses')}</span>
<span className={cn('font-mono tabular-nums',
autoCall.misses + 1 >= autoCall.max_miss ? 'text-warning' : 'text-foreground')}>
{autoCall.misses}/{autoCall.max_miss}
</span>
</span>
)}
</span>
)}
{/* Wanted, decoded, and in a QSO with somebody else. Holding fire looks
exactly like having nothing to do, and the operator had no way to
tell them apart. */}
{autoCallOn && !autoCall?.target && autoCall?.waiting && (
<span className="h-8 inline-flex items-center gap-1.5 rounded-lg border border-warning/50 bg-warning/10 px-2 animate-pulse"
title={t('dec.autoWaitTip', { call: autoCall.waiting })}>
<span className="text-sm"></span>
<span className="font-mono text-sm font-bold text-warning">{autoCall.waiting}</span>
<span className="text-[11px] uppercase tracking-wide text-muted-foreground">{t('dec.autoWaiting')}</span>
</span>
)}
{/* The chase list. Raw text while typing, committed on blur or Enter: {/* The chase list. Raw text while typing, committed on blur or Enter:
the stored value is upper-cased and trimmed, and binding the box to the stored value is upper-cased and trimmed, and binding the box to
that makes the space bar look dead — in a field whose whole purpose that makes the space bar look dead — in a field whose whole purpose
+55 -3
View File
@@ -5,6 +5,7 @@ import { gridToLatLon, greatCirclePoints, splitAtAntimeridian } from '@/lib/maid
import { BASEMAPS, type BasemapKey } from '@/components/MainMap'; import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { loadMapView, saveMapView, MAP_VIEW_FT } from '@/lib/mapView';
// FT Map — the live decode feed as geography: every station decoded in the // FT Map — the live decode feed as geography: every station decoded in the
// last half hour, an arc from the operator's own square to theirs, coloured by // last half hour, an arc from the operator's own square to theirs, coloured by
@@ -18,12 +19,16 @@ import { useI18n } from '@/lib/i18n';
// capped, and redraws happen when the DECODE LIST changes — every 15 s in FT8, // capped, and redraws happen when the DECODE LIST changes — every 15 s in FT8,
// not per frame. // not per frame.
// The map is handed the SAME decode objects the list works from — it only reads
// a few of the fields. The rest travel with them so a click here can answer the
// station exactly as a double-click in the list does.
export type FTMapDecode = { export type FTMapDecode = {
call: string; call: string;
grid?: string; grid?: string;
band?: string; band?: string;
snr: number; snr: number;
at: string; at: string;
[k: string]: unknown;
}; };
// The band palette every PSK Reporter user already knows, near enough. // The band palette every PSK Reporter user already knows, near enough.
@@ -38,7 +43,26 @@ const bandColour = (b?: string) => BAND_COLOURS[(b ?? '').toLowerCase()] || '#9c
const MAX_ARCS = 300; const MAX_ARCS = 300;
const MAX_AGE_MS = 30 * 60_000; const MAX_AGE_MS = 30 * 60_000;
export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid: string }) { export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
decodes: FTMapDecode[];
myGrid: string;
// One click takes the station, two answer it — the list's own gestures.
onSelect?: (d: FTMapDecode) => void;
onCall?: (d: FTMapDecode) => void;
}) {
// Held in refs so the redraw below does not have to list them as dependencies
// and rebuild every arc whenever the parent re-renders.
const selectRef = useRef(onSelect);
const callRef = useRef(onCall);
useEffect(() => { selectRef.current = onSelect; callRef.current = onCall; }, [onSelect, onCall]);
// Distinguishing the two gestures is ours to do: Leaflet fires click before
// dblclick and leaves the telling apart to the handler.
const clickTimer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(clickTimer.current), []);
// Where this map was left. Panning and zooming it is the operator saying which
// part of the world they are working; throwing that away on every tab switch
// made it something to set up again rather than something to glance at.
const saved = useRef(loadMapView(MAP_VIEW_FT));
const { t } = useI18n(); const { t } = useI18n();
const divRef = useRef<HTMLDivElement>(null); const divRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null); const mapRef = useRef<L.Map | null>(null);
@@ -58,7 +82,15 @@ export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid
// No maxBounds: with the world smaller than the window the clamp // No maxBounds: with the world smaller than the window the clamp
// dragged every zoom into a corner. noWrap tiles alone keep one world. // dragged every zoom into a corner. noWrap tiles alone keep one world.
worldCopyJump: false, preferCanvas: true, worldCopyJump: false, preferCanvas: true,
center: [25, 0], zoom: 2, minZoom: 2, center: saved.current ? [saved.current.lat, saved.current.lon] : [25, 0],
zoom: saved.current ? saved.current.zoom : 2,
minZoom: 2,
});
// Every move, not just the deliberate ones: a zoom is as much a choice as a
// pan, and there is no moment afterwards at which to ask.
m.on('moveend', () => {
const c = m.getCenter();
saveMapView(MAP_VIEW_FT, c.lat, c.lng, m.getZoom());
}); });
mapRef.current = m; mapRef.current = m;
layerRef.current = L.layerGroup().addTo(m); layerRef.current = L.layerGroup().addTo(m);
@@ -132,10 +164,30 @@ export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid
L.polyline(pts as L.LatLngExpression[][], { L.polyline(pts as L.LatLngExpression[][], {
color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0, color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
}).addTo(layer); }).addTo(layer);
L.circleMarker([to.lat, to.lon], { const mk = L.circleMarker([to.lat, to.lon], {
// A three-pixel dot is a fine mark and a poor target, so the visible
// radius stays and an invisible one twice the size takes the clicks.
radius: 3, color: colour, weight: 1, fillColor: colour, fillOpacity: 0.9 * fade, radius: 3, color: colour, weight: 1, fillColor: colour, fillOpacity: 0.9 * fade,
}).bindTooltip(`${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`, { direction: 'top' }) }).bindTooltip(`${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`, { direction: 'top' })
.addTo(layer); .addTo(layer);
const hit = L.circleMarker([to.lat, to.lon], {
radius: 9, opacity: 0, fillOpacity: 0, interactive: true,
}).addTo(layer);
for (const target of [mk, hit]) {
target.on('click', (e) => {
// Not to the map: a click on a station is not a click on the water.
L.DomEvent.stopPropagation(e as unknown as Event);
window.clearTimeout(clickTimer.current);
clickTimer.current = window.setTimeout(() => selectRef.current?.(d), 250);
});
target.on('dblclick', (e) => {
// stop(), not stopPropagation(): the map zooms on a double click, and
// answering a station is not a request to zoom in on it.
L.DomEvent.stop(e as unknown as Event);
window.clearTimeout(clickTimer.current);
callRef.current?.(d);
});
}
} }
}, [decodes, myGrid]); }, [decodes, myGrid]);
+12 -3
View File
@@ -8,6 +8,7 @@ import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { BASEMAPS, addBasemap, loadBasemap, type BasemapKey } from '@/components/MainMap'; import { BASEMAPS, addBasemap, loadBasemap, type BasemapKey } from '@/components/MainMap';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { loadMapView, saveMapView, MAP_VIEW_GRIDS } from '@/lib/mapView';
// GridSquareMap — every Maidenhead square in the log, drawn on a world map. // GridSquareMap — every Maidenhead square in the log, drawn on a world map.
// //
@@ -131,8 +132,16 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
}).setView([20, 0], 2); }).setView([20, 0], 2);
// The whole world, once, whatever the window is shaped like — computed by // The whole world, once, whatever the window is shaped like — computed by
// Leaflet from the container rather than guessed with a zoom number. // Leaflet from the container rather than guessed with a zoom number. Unless
m.fitWorld({ animate: false }); // the operator has already chosen a view: theirs is the answer, and fitting
// the world over it would undo the choice on every tab switch.
const saved = loadMapView(MAP_VIEW_GRIDS);
if (saved) m.setView([saved.lat, saved.lon], saved.zoom);
else m.fitWorld({ animate: false });
m.on('moveend', () => {
const c = m.getCenter();
saveMapView(MAP_VIEW_GRIDS, c.lat, c.lng, m.getZoom());
});
mapRef.current = m; mapRef.current = m;
layerRef.current = L.layerGroup().addTo(m); layerRef.current = L.layerGroup().addTo(m);
// Leaflet measures its container ONCE, when the map is created, and never // Leaflet measures its container ONCE, when the map is created, and never
@@ -144,7 +153,7 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
// fitWorld above ran against a container that may still have had no size — // fitWorld above ran against a container that may still have had no size —
// the tab is mounted hidden. Fit ONCE more the first time it really has one, // the tab is mounted hidden. Fit ONCE more the first time it really has one,
// and never again: after that the view belongs to the operator. // and never again: after that the view belongs to the operator.
let fitted = false; let fitted = !!saved; // a remembered view is already the right size
const ro = new ResizeObserver(() => { const ro = new ResizeObserver(() => {
m.invalidateSize({ animate: false }); m.invalidateSize({ animate: false });
const el = hostRef.current; const el = hostRef.current;
+49 -12
View File
@@ -5,15 +5,13 @@ import { nightPolygon } from '../lib/greyline';
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead'; import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { formatDistance } from '@/lib/units'; import { formatDistance } from '@/lib/units';
import { loadMapView, saveMapView, MAP_VIEW_WORLD } from '@/lib/mapView';
// Persisted free-pan view of the world map (when auto-zoom is off). // Persisted free-pan view of the world map (when auto-zoom is off).
function loadMapView(): { lat: number; lon: number; zoom: number } | null { const loadWorldView = () => loadMapView(MAP_VIEW_WORLD);
try { const v = JSON.parse(localStorage.getItem('opslog.mapView') || 'null'); return v && typeof v.zoom === 'number' ? v : null; } function saveWorldView(m: L.Map) {
catch { return null; }
}
function saveMapView(m: L.Map) {
const c = m.getCenter(); const c = m.getCenter();
writeUiPref('opslog.mapView', JSON.stringify({ lat: c.lat, lon: c.lng, zoom: m.getZoom() })); saveMapView(MAP_VIEW_WORLD, c.lat, c.lng, m.getZoom());
} }
// The Main tab is built from two independent map panes that the operator can // The Main tab is built from two independent map panes that the operator can
@@ -28,6 +26,26 @@ function saveMapView(m: L.Map) {
// unwrapLon makes a lat/lon ring continuous in longitude (each point within // unwrapLon makes a lat/lon ring continuous in longitude (each point within
// 180° of the previous) so a polygon crossing the antimeridian doesn't snap // 180° of the previous) so a polygon crossing the antimeridian doesn't snap
// across the whole map. Coords may exceed ±180; Leaflet (worldCopyJump) is fine. // across the whole map. Coords may exceed ±180; Leaflet (worldCopyJump) is fine.
// homeView is where a world map should open for THIS station.
//
// Centred on 0° a world map puts Europe in the middle and leaves an Australian
// looking at their own country in the bottom-right corner, with the paths to
// everywhere they work running off both edges. Centred on their own longitude
// the same map reads the way their antenna does: the Americas to the east,
// Europe and Africa to the west. Leaflet repeats the world horizontally
// (worldCopyJump), so this is a choice of viewpoint and costs nothing — the
// tiles either side are the same world, and the great-circle code already draws
// across the seam (see unwrapLon).
//
// The latitude is damped, not followed: a station at 69° north centred on itself
// would spend half the map on the Arctic. It leans towards the operator's
// hemisphere and stops well short of the pole.
function homeView(grid: string): [number, number] {
const home = gridToLatLon(grid);
if (!home) return [20, 0];
return [Math.max(-35, Math.min(45, home.lat * 0.6 + 8)), home.lon];
}
function unwrapLon(ring: [number, number][]): [number, number][] { function unwrapLon(ring: [number, number][]): [number, number][] {
const out: [number, number][] = []; const out: [number, number][] = [];
let prev = NaN; let prev = NaN;
@@ -178,31 +196,50 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
const autoZoomRef = useRef(autoZoom); const autoZoomRef = useRef(autoZoom);
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]); useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
// WAIT FOR THE SQUARE BEFORE PAINTING.
//
// The station's own locator arrives from the profile a moment after this
// component mounts. Building the map immediately meant painting the world at
// 0°, then moving it to the operator's longitude — a screenful of tiles
// fetched from Esri and thrown away on every first run. The map is built once,
// when it knows where it is looking.
//
// Not for ever, though: an operator with no locator set (or a profile still
// loading after two seconds) gets the default view rather than a blank panel.
const [mapReady, setMapReady] = useState(() => !!gridToLatLon(fromGrid) || !!loadWorldView());
useEffect(() => {
if (mapReady) return;
if (gridToLatLon(fromGrid)) { setMapReady(true); return; }
const t = window.setTimeout(() => setMapReady(true), 2000);
return () => window.clearTimeout(t);
}, [fromGrid, mapReady]);
// One-time map creation. // One-time map creation.
useEffect(() => { useEffect(() => {
if (worldRef.current && !worldMap.current) { if (worldRef.current && !worldMap.current && mapReady) {
// preferCanvas: the beam lobe is a dense FAN of translucent radials — up to // preferCanvas: the beam lobe is a dense FAN of translucent radials — up to
// ~120 thick strokes with a bidirectional Ultrabeam. As SVG that is ~120 // ~120 thick strokes with a bidirectional Ultrabeam. As SVG that is ~120
// composited paths re-rasterised on every pan, zoom and redraw, which is // composited paths re-rasterised on every pan, zoom and redraw, which is
// enough to blow WebView2's raster budget and leave the window painting in // enough to blow WebView2's raster budget and leave the window painting in
// patches. On canvas it is a single layer. // patches. On canvas it is a single layer.
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true, preferCanvas: true }) const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true, preferCanvas: true })
.setView([20, 0], 1); .setView(homeView(fromGrid), 1);
addBasemap(m, basemap, baseLayer, labelsLayer); addBasemap(m, basemap, baseLayer, labelsLayer);
worldOverlay.current = L.layerGroup().addTo(m); worldOverlay.current = L.layerGroup().addTo(m);
worldMap.current = m; worldMap.current = m;
const sv = loadMapView(); const sv = loadWorldView();
if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom); if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom);
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); }); m.on('moveend', () => { if (!autoZoomRef.current) saveWorldView(m); });
} }
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80); const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
// Resizing the pane is the ONLY thing that needs invalidateSize. Calling it on // Resizing the pane is the ONLY thing that needs invalidateSize. Calling it on
// every overlay redraw (as before) forced a full repaint of the map each time // every overlay redraw (as before) forced a full repaint of the map each time
// the rotor moved a degree. // the rotor moved a degree.
const ro = new ResizeObserver(() => worldMap.current?.invalidateSize()); const ro = new ResizeObserver(() => worldMap.current?.invalidateSize());
if (worldRef.current) ro.observe(worldRef.current); if (worldRef.current) ro.observe(worldRef.current);
return () => { window.clearTimeout(t); ro.disconnect(); }; return () => { window.clearTimeout(t); ro.disconnect(); };
}, []); }, [mapReady]);
// Swap the basemap (and its optional place-name overlay) when the operator // Swap the basemap (and its optional place-name overlay) when the operator
// picks a different one. Vector overlays (path/beam) live in Leaflet's // picks a different one. Vector overlays (path/beam) live in Leaflet's
@@ -427,7 +464,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
setAutoZoom(v); setAutoZoom(v);
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0'); writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
const m = worldMap.current; const m = worldMap.current;
if (!v && m) saveMapView(m); if (!v && m) saveWorldView(m);
}} }}
title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'} title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'}
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${ className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
+7 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react'; import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings, OpenExternalURL, SetOpsLogQSLReceived } from '../../wailsjs/go/main/App'; import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings, OpenExternalURL, SetOpsLogQSLReceived } from '../../wailsjs/go/main/App';
import { rstOptions, type RSTLists } from '@/lib/rst'; import { rstOptions, stepRST, type RSTLists } from '@/lib/rst';
import { AwardRefSelector } from '@/components/AwardRefSelector'; import { AwardRefSelector } from '@/components/AwardRefSelector';
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor'; import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
import { applyAwardRefs } from '@/lib/awardRefs'; import { applyAwardRefs } from '@/lib/awardRefs';
@@ -598,9 +598,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} /> value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
</div> </div>
<div className="flex flex-col w-20"><Label>S</Label> <div className="flex flex-col w-20"><Label>S</Label>
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_sent', v)} /></div> <Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType
onChange={(v) => set('rst_sent', v)}
onWheelStep={(d) => set('rst_sent', stepRST(draft.rst_sent ?? '', d, draft.mode ?? ''))} /></div>
<div className="flex flex-col w-20"><Label>R</Label> <div className="flex flex-col w-20"><Label>R</Label>
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_rcvd', v)} /></div> <Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType
onChange={(v) => set('rst_rcvd', v)}
onWheelStep={(d) => set('rst_rcvd', stepRST(draft.rst_rcvd ?? '', d, draft.mode ?? ''))} /></div>
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking} <Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
title={t('qedit.fetchTitle')}> title={t('qedit.fetchTitle')}>
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')} {looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
+19 -6
View File
@@ -1621,6 +1621,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const [listenPref, setListenPref] = useState(true); const [listenPref, setListenPref] = useState(true);
const [activeRadio, setActiveRadioId] = useState(''); const [activeRadio, setActiveRadioId] = useState('');
const [radioBusy, setRadioBusy] = useState(false); const [radioBusy, setRadioBusy] = useState(false);
// "Other (custom address)" has to STAY chosen. The dropdown is derived from
// the address, so picking Other while the address still matched a listed rig
// put the list straight back on that rig — the operator saw it jump back to
// IC-7610 the moment they chose Other.
const [icomCustom, setIcomCustom] = useState(false);
const [catCfg, setCatCfg] = useState<CATSettings>({ const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false, enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false,
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_link: 'usb', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '', yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_link: 'usb', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
@@ -2101,7 +2106,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0'); const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0');
const [chaseNew, setChaseNew] = useState(false); const [chaseNew, setChaseNew] = useState(false);
const [pskTgt, setPskTgt] = useState<any>({ enabled: false, scope: 'target' }); const [pskTgt, setPskTgt] = useState<any>({ enabled: false, scope: 'target' });
const [ac, setAc] = useState<any>({ enabled: false, only: '', attempts: 7, watched_attempts: 15, misses: 3, max_rounds: 3, rest_min: 2, on_screen_only: true }); const [ac, setAc] = useState<any>({ enabled: false, only: '', attempts: 7, watched_attempts: 15, misses: 3, max_rounds: 3, rest_periods: 1, on_screen_only: true });
// The named contest callsigns. Raw text in state, written on blur: it is a // The named contest callsigns. Raw text in state, written on blur: it is a
// multi-line list, and normalising it on every keystroke would fight the // multi-line list, and normalising it on every keystroke would fight the
// Return key — the one key this box is built around. // Return key — the one key this box is built around.
@@ -3628,8 +3633,12 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('cat.icomModel')}</Label> <Label>{t('cat.icomModel')}</Label>
<Select <Select
value={ICOM_MODELS.find((m) => m.addr === (catCfg.icom_addr ?? 0x98))?.name ?? '_custom'} value={icomCustom ? '_custom' : (ICOM_MODELS.find((m) => m.addr === (catCfg.icom_addr ?? 0x98))?.name ?? '_custom')}
onValueChange={(v) => { const m = ICOM_MODELS.find((x) => x.name === v); if (m) setCatCfg((s) => ({ ...s, icom_addr: m.addr })); }}> onValueChange={(v) => {
const m = ICOM_MODELS.find((x) => x.name === v);
setIcomCustom(!m);
if (m) setCatCfg((s) => ({ ...s, icom_addr: m.addr }));
}}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
{ICOM_MODELS.map((m) => <SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>)} {ICOM_MODELS.map((m) => <SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>)}
@@ -3655,8 +3664,12 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('cat.icomModel')}</Label> <Label>{t('cat.icomModel')}</Label>
<Select <Select
value={ICOM_MODELS.find((m) => m.addr === (catCfg.icom_addr ?? 0x98))?.name ?? '_custom'} value={icomCustom ? '_custom' : (ICOM_MODELS.find((m) => m.addr === (catCfg.icom_addr ?? 0x98))?.name ?? '_custom')}
onValueChange={(v) => { const m = ICOM_MODELS.find((x) => x.name === v); if (m) setCatCfg((s) => ({ ...s, icom_addr: m.addr })); }}> onValueChange={(v) => {
const m = ICOM_MODELS.find((x) => x.name === v);
setIcomCustom(!m);
if (m) setCatCfg((s) => ({ ...s, icom_addr: m.addr }));
}}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
{ICOM_MODELS.map((m) => <SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>)} {ICOM_MODELS.map((m) => <SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>)}
@@ -5553,7 +5566,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
['watched_attempts', t('ac.watchedAttempts'), 1, 60], ['watched_attempts', t('ac.watchedAttempts'), 1, 60],
['misses', t('ac.misses'), 1, 10], ['misses', t('ac.misses'), 1, 10],
['max_rounds', t('ac.rounds'), 1, 10], ['max_rounds', t('ac.rounds'), 1, 10],
['rest_min', t('ac.rest'), 1, 60], ['rest_periods', t('ac.rest'), 1, 20],
] as [string, string, number, number][]).map(([k, label, min, max]) => ( ] as [string, string, number, number][]).map(([k, label, min, max]) => (
<span key={k} className="inline-flex items-center gap-1.5"> <span key={k} className="inline-flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{label}</span> <span className="text-xs text-muted-foreground">{label}</span>
+20 -1
View File
@@ -9,7 +9,7 @@ import { cn } from '@/lib/utils';
// can't hold a typo'd value that isn't in the list. // can't hold a typo'd value that isn't in the list.
export function Combobox({ export function Combobox({
value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false, value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false,
showToggle = false, showToggle = false, onWheelStep,
}: { }: {
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
@@ -21,6 +21,10 @@ export function Combobox({
// fields read live by other actions — e.g. RST, so a CW macro sent without // fields read live by other actions — e.g. RST, so a CW macro sent without
// leaving the field uses the value just typed. // leaving the field uses the value just typed.
commitOnType?: boolean; commitOnType?: boolean;
// Wheel over the field steps the value. The control cannot know what a step
// means — a decibel here, an S-unit there — so it reports the direction and
// the owner decides.
onWheelStep?: (dir: 1 | -1) => void;
// Draw a chevron that opens the full list on click. // Draw a chevron that opens the full list on click.
// //
// Without it this control is indistinguishable from a plain text box: it opens // Without it this control is indistinguishable from a plain text box: it opens
@@ -42,6 +46,21 @@ export function Combobox({
// leave the list floating over the wrong row. // leave the list floating over the wrong row.
const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 }); const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 });
// A NATIVE, non-passive listener: React attaches onWheel passively, where
// preventDefault does nothing at all and the panel scrolls away under the
// field being adjusted.
useEffect(() => {
const el = ref.current;
if (!el || !onWheelStep) return;
const onWheel = (e: WheelEvent) => {
if (e.deltaY === 0) return;
e.preventDefault();
onWheelStep(e.deltaY < 0 ? 1 : -1); // up is a better report
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [onWheelStep]);
useEffect(() => { useEffect(() => {
function onDoc(e: MouseEvent) { function onDoc(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
+4 -4
View File
@@ -193,8 +193,8 @@ const en: Dict = {
'ac.ladder': 'Order of priority: every callsign on the watch list first (among themselves: new entity, band, mode, slot), then new entity, new band, new mode, new slot. A real need comes before one that exists only because a QSL never came. A station calling YOU is answered whatever the log makes of it. A station in the middle of a QSO with somebody else is never called — it cannot answer.', 'ac.ladder': 'Order of priority: every callsign on the watch list first (among themselves: new entity, band, mode, slot), then new entity, new band, new mode, new slot. A real need comes before one that exists only because a QSL never came. A station calling YOU is answered whatever the log makes of it. A station in the middle of a QSO with somebody else is never called — it cannot answer.',
'ac.only': 'Chase only', 'ac.onlyPh': 'VP6D 3Y0J (space or comma)', 'ac.onlyHint': 'one or more callsigns — nothing else is called; empty means call whatever the log needs', 'ac.only': 'Chase only', 'ac.onlyPh': 'VP6D 3Y0J (space or comma)', 'ac.onlyHint': 'one or more callsigns — nothing else is called; empty means call whatever the log needs',
'ac.attempts': 'Calls before giving up', 'ac.watchedAttempts': 'if watched', 'ac.misses': 'Missed periods', 'ac.attempts': 'Calls before giving up', 'ac.watchedAttempts': 'if watched', 'ac.misses': 'Missed periods',
'ac.rounds': 'Series per station', 'ac.rest': 'Rest (min)', 'ac.trace': 'Log every decision', 'ac.traceHint': '— one line per period in the diagnostic log: what was on the air, why each station was refused, and what was decided. For working out why nothing is being called. A line every fifteen seconds, so leave it off otherwise.', 'ac.onScreen': 'Call only what the decodes list is showing', 'ac.onScreenHint': '— the filters above the list (CQ only, LoTW only, the new-category chips, continents, report, search) steer the transmitter too: a station filtered off the screen is not called. With the list closed nothing is filtered.', 'ac.rounds': 'Series per station', 'ac.rest': 'Rest (overs)', 'ac.trace': 'Log every decision', 'ac.traceHint': '— one line per period in the diagnostic log: what was on the air, why each station was refused, and what was decided. For working out why nothing is being called. A line every fifteen seconds, so leave it off otherwise.', 'ac.onScreen': 'Call only what the decodes list is showing', 'ac.onScreenHint': '— the filters above the list (CQ only, LoTW only, the new-category chips, continents, report, search) steer the transmitter too: a station filtered off the screen is not called. With the list closed nothing is filtered.',
'dec.chasePh': 'chase…', 'dec.chaseTip': 'Call ONLY these stations — one or more callsigns, spaces or commas. Empty means call whatever the log needs. Same field as Preferences → DXHunter.', 'dec.autoWaitTip': '{call} is wanted and is working somebody else — it is called the moment it is free.', 'dec.autoStoppedTip': 'Auto-call has given up on a station from the chase list — click to switch it off, or Halt to clear it.', 'dec.chasePh': 'chase…', 'dec.chaseTip': 'Call ONLY these stations — one or more callsigns, spaces or commas. Empty means call whatever the log needs. Same field as Preferences → DXHunter.', 'dec.autoCalls': 'calls', 'dec.autoMisses': 'missed', 'dec.autoWaiting': 'waiting', 'dec.autoTargetTip': 'Auto-call is calling {call} — calls made of the allowance, and periods of its own it was not decoded in.', 'dec.autoWaitTip': '{call} is wanted and is working somebody else — it is called the moment it is free.', 'dec.autoStoppedTip': 'Auto-call has given up on a station from the chase list — click to switch it off, or Halt to clear it.',
// Contest watchlist (Settings → DXHunter): how a special-event fleet joins the list on its own // Contest watchlist (Settings → DXHunter): how a special-event fleet joins the list on its own
'wlc.title': 'Contest', 'wlc.pattern': 'Auto-add on', 'wlc.patternPh': 'WWA', 'wlc.patternHint': 'any spotted callsign CONTAINING this joins the watchlist as a contest entry (TM29WWA, HB9WWA, F4WWA/P)', 'wlc.title': 'Contest', 'wlc.pattern': 'Auto-add on', 'wlc.patternPh': 'WWA', 'wlc.patternHint': 'any spotted callsign CONTAINING this joins the watchlist as a contest entry (TM29WWA, HB9WWA, F4WWA/P)',
'wlc.calls': 'And these callsigns, one per line', 'wlc.callsPh': 'R7W DL0ABC', 'wlc.callsHint': 'For the entries a pattern cannot catch: a station taking part under a callsign that says nothing about the event. Named here, it joins the contest watchlist the moment it is spotted. Commas and spaces work too.', 'wlc.calls': 'And these callsigns, one per line', 'wlc.callsPh': 'R7W DL0ABC', 'wlc.callsHint': 'For the entries a pattern cannot catch: a station taking part under a callsign that says nothing about the event. Named here, it joins the contest watchlist the moment it is spotted. Commas and spaces work too.',
@@ -750,8 +750,8 @@ const fr: Dict = {
'ac.ladder': "Ordre de priorité : d'abord tout indicatif de la liste de surveillance (entre eux : nouvelle entité, bande, mode, slot), puis nouvelle entité, nouvelle bande, nouveau mode, nouveau slot. Un vrai besoin passe devant un besoin qui n'existe que faute de QSL. Une station qui VOUS appelle est toujours prise, quoi qu'en dise le log. Une station en plein QSO avec quelqu'un d'autre n'est jamais appelée — elle ne peut pas répondre.", 'ac.ladder': "Ordre de priorité : d'abord tout indicatif de la liste de surveillance (entre eux : nouvelle entité, bande, mode, slot), puis nouvelle entité, nouvelle bande, nouveau mode, nouveau slot. Un vrai besoin passe devant un besoin qui n'existe que faute de QSL. Une station qui VOUS appelle est toujours prise, quoi qu'en dise le log. Une station en plein QSO avec quelqu'un d'autre n'est jamais appelée — elle ne peut pas répondre.",
'ac.only': 'Chasser uniquement', 'ac.onlyPh': 'VP6D 3Y0J (espace ou virgule)', 'ac.onlyHint': 'un ou plusieurs indicatifs — rien dautre nest appelé ; vide = appeler ce dont le log a besoin', 'ac.only': 'Chasser uniquement', 'ac.onlyPh': 'VP6D 3Y0J (espace ou virgule)', 'ac.onlyHint': 'un ou plusieurs indicatifs — rien dautre nest appelé ; vide = appeler ce dont le log a besoin',
'ac.attempts': 'Appels avant abandon', 'ac.watchedAttempts': 'si surveillé', 'ac.misses': 'Périodes manquées', 'ac.attempts': 'Appels avant abandon', 'ac.watchedAttempts': 'si surveillé', 'ac.misses': 'Périodes manquées',
'ac.rounds': 'Séries par station', 'ac.rest': 'Repos (min)', 'ac.trace': 'Journaliser chaque décision', 'ac.traceHint': '— une ligne par période dans le journal de diagnostic : ce quil y avait sur lair, pourquoi chaque station a été écartée, et ce qui a été décidé. Pour comprendre pourquoi rien nest appelé. Une ligne toutes les quinze secondes : à laisser éteint le reste du temps.', 'ac.onScreen': 'Nappeler que ce quaffiche la liste des décodages', 'ac.onScreenHint': '— les filtres au-dessus de la liste (CQ seuls, LoTW, les pastilles de catégorie, continents, report, recherche) pilotent aussi l’émetteur : une station filtrée hors écran nest pas appelée. La liste fermée, rien nest filtré.', 'ac.rounds': 'Séries par station', 'ac.rest': 'Repos (tours)', 'ac.trace': 'Journaliser chaque décision', 'ac.traceHint': '— une ligne par période dans le journal de diagnostic : ce quil y avait sur lair, pourquoi chaque station a été écartée, et ce qui a été décidé. Pour comprendre pourquoi rien nest appelé. Une ligne toutes les quinze secondes : à laisser éteint le reste du temps.', 'ac.onScreen': 'Nappeler que ce quaffiche la liste des décodages', 'ac.onScreenHint': '— les filtres au-dessus de la liste (CQ seuls, LoTW, les pastilles de catégorie, continents, report, recherche) pilotent aussi l’émetteur : une station filtrée hors écran nest pas appelée. La liste fermée, rien nest filtré.',
'dec.chasePh': 'chasser…', 'dec.chaseTip': "N'appeler QUE ces stations — un ou plusieurs indicatifs, espaces ou virgules. Vide = appeler ce dont le log a besoin. Même champ que Réglages → DXHunter.", 'dec.autoWaitTip': '{call} est voulue et travaille quelquun dautre — elle sera appelée dès quelle sera libre.', 'dec.autoStoppedTip': "L'appel automatique a renoncé à une station de la liste de chasse — cliquez pour l'éteindre, ou Stop pour effacer.", 'dec.chasePh': 'chasser…', 'dec.chaseTip': "N'appeler QUE ces stations — un ou plusieurs indicatifs, espaces ou virgules. Vide = appeler ce dont le log a besoin. Même champ que Réglages → DXHunter.", 'dec.autoCalls': 'appels', 'dec.autoMisses': 'ratés', 'dec.autoWaiting': 'en attente', 'dec.autoTargetTip': 'Lauto-call appelle {call} — appels effectués sur lallocation, et périodes à lui où il na pas été décodé.', 'dec.autoWaitTip': '{call} est voulue et travaille quelquun dautre — elle sera appelée dès quelle sera libre.', 'dec.autoStoppedTip': "L'appel automatique a renoncé à une station de la liste de chasse — cliquez pour l'éteindre, ou Stop pour effacer.",
// Watchlist contest (Réglages → DXHunter) : comment une flotte d’événement rejoint la liste toute seule // Watchlist contest (Réglages → DXHunter) : comment une flotte d’événement rejoint la liste toute seule
'wlc.title': 'Contest', 'wlc.pattern': 'Ajout auto sur', 'wlc.patternPh': 'WWA', 'wlc.patternHint': 'tout indicatif spotté CONTENANT ceci rejoint la watchlist comme entrée contest (TM29WWA, HB9WWA, F4WWA/P)', 'wlc.title': 'Contest', 'wlc.pattern': 'Ajout auto sur', 'wlc.patternPh': 'WWA', 'wlc.patternHint': 'tout indicatif spotté CONTENANT ceci rejoint la watchlist comme entrée contest (TM29WWA, HB9WWA, F4WWA/P)',
'wlc.calls': 'Et ces indicatifs, un par ligne', 'wlc.callsPh': 'R7W DL0ABC', 'wlc.callsHint': 'Pour les participants quaucun motif ne peut attraper : une station engagée sous un indicatif qui ne dit rien de l’événement. Nommée ici, elle rejoint la watchlist contest dès quelle est spottée. Les virgules et les espaces marchent aussi.', 'wlc.calls': 'Et ces indicatifs, un par ligne', 'wlc.callsPh': 'R7W DL0ABC', 'wlc.callsHint': 'Pour les participants quaucun motif ne peut attraper : une station engagée sous un indicatif qui ne dit rien de l’événement. Nommée ici, elle rejoint la watchlist contest dès quelle est spottée. Les virgules et les espaces marchent aussi.',
+31
View File
@@ -0,0 +1,31 @@
// Remembered map views — where each map was left, per map.
//
// Panning and zooming a map is an operator saying "this is the part of the world
// I work". Throwing that away on every tab switch made the maps something to
// set up again each time rather than something to glance at, and the world map
// was the only one that remembered anything.
//
// One key per map, and portable (see lib/uiPref) like the world map's own: a
// copied data folder brings the views with it.
import { writeUiPref } from '@/lib/uiPref';
export type MapView = { lat: number; lon: number; zoom: number };
// The keys in use. Named here rather than typed at each call site so a rename
// cannot silently orphan somebody's saved view.
export const MAP_VIEW_WORLD = 'opslog.mapView';
export const MAP_VIEW_FT = 'opslog.ftMapView';
export const MAP_VIEW_GRIDS = 'opslog.gridMapView';
export function loadMapView(key: string): MapView | null {
try {
const v = JSON.parse(localStorage.getItem(key) || 'null');
return v && typeof v.zoom === 'number' && typeof v.lat === 'number' && typeof v.lon === 'number' ? v : null;
} catch {
return null; // corrupt or unreadable → open where the map would by default
}
}
export function saveMapView(key: string, lat: number, lon: number, zoom: number): void {
writeUiPref(key, JSON.stringify({ lat, lon, zoom }));
}
+82
View File
@@ -36,3 +36,85 @@ export function rstOptions(mode: string, lists: RSTLists): string[] {
if (l && l.length) return l; if (l && l.length) return l;
return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20']; return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20'];
} }
// stepRST moves a report one step up or down, the way an operator would say it.
//
// The dropdown beside these fields is a list of the values worth having to hand,
// not of every legal report: nobody keeps 41 dB figures in it. So the wheel
// works on the VALUE, not on the list — a digital report moves by a decibel and
// an RST by one S-unit, which is what the hand on the wheel is asking for.
//
// dir is +1 for wheel up (a better report) and -1 for down.
export function stepRST(value: string, dir: number, mode: string): string {
const v = (value || '').trim();
if (v === '') return v;
if (rstCategory(mode) === 'digital') {
// A dB report: "-12", "+05", "0". Kept in its own shape — signed and two
// digits — because that is how every decoder writes it and how the operator
// reads it back off the screen.
const n = parseInt(v, 10);
if (!Number.isFinite(n)) return v;
// The range WSJT-X itself reports in, with room either side. Beyond it the
// number stops meaning anything.
const next = Math.max(-30, Math.min(35, n + dir));
return (next < 0 ? '-' : '+') + String(Math.abs(next)).padStart(2, '0');
}
// RST/RS, and the S9+ ladder above it. What an operator counts through is
//
// … 57 58 59 59+5 59+10 59+15 59+20 …
//
// — one S-unit up to nine, then five decibels at a time, because that is how
// a strong signal is reported and how the S-meter is read (see sMeterRST).
// Stepping the S digit and leaving the "+20" where it was would have gone
// from 59+20 to 58+20, which nobody has ever said on the air.
//
// R and T do not move: they are judgements about readability and tone, and a
// wheel has no business changing them.
const m = /^(\d)(\d)(\d?)(?:\+(\d+))?$/.exec(v);
if (!m) return v;
const head = m[1];
const tone = m[3];
let sUnit = parseInt(m[2], 10);
let over = m[4] === undefined ? 0 : parseInt(m[4], 10);
if (over > 0) {
// Above S9: five at a time, and back down through +5 to a plain 59.
over = Math.max(0, Math.min(60, over + dir * 5));
} else if (sUnit >= 9 && dir > 0) {
over = 5; // 59 → 59+5
} else {
sUnit = Math.max(1, Math.min(9, sUnit + dir));
}
return head + String(sUnit) + tone + (over > 0 ? '+' + over : '');
}
// rstFitsMode says whether a report belongs to the family a mode reports in.
//
// The three families are written differently and are not interchangeable: a
// signed decibel figure ("+00"), a three-figure RST ("599"), a two-figure RS
// ("59", with an optional "+20" above S9). "+00" on SSB is not a weak report,
// it is not a report at all.
export function rstFitsMode(value: string, mode: string): boolean {
const v = (value || '').trim();
if (v === '') return false;
switch (rstCategory(mode)) {
case 'digital': return /^[+-]\d{1,2}$/.test(v);
case 'cw': return /^\d{3}(\+\d+)?$/.test(v);
default: return /^\d{2}(\+\d+)?$/.test(v);
}
}
// convertRST carries a report across the CW/phone divide, keeping the operator's
// own judgement of the signal: 57 becomes 579, 599 becomes 59.
//
// Only between those two — a decibel figure says nothing about readability and
// an RST says nothing in decibels, so there the preset is the honest answer.
// Returns '' when it cannot be done.
export function convertRST(value: string, toMode: string): string {
const v = (value || '').trim();
const to = rstCategory(toMode);
if (to === 'digital') return '';
const m = /^(\d)(\d)(\d?)((?:\+\d+)?)$/.exec(v);
if (!m) return '';
if (to === 'cw') return m[1] + m[2] + '9' + m[4]; // RS → RST, tone 9
return m[1] + m[2] + m[4]; // RST → RS, tone dropped
}
+7 -4
View File
@@ -15,12 +15,15 @@ export function cleanSpotter(s: string): string {
// alone instead of guessing wrong. // alone instead of guessing wrong.
export function inferSpotMode(comment: string, freqHz: number): string { export function inferSpotMode(comment: string, freqHz: number): string {
const c = (comment || '').toUpperCase(); const c = (comment || '').toUpperCase();
// SuperFox and Fox/Hound are FT8 — they are WSJT-X's DXpedition transmit // SuperFox and Fox/Hound are FT8 — they are WSJT-X's DXpedition transmit modes,
// modes, not modes of their own. A spot commented "super fox" fell through to // not modes of their own, and they turn up in a comment written every way an
// the band plan and came out DATA, and that verdict is not cosmetic: the // operator can shorten them: "super fox", SFOX, S/F, F/H.
//
// A spot commented "super fox" fell through to the band plan and came out
// DATA, and that verdict is not cosmetic: the
// band+mode status is computed from this answer, so a ZD8 on 21.071 read as a // band+mode status is computed from this answer, so a ZD8 on 21.071 read as a
// new DATA slot rather than the new FT8 one it is. // new DATA slot rather than the new FT8 one it is.
if (/\bSUPER\s*FOX\b|\bSFOX\b|\bFOX\s*\/?\s*HOUND\b|\bF\/H\b/.test(c)) return 'FT8'; if (/\bSUPER\s*FOX\b|\bSFOX\b|\bS\/F\b|\bFOX\s*\/?\s*HOUND\b|\bF\/H\b/.test(c)) return 'FT8';
if (/\bFT8\b/.test(c)) return 'FT8'; if (/\bFT8\b/.test(c)) return 'FT8';
if (/\bFT4\b/.test(c)) return 'FT4'; if (/\bFT4\b/.test(c)) return 'FT4';
if (/\bJS8\b/.test(c)) return 'JS8'; if (/\bJS8\b/.test(c)) return 'JS8';
+3
View File
@@ -29,6 +29,9 @@ const PORTABLE_KEYS = [
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab 'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom) 'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom) 'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
// The same, for the FT decodes map and the grid-square map: a view an
// operator set up is theirs, and it should follow the folder like the rest.
'opslog.ftMapView', 'opslog.gridMapView',
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing 'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots 'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane) 'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
+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.27.13'; export const APP_VERSION = '0.27.14';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+2 -2
View File
@@ -1961,7 +1961,7 @@ export namespace main {
watched_attempts: number; watched_attempts: number;
misses: number; misses: number;
max_rounds: number; max_rounds: number;
rest_min: number; rest_periods: number;
on_screen_only: boolean; on_screen_only: boolean;
trace: boolean; trace: boolean;
@@ -1977,7 +1977,7 @@ export namespace main {
this.watched_attempts = source["watched_attempts"]; this.watched_attempts = source["watched_attempts"];
this.misses = source["misses"]; this.misses = source["misses"];
this.max_rounds = source["max_rounds"]; this.max_rounds = source["max_rounds"];
this.rest_min = source["rest_min"]; this.rest_periods = source["rest_periods"];
this.on_screen_only = source["on_screen_only"]; this.on_screen_only = source["on_screen_only"];
this.trace = source["trace"]; this.trace = source["trace"];
} }
+40 -5
View File
@@ -120,12 +120,36 @@ func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (in
func SingleRecordADIF(q qso.QSO) string { func SingleRecordADIF(q qso.QSO) string {
var b strings.Builder var b strings.Builder
bw := bufio.NewWriter(&b) bw := bufio.NewWriter(&b)
// Uploads target other services — keep it standard (no app-specific tags). // Uploads target other services — keep it standard (no app-specific tags),
// and say nothing about the receive side when there is nothing to say: in
// ADIF an absent BAND_RX/FREQ_RX means "same as transmit", and a logger
// given both draws both. Wavelog reads a simplex FT8 contact uploaded with
// BAND_RX filled in as split and shows it as "17m/17m".
writeRecord(bw, q, false, nil) writeRecord(bw, q, false, nil)
bw.Flush() bw.Flush()
return b.String() return b.String()
} }
// ForwardRecordADIF is the record sent to ANOTHER LOGGER on the UDP link.
//
// The receive side is written even when it repeats the transmit side, which is
// the opposite of the upload rule above and is deliberate: Log4OM reads BAND_RX
// and found nothing there for contacts logged by a path that left it blank. A
// logger on the same desk is being handed a copy of our record, not published
// to a service that will draw conclusions from every tag present.
func ForwardRecordADIF(q qso.QSO) string {
var b strings.Builder
bw := bufio.NewWriter(&b)
writeRecord(bw, q, false, nil, keepRX)
bw.Flush()
return b.String()
}
// keepRX marks a record whose receive side must be written out in full.
type rxMode int
const keepRX rxMode = 1
// FullRecordADIF serialises one QSO LOSSLESSLY — including the APP_* extras — // FullRecordADIF serialises one QSO LOSSLESSLY — including the APP_* extras —
// so it can be written out and read back with nothing dropped. Used by the // so it can be written out and read back with nothing dropped. Used by the
// offline queue: a QSO parked in the safety file must come back identical // offline queue: a QSO parked in the safety file must come back identical
@@ -161,7 +185,18 @@ func BatchRecordsADIF(records []string) string {
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted" // Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
// mode (e.g. FT4 stored without a parent) is exported as the canonical // mode (e.g. FT4 stored without a parent) is exported as the canonical
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers. // pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]bool) { func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]bool, rx ...rxMode) {
// The receive side, unless it merely repeats the transmit side. See
// SingleRecordADIF and ForwardRecordADIF.
bandRX, freqRX := q.BandRX, q.FreqRXHz
if len(rx) == 0 || rx[0] != keepRX {
if strings.EqualFold(strings.TrimSpace(bandRX), strings.TrimSpace(q.Band)) {
bandRX = ""
}
if freqRX != nil && q.FreqHz != nil && *freqRX == *q.FreqHz {
freqRX = nil
}
}
// allow == nil → write every promoted field (standard/full behaviour). // allow == nil → write every promoted field (standard/full behaviour).
// Otherwise a promoted tag is written only when it's in the chosen set. // Otherwise a promoted tag is written only when it's in the chosen set.
// w/wi/wf wrap the raw writers with that gate so the ~150 field lines below // w/wi/wf wrap the raw writers with that gate so the ~150 field lines below
@@ -194,7 +229,7 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
w("TIME_OFF", q.QSODateOff.UTC().Format("150405")) w("TIME_OFF", q.QSODateOff.UTC().Format("150405"))
} }
w("BAND", q.Band) w("BAND", q.Band)
w("BAND_RX", q.BandRX) w("BAND_RX", bandRX)
mode, submode := modeForExport(q.Mode, q.Submode) mode, submode := modeForExport(q.Mode, q.Submode)
w("MODE", mode) w("MODE", mode)
@@ -203,8 +238,8 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
if q.FreqHz != nil && *q.FreqHz > 0 { if q.FreqHz != nil && *q.FreqHz > 0 {
w("FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64)) w("FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
} }
if q.FreqRXHz != nil && *q.FreqRXHz > 0 { if freqRX != nil && *freqRX > 0 {
w("FREQ_RX", strconv.FormatFloat(float64(*q.FreqRXHz)/1_000_000, 'f', 6, 64)) w("FREQ_RX", strconv.FormatFloat(float64(*freqRX)/1_000_000, 'f', 6, 64))
} }
w("RST_SENT", q.RSTSent) w("RST_SENT", q.RSTSent)
+61 -11
View File
@@ -169,10 +169,16 @@ type Settings struct {
// Misses is how many of the station's OWN transmit periods may pass with no // Misses is how many of the station's OWN transmit periods may pass with no
// decode of it before it is given up on. // decode of it before it is given up on.
Misses int Misses int
// Rest is how long a released callsign waits before it can be picked again, // RestPeriods is how many of the station's OWN OVERS a released callsign sits
// and MaxRounds how many such series it gets before being parked for the // out before it can be picked again, and MaxRounds how many such series it
// session. // gets before being parked for the session.
Rest time.Duration //
// Counted in overs, not in minutes, because that is the unit the thing is
// happening in: an operator who has called a DX seven times without an answer
// listens through one of its transmissions and calls again if it is still
// there. Two minutes is four overs on FT8 — the DX has worked four other
// callers by then, and half the time it has gone.
RestPeriods int
MaxRounds int MaxRounds int
// MaxHold is the wall-clock backstop on one target. // MaxHold is the wall-clock backstop on one target.
MaxHold time.Duration MaxHold time.Duration
@@ -195,7 +201,7 @@ type Settings struct {
func Defaults() Settings { func Defaults() Settings {
return Settings{ return Settings{
Attempts: 7, WatchedAttempts: 15, Misses: 3, Attempts: 7, WatchedAttempts: 15, Misses: 3,
Rest: 2 * time.Minute, MaxRounds: 3, MaxHold: 4 * time.Minute, RestPeriods: 1, MaxRounds: 3, MaxHold: 4 * time.Minute,
} }
} }
@@ -210,8 +216,8 @@ func (s Settings) withDefaults() Settings {
if s.Misses <= 0 { if s.Misses <= 0 {
s.Misses = d.Misses s.Misses = d.Misses
} }
if s.Rest <= 0 { if s.RestPeriods <= 0 {
s.Rest = d.Rest s.RestPeriods = d.RestPeriods
} }
if s.MaxRounds <= 0 { if s.MaxRounds <= 0 {
s.MaxRounds = d.MaxRounds s.MaxRounds = d.MaxRounds
@@ -284,6 +290,12 @@ type Engine struct {
heldSince time.Time heldSince time.Time
attempts int attempts int
misses int misses int
// seenKey is the period in which the target was last DECODED. A period is
// judged more than once — the decodes arrive in bursts and stragglers follow —
// and a later judgement holds a partial view of it, not evidence of absence:
// the station answered in that very period and the counter still read one
// miss out of three.
seenKey string
lastMiss string // period already counted, so one period counts once lastMiss string // period already counted, so one period counts once
txSlot int // which of the two slots the target transmits in; -1 unknown txSlot int // which of the two slots the target transmits in; -1 unknown
stopped bool // an explicit "Only" target gave up: needs a restart stopped bool // an explicit "Only" target gave up: needs a restart
@@ -735,7 +747,7 @@ func (e *Engine) OnPeriod(p Period) Action {
// The decode is refreshed so a reply carries a current timestamp; the // The decode is refreshed so a reply carries a current timestamp; the
// hold clock is NOT — see heldSince. // hold clock is NOT — see heldSince.
e.target = seen e.target = seen
e.misses, e.lastMiss = 0, "" e.misses, e.lastMiss, e.seenKey = 0, "", p.Key
e.txSlot = slotOf(p.At, periodSecs(p, *seen)) e.txSlot = slotOf(p.At, periodSecs(p, *seen))
// In QSO: the decoder is sequencing the exchange on its own and the // In QSO: the decoder is sequencing the exchange on its own and the
// attempt counter has done its job. Interrupting it with another // attempt counter has done its job. Interrupting it with another
@@ -780,6 +792,10 @@ func (e *Engine) OnPeriod(p Period) Action {
if p.TX.Transmitting { if p.TX.Transmitting {
return Action{} return Action{}
} }
// Nor a period it was already decoded in: see seenKey.
if e.seenKey == p.Key {
return Action{}
}
if e.txSlot >= 0 && slotOf(p.At, periodSecs(p, t)) != e.txSlot { if e.txSlot >= 0 && slotOf(p.At, periodSecs(p, t)) != e.txSlot {
return Action{} return Action{}
} }
@@ -1000,7 +1016,19 @@ func (e *Engine) judge(c Candidate, p Period, only []string) (bool, string) {
if rank(c) == 0 { if rank(c) == 0 {
return false, "nothing-needed" return false, "nothing-needed"
} }
if e.rounds[call] >= e.set.MaxRounds { // A WATCHED callsign is never parked.
//
// Parking is the answer to "it will not answer, stop wasting the evening on
// it" — a fair verdict about a station the LOG picked out, and the wrong one
// about a station the OPERATOR did. A DXpedition running a pileup takes more
// than two series of calls to get through to, which is precisely why it was
// put on the list; watched ZD8GB was refused for the rest of the session
// after fourteen unanswered calls, while it went on transmitting six streams
// a period.
//
// The rest between series still applies, so it does not monopolise the
// transmitter — it simply never becomes ineligible.
if e.rounds[call] >= e.set.MaxRounds && !c.Watched {
return false, "parked" // its series are spent for the session return false, "parked" // its series are spent for the session
} }
// RESTING. A series that ended in a brake is followed by a real pause, // RESTING. A series that ended in a brake is followed by a real pause,
@@ -1082,7 +1110,7 @@ func (e *Engine) preempt(p Period) (Action, bool) {
// is picked again like any other the moment it is. // is picked again like any other the moment it is.
func (e *Engine) drop() { func (e *Engine) drop() {
e.target, e.attempts, e.misses, e.txSlot, e.lastMiss = nil, 0, 0, -1, "" e.target, e.attempts, e.misses, e.txSlot, e.lastMiss = nil, 0, 0, -1, ""
e.targetInst, e.answered = "", false e.targetInst, e.answered, e.seenKey = "", false, ""
} }
// addressee is who a message is being sent to — the first token, which is the // addressee is who a message is being sent to — the first token, which is the
@@ -1121,7 +1149,7 @@ func (e *Engine) giveUp(call string, t Candidate) {
if at.IsZero() { if at.IsZero() {
at = time.Now() at = time.Now()
} }
e.rested[call] = at.Add(e.set.Rest) e.rested[call] = at.Add(restFor(e.set.RestPeriods, candidateSecs(t)))
// An explicit "call this station" that runs out of attempts stops the // An explicit "call this station" that runs out of attempts stops the
// feature instead of moving on. There is nothing else it was asked to do. // feature instead of moving on. There is nothing else it was asked to do.
if strings.TrimSpace(e.set.Only) != "" { if strings.TrimSpace(e.set.Only) != "" {
@@ -1223,6 +1251,28 @@ func workedNow(decodes []Candidate, call string) bool {
return false return false
} }
// restFor turns a number of the station's own overs into a deadline.
//
// A station transmits every OTHER slot, so one of its overs is two T/R periods —
// and the extra half-cycle is what makes the answer stable: without it the
// deadline lands exactly on the station's next transmission, where a
// millisecond of clock skew decides whether it is called or passed over. Landing
// it mid-cycle makes "sit out one over" mean one over, every time.
func restFor(overs, trSec int) time.Duration {
if overs <= 0 {
return time.Duration(trSec) * time.Second // no rest: its next over will do
}
return time.Duration(overs*2*trSec+trSec) * time.Second
}
// candidateSecs is the station's own T/R period in seconds, defaulting to FT8's.
func candidateSecs(c Candidate) int {
if c.TRPeriod > 0 {
return c.TRPeriod
}
return 15
}
func periodSecs(p Period, c Candidate) int { func periodSecs(p Period, c Candidate) int {
if c.TRPeriod > 0 { if c.TRPeriod > 0 {
return c.TRPeriod return c.TRPeriod
+65 -11
View File
@@ -244,24 +244,28 @@ func TestAReleasedStationYieldsToAnythingBetter(t *testing.T) {
} }
} }
func TestAReleasedStationRestsBeforeItIsCalledAgain(t *testing.T) { // A series that ended in a brake is followed by ONE of the station's own overs
// passed over — long enough to be a pause, short enough that the DX everybody
// is chasing is still there when the calling resumes. It used to be two
// minutes, which on FT8 is four overs: by then the DX has worked four other
// callers, and half the time it has gone.
func TestAReleasedStationSitsOutOneOfItsOvers(t *testing.T) {
e := on() e := on()
e.OnPeriod(period(0, cq("DX", NeedBand, -5))) e.OnPeriod(period(0, cq("DX", NeedBand, -5)))
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"} tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
for i := 0; i < 7; i++ { for i := 0; i < 7; i++ {
e.NoteTX(tx) e.NoteTX(tx)
} }
// Still the only thing on the air, and still resting: seven calls, a halt, // Its very next over is passed over, whatever else is on the air: seven
// and the same station called again four seconds later is not a rest. // calls, a halt, and the same station called again four seconds later is not
for _, n := range []int{2, 4, 6} { // all inside the two minutes // a rest, it is a stutter.
if a := e.OnPeriod(period(n, cq("DX", NeedBand, -5))); a.Kind != DoNothing { if a := e.OnPeriod(period(2, cq("DX", NeedBand, -5))); a.Kind != DoNothing {
t.Fatalf("period %d: called again during the rest: %+v", n, a) t.Fatalf("called again during the rest: %+v", a)
} }
} // And the one after that goes: still there, still wanted, and the operator
// Two minutes later (period 8 × 15 s = 2 min past the give-up) it may go // has listened through an over.
// again — the station is still there and nothing better is. if a := e.OnPeriod(period(4, cq("DX", NeedBand, -5))); a.Kind != DoReply {
if a := e.OnPeriod(period(9, cq("DX", NeedBand, -5))); a.Kind != DoReply { t.Errorf("after one over: %+v, want the station called again", a)
t.Errorf("after the rest: %+v, want the station called again", a)
} }
} }
@@ -922,3 +926,53 @@ func TestTheFirstCallStartsWithNoMisses(t *testing.T) {
t.Fatalf("misses=%d — the station's own silent period was not counted", e.misses) t.Fatalf("misses=%d — the station's own silent period was not counted", e.misses)
} }
} }
// Parking answers "it will not answer, stop wasting the evening on it" — which
// is a verdict about a station the LOG picked out, not about one the OPERATOR
// did. From the air: a watched ZD8GB, six streams a period, refused for the
// rest of the session after two series of unanswered calls.
func TestAWatchedCallsignIsNeverParked(t *testing.T) {
spent := func(c Candidate) *Engine {
e := New(Settings{Enabled: true, MaxRounds: 2})
e.OnPeriod(period(0)) // the engine's clock, so the rest below is period time
for i := 0; i < 2; i++ {
e.giveUp(strings.ToUpper(c.Call), c) // a series ended by a brake
}
return e
}
// Period 8, well past the rest that follows a series: what is left is the
// parking, and only the parking.
plain := cq("PLAIN", NeedDXCC, 0)
if a := spent(plain).OnPeriod(period(8, plain)); a.Kind == DoReply {
t.Errorf("%+v — a station whose series are spent was called again", a)
}
dx := cq("ZD8GB", NeedDXCC, 0, watched)
if a := spent(dx).OnPeriod(period(8, dx)); a.Kind != DoReply {
t.Errorf("%+v — a watched DXpedition was parked for the session", a)
}
}
// A period is judged more than once — the decodes arrive in a burst and
// stragglers follow — and a later judgement of it holds a partial view, not
// evidence that the station has gone. From the air: D2ACE answered in the very
// period the counter then read as a miss.
func TestAPeriodTheTargetWasSeenInIsNeverAMiss(t *testing.T) {
e := on()
if a := e.OnPeriod(period(1, cq("D2ACE", NeedBand, 13))); a.Kind != DoReply {
t.Fatalf("%+v", a)
}
// Its own next period: decoded, so the exchange is under way.
p := period(3, callsMe("D2ACE", NeedBand, 13))
e.OnPeriod(p)
if e.Status().Misses != 0 {
t.Fatalf("misses=%d after being decoded", e.Status().Misses)
}
// The same period again, this time from a flush that carries only the
// stragglers — the target is not among them.
e.OnPeriod(period(3))
if got := e.Status().Misses; got != 0 {
t.Errorf("misses=%d — a second flush of a period it was seen in counted against it", got)
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ var icnBE = binary.BigEndian
// = CI-V only (the proven default). The audio stream is fully separate from CAT, // = CI-V only (the proven default). The audio stream is fully separate from CAT,
// so enabling it can't affect freq/mode/DSP control. // so enabling it can't affect freq/mode/DSP control.
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string, audioSink func([]byte)) *IcomSerial { func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string, audioSink func([]byte)) *IcomSerial {
if civAddr <= 0 || civAddr > 0xFF { if civAddr < 0 || civAddr > 0xFF {
civAddr = 0x98 // IC-7610 civAddr = 0x98 // IC-7610
} }
if digitalDefault == "" { if digitalDefault == "" {
+3 -2
View File
@@ -172,12 +172,13 @@ const (
) )
// NewIcomSerial builds an (unconnected) Icom serial backend. baud defaults to // NewIcomSerial builds an (unconnected) Icom serial backend. baud defaults to
// 115200, rig address to the IC-7610's 0x98 when out of range. // 115200, rig address to the IC-7610's 0x98 when out of range — and 0x00 is in
// range: it is a valid CI-V address that some rigs and interfaces are set to.
func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *IcomSerial { func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *IcomSerial {
if baud <= 0 { if baud <= 0 {
baud = 115200 baud = 115200
} }
if civAddr <= 0 || civAddr > 0xFF { if civAddr < 0 || civAddr > 0xFF {
civAddr = 0x98 // IC-7610 civAddr = 0x98 // IC-7610
} }
if digitalDefault == "" { if digitalDefault == "" {
+23 -5
View File
@@ -279,13 +279,9 @@ func (w *Watcher) Watch(target, mode string, dialHz int64) error {
return nil return nil
} }
w.mu.Lock()
// The window belongs to the target it was collected for. Keeping it across a
// change would answer the new question with the old station's evidence.
if changed { if changed {
w.spots = w.spots[:0] w.dropWindowOfPreviousTarget()
} }
w.mu.Unlock()
if err := w.resubscribe(client); err != nil { if err := w.resubscribe(client); err != nil {
return err return err
@@ -301,6 +297,28 @@ func (w *Watcher) Watch(target, mode string, dialHz int64) error {
return nil return nil
} }
// dropWindowOfPreviousTarget empties the collected window when it belonged to
// the station being left behind.
//
// WHOSE WINDOW IS IT? Under the narrow scope the subscription IS the target —
// three filters about one station — so the window is his, and keeping it across
// a change would answer the new question with the old station's evidence.
//
// Under the band-wide scope it is the BAND's: every FTx report on it, filtered
// by target only when the analysis is drawn. Clearing it there threw away an
// hour of accumulated evidence on every click, and a panel that had been showing
// hundreds of reports read zero — the operator clicking a decode to ask "can he
// hear me" is the very moment that history is worth something, and it was being
// deleted in order to answer the question.
func (w *Watcher) dropWindowOfPreviousTarget() {
w.mu.Lock()
defer w.mu.Unlock()
if w.cfg.Scope == ScopeBand {
return
}
w.spots = w.spots[:0]
}
// resubscribe replaces every filter with the ones the current target and scope // resubscribe replaces every filter with the ones the current target and scope
// want. Takes the old ones down first: a target change that only ADDED filters // want. Takes the old ones down first: a target change that only ADDED filters
// would leave the previous station's reports arriving for ever. // would leave the previous station's reports arriving for ever.
+23
View File
@@ -156,3 +156,26 @@ func TestACrowdedPassbandStillGetsAnAnswer(t *testing.T) {
t.Errorf("suggested %d Hz, want the quietest slot around 2400", got) t.Errorf("suggested %d Hz, want the quietest slot around 2400", got)
} }
} }
// Under the band-wide scope the window is the BAND's, not the target's: it is
// every FTx report on it, filtered by target only when the analysis is drawn.
// Clearing it on a target change threw away an hour of accumulated evidence at
// the exact moment it was worth something — the operator clicking a decode to
// ask "can he hear me" saw the report count drop to zero.
func TestBandScopeKeepsItsWindowAcrossATargetChange(t *testing.T) {
for _, tc := range []struct {
scope Scope
keep bool
}{{ScopeBand, true}, {ScopeTarget, false}} {
w := &Watcher{cfg: Config{Scope: tc.scope, Logf: func(string, ...any) {}}}
w.target, w.mode = "OLD", "FT8"
w.spots = []spot{{TxCall: "OLD", RxCall: "F4BPO", at: time.Now()}}
w.dropWindowOfPreviousTarget()
w.target = "NEW"
if got := len(w.spots) > 0; got != tc.keep {
t.Errorf("%s scope: window kept = %v, want %v", tc.scope, got, tc.keep)
}
}
}
+16 -1
View File
@@ -10,6 +10,10 @@ import (
// A contact that was not split still has a receive side, and the record // A contact that was not split still has a receive side, and the record
// forwarded to another logger has to carry it: Log4OM reads BAND_RX. // forwarded to another logger has to carry it: Log4OM reads BAND_RX.
//
// The record UPLOADED to a service must not: in ADIF an absent BAND_RX means
// "same as transmit", and Wavelog shown both draws "17m/17m" on a simplex FT8
// contact — reported by OE6CLD, who had never logged a split QSO in his life.
func TestFillRXDefaults(t *testing.T) { func TestFillRXDefaults(t *testing.T) {
hz := int64(14074000) hz := int64(14074000)
q := qso.QSO{Callsign: "F4BPO", Band: "20m", FreqHz: &hz} q := qso.QSO{Callsign: "F4BPO", Band: "20m", FreqHz: &hz}
@@ -22,7 +26,7 @@ func TestFillRXDefaults(t *testing.T) {
} }
// Assert on the RECORD another logger reads, not merely on the struct: // Assert on the RECORD another logger reads, not merely on the struct:
// both halves of the receive side have to reach it. // both halves of the receive side have to reach it.
rec := strings.ToUpper(adif.SingleRecordADIF(q)) rec := strings.ToUpper(adif.ForwardRecordADIF(q))
if !strings.Contains(rec, "<BAND_RX:3>20M") { if !strings.Contains(rec, "<BAND_RX:3>20M") {
t.Errorf("BAND_RX missing from the forwarded record:\n%s", rec) t.Errorf("BAND_RX missing from the forwarded record:\n%s", rec)
} }
@@ -40,3 +44,14 @@ func TestFillRXDefaultsKeepsSplit(t *testing.T) {
t.Errorf("split QSO was overwritten: band_rx=%q freq_rx=%v", q.BandRX, q.FreqRXHz) t.Errorf("split QSO was overwritten: band_rx=%q freq_rx=%v", q.BandRX, q.FreqRXHz)
} }
} }
// A genuine split contact is uploaded AS split: the rule above is about a
// receive side that repeats the transmit side, not about dropping one.
func TestSplitIsUploadedWithItsReceiveSide(t *testing.T) {
tx, rx := int64(14195000), int64(18100000)
q := qso.QSO{Callsign: "F4BPO", Band: "20m", BandRX: "17m", FreqHz: &tx, FreqRXHz: &rx}
up := strings.ToUpper(adif.SingleRecordADIF(q))
if !strings.Contains(up, "<BAND_RX:3>17M") || !strings.Contains(up, "<FREQ_RX:9>18.100000") {
t.Errorf("a cross-band contact lost its receive side:\n%s", up)
}
}
+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.27.13" appVersion = "0.27.14"
// 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.
+35
View File
@@ -0,0 +1,35 @@
package main
import (
"strings"
"testing"
"hamlog/internal/extsvc"
)
// An upload to a service with no credentials used to look exactly like one that
// worked: it ran on its own goroutine and reported into the QSL Manager's
// console, which is not open when the command came from the QSO list.
func TestUploadRefusesAnUnconfiguredService(t *testing.T) {
var empty extsvc.ExternalServices
for _, svc := range []extsvc.Service{
extsvc.ServiceCloudlog, extsvc.ServiceQRZ, extsvc.ServiceClublog,
extsvc.ServiceHRDLog, extsvc.ServiceEQSL, extsvc.ServiceHamQTH, extsvc.ServiceLoTW,
} {
err := uploadConfigured(svc, empty)
if err == nil {
t.Errorf("%s: an unconfigured service was accepted", svc)
continue
}
if !strings.Contains(err.Error(), "Settings") {
t.Errorf("%s: %q does not say where to fix it", svc, err)
}
}
// Configured: nothing in the way.
cfg := extsvc.ExternalServices{}
cfg.Cloudlog.URL, cfg.Cloudlog.APIKey = "https://log.f4bpo.fr", "cl123"
if err := uploadConfigured(extsvc.ServiceCloudlog, cfg); err != nil {
t.Errorf("a configured Cloudlog was refused: %v", err)
}
}