chore: release v0.27.12

This commit is contained in:
2026-09-05 19:07:21 +02:00
parent f93e1c5898
commit be889681a9
40 changed files with 4971 additions and 453 deletions
+127 -7
View File
@@ -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