chore: release v0.25.9
This commit is contained in:
@@ -114,6 +114,7 @@ const (
|
|||||||
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
|
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
|
||||||
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
|
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
|
||||||
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
|
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
|
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
|
keyCATFlexDecodeSecs = "cat.flex.decode_secs" // decode spot display duration (seconds) before auto-removal
|
||||||
keyCATPollMs = "cat.poll_ms"
|
keyCATPollMs = "cat.poll_ms"
|
||||||
@@ -245,6 +246,9 @@ const (
|
|||||||
keyMotorBandFreqs = "motor.band_freqs" // per-band tune frequency: "40m=7100,20m=14150"
|
keyMotorBandFreqs = "motor.band_freqs" // per-band tune frequency: "40m=7100,20m=14150"
|
||||||
keyChaseNewGrids = "cluster.chase_grids" // "1" → persist learnt locators across restarts
|
keyChaseNewGrids = "cluster.chase_grids" // "1" → persist learnt locators across restarts
|
||||||
keyRowColors = "appearance.row_colors"
|
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
|
keyMatrixColors = "appearance.matrix_colors" // band/mode matrix palette overrides
|
||||||
keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam)
|
keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam)
|
||||||
keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp)
|
keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp)
|
||||||
@@ -427,6 +431,7 @@ type CATSettings struct {
|
|||||||
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
|
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
|
||||||
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
|
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
|
||||||
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
|
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
|
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)
|
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…)
|
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
|
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
|
||||||
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
|
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
|
||||||
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
|
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/…)
|
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
|
ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs
|
||||||
SharePort int `json:"share_port"` // TCP port for the rigctl server (default 4532)
|
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)
|
startupProfile string // --profile <name> from the command line (activate at startup)
|
||||||
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
||||||
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
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
|
pttMu sync.Mutex
|
||||||
// confDLMu/confDLCancel cancel the in-flight confirmation download (LoTW/QRZ)
|
// confDLMu/confDLCancel cancel the in-flight confirmation download (LoTW/QRZ)
|
||||||
// so closing the QSL Manager — or starting another download — stops the previous
|
// 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
|
offlineMode bool // last write failed because the DB was unreachable
|
||||||
|
|
||||||
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
|
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
|
catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter
|
||||||
catFlexDecodeSecs int // decode spot display duration (seconds)
|
catFlexDecodeSecs int // decode spot display duration (seconds)
|
||||||
liveActMu sync.Mutex // guards the entry-strip activity reported for live status
|
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 {
|
if keyed {
|
||||||
go a.dvkUnkeyPTT(gen)
|
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 {
|
if a.ctx != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "audio:status", st)
|
wruntime.EventsEmit(a.ctx, "audio:status", st)
|
||||||
@@ -7508,7 +7524,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
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 {
|
if err != nil {
|
||||||
return CATSettings{}, err
|
return CATSettings{}, err
|
||||||
}
|
}
|
||||||
@@ -7519,6 +7535,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
FlexHost: m[keyCATFlexHost],
|
FlexHost: m[keyCATFlexHost],
|
||||||
FlexPort: 4992,
|
FlexPort: 4992,
|
||||||
FlexSpots: m[keyCATFlexSpots] == "1",
|
FlexSpots: m[keyCATFlexSpots] == "1",
|
||||||
|
FlexDVKDax: m[keyCATFlexDVKDax] == "1",
|
||||||
FlexDecodeSpots: m[keyCATFlexDecodeSpots] == "1",
|
FlexDecodeSpots: m[keyCATFlexDecodeSpots] == "1",
|
||||||
FlexDecodeSecs: 120,
|
FlexDecodeSecs: 120,
|
||||||
XieguPort: m[keyCATXieguPort],
|
XieguPort: m[keyCATXieguPort],
|
||||||
@@ -7547,6 +7564,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
PTTHotkeyToggle: m[keyCATPttHotkeyToggle] == "1",
|
PTTHotkeyToggle: m[keyCATPttHotkeyToggle] == "1",
|
||||||
PollMs: 250,
|
PollMs: 250,
|
||||||
DelayMs: 0,
|
DelayMs: 0,
|
||||||
|
OffsetOn: m[keyCATOffsetOn] == "1",
|
||||||
DigitalDefault: m[keyCATDigitalDefault],
|
DigitalDefault: m[keyCATDigitalDefault],
|
||||||
ShareEnabled: m[keyCATShareEnabled] == "1",
|
ShareEnabled: m[keyCATShareEnabled] == "1",
|
||||||
SharePort: 4532,
|
SharePort: 4532,
|
||||||
@@ -7556,6 +7574,11 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
|
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
|
||||||
out.FlexPort = n
|
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 {
|
if n, _ := strconv.Atoi(m[keyCATFlexDecodeSecs]); n >= 10 && n <= 3600 {
|
||||||
out.FlexDecodeSecs = n
|
out.FlexDecodeSecs = n
|
||||||
}
|
}
|
||||||
@@ -7666,6 +7689,14 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
if s.FlexSpots {
|
if s.FlexSpots {
|
||||||
flexSpots = "1"
|
flexSpots = "1"
|
||||||
}
|
}
|
||||||
|
flexDVKDax := "0"
|
||||||
|
if s.FlexDVKDax {
|
||||||
|
flexDVKDax = "1"
|
||||||
|
}
|
||||||
|
offsetOn := "0"
|
||||||
|
if s.OffsetOn {
|
||||||
|
offsetOn = "1"
|
||||||
|
}
|
||||||
flexDecodeSpots := "0"
|
flexDecodeSpots := "0"
|
||||||
if s.FlexDecodeSpots {
|
if s.FlexDecodeSpots {
|
||||||
flexDecodeSpots = "1"
|
flexDecodeSpots = "1"
|
||||||
@@ -7702,6 +7733,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
|
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
|
||||||
keyCATFlexPort: strconv.Itoa(s.FlexPort),
|
keyCATFlexPort: strconv.Itoa(s.FlexPort),
|
||||||
keyCATFlexSpots: flexSpots,
|
keyCATFlexSpots: flexSpots,
|
||||||
|
keyCATFlexDVKDax: flexDVKDax,
|
||||||
keyCATFlexDecodeSpots: flexDecodeSpots,
|
keyCATFlexDecodeSpots: flexDecodeSpots,
|
||||||
keyCATFlexDecodeSecs: strconv.Itoa(s.FlexDecodeSecs),
|
keyCATFlexDecodeSecs: strconv.Itoa(s.FlexDecodeSecs),
|
||||||
keyCATXieguPort: strings.TrimSpace(s.XieguPort),
|
keyCATXieguPort: strings.TrimSpace(s.XieguPort),
|
||||||
@@ -7731,6 +7763,8 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
keyCATPttHotkeyToggle: b01(s.PTTHotkeyToggle),
|
keyCATPttHotkeyToggle: b01(s.PTTHotkeyToggle),
|
||||||
keyCATPollMs: strconv.Itoa(s.PollMs),
|
keyCATPollMs: strconv.Itoa(s.PollMs),
|
||||||
keyCATDelayMs: strconv.Itoa(s.DelayMs),
|
keyCATDelayMs: strconv.Itoa(s.DelayMs),
|
||||||
|
keyCATOffsetOn: offsetOn,
|
||||||
|
keyCATOffsetHz: strconv.FormatInt(s.OffsetHz, 10),
|
||||||
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
|
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
|
||||||
keyCATShareEnabled: shareEnabled,
|
keyCATShareEnabled: shareEnabled,
|
||||||
keyCATSharePort: strconv.Itoa(s.SharePort),
|
keyCATSharePort: strconv.Itoa(s.SharePort),
|
||||||
@@ -8591,6 +8625,9 @@ func (a *App) QSOAudioPlayOnAir() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cfg := cfgEarly
|
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 {
|
if err := a.pttKey(cfg); err != nil {
|
||||||
applog.Printf("qso-rec: PTT on failed before playback: %v", err)
|
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.
|
// 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 {
|
if keyed {
|
||||||
go a.dvkUnkeyPTT(gen)
|
go a.dvkUnkeyPTT(gen)
|
||||||
}
|
}
|
||||||
|
a.lowerFlexDVKDax() // nothing went out — give the microphone straight back
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
applog.Printf("qso-rec: playing the recording on the air (%d bytes)", len(pcm))
|
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)
|
return fmt.Errorf("no recording in slot %d", slot)
|
||||||
}
|
}
|
||||||
cfg, _ := a.GetAudioSettings()
|
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 {
|
if err := a.pttKey(cfg); err != nil {
|
||||||
applog.Printf("dvk: PTT on failed: %v", err)
|
applog.Printf("dvk: PTT on failed: %v", err)
|
||||||
// Keep going — the audio still reaches the rig; the user may use VOX.
|
// 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 {
|
if keyed {
|
||||||
go a.dvkUnkeyPTT(gen)
|
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 err
|
||||||
}
|
}
|
||||||
return nil
|
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
|
// 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
|
// 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
|
// 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)
|
done(matched, total)
|
||||||
return
|
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
|
// Snapshot award-valid confirmations (LoTW + paper QSL — the only two
|
||||||
// that count for ARRL awards) so each incoming one is flagged NEW.
|
// that count for ARRL awards) so each incoming one is flagged NEW.
|
||||||
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"})
|
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")
|
date = time.Now().UTC().Format("20060102")
|
||||||
}
|
}
|
||||||
a.enrichContactedFromCty(&q) // country/dxcc/zones from cty.dat
|
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)
|
minute := q.QSODate.UTC().Format("2006-01-02T15:04")
|
||||||
if id, found := keyIDs[key]; found {
|
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 {
|
if e := a.qso.MarkLoTWConfirmed(ctx, id, date); e == nil {
|
||||||
matched++
|
matched++
|
||||||
}
|
}
|
||||||
@@ -11274,6 +11400,7 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
|
|||||||
q.LOTWRcvdDate = date
|
q.LOTWRcvdDate = date
|
||||||
if newID, e := a.qso.Add(ctx, q); e == nil {
|
if newID, e := a.qso.Add(ctx, q); e == nil {
|
||||||
keyIDs[key] = newID // guard against dup records in the report
|
keyIDs[key] = newID // guard against dup records in the report
|
||||||
|
classIDs[classKey] = newID
|
||||||
added++
|
added++
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -11324,6 +11451,14 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
|
|||||||
} else {
|
} else {
|
||||||
emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total))
|
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
|
// 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
|
// and why (time off by a minute, FT4 logged as MFSK, portable call, or
|
||||||
// never logged). Tick "Add not-found" to import them instead.
|
// 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.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond)
|
||||||
a.cat.SetCommandDelay(time.Duration(s.DelayMs) * 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.catFlexSpots = s.Enabled && ((s.Backend == "flex" && s.FlexSpots) || (s.Backend == "tci" && s.TCISpots))
|
||||||
a.catFlexDecodeSpots = s.Enabled && s.Backend == "flex" && s.FlexDecodeSpots
|
a.catFlexDecodeSpots = s.Enabled && s.Backend == "flex" && s.FlexDecodeSpots
|
||||||
a.catFlexDecodeSecs = s.FlexDecodeSecs
|
a.catFlexDecodeSecs = s.FlexDecodeSecs
|
||||||
|
a.catFlexDVKDax = s.Enabled && s.Backend == "flex" && s.FlexDVKDax
|
||||||
a.reloadCATShare(s)
|
a.reloadCATShare(s)
|
||||||
if !s.Enabled {
|
if !s.Enabled {
|
||||||
a.cat.Stop()
|
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;
|
// RotatorPark moves the active rotor to its parked position (PstRotator only;
|
||||||
// the 4O3A native and ARCO GS-232 protocols have no park command).
|
// the 4O3A native and ARCO GS-232 protocols have no park command).
|
||||||
func (a *App) RotatorPark() error {
|
func (a *App) RotatorPark() error {
|
||||||
|
|||||||
+14
-2
@@ -5,12 +5,24 @@
|
|||||||
"en": [
|
"en": [
|
||||||
"The band/mode matrix colours can be chosen in Appearance, starting from the ones your theme already paints. Its legend is translated too.",
|
"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.",
|
"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": [
|
"fr": [
|
||||||
"Les couleurs de la matrice bandes/modes se choisissent dans Apparence, à partir de celles du thème. Sa légende est traduite aussi.",
|
"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.",
|
"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 l’accusé se perd est renvoyé, comme le PTT — en perdre un faisait lâcher JTDX."
|
"Icom : un changement de fréquence ou de mode dont l’accusé se perd est renvoyé, comme le PTT — en perdre un faisait lâcher JTDX.",
|
||||||
|
"FlexRadio : l’audio d’émission peut basculer sur DAX le temps d’un 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 l’antenne.",
|
||||||
|
"Une station portable dans une autre entité n’hérite plus de son comté, son état et son locator d’origine : 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 qu’une 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 d’un coup, et chaque bande retient son zoom."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ import { ShutdownProgress } from '@/components/ShutdownProgress';
|
|||||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||||
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
||||||
import { applySpotDisplay, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
|
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 { applyMatrixColors } from '@/lib/matrixColors';
|
||||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||||
import { NetControlPanel } from '@/components/NetControlPanel';
|
import { NetControlPanel } from '@/components/NetControlPanel';
|
||||||
@@ -2018,6 +2018,10 @@ export default function App() {
|
|||||||
// settings dialog closes, which is the only place it changes.
|
// settings dialog closes, which is the only place it changes.
|
||||||
const [rowColors, setRowColors] = useState<any>(null);
|
const [rowColors, setRowColors] = useState<any>(null);
|
||||||
useEffect(() => { GetRowColors().then(setRowColors).catch(() => {}); }, [showSettings]);
|
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
|
// Band/mode matrix palette overrides (Settings → Appearance). Stamped onto
|
||||||
// <html> rather than held in state: the matrix reads CSS custom properties, so
|
// <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
|
// 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
|
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
|
||||||
rotator is configured or a DX bearing exists. */}
|
rotator is configured or a DX bearing exists. */}
|
||||||
{showRotor && (rotatorHeading.enabled || dxPath) && (
|
{showRotor && (rotatorHeading.enabled || dxPath) && (
|
||||||
<div className="w-[186px] shrink-0 min-h-0">
|
<div className="w-[320px] shrink-0 min-h-0">
|
||||||
<RotorCompass
|
<RotorCompass
|
||||||
|
presets={rotorPresets}
|
||||||
|
onStop={() => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
|
||||||
bearing={dxPath?.bearingShort ?? null}
|
bearing={dxPath?.bearingShort ?? null}
|
||||||
headings={beamHeadings}
|
headings={beamHeadings}
|
||||||
boomHeading={boomHeading}
|
boomHeading={boomHeading}
|
||||||
|
|||||||
@@ -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 { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
||||||
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
|
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
|
||||||
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
// BandMap — vertical spectrum panel inspired by Log4OM.
|
// BandMap — vertical spectrum panel inspired by Log4OM.
|
||||||
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
|
// - 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
|
// 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
|
// every 2.75 kHz fits without anti-overlap kicking in — comfortable for
|
||||||
// most bands. Higher levels are for fine inspection of crowded sub-bands.
|
// 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 SCALE_W = 56;
|
||||||
const PILL_H = 22; // px — height of each callsign pill
|
const PILL_H = 22; // px — height of each callsign pill
|
||||||
const PILL_GAP = 32; // px between scale border and first pill (room for leader)
|
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]);
|
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight]);
|
||||||
const range = BAND_RANGES[band];
|
const range = BAND_RANGES[band];
|
||||||
const segments = SEGMENT_COLORS[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 scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [containerH, setContainerH] = useState(400);
|
const [containerH, setContainerH] = useState(400);
|
||||||
|
|
||||||
@@ -421,7 +467,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
if (!range) return;
|
if (!range) return;
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
e.preventDefault();
|
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 });
|
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
|
// Tick step (where small marks land) and label step (where the kHz
|
||||||
// number is printed) are decoupled so the scale shows ~10-20 numeric
|
// 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 per viewport regardless of zoom — at 8 px/kHz we want labels
|
||||||
// labels every 25 kHz, not every 250.
|
// 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 tickStep = 50;
|
||||||
let labelStep = 100;
|
let labelStep = 100;
|
||||||
if (pxPerKHz >= 4) { tickStep = 25; labelStep = 50; }
|
if (pxPerKHz >= 4) { tickStep = 25; labelStep = 50; }
|
||||||
@@ -502,14 +550,17 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
return (
|
return (
|
||||||
<div className="h-full w-full flex flex-col min-h-0 bg-card">
|
<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">
|
<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>
|
{/* The band alone. "Map · 20M" spent a third of a narrow header saying
|
||||||
<button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={fitToBand || zoomIdx === 0}
|
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"
|
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||||
title={t('bmp.zoomOut')}>
|
title={t('bmp.zoomOut')}>
|
||||||
<Minus className="size-3" />
|
<Minus className="size-3" />
|
||||||
</button>
|
</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>
|
<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"
|
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||||
title={t('bmp.zoomIn')}>
|
title={t('bmp.zoomIn')}>
|
||||||
<Plus className="size-3" />
|
<Plus className="size-3" />
|
||||||
|
|||||||
@@ -5,12 +5,13 @@
|
|||||||
// when an Ultrabeam is bidirectional, the opposite one when reversed); a small
|
// 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
|
// red marker on the bezel shows the short-path bearing to the DX. Click the dial
|
||||||
// to turn the antenna there.
|
// to turn the antenna there.
|
||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo';
|
import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo';
|
||||||
import { feature } from 'topojson-client';
|
import { feature } from 'topojson-client';
|
||||||
import landTopo from 'world-atlas/land-110m.json';
|
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 { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
// Decode the coastline outline once (≈110 m simplified land polygons).
|
// Decode the coastline outline once (≈110 m simplified land polygons).
|
||||||
const LAND = feature(landTopo as any, (landTopo as any).objects.land);
|
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
|
onSelectRotor?: (i: number) => void; // switch the active rotor
|
||||||
onGoto?: (az: number) => void; // click-to-turn
|
onGoto?: (az: number) => void; // click-to-turn
|
||||||
onClose?: () => void;
|
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;
|
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)];
|
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(
|
const cardinals = useMemo(
|
||||||
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
() => [ { 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' } ],
|
{ 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;
|
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 (
|
return (
|
||||||
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
|
<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. */}
|
{/* Header — matches the WinKeyer / Voice keyer panels. */}
|
||||||
@@ -128,9 +199,13 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
|||||||
</div>
|
</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
|
{/* flex-col: the readout goes BELOW the dial. This wrapper was a row, so a
|
||||||
sibling of the <svg> landed beside it. */}
|
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
|
<svg
|
||||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||||
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
|
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} />
|
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* Short and long path to the DX, in figures.
|
{/* With the controls column present the readout goes at the FOOT OF IT
|
||||||
The bezel already carries the short path as a red marker, but a
|
instead: the dial sets the widget's height, the controls are shorter
|
||||||
marker is a direction, not a number — the same pair sits in the
|
than the dial, and that leftover space is exactly the right size for
|
||||||
status bar at 10px and operators reported not being able to read it.
|
the pair. Under the dial it would push the whole widget taller. */}
|
||||||
Here because it belongs to the compass: every place that draws one
|
{!showControls && pathReadout}
|
||||||
gets the readout, instead of each caller inventing its own.
|
</div>
|
||||||
Clickable when the caller can turn, like the status bar's. */}
|
|
||||||
<div className="flex gap-1.5 mt-2 font-mono w-full">
|
{/* Quick turns + free azimuth + Stop. */}
|
||||||
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
|
{showControls && (
|
||||||
<button key={lbl} type="button" disabled={az == null || !onGoto}
|
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
|
||||||
onClick={() => { if (az != null && onGoto) onGoto(Math.round(az)); }}
|
{/* Two columns so six regions fit beside the dial rather than under
|
||||||
title={az == null ? '' : `${lbl} ${Math.round(az)}°`}
|
it. An operator with fewer keeps the same compact block. */}
|
||||||
className={
|
{!!presets?.length && (
|
||||||
'flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors ' +
|
<div className="grid grid-cols-2 gap-1">
|
||||||
(az == null
|
{presets.map((p, i) => (
|
||||||
? 'border-border text-muted-foreground/50 cursor-not-allowed'
|
<button
|
||||||
: onGoto
|
key={`${p.label}-${i}`}
|
||||||
? 'border-info-border text-info-muted-foreground hover:bg-info-muted cursor-pointer'
|
type="button"
|
||||||
: 'border-border text-muted-foreground cursor-default')
|
disabled={!onGoto}
|
||||||
}>
|
onClick={() => pressPreset(i, p.azimuth)}
|
||||||
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
|
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
|
||||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||||
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
||||||
|
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
|
||||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||||
@@ -1399,16 +1400,25 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [bandDraft, setBandDraft] = useState('');
|
const [bandDraft, setBandDraft] = useState('');
|
||||||
const [modeDraft, setModeDraft] = useState('');
|
const [modeDraft, setModeDraft] = useState('');
|
||||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
|
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: '',
|
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,
|
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,
|
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,
|
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false,
|
||||||
});
|
});
|
||||||
// While true, the next key press is captured as the PTT hotkey.
|
// While true, the next key press is captured as the PTT hotkey.
|
||||||
const [capturingPtt, setCapturingPtt] = useState(false);
|
const [capturingPtt, setCapturingPtt] = useState(false);
|
||||||
const [rotors, setRotors] = useState<RotatorDevice[]>([]);
|
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 [rotatorTesting, setRotatorTesting] = useState(false);
|
||||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
@@ -1825,6 +1835,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
await reloadClusterServers();
|
await reloadClusterServers();
|
||||||
setCatCfg(c);
|
setCatCfg(c);
|
||||||
setRotors((r ?? []) as any);
|
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 { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
try { setTunergenius(await GetTunerGeniusSettings() 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 { setLookup(await GetLookupSettings() as any); } catch {}
|
||||||
try { setCatCfg(await GetCATSettings() as any); } catch {}
|
try { setCatCfg(await GetCATSettings() as any); } catch {}
|
||||||
try { setRotors(((await GetRotators()) ?? []) 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 { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
try { setTunergenius(await GetTunerGeniusSettings() 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 SaveLookupSettings(lookup as any);
|
||||||
await SaveCATSettings(catCfg as any);
|
await SaveCATSettings(catCfg as any);
|
||||||
await SaveRotators(rotors 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 SaveUltrabeamSettings(ultrabeam as any);
|
||||||
await SaveAntGeniusSettings(antgenius as any);
|
await SaveAntGeniusSettings(antgenius as any);
|
||||||
await SaveTunerGeniusSettings(tunergenius 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>
|
<span className="text-xs text-muted-foreground">{t('cat.flexDecodeSecsHint')}</span>
|
||||||
</div>
|
</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' && (
|
{catCfg.backend === 'xiegu' && (
|
||||||
@@ -3142,6 +3164,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</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">
|
<div className="space-y-1 col-span-2">
|
||||||
<Label>{t('cat.digitalDefault')}</Label>
|
<Label>{t('cat.digitalDefault')}</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -4135,6 +4179,41 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{rotatorTest && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'text-xs rounded-md p-2.5 border',
|
'text-xs rounded-md p-2.5 border',
|
||||||
|
|||||||
@@ -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.',
|
'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.',
|
'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 controller’s 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.spidHint': 'Native SPID protocol over the controller’s 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.",
|
'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 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
'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 1–2 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 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 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',
|
'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 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 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.icomNetAudio': 'Stream RX audio over the network (experimental)',
|
||||||
'cat.icomNetAudioHint': 'Play the rig’s 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.icomNetAudioHint': 'Play the rig’s 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.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.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.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.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.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.",
|
'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',
|
'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.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.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.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)',
|
'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).',
|
'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.",
|
'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.",
|
'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.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 n’a 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.",
|
'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 n’a 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 1–2 min pour corriger un QSO mal saisi avant).",
|
'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 1–2 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',
|
'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 l’audio RX par le réseau (expérimental)',
|
'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
|
||||||
'cat.icomNetAudioHint': 'Écoute l’audio 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.icomNetAudioHint': 'Écoute l’audio 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.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 l’adresse 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.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 l’adresse 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.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.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.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é.",
|
'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',
|
'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.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.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.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)',
|
'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).',
|
'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).',
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode
|
'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode
|
||||||
'opslog.bandMapWidth', // docked band map: column width (px)
|
'opslog.bandMapWidth', // docked band map: column width (px)
|
||||||
'opslog.bandMapTabWidth', // Band map tab: shared card 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.
|
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
||||||
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
||||||
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.25.8';
|
export const APP_VERSION = '0.25.9';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+6
@@ -513,6 +513,8 @@ export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
|||||||
|
|
||||||
export function GetRotators():Promise<Array<main.RotatorDevice>>;
|
export function GetRotators():Promise<Array<main.RotatorDevice>>;
|
||||||
|
|
||||||
|
export function GetRotorPresets():Promise<Array<main.RotorPreset>>;
|
||||||
|
|
||||||
export function GetRowColors():Promise<main.RowColorSettings>;
|
export function GetRowColors():Promise<main.RowColorSettings>;
|
||||||
|
|
||||||
export function GetSPEStatus():Promise<spe.Status>;
|
export function GetSPEStatus():Promise<spe.Status>;
|
||||||
@@ -883,6 +885,8 @@ export function ResetAwardDefs():Promise<Array<award.Def>>;
|
|||||||
|
|
||||||
export function ResetDatabaseToDefault():Promise<void>;
|
export function ResetDatabaseToDefault():Promise<void>;
|
||||||
|
|
||||||
|
export function ResetRotorPresets():Promise<Array<main.RotorPreset>>;
|
||||||
|
|
||||||
export function RestartApp():Promise<void>;
|
export function RestartApp():Promise<void>;
|
||||||
|
|
||||||
export function RestartQSORecorder():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 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 SaveRowColors(arg1:main.RowColorSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
|
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
|
||||||
|
|||||||
@@ -966,6 +966,10 @@ export function GetRotators() {
|
|||||||
return window['go']['main']['App']['GetRotators']();
|
return window['go']['main']['App']['GetRotators']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetRotorPresets() {
|
||||||
|
return window['go']['main']['App']['GetRotorPresets']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetRowColors() {
|
export function GetRowColors() {
|
||||||
return window['go']['main']['App']['GetRowColors']();
|
return window['go']['main']['App']['GetRowColors']();
|
||||||
}
|
}
|
||||||
@@ -1706,6 +1710,10 @@ export function ResetDatabaseToDefault() {
|
|||||||
return window['go']['main']['App']['ResetDatabaseToDefault']();
|
return window['go']['main']['App']['ResetDatabaseToDefault']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ResetRotorPresets() {
|
||||||
|
return window['go']['main']['App']['ResetRotorPresets']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RestartApp() {
|
export function RestartApp() {
|
||||||
return window['go']['main']['App']['RestartApp']();
|
return window['go']['main']['App']['RestartApp']();
|
||||||
}
|
}
|
||||||
@@ -1886,6 +1894,10 @@ export function SaveRotators(arg1) {
|
|||||||
return window['go']['main']['App']['SaveRotators'](arg1);
|
return window['go']['main']['App']['SaveRotators'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveRotorPresets(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveRotorPresets'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveRowColors(arg1) {
|
export function SaveRowColors(arg1) {
|
||||||
return window['go']['main']['App']['SaveRowColors'](arg1);
|
return window['go']['main']['App']['SaveRowColors'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2021,6 +2021,7 @@ export namespace main {
|
|||||||
flex_host: string;
|
flex_host: string;
|
||||||
flex_port: number;
|
flex_port: number;
|
||||||
flex_spots: boolean;
|
flex_spots: boolean;
|
||||||
|
flex_dvk_dax: boolean;
|
||||||
flex_decode_spots: boolean;
|
flex_decode_spots: boolean;
|
||||||
flex_decode_secs: number;
|
flex_decode_secs: number;
|
||||||
xiegu_port: string;
|
xiegu_port: string;
|
||||||
@@ -2047,6 +2048,8 @@ export namespace main {
|
|||||||
tci_spots: boolean;
|
tci_spots: boolean;
|
||||||
poll_ms: number;
|
poll_ms: number;
|
||||||
delay_ms: number;
|
delay_ms: number;
|
||||||
|
offset_on: boolean;
|
||||||
|
offset_hz: number;
|
||||||
digital_default: string;
|
digital_default: string;
|
||||||
share_enabled: boolean;
|
share_enabled: boolean;
|
||||||
share_port: number;
|
share_port: number;
|
||||||
@@ -2069,6 +2072,7 @@ export namespace main {
|
|||||||
this.flex_host = source["flex_host"];
|
this.flex_host = source["flex_host"];
|
||||||
this.flex_port = source["flex_port"];
|
this.flex_port = source["flex_port"];
|
||||||
this.flex_spots = source["flex_spots"];
|
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_spots = source["flex_decode_spots"];
|
||||||
this.flex_decode_secs = source["flex_decode_secs"];
|
this.flex_decode_secs = source["flex_decode_secs"];
|
||||||
this.xiegu_port = source["xiegu_port"];
|
this.xiegu_port = source["xiegu_port"];
|
||||||
@@ -2095,6 +2099,8 @@ export namespace main {
|
|||||||
this.tci_spots = source["tci_spots"];
|
this.tci_spots = source["tci_spots"];
|
||||||
this.poll_ms = source["poll_ms"];
|
this.poll_ms = source["poll_ms"];
|
||||||
this.delay_ms = source["delay_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.digital_default = source["digital_default"];
|
||||||
this.share_enabled = source["share_enabled"];
|
this.share_enabled = source["share_enabled"];
|
||||||
this.share_port = source["share_port"];
|
this.share_port = source["share_port"];
|
||||||
@@ -3206,6 +3212,20 @@ export namespace main {
|
|||||||
this.motorized = source["motorized"];
|
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 {
|
export class RowColorRule {
|
||||||
id: string;
|
id: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ type Manager struct {
|
|||||||
|
|
||||||
pollEvery time.Duration
|
pollEvery time.Duration
|
||||||
cmdDelay time.Duration // pause after each command (some rigs need it)
|
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 {
|
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.
|
// 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 {
|
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) })
|
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
|
// 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.
|
// report is worth far more than a success it cannot check.
|
||||||
func (m *Manager) SetSplit(on bool, txHz int64) error {
|
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 {
|
return m.exec(func(b Backend) error {
|
||||||
s, ok := b.(splitSetter)
|
s, ok := b.(splitSetter)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -744,6 +782,20 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
|
|||||||
ns.Enabled = true
|
ns.Enabled = true
|
||||||
ns.Backend = b.Name()
|
ns.Backend = b.Name()
|
||||||
ns.UpdatedAt = time.Now()
|
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 == "" {
|
if ns.FreqHz != 0 && ns.Band == "" {
|
||||||
ns.Band = BandFromHz(ns.FreqHz)
|
ns.Band = BandFromHz(ns.FreqHz)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// operator was looking the call up for, and it came back empty while
|
||||||
// the same lookup without the suffix answered perfectly.
|
// the same lookup without the suffix answered perfectly.
|
||||||
if !saysNothingAboutLocation(call) {
|
if !saysNothingAboutLocation(call) {
|
||||||
r.Country, r.Continent = "", ""
|
clearHomeLocation(&r)
|
||||||
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
|
|
||||||
r.Lat, r.Lon = 0, 0
|
|
||||||
r.Grid, r.State, r.County = "", "", ""
|
|
||||||
}
|
}
|
||||||
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
|
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
|
||||||
normalizeNames(&r)
|
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
|
// the DXCC number since QRZ's value is wrong and we don't have an entity
|
||||||
// → DXCC# table yet.
|
// → DXCC# table yet.
|
||||||
// Returns true if any field was filled.
|
// 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 {
|
func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
||||||
if dxcc == nil {
|
if dxcc == nil {
|
||||||
return false
|
return false
|
||||||
@@ -387,6 +395,33 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
filled := 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 != "" {
|
if country != "" {
|
||||||
r.Country = country
|
r.Country = country
|
||||||
filled = true
|
filled = true
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2691,6 +2691,51 @@ func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
|
|||||||
return out, rows.Err()
|
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.
|
// matchRef is one local QSO's time + id, for time-window confirmation matching.
|
||||||
type matchRef struct {
|
type matchRef struct {
|
||||||
when time.Time
|
when time.Time
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.25.8"
|
appVersion = "0.25.9"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user