From be889681a91eebb9c137f355721df20258f4fa0c Mon Sep 17 00:00:00 2001 From: rouggy Date: Sat, 5 Sep 2026 19:07:21 +0200 Subject: [PATCH] chore: release v0.27.12 --- 1 | 0 app.go | 134 ++- app_watchlist.go | 74 +- app_wsjt_highlight.go | 86 +- autocall.go | 494 ++++++++++ bandopen_sources.go | 31 +- changelog.json | 44 + frontend/src/App.tsx | 280 +++--- frontend/src/components/DecodesPanel.tsx | 83 +- frontend/src/components/FTMapPanel.tsx | 10 +- frontend/src/components/IcomPanel.tsx | 39 +- frontend/src/components/PSKReporterPanel.tsx | 329 +++++++ frontend/src/components/SettingsModal.tsx | 154 +++- .../src/components/UDPIntegrationsPanel.tsx | 17 +- frontend/src/components/WatchlistTab.tsx | 42 +- frontend/src/lib/autocall.ts | 221 ----- frontend/src/lib/decoderName.ts | 25 + frontend/src/lib/i18n.tsx | 106 ++- frontend/src/lib/maidenhead.ts | 44 + frontend/src/lib/theme.tsx | 4 +- frontend/src/style.css | 158 +++- frontend/src/version.ts | 2 +- frontend/wailsjs/go/main/App.d.ts | 33 + frontend/wailsjs/go/main/App.js | 64 ++ frontend/wailsjs/go/models.ts | 192 ++++ internal/autocall/autocall.go | 770 ++++++++++++++++ internal/autocall/autocall_test.go | 462 ++++++++++ internal/cat/cat.go | 75 +- internal/cat/civ/civ.go | 46 + internal/cat/civ/civ_test.go | 33 + internal/cat/icomserial.go | 65 ++ internal/geo/geo.go | 63 ++ internal/pskr/pskr.go | 36 +- internal/pskrtgt/pskrtgt.go | 863 ++++++++++++++++++ internal/pskrtgt/pskrtgt_test.go | 134 +++ internal/rigctld/rigctld.go | 26 + internal/rigctld/rigctld_test.go | 28 + profilereload_test.go | 5 + pskrtarget.go | 150 +++ telemetry.go | 2 +- 40 files changed, 4971 insertions(+), 453 deletions(-) delete mode 100644 1 create mode 100644 autocall.go create mode 100644 frontend/src/components/PSKReporterPanel.tsx delete mode 100644 frontend/src/lib/autocall.ts create mode 100644 frontend/src/lib/decoderName.ts create mode 100644 internal/autocall/autocall.go create mode 100644 internal/autocall/autocall_test.go create mode 100644 internal/pskrtgt/pskrtgt.go create mode 100644 internal/pskrtgt/pskrtgt_test.go create mode 100644 pskrtarget.go diff --git a/1 b/1 deleted file mode 100644 index e69de29..0000000 diff --git a/app.go b/app.go index 1ab6085..ba89f2d 100644 --- a/app.go +++ b/app.go @@ -26,6 +26,7 @@ import ( "hamlog/internal/antgenius" "hamlog/internal/applog" "hamlog/internal/audio" + "hamlog/internal/autocall" "hamlog/internal/award" "hamlog/internal/awardref" "hamlog/internal/backup" @@ -54,6 +55,7 @@ import ( "hamlog/internal/powergenius" "hamlog/internal/profile" "hamlog/internal/pskr" + "hamlog/internal/pskrtgt" "hamlog/internal/psu" "hamlog/internal/qslcard" "hamlog/internal/qso" @@ -100,6 +102,7 @@ const ( keyStationCallsign = "station.callsign" keyStationOperator = "station.operator" keyWatchlistContestPattern = "watchlist.contest_pattern" // auto-add contest calls containing this + keyWatchlistContestCalls = "watchlist.contest_calls" // …and these, named one by one keyStationMyGrid = "station.my_grid" keyStationCountry = "station.my_country" keyStationSOTA = "station.my_sota_ref" @@ -754,6 +757,31 @@ type App struct { // It is the source that makes VHF detection work at all: the cluster and RBN // carry a handful of 6 m spots where PSK Reporter carries hundreds. pskr *pskr.Watcher + // pskTgt is the SECOND PSK Reporter connection: the per-station analysis behind + // the FT decodes panel. Separate from a.pskr because the two want opposite + // slices of the same feed — everything heard near here, against everything + // about one callsign anywhere. Built on first use, nil while the panel is + // closed. + pskTgtMu sync.Mutex + pskTgt *pskrtgt.Watcher + // Auto-call: the engine, the period being collected, and the last transmit + // state the decoder reported. One mutex — every field here is touched by the + // UDP event loop and by the two-second sweeper, and never for long. + acMu sync.Mutex + ac *autocall.Engine + // Keyed by RECEIVER. Two decoders have two independent slot clocks — an + // FT8 window and an FT4 one do not even share a period length — and one + // buffer for both cut every slot into fragments, each judged as a period of + // its own. The engine then counted a missed period several times a slot. + acPeriod map[string]string + acAt map[string]time.Time + acTR map[string]int + acBuf map[string][]acDecode + acTX autocall.TXState + acReason string + acLastJudge time.Time + // acTXPeriod is the last transmit period already counted — see autoCallNoteTX. + acTXPeriod string // Self-spot throttle: when and on what frequency we last announced ourselves. // Held in memory only — a restart legitimately re-announces the station. selfSpotMu sync.Mutex @@ -769,12 +797,20 @@ type App struct { // WSJT-X decode highlighting (message 13) — see app_wsjt_highlight.go. wsjtHighlightOn atomic.Bool - wsjtHLMu sync.Mutex - wsjtHLSent map[string]string - watchPattern atomic.Value // auto-contest pattern (string), loaded at startup - operating *operating.Repo - udp *udp.Manager - udpRepo *udp.Repo + // Greying out what is already worked is a sub-option of the same feature — + // read on the decode path, so an atomic rather than a settings lookup per + // decode. + wsjtHLWorkedOn atomic.Bool + wsjtHLMu sync.Mutex + wsjtHLSent map[string]string + watchPattern atomic.Value // auto-contest pattern (string), loaded at startup + // The named contest callsigns, as a set, loaded with the pattern and read on + // the same hot path — one spot per station on the band, so no settings + // lookup and no split per spot. + watchCalls atomic.Value // map[string]struct{} + operating *operating.Repo + udp *udp.Manager + udpRepo *udp.Repo // Program id of the last decoding application that reported its status. // Halt Tx is routed by id, and the panel's Halt button must work even when // nothing is transmitting at that instant — so the id is remembered from @@ -930,6 +966,7 @@ type App struct { opLon float64 opSet bool opCall string // active profile callsign, cached for outbound UDP emitters + opGrid string // active profile locator, cached the same way and for the same reason // Dedup for the frequency/mode outbound UDP emitters (PstRotator, N1MM): only // send when the frequency or mode actually changes. @@ -979,6 +1016,7 @@ func (a *App) refreshOperatorGrid() { return } a.opCall = strings.ToUpper(strings.TrimSpace(p.Callsign)) + a.opGrid = strings.ToUpper(strings.TrimSpace(p.MyGrid)) lat, lon, ok := gridToLatLon(p.MyGrid) if !ok { return @@ -1487,7 +1525,9 @@ func (a *App) startup(ctx context.Context) { a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) }) a.startWatchlistClubLog() a.watchPattern.Store(strings.ToUpper(strings.TrimSpace(a.settingOr(keyWatchlistContestPattern, "")))) + a.watchCalls.Store(parseContestCalls(a.settingOr(keyWatchlistContestCalls, ""))) a.wsjtHighlightOn.Store(a.settingOr(keyWsjtHighlight, "0") == "1") + a.wsjtHLWorkedOn.Store(a.settingOr(keyWsjtHLWorked, "0") == "1") go a.pota.Run(a.ctx) // DX Cluster (multi-server): the spot callback enriches each spot @@ -1616,6 +1656,9 @@ func (a *App) startup(ctx context.Context) { // PSK Reporter. After the operator's grid is known: without it there is no // distance to measure and no receiver squares to filter on, so it stays down. a.startBandOpenFeed() + // Auto-call. After the watch list and the logbook: the ladder is meaningless + // without either, and it must never call anybody on a log it cannot read. + a.startAutoCall() // One-time tidy-up of a field nothing used to record. Background, once. a.backfillDistancesOnce() @@ -1879,6 +1922,7 @@ func (a *App) shutdown(ctx context.Context) { // logger has spent seconds tearing down ports. applog.Printf("shutdown: closing autostart programs") a.CloseAutostartPrograms() + a.stopPSKTarget() // one TLS socket to a public broker; nothing to flush applog.Printf("shutdown: stopping UDP") if a.udp != nil { a.udp.StopAll() @@ -14328,6 +14372,17 @@ func (a *App) consumeUDPEvents() { map[bool]string{true: "", false: " — nothing we send can start a transmission while this is false"}[now.enabled]) } } + // Auto-call counts what actually goes on the air from here: a reply + // we sent is a request, and this is the answer to whether the + // decoder acted on it. + acTX := autocall.TXState{ + Transmitting: ev.Transmitting, Enabled: ev.TxEnabled, + DXCall: ev.DXCall, Msg: ev.TxMessage, Instance: ev.ProgramID, + } + a.autoCallSetTX(acTX) + if ev.Transmitting { + a.autoCallNoteTX(acTX) + } wruntime.EventsEmit(a.ctx, "udp:tx_state", map[string]any{ "msg": ev.TxMessage, "transmitting": ev.Transmitting, @@ -14385,10 +14440,20 @@ func (a *App) consumeUDPEvents() { // false on a Replay's resent history — shown, never auto-answered. "is_new": ev.DecodeIsNew, }) + // Auto-call sees the decode after the panel, never before: the list + // the operator reads must not wait on a decision about calling. + a.autoCallFeed(autocall.Decode{ + Call: ev.DecodeCall, Band: bandForHz(ev.DecodeFreqHz), Mode: ev.Mode, + Msg: ev.DecodeMsg, SNR: ev.DecodeSNR, At: at, TRPeriod: ev.DecodeTRPeriod, + Instance: ev.ProgramID, CQ: ev.DecodeCQ, + Ms: ev.DecodeMs, DT: ev.DecodeDT, AudioHz: int64(ev.DecodeAudioHz), + ModeRaw: ev.DecodeModeRaw, MsgRaw: ev.DecodeMsgRaw, LowConf: ev.DecodeLowConf, + IsNew: ev.DecodeIsNew, + }) // Log-aware colour in the decoder's own window (see // app_wsjt_highlight.go). After the emit: painting must never delay // the panel. - a.maybeHighlightDecode(ev.ProgramID, ev.DecodeCall, bandForHz(ev.DecodeFreqHz)) + a.maybeHighlightDecode(ev.ProgramID, ev.DecodeCall, bandForHz(ev.DecodeFreqHz), ev.Mode) // A WSJT-X decode (heard station). Render it on the FlexRadio // panadapter when the option is on; green + SNR comment, auto-expiring // after the configured duration. De-duped per call in the Flex backend. @@ -15167,6 +15232,53 @@ func (a *App) IcomSetPower(on bool) error { return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetPower(on) }) } +// icomBandCodes maps a band label to the code the band stacking registers use +// (CI-V 0x1A 0x01). Deliberately partial. +// +// The HF codes 01-09 are the same on every Icom that has HF, and are safe. What +// is NOT the same is everything above them: 50 MHz is 10 on an HF+6 rig, while +// an IC-9700 has no HF at all and numbers its three bands 144/430/1200 as 1/2/3. +// A band missing here is not a failure — the caller falls back to sending a +// frequency, which is what the button did before — so an unlisted band or an +// unlisted model loses nothing, and a GUESSED code would land the operator on +// another band entirely. +func icomBandCodes(model string) map[string]int { + m := strings.ToUpper(model) + if strings.Contains(m, "9700") { + return map[string]int{"2": 1, "70cm": 2, "23cm": 3} + } + // HF + 6 m: the 7300 / 7610 / 7760 / 7100 / 7851 / 705 / 9100 class. 60 m and + // 4 m are absent on purpose — they are not a band key on these radios. + return map[string]int{ + "160": 1, "80": 2, "40": 3, "30": 4, "20": 5, + "17": 6, "15": 7, "12": 8, "10": 9, "6": 0x10, + } +} + +// IcomRecallBand puts the radio where its own band stacking register says the +// operator last was on that band — the front panel's band key, from here. +// +// reg is 1, 2 or 3, so pressing the same band button again walks through the +// registers exactly as the radio's key does. Returns the frequency landed on; +// an error means nothing was changed and the caller should send its own +// frequency instead. +func (a *App) IcomRecallBand(band string, reg int) (int64, error) { + if a.cat == nil { + return 0, fmt.Errorf("cat not initialized") + } + code, ok := icomBandCodes(a.cat.State().Rig)[strings.ToLower(strings.TrimSpace(band))] + if !ok { + return 0, fmt.Errorf("no band stacking register for %s on this radio", band) + } + var hz int64 + err := a.cat.IcomDo(func(ic cat.IcomController) error { + var e error + hz, e = ic.RecallBandStack(code, reg) + return e + }) + return hz, err +} + // IcomSetScope enables/disables the spectrum-scope waveform stream. func (a *App) IcomSetScope(on bool) error { if a.cat == nil { @@ -16577,6 +16689,14 @@ func (a *App) reloadAfterProfileSwitch() { // per-profile settings too — rebuilt so a switch doesn't keep showing the // previous profile's view of who is worth chasing. a.startWatchlistClubLog() + // Auto-call is per profile — attempts, the "call only" callsign, whether it + // is on at all — and it TRANSMITS. A switch that left the previous profile's + // settings running would have the wrong station calling on the wrong log. + // Re-applied rather than restarted: the loop is one goroutine for the life + // of the process, and applyAutoCall clears the target when the feature is + // off in the profile just activated. + a.applyAutoCall() + a.autoCallEngine().Reset() } // DuplicateProfile clones an existing profile under newName. Useful when diff --git a/app_watchlist.go b/app_watchlist.go index 8d13fbf..7e4fc83 100644 --- a/app_watchlist.go +++ b/app_watchlist.go @@ -42,6 +42,62 @@ func (a *App) SetWatchlistContestPattern(p string) { a.setSettingGlobal(keyWatchlistContestPattern, p) } +// The named contest callsigns: the other half of the auto-add, and the half the +// pattern cannot express. +// +// A special-event fleet is usually a common string in the callsign — WWA in +// TM29WWA, HB9WWA, F4WWA/P — and the pattern collects those on sight. But an +// entry list is not a naming convention: R7W can be part of the same event and +// share nothing with it, and no pattern will ever catch that station without +// catching half the band with it. So the two work together: the pattern for the +// family, this list for everybody else. +// +// Stored GLOBALLY, like the pattern and the list itself — an event is worth +// hunting whichever station profile is on. +func (a *App) GetWatchlistContestCalls() string { + return a.settingOr(keyWatchlistContestCalls, "") +} + +// SetWatchlistContestCalls stores the list as typed and caches the parsed set. +// +// The RAW text is what is stored: the operator's line breaks and their order +// are how the list is read back a week later, and rewriting it into a +// normalised single line loses the only structure it has. +func (a *App) SetWatchlistContestCalls(list string) { + a.setSettingGlobal(keyWatchlistContestCalls, list) + a.watchCalls.Store(parseContestCalls(list)) + applog.Printf("watchlist: %d named contest callsigns", len(parseContestCalls(list))) +} + +// parseContestCalls splits the box into a set. One per line is what is asked +// for, and commas, semicolons and spaces are accepted too: a list pasted from +// an announcement arrives in whatever shape the announcement used. +func parseContestCalls(list string) map[string]struct{} { + out := map[string]struct{}{} + for _, tok := range strings.FieldsFunc(strings.ToUpper(list), func(r rune) bool { + return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r' + }) { + if tok = strings.TrimSpace(tok); tok != "" { + out[tok] = struct{}{} + } + } + return out +} + +// isNamedContestCall answers the hot path from the cached set. +func (a *App) isNamedContestCall(call string) bool { + v := a.watchCalls.Load() + if v == nil { + return false + } + set, ok := v.(map[string]struct{}) + if !ok { + return false + } + _, found := set[strings.ToUpper(strings.TrimSpace(call))] + return found +} + // WatchlistEntries returns the list for the tab. func (a *App) WatchlistEntries() []watchlist.Entry { if a.watchlist == nil { @@ -210,12 +266,20 @@ func (a *App) watchSpot(dxCall, band, mode, country, comment string, freqHz int6 } entry, notify, ok := a.watchlist.MarkSeen(dxCall) if !ok { - // Not watched yet — the auto-contest pattern may claim it. Contains, not - // prefix: the event string sits anywhere in these calls (HB9WWA, F4WWA/P). - if p := a.GetWatchlistContestPattern(); p != "" && - strings.Contains(strings.ToUpper(dxCall), p) { + // Not watched yet — the contest pattern or the named list may claim it. + // Contains, not prefix, for the pattern: the event string sits anywhere + // in those calls (HB9WWA, F4WWA/P). The named list is exact, and is what + // catches the entries whose callsign says nothing about the event. + p := a.GetWatchlistContestPattern() + byPattern := p != "" && strings.Contains(strings.ToUpper(dxCall), p) + byName := a.isNamedContestCall(dxCall) + if byPattern || byName { if err := a.watchlist.Add(dxCall, true); err == nil { - applog.Printf("watchlist: auto-added %s (contest pattern %q)", dxCall, p) + why := fmt.Sprintf("contest pattern %q", p) + if byName { + why = "named in the contest list" + } + applog.Printf("watchlist: auto-added %s (%s)", dxCall, why) entry, notify, ok = a.watchlist.MarkSeen(dxCall) if a.ctx != nil { wruntime.EventsEmit(a.ctx, "watchlist:changed") diff --git a/app_wsjt_highlight.go b/app_wsjt_highlight.go index 1276ec2..0628884 100644 --- a/app_wsjt_highlight.go +++ b/app_wsjt_highlight.go @@ -17,6 +17,7 @@ import ( const ( keyWsjtHighlight = "udp.wsjt.highlight" keyWsjtFollowMode = "udp.wsjt.followmode" // spot clicks switch the decoder's mode + keyWsjtHLWorked = "udp.wsjt.highlight_worked" ) // wsjtModes are the modes a Configure message can meaningfully ask for — the @@ -60,8 +61,55 @@ var ( hlNewBand = udp.RGB{R: 226, G: 122, B: 24} // orange hlWhite = udp.RGB{R: 255, G: 255, B: 255} hlBlack = udp.RGB{R: 20, G: 20, B: 20} + // Worked already, on this band and in this mode. Grey on purpose, and the + // only DIM colour of the four: the others say "look at this", and this one + // says the opposite — it has to recede, not compete with them. + hlWorked = udp.RGB{R: 75, G: 85, B: 99} + hlWorkedFg = udp.RGB{R: 203, G: 213, B: 225} ) +// GetWsjtHighlightWorked reports whether stations already worked on this band +// and mode are greyed out as well. +// +// Its own switch, and off by default. The other three verdicts pick out a +// handful of decodes in a period; this one can match most of them on a +// well-filled log, and a screen where nearly every line is coloured has stopped +// saying anything. It is worth having only for an operator who wants the dupes +// struck out rather than the catches picked out. +func (a *App) GetWsjtHighlightWorked() bool { + return a.settingOr(keyWsjtHLWorked, "0") == "1" +} + +// SetWsjtHighlightWorked flips it, and repaints. +// +// Turning it OFF has to clear what it painted: those callsigns keep their grey +// in the decoder's window otherwise, and nothing would ever say another word +// about them — the de-duplication remembers that they were already told. +func (a *App) SetWsjtHighlightWorked(on bool) { + v := "0" + if on { + v = "1" + } + a.setSetting(keyWsjtHLWorked, v) + a.wsjtHLWorkedOn.Store(on) + a.clearWsjtHighlights() + applog.Printf("wsjt highlight: worked stations %v", on) +} + +// clearWsjtHighlights wipes every instruction OpsLog installed, in every +// running decoder, and forgets what it had said. +func (a *App) clearWsjtHighlights() { + if a.udp == nil { + return + } + for _, inst := range a.udp.Instances() { + _ = a.udp.SendClearHighlights(inst) + } + a.wsjtHLMu.Lock() + a.wsjtHLSent = map[string]string{} + a.wsjtHLMu.Unlock() +} + // GetWsjtHighlight reports whether decode highlighting is on. func (a *App) GetWsjtHighlight() bool { return a.settingOr(keyWsjtHighlight, "0") == "1" @@ -77,13 +125,8 @@ func (a *App) SetWsjtHighlight(on bool) { } a.setSetting(keyWsjtHighlight, v) a.wsjtHighlightOn.Store(on) - if !on && a.udp != nil { - for _, inst := range a.udp.Instances() { - _ = a.udp.SendClearHighlights(inst) - } - a.wsjtHLMu.Lock() - a.wsjtHLSent = map[string]string{} - a.wsjtHLMu.Unlock() + if !on { + a.clearWsjtHighlights() applog.Printf("wsjt highlight: off — cleared in every instance") } } @@ -92,12 +135,14 @@ func (a *App) SetWsjtHighlight(on bool) { // it, when the option is on and the verdict is worth a colour. De-duplicated // per instance+call+verdict: a station CQing all evening is decoded four times // a minute, and the instruction only needs to be said once. -func (a *App) maybeHighlightDecode(instance, call, band string) { +func (a *App) maybeHighlightDecode(instance, call, band, mode string) { if !a.wsjtHighlightOn.Load() || a.udp == nil || call == "" || instance == "" { return } - bg, fg, verdict := a.decodeHighlightVerdict(call, band) - key := instance + "|" + strings.ToUpper(call) + "|" + band + bg, fg, verdict := a.decodeHighlightVerdict(call, band, mode) + // The MODE is part of the key: one receiver can be handed FT8 and FT4 in + // the same session, and the verdict on a callsign is not the same in both. + key := instance + "|" + strings.ToUpper(call) + "|" + band + "|" + strings.ToUpper(mode) a.wsjtHLMu.Lock() if a.wsjtHLSent == nil { a.wsjtHLSent = map[string]string{} @@ -124,9 +169,11 @@ func (a *App) maybeHighlightDecode(instance, call, band string) { } // decodeHighlightVerdict ranks a callsign: watchlist beats new-DXCC beats -// new-band; anything else is "no colour". The empty verdict doubles as the -// clear signal in maybeHighlightDecode. -func (a *App) decodeHighlightVerdict(call, band string) (bg, fg *udp.RGB, verdict string) { +// new-band, and "already worked here" comes last of all — it is the only +// verdict that says do NOT call, so anything worth calling for outranks it. +// Anything else is "no colour", and the empty verdict doubles as the clear +// signal in maybeHighlightDecode. +func (a *App) decodeHighlightVerdict(call, band, mode string) (bg, fg *udp.RGB, verdict string) { if a.watchlist != nil { if _, ok := a.watchlist.Match(call); ok { c := hlWatchlist @@ -151,5 +198,18 @@ func (a *App) decodeHighlightVerdict(call, band string) (bg, fg *udp.RGB, verdic } } } + // Worked already, this exact callsign on this band in this mode — a dupe, + // judged by the same ledger and the same digital-mode grouping the cluster + // uses, so the two windows cannot disagree about what "worked" means. + if a.wsjtHLWorkedOn.Load() && band != "" && mode != "" { + m := strings.ToUpper(strings.TrimSpace(mode)) + if c.normMode != nil { + m = c.normMode(m) + } + if _, ok := c.workedCallSlots[strings.ToUpper(call)+"|"+strings.ToLower(band)+"|"+m]; ok { + bgc, fgc := hlWorked, hlWorkedFg + return &bgc, &fgc, "worked" + } + } return nil, nil, "" } diff --git a/autocall.go b/autocall.go new file mode 100644 index 0000000..e8250ce --- /dev/null +++ b/autocall.go @@ -0,0 +1,494 @@ +package main + +// Auto-call — the wiring around internal/autocall. +// +// The DECISION is in that package, alone and tested. This file does the three +// things it cannot do for itself: cut the decode stream into periods, tell it +// what the log still needs from each station, and carry out what it decides. +// +// It lives in the backend rather than in the panel because it keys a +// transmitter: it must behave identically whether the FT decodes tab is open, +// behind another tab, or the window is minimised — and because every rule it +// applies is then a Go test rather than something only the air can check. + +import ( + "fmt" + "strconv" + "strings" + "time" + + wruntime "github.com/wailsapp/wails/v2/pkg/runtime" + + "hamlog/internal/applog" + "hamlog/internal/autocall" +) + +const ( + keyAutoCallOn = "autocall.enabled" + keyAutoCallOnly = "autocall.only" + keyAutoCallAttempts = "autocall.attempts" + keyAutoCallWatched = "autocall.watched_attempts" + keyAutoCallMisses = "autocall.misses" + keyAutoCallRounds = "autocall.max_rounds" + keyAutoCallRestMin = "autocall.rest_min" +) + +// AutoCallSettings is the panel's shape. Durations are in minutes because that +// is what the operator is asked for. +type AutoCallSettings struct { + Enabled bool `json:"enabled"` + Only string `json:"only"` + // Attempts / WatchedAttempts: how many calls one station gets before it is + // released. The larger allowance is for a callsign on the watch list. + Attempts int `json:"attempts"` + WatchedAttempts int `json:"watched_attempts"` + // Misses: periods in which the station itself transmits, with no decode of + // it, before it is given up on. + Misses int `json:"misses"` + // MaxRounds: how many series of calls one station gets in a session, and + // RestMin the pause between two of them. + MaxRounds int `json:"max_rounds"` + RestMin int `json:"rest_min"` +} + +func (a *App) GetAutoCallSettings() AutoCallSettings { + d := autocall.Defaults() + num := func(key string, def int) int { + n, err := strconv.Atoi(strings.TrimSpace(a.settingOr(key, ""))) + if err != nil || n <= 0 { + return def + } + return n + } + return AutoCallSettings{ + // Never on from a stored value alone — see startAutoCall. + Enabled: a.settingOr(keyAutoCallOn, "0") == "1", + Only: strings.ToUpper(strings.TrimSpace(a.settingOr(keyAutoCallOnly, ""))), + Attempts: num(keyAutoCallAttempts, d.Attempts), + WatchedAttempts: num(keyAutoCallWatched, d.WatchedAttempts), + Misses: num(keyAutoCallMisses, d.Misses), + MaxRounds: num(keyAutoCallRounds, d.MaxRounds), + RestMin: num(keyAutoCallRestMin, int(d.Rest/time.Minute)), + } +} + +func (a *App) SaveAutoCallSettings(s AutoCallSettings) error { + a.setSetting(keyAutoCallOn, map[bool]string{true: "1", false: "0"}[s.Enabled]) + a.setSetting(keyAutoCallOnly, strings.ToUpper(strings.TrimSpace(s.Only))) + for key, v := range map[string]int{ + keyAutoCallAttempts: s.Attempts, keyAutoCallWatched: s.WatchedAttempts, + keyAutoCallMisses: s.Misses, keyAutoCallRounds: s.MaxRounds, + keyAutoCallRestMin: s.RestMin, + } { + if v > 0 { + a.setSetting(key, strconv.Itoa(v)) + } + } + a.applyAutoCall() + 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) + return nil +} + +// SetAutoCallOnly is the chase-list field in the decodes toolbar. +// +// Its own binding rather than a settings round-trip: the toolbar knows one +// field, and handing back a whole struct it never read is how a Preferences +// window left open somewhere quietly reverts a limit that was just changed. +func (a *App) SetAutoCallOnly(list string) error { + s := a.GetAutoCallSettings() + s.Only = list + return a.SaveAutoCallSettings(s) +} + +// SetAutoCall is the toolbar switch above the decodes. +func (a *App) SetAutoCall(on bool) error { + s := a.GetAutoCallSettings() + s.Enabled = on + return a.SaveAutoCallSettings(s) +} + +// autoCallEngine returns the engine, built on first use. +func (a *App) autoCallEngine() *autocall.Engine { + a.acMu.Lock() + defer a.acMu.Unlock() + if a.ac == nil { + a.ac = autocall.New(a.autoCallSettings()) + } + return a.ac +} + +func (a *App) autoCallSettings() autocall.Settings { + s := a.GetAutoCallSettings() + return autocall.Settings{ + Enabled: s.Enabled, Only: s.Only, + Attempts: s.Attempts, WatchedAttempts: s.WatchedAttempts, + Misses: s.Misses, MaxRounds: s.MaxRounds, + Rest: time.Duration(s.RestMin) * time.Minute, + } +} + +// applyAutoCall pushes the settings into the engine, and clears its state when +// the feature is switched off — an operator turning it off is entitled to have +// it forget the station it was calling, not resume it half an hour later. +func (a *App) applyAutoCall() { + e := a.autoCallEngine() + s := a.autoCallSettings() + e.SetSettings(s) + if !s.Enabled { + e.Reset() + a.acMu.Lock() + a.acPeriod, a.acBuf = nil, nil + a.acMu.Unlock() + } + a.emitAutoCall() +} + +// ResetAutoCall is the operator's restart after it gave up — and what Halt +// does, since halting means "not this station". +func (a *App) ResetAutoCall() { + a.autoCallEngine().Reset() + a.emitAutoCall() +} + +// AutoCallStatus is what the toolbar shows. +type AutoCallStatus struct { + Enabled bool `json:"enabled"` + // Only is the chase list, carried in the status so the field in the decodes + // toolbar and the one in Preferences are never two versions of the truth: + // whichever is typed into, both show it. + Only string `json:"only"` + Target string `json:"target"` + Calls int `json:"calls"` + Max int `json:"max"` + Misses int `json:"misses"` + MaxMiss int `json:"max_miss"` + Stopped bool `json:"stopped"` + // Reason is the last decision in plain words. An auto-call that is doing + // nothing on purpose looks exactly like one that is broken. + Reason string `json:"reason"` +} + +func (a *App) GetAutoCallStatus() AutoCallStatus { + st := a.autoCallEngine().Status() + a.acMu.Lock() + reason := a.acReason + a.acMu.Unlock() + set := a.GetAutoCallSettings() + return AutoCallStatus{ + Enabled: set.Enabled, Only: set.Only, + Target: st.Target, Calls: st.Attempts, Max: st.Max, + Misses: st.Misses, MaxMiss: st.MaxMiss, Stopped: st.Stopped, + Reason: reason, + } +} + +func (a *App) emitAutoCall() { + if a.ctx == nil { + return + } + wruntime.EventsEmit(a.ctx, "autocall:status", a.GetAutoCallStatus()) +} + +// TakeAutoCallTarget adopts the station the operator has just clicked, so a +// manual pick gets the same watchdogs as an automatic one — the click is the +// choice of station, not a decision to call it for ever. +func (a *App) TakeAutoCallTarget(call, band, mode string) { + if !a.GetAutoCallSettings().Enabled { + return + } + call = strings.ToUpper(strings.TrimSpace(call)) + if call == "" { + return + } + a.autoCallEngine().Take(autocall.Candidate{ + Decode: autocall.Decode{Call: call, Band: band, Mode: mode, At: time.Now().UTC(), IsNew: true}, + Need: a.autoCallNeed(call, band, mode), + Watched: a.autoCallWatched(call), + }) + a.emitAutoCall() +} + +// ── The decode stream, cut into periods ─────────────────────────────────── + +// acDecode is one decode held until its period is complete. +type acDecode struct { + d autocall.Decode + tx bool // the decode is our own transmission echoed back +} + +// autoCallFeed takes one decode from the UDP loop. +// +// Decodes arrive one datagram at a time and a decision needs the whole period: +// the best station in it, and whether the target was there at all. They are +// therefore buffered under the period they belong to, and the period is judged +// when the next one starts — or, if the band goes quiet, by the sweeper below, +// which is what makes "not decoded for three of its periods" reachable when the +// answer is that nothing is being decoded at all. +func (a *App) autoCallFeed(d autocall.Decode) { + if !a.GetAutoCallSettings().Enabled { + return + } + inst := d.Instance + key := acPeriodKey(d.At, d.TRPeriod) + a.acMu.Lock() + 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{} + } + if prev := a.acPeriod[inst]; prev != "" && prev != key { + prevAt, prevTR, buf := a.acAt[inst], a.acTR[inst], a.acBuf[inst] + a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod + a.acBuf[inst] = []acDecode{{d: d}} + a.acMu.Unlock() + a.autoCallJudge(inst, prev, prevAt, prevTR, buf) + return + } + a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod + a.acBuf[inst] = append(a.acBuf[inst], acDecode{d: d}) + a.acMu.Unlock() +} + +// autoCallSweep closes the periods nothing has closed for us. Called on a timer. +// +// Per receiver, because with two decoders one may fall silent while the other +// is busy — and it is the silent one's period that has to close for a missed +// period to be counted at all. +func (a *App) autoCallSweep() { + if !a.GetAutoCallSettings().Enabled { + return + } + type due struct { + inst, key string + at time.Time + tr int + buf []acDecode + } + var ready []due + a.acMu.Lock() + for inst, key := range a.acPeriod { + if key == "" { + continue + } + tr := a.acTR[inst] + if tr <= 0 { + tr = 15 + } + // One slot plus a margin: decodes for a period keep arriving for a + // second or two after it ends, and judging early would count a station + // as missing that is about to be listed. + if time.Since(a.acAt[inst]) < time.Duration(tr)*time.Second+4*time.Second { + continue + } + ready = append(ready, due{inst, key, a.acAt[inst], tr, a.acBuf[inst]}) + delete(a.acPeriod, inst) + delete(a.acBuf, inst) + } + a.acMu.Unlock() + for _, d := range ready { + a.autoCallJudge(d.inst, d.key, d.at, d.tr, d.buf) + } +} + +// autoCallSilence is the empty period. With the band dead, no decode ever +// arrives to close the next one, and the target's absence would never be +// counted — so a period with nothing in it is still a period. +// +// Only for the receiver the target is being called on: an idle second decoder +// has no periods to miss. +func (a *App) autoCallSilence() { + if !a.GetAutoCallSettings().Enabled { + return + } + inst, target := a.autoCallEngine().TargetInstance() + if target == "" { + return + } + a.acMu.Lock() + quiet := a.acPeriod[inst] == "" && time.Since(a.acLastJudge) > 20*time.Second + tr := a.acTR[inst] + a.acMu.Unlock() + if !quiet { + return + } + now := time.Now().UTC() + a.autoCallJudge(inst, acPeriodKey(now, tr), now, tr, nil) +} + +func acPeriodKey(at time.Time, trSec int) string { + if trSec <= 0 { + trSec = 15 + } + return fmt.Sprintf("%d", at.UTC().Unix()/int64(trSec)) +} + +// autoCallJudge resolves what the log needs from each station in the period, +// runs the decision, and carries it out. +func (a *App) autoCallJudge(inst, key string, at time.Time, tr int, buf []acDecode) { + a.acMu.Lock() + a.acLastJudge = time.Now() + a.acMu.Unlock() + + // One status call for the whole period. It reads a cached worked-index, but + // it is still per-callsign work and a busy period is thirty of them. + seen := map[string]bool{} + var q []SpotQuery + var uniq []acDecode + for _, dd := range buf { + k := dd.d.Call + "|" + dd.d.Band + "|" + dd.d.Mode + if seen[k] { + // The same station twice in one period is one candidate, judged on + // its most callable line — the engine's bestOf does that, so both + // decodes are kept; only the status lookup is deduplicated. + uniq = append(uniq, dd) + continue + } + seen[k] = true + uniq = append(uniq, dd) + q = append(q, SpotQuery{Call: dd.d.Call, Band: dd.d.Band, Mode: dd.d.Mode}) + } + status := map[string]SpotStatus{} + for _, st := range a.ClusterSpotStatuses(q) { + status[st.Call+"|"+st.Band+"|"+st.Mode] = st + } + + cands := make([]autocall.Candidate, 0, len(uniq)) + for _, dd := range uniq { + st := status[dd.d.Call+"|"+dd.d.Band+"|"+dd.d.Mode] + cands = append(cands, autocall.Candidate{ + Decode: dd.d, + Need: autoCallNeedOf(st.Status), + Watched: a.autoCallWatched(dd.d.Call), + Worked: st.Status == "worked", + }) + } + + tx := a.autoCallTX() + act := a.autoCallEngine().OnPeriod(autocall.Period{ + Instance: inst, Key: key, At: at, TRPeriod: tr, Decodes: cands, TX: tx, + MyCall: a.opCall, + }) + a.autoCallDo(act) +} + +// autoCallDo carries out a decision and records it. +func (a *App) autoCallDo(act autocall.Action) { + if act.Reason != "" { + a.acMu.Lock() + a.acReason = act.Reason + a.acMu.Unlock() + applog.Printf("autocall: %s", act.Reason) + } + switch act.Kind { + case autocall.DoReply: + d := act.Decode + if err := a.AnswerDecode(d.Instance, d.Ms, d.SNR, d.DT, d.AudioHz, d.ModeRaw, d.MsgRaw, d.LowConf); err != nil { + applog.Printf("autocall: the call to %s could not be sent: %v", d.Call, err) + } + case autocall.DoHalt: + // autoTxOnly=false: stop now. The whole point of a brake is that it does + // not wait for the over in progress to finish. + if err := a.HaltDecodeTx("", false); err != nil { + applog.Printf("autocall: halt failed: %v", err) + } + } + if act.Kind != autocall.DoNothing || act.Reason != "" { + a.emitAutoCall() + } +} + +// autoCallNoteTX is the attempt counter, fed from the decoder's own status. +// +// ONCE PER TRANSMIT PERIOD. Status arrives every second and says "transmitting" +// throughout the over, so counting each one would spend the whole allowance of +// seven calls inside a single fifteen-second slot — the counter has to measure +// transmissions, not seconds of carrier. +func (a *App) autoCallNoteTX(tx autocall.TXState) { + if !a.GetAutoCallSettings().Enabled { + return + } + a.acMu.Lock() + // Per receiver as well as per period: in a split view both decoders report + // their own transmissions, and one key for both would let the second one's + // carrier swallow the first one's count. + key := tx.Instance + "|" + acPeriodKey(time.Now().UTC(), a.acTR[tx.Instance]) + if a.acTXPeriod == key { + a.acMu.Unlock() + return + } + a.acTXPeriod = key + a.acMu.Unlock() + a.autoCallDo(a.autoCallEngine().NoteTX(tx)) +} + +// autoCallTX is the last transmit state reported, as the engine wants it. +func (a *App) autoCallTX() autocall.TXState { + a.acMu.Lock() + defer a.acMu.Unlock() + return a.acTX +} + +func (a *App) autoCallSetTX(tx autocall.TXState) { + a.acMu.Lock() + a.acTX = tx + a.acMu.Unlock() +} + +// autoCallNeedOf maps the cluster's own status vocabulary onto the ladder. One +// vocabulary for both, so a station that reads NEW BAND in the decodes list is +// the same NEW BAND the auto-call ranks — two answers to one question is how +// the panel and the caller quietly start disagreeing. +func autoCallNeedOf(status string) autocall.Need { + switch status { + case "new": + return autocall.NeedDXCC + case "new-band-mode", "new-band": + // New on both counts is at least a new band, and it is the better catch + // of the two — it must not fall below a plain new band. + return autocall.NeedBand + case "new-mode": + return autocall.NeedMode + case "new-slot": + return autocall.NeedSlot + } + return autocall.NeedNone +} + +func (a *App) autoCallNeed(call, band, mode string) autocall.Need { + st := a.ClusterSpotStatuses([]SpotQuery{{Call: call, Band: band, Mode: mode}}) + if len(st) == 0 { + return autocall.NeedNone + } + return autoCallNeedOf(st[0].Status) +} + +// autoCallWatched asks the watch list, which is the same list the spot alerts +// and the cluster colouring use. +func (a *App) autoCallWatched(call string) bool { + if a.watchlist == nil { + return false + } + _, ok := a.watchlist.Match(strings.ToUpper(strings.TrimSpace(call))) + return ok +} + +// startAutoCall arms the engine at launch. +// +// The stored "on" is honoured, and the engine starts with NO target: a program +// that came up already calling a station chosen before the last shutdown is not +// something an operator can be expected to anticipate. +func (a *App) startAutoCall() { + a.applyAutoCall() + go a.autoCallLoop() +} + +func (a *App) autoCallLoop() { + t := time.NewTicker(2 * time.Second) + defer t.Stop() + for range t.C { + if a.ctx == nil { + return + } + a.autoCallSweep() + a.autoCallSilence() + } +} diff --git a/bandopen_sources.go b/bandopen_sources.go index e01c3a1..9a93722 100644 --- a/bandopen_sources.go +++ b/bandopen_sources.go @@ -93,14 +93,27 @@ func keepKnownBands(want []string) []string { return out } +// pskrMaxGrids bounds the receiver squares the broker is asked to filter on. +// Each is one subscription, and the traffic follows the area: 2000 km is about +// 615 squares, which the broker takes in batches without complaint. The cap is +// there so an absurd radius cannot turn into thousands of subscriptions — past +// it the nearest squares are kept, and the log says how many. +const pskrMaxGrids = 900 + // bandOpenNearKm reads the stored receiver radius, falling back to the default // for anything unset or out of range. The bounds are the two ways to make the // watch useless: below 25 km almost nobody is ever near enough to hear anything, -// and past 1000 km the reports stop being about the operator's own path — which -// is the entire premise of measuring from a receiver rather than a transmitter. +// and past 3000 km a "nearby" receiver is on the far side of a continent, which +// says nothing about the operator's own path. +// +// The ceiling was 1000 km, chosen with Europe in mind, where that radius holds a +// dozen countries' worth of receivers. It is the wrong number in VK: a station +// there can have almost nobody inside 300 km, and the chase list stayed empty +// not because nothing was on the air but because nothing was listening close +// enough to count. func bandOpenNearKm(raw string) int { n, err := strconv.Atoi(strings.TrimSpace(raw)) - if err != nil || n < 25 || n > 1000 { + if err != nil || n < 25 || n > 3000 { return pskr.DefaultNearKm } return n @@ -194,7 +207,17 @@ func (a *App) startBandOpenFeed() { // hundred survived the NearKm test below. One ring of squares — about the // same 300 km — is 0.2 to 1.2 a second, and the same for every operator, // where filtering by DXCC ranged from 1.2 (OH) to 72.5 (K). - rxGrids := geo.NeighbourGrids(a.opLat, a.opLon, 1) + // + // The set follows the radius the operator asked for. It was one fixed ring + // whatever they set — about 300 km — so raising the radius bought nothing: + // the reports that would have satisfied it were never sent to us in the + // first place, and an operator in a thinly-populated region who widened the + // circle to find some receivers saw no change at all. + rxGrids := geo.GridsWithin(a.opLat, a.opLon, float64(s.NearKm), pskrMaxGrids) + if len(rxGrids) == 0 { + rxGrids = geo.NeighbourGrids(a.opLat, a.opLon, 1) + } + applog.Printf("pskr: receiver filter — %d squares within %d km of the station", len(rxGrids), s.NearKm) var onGrid func(call, grid string) if chaseGrids { diff --git a/changelog.json b/changelog.json index 0fc3f64..59a69d2 100644 --- a/changelog.json +++ b/changelog.json @@ -1,4 +1,48 @@ [ + { + "version": "0.27.12", + "date": "", + "en": [ + "Help menu: “Join the Discord” opens the OpsLog Discord server in your browser.", + "Icom console: a tick box makes the band buttons recall the radio's own band stacking registers — where you last were on that band, in the mode you were in. Press the same band again to step through its three registers, exactly as the radio's band key does. A band the register cannot be read for still sends the fixed frequency.", + "Shared CAT: a frequency or mode just commanded is reported back at once instead of on the next poll, and a mode the radio is already in is no longer re-sent. WSJT-X waits for that readback before it believes the band changed — over a network CI-V link that wait was several seconds.", + "Chase new / band openings: the receiver radius goes up to 3000 km, and the PSK Reporter subscription now follows it. Widening the circle used to change nothing, because the reports it would have accepted were never sent to us. 300 km holds no receivers at all in much of VK, ZL or North America.", + "The radius is also reachable from the Chase new option itself, rather than only from inside the band-opening watch.", + "New PSK Reporter panel beside the FT decodes, which can be hidden: for the station you are calling, has he decoded YOU and how long ago, who near you he is hearing, who near him heard you, how many stations he is working through, and where his receive passband is free. Follows the station you click or call. Off by default — Settings → DXHunter/spots. The narrow feed is a few messages a second; a whole-band option is there for a fast machine.", + "FT Map: the arcs no longer run off the side of the map. The map shows one world, and a path crossing the antimeridian was drawn past 180° into the blank space beside it — from VK or ZL that is most of them, each one ending nowhere while its own marker sat on the far coast. A path now leaves one edge and re-enters at the other, at the latitude it left.", + "NEW — Auto-call: OpsLog can answer FT8/FT4 decodes on its own. It picks the best station on the air by what the log still needs — a watched callsign outranking the same need from anybody else — and, above all, it knows when to stop: 7 calls (15 for a watched callsign), 3 of the station’s own transmit periods with no decode of it, a four-minute backstop, and three series per station for a whole session. A station in the middle of a QSO with somebody else is never called, because it cannot answer. Every decision is written to the log with its reason, the toolbar button shows the target and the count as it goes, and Halt stops it. OFF by default — Settings → DXHunter/spots. It keys your transmitter without asking, so read that panel’s warning before switching it on, and switch DXHunter’s own auto-call off: two programs answering decodes from one shack transmit over each other.", + "PSK Reporter panel: the window is ten minutes, and the history query now fills BOTH directions when the target changes. Side by side with DXHunter on the same station at the same moment it showed 18 decodes against 27, and a co-area station missing — five minutes catches about one upload cycle per reporting station, and most of them report every five.", + "Auto-call: “Chase only” takes SEVERAL callsigns, separated by spaces or commas. Nothing off the list is called, the priority ladder still orders the ones on it, and working one leaves the others callable.", + "Two new themes on DXHunter’s palette: “DXHunter”, its slate and its blue exactly, and “DXHunter orange”, the same slate with OpsLog’s own orange accent. Both carry its status colours (emerald, amber, cyan, red), so a green number means the same thing in either window. Settings → General → Theme.", + "The auto-call chase list is also in the decodes toolbar, beside the Auto button: naming the station you are waiting for is done while watching the band, not in a settings tree. It is the same setting as the one in Preferences — type into either and both show it.", + "FT decodes: one click SELECTS a station — it fills the entry and points the panels at it, like a cluster spot — and a double click calls it. A single click used to hand the decode straight to WSJT-X as a reply, so brushing a row while reading the band started transmitting.", + "Auto-call with two decoders running: while a station is being called, a period from the other receiver is not looked at. It cannot start a second QSO over the one in progress however good the station it hears, its periods count no missed periods against a target that was never on its band, and its transmissions are not counted as calls to that target.", + "Watchlist: drawn as DXHunter draws it — the callsign in the interface font rather than monospaced (the difference that shows with the two windows side by side), badges at its size, a check or a warning triangle at the head of each spot line, and frequencies as 7.056 rather than 7.0560.", + "A decoder is named by what it IS, not by what it announces: Nexus sends its packets as “Tempo”, the engine inside it, and OpsLog showed a program the operator had never heard of. The id itself is untouched — it is what a reply, a halt and the auto-call are routed by.", + "WSJT-X colouring can grey out a station already worked on this band AND in this mode — the duplicate. Its own switch under the highlight option, off by default: the other three verdicts pick out a handful of decodes, this one can match most of a period on a well-filled log. Grey rather than a colour, because it says the opposite of the others.", + "Contest watchlist (Settings → DXHunter): beside the auto-add pattern there is now a list of callsigns, one per line. A pattern collects a fleet that shares a string — TM29WWA, HB9WWA, F4WWA/P — but not the station taking part under a callsign that says nothing about the event. Named in the list, it joins the contest watchlist the moment it is spotted." + ], + "fr": [ + "Menu Aide : « Rejoindre le Discord » ouvre le serveur Discord d’OpsLog dans votre navigateur.", + "Console Icom : une case à cocher fait rappeler aux boutons de bande les registres de bande de la radio — là où vous étiez la dernière fois sur cette bande, dans le mode où vous étiez. Réappuyez sur la même bande pour parcourir ses trois registres, comme le fait la touche de bande du poste. Une bande dont le registre est illisible envoie toujours la fréquence fixe.", + "CAT partagé : une fréquence ou un mode qu’on vient de commander est renvoyé immédiatement, sans attendre le sondage suivant, et un mode que la radio a déjà n’est plus réémis. WSJT-X attend cette relecture avant de croire au changement de bande — sur une liaison CI-V réseau, cette attente durait plusieurs secondes.", + "Chase new / ouvertures : le rayon des récepteurs monte à 3000 km, et l’abonnement PSK Reporter le suit désormais. Élargir le cercle ne changeait rien, car les reports qu’il aurait acceptés ne nous étaient jamais envoyés. 300 km ne contient aucun récepteur dans une bonne partie de VK, ZL ou d’Amérique du Nord.", + "Ce rayon est aussi accessible depuis l’option Chase new elle-même, et non plus seulement depuis la veille d’ouvertures.", + "Nouveau panneau PSK Reporter à côté des décodages FTx, masquable : pour la station que vous appelez, vous a-t-il décodé et depuis combien de temps, qui entend-il près de chez vous, qui près de lui vous a entendu, combien de stations défilent chez lui, et où son passe-bande est libre. Il suit la station que vous cliquez ou appelez. Désactivé par défaut — Réglages → DXHunter/spots. Le flux étroit ne coûte que quelques messages par seconde ; une option « toute la bande » existe pour une machine rapide.", + "FT Map : les arcs ne partent plus hors de la carte. La carte n’affiche qu’un seul monde, et un chemin franchissant l’antiméridien était tracé au-delà de 180°, dans le vide à côté — depuis VK ou ZL c’est la majorité d’entre eux, chacun finissant nulle part alors que son propre marqueur était sur la côte opposée. Un chemin sort désormais par un bord et revient par l’autre, à la latitude où il est sorti.", + "NOUVEAU — Appel automatique : OpsLog peut répondre seul aux décodages FT8/FT4. Il choisit la meilleure station en fonction de ce qui manque au log — un indicatif surveillé passant devant le même besoin chez un autre — et surtout il sait s’arrêter : 7 appels (15 pour un indicatif surveillé), 3 périodes d’émission de la station sans la décoder, un butoir de quatre minutes, et trois séries par station pour toute une session. Une station en plein QSO avec quelqu’un d’autre n’est jamais appelée : elle ne peut pas répondre. Chaque décision est écrite dans le journal avec sa raison, le bouton de la barre affiche la cible et le décompte en direct, et Stop l’interrompt. DÉSACTIVÉ par défaut — Réglages → DXHunter/spots. Il met votre émetteur en marche sans vous demander : lisez l’avertissement du panneau avant de l’activer, et coupez l’appel automatique de DXHunter — deux programmes qui répondent aux décodages du même shack s’émettent dessus.", + "Panneau PSK Reporter : la fenêtre passe à dix minutes, et la requête d’historique remplit désormais les DEUX sens au changement de cible. Côte à côte avec DXHunter sur la même station au même instant, il affichait 18 décodages contre 27, et une station de la région manquait — cinq minutes ne captent qu’un cycle d’envoi par station, et la plupart n’envoient que toutes les cinq minutes.", + "Appel automatique : « Chasser uniquement » accepte PLUSIEURS indicatifs, séparés par des espaces ou des virgules. Rien hors de la liste n’est appelé, l’échelle de priorité départage ceux qui y sont, et en travailler un laisse les autres appelables.", + "Deux nouveaux thèmes sur la palette de DXHunter : « DXHunter », son ardoise et son bleu à l’identique, et « DXHunter orange », la même ardoise avec l’orange d’OpsLog. Les deux reprennent ses couleurs d’état (émeraude, ambre, cyan, rouge) : un nombre vert veut dire la même chose dans les deux fenêtres. Réglages → Général → Thème.", + "La liste de chasse de l’appel automatique est aussi dans la barre des décodages, à côté du bouton Auto : nommer la station qu’on attend se fait en regardant la bande, pas dans un arbre de réglages. C’est le même réglage que dans les Préférences — saisi dans l’un, il apparaît dans l’autre.", + "Décodages FT : un clic SÉLECTIONNE une station — il remplit la saisie et y pointe les panneaux, comme un spot du cluster — et un double-clic l’appelle. Un simple clic passait le décodage directement à WSJT-X en réponse : frôler une ligne en lisant la bande déclenchait une émission.", + "Appel automatique avec deux décodeurs : pendant qu’une station est appelée, une période de l’autre récepteur n’est pas examinée. Il ne peut pas ouvrir un second QSO par-dessus celui en cours, si bonne que soit la station qu’il entend ; ses périodes ne comptent aucune période manquée contre une cible qui n’a jamais été sur sa bande, et ses émissions ne comptent pas comme des appels vers elle.", + "Watchlist : dessinée comme DXHunter la dessine — l’indicatif dans la police de l’interface plutôt qu’en chasse fixe (la différence qui saute aux yeux avec les deux fenêtres côte à côte), pastilles à sa taille, coche ou triangle d’alerte en tête de chaque ligne de spot, et fréquences en 7.056 plutôt que 7.0560.", + "Un décodeur est nommé par ce qu’il EST, non par ce qu’il annonce : Nexus envoie ses paquets sous le nom « Tempo », le moteur qu’il embarque, et OpsLog affichait un programme inconnu de l’opérateur. L’identifiant lui-même n’est pas touché : c’est par lui que passent une réponse, un Stop et l’appel automatique.", + "Coloration WSJT-X : possibilité de griser une station déjà travaillée sur cette bande ET dans ce mode — le doublon. Sa propre case sous l’option de coloration, désactivée par défaut : les trois autres verdicts désignent quelques décodages, celui-ci peut concerner la majorité d’une période sur un log bien rempli. En gris et non en couleur, car il dit l’inverse des autres.", + "Watchlist contest (Réglages → DXHunter) : à côté du motif d’ajout automatique, une liste d’indicatifs, un par ligne. Un motif attrape une flotte qui partage une chaîne — TM29WWA, HB9WWA, F4WWA/P — mais pas la station engagée sous un indicatif qui ne dit rien de l’événement. Nommée dans la liste, elle rejoint la watchlist contest dès qu’elle est spottée." + ] + }, { "version": "0.27.11", "date": "", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 82b8429..0332dcb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock, - ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Star, Terminal, Trash2, Unlock, X, Zap, + ChevronLeft, ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Star, Terminal, Trash2, Unlock, X, Zap, } from 'lucide-react'; import { @@ -54,6 +54,7 @@ import { GetFlexState, FlexAmpOperate, GetPSKReporterStatus, GetLiveOpenings, GetChaseNew, QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair, + GetAutoCallStatus, SetAutoCall, SetAutoCallOnly, TakeAutoCallTarget, ResetAutoCall, } from '../wailsjs/go/main/App'; import { Combobox } from '@/components/ui/combobox'; import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs'; @@ -119,9 +120,9 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal'; import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel'; import { RotorCompass } from '@/components/RotorCompass'; import { GridSquareMap } from '@/components/GridSquareMap'; -import { loadAutoCall, shouldAutoCall, autoCallKey, type AutoCallSettings } from '@/lib/autocall'; import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros'; import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel'; +import { PSKReporterPanel } from '@/components/PSKReporterPanel'; import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading'; import { writeUiPref } from '@/lib/uiPref'; import { formatDateTimeUTC } from '@/lib/dateFormat'; @@ -2565,136 +2566,34 @@ export default function App() { // ── Auto-call ────────────────────────────────────────────────────── // - // Answers a decode without the operator clicking it. The DECISION lives in - // lib/autocall (one pure function, so the dangerous part can be read and - // argued with); this is only the plumbing that runs it and keys the radio. - // - // Re-read when Preferences closes, like every other setting edited there. + // The DECISION is in the BACKEND (internal/autocall), because it keys a + // transmitter: it has to behave the same whether this tab is open or the + // window is minimised, and every rule it applies — seven calls, three missed + // periods, the ladder — is a Go test rather than something only the air can + // check. What is left here is the switch, the readout, and handing over the + // station the operator clicks. + // The named command buttons, re-read when Preferences closes like every other // setting edited there. const [clusterMacros, setClusterMacros] = useState(loadClusterMacros); useEffect(() => { if (!showSettings) setClusterMacros(loadClusterMacros()); }, [showSettings]); const clusterMacrosShown = useMemo(() => visibleClusterMacros(clusterMacros), [clusterMacros]); - // Auto-call is withdrawn — it duplicated DXHunter, which answers decodes from - // the same shack. This flag is the single place that says so at runtime. - const AUTO_CALL_ENABLED = false; - const [autoCall, setAutoCall] = useState(loadAutoCall); - useEffect(() => { if (!showSettings) setAutoCall(loadAutoCall()); }, [showSettings]); - // When each callsign was last answered, so a station still calling CQ is not - // re-answered every slot while the QSO it started is still running. - const autoCalledRef = useRef>(new Map()); - // Decodes are scanned once. Without this the same decode is reconsidered on - // every status refresh, and a cooldown that has just expired would fire again - // on a decode minutes old. - const autoSeenRef = useRef>(new Set()); - // Set when a call goes out, so nothing else fires until the receiver's own - // status catches up and `busy` can be trusted again. - const autoHoldUntilRef = useRef(0); - // The station auto-call is currently working, and when it started. - // - // This is OpsLog's OWN record of "a QSO is running", and it exists because - // deriving that from the sender's Status was not enough: the moment - // WSJT-X/JTDX drops the DX call or the Enable-Tx flag between overs — which - // they do — the exchange looks finished and the next CQ gets answered, - // interleaving two and then three QSOs on one slice. A lock we set ourselves - // cannot be cleared by a flag we do not control. - // - // Released when that station's QSO is logged, when the operator halts or - // takes over by clicking a decode, and by the watchdog below. - const autoTargetRef = useRef<{ call: string; at: number } | null>(null); - // An exchange abandoned mid-way must not lock auto-call out for ever: four - // minutes covers a repeated FT8 QSO and still frees the next period soon - // enough to matter. - const AUTO_TARGET_MAX_MS = 240_000; - // Per receiver, when the carrier was last up. Feeds the stale-exchange - // backstop below. - const lastTxAtRef = useRef>(new Map()); + const [autoCallStatus, setAutoCallStatus] = useState({ enabled: false, target: '', calls: 0, max: 0, misses: 0, max_miss: 0, stopped: false, reason: '' }); useEffect(() => { - // AUTO-CALL IS WITHDRAWN — DXHunter already answers decodes, and two - // programs doing it from one shack key over each other. See lib/autocall.ts. - // - // Returning here rather than deleting the loop: the decision it implements - // is the delicate part, argued over and tested, and worth keeping intact. - // The guard is what matters — an operator whose stored preference still - // says "enabled" must not have their transmitter keyed by a feature they - // can no longer see, let alone switch off. - if (!AUTO_CALL_ENABLED) return; - if (!autoCall.enabled) return; - const now = Date.now(); - // A QSO is in progress somewhere if ANY receiver is transmitting or still - // holding a DX call it has not finished with. Both matter: between overs the - // carrier is down but the exchange is not over, and calling someone else - // then is exactly the "it never stops" behaviour. - const busy = Object.values(txStates).some((tx) => { - if (tx?.transmitting) return true; - // A DX call still set means the exchange is not finished — but ONLY while - // the sender still intends to transmit. The watchdog stops transmission - // and leaves the DX call behind, and reading the call alone left auto-call - // waiting for a QSO that had already been given up on, for ever. - if (!tx?.dx_call?.trim()) return false; - if (tx.tx_enabled === false) return false; - // Backstop for a sender that never reports the toggle: an exchange with no - // transmission for three minutes is over, whatever the DX call still says. - // Measured from the last TIME THE CARRIER WAS UP — Status itself arrives - // every second and so can never go stale. - const last = lastTxAtRef.current.get(tx.instance ?? ''); - return last === undefined || now - last < 180_000; - }) - // The hold closes the gap between sending a Reply and the receiver saying - // it has acted on it — about a second. Without it the OTHER instance still - // looks idle in that window and gets a call of its own. - || now < autoHoldUntilRef.current; - // Our own lock, evaluated after the watchdog so an abandoned exchange does - // not hold the transmitter shut. - if (autoTargetRef.current && now - autoTargetRef.current.at > AUTO_TARGET_MAX_MS) { - LogUIError('auto-call', `giving up on ${autoTargetRef.current.call} — nothing logged in four minutes`, ''); - autoTargetRef.current = null; - } - const locked = busy || autoTargetRef.current !== null; - for (const d of decodes) { - const seenKey = `${d.call}|${d.ms ?? d.at}|${d.instance ?? ''}`; - if (autoSeenRef.current.has(seenKey)) continue; - // A Replay's resent history is display-only: answering a line the far - // end already dropped would fail anyway, and doing it at startup — the - // moment replays arrive — would be a transmitter firing on old news. - if ((d as any).is_new === false) { autoSeenRef.current.add(seenKey); continue; } - // Only decodes from the CURRENT period are worth answering: replying to a - // slot that has closed asks the far end to match a decode it has dropped. - if (now - Date.parse(d.at) > 30_000) { autoSeenRef.current.add(seenKey); continue; } - const e = spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`]; - // NOT marked seen until its status has resolved. Statuses land a few - // hundred milliseconds after the decode, so consuming it on first sight - // would throw away almost every decode unjudged — the effect re-runs when - // spotStatus changes, and this is what lets it look again. - if (!e) continue; - autoSeenRef.current.add(seenKey); - const verdict = shouldAutoCall(autoCall, d, e as any, { - busy: locked, - calledAt: autoCalledRef.current, - now, - myCall: station.callsign, - }); - if (!verdict.call) continue; - autoCalledRef.current.set(d.call.toUpperCase(), now); - autoHoldUntilRef.current = now + 12_000; // an FT8 slot, near enough - autoTargetRef.current = { call: d.call.toUpperCase(), at: now }; - // Same reason as a manual click: put the transmitter on the decode's band - // before answering, or a second slice answers on the wrong one. - FlexTXOnBand(d.band ?? '').catch(() => {}); - // Logged, always: an automatic transmission with no record of WHY is the - // one thing an operator cannot argue with after the fact. - LogUIError('auto-call', `calling ${d.call} — ${verdict.reason}`, ''); - AnswerDecode( - d.instance ?? '', d.ms ?? 0, d.snr, d.dt ?? 0, - d.audio_hz ?? 0, d.mode_raw || d.mode || '', d.msg_raw ?? d.msg ?? '', !!d.low_conf, - ).catch((e2: any) => setError(String(e2?.message ?? e2))); - onCallsignInput(d.call, { force: true }); - break; // one per pass: a period can hold several, and we work one station - } - // The seen-set is bounded by the same half hour the decode list keeps. - if (autoSeenRef.current.size > 20000) autoSeenRef.current.clear(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [decodes, spotStatus, autoCall, txStates]); + GetAutoCallStatus().then(setAutoCallStatus).catch(() => {}); + // Pushed by the engine on every decision, and polled as well: the push is + // what makes the counter move the instant a call goes out, the poll is what + // recovers a status missed while the window was asleep. + const off = EventsOn('autocall:status', (s: any) => setAutoCallStatus(s)); + const id = window.setInterval(() => { GetAutoCallStatus().then(setAutoCallStatus).catch(() => {}); }, 3000); + return () => { off(); window.clearInterval(id); }; + }, []); + const toggleAutoCall = () => { + const next = !autoCallStatus?.enabled; + setAutoCallStatus((s: any) => ({ ...s, enabled: next })); + SetAutoCall(next).catch((e: any) => setError(String(e?.message ?? e))); + }; + // Staged like the cluster's, so a period arriving as one burst of fifty // packets costs one status lookup and one render, not fifty of each. const pendingDecodesRef = useRef([]); @@ -2846,6 +2745,14 @@ export default function App() { const [showChaseNew, setShowChaseNew] = useState(() => localStorage.getItem('opslog.showChaseNew') !== '0'); const refreshChaseNew = useCallback(() => { GetChaseNew().then(setChaseNewOn).catch(() => {}); }, []); useEffect(() => { refreshChaseNew(); }, [refreshChaseNew]); + // The PSK Reporter panel beside the decodes. Open/closed is remembered; the + // TARGET is whichever station is being worked, so it follows a click on a + // decode and the DX call the digital application reports, and nothing has to + // be selected twice. + const [pskPanelOpen, setPskPanelOpen] = useState(() => localStorage.getItem('opslog.pskPanel') === '1'); + useEffect(() => { try { localStorage.setItem('opslog.pskPanel', pskPanelOpen ? '1' : '0'); } catch { /* private mode */ } }, [pskPanelOpen]); + const [pskTarget, setPskTarget] = useState(''); + const [pskTargetMode, setPskTargetMode] = useState(''); const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0'); // Compact rotor widget (Settings → Rotator): dial + SP/LP only. RotorCompass // already draws exactly that when it is given neither presets nor onStop — @@ -3836,11 +3743,6 @@ export default function App() { setTxState(m as TxMsgRow); if (m?.instance) { setTxStates((prev) => ({ ...prev, [m.instance]: m as TxMsgRow })); - // When this receiver last actually TRANSMITTED, which is not the same as - // when it last spoke: Status arrives about once a second whether the - // carrier is up or not, so it can never say how long an exchange has - // been stalled. - if (m.transmitting) lastTxAtRef.current.set(m.instance, Date.now()); } // The period history takes only real transmissions — Status repeats // itself once a second whether the carrier is up or not. @@ -3957,11 +3859,6 @@ export default function App() { try { await LogUDPLoggedADIF(text); await refresh(); - // The QSO auto-call started has finished — release the lock so the next - // CQ can be answered. Matched on the callsign: a QSO logged from - // somewhere else must not free a run that is still going. - const logged = /]*)?>([^ { + for (const d of decodes) if (d.dial_hz && d.dial_hz > 0) return d.dial_hz; + return txState?.freq_hz ?? 0; + }, [decodes, txState?.freq_hz]); + + // Stations WE are decoding that are calling the same DX — the competition at + // this end, which PSK Reporter cannot see: it carries who was heard, never + // who they were calling. + const pskCallers = useMemo(() => { + if (!pskTarget) return [] as string[]; + const want = pskTarget.toUpperCase() + ' '; + const seen = new Set(); + for (const d of decodes) { + const msg = (d.msg ?? '').toUpperCase().trim(); + if (!msg.startsWith(want)) continue; + const caller = msg.slice(want.length).trim().split(/\s+/)[0]; + // "CQ" cannot be a caller, and neither can our own transmission coming + // back as a decode of ourselves. + if (caller && caller !== 'CQ' && caller !== (station.callsign ?? '').toUpperCase()) seen.add(caller); + } + return [...seen]; + }, [decodes, pskTarget, station.callsign]); + + // Follow the station the digital application says it is calling. A decode + // clicked here sets the target directly (see onCall); this covers the QSO + // started from the other side — WSJT-X's own double-click, or auto-call. + useEffect(() => { + const dx = (txState?.dx_call ?? '').toUpperCase().trim(); + if (dx && dx !== pskTarget) { + setPskTarget(dx); + setPskTargetMode(txState?.mode ?? ''); + } + }, [txState?.dx_call, txState?.mode, pskTarget]); + const renderDecodesPanel = () => ( +
+
+ {renderDecodesList()} +
+ {pskPanelOpen ? ( + setPskPanelOpen(false)} + /> + ) : ( + // Collapsed to a strip rather than removed: a panel with no way back is + // one an operator loses, and the button has to say what it opens. + + )} +
+ ); + + const renderDecodesList = () => ( { + setPskTarget((d.call ?? '').toUpperCase()); + setPskTargetMode(d.mode ?? ''); + onCallsignInput(d.call, { force: true }); + }} onCall={(d) => { - // The operator has picked a station: that is now the QSO in progress, so - // auto-call must not answer someone else over the top of it. - autoTargetRef.current = { call: (d.call ?? '').toUpperCase(), at: Date.now() }; + // 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 @@ -6389,7 +6368,6 @@ export default function App() { // buffer goes too or the next flush would put back what was just cleared. onClear={(instance) => { if (!instance) { - autoTargetRef.current = null; pendingDecodesRef.current = []; setDecodes([]); setTxMsgs([]); @@ -6407,10 +6385,20 @@ export default function App() { // An empty instance lets the backend fall back to whichever application // last reported its status — the normal single-receiver case. onHalt={(instance) => { - // Halt means stop, including whatever auto-call had started. - autoTargetRef.current = null; + // Halt means stop, including whatever auto-call had started — and it + // clears the engine's state, so a target it had given up on is not + // still sitting there when the operator switches it back on. + ResetAutoCall().catch(() => {}); HaltDecodeTx(instance, false).catch((e: any) => setError(String(e?.message ?? e))); }} + autoCallOn={!!autoCallStatus?.enabled} + onToggleAutoCall={toggleAutoCall} + autoCall={autoCallStatus} + autoCallOnly={autoCallStatus?.only ?? ''} + onSetAutoCallOnly={(list) => { + setAutoCallStatus((st: any) => ({ ...st, only: list.toUpperCase() })); + SetAutoCallOnly(list).catch((e: any) => setError(String(e?.message ?? e))); + }} /> ); diff --git a/frontend/src/components/DecodesPanel.tsx b/frontend/src/components/DecodesPanel.tsx index be6d054..469cbf1 100644 --- a/frontend/src/components/DecodesPanel.tsx +++ b/frontend/src/components/DecodesPanel.tsx @@ -19,6 +19,7 @@ import { useI18n } from '@/lib/i18n'; import { chaseAllows } from '@/lib/spotDisplay'; import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers'; import { writeUiPref } from '@/lib/uiPref'; +import { decoderName } from '@/lib/decoderName'; export type Decode = { call: string; @@ -99,6 +100,9 @@ interface Props { // the decoder announces — see the drift warning. rigBand?: string; onCall: (d: Decode) => void; + // A single click: take the station without transmitting — fill the entry, and + // point the panels at it. Absent, a click falls back to onCall. + onSelect?: (d: Decode) => void; myCall?: string; // Drop every decode and transmit message held for this panel. The list is a // live view, not data — clearing it costs nothing but the seconds until the @@ -115,6 +119,12 @@ interface Props { // machine off is not something to go hunting through a settings tree for. autoCallOn?: boolean; onToggleAutoCall?: () => void; + // The engine's own account of what it is doing, straight from the backend. + autoCall?: { target: string; calls: number; max: number; misses: number; max_miss: number; stopped: boolean; reason: string }; + // The chase list, here as well as in Preferences: naming the station you are + // waiting for is done WHILE watching the band, not in a settings tree. + autoCallOnly?: string; + onSetAutoCallOnly?: (list: string) => void; } // The "new" categories, as toggle badges — the same idea and the same colours as @@ -542,12 +552,21 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) { })); } -export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall }: Props) { +export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly }: Props) { const { t } = useI18n(); // Column widths, dragged in the header and shared by every row. Persisted // through writeUiPref (not raw localStorage) so the layout travels with data/ // like every other portable preference. const [colw, setColw] = useState(loadWidths); + // The chase list as TYPED. Re-seeded whenever the stored value changes — + // from Preferences, or from another window — but never while the box has the + // focus, or a status arriving mid-word would rewrite what is being typed. + const [onlyText, setOnlyText] = useState(autoCallOnly ?? ''); + useEffect(() => { + const el = document.activeElement as HTMLElement | null; + if (el && el.tagName === 'INPUT' && el.getAttribute('placeholder') === t('dec.chasePh')) return; + setOnlyText(autoCallOnly ?? ''); + }, [autoCallOnly, t]); const template = useMemo(() => COLS.map((c) => `${colw[c.key]}px`).join(' '), [colw]); const tableW = useMemo(() => COLS.reduce((s, c) => s + colw[c.key], 0), [colw]); const setColWidth = (key: ColKey, px: number) => { @@ -710,7 +729,8 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r } return instances.map((inst) => ({ key: inst, - label: inst, + // What the program is called, not the id it announces — see decoderName. + label: decoderName(inst), tx: txStates?.[inst], periods: buildPeriods( filtered.filter((d) => (d.instance ?? '') === inst), @@ -757,7 +777,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r className="inline-flex items-center gap-1 rounded-full border border-warning px-2 py-0.5 text-[11px] font-semibold text-warning"> {t('dec.bandDrift', { - app: driftInstance || t('dec.bandDriftApp'), + app: decoderName(driftInstance) || t('dec.bandDriftApp'), dec: decoderBand.toUpperCase(), rig: (rigBand ?? '').toUpperCase(), })} @@ -889,6 +909,54 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r filter. Halt goes LAST — it is the one that must be findable without reading, and the end of the row is the one position that never moves as filters come and go. */} + {/* Auto-call. Deliberately next to Halt: the two belong together, and + what it is doing right now — which station, how many calls of how + many — is on the button itself, because a thing that keys the + transmitter must never be a switch with no readout. */} + {onToggleAutoCall && ( + + )} + {/* 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 + that makes the space bar look dead — in a field whose whole purpose + is a list separated by spaces. */} + {onSetAutoCallOnly && ( + setOnlyText(e.target.value)} + onBlur={() => { if (onlyText.toUpperCase() !== (autoCallOnly ?? '')) onSetAutoCallOnly(onlyText); }} + onKeyDown={(e) => { + if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); + if (e.key === 'Escape') setOnlyText(autoCallOnly ?? ''); + }} + placeholder={t('dec.chasePh')} + title={t('dec.chaseTip')} + className={cn('h-8 w-44 rounded-lg border px-2 text-sm font-mono uppercase bg-background', + (autoCallOnly ?? '').trim() + ? 'border-primary text-foreground' + : 'border-border text-muted-foreground')} + /> + )} {onHalt && ( + + + +
+ {a?.enabled === false ? ( +

{t('psk.enableHint')}

+ ) : !target ? ( +

{t('psk.pickHint')}

+ ) : ( + <> + {/* ── The answer ──────────────────────────────────────────── */} +
+
+ {a?.he_me ? '✓' : a?.path_open ? '≈' : '·'} +
+
+ {a?.he_me ? ( + <> +
{t('psk.heardYou', { s: a.he_me_seconds })}
+
+ {snr(a.he_me_snr)} dB{a.he_me_offset_hz > 0 ? ` @ +${a.he_me_offset_hz} Hz` : ''} +
+ + ) : a?.path_open ? ( + <> +
{t('psk.pathOpen')}
+
{t('psk.pathOpenSub', { n: a.from_my_area_count })}
+ + ) : ( + <> +
{t('psk.notYet')}
+
{t('psk.notYetSub')}
+ + )} +
+
+ + {/* Your signal reported next to him. Only when he has not decoded + you himself — that is strictly stronger evidence, and two + banners saying the same thing differently is noise. */} + {!a?.he_me && (a?.near_him_count ?? 0) > 0 && a?.target_grid && ( +
+
+ + ✓ {t('psk.nearHim', { g: a.target_grid })} + + {t('psk.nRx', { n: a.near_him_count })} +
+
+ {(a.near_him_top ?? []).map((h) => ( + + {h.call} {snr(h.snr)} + + ))} +
+
+ )} + + {/* ── The four numbers ────────────────────────────────────── */} +
+ `${h.call} (${h.grid ?? '?'}) ${snr(h.snr)}`).join(' · ')} /> + + + 0 ? `${callers} (${confirmed})` : callers} + foot={confirmed > 0 ? t('psk.tCallersFootConf') : t('psk.tCallersFoot')} + tone="text-warning" title={t('psk.tCallersTip')} /> +
+ + {/* The one thing that turns an empty panel from a verdict into a + missing measurement. */} + {a?.target_uploads ? ( +
✓ {t('psk.uploads')}
+ ) : ( +
+ ⚠ {t('psk.noUploads', { c: target })} +
+ )} + + {/* ── Who near you he is hearing ──────────────────────────── */} +
+
{t('psk.fromAreaList')}
+ {(a?.from_my_area_top ?? []).length > 0 ? ( +
+ {(a?.from_my_area_top ?? []).slice(0, 4).map((h) => ( +
+ {h.call} + ({(h.grid ?? '?').slice(0, 4)}) + {snr(h.snr)} dB + {h.offset_hz > 0 && h.offset_hz < 10000 && ( + @ +{h.offset_hz} Hz + )} +
+ ))} +
+ ) : ( +
{t('psk.fromAreaEmpty')}
+ )} +
+ + {/* ── His passband ────────────────────────────────────────── */} +
+
+ {t('psk.passband')} + + {(a?.ceiling_hz ?? 0) > 0 + ? t('psk.ceiling', { hz: a!.ceiling_hz, n: a!.decodes_in_window }) + : (a?.decodes_in_window ?? 0) > 0 ? t('psk.noDial') : t('psk.noDecodes')} + +
+
+ {columns.map((c) => { + const ratio = c.count / maxCount; + return ( +
0.66 ? 'bg-primary' + : ratio > 0.33 ? 'bg-primary/70' : 'bg-primary/40')} + style={{ height: `${Math.max(2, Math.round(ratio * 36))}px` }} + title={`${c.edge}-${c.edge + STEP} Hz · ${c.count}${c.snr !== null ? ` @ ${c.snr.toFixed(0)} dB` : ''}`} /> + ); + })} + {(a?.suggested_offset ?? 0) > 0 && ( +
+ )} +
+
+ {[1000, 2000, 3000, 4000].map((hz) => ( + + {hz} + + ))} +
+ {(a?.suggested_offset ?? 0) > 0 && ( +
+ 🎯 {t('psk.tryOffset', { hz: a!.suggested_offset })} +
+ )} +
+ + )} +
+
+ ); +} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 1d70589..1ab4242 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -60,7 +60,7 @@ import { GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow, GetRelayAuto, SaveRelayAuto, GetStationDevices, GetAwardDefs, GetTrackedAwards, SaveTrackedAwards, - GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax, + GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetPSKTargetSettings, SavePSKTargetSettings, GetAutoCallSettings, SaveAutoCallSettings, GetWatchlistContestCalls, SetWatchlistContestCalls, GetWatchlistContestPattern, SetWatchlistContestPattern, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax, } from '../../wailsjs/go/main/App'; import type { profile as profileModels } from '../../wailsjs/go/models'; import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; @@ -484,6 +484,8 @@ const THEME_SWATCH: Record, { bg: string; card: str 'dark-indigo': { bg: '#0e0f1f', card: '#181a30', accent: '#7c6cff' }, 'dark-teal': { bg: '#061c21', card: '#0d2c33', accent: '#22d3ee' }, 'dark-plum': { bg: '#180f1e', card: '#251830', accent: '#f472b6' }, + 'dxhunter': { bg: '#0f172a', card: '#1e293b', accent: '#3b82f6' }, + 'dxhunter-orange': { bg: '#0f172a', card: '#1e293b', accent: '#f97316' }, 'high-contrast': { bg: '#000000', card: '#0d0d0d', accent: '#ff7a1a' }, }; @@ -2086,6 +2088,21 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged const [chaseStateOn, setChaseStateOn] = useState(() => localStorage.getItem('opslog.chaseState') !== '0'); const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0'); const [chaseNew, setChaseNew] = useState(false); + const [pskTgt, setPskTgt] = useState({ enabled: false, scope: 'target' }); + const [ac, setAc] = useState({ enabled: false, only: '', attempts: 7, watched_attempts: 15, misses: 3, max_rounds: 3, rest_min: 2 }); + // 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 + // Return key — the one key this box is built around. + const [contestCalls, setContestCalls] = useState(''); + const [contestPattern, setContestPattern] = useState(''); + const saveAC = async (next: any) => { + setAc(next); + try { await SaveAutoCallSettings(next); } catch { /* the toolbar shows what the engine is doing */ } + }; + const savePSKTgt = async (next: any) => { + setPskTgt(next); + try { await SavePSKTargetSettings(next); } catch { /* the panel itself reports what the feed is doing */ } + }; const [spotTTL, setSpotTTL] = useState(0); const [spotTTLText, setSpotTTLText] = useState('0'); const [spotMaxText, setSpotMaxText] = useState('1000'); @@ -2113,6 +2130,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged writeUiPref('opslog.chaseGrids', g ? '1' : '0'); } catch { /* defaults stand */ } try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ } + try { setPskTgt(await GetPSKTargetSettings()); } catch { /* defaults stand */ } + try { setAc(await GetAutoCallSettings()); } catch { /* defaults stand */ } + try { setContestCalls(await GetWatchlistContestCalls()); } catch { /* defaults stand */ } + try { setContestPattern(await GetWatchlistContestPattern()); } catch { /* defaults stand */ } try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ } try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ } try { const n = await GetSpotMax(); setSpotMaxText(String(n)); } catch { /* defaults stand */ } @@ -5357,6 +5378,135 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged onCheckedChange={(c) => { setChaseNew(!!c); SetChaseNew(!!c).catch(() => {}); }} /> {t('chn.option')} {t('chn.optionHelp')} + {/* The same radius the band-opening watch uses — one feed, one + circle — but reachable from here, because an operator who only + wants the chase list would otherwise have to find it inside a + watch they never switched on. It is the setting that decides + whether this list has anything in it at all: where stations are + far apart, 300 km can hold no receivers whatsoever. */} + {chaseNew && ( +
+ {t('bo.nearKm')} + { + const v = parseInt(e.target.value, 10); + if (!isNaN(v) && v !== bandOpen.near_km) saveBandOpen({ ...bandOpen, near_km: v }); + }} + onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} + /> + km + {t('chn.nearKmHint')} +
+ )} +
+ + {/* PSK Reporter analysis of the station being called. Same service as + the two options above, opposite question: those ask what is being + heard around here, this asks whether ONE station can hear you. */} +
+ + {pskTgt.enabled && ( +
+
+ {t('psk.setScope')} + +
+

{t('psk.setScopeHint')}

+
+ )} +
+ + {/* Contest — how a special-event fleet finds its way onto the watch + list on its own. Two halves, because a fleet has two kinds of + member. */} +
+
{t('wlc.title')}
+
+ {t('wlc.pattern')} + { + const v = e.target.value.toUpperCase().trim(); + if (v !== contestPattern) { setContestPattern(v); void SetWatchlistContestPattern(v); } + }} + onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} /> + {t('wlc.patternHint')} +
+
+ {t('wlc.calls')} +