chore: release v0.25.9

This commit is contained in:
2026-08-18 05:09:04 +02:00
parent a81125eab1
commit 9599c3e0b9
18 changed files with 962 additions and 67 deletions
+256 -3
View File
@@ -114,6 +114,7 @@ const (
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
keyCATFlexDVKDax = "cat.flex.dvk_dax" // raise the transmit-bar DAX button while a voice message plays
keyCATFlexDecodeSpots = "cat.flex.decode_spots" // push WSJT-X decodes (heard stations) to the panadapter
keyCATFlexDecodeSecs = "cat.flex.decode_secs" // decode spot display duration (seconds) before auto-removal
keyCATPollMs = "cat.poll_ms"
@@ -245,6 +246,9 @@ const (
keyMotorBandFreqs = "motor.band_freqs" // per-band tune frequency: "40m=7100,20m=14150"
keyChaseNewGrids = "cluster.chase_grids" // "1" → persist learnt locators across restarts
keyRowColors = "appearance.row_colors"
keyRotorPresets = "rotator.presets" // quick-turn buttons on the rotor widget
keyCATOffsetHz = "cat.offset_hz" // transverter offset: real frequency rig frequency
keyCATOffsetOn = "cat.offset_on" // "1" → apply the transverter offset
keyMatrixColors = "appearance.matrix_colors" // band/mode matrix palette overrides
keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam)
keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp)
@@ -427,6 +431,7 @@ type CATSettings struct {
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
FlexDVKDax bool `json:"flex_dvk_dax"` // raise transmit DAX while a voice message plays, lower it after
FlexDecodeSpots bool `json:"flex_decode_spots"` // push WSJT-X decodes (heard stations) to the panadapter
FlexDecodeSecs int `json:"flex_decode_secs"` // decode spot display duration (s) before removal (default 120)
XieguPort string `json:"xiegu_port"` // Xiegu CI-V serial port (G90/X6100…)
@@ -460,6 +465,11 @@ type CATSettings struct {
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
// Transverter offset: real frequency rig frequency, in Hz. A 28 MHz IF
// driving a 144 MHz transverter is +116000000. Kept as a separate on/off so
// the number survives switching the transverter out of line for an evening.
OffsetOn bool `json:"offset_on"`
OffsetHz int64 `json:"offset_hz"`
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs
SharePort int `json:"share_port"` // TCP port for the rigctl server (default 4532)
@@ -740,6 +750,7 @@ type App struct {
startupProfile string // --profile <name> from the command line (activate at startup)
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
flexDaxRaised bool // we pressed the Flex transmit DAX button for it; restore when it ends
pttMu sync.Mutex
// confDLMu/confDLCancel cancel the in-flight confirmation download (LoTW/QRZ)
// so closing the QSL Manager — or starting another download — stops the previous
@@ -774,6 +785,7 @@ type App struct {
offlineMode bool // last write failed because the DB was unreachable
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
catFlexDVKDax bool // raise the Flex transmit DAX button around a voice message
catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter
catFlexDecodeSecs int // decode spot display duration (seconds)
liveActMu sync.Mutex // guards the entry-strip activity reported for live status
@@ -1400,6 +1412,10 @@ func (a *App) startup(ctx context.Context) {
if keyed {
go a.dvkUnkeyPTT(gen)
}
// And give the microphone back, if we took it (see raiseFlexDVKDax).
// Off the callback's goroutine: this talks to the radio, and the
// audio manager is reporting a state change, not waiting on us.
go a.lowerFlexDVKDax()
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "audio:status", st)
@@ -7508,7 +7524,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort)
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort)
if err != nil {
return CATSettings{}, err
}
@@ -7519,6 +7535,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
FlexHost: m[keyCATFlexHost],
FlexPort: 4992,
FlexSpots: m[keyCATFlexSpots] == "1",
FlexDVKDax: m[keyCATFlexDVKDax] == "1",
FlexDecodeSpots: m[keyCATFlexDecodeSpots] == "1",
FlexDecodeSecs: 120,
XieguPort: m[keyCATXieguPort],
@@ -7547,6 +7564,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
PTTHotkeyToggle: m[keyCATPttHotkeyToggle] == "1",
PollMs: 250,
DelayMs: 0,
OffsetOn: m[keyCATOffsetOn] == "1",
DigitalDefault: m[keyCATDigitalDefault],
ShareEnabled: m[keyCATShareEnabled] == "1",
SharePort: 4532,
@@ -7556,6 +7574,11 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
out.FlexPort = n
}
// Signed: a transverter can sit either side of its IF (a 144 MHz IF feeding a
// 28 MHz receive converter is a negative offset).
if n, err := strconv.ParseInt(strings.TrimSpace(m[keyCATOffsetHz]), 10, 64); err == nil {
out.OffsetHz = n
}
if n, _ := strconv.Atoi(m[keyCATFlexDecodeSecs]); n >= 10 && n <= 3600 {
out.FlexDecodeSecs = n
}
@@ -7666,6 +7689,14 @@ func (a *App) SaveCATSettings(s CATSettings) error {
if s.FlexSpots {
flexSpots = "1"
}
flexDVKDax := "0"
if s.FlexDVKDax {
flexDVKDax = "1"
}
offsetOn := "0"
if s.OffsetOn {
offsetOn = "1"
}
flexDecodeSpots := "0"
if s.FlexDecodeSpots {
flexDecodeSpots = "1"
@@ -7702,6 +7733,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
keyCATFlexPort: strconv.Itoa(s.FlexPort),
keyCATFlexSpots: flexSpots,
keyCATFlexDVKDax: flexDVKDax,
keyCATFlexDecodeSpots: flexDecodeSpots,
keyCATFlexDecodeSecs: strconv.Itoa(s.FlexDecodeSecs),
keyCATXieguPort: strings.TrimSpace(s.XieguPort),
@@ -7731,6 +7763,8 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATPttHotkeyToggle: b01(s.PTTHotkeyToggle),
keyCATPollMs: strconv.Itoa(s.PollMs),
keyCATDelayMs: strconv.Itoa(s.DelayMs),
keyCATOffsetOn: offsetOn,
keyCATOffsetHz: strconv.FormatInt(s.OffsetHz, 10),
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
keyCATShareEnabled: shareEnabled,
keyCATSharePort: strconv.Itoa(s.SharePort),
@@ -8591,6 +8625,9 @@ func (a *App) QSOAudioPlayOnAir() error {
}
cfg := cfgEarly
// Same as the voice keyer: this goes out over the air, so on a Flex the
// transmit source has to be DAX before the carrier comes up.
a.raiseFlexDVKDax()
if err := a.pttKey(cfg); err != nil {
applog.Printf("qso-rec: PTT on failed before playback: %v", err)
// Keep going — the audio still reaches the rig and the operator may use VOX.
@@ -8609,6 +8646,7 @@ func (a *App) QSOAudioPlayOnAir() error {
if keyed {
go a.dvkUnkeyPTT(gen)
}
a.lowerFlexDVKDax() // nothing went out — give the microphone straight back
return err
}
applog.Printf("qso-rec: playing the recording on the air (%d bytes)", len(pcm))
@@ -9529,6 +9567,10 @@ func (a *App) DVKPlay(slot int) error {
return fmt.Errorf("no recording in slot %d", slot)
}
cfg, _ := a.GetAudioSettings()
// Before PTT: on a Flex the transmit audio comes from the microphone or from
// DAX, so the source has to be right before the carrier goes up — otherwise
// the first syllables are transmitted as silence.
a.raiseFlexDVKDax()
if err := a.pttKey(cfg); err != nil {
applog.Printf("dvk: PTT on failed: %v", err)
// Keep going — the audio still reaches the rig; the user may use VOX.
@@ -9546,11 +9588,77 @@ func (a *App) DVKPlay(slot int) error {
if keyed {
go a.dvkUnkeyPTT(gen)
}
// Nothing is going out, so the microphone must come straight back — the
// status callback that normally does it never fires for a play that
// never started.
a.lowerFlexDVKDax()
return err
}
return nil
}
// raiseFlexDVKDax presses SmartSDR's transmit-bar DAX button before a voice
// message goes out, and remembers whether it was already down.
//
// On a FlexRadio the transmit audio comes from EITHER the microphone or DAX,
// never both. A voice keyer plays into the DAX TX stream, so with the button up
// the message is transmitted as silence; with it left down afterwards the
// operator's own microphone is dead. Pressing it by hand around every message is
// exactly the kind of thing that gets forgotten mid-pileup, hence the option.
//
// The PREVIOUS state is what gets restored, not a blanket "off": an operator
// running WSJT-X has DAX down on purpose, and lowering it for them after a voice
// message would break the digital setup to solve a problem they do not have. In
// the case this option exists for — microphone operating, DAX up — restoring the
// previous state is precisely "give me my microphone back".
//
// Silent no-op when the option is off or the radio is not a Flex: this sits on
// the voice-keyer path, which must not fail because a rig cannot do DAX.
func (a *App) raiseFlexDVKDax() {
if a.cat == nil || !a.catFlexDVKDax {
return
}
var was bool
err := a.cat.FlexDo(func(fc cat.FlexController) error {
was = fc.FlexState().TXDAX
if was {
return nil // already down — nothing to press, nothing to restore
}
return fc.SetTXDAX(true)
})
if err != nil {
applog.Printf("dvk: could not raise the Flex TX DAX button: %v", err)
return
}
if was {
return
}
a.pttMu.Lock()
a.flexDaxRaised = true
a.pttMu.Unlock()
applog.Printf("dvk: Flex TX DAX on for the voice message")
}
// lowerFlexDVKDax puts the transmit-bar DAX button back where it was, and only
// when raiseFlexDVKDax actually moved it. Called where the voice-message PTT is
// released, so the microphone comes back at the moment the message ends.
func (a *App) lowerFlexDVKDax() {
a.pttMu.Lock()
raised := a.flexDaxRaised
a.flexDaxRaised = false
a.pttMu.Unlock()
if !raised || a.cat == nil {
return
}
if err := a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXDAX(false) }); err != nil {
// Worth saying loudly: the microphone stays dead until it is pressed by
// hand, and an operator whose voice is not going out has no other clue.
applog.Printf("dvk: could not lower the Flex TX DAX button — the microphone stays off air until you press it in SmartSDR: %v", err)
return
}
applog.Printf("dvk: Flex TX DAX off — microphone back on air")
}
// dvkUnkeyPTT releases PTT after a short tail so the rig doesn't clip the end
// of the message — but ONLY if no newer key happened since (gen unchanged). A
// rapid replay (or a Test PTT) starts a fresh transmission whose key must not
@@ -11244,6 +11352,12 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
done(matched, total)
return
}
// Second pass, at mode-CLASS granularity: LoTW answers with the mode
// GROUP ("DATA"), never the submode it was given, so an FT4/FT8/FT2
// contact can never match on the exact string and the operator is told
// their own QSO is missing from their log.
classIDs, _ := a.qso.DedupeClassKeyIDs(ctx)
byClass := 0
// Snapshot award-valid confirmations (LoTW + paper QSL — the only two
// that count for ARRL awards) so each incoming one is flagged NEW.
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"})
@@ -11263,8 +11377,20 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
date = time.Now().UTC().Format("20060102")
}
a.enrichContactedFromCty(&q) // country/dxcc/zones from cty.dat
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
if id, found := keyIDs[key]; found {
minute := q.QSODate.UTC().Format("2006-01-02T15:04")
key := qso.DedupeKey(q.Callsign, minute, q.Band, q.Mode)
classKey := qso.DedupeClassKey(q.Callsign, minute, q.Band, q.Mode)
id, found := keyIDs[key]
if !found {
// The mode differs but the station, minute, band and mode class
// agree — the same contact under LoTW's group name. An ambiguous
// class key (id 0) is deliberately NOT taken: see DedupeClassKeyIDs.
if cid, ok := classIDs[classKey]; ok && cid != 0 {
id, found = cid, true
byClass++
}
}
if found {
if e := a.qso.MarkLoTWConfirmed(ctx, id, date); e == nil {
matched++
}
@@ -11274,6 +11400,7 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
q.LOTWRcvdDate = date
if newID, e := a.qso.Add(ctx, q); e == nil {
keyIDs[key] = newID // guard against dup records in the report
classIDs[classKey] = newID
added++
}
} else {
@@ -11324,6 +11451,14 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
} else {
emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total))
}
if byClass > 0 {
// Said out loud rather than folded silently into the total: these
// matched on the mode CLASS, not the mode. LoTW hands back "DATA" for
// every digital submode, so this is the normal case for FT4/FT8 — but
// an operator reconciling a log deserves to know the match was looser
// than call+minute+band+mode.
emit(fmt.Sprintf(" (%d matched on mode class — LoTW reports the group, e.g. DATA, where your log has the exact mode)", byClass))
}
// Surface confirmations with no local match so the user sees WHICH one
// and why (time off by a minute, FT4 logged as MFSK, portable call, or
// never logged). Tick "Add not-found" to import them instead.
@@ -14014,9 +14149,18 @@ func (a *App) reloadCAT() {
}
a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond)
a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond)
// Transverter offset. Zero when switched off — the manager treats "no offset"
// and "disabled" as the same thing, so the number stays in the settings for
// the next time the transverter goes back in line.
if s.OffsetOn {
a.cat.SetFreqOffset(s.OffsetHz)
} else {
a.cat.SetFreqOffset(0)
}
a.catFlexSpots = s.Enabled && ((s.Backend == "flex" && s.FlexSpots) || (s.Backend == "tci" && s.TCISpots))
a.catFlexDecodeSpots = s.Enabled && s.Backend == "flex" && s.FlexDecodeSpots
a.catFlexDecodeSecs = s.FlexDecodeSecs
a.catFlexDVKDax = s.Enabled && s.Backend == "flex" && s.FlexDVKDax
a.reloadCATShare(s)
if !s.Enabled {
a.cat.Stop()
@@ -14896,6 +15040,115 @@ func (a *App) RotatorStop() error {
}
}
// RotorPreset is one quick-turn button on the rotor widget: a short label and
// the azimuth it swings to.
type RotorPreset struct {
Label string `json:"label"`
Azimuth int `json:"azimuth"`
}
// rotorPresetMax bounds both the number of buttons and the label length. The
// widget lays them out in a fixed grid beside the dial, and a label that does not
// fit is a button whose meaning is guessed rather than read.
const (
rotorPresetMax = 8
rotorPresetLabelMax = 6
)
// rotorPresetRegions are the out-of-the-box buttons, each with a representative
// point rather than a ready-made azimuth.
//
// The bearing is COMPUTED from the operator's own square. A bearing is only
// meaningful from somewhere: a table of degrees that suits one station points
// every other one at the wrong continent, and "EU 090" is worse than no default
// at all for a station in Japan. With no square known they come out at 0 and the
// operator fills them in — which is what the editor in Settings is for.
var rotorPresetRegions = []struct {
label string
lat, lon float64
}{
{"EU", 50, 15}, // central Europe
{"NA", 40, -95}, // central United States
{"SA", -15, -60}, // Brazil
{"VK", -25, 135}, // central Australia
{"JA", 36, 138}, // Japan
{"AF", 0, 20}, // central Africa
}
// defaultRotorPresets builds the six regions as seen from the operator's grid.
func (a *App) defaultRotorPresets() []RotorPreset {
out := make([]RotorPreset, 0, len(rotorPresetRegions))
for _, r := range rotorPresetRegions {
az := 0
if a.opSet {
az = int(initialBearingDeg(a.opLat, a.opLon, r.lat, r.lon) + 0.5)
}
out = append(out, RotorPreset{Label: r.label, Azimuth: az % 360})
}
return out
}
// normRotorPresets keeps the list within what the widget can draw and the rotor
// can accept. A blank label drops the button entirely — that is how an operator
// removes one without a delete control.
func normRotorPresets(in []RotorPreset) []RotorPreset {
out := make([]RotorPreset, 0, len(in))
for _, p := range in {
label := strings.TrimSpace(p.Label)
if label == "" {
continue
}
if len(label) > rotorPresetLabelMax {
label = label[:rotorPresetLabelMax]
}
az := p.Azimuth % 360
if az < 0 {
az += 360
}
out = append(out, RotorPreset{Label: label, Azimuth: az})
if len(out) == rotorPresetMax {
break
}
}
return out
}
// GetRotorPresets returns the quick-turn buttons, falling back to the regions
// computed from the operator's square so the widget is useful before anyone
// visits the settings.
func (a *App) GetRotorPresets() []RotorPreset {
if raw := a.settingOr(keyRotorPresets, ""); raw != "" {
var saved []RotorPreset
if err := json.Unmarshal([]byte(raw), &saved); err == nil {
// A saved-but-empty list is a deliberate "no buttons", not a missing
// setting — otherwise clearing them all would resurrect the defaults.
return normRotorPresets(saved)
}
}
return a.defaultRotorPresets()
}
// SaveRotorPresets persists the buttons.
func (a *App) SaveRotorPresets(list []RotorPreset) error {
b, err := json.Marshal(normRotorPresets(list))
if err != nil {
return err
}
a.setSetting(keyRotorPresets, string(b))
return nil
}
// ResetRotorPresets puts back the six regions, recomputed from the operator's
// current square — the way to fix them after moving, or after a first run with
// no grid set.
func (a *App) ResetRotorPresets() []RotorPreset {
def := a.defaultRotorPresets()
if b, err := json.Marshal(def); err == nil {
a.setSetting(keyRotorPresets, string(b))
}
return def
}
// RotatorPark moves the active rotor to its parked position (PstRotator only;
// the 4O3A native and ARCO GS-232 protocols have no park command).
func (a *App) RotatorPark() error {
+14 -2
View File
@@ -5,12 +5,24 @@
"en": [
"The band/mode matrix colours can be chosen in Appearance, starting from the ones your theme already paints. Its legend is translated too.",
"TCI sharing: a refused un-key no longer leaves the rig stuck transmitting, and PTT is dropped if the client dies mid-over.",
"Icom: a frequency or mode change whose acknowledgement is lost is sent again, like PTT — losing one made JTDX drop the radio."
"Icom: a frequency or mode change whose acknowledgement is lost is sent again, like PTT — losing one made JTDX drop the radio.",
"FlexRadio: transmit audio can switch to DAX for a voice message and back afterwards, so your microphone is not left off air.",
"Rotor widget: quick-turn buttons you can name and aim yourself, plus an azimuth box where Enter turns the antenna.",
"A station portable in another entity no longer inherits its home county, state and grid — TI8/W2RE was logged in New York.",
"LoTW confirmations for FT2, FT4 and FT8 match again: LoTW answers “DATA”, and the exact mode never lined up with the log.",
"CAT: a transverter offset, so a 28 MHz IF behind a 144 MHz transverter logs, spots and tunes on the band you are really on.",
"Band map: zoom goes down to 2 px/kHz for a whole band in one screen, and each band keeps the zoom you left it at."
],
"fr": [
"Les couleurs de la matrice bandes/modes se choisissent dans Apparence, à partir de celles du thème. Sa légende est traduite aussi.",
"Partage TCI : un retour en réception refusé ne laisse plus le poste bloqué en émission, et le PTT retombe si le logiciel meurt.",
"Icom : un changement de fréquence ou de mode dont laccusé se perd est renvoyé, comme le PTT — en perdre un faisait lâcher JTDX."
"Icom : un changement de fréquence ou de mode dont laccusé se perd est renvoyé, comme le PTT — en perdre un faisait lâcher JTDX.",
"FlexRadio : laudio d’émission peut basculer sur DAX le temps dun message vocal et revenir après, pour ne pas perdre le micro.",
"Widget rotor : des boutons de rotation rapide que tu nommes et vises toi-même, et un champ azimut où Entrée lance lantenne.",
"Une station portable dans une autre entité nhérite plus de son comté, son état et son locator dorigine : TI8/W2RE sortait à New York.",
"Les confirmations LoTW des QSO FT2, FT4 et FT8 se retrouvent : LoTW répond « DATA », et le mode exact ne correspondait jamais au log.",
"CAT : un décalage transverter, pour quune FI 28 MHz derrière un transverter 144 MHz logue et accorde sur la vraie bande.",
"Carte de bande : le zoom descend à 2 px/kHz pour voir toute la bande dun coup, et chaque bande retient son zoom."
]
},
{
+8 -2
View File
@@ -94,7 +94,7 @@ import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
import { applySpotDisplay, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
import { GetMatrixColors, GetRowColors, GetSpotTTLMinutes, IsNewUSCounty } from '../wailsjs/go/main/App';
import { GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, IsNewUSCounty } from '../wailsjs/go/main/App';
import { applyMatrixColors } from '@/lib/matrixColors';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
import { NetControlPanel } from '@/components/NetControlPanel';
@@ -2018,6 +2018,10 @@ export default function App() {
// settings dialog closes, which is the only place it changes.
const [rowColors, setRowColors] = useState<any>(null);
useEffect(() => { GetRowColors().then(setRowColors).catch(() => {}); }, [showSettings]);
// Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the
// row colours: the settings dialog is the only place they change.
const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]);
useEffect(() => { GetRotorPresets().then((p) => setRotorPresets((p ?? []) as any)).catch(() => {}); }, [showSettings]);
// Band/mode matrix palette overrides (Settings → Appearance). Stamped onto
// <html> rather than held in state: the matrix reads CSS custom properties, so
// nothing re-renders and no component has to be told about the colours. Same
@@ -6310,8 +6314,10 @@ export default function App() {
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
rotator is configured or a DX bearing exists. */}
{showRotor && (rotatorHeading.enabled || dxPath) && (
<div className="w-[186px] shrink-0 min-h-0">
<div className="w-[320px] shrink-0 min-h-0">
<RotorCompass
presets={rotorPresets}
onStop={() => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
bearing={dxPath?.bearingShort ?? null}
headings={beamHeadings}
boomHeading={boomHeading}
+60 -9
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
import { writeUiPref } from '@/lib/uiPref';
// BandMap — vertical spectrum panel inspired by Log4OM.
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
@@ -217,7 +218,37 @@ function statusStyle(s: string): { pill: string; bar: string; line: string; dot:
// Pixels-per-kHz at each zoom step. Base 8 px/kHz means a stacked spot
// every 2.75 kHz fits without anti-overlap kicking in — comfortable for
// most bands. Higher levels are for fine inspection of crowded sub-bands.
const PX_PER_KHZ = [8, 16, 32, 64, 128, 256];
// Zoom steps, px per kHz. 2 and 4 sit below the old floor of 8 so a whole band
// fits without FIT taking the scale away from you: 20 m end to end is 700 px at
// 2 px/kHz, which scrolls in one screen on most displays.
const PX_PER_KHZ = [2, 4, 8, 16, 32, 64, 128, 256];
const DEFAULT_ZOOM_IDX = PX_PER_KHZ.indexOf(32);
// Zoom is remembered PER BAND, in ONE json key rather than one key per band:
// lib/uiPref keeps an explicit list of the preferences that travel with data/,
// and thirteen entries there to say the same thing would be thirteen chances to
// forget one.
const ZOOM_KEY = 'opslog.bandMapZoom';
function readZoomMap(): Record<string, number> {
try {
const m = JSON.parse(localStorage.getItem(ZOOM_KEY) || '{}');
return m && typeof m === 'object' ? m : {};
} catch {
return {};
}
}
function readZoom(band: string): number {
const n = readZoomMap()[band];
return Number.isInteger(n) && n >= 0 && n < PX_PER_KHZ.length ? n : DEFAULT_ZOOM_IDX;
}
function writeZoom(band: string, idx: number): void {
const m = readZoomMap();
m[band] = idx;
writeUiPref(ZOOM_KEY, JSON.stringify(m));
}
const SCALE_W = 56;
const PILL_H = 22; // px — height of each callsign pill
const PILL_GAP = 32; // px between scale border and first pill (room for leader)
@@ -252,7 +283,22 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight]);
const range = BAND_RANGES[band];
const segments = SEGMENT_COLORS[band] ?? [];
const [zoomIdx, setZoomIdx] = useState(2); // default 32 px/kHz
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
// The docked map follows the rig, so a band change must bring up THAT band's
// remembered zoom.
useEffect(() => { setZoomIdx(readZoom(band)); }, [band]);
// Stored from the interaction, not from an effect on zoomIdx: an effect would
// also fire on the band change above, and in the same commit it would still be
// holding the PREVIOUS band's index — writing it over the new band's. Doing it
// where the operator actually turns the wheel keeps the two apart. (Running
// twice under StrictMode is harmless: the same value is stored.)
const changeZoom = useCallback((delta: number) => {
setZoomIdx((z) => {
const n = Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + delta));
if (n !== z) writeZoom(band, n);
return n;
});
}, [band]);
const scrollerRef = useRef<HTMLDivElement | null>(null);
const [containerH, setContainerH] = useState(400);
@@ -421,7 +467,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
if (!range) return;
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
setZoomIdx((z) => Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + (e.deltaY > 0 ? -1 : 1))));
changeZoom(e.deltaY > 0 ? -1 : 1);
}
};
el.addEventListener('wheel', onWheel, { passive: false });
@@ -476,8 +522,10 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
// Tick step (where small marks land) and label step (where the kHz
// number is printed) are decoupled so the scale shows ~10-20 numeric
// labels per viewport regardless of zoom — at base 8 px/kHz we want
// labels every 25 kHz, not every 250.
// labels per viewport regardless of zoom — at 8 px/kHz we want labels
// every 25 kHz, not every 250. The bare values below are the floor, and
// they are what the 2 px/kHz step lands on: a whole band in one screen
// wants a number every 100 kHz, not every 25.
let tickStep = 50;
let labelStep = 100;
if (pxPerKHz >= 4) { tickStep = 25; labelStep = 50; }
@@ -502,14 +550,17 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
return (
<div className="h-full w-full flex flex-col min-h-0 bg-card">
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex flex-nowrap items-center gap-0.5 shrink-0">
<span className="flex-1 min-w-0 truncate">{t('bmp.map')} · {band}</span>
<button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={fitToBand || zoomIdx === 0}
{/* The band alone. "Map · 20M" spent a third of a narrow header saying
what the panel obviously is, and with four band maps side by side it
was four times the same word. */}
<span className="flex-1 min-w-0 truncate">{band}</span>
<button type="button" onClick={() => changeZoom(-1)} disabled={fitToBand || zoomIdx === 0}
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
title={t('bmp.zoomOut')}>
<Minus className="size-3" />
</button>
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span>
<button type="button" onClick={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
<button type="button" onClick={() => changeZoom(1)} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
title={t('bmp.zoomIn')}>
<Plus className="size-3" />
+155 -25
View File
@@ -5,12 +5,13 @@
// when an Ultrabeam is bidirectional, the opposite one when reversed); a small
// red marker on the bezel shows the short-path bearing to the DX. Click the dial
// to turn the antenna there.
import { useMemo } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo';
import { feature } from 'topojson-client';
import landTopo from 'world-atlas/land-110m.json';
import { Compass, X } from 'lucide-react';
import { Compass, X, Play, Square } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
// Decode the coastline outline once (≈110 m simplified land polygons).
const LAND = feature(landTopo as any, (landTopo as any).objects.land);
@@ -29,6 +30,11 @@ interface Props {
onSelectRotor?: (i: number) => void; // switch the active rotor
onGoto?: (az: number) => void; // click-to-turn
onClose?: () => void;
// Quick-turn buttons and the azimuth box, shown only where the caller wants
// them: Station Control draws its own GoTo/Stop around this compass, and two
// sets of the same controls side by side would be nothing but confusing.
presets?: { label: string; azimuth: number }[];
onStop?: () => void;
}
const SIZE = 168;
@@ -41,7 +47,43 @@ function pt(az: number, radius: number): [number, number] {
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
}
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose }: Props) {
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose, presets, onStop }: Props) {
const { t } = useI18n();
// Raw text, not a number: binding the input to a normalised value makes
// Backspace fight the operator on the way from "230" to "23". It is parsed
// when it is sent, and only then.
const [azText, setAzText] = useState('');
const showControls = !!(presets || onStop);
// Which preset was just pressed, so it can light up for a moment.
//
// A rotor takes seconds to start moving and the needle barely twitches at
// first, so without this the only answer to "did that register?" is to press
// it again — which is how an antenna ends up ordered somewhere twice. The
// acknowledgement has to come from the button itself, at once.
const [flashIdx, setFlashIdx] = useState<number | null>(null);
const flashTimer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(flashTimer.current), []);
const pressPreset = (i: number, az: number) => {
if (!onGoto) return;
setFlashIdx(i);
window.clearTimeout(flashTimer.current);
flashTimer.current = window.setTimeout(() => setFlashIdx(null), 450);
onGoto(az);
};
// 0-359 and nothing else. 360 is refused rather than folded to 0 — it is
// almost always a typo for 36 or 306, and a rotor swinging through north on a
// slip of the finger is worth one rejected keypress.
const sendAz = () => {
const s = azText.trim();
if (s === '' || !onGoto) return;
const n = Number(s);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0 || n > 359) return;
onGoto(n);
setAzText('');
};
const cardinals = useMemo(
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
@@ -72,6 +114,35 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
const headLabel = headings.length ? headings[0] : null;
// Short and long path to the DX, in figures.
//
// The bezel already carries the short path as a red marker, but a marker is a
// direction, not a number — the same pair sits in the status bar at 10px and
// operators reported not being able to read it. It lives here because it
// belongs to the compass: every place that draws one gets the readout, instead
// of each caller inventing its own. Clickable when the caller can turn, like
// the status bar's. Built as a value because it is placed in one of two
// columns depending on whether the controls are shown.
const pathReadout = (
<div className="flex gap-1.5 mt-2 font-mono w-full">
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
<button key={lbl} type="button" disabled={az == null || !onGoto}
onClick={() => { if (az != null && onGoto) onGoto(Math.round(az)); }}
title={az == null ? '' : `${lbl} ${Math.round(az)}°`}
className={
'flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors active:scale-95 ' +
(az == null
? 'border-border text-muted-foreground/50 cursor-not-allowed'
: onGoto
? 'border-info-border text-info-muted-foreground hover:bg-info-muted cursor-pointer'
: 'border-border text-muted-foreground cursor-default')
}>
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
</button>
))}
</div>
);
return (
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
{/* Header — matches the WinKeyer / Voice keyer panels. */}
@@ -128,9 +199,13 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
</div>
)}
{/* Dial on the left, controls on the right. The controls column is sized to
fit WITHIN the dial's height: the widget sits in a row whose height is
set by the entry strip, so it may grow sideways but never downwards. */}
<div className="flex items-start gap-2 p-2 min-h-0">
{/* flex-col: the readout goes BELOW the dial. This wrapper was a row, so a
sibling of the <svg> landed beside it. */}
<div className="flex flex-col items-center justify-center p-2 min-h-0">
<div className="flex flex-col items-center justify-center min-h-0 shrink-0">
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
@@ -191,30 +266,85 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
</svg>
{/* Short and long path to the DX, in figures.
The bezel already carries the short path as a red marker, but a
marker is a direction, not a number — the same pair sits in the
status bar at 10px and operators reported not being able to read it.
Here because it belongs to the compass: every place that draws one
gets the readout, instead of each caller inventing its own.
Clickable when the caller can turn, like the status bar's. */}
<div className="flex gap-1.5 mt-2 font-mono w-full">
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
<button key={lbl} type="button" disabled={az == null || !onGoto}
onClick={() => { if (az != null && onGoto) onGoto(Math.round(az)); }}
title={az == null ? '' : `${lbl} ${Math.round(az)}°`}
className={
'flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors ' +
(az == null
? 'border-border text-muted-foreground/50 cursor-not-allowed'
: onGoto
? 'border-info-border text-info-muted-foreground hover:bg-info-muted cursor-pointer'
: 'border-border text-muted-foreground cursor-default')
}>
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
{/* With the controls column present the readout goes at the FOOT OF IT
instead: the dial sets the widget's height, the controls are shorter
than the dial, and that leftover space is exactly the right size for
the pair. Under the dial it would push the whole widget taller. */}
{!showControls && pathReadout}
</div>
{/* Quick turns + free azimuth + Stop. */}
{showControls && (
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
{/* Two columns so six regions fit beside the dial rather than under
it. An operator with fewer keeps the same compact block. */}
{!!presets?.length && (
<div className="grid grid-cols-2 gap-1">
{presets.map((p, i) => (
<button
key={`${p.label}-${i}`}
type="button"
disabled={!onGoto}
onClick={() => pressPreset(i, p.azimuth)}
title={`${p.label}${p.azimuth}°`}
className={cn(
'rounded-md border py-1 text-xs font-semibold truncate transition-all duration-150 active:scale-95',
flashIdx === i
// Lit, and showing the azimuth it just sent: the label alone
// would only say the press landed, not what was ordered.
? 'border-success bg-success text-success-foreground scale-95'
: 'border-border bg-muted/40',
onGoto && flashIdx !== i ? 'hover:bg-muted' : '',
!onGoto ? 'opacity-50 cursor-not-allowed' : '',
)}
>
{flashIdx === i ? `${p.azimuth}°` : p.label}
</button>
))}
</div>
)}
{/* Free azimuth: Enter sends, so the whole thing is type-three-digits
-and-go without reaching for the mouse. */}
<div className="flex gap-1">
<input
type="text"
inputMode="numeric"
value={azText}
onChange={(e) => setAzText(e.target.value.replace(/[^0-9]/g, '').slice(0, 3))}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendAz(); } }}
placeholder={t('rotor.azPh')}
title={t('rotor.azTitle')}
disabled={!onGoto}
className="min-w-0 flex-1 rounded-md border border-border bg-background px-2 py-1 text-xs font-mono tabular-nums text-center disabled:opacity-50"
/>
<button
type="button"
onClick={sendAz}
disabled={!onGoto || azText.trim() === ''}
title={t('rotor.go')}
className="flex items-center gap-1 rounded-md border border-success/60 bg-success-muted px-2 py-1 text-xs font-bold text-success-muted-foreground transition-transform hover:bg-success/25 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Play className="size-3" /> {t('rotor.go')}
</button>
</div>
{onStop && (
<button
type="button"
onClick={onStop}
title={t('rotor.stop')}
className="flex items-center justify-center gap-1.5 rounded-md border border-destructive/60 bg-destructive/15 py-1 text-xs font-bold text-destructive hover:bg-destructive/25"
>
<Square className="size-3 fill-current" /> {t('rotor.stop')}
</button>
)}
{/* mt-auto: the readout sits at the FOOT of the column, level with the
bottom of the dial, instead of floating under the Stop button. */}
<div className="mt-auto">{pathReadout}</div>
</div>
)}
</div>
</section>
);
+81 -2
View File
@@ -11,6 +11,7 @@ import {
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
GetAntGeniusSettings, SaveAntGeniusSettings,
GetTunerGeniusSettings, SaveTunerGeniusSettings,
@@ -1399,16 +1400,25 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [bandDraft, setBandDraft] = useState('');
const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false,
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false,
});
// While true, the next key press is captured as the PTT hotkey.
const [capturingPtt, setCapturingPtt] = useState(false);
const [rotors, setRotors] = useState<RotatorDevice[]>([]);
const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]);
// Whether the presets have actually been READ back yet.
//
// An empty list is a legitimate answer — an operator may want no quick-turn
// buttons at all — so the backend stores it as one. That makes "not loaded
// yet" and "deliberately none" the same value, and a Save landing in the first
// state wiped the buttons for good. Saving is gated on having read them, so
// the only empty list that can ever reach the store is one the operator made.
const [rotorPresetsLoaded, setRotorPresetsLoaded] = useState(false);
const [rotatorTesting, setRotatorTesting] = useState(false);
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
@@ -1825,6 +1835,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
await reloadClusterServers();
setCatCfg(c);
setRotors((r ?? []) as any);
// Loaded HERE, in the loader that runs on mount — not only in the
// event-driven one below. Missing from this one, the state stayed empty
// on a normal open and Save then wrote an empty list over the operator's
// buttons. See rotorPresetsLoaded for the belt to this brace.
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
@@ -1868,6 +1883,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
try { setLookup(await GetLookupSettings() as any); } catch {}
try { setCatCfg(await GetCATSettings() as any); } catch {}
try { setRotors(((await GetRotators()) ?? []) as any); } catch {}
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
@@ -2062,6 +2078,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
await SaveLookupSettings(lookup as any);
await SaveCATSettings(catCfg as any);
await SaveRotators(rotors as any);
// Only once they have been read back — see rotorPresetsLoaded.
if (rotorPresetsLoaded) await SaveRotorPresets(rotorPresets as any);
await SaveUltrabeamSettings(ultrabeam as any);
await SaveAntGeniusSettings(antgenius as any);
await SaveTunerGeniusSettings(tunergenius as any);
@@ -2862,6 +2880,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<span className="text-xs text-muted-foreground">{t('cat.flexDecodeSecsHint')}</span>
</div>
)}
<label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer">
<Checkbox className="mt-0.5" checked={!!catCfg.flex_dvk_dax} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_dvk_dax: !!c }))} />
<span>{t('cat.flexDvkDax')} <span className="text-xs text-muted-foreground">{t('cat.flexDvkDaxHint')}</span></span>
</label>
</>
)}
{catCfg.backend === 'xiegu' && (
@@ -3142,6 +3164,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
</>
)}
{/* Transverter offset. OUTSIDE the per-backend blocks on purpose: a
transverter hangs off the IF of whatever radio you own, and the
offset is applied in the CAT manager, above every backend. Tucked
under the OmniRig/Icom section it would have been invisible to the
Yaesu, Kenwood, Xiegu, Flex and TCI operators who need it just as
much. */}
<label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer">
<Checkbox className="mt-0.5" checked={!!catCfg.offset_on}
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, offset_on: !!c }))} />
<span>{t('cat.offsetOn')} <span className="text-xs text-muted-foreground">{t('cat.offsetHint')}</span></span>
</label>
{catCfg.offset_on && (
<div className="col-span-2 flex items-center gap-2 pl-6">
<Label className="text-sm">{t('cat.offsetMhz')}</Label>
<Input
type="number" step="0.000001" className="w-40"
value={(catCfg.offset_hz ?? 0) / 1e6}
onChange={(e) => setCatCfg((s) => ({ ...s, offset_hz: Math.round((parseFloat(e.target.value) || 0) * 1e6) }))}
/>
<span className="text-xs text-muted-foreground">{t('cat.offsetExample')}</span>
</div>
)}
<div className="space-y-1 col-span-2">
<Label>{t('cat.digitalDefault')}</Label>
<Select
@@ -4135,6 +4179,41 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
)}
</div>
)}
{/* Quick-turn buttons for the rotor widget. Their azimuths start out
computed from the station square, so they are right for THIS QTH
rather than copied from someone else's. */}
<div className="border-t border-border/60 pt-3 space-y-2">
<div className="text-sm font-semibold">{t('rot.presets')}</div>
<p className="text-xs text-muted-foreground">{t('rot.presetsHint')}</p>
<div className="space-y-1.5">
{rotorPresets.map((p, i) => (
<div key={i} className="flex items-center gap-2">
<Input className="w-24" maxLength={6} value={p.label}
placeholder={t('rot.presetName')}
onChange={(e) => setRotorPresets((list) => list.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)))} />
<Input className="w-24" type="number" min={0} max={359} value={p.azimuth}
onChange={(e) => setRotorPresets((list) => list.map((x, j) => (j === i ? { ...x, azimuth: Math.max(0, Math.min(359, parseInt(e.target.value, 10) || 0)) } : x)))} />
<span className="text-xs text-muted-foreground">°</span>
<Button variant="ghost" size="sm" title={t('rot.remove')}
onClick={() => setRotorPresets((list) => list.filter((_, j) => j !== i))}>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
</div>
<div className="flex items-center gap-2">
{rotorPresets.length < 8 && (
<Button variant="outline" size="sm" onClick={() => setRotorPresets((l) => [...l, { label: '', azimuth: 0 }])}>
<Plus className="size-3.5 mr-1" /> {t('rot.presetAdd')}
</Button>
)}
<Button variant="outline" size="sm"
onClick={() => { ResetRotorPresets().then((p) => setRotorPresets((p ?? []) as any)).catch((e) => setErr(String(e?.message ?? e))); }}>
{t('rot.presetsReset')}
</Button>
</div>
</div>
{rotatorTest && (
<div className={cn(
'text-xs rounded-md p-2.5 border',
+18 -2
View File
@@ -300,6 +300,11 @@ const en: Dict = {
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
'tg2.hint': 'OpsLog talks to the 4O3A Tuner Genius XL over TCP (port fixed at 9010), so only the device IP is needed. A docked widget then shows SWR and forward power and offers Tune, Bypass and Operate/Standby. Control it directly (not through the radio) so OpsLog uses just one of the box\'s connection slots.', 'tg2.enable': 'Enable Tuner Genius control', 'tg2.portHint': 'The TCP port is fixed at 9010 on the device.', 'tg2.password': 'Remote code', 'tg2.passwordPh': 'blank on LAN', 'tg2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AUTH" and rejects commands until you log in. Leave blank on the local network.',
'rot.spidHint': 'Native SPID protocol over the controllers COM port — no PstRotator needed. Pick the dialect above: Rot2Prog answers with azimuth and elevation at 600 baud, Rot1Prog with azimuth only at 1200.', 'rot.spidModel': 'SPID protocol',
'rot.presets': 'Quick-turn buttons', 'rot.presetName': 'Name', 'rot.presetAdd': 'Add button',
'rot.presetsHint': 'Shown beside the rotor dial. The azimuths started out computed from your station square, so they point at those regions FROM HERE — change either field, or clear a name to drop the button.',
'rot.presetsReset': 'Recompute from my square',
'rotor.go': 'GO', 'rotor.stop': 'STOP', 'rotor.azPh': '0 359',
'rotor.azTitle': 'Azimuth 0-359, Enter to turn the rotor',
'rot.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.testOkRead': 'Connected — the controller answered with its heading. Nothing was moved: this test only reads the position.', 'rot.type': 'Rotator type', 'rot.rotatorNum': 'Rotator #', 'rot.dual': 'Two rotors', 'rot.rotor1': 'Rotor 1', 'rot.rotor2': 'Rotor 2', 'rot.name': 'Name', 'rot.antenna': 'Antenna', 'rot.antenna1': 'Rotor 1 antenna', 'rot.antenna2': 'Rotor 2 antenna', 'rot.name1': 'Rotor 1 name', 'rot.name2': 'Rotor 2 name', 'rot.antStd': 'Standard antenna', 'rot.antMotor': 'Motorized (Ultrabeam/SteppIR)', 'rot.add': 'Add rotor', 'rot.remove': 'Remove rotor', 'rot.none': 'No rotor configured. Click “Add rotor” to add one.', 'rot.rgDual': 'This Rotator Genius drives two rotors (adds a second one)', 'rot.testRotor1': 'Test rotor 1', 'rot.testRotor2': 'Test rotor 2', 'rot.dualHint': 'Two independent rotors of any type — for example an ARCO and a Rotator Genius side by side. Configure each below; OpsLog shows a Rotor 1/2 toggle on the compass to pick which one it turns and displays. Set each rotor to "Motorized" so the boom/pattern paths (reverse, bidirectional) show only for the one that carries the Ultrabeam/SteppIR. (For a single Rotator Genius driving two rotors, set both to Rotator Genius with the same host and rotator # 1 and 2.)', 'rot.rgHint': 'Talks directly to a 4O3A Rotator Genius over TCP (default port 9006) — no PstRotator needed. Rotator # selects which of the two rotators to drive.', 'rot.arcoHint': "Talks directly to any controller set to Yaesu GS-232A — no PstRotator needed. microHAM ARCO: set Config → LAN → CONTROL PROTOCOL (or USB CONTROL PROTOCOL) to 'Yaesu GS-232A'; on USB the baud rate does not matter. ERC (Easy Rotor Control, incl. ERC Mini): its emulation MUST be set to GS-232 — an ERC left on Hy-Gain DCU-1 speaks a different command set and will not answer — then pick its COM port and the same baud rate as in the ERC configuration.", 'rot.dcu1Hint': "Speaks the Hy-Gain DCU-1 command set (RotorCard DXA, Idiom Press Rotor-EZ, Green Heron). Connect over the controller's COM port (a DCU-1 is 4800 baud; RotorCard/Green Heron may differ — match the controller) or over TCP through a serial-over-IP bridge. Azimuth only, no elevation. New backend — please report if your controller needs a different command or baud.", 'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 12 min delay so a mis-logged QSO can still be fixed first).',
'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 1354 MHz (20 m6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.motorBands': 'Covered bands', 'hw.motorStep': 'Re-tune step', 'hw.motorBandFreqHint': 'Frequency each band button tunes the antenna to (kHz). Leave empty for the default shown.', 'hw.motorFollow': 'Follow rig frequency (auto-tune the antenna)', 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
@@ -310,8 +315,11 @@ const en: Dict = {
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
'cat.icomNetAudioHint': 'Play the rigs received audio through your Listening device (Settings → Audio) over the 50003 stream. Experimental — the audio framing is pending on-rig verification; leave off if control misbehaves.',
'cat.omnirigRig': 'OmniRig rig slot', 'cat.omnirigVfo': 'VFO to read', 'cat.omnirigVfoAuto': 'As reported by the rig file', 'cat.omnirigVfoA': 'Always VFO A (main)', 'cat.omnirigVfoB': 'Always VFO B (sub)', 'cat.omnirigVfoHint': 'OmniRig reports the active VFO from the rig file, and some files get it wrong — the frequency then follows the other VFO and appears frozen. Force one here if that happens.', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)", 'cat.flexDecodeSpots': 'Show WSJT-X decodes on the panadapter', 'cat.flexDecodeSpotsHint': '(heard FT8/FT4 stations from your WSJT-X/JTDX UDP feed, one spot per call)', 'cat.flexDecodeSecs': 'Display for', 'cat.flexDecodeSecsHint': 'seconds before a station is removed',
'cat.flexDvkDax': 'Switch transmit audio to DAX for voice messages',
'cat.flexDvkDaxHint': '(pressed while a DVK message or a QSO recording goes out, and put back afterwards so your microphone works again)',
'cat.icomPort': 'Icom CI-V port', 'cat.selectCom': 'Select COM port', 'cat.noPorts': 'No ports found', 'cat.baud': 'Baud rate', 'cat.icomModel': 'Rig model', 'cat.icomModelOther': 'Other (custom address)', 'cat.civAddr': 'CI-V address (hex)', 'cat.civHint': 'Pick your model to set the CI-V address automatically (or choose "Other" and type it). Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.',
'cat.tciHost': 'TCI host', 'cat.tciHint': 'Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.', 'cat.tciSpots': 'Show cluster spots on the panorama', 'cat.tciSpotsHint': "(spots from OpsLog's DX cluster appear on the SDR panadapter)",
'cat.offsetOn': 'Transverter offset', 'cat.offsetHint': '(the rig shows its IF; OpsLog logs, spots and tunes on the real band)', 'cat.offsetMhz': 'Offset (MHz)', 'cat.offsetExample': 'e.g. 116 for a 28 MHz IF on 144 MHz — negative is allowed',
'cat.pollMs': 'Poll interval (ms)', 'cat.delayMs': 'CAT delay (ms)', 'cat.digitalDefault': 'Default digital mode (when rig reports DIG)', 'cat.modeBeforeFreq': 'Set mode before frequency', 'cat.modeBeforeFreqHint': '(older rigs that drop the mode after a band change)',
'cat.omnirigHint': 'Configure your rig (COM port, baud rate, model) in OmniRig\'s own settings GUI first. OpsLog will read whichever Rig slot you select here. Set CAT delay above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu). OmniRig only reports generic "DIG" for digital modes — Default digital mode is the specific mode OpsLog will surface (and log).',
'cat.rotatorOk': "Packet sent — antenna should swing to 0° (north). If it didn't, check PstRotator host/port and that PstRotator's UDP listener is enabled.",
@@ -362,7 +370,7 @@ const en: Dict = {
'chp.lotwRcvd': 'LoTW rcvd', 'chp.bureauRcvd': 'Bureau rcvd', 'chp.olderQsos': '+ {n} older QSOs',
'bmp.statusNew': 'NEW DXCC (entity never worked)', 'bmp.statusNewBand': 'NEW BAND (entity not worked on this band)', 'bmp.statusNewSlot': 'NEW SLOT (mode not worked on this band)', 'bmp.statusNewCall': 'NEW CALL (this callsign never worked on this band and mode)', 'bmp.statusNewMode': 'NEW MODE (mode never worked for this entity)',
'bmp.statusWorked': 'Worked (this band + mode already in log)', 'bmp.statusUnresolved': 'Entity not resolved', 'bmp.bandMap': 'Band map', 'bmp.notConfigured': 'Not configured for {band}.',
'bmp.map': 'Map', 'bmp.zoomOut': 'Zoom out', 'bmp.zoomIn': 'Zoom in', 'bmp.scrollToRig': 'Scroll to current rig frequency', 'bmp.moveLeft': 'Move band map to the left', 'bmp.moveRight': 'Move band map to the right', 'bmp.hide': 'Hide band map',
'bmp.zoomOut': 'Zoom out', 'bmp.zoomIn': 'Zoom in', 'bmp.scrollToRig': 'Scroll to current rig frequency', 'bmp.moveLeft': 'Move band map to the left', 'bmp.moveRight': 'Move band map to the right', 'bmp.hide': 'Hide band map',
'bmp.bandsLabel': 'Bands:', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Hide FTx', 'bmp.hideFtTitle': 'Hide all digital (FT8/FT4/JS8/…) spots on every band map', 'bmp.fitBand': 'Fit to band', 'bmp.widthTip': 'Drag to resize — double-click to reset', 'bmp.fitTitle': 'Size each band map to show the whole band edge-to-edge',
'bmp.legendNewDxcc': 'New DXCC', 'bmp.legendNewBand': 'New band', 'bmp.legendNewSlot': 'New slot (mode)', 'bmp.openUnusual': 'unusual for the season', 'bmp.legendWorked': 'Worked', 'bmp.legendNewPota': 'New POTA', 'bmp.legendNewCounty': 'New county', 'bmp.legendWorkedCall': 'Callsign already worked', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Data', 'bmp.legendPhone': 'Phone', 'bmp.footerHint': 'scroll · ctrl+wheel = zoom · ◎ = jump to rig', 'bmp.spotsHidden': '{n} FT8/FT4 spots hidden — top {max} kept (CW/SSB all shown)',
'frm.welcome': 'Welcome to OpsLog', 'frm.intro': 'Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).',
@@ -740,6 +748,11 @@ const fr: Dict = {
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
'tg2.hint': "OpsLog dialogue avec le 4O3A Tuner Genius XL en TCP (port fixé à 9010), seule l'IP de l'appareil est nécessaire. Un widget ancré affiche le ROS et la puissance directe et propose Accord, Bypass et Operate/Standby. Pilotage direct (pas via la radio) pour n'utiliser qu'une des connexions de la boîte.", 'tg2.enable': "Activer le contrôle du Tuner Genius", 'tg2.portHint': "Le port TCP est fixé à 9010 sur l'appareil.", 'tg2.password': 'Code distant', 'tg2.passwordPh': 'vide en LAN', 'tg2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
'rot.spidHint': 'Protocole SPID natif sur le port COM du contrôleur — sans PstRotator. Choisissez le dialecte ci-dessus : Rot2Prog répond azimut et élévation à 600 bauds, Rot1Prog azimut seul à 1200.', 'rot.spidModel': 'Protocole SPID',
'rot.presets': 'Boutons de rotation rapide', 'rot.presetName': 'Nom', 'rot.presetAdd': 'Ajouter un bouton',
'rot.presetsHint': "Affichés à côté du cadran du rotor. Les azimuts ont été calculés depuis ton carré : ils visent ces régions DEPUIS ICI. Modifie l'un ou l'autre champ, ou vide le nom pour retirer le bouton.",
'rot.presetsReset': 'Recalculer depuis mon carré',
'rotor.go': 'GO', 'rotor.stop': 'STOP', 'rotor.azPh': '0 359',
'rotor.azTitle': 'Azimut 0-359, Entrée pour lancer le rotor',
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.testOkRead': 'Connecté — le contrôleur a répondu avec son azimut. Rien na bougé : ce test ne fait que lire la position.', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.dual': 'Deux rotors', 'rot.rotor1': 'Rotor 1', 'rot.rotor2': 'Rotor 2', 'rot.name': 'Nom', 'rot.antenna': 'Antenne', 'rot.antenna1': 'Antenne rotor 1', 'rot.antenna2': 'Antenne rotor 2', 'rot.name1': 'Nom du rotor 1', 'rot.name2': 'Nom du rotor 2', 'rot.antStd': 'Antenne standard', 'rot.antMotor': 'Motorisée (Ultrabeam/SteppIR)', 'rot.add': 'Ajouter un rotor', 'rot.remove': 'Supprimer le rotor', 'rot.none': 'Aucun rotor configuré. Cliquez sur « Ajouter un rotor ».', 'rot.rgDual': 'Ce Rotator Genius pilote deux rotors (en ajoute un second)', 'rot.testRotor1': 'Tester rotor 1', 'rot.testRotor2': 'Tester rotor 2', 'rot.dualHint': 'Deux rotors indépendants de n\'importe quel type — par exemple un ARCO et un Rotator Genius côte à côte. Configurez chacun ci-dessous ; OpsLog affiche un sélecteur Rotor 1/2 sur le compas pour choisir celui qu\'il tourne et affiche. Réglez chaque rotor sur « Motorisée » pour que les tracés de boom/diagramme (inverse, bidirectionnel) n\'apparaissent que pour celui qui porte l\'Ultrabeam/SteppIR. (Pour un seul Rotator Genius pilotant deux rotors, réglez les deux sur Rotator Genius avec le même hôte et le rotator n° 1 et 2.)', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à tout contrôleur réglé sur Yaesu GS-232A — sans PstRotator. microHAM ARCO : régler Config → LAN → CONTROL PROTOCOL (ou USB CONTROL PROTOCOL) sur « Yaesu GS-232A » ; en USB la vitesse est sans importance. ERC (Easy Rotor Control, ERC Mini compris) : son émulation DOIT être réglée sur GS-232 — un ERC laissé en Hy-Gain DCU-1 parle un autre jeu de commandes et ne répondra pas — puis choisir son port COM et la même vitesse que dans la configuration de l'ERC.", 'rot.dcu1Hint': "Parle le jeu de commandes Hy-Gain DCU-1 (RotorCard DXA, Idiom Press Rotor-EZ, Green Heron). Connexion via le port COM du contrôleur (un DCU-1 est à 4800 bauds ; RotorCard/Green Heron peuvent différer — reprendre la vitesse du contrôleur) ou en TCP via un pont série-sur-IP. Azimut uniquement, pas d'élévation. Nouveau pilote — signalez si votre contrôleur nécessite une autre commande ou vitesse.", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 12 min pour corriger un QSO mal saisi avant).",
'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.motorBands': 'Bandes couvertes', 'hw.motorStep': 'Pas de réaccord', 'hw.motorBandFreqHint': "Fréquence sur laquelle chaque bouton de bande accorde l'antenne (kHz). Laisser vide pour le défaut affiché.", 'hw.motorFollow': "Suivre la fréquence du rig (accord auto de l'antenne)", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
@@ -749,8 +762,11 @@ const fr: Dict = {
'cat.icomNetAudio': 'Diffuser laudio RX par le réseau (expérimental)',
'cat.icomNetAudioHint': 'Écoute laudio reçu du poste sur ton périphérique d’écoute (Réglages → Audio) via le flux 50003. Expérimental — le format audio reste à vérifier sur le poste ; laisse désactivé si le contrôle se comporte mal.',
'cat.omnirigRig': 'Slot OmniRig', 'cat.omnirigVfo': 'VFO à lire', 'cat.omnirigVfoAuto': 'Selon le fichier radio', 'cat.omnirigVfoA': 'Toujours le VFO A (principal)', 'cat.omnirigVfoB': 'Toujours le VFO B (secondaire)', 'cat.omnirigVfoHint': "OmniRig indique le VFO actif d'après le fichier radio, et certains fichiers se trompent — la fréquence suit alors l'autre VFO et paraît figée. Forcez-en un ici le cas échéant.", 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)", 'cat.flexDecodeSpots': 'Afficher les décodes WSJT-X sur le panadapter', 'cat.flexDecodeSpotsHint': '(stations FT8/FT4 entendues via ton flux UDP WSJT-X/JTDX, un spot par station)', 'cat.flexDecodeSecs': 'Affichage pendant', 'cat.flexDecodeSecsHint': 'secondes avant retrait d\'une station',
'cat.flexDvkDax': "Basculer l'audio d'émission sur DAX pour les messages vocaux",
'cat.flexDvkDaxHint': "(enfoncé le temps d'un message DVK ou d'un enregistrement de QSO, puis remis comme avant pour retrouver ton micro)",
'cat.icomPort': 'Port CI-V Icom', 'cat.selectCom': 'Choisir un port COM', 'cat.noPorts': 'Aucun port trouvé', 'cat.baud': 'Débit (baud)', 'cat.icomModel': 'Modèle de poste', 'cat.icomModelOther': 'Autre (adresse perso)', 'cat.civAddr': 'Adresse CI-V (hex)', 'cat.civHint': 'Choisis ton modèle pour fixer ladresse CI-V automatiquement (ou « Autre » et saisis-la). Mets « CI-V USB Echo Back » sur OFF et fais correspondre le débit CI-V sur le poste.',
'cat.tciHost': 'Hôte TCI', 'cat.tciHint': 'Active le serveur TCI dans ExpertSDR2/EESDR (Options → TCI). Port par défaut 40001. Utilise 127.0.0.1 si OpsLog tourne sur le même PC.', 'cat.tciSpots': 'Afficher les spots cluster sur le panorama', 'cat.tciSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur le panadapter SDR)",
'cat.offsetOn': 'Décalage transverter', 'cat.offsetHint': "(le poste affiche sa FI ; OpsLog logue, spotte et accorde sur la vraie bande)", 'cat.offsetMhz': 'Décalage (MHz)', 'cat.offsetExample': 'p. ex. 116 pour une FI 28 MHz sur 144 MHz — négatif accepté',
'cat.pollMs': 'Intervalle de poll (ms)', 'cat.delayMs': 'Délai CAT (ms)', 'cat.digitalDefault': 'Mode numérique par défaut (quand le poste indique DIG)', 'cat.modeBeforeFreq': 'Régler le mode avant la fréquence', 'cat.modeBeforeFreqHint': '(anciens postes qui perdent le mode après un changement de bande)',
'cat.omnirigHint': "Configure d'abord ton poste (port COM, débit, modèle) dans l'interface de réglages d'OmniRig. OpsLog lira le slot Rig que tu choisis ici. Mets le délai CAT au-dessus de 0 si ton poste perd des commandes envoyées coup sur coup (certains anciens Kenwood/Yaesu). OmniRig ne rapporte qu'un « DIG » générique pour les modes numériques — le mode numérique par défaut est le mode précis qu'OpsLog affichera (et loggera).",
'cat.rotatorOk': "Paquet envoyé — l'antenne devrait tourner vers 0° (nord). Sinon, vérifie l'hôte/port PstRotator et que l'écouteur UDP de PstRotator est activé.",
@@ -796,7 +812,7 @@ const fr: Dict = {
'chp.lotwRcvd': 'LoTW reçue', 'chp.bureauRcvd': 'Bureau reçue', 'chp.olderQsos': '+ {n} QSO plus anciens',
'bmp.statusNew': 'NOUVEAU DXCC (entité jamais contactée)', 'bmp.statusNewBand': 'NOUVELLE BANDE (entité non contactée sur cette bande)', 'bmp.statusNewSlot': 'NOUVEAU MODE (mode non contacté sur cette bande)', 'bmp.statusNewCall': "CALL NEUF (indicatif jamais contacté sur cette bande et ce mode)", 'bmp.statusNewMode': 'NOUVEAU MODE (mode jamais contacté pour cette entité)',
'bmp.statusWorked': 'Contacté (cette bande + mode déjà au log)', 'bmp.statusUnresolved': 'Entité non résolue', 'bmp.bandMap': 'Carte de bande', 'bmp.notConfigured': 'Non configurée pour {band}.',
'bmp.map': 'Carte', 'bmp.zoomOut': 'Dézoomer', 'bmp.zoomIn': 'Zoomer', 'bmp.scrollToRig': 'Aller à la fréquence actuelle du poste', 'bmp.moveLeft': 'Déplacer la carte de bande à gauche', 'bmp.moveRight': 'Déplacer la carte de bande à droite', 'bmp.hide': 'Masquer la carte de bande',
'bmp.zoomOut': 'Dézoomer', 'bmp.zoomIn': 'Zoomer', 'bmp.scrollToRig': 'Aller à la fréquence actuelle du poste', 'bmp.moveLeft': 'Déplacer la carte de bande à gauche', 'bmp.moveRight': 'Déplacer la carte de bande à droite', 'bmp.hide': 'Masquer la carte de bande',
'bmp.bandsLabel': 'Bandes :', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Masquer FTx', 'bmp.hideFtTitle': 'Masquer tous les spots numériques (FT8/FT4/JS8/…) sur toutes les cartes', 'bmp.fitBand': 'Ajuster à la bande', 'bmp.widthTip': 'Glisser pour redimensionner — double-clic pour réinitialiser', 'bmp.fitTitle': 'Dimensionner chaque carte pour afficher toute la bande',
'bmp.legendNewDxcc': 'Nouveau DXCC', 'bmp.legendNewBand': 'Nouvelle bande', 'bmp.legendNewSlot': 'Nouveau mode', 'bmp.openUnusual': 'inhabituel pour la saison', 'bmp.legendWorked': 'Contacté', 'bmp.legendNewPota': 'Nouveau POTA', 'bmp.legendNewCounty': 'Nouveau comté', 'bmp.legendWorkedCall': 'Indicatif déjà contacté', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Numérique', 'bmp.legendPhone': 'Phonie', 'bmp.footerHint': 'défiler · ctrl+molette = zoom · ◎ = aller au poste', 'bmp.spotsHidden': '{n} spots FT8/FT4 masqués — {max} meilleurs conservés (CW/SSB tous affichés)',
'frm.welcome': 'Bienvenue dans OpsLog', 'frm.intro': 'Configure ta station pour commencer à logger. Ces champs sont inscrits sur chaque QSO et peuvent être modifiés plus tard dans Préférences → Informations station (et par profil).',
+1
View File
@@ -44,6 +44,7 @@ const PORTABLE_KEYS = [
'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode
'opslog.bandMapWidth', // docked band map: column width (px)
'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
'opslog.bandMapZoom', // band map zoom (px/kHz step) remembered per band, as one {band: index} map
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.25.8';
export const APP_VERSION = '0.25.9';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+6
View File
@@ -513,6 +513,8 @@ export function GetRotatorHeading():Promise<main.RotatorHeading>;
export function GetRotators():Promise<Array<main.RotatorDevice>>;
export function GetRotorPresets():Promise<Array<main.RotorPreset>>;
export function GetRowColors():Promise<main.RowColorSettings>;
export function GetSPEStatus():Promise<spe.Status>;
@@ -883,6 +885,8 @@ export function ResetAwardDefs():Promise<Array<award.Def>>;
export function ResetDatabaseToDefault():Promise<void>;
export function ResetRotorPresets():Promise<Array<main.RotorPreset>>;
export function RestartApp():Promise<void>;
export function RestartQSORecorder():Promise<void>;
@@ -973,6 +977,8 @@ export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
export function SaveRotorPresets(arg1:Array<main.RotorPreset>):Promise<void>;
export function SaveRowColors(arg1:main.RowColorSettings):Promise<void>;
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
+12
View File
@@ -966,6 +966,10 @@ export function GetRotators() {
return window['go']['main']['App']['GetRotators']();
}
export function GetRotorPresets() {
return window['go']['main']['App']['GetRotorPresets']();
}
export function GetRowColors() {
return window['go']['main']['App']['GetRowColors']();
}
@@ -1706,6 +1710,10 @@ export function ResetDatabaseToDefault() {
return window['go']['main']['App']['ResetDatabaseToDefault']();
}
export function ResetRotorPresets() {
return window['go']['main']['App']['ResetRotorPresets']();
}
export function RestartApp() {
return window['go']['main']['App']['RestartApp']();
}
@@ -1886,6 +1894,10 @@ export function SaveRotators(arg1) {
return window['go']['main']['App']['SaveRotators'](arg1);
}
export function SaveRotorPresets(arg1) {
return window['go']['main']['App']['SaveRotorPresets'](arg1);
}
export function SaveRowColors(arg1) {
return window['go']['main']['App']['SaveRowColors'](arg1);
}
+20
View File
@@ -2021,6 +2021,7 @@ export namespace main {
flex_host: string;
flex_port: number;
flex_spots: boolean;
flex_dvk_dax: boolean;
flex_decode_spots: boolean;
flex_decode_secs: number;
xiegu_port: string;
@@ -2047,6 +2048,8 @@ export namespace main {
tci_spots: boolean;
poll_ms: number;
delay_ms: number;
offset_on: boolean;
offset_hz: number;
digital_default: string;
share_enabled: boolean;
share_port: number;
@@ -2069,6 +2072,7 @@ export namespace main {
this.flex_host = source["flex_host"];
this.flex_port = source["flex_port"];
this.flex_spots = source["flex_spots"];
this.flex_dvk_dax = source["flex_dvk_dax"];
this.flex_decode_spots = source["flex_decode_spots"];
this.flex_decode_secs = source["flex_decode_secs"];
this.xiegu_port = source["xiegu_port"];
@@ -2095,6 +2099,8 @@ export namespace main {
this.tci_spots = source["tci_spots"];
this.poll_ms = source["poll_ms"];
this.delay_ms = source["delay_ms"];
this.offset_on = source["offset_on"];
this.offset_hz = source["offset_hz"];
this.digital_default = source["digital_default"];
this.share_enabled = source["share_enabled"];
this.share_port = source["share_port"];
@@ -3206,6 +3212,20 @@ export namespace main {
this.motorized = source["motorized"];
}
}
export class RotorPreset {
label: string;
azimuth: number;
static createFrom(source: any = {}) {
return new RotorPreset(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.label = source["label"];
this.azimuth = source["azimuth"];
}
}
export class RowColorRule {
id: string;
color: string;
+52
View File
@@ -79,6 +79,9 @@ type Manager struct {
pollEvery time.Duration
cmdDelay time.Duration // pause after each command (some rigs need it)
// freqOffset is the transverter offset in Hz — see SetFreqOffset. Added to
// what the rig reports, taken off what it is told.
freqOffset int64
}
func NewManager(emit func(RigState)) *Manager {
@@ -185,8 +188,39 @@ func (m *Manager) stopLocked() {
}
}
// SetFreqOffset sets the transverter offset: the number of hertz between what
// the RIG is tuned to and where the station is actually on the air.
//
// A 28 MHz IF driving a 144 MHz transverter is an offset of +116 MHz. Everything
// above this layer — the entry form, the band, the log, the cluster, the shared
// CAT servers — then works in real frequencies, and the rig keeps seeing its own.
//
// Zero disables it, which is why it is a plain number and not a flag plus a
// number: "enabled with an offset of nothing" and "disabled" are the same thing
// to everyone downstream.
func (m *Manager) SetFreqOffset(hz int64) {
m.mu.Lock()
m.freqOffset = hz
m.mu.Unlock()
}
// freqOffsetHz reads the offset.
func (m *Manager) freqOffsetHz() int64 {
m.mu.RLock()
defer m.mu.RUnlock()
return m.freqOffset
}
// SetFrequency dispatches a SetFreq call to the CAT goroutine.
//
// The caller speaks in REAL frequencies (a 2 m spot is 144.300), so the offset
// comes back off before the rig hears it. Without this the offset would be a
// display trick: the readout would say 144 and every spot click, band change and
// memory recall would send the rig somewhere 116 MHz away.
func (m *Manager) SetFrequency(hz int64) error {
if off := m.freqOffsetHz(); off != 0 && hz > off {
hz -= off
}
return m.exec(func(b Backend) error { return b.SetFrequency(hz) })
}
@@ -215,6 +249,10 @@ type splitSetter interface {
// band when it was transmitting on the DX's own frequency. A refusal WSJT-X can
// report is worth far more than a success it cannot check.
func (m *Manager) SetSplit(on bool, txHz int64) error {
// Real frequency in, IF frequency out — same as SetFrequency.
if off := m.freqOffsetHz(); off != 0 && txHz > off {
txHz -= off
}
return m.exec(func(b Backend) error {
s, ok := b.(splitSetter)
if !ok {
@@ -744,6 +782,20 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
ns.Enabled = true
ns.Backend = b.Name()
ns.UpdatedAt = time.Now()
// Transverter offset: the rig reports its IF, the operator is on the
// real band. Applied BEFORE the band is worked out, or a 28 MHz IF
// behind a 2 m transverter would log every contact on 10 m — and the
// band the backend may already have filled in is the IF's, so it is
// recomputed rather than trusted.
if off := m.freqOffsetHz(); off != 0 {
if ns.FreqHz != 0 {
ns.FreqHz += off
ns.Band = ""
}
if ns.RxFreqHz != 0 {
ns.RxFreqHz += off
}
}
if ns.FreqHz != 0 && ns.Band == "" {
ns.Band = BandFromHz(ns.FreqHz)
}
+39 -4
View File
@@ -195,10 +195,7 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
// operator was looking the call up for, and it came back empty while
// the same lookup without the suffix answered perfectly.
if !saysNothingAboutLocation(call) {
r.Country, r.Continent = "", ""
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
r.Lat, r.Lon = 0, 0
r.Grid, r.State, r.County = "", "", ""
clearHomeLocation(&r)
}
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
normalizeNames(&r)
@@ -378,6 +375,17 @@ func titleCase(s string) string {
// the DXCC number since QRZ's value is wrong and we don't have an entity
// → DXCC# table yet.
// Returns true if any field was filled.
// clearHomeLocation drops the fields that say WHERE a callbook record's operator
// lives, and keeps the ones that say WHO they are — name, address, QSL route.
// A portable operator's cards still go to the home address, so that address is
// not wrong; their county is.
func clearHomeLocation(r *Result) {
r.Country, r.Continent = "", ""
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
r.Lat, r.Lon = 0, 0
r.Grid, r.State, r.County = "", "", ""
}
func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
if dxcc == nil {
return false
@@ -387,6 +395,33 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
return false
}
filled := false
// A subdivision belongs to an ENTITY, so a record describing one entity has
// nothing to say about a station operating from another.
//
// TI8/W2RE came back as a Costa Rica contact in Dutchess County, New York, on
// square FN31 — W2RE's home details, from a QRZ page that carries the home
// mailing address as most portable pages do. CNTY is *defined* as a US
// county, so that is not a cosmetic slip: it is a US county award credit
// recorded against a Costa Rica QSO, and a distance and beam heading computed
// from a square 3,600 km from where the station actually is.
//
// The home-call fallback above already clears these, but it only runs when NO
// provider had the slashed form. An operator with a page for their portable
// call never reached it. Cleared here instead, where the operating entity is
// known — which also heals rows already in the cache, since every cache hit
// comes back through here.
//
// Same-entity portables (F4BPO/P, W2RE/2) are untouched: the entities match,
// and there the home details ARE where the operator is.
if dxccNum != 0 && strings.ContainsRune(r.Callsign, '/') && !saysNothingAboutLocation(r.Callsign) {
if home := homeCall(r.Callsign); home != "" && home != r.Callsign {
if homeNum, _, _, _, _, _, _, homeOK := dxcc.Resolve(home); homeOK && homeNum != 0 && homeNum != dxccNum {
clearHomeLocation(r)
filled = true
}
}
}
if country != "" {
r.Country = country
filled = true
+93
View File
@@ -0,0 +1,93 @@
package lookup
import "testing"
// fakeDXCC resolves a handful of calls, enough to stand in for cty.dat.
type fakeDXCC map[string]struct {
num int
country string
cont string
cqz, ituz int
lat, lon float64
}
func (f fakeDXCC) Resolve(call string) (int, string, string, int, int, float64, float64, bool) {
e, ok := f[call]
if !ok {
return 0, "", "", 0, 0, 0, 0, false
}
return e.num, e.country, e.cont, e.cqz, e.ituz, e.lat, e.lon, true
}
func testDXCC() fakeDXCC {
return fakeDXCC{
// Costa Rica, as cty.dat reads the OPERATING call.
"TI8/W2RE": {num: 308, country: "Costa Rica", cont: "NA", cqz: 7, ituz: 11, lat: 9.9, lon: -84.1},
// The home call: United States.
"W2RE": {num: 291, country: "United States", cont: "NA", cqz: 5, ituz: 8, lat: 39.8, lon: -98.5},
// A same-entity portable: still France either way.
"F4BPO/P": {num: 227, country: "France", cont: "EU", cqz: 14, ituz: 27, lat: 46.2, lon: 2.2},
"F4BPO": {num: 227, country: "France", cont: "EU", cqz: 14, ituz: 27, lat: 46.2, lon: 2.2},
}
}
// A callbook record for a portable call routinely carries the operator's HOME
// address — most QRZ pages for a portable call do — and a subdivision belongs to
// an entity. Carrying the home county across an entity change is not cosmetic:
// CNTY is defined as a US county, so TI8/W2RE was coming out as a Costa Rica
// contact credited to Dutchess County, New York, with the distance and beam
// heading taken from a square 3,600 km from where the station actually was.
func TestPortableInAnotherEntityDropsTheHomeSubdivision(t *testing.T) {
r := Result{
Callsign: "TI8/W2RE",
Name: "Raymond", // who they are — kept
QTH: "Poughquag",
Address: "499 Pleasant Ridge Road", // cards still go there — kept
State: "NY",
County: "Dutchess",
Grid: "FN31",
Lat: 41.6, Lon: -73.7,
DXCC: 291,
}
fillFromDXCC(&r, testDXCC())
if r.County != "" {
t.Errorf("county = %q, want empty — a US county on a Costa Rica QSO is a false award credit", r.County)
}
if r.State != "" {
t.Errorf("state = %q, want empty — a subdivision of the entity that is not being worked", r.State)
}
if r.Grid != "" {
t.Errorf("grid = %q, want empty — it is the home square, 3,600 km from the operation", r.Grid)
}
// The entity and its centroid take over, so distance and bearing mean
// something again.
if r.DXCC != 308 || r.Country != "Costa Rica" {
t.Errorf("entity = %d %q, want 308 Costa Rica", r.DXCC, r.Country)
}
if r.Lat != 9.9 || r.Lon != -84.1 {
t.Errorf("lat/lon = %v/%v, want the Costa Rica centroid — the home coordinates must not survive", r.Lat, r.Lon)
}
// Who they are, and where their cards go, is unchanged.
if r.Name != "Raymond" || r.Address != "499 Pleasant Ridge Road" {
t.Errorf("name/address were cleared (%q / %q) — a portable operator's post still reaches home", r.Name, r.Address)
}
}
// The other half of the rule: a portable WITHIN the same entity is at home as
// far as the entity is concerned, and its details must survive untouched.
func TestSameEntityPortableKeepsItsLocation(t *testing.T) {
r := Result{
Callsign: "F4BPO/P",
State: "77", County: "Seine-et-Marne", Grid: "JN18cs",
Lat: 48.8, Lon: 2.4, DXCC: 227,
}
fillFromDXCC(&r, testDXCC())
if r.Grid != "JN18cs" || r.County != "Seine-et-Marne" || r.State != "77" {
t.Errorf("a same-entity portable lost its location: grid=%q county=%q state=%q", r.Grid, r.County, r.State)
}
if r.Lat != 48.8 || r.Lon != 2.4 {
t.Errorf("lat/lon = %v/%v — the precise home position was replaced by the entity centroid", r.Lat, r.Lon)
}
}
+84
View File
@@ -0,0 +1,84 @@
package qso
import (
"context"
"testing"
"time"
)
// LoTW never hands back the submode it was given: every digital contact comes
// back as the mode GROUP, "DATA". Matched on the exact mode string that is
// simply never equal, so an operator downloading their confirmations was told
// their own QSOs were not in their log — reported for FT2 contacts confirmed as
// DATA, and true of FT4 and FT8 alike.
func TestClassKeyMatchesAConfirmationCarryingTheModeGroup(t *testing.T) {
r := openRepo(t)
ctx := context.Background()
when := time.Date(2026, 8, 16, 16, 29, 0, 0, time.UTC)
id, err := r.Add(ctx, QSO{Callsign: "F1NQP", QSODate: when, Band: "20m", Mode: "FT2"})
if err != nil {
t.Fatal(err)
}
minute := when.Format("2006-01-02T15:04")
exact, err := r.DedupeKeyIDs(ctx)
if err != nil {
t.Fatal(err)
}
if _, found := exact[DedupeKey("F1NQP", minute, "20m", "DATA")]; found {
t.Fatal("the exact index matched DATA against FT2 — the test proves nothing")
}
byClass, err := r.DedupeClassKeyIDs(ctx)
if err != nil {
t.Fatal(err)
}
got, found := byClass[DedupeClassKey("F1NQP", minute, "20m", "DATA")]
if !found || got != id {
t.Errorf("class match = (%d, %v), want (%d, true) — the confirmation would be reported as having no local QSO", got, found, id)
}
}
// Two digital contacts with the same station, same band, same minute is barely
// physical — but if it happens, stamping the confirmation on whichever row the
// map happened to keep is a silent error in an award credit. Ambiguity maps to
// 0 so the caller reports it unmatched instead of guessing.
func TestAnAmbiguousClassKeyIsRefusedRatherThanGuessed(t *testing.T) {
r := openRepo(t)
ctx := context.Background()
when := time.Date(2026, 8, 16, 16, 29, 0, 0, time.UTC)
for _, m := range []string{"FT8", "RTTY"} {
if _, err := r.Add(ctx, QSO{Callsign: "F1NQP", QSODate: when, Band: "20m", Mode: m}); err != nil {
t.Fatal(err)
}
}
byClass, err := r.DedupeClassKeyIDs(ctx)
if err != nil {
t.Fatal(err)
}
if got := byClass[DedupeClassKey("F1NQP", when.Format("2006-01-02T15:04"), "20m", "DATA")]; got != 0 {
t.Errorf("ambiguous class key resolved to %d — one of two QSOs was picked at random", got)
}
}
// Phone and CW keep their own classes: a CW confirmation must never land on an
// SSB contact just because the rest of the key agrees.
func TestClassMatchDoesNotCrossPhoneAndCW(t *testing.T) {
r := openRepo(t)
ctx := context.Background()
when := time.Date(2026, 8, 16, 16, 29, 0, 0, time.UTC)
if _, err := r.Add(ctx, QSO{Callsign: "F1NQP", QSODate: when, Band: "20m", Mode: "SSB"}); err != nil {
t.Fatal(err)
}
byClass, err := r.DedupeClassKeyIDs(ctx)
if err != nil {
t.Fatal(err)
}
minute := when.Format("2006-01-02T15:04")
if _, found := byClass[DedupeClassKey("F1NQP", minute, "20m", "CW")]; found {
t.Error("a CW confirmation matched an SSB contact")
}
if _, found := byClass[DedupeClassKey("F1NQP", minute, "20m", "USB")]; !found {
t.Error("USB did not match the SSB contact — the phone sidebands are one class")
}
}
+45
View File
@@ -2691,6 +2691,51 @@ func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
return out, rows.Err()
}
// DedupeClassKey is DedupeKey with the mode collapsed to its CLASS (Phone / CW /
// Digital).
//
// For matching a downloaded confirmation whose mode is not the one you logged.
// LoTW does not hand back the submode it was given: a contact uploaded as FT4,
// FT8 or anything else digital comes back as the mode GROUP, "DATA". Matched on
// the exact string that is simply never equal, and the operator is told their
// own QSO is not in their log — which is how a confirmed contact goes unrecorded.
func DedupeClassKey(callsign, qsoDateMinute, band, mode string) string {
return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + modeClass(mode)
}
// DedupeClassKeyIDs is DedupeKeyIDs at mode-CLASS granularity, for the second
// pass when the exact key misses.
//
// A key shared by more than one QSO maps to 0 — AMBIGUOUS, not "pick one". Two
// contacts with the same station, on the same band, in the same minute, in two
// digital modes is barely physical; but if it ever happens, stamping the
// confirmation on whichever row the map happened to keep would be a silent
// error in someone's award credit. Better to report it unmatched.
func (r *Repo) DedupeClassKeyIDs(ctx context.Context) (map[string]int64, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, callsign, substr(qso_date, 1, 16), band, mode
FROM qso`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]int64, 1024)
for rows.Next() {
var id int64
var call, when, band, mode string
if err := rows.Scan(&id, &call, &when, &band, &mode); err != nil {
return nil, err
}
k := DedupeClassKey(call, when, band, mode)
if prev, seen := out[k]; seen && prev != id {
out[k] = 0
continue
}
out[k] = id
}
return out, rows.Err()
}
// matchRef is one local QSO's time + id, for time-window confirmation matching.
type matchRef struct {
when time.Time
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.25.8"
appVersion = "0.25.9"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.