Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbddba6e74 | ||
|
|
b659f6f16d | ||
|
|
b4ad12bdc6 | ||
|
|
5dd3532074 | ||
|
|
dd7b63c059 | ||
|
|
8fc6673611 | ||
|
|
d33291b521 | ||
|
|
1b32b1ddec | ||
|
|
65bbaa85f3 | ||
|
|
99d903eb44 | ||
|
|
13515c58c0 | ||
|
|
e4014e11d2 | ||
|
|
025472820b | ||
|
|
8b66030c89 | ||
|
|
d4f23a52af | ||
|
|
b4b9674d8c |
@@ -233,6 +233,8 @@ const (
|
|||||||
keyUltrabeamPort = "ultrabeam.port"
|
keyUltrabeamPort = "ultrabeam.port"
|
||||||
keyUltrabeamFollow = "ultrabeam.follow" // "1" → re-tune to the rig frequency
|
keyUltrabeamFollow = "ultrabeam.follow" // "1" → re-tune to the rig frequency
|
||||||
keyUltrabeamStep = "ultrabeam.step_khz" // re-tune hysteresis: 25 | 50 | 100 kHz
|
keyUltrabeamStep = "ultrabeam.step_khz" // re-tune hysteresis: 25 | 50 | 100 kHz
|
||||||
|
keyMotorTrackMode = "motor.track_mode" // "always" | "step" | "band"
|
||||||
|
keyMotorBandFreqs = "motor.band_freqs" // per-band tune frequency: "40m=7100,20m=14150"
|
||||||
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)
|
||||||
keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0)
|
keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0)
|
||||||
@@ -2721,6 +2723,15 @@ func (a *App) applySolar(q *qso.QSO) {
|
|||||||
if a.solar == nil {
|
if a.solar == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Today's space weather belongs on today's QSO. The ADIF monitor and the UDP
|
||||||
|
// path both feed contacts that are normally seconds old, but neither promises
|
||||||
|
// it: a logger re-broadcasting its backlog, or an operator typing in last
|
||||||
|
// month's contact by hand, would otherwise be given this morning's SFI as if
|
||||||
|
// it had been measured at the time. A wrong number is worse than none — it
|
||||||
|
// cannot be told from a real reading afterwards.
|
||||||
|
if !q.QSODate.IsZero() && time.Since(q.QSODate) > 24*time.Hour {
|
||||||
|
return
|
||||||
|
}
|
||||||
d := a.solar.Get()
|
d := a.solar.Get()
|
||||||
if !d.OK {
|
if !d.OK {
|
||||||
return
|
return
|
||||||
@@ -4475,6 +4486,7 @@ func (a *App) awardRefMetas(defs []award.Def) map[string][]award.RefMeta {
|
|||||||
metas = append(metas, award.RefMeta{
|
metas = append(metas, award.RefMeta{
|
||||||
Code: rf.Code, Name: rf.Name, Group: rf.Group, SubGrp: rf.SubGrp,
|
Code: rf.Code, Name: rf.Name, Group: rf.Group, SubGrp: rf.SubGrp,
|
||||||
DXCCList: dxccList, Pattern: rf.Pattern, Valid: rf.Valid,
|
DXCCList: dxccList, Pattern: rf.Pattern, Valid: rf.Valid,
|
||||||
|
ValidFrom: rf.ValidFrom, ValidTo: rf.ValidTo,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
out[code] = metas
|
out[code] = metas
|
||||||
@@ -11823,6 +11835,16 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
|||||||
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
||||||
a.applyQSLDefaults(&q)
|
a.applyQSLDefaults(&q)
|
||||||
|
|
||||||
|
// ── Space weather and path length ──
|
||||||
|
// Also "same as the manual path", and they were missed when that comment was
|
||||||
|
// written. A QSO auto-logged from WSJT-X went in with no SFI, no A, no K and
|
||||||
|
// no distance, so an operator running digital — which is most of the traffic
|
||||||
|
// on most stations — had those fields empty across the whole log while a
|
||||||
|
// hand-logged contact carried them. Both are stamped only where the record
|
||||||
|
// left them empty, so an ADIF that supplied its own still wins.
|
||||||
|
a.applySolar(&q)
|
||||||
|
fillDistance(&q)
|
||||||
|
|
||||||
// ── Dedup (serialised) ──
|
// ── Dedup (serialised) ──
|
||||||
// Match by call + band + mode within a ±2-minute window: a QSO logged
|
// Match by call + band + mode within a ±2-minute window: a QSO logged
|
||||||
// manually in OpsLog and re-broadcast by Log4OM over UDP often differs by
|
// manually in OpsLog and re-broadcast by Log4OM over UDP often differs by
|
||||||
@@ -12346,13 +12368,25 @@ func (a *App) ultrabeamFollowNow(freqHz int64) {
|
|||||||
if ref <= 0 {
|
if ref <= 0 {
|
||||||
ref = c.LastSetKHz()
|
ref = c.LastSetKHz()
|
||||||
}
|
}
|
||||||
diff := khz - ref
|
switch normMotorTrackMode(s.TrackMode) {
|
||||||
if diff < 0 {
|
case motorTrackAlways:
|
||||||
diff = -diff
|
// Every frequency change means every frequency change, including this one.
|
||||||
}
|
case motorTrackBand:
|
||||||
if ref > 0 && diff < step {
|
// The antenna is already resonant somewhere in this band — that is all the
|
||||||
applog.Printf("ultrabeam: followNow within deadband (%d kHz vs ref %d, step %d) — no move", khz, ref, step)
|
// operator asked for in band mode, so a spot click inside it moves nothing.
|
||||||
return // within the deadband — don't chase a tiny QSY
|
if ref > 0 && bandForHz(int64(ref)*1000) == bandForHz(freqHz) {
|
||||||
|
applog.Printf("ultrabeam: followNow stays in band %q (antenna at %d kHz) — no move", bandForHz(freqHz), ref)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
diff := khz - ref
|
||||||
|
if diff < 0 {
|
||||||
|
diff = -diff
|
||||||
|
}
|
||||||
|
if ref > 0 && diff < step {
|
||||||
|
applog.Printf("ultrabeam: followNow within deadband (%d kHz vs ref %d, step %d) — no move", khz, ref, step)
|
||||||
|
return // within the deadband — don't chase a tiny QSY
|
||||||
|
}
|
||||||
}
|
}
|
||||||
a.noteMotorMoveCommanded()
|
a.noteMotorMoveCommanded()
|
||||||
if err := c.SetFrequency(khz, st.Direction); err != nil {
|
if err := c.SetFrequency(khz, st.Direction); err != nil {
|
||||||
@@ -14634,6 +14668,17 @@ type UltrabeamSettings struct {
|
|||||||
Baud int `json:"baud"` // serial baud
|
Baud int `json:"baud"` // serial baud
|
||||||
Follow bool `json:"follow"` // re-tune the antenna to the rig's frequency
|
Follow bool `json:"follow"` // re-tune the antenna to the rig's frequency
|
||||||
StepKHz int `json:"step_khz"` // re-tune only when the freq moved this far (25/50/100)
|
StepKHz int `json:"step_khz"` // re-tune only when the freq moved this far (25/50/100)
|
||||||
|
// When the follow loop is allowed to move the motors. The three choices the
|
||||||
|
// SteppIR's own controller software offers, because operators arrive with
|
||||||
|
// that mental model:
|
||||||
|
// "always" — every frequency change. Resonance is always right, at the cost
|
||||||
|
// of motors running constantly; on a SteppIR every move also
|
||||||
|
// inhibits transmit while the elements travel.
|
||||||
|
// "step" — only past a threshold (StepKHz). The default, and the sane
|
||||||
|
// middle: the antenna follows a QSY but ignores tuning around.
|
||||||
|
// "band" — only when the band changes. Motors move a handful of times a
|
||||||
|
// day; resonance is whatever the band-entry frequency gave.
|
||||||
|
TrackMode string `json:"track_mode"`
|
||||||
TXInhibit bool `json:"tx_inhibit"` // block Flex transmission while the elements are moving
|
TXInhibit bool `json:"tx_inhibit"` // block Flex transmission while the elements are moving
|
||||||
// Bands the antenna covers — the follow filter. The follow loop only re-tunes
|
// Bands the antenna covers — the follow filter. The follow loop only re-tunes
|
||||||
// (and only lets TX-inhibit trigger) on a band in this set; on any other band
|
// (and only lets TX-inhibit trigger) on a band in this set; on any other band
|
||||||
@@ -14641,6 +14686,11 @@ type UltrabeamSettings struct {
|
|||||||
// range so a single band can be dropped (e.g. 30 m without its extension) while
|
// range so a single band can be dropped (e.g. 30 m without its extension) while
|
||||||
// its neighbours stay. Applies to BOTH the Ultrabeam and the SteppIR.
|
// its neighbours stay. Applies to BOTH the Ultrabeam and the SteppIR.
|
||||||
Bands []string `json:"bands"`
|
Bands []string `json:"bands"`
|
||||||
|
// Per-band tune frequency (kHz) — where a band button in Station Control
|
||||||
|
// sends the antenna. Sparse: a band with no entry uses its default, so an
|
||||||
|
// operator sets only the bands he cares about and an existing config needs no
|
||||||
|
// migration.
|
||||||
|
BandFreqs map[string]int `json:"band_freqs"`
|
||||||
// Legacy tunable range (MHz). Superseded by Bands; kept so an older config
|
// Legacy tunable range (MHz). Superseded by Bands; kept so an older config
|
||||||
// migrates cleanly (the range is converted to a band set on load) and so the
|
// migrates cleanly (the range is converted to a band set on load) and so the
|
||||||
// value round-trips. Not used by the follow filter once Bands is set.
|
// value round-trips. Not used by the follow filter once Bands is set.
|
||||||
@@ -14648,16 +14698,39 @@ type UltrabeamSettings struct {
|
|||||||
FreqMaxMHz int `json:"freq_max_mhz"`
|
FreqMaxMHz int `json:"freq_max_mhz"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tracking modes. Stored as strings rather than an int so a settings row stays
|
||||||
|
// readable when diagnosing an antenna that moves too much or not at all.
|
||||||
|
const (
|
||||||
|
motorTrackAlways = "always"
|
||||||
|
motorTrackStep = "step"
|
||||||
|
motorTrackBand = "band"
|
||||||
|
)
|
||||||
|
|
||||||
|
// normMotorTrackMode keeps an unknown or empty value on the threshold mode
|
||||||
|
// instead of guessing — a config written before this option existed then
|
||||||
|
// behaves exactly as it did.
|
||||||
|
func normMotorTrackMode(m string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(m)) {
|
||||||
|
case motorTrackAlways:
|
||||||
|
return motorTrackAlways
|
||||||
|
case motorTrackBand:
|
||||||
|
return motorTrackBand
|
||||||
|
default:
|
||||||
|
return motorTrackStep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetUltrabeamSettings returns the persisted motorized-antenna config, defaulting
|
// GetUltrabeamSettings returns the persisted motorized-antenna config, defaulting
|
||||||
// to the pre-SteppIR behaviour (Ultrabeam over TCP) so an existing install is
|
// to the pre-SteppIR behaviour (Ultrabeam over TCP) so an existing install is
|
||||||
// unchanged.
|
// unchanged.
|
||||||
func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
|
func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
|
||||||
out := UltrabeamSettings{Type: "ultrabeam", Transport: "tcp", Port: 23, Baud: 9600, StepKHz: 50}
|
out := UltrabeamSettings{Type: "ultrabeam", Transport: "tcp", Port: 23, Baud: 9600, StepKHz: 50, TrackMode: motorTrackStep}
|
||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return out, fmt.Errorf("db not initialized")
|
return out, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
m, err := a.settings.GetMany(a.ctx, keyUltrabeamEnabled, keyUltrabeamHost, keyUltrabeamPort, keyUltrabeamFollow, keyUltrabeamStep,
|
m, err := a.settings.GetMany(a.ctx, keyUltrabeamEnabled, keyUltrabeamHost, keyUltrabeamPort, keyUltrabeamFollow, keyUltrabeamStep,
|
||||||
keyMotorType, keyMotorTransport, keyMotorCOM, keyMotorBaud, keyMotorTXInhibit, keyMotorFreqMin, keyMotorFreqMax, keyMotorBands)
|
keyMotorType, keyMotorTransport, keyMotorCOM, keyMotorBaud, keyMotorTXInhibit, keyMotorFreqMin, keyMotorFreqMax, keyMotorBands,
|
||||||
|
keyMotorTrackMode, keyMotorBandFreqs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -14681,6 +14754,8 @@ func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
|
|||||||
if st, _ := strconv.Atoi(m[keyUltrabeamStep]); st == 25 || st == 50 || st == 100 {
|
if st, _ := strconv.Atoi(m[keyUltrabeamStep]); st == 25 || st == 50 || st == 100 {
|
||||||
out.StepKHz = st
|
out.StepKHz = st
|
||||||
}
|
}
|
||||||
|
out.TrackMode = normMotorTrackMode(m[keyMotorTrackMode])
|
||||||
|
out.BandFreqs = decodeMotorBandFreqs(m[keyMotorBandFreqs])
|
||||||
out.FreqMinMHz, _ = strconv.Atoi(m[keyMotorFreqMin])
|
out.FreqMinMHz, _ = strconv.Atoi(m[keyMotorFreqMin])
|
||||||
out.FreqMaxMHz, _ = strconv.Atoi(m[keyMotorFreqMax])
|
out.FreqMaxMHz, _ = strconv.Atoi(m[keyMotorFreqMax])
|
||||||
// Bands is the follow filter. If it was saved, use it verbatim. Otherwise this
|
// Bands is the follow filter. If it was saved, use it verbatim. Otherwise this
|
||||||
@@ -14746,6 +14821,8 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
|
|||||||
keyUltrabeamPort: strconv.Itoa(s.Port),
|
keyUltrabeamPort: strconv.Itoa(s.Port),
|
||||||
keyUltrabeamFollow: boolStr(s.Follow),
|
keyUltrabeamFollow: boolStr(s.Follow),
|
||||||
keyUltrabeamStep: strconv.Itoa(s.StepKHz),
|
keyUltrabeamStep: strconv.Itoa(s.StepKHz),
|
||||||
|
keyMotorTrackMode: normMotorTrackMode(s.TrackMode),
|
||||||
|
keyMotorBandFreqs: encodeMotorBandFreqs(normMotorBandFreqs(s.BandFreqs)),
|
||||||
keyMotorType: s.Type,
|
keyMotorType: s.Type,
|
||||||
keyMotorTransport: s.Transport,
|
keyMotorTransport: s.Transport,
|
||||||
keyMotorCOM: strings.TrimSpace(s.COM),
|
keyMotorCOM: strings.TrimSpace(s.COM),
|
||||||
@@ -14831,12 +14908,91 @@ func (a *App) startUltrabeam() {
|
|||||||
// fitted) while keeping its neighbours — something a contiguous min/max range
|
// fitted) while keeping its neighbours — something a contiguous min/max range
|
||||||
// can't express. nomMHz is a representative in-band frequency, used only to
|
// can't express. nomMHz is a representative in-band frequency, used only to
|
||||||
// migrate a legacy FreqMin/FreqMax range into a band set.
|
// migrate a legacy FreqMin/FreqMax range into a band set.
|
||||||
|
// defKHz is where a band button tunes the antenna when the operator has not
|
||||||
|
// chosen a frequency for that band — roughly mid-band, where a beam's pattern is
|
||||||
|
// usable across the whole allocation. It is only a default: an operator who
|
||||||
|
// lives in the CW segment sets his own, exactly as the SteppIR controller's own
|
||||||
|
// "Frequency (KHz)" column does.
|
||||||
var motorBands = []struct {
|
var motorBands = []struct {
|
||||||
name string
|
name string
|
||||||
nomMHz int
|
nomMHz int
|
||||||
|
defKHz int
|
||||||
}{
|
}{
|
||||||
{"40m", 7}, {"30m", 10}, {"20m", 14}, {"17m", 18},
|
{"40m", 7, 7100}, {"30m", 10, 10125}, {"20m", 14, 14150}, {"17m", 18, 18110},
|
||||||
{"15m", 21}, {"12m", 24}, {"10m", 28}, {"6m", 50},
|
{"15m", 21, 21150}, {"12m", 24, 24930}, {"10m", 28, 28400}, {"6m", 50, 50150},
|
||||||
|
}
|
||||||
|
|
||||||
|
// motorBandDefaultKHz is the fallback tune frequency for a band, 0 if unknown.
|
||||||
|
func motorBandDefaultKHz(band string) int {
|
||||||
|
band = strings.ToLower(strings.TrimSpace(band))
|
||||||
|
for _, b := range motorBands {
|
||||||
|
if b.name == band {
|
||||||
|
return b.defKHz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// normMotorBandFreqs keeps only entries that name a real motor band AND whose
|
||||||
|
// frequency actually falls in that band.
|
||||||
|
//
|
||||||
|
// The check matters: this value is fed straight to the antenna as a tune
|
||||||
|
// command. A slip of one digit — 1450 for 20 m, or kHz typed as MHz — would send
|
||||||
|
// the elements travelling to a length that is wrong for the band the operator is
|
||||||
|
// on, and on a SteppIR that is a long, transmit-inhibited journey to a position
|
||||||
|
// nobody asked for. An entry that fails the check is dropped, so the band falls
|
||||||
|
// back to its default rather than to nonsense.
|
||||||
|
func normMotorBandFreqs(in map[string]int) map[string]int {
|
||||||
|
out := map[string]int{}
|
||||||
|
for _, b := range motorBands {
|
||||||
|
khz, ok := in[b.name]
|
||||||
|
if !ok || khz <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if bandForHz(int64(khz)*1000) != b.name {
|
||||||
|
applog.Printf("motor-antenna: ignoring %d kHz for %s — not in that band", khz, b.name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[b.name] = khz
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeMotorBandFreqs / decodeMotorBandFreqs store the map as "40m=7100,20m=14150".
|
||||||
|
// A flat string rather than JSON so the settings row stays readable, and so a
|
||||||
|
// value corrupted by hand degrades one band instead of the whole set.
|
||||||
|
func encodeMotorBandFreqs(m map[string]int) string {
|
||||||
|
parts := []string{}
|
||||||
|
for _, b := range motorBands { // canonical order, not map order
|
||||||
|
if khz := m[b.name]; khz > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s=%d", b.name, khz))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeMotorBandFreqs(s string) map[string]int {
|
||||||
|
out := map[string]int{}
|
||||||
|
for _, kv := range strings.Split(s, ",") {
|
||||||
|
name, val, ok := strings.Cut(strings.TrimSpace(kv), "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if khz, err := strconv.Atoi(strings.TrimSpace(val)); err == nil && khz > 0 {
|
||||||
|
out[strings.ToLower(strings.TrimSpace(name))] = khz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normMotorBandFreqs(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// motorTuneKHzForBand is the frequency a band button commands: the operator's
|
||||||
|
// choice when set, the default otherwise.
|
||||||
|
func motorTuneKHzForBand(m map[string]int, band string) int {
|
||||||
|
band = strings.ToLower(strings.TrimSpace(band))
|
||||||
|
if khz := m[band]; khz > 0 {
|
||||||
|
return khz
|
||||||
|
}
|
||||||
|
return motorBandDefaultKHz(band)
|
||||||
}
|
}
|
||||||
|
|
||||||
// motorBandNames is the full ordered set (all bands enabled).
|
// motorBandNames is the full ordered set (all bands enabled).
|
||||||
@@ -14985,10 +15141,21 @@ func (a *App) motorTXInhibitLoop(c motorAntenna, bands []string, stop <-chan str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, bands []string, stop <-chan struct{}) {
|
func (a *App) ultrabeamFollowLoop(c motorAntenna, mode string, stepKHz int, bands []string, stop <-chan struct{}) {
|
||||||
if stepKHz <= 0 {
|
if stepKHz <= 0 {
|
||||||
stepKHz = 50
|
stepKHz = 50
|
||||||
}
|
}
|
||||||
|
mode = normMotorTrackMode(mode)
|
||||||
|
// "Every time the frequency changes" is the threshold mode with the smallest
|
||||||
|
// threshold there is: the loop already re-tunes when the rig has moved at
|
||||||
|
// least stepKHz from the last commanded frequency, and 1 kHz makes that
|
||||||
|
// "moved at all". Expressing it this way keeps ONE decision path, so the
|
||||||
|
// deadband reference — which is the rig, not the antenna's own flaky reported
|
||||||
|
// frequency — cannot drift out of step between modes.
|
||||||
|
if mode == motorTrackAlways {
|
||||||
|
stepKHz = 1
|
||||||
|
}
|
||||||
|
lastCmdBand := "" // band of the last commanded move — the reference in band mode
|
||||||
ticker := time.NewTicker(1500 * time.Millisecond)
|
ticker := time.NewTicker(1500 * time.Millisecond)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
lastRigKHz := 0 // only log when the followed rig frequency actually changes
|
lastRigKHz := 0 // only log when the followed rig frequency actually changes
|
||||||
@@ -15046,19 +15213,35 @@ func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, bands []string, s
|
|||||||
ref = c.LastSetKHz()
|
ref = c.LastSetKHz()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
diff := rigKHz - ref
|
// Band mode ignores the threshold entirely: the antenna is re-tuned once
|
||||||
if diff < 0 {
|
// on entering a band and then left alone however far the rig roams
|
||||||
diff = -diff
|
// inside it. The reference is the band we last COMMANDED for, not the
|
||||||
}
|
// rig's previous band — otherwise a first move after startup, or any
|
||||||
if ref > 0 && diff < stepKHz {
|
// move the operator made by hand, would never be reconciled.
|
||||||
continue // within the deadband — leave the motors alone
|
if mode == motorTrackBand {
|
||||||
|
b := bandForHz(rs.FreqHz)
|
||||||
|
if b == lastCmdBand {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if newFreq {
|
||||||
|
applog.Printf("ultrabeam: band changed %q → %q — re-tuning to %d kHz", lastCmdBand, b, rigKHz)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
diff := rigKHz - ref
|
||||||
|
if diff < 0 {
|
||||||
|
diff = -diff
|
||||||
|
}
|
||||||
|
if ref > 0 && diff < stepKHz {
|
||||||
|
continue // within the deadband — leave the motors alone
|
||||||
|
}
|
||||||
}
|
}
|
||||||
a.noteMotorMoveCommanded()
|
a.noteMotorMoveCommanded()
|
||||||
if err := c.SetFrequency(rigKHz, st.Direction); err != nil {
|
if err := c.SetFrequency(rigKHz, st.Direction); err != nil {
|
||||||
applog.Printf("ultrabeam: follow re-tune to %d kHz failed: %v", rigKHz, err)
|
applog.Printf("ultrabeam: follow re-tune to %d kHz failed: %v", rigKHz, err)
|
||||||
} else {
|
} else {
|
||||||
lastCmdKHz = rigKHz
|
lastCmdKHz = rigKHz
|
||||||
applog.Printf("ultrabeam: followed rig → %d kHz (dir %d, was ref %d kHz, step %d)", rigKHz, st.Direction, ref, stepKHz)
|
lastCmdBand = bandForHz(rs.FreqHz)
|
||||||
|
applog.Printf("ultrabeam: followed rig → %d kHz (dir %d, was ref %d kHz, mode %s, step %d)", rigKHz, st.Direction, ref, mode, stepKHz)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15078,12 +15261,17 @@ type UltrabeamStatusInfo struct {
|
|||||||
Elements []int `json:"elements"` // per-element lengths (mm); empty when unsupported
|
Elements []int `json:"elements"` // per-element lengths (mm); empty when unsupported
|
||||||
// Follow and StepKHz are mirrored here so the Station Control widget can show
|
// Follow and StepKHz are mirrored here so the Station Control widget can show
|
||||||
// and change tracking without loading the whole settings block for a poll.
|
// and change tracking without loading the whole settings block for a poll.
|
||||||
Follow bool `json:"follow"`
|
Follow bool `json:"follow"`
|
||||||
StepKHz int `json:"step_khz"`
|
StepKHz int `json:"step_khz"`
|
||||||
|
TrackMode string `json:"track_mode"`
|
||||||
// Bands the antenna is configured to cover — the widget offers exactly these
|
// Bands the antenna is configured to cover — the widget offers exactly these
|
||||||
// as buttons rather than inventing its own list, so a band dropped in Settings
|
// as buttons rather than inventing its own list, so a band dropped in Settings
|
||||||
// cannot be clicked here.
|
// cannot be clicked here.
|
||||||
Bands []string `json:"bands"`
|
Bands []string `json:"bands"`
|
||||||
|
// Where each band button tunes. Resolved here — operator's choice or the
|
||||||
|
// default — so the widget never has to carry its own copy of the band table
|
||||||
|
// and cannot drift from what Settings shows.
|
||||||
|
BandFreqs map[string]int `json:"band_freqs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUltrabeamStatus returns the antenna's current state for the UI poll.
|
// GetUltrabeamStatus returns the antenna's current state for the UI poll.
|
||||||
@@ -15094,6 +15282,13 @@ func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo {
|
|||||||
out.Type = s.Type
|
out.Type = s.Type
|
||||||
out.Follow = s.Follow
|
out.Follow = s.Follow
|
||||||
out.StepKHz = s.StepKHz
|
out.StepKHz = s.StepKHz
|
||||||
|
out.TrackMode = normMotorTrackMode(s.TrackMode)
|
||||||
|
out.BandFreqs = map[string]int{}
|
||||||
|
for _, b := range s.Bands {
|
||||||
|
if khz := motorTuneKHzForBand(s.BandFreqs, b); khz > 0 {
|
||||||
|
out.BandFreqs[b] = khz
|
||||||
|
}
|
||||||
|
}
|
||||||
out.Bands = append(out.Bands, s.Bands...)
|
out.Bands = append(out.Bands, s.Bands...)
|
||||||
if a.motorAnt == nil {
|
if a.motorAnt == nil {
|
||||||
return out
|
return out
|
||||||
@@ -15199,7 +15394,7 @@ func (a *App) MotorNudgeKHz(deltaKHz int) error {
|
|||||||
// opening Settings. Both are ordinary operating decisions — an operator turns
|
// opening Settings. Both are ordinary operating decisions — an operator turns
|
||||||
// tracking off to park the antenna and back on to resume — and a preferences
|
// tracking off to park the antenna and back on to resume — and a preferences
|
||||||
// dialog is the wrong place for something changed that often.
|
// dialog is the wrong place for something changed that often.
|
||||||
func (a *App) SetMotorFollow(on bool, stepKHz int) error {
|
func (a *App) SetMotorFollow(on bool, stepKHz int, mode string) error {
|
||||||
s, err := a.GetUltrabeamSettings()
|
s, err := a.GetUltrabeamSettings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -15211,6 +15406,11 @@ func (a *App) SetMotorFollow(on bool, stepKHz int) error {
|
|||||||
default:
|
default:
|
||||||
return fmt.Errorf("step must be 25, 50 or 100 kHz")
|
return fmt.Errorf("step must be 25, 50 or 100 kHz")
|
||||||
}
|
}
|
||||||
|
// An empty mode leaves it alone, so the caller toggling tracking on and off
|
||||||
|
// does not have to know or resend it.
|
||||||
|
if strings.TrimSpace(mode) != "" {
|
||||||
|
s.TrackMode = normMotorTrackMode(mode)
|
||||||
|
}
|
||||||
s.Follow = on
|
s.Follow = on
|
||||||
|
|
||||||
// Persist WITHOUT the restart. SaveUltrabeamSettings tears the client down and
|
// Persist WITHOUT the restart. SaveUltrabeamSettings tears the client down and
|
||||||
@@ -15232,6 +15432,9 @@ func (a *App) SetMotorFollow(on bool, stepKHz int) error {
|
|||||||
if err := a.settings.Set(a.ctx, keyUltrabeamStep, strconv.Itoa(s.StepKHz)); err != nil {
|
if err := a.settings.Set(a.ctx, keyUltrabeamStep, strconv.Itoa(s.StepKHz)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.settings.Set(a.ctx, keyMotorTrackMode, normMotorTrackMode(s.TrackMode)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
a.restartMotorFollow(s)
|
a.restartMotorFollow(s)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -15249,8 +15452,8 @@ func (a *App) restartMotorFollow(s UltrabeamSettings) {
|
|||||||
}
|
}
|
||||||
stop := make(chan struct{})
|
stop := make(chan struct{})
|
||||||
a.ubFollowStop = stop
|
a.ubFollowStop = stop
|
||||||
applog.Printf("ultrabeam: follow loop restarting — covered bands %v, step %d kHz", s.Bands, s.StepKHz)
|
applog.Printf("ultrabeam: follow loop restarting — covered bands %v, mode %s, step %d kHz", s.Bands, normMotorTrackMode(s.TrackMode), s.StepKHz)
|
||||||
go a.ultrabeamFollowLoop(a.motorAnt, s.StepKHz, s.Bands, stop)
|
go a.ultrabeamFollowLoop(a.motorAnt, s.TrackMode, s.StepKHz, s.Bands, stop)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UltrabeamRetract retracts all elements (storage / safe position).
|
// UltrabeamRetract retracts all elements (storage / safe position).
|
||||||
|
|||||||
@@ -126,7 +126,13 @@ func (a *App) startBandOpenFeed() {
|
|||||||
a.pskr = nil
|
a.pskr = nil
|
||||||
}
|
}
|
||||||
s := a.GetBandOpenSettings()
|
s := a.GetBandOpenSettings()
|
||||||
|
a.bandOpen.on.Store(s.Enabled)
|
||||||
if !s.Enabled {
|
if !s.Enabled {
|
||||||
|
// Put out whatever is currently lit. Leaving the badges up would keep
|
||||||
|
// announcing an opening from a watch that is now off, and they only fade
|
||||||
|
// on a timer fed by spots this path no longer looks at — so they would
|
||||||
|
// hang there until the app restarted.
|
||||||
|
a.clearBandOpenings()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Every spot is measured from the operator's position. Without one there is
|
// Every spot is measured from the operator's position. Without one there is
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
"hamlog/internal/applog"
|
||||||
@@ -21,6 +22,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type bandOpenState struct {
|
type bandOpenState struct {
|
||||||
|
// on mirrors the "Watch for band openings" setting.
|
||||||
|
//
|
||||||
|
// Cached rather than read per spot: this is the cluster hot path, where a
|
||||||
|
// settings query per spot is exactly what the rest of this file avoids.
|
||||||
|
// startBandOpenFeed owns it — it runs at startup and again on every save, so
|
||||||
|
// the switch takes effect without a restart.
|
||||||
|
on atomic.Bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
det *bandopen.Detector
|
det *bandopen.Detector
|
||||||
last []bandopen.Opening // most recent first, for the UI
|
last []bandopen.Opening // most recent first, for the UI
|
||||||
@@ -45,6 +53,16 @@ const maxRememberedOpenings = 20
|
|||||||
|
|
||||||
// detectBandOpening feeds one spot to the detector and announces a hit.
|
// detectBandOpening feeds one spot to the detector and announces a hit.
|
||||||
func (a *App) detectBandOpening(s cluster.Spot) {
|
func (a *App) detectBandOpening(s cluster.Spot) {
|
||||||
|
// The watch has to be switched on.
|
||||||
|
//
|
||||||
|
// It was not checked here at all: the setting only ever governed the extra
|
||||||
|
// DATA SOURCES (the RBN nodes and the PSK Reporter feed), while the detector
|
||||||
|
// itself ran on every ordinary cluster spot. So an operator who had never
|
||||||
|
// enabled the watch still got opening banners, from a feature they had
|
||||||
|
// deliberately left off.
|
||||||
|
if !a.bandOpen.on.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
// No operator grid = no distance and no bearing on the spot, and the whole
|
// No operator grid = no distance and no bearing on the spot, and the whole
|
||||||
// detection rests on those two. Say nothing rather than guess.
|
// detection rests on those two. Say nothing rather than guess.
|
||||||
if !a.opSet || s.DistanceKm <= 0 || !bandopen.Watched(s.Band) {
|
if !a.opSet || s.DistanceKm <= 0 || !bandopen.Watched(s.Band) {
|
||||||
@@ -122,6 +140,21 @@ func (a *App) GetLiveOpenings() []bandopen.Opening {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clearBandOpenings puts out every lit badge and forgets the detector's window.
|
||||||
|
// Called when the watch is switched off: the badges fade on a timer fed by
|
||||||
|
// spots the detector no longer looks at, so without this they would stay up
|
||||||
|
// until the next restart. The remembered list is left alone — those openings
|
||||||
|
// really did happen, and the operator may still want to see what he missed.
|
||||||
|
func (a *App) clearBandOpenings() {
|
||||||
|
a.bandOpen.mu.Lock()
|
||||||
|
defer a.bandOpen.mu.Unlock()
|
||||||
|
a.bandOpen.live = nil
|
||||||
|
a.bandOpen.aliveUntil = nil
|
||||||
|
// Drop the accumulated spot window too, so switching the watch back on starts
|
||||||
|
// from what is on the air now rather than from an hour-old burst.
|
||||||
|
a.bandOpen.det = nil
|
||||||
|
}
|
||||||
|
|
||||||
// announceOpening logs and pushes one detection. Shared by both feeds — the
|
// announceOpening logs and pushes one detection. Shared by both feeds — the
|
||||||
// cluster path here and the PSK Reporter path in bandopen_sources.go — so an
|
// cluster path here and the PSK Reporter path in bandopen_sources.go — so an
|
||||||
// opening reads the same however it was noticed.
|
// opening reads the same however it was noticed.
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/bandopen"
|
||||||
|
"hamlog/internal/cluster"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The "Watch for band openings" switch has to gate the DETECTOR, not just the
|
||||||
|
// extra data sources.
|
||||||
|
//
|
||||||
|
// It originally governed only the RBN nodes and the PSK Reporter feed, while
|
||||||
|
// the detector itself ran on every ordinary cluster spot — so an operator who
|
||||||
|
// had never enabled the watch still got opening banners for a feature he had
|
||||||
|
// deliberately left off. That is what this pins.
|
||||||
|
func TestBandOpenWatchGatesTheDetector(t *testing.T) {
|
||||||
|
spot := func() cluster.Spot {
|
||||||
|
return cluster.Spot{
|
||||||
|
DXCall: "EA1ABC", Band: "6m", DistanceKm: 1400, ShortPath: 210,
|
||||||
|
ReceivedAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switched off: the spot must not even reach the detector.
|
||||||
|
off := &App{opSet: true, opLat: 48.0, opLon: 2.0}
|
||||||
|
off.detectBandOpening(spot())
|
||||||
|
if off.bandOpen.det != nil {
|
||||||
|
t.Error("the detector ran with the watch switched off")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switched on: the same spot is accepted (one spot is not an opening, so
|
||||||
|
// nothing is announced — but the detector now exists and is collecting).
|
||||||
|
on := &App{opSet: true, opLat: 48.0, opLon: 2.0}
|
||||||
|
on.bandOpen.on.Store(true)
|
||||||
|
on.detectBandOpening(spot())
|
||||||
|
if on.bandOpen.det == nil {
|
||||||
|
t.Error("the detector did not run with the watch switched on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switching the watch off must put the badges out. They fade on a timer fed by
|
||||||
|
// spots the detector no longer looks at, so left alone they would stay lit
|
||||||
|
// until the next restart.
|
||||||
|
func TestClearBandOpeningsPutsTheBadgesOut(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
a.bandOpen.live = map[string]bandopen.Opening{"6m": {Band: "6m", Calls: 9}}
|
||||||
|
a.bandOpen.aliveUntil = map[string]time.Time{"6m": time.Now().Add(time.Hour)}
|
||||||
|
a.bandOpen.det = bandopen.New(bandopen.DefaultConfig())
|
||||||
|
a.bandOpen.last = []bandopen.Opening{{Band: "6m", Calls: 9}}
|
||||||
|
|
||||||
|
a.clearBandOpenings()
|
||||||
|
|
||||||
|
if got := a.GetLiveOpenings(); len(got) != 0 {
|
||||||
|
t.Errorf("a badge stayed lit after the watch was switched off: %v", got)
|
||||||
|
}
|
||||||
|
if a.bandOpen.det != nil {
|
||||||
|
t.Error("the accumulated spot window survived — switching back on would start from a stale burst")
|
||||||
|
}
|
||||||
|
// The history is NOT cleared: those openings really happened.
|
||||||
|
if len(a.GetBandOpenings()) != 1 {
|
||||||
|
t.Error("the remembered openings were thrown away")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,30 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.24.7",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"QSOs logged from WSJT-X now carry the space weather and the distance, like hand-logged ones.",
|
||||||
|
"Band openings: EME contacts are no longer mistaken for an opening. Each band now has the longest path the atmosphere can carry.",
|
||||||
|
"Kenwood: WSJT-X \"Fake It\" no longer leaves the dial on the transmit frequency.",
|
||||||
|
"PowerGenius XL: the Station Control card now shows power, current, SWR and temperature without a FlexRadio.",
|
||||||
|
"Motorized antennas: tracking now offers three modes — every frequency change, past a step, or band change only.",
|
||||||
|
"Motorized antennas: each covered band now has its own tune frequency, set in Settings.",
|
||||||
|
"Awards: a single award can now be exported on its own.",
|
||||||
|
"Awards: each reference now has its own validity window, so a reference that ceased to exist stops counting for later QSOs.",
|
||||||
|
"Band openings: the watch switch now governs the detection itself, not just the extra data sources."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Les QSO enregistrés depuis WSJT-X portent désormais la météo spatiale et la distance, comme ceux saisis à la main.",
|
||||||
|
"Ouvertures de bande : les contacts EME ne sont plus pris pour une ouverture. Chaque bande a désormais la distance maximale que l atmosphère peut porter.",
|
||||||
|
"Kenwood : le « Fake It » de WSJT-X ne laisse plus le VFO sur la fréquence d émission.",
|
||||||
|
"PowerGenius XL : la carte du Contrôle station affiche puissance, courant, ROS et température sans FlexRadio.",
|
||||||
|
"Antennes motorisées : le suivi propose trois modes — à chaque changement de fréquence, au-delà d un pas, ou au changement de bande.",
|
||||||
|
"Antennes motorisées : chaque bande couverte a désormais sa propre fréquence d accord, réglable dans les Réglages.",
|
||||||
|
"Diplômes : un diplôme peut désormais être exporté seul.",
|
||||||
|
"Diplômes : chaque référence a désormais sa fenêtre de validité, une référence disparue cesse donc de compter pour les QSO suivants.",
|
||||||
|
"Ouvertures de bande : l interrupteur de la veille gouverne désormais la détection elle-même, plus seulement les sources de données."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.24.6",
|
"version": "0.24.6",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
+11
-5
@@ -90,7 +90,7 @@ import { ExportFieldsDialog } from '@/components/ExportFieldsDialog';
|
|||||||
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
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 } from '@/lib/spotDisplay';
|
import { applySpotDisplay, readSpotDisplayOptions, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
|
||||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||||
import { NetControlPanel } from '@/components/NetControlPanel';
|
import { NetControlPanel } from '@/components/NetControlPanel';
|
||||||
import { ContestPanel, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestPanel';
|
import { ContestPanel, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestPanel';
|
||||||
@@ -1497,8 +1497,11 @@ export default function App() {
|
|||||||
// beside Hide worked and not among the status chips.
|
// beside Hide worked and not among the status chips.
|
||||||
const [clusterLotwOnly, setClusterLotwOnly] = useState(() => localStorage.getItem('opslog.clusterLotwOnly') === '1');
|
const [clusterLotwOnly, setClusterLotwOnly] = useState(() => localStorage.getItem('opslog.clusterLotwOnly') === '1');
|
||||||
const [clusterSpotterConts, setClusterSpotterConts] = useState<Set<string>>(() => lsSet<string>('opslog.clusterSpotterCont'));
|
const [clusterSpotterConts, setClusterSpotterConts] = useState<Set<string>>(() => lsSet<string>('opslog.clusterSpotterCont'));
|
||||||
const [clusterMuteWorked, setClusterMuteWorked] = useState(() => localStorage.getItem('opslog.clusterMuteWorked') === '1');
|
// Read through lib/spotDisplay, not straight from localStorage: while the two
|
||||||
const [clusterSlotHighlight, setClusterSlotHighlight] = useState(() => localStorage.getItem('opslog.clusterSlotHighlight') === '1');
|
// options are withdrawn it answers false, so the cluster list cannot end up
|
||||||
|
// applying an option the operator can no longer see or switch off.
|
||||||
|
const [clusterMuteWorked, setClusterMuteWorked] = useState(() => readSpotDisplayOptions().muteWorked);
|
||||||
|
const [clusterSlotHighlight, setClusterSlotHighlight] = useState(() => readSpotDisplayOptions().slotHighlight);
|
||||||
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
|
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
|
||||||
// Mode filter chips. Empty set = show every mode. Categories map the
|
// Mode filter chips. Empty set = show every mode. Categories map the
|
||||||
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
||||||
@@ -4843,8 +4846,11 @@ export default function App() {
|
|||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{fSwitch(t('clu.hideWorked'), clusterHideWorked, setClusterHideWorked)}
|
{fSwitch(t('clu.hideWorked'), clusterHideWorked, setClusterHideWorked)}
|
||||||
{fSwitch(t('clu.groupDup'), clusterGroup, setClusterGroup)}
|
{fSwitch(t('clu.groupDup'), clusterGroup, setClusterGroup)}
|
||||||
{fSwitch(t('clu.muteWorkedShort'), clusterMuteWorked, (v) => { setClusterMuteWorked(v); writeUiPref('opslog.clusterMuteWorked', v ? '1' : '0'); })}
|
{/* The two display options are withdrawn for now — the flag is in
|
||||||
{fSwitch(t('clu.slotHighlightShort'), clusterSlotHighlight, (v) => { setClusterSlotHighlight(v); writeUiPref('opslog.clusterSlotHighlight', v ? '1' : '0'); })}
|
lib/spotDisplay, and it also forces them off for the band map, so
|
||||||
|
there is one place to flip when they come back. */}
|
||||||
|
{SPOT_DISPLAY_OPTIONS_EXPOSED && fSwitch(t('clu.muteWorkedShort'), clusterMuteWorked, (v) => { setClusterMuteWorked(v); writeUiPref('opslog.clusterMuteWorked', v ? '1' : '0'); })}
|
||||||
|
{SPOT_DISPLAY_OPTIONS_EXPOSED && fSwitch(t('clu.slotHighlightShort'), clusterSlotHighlight, (v) => { setClusterSlotHighlight(v); writeUiPref('opslog.clusterSlotHighlight', v ? '1' : '0'); })}
|
||||||
{fSwitch(t('clu.lotwOnly'), clusterLotwOnly, (v) => { setClusterLotwOnly(v); writeUiPref('opslog.clusterLotwOnly', v ? '1' : '0'); })}
|
{fSwitch(t('clu.lotwOnly'), clusterLotwOnly, (v) => { setClusterLotwOnly(v); writeUiPref('opslog.clusterLotwOnly', v ? '1' : '0'); })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,45 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
|||||||
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
|
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
|
||||||
const connected = !!pg.connected || viaFlex;
|
const connected = !!pg.connected || viaFlex;
|
||||||
const fault = flex?.amp_fault;
|
const fault = flex?.amp_fault;
|
||||||
|
|
||||||
|
// Meters built from the amplifier's own GSCP status frame, for when the radio
|
||||||
|
// is not feeding a meter stream.
|
||||||
|
//
|
||||||
|
// Whether there is power is the amp's state field; how much is the plain
|
||||||
|
// forward figure. NOT "peakfwd" — that is a latched maximum which is never
|
||||||
|
// reset and survives in the last-known status after the amp disconnects, so it
|
||||||
|
// once claimed 1350 W from an old transmission while 10 W was going out. Same
|
||||||
|
// reason peak_id is left alone. Both readings are gated on transmit so they
|
||||||
|
// fall back to zero between overs instead of freezing on the last one.
|
||||||
|
const pgxlMeters = () => {
|
||||||
|
if (!pg.connected) return null;
|
||||||
|
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : /TRANSMIT/i.test(pg.state || '');
|
||||||
|
const fwdW = peakHold('pgfwd', txing ? Number(pg.fwd_w) || 0 : 0);
|
||||||
|
const idA = peakHold('pgid', txing ? Number(pg.id) || 0 : 0);
|
||||||
|
const swr = peakHold('pgswr', txing ? Number(pg.vswr) || 0 : 0);
|
||||||
|
const tempC = Number(pg.temperature) || 0;
|
||||||
|
// Two columns, not four. The card sits beside a tall neighbour in Station
|
||||||
|
// Control, so a single row of four leaves the height empty and squeezes each
|
||||||
|
// bar into a quarter width — two rows of two use the room that is already
|
||||||
|
// there and give every bar twice the resolution.
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||||
|
<MeterBar label={t('flxp.outputPower')} value={fwdW} unit="W" lo={0} hi={2000}
|
||||||
|
display={`${Math.round(fwdW)} W`}
|
||||||
|
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
<MeterBar label={t('ampw.id')} value={idA} lo={0} hi={25} display={`${idA.toFixed(1)} A`} accent="#16a34a" />
|
||||||
|
{/* Below 1:1 the reading is meaningless, so an idle amp shows a flat bar
|
||||||
|
rather than a zero that looks like a perfect match. */}
|
||||||
|
<MeterBar label={t('ampw.swr')} value={swr >= 1 ? swr : 1} lo={1} hi={3}
|
||||||
|
display={swr >= 1 ? swr.toFixed(1) : '—'}
|
||||||
|
segColor={(f) => (f > 0.75 ? '#dc2626' : f > 0.4 ? '#f59e0b' : '#16a34a')} />
|
||||||
|
<MeterBar label={t('ampw.temp')} value={tempC} unit="°C" lo={0} hi={100}
|
||||||
|
display={tempC > 0 ? `${Math.round(tempC)} °C` : '—'}
|
||||||
|
segColor={(f) => (f > 0.8 ? '#dc2626' : f > 0.6 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')}${flex?.amp_model ? ' · ' + flex.amp_model : (pg.model ? ' · ' + pg.model : '')} · ${amp.name}`} accent="#ea580c">
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')}${flex?.amp_model ? ' · ' + flex.amp_model : (pg.model ? ' · ' + pg.model : '')} · ${amp.name}`} accent="#ea580c">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
@@ -204,13 +243,20 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
|||||||
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {fault}</span>
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {fault}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Amplifier meters (FWD / ID / TEMP …) from the FlexRadio UDP stream. */}
|
{/* Amplifier meters (FWD / ID / TEMP …).
|
||||||
{viaFlex && (() => {
|
The FlexRadio UDP stream is the preferred source — it is fast and reads
|
||||||
|
the same as SmartSDR. When there is no Flex, or it is not streaming,
|
||||||
|
the amplifier's OWN link carries the same figures; falling back to them
|
||||||
|
is what the docked widget already does. Without that fallback this card
|
||||||
|
showed an operator on a Kenwood nothing but OPERATE and the fan mode,
|
||||||
|
while the amplifier was reporting power, current and temperature all
|
||||||
|
along. */}
|
||||||
|
{(() => {
|
||||||
const meters = (flex?.meters as any[]) || [];
|
const meters = (flex?.meters as any[]) || [];
|
||||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||||
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||||
if (amps.length === 0) return null;
|
if (!viaFlex || amps.length === 0) return pgxlMeters();
|
||||||
// Power comes from the radio's meter stream and nothing else. The
|
// Power comes from the radio's meter stream and nothing else. The
|
||||||
// amplifier also reports a "peakfwd", and using it was a mistake twice
|
// amplifier also reports a "peakfwd", and using it was a mistake twice
|
||||||
// over: it is a latched maximum that is never reset, and it survives in
|
// over: it is a latched maximum that is never reset, and it survives in
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen, ArrowUpCircle } from 'lucide-react';
|
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen, ArrowUpCircle, Share2 } from 'lucide-react';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
ListCountries, DXCCForCountry, DXCCName,
|
ListCountries, DXCCForCountry, DXCCName,
|
||||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||||
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
||||||
ExportAwardForCatalog,
|
ExportAwardForCatalog, ExportAward,
|
||||||
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
@@ -97,6 +97,13 @@ function Chips({ all, value, onToggle }: { all: string[]; value: string[]; onTog
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Awards use a far-future date as "no end" (RDA carries 9999-12-31). Printing it
|
||||||
|
// back at the operator reads like a real deadline, so show it as open-ended.
|
||||||
|
function openEnded(d?: string): string {
|
||||||
|
if (!d) return '—';
|
||||||
|
return /^9\d{3}-/.test(d) ? '—' : d;
|
||||||
|
}
|
||||||
|
|
||||||
function Field2({ label, children }: { label: string; children: React.ReactNode }) {
|
function Field2({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[120px_1fr] items-center gap-2">
|
<div className="grid grid-cols-[120px_1fr] items-center gap-2">
|
||||||
@@ -307,6 +314,23 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
if (p) setErr(t('awed.exportedTo', { path: p }));
|
if (p) setErr(t('awed.exportedTo', { path: p }));
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
// Export the SELECTED award on its own — the unit you actually share. The
|
||||||
|
// bundle above is a backup: sending it to someone hands over your whole
|
||||||
|
// catalogue when they asked for one award.
|
||||||
|
//
|
||||||
|
// Distinct from the catalog publish below, which is for shipping an award INTO
|
||||||
|
// OpsLog: that one stamps a version and clears user_edited. This one is a plain
|
||||||
|
// share and leaves both alone, so the recipient's copy is correctly marked as
|
||||||
|
// someone else's work rather than as a pristine built-in.
|
||||||
|
async function exportOne() {
|
||||||
|
setErr('');
|
||||||
|
if (!cur) return;
|
||||||
|
try {
|
||||||
|
const code = cur.code.trim().toUpperCase();
|
||||||
|
const p = await ExportAward(code);
|
||||||
|
if (p) setErr(t('awed.exportedOneTo', { code, path: p }));
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
|
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
|
||||||
// paste over internal/award/catalog/<code>.json. A new release then ships it to
|
// paste over internal/award/catalog/<code>.json. A new release then ships it to
|
||||||
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
|
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
|
||||||
@@ -761,6 +785,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<TabsContent value="refs" className="mt-0">
|
<TabsContent value="refs" className="mt-0">
|
||||||
<ReferencesPanel
|
<ReferencesPanel
|
||||||
code={cur.code.trim().toUpperCase()} presets={presets} meta={meta[cur.code.toUpperCase()]}
|
code={cur.code.trim().toUpperCase()} presets={presets} meta={meta[cur.code.toUpperCase()]}
|
||||||
|
awardValidFrom={cur.valid_from} awardValidTo={cur.valid_to}
|
||||||
onUpdateOnline={() => updateList(cur.code.toUpperCase())} updating={updating === cur.code.toUpperCase()}
|
onUpdateOnline={() => updateList(cur.code.toUpperCase())} updating={updating === cur.code.toUpperCase()}
|
||||||
onChanged={loadMeta} setErr={setErr}
|
onChanged={loadMeta} setErr={setErr}
|
||||||
/>
|
/>
|
||||||
@@ -780,6 +805,14 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
|
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
|
||||||
<Download className="size-3.5 mr-1" /> {t('awed.export')}
|
<Download className="size-3.5 mr-1" /> {t('awed.export')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Share just the selected one. Sitting next to the whole-catalogue
|
||||||
|
export because that is where an operator looks for it, and labelled
|
||||||
|
with the code so the two are never confused at a glance. */}
|
||||||
|
{cur && (
|
||||||
|
<Button variant="outline" onClick={exportOne} title={t('awed.exportOneTitle')}>
|
||||||
|
<Share2 className="size-3.5 mr-1" /> {t('awed.exportOne', { code: cur.code.trim().toUpperCase() })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
|
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
|
||||||
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
|
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -874,8 +907,8 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
// ReferencesPanel — manage the reference list of one award: search/list on the
|
// ReferencesPanel — manage the reference list of one award: search/list on the
|
||||||
// left, a per-reference editor on the right, plus bulk paste/CSV, presets and
|
// left, a per-reference editor on the right, plus bulk paste/CSV, presets and
|
||||||
// the online updater (POTA/SOTA/WWFF).
|
// the online updater (POTA/SOTA/WWFF).
|
||||||
function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChanged, setErr }: {
|
function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, onUpdateOnline, updating, onChanged, setErr }: {
|
||||||
code: string; presets: Preset[]; meta?: RefMeta;
|
code: string; presets: Preset[]; meta?: RefMeta; awardValidFrom?: string; awardValidTo?: string;
|
||||||
onUpdateOnline: () => void; updating: boolean; onChanged: () => void; setErr: (s: string) => void;
|
onUpdateOnline: () => void; updating: boolean; onChanged: () => void; setErr: (s: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -1031,6 +1064,26 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
|||||||
third-party list may carry the values — but they are not offered
|
third-party list may carry the values — but they are not offered
|
||||||
for editing until something actually reads them. */}
|
for editing until something actually reads them. */}
|
||||||
<Field2 label={t('awed.grid')}><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
|
<Field2 label={t('awed.grid')}><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
|
||||||
|
{/* This reference's own validity window. A reference is not forever:
|
||||||
|
a park is delisted, a district merged. A QSO made while it
|
||||||
|
existed still counts — it was a valid contact on the day — and
|
||||||
|
one made afterwards does not.
|
||||||
|
Left empty the award's own dates govern, which is why they show
|
||||||
|
as the placeholder: the operator can see what "empty" inherits
|
||||||
|
instead of having to remember. */}
|
||||||
|
<Field2 label={t('awed.refValidFrom')}>
|
||||||
|
<Input type="date" className="h-8 w-44" value={sel.valid_from ?? ''}
|
||||||
|
onChange={(e) => patchSel({ valid_from: e.target.value })} />
|
||||||
|
</Field2>
|
||||||
|
<Field2 label={t('awed.refValidTo')}>
|
||||||
|
<Input type="date" className="h-8 w-44" value={sel.valid_to ?? ''}
|
||||||
|
onChange={(e) => patchSel({ valid_to: e.target.value })} />
|
||||||
|
</Field2>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{(awardValidFrom || awardValidTo)
|
||||||
|
? t('awed.refValidHintAward', { from: openEnded(awardValidFrom), to: openEnded(awardValidTo) })
|
||||||
|
: t('awed.refValidHint')}
|
||||||
|
</p>
|
||||||
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -373,12 +373,16 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
|||||||
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<Field label={t('detp.qslMessage')} span={7}>
|
{/* QSL via gets the room, not the message. Width should follow use, and
|
||||||
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
these two are nowhere near equal: a manager's callsign is filled in
|
||||||
</Field>
|
constantly and a QSL message almost never. The message had 7 columns
|
||||||
<Field label={t('detp.qslVia')} span={5}>
|
of 12 for text most operators never type. */}
|
||||||
|
<Field label={t('detp.qslVia')} span={7}>
|
||||||
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label={t('detp.qslMessage')} span={5}>
|
||||||
|
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -689,6 +689,13 @@ const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '1
|
|||||||
// Bands a motorized HF/6 m antenna (Ultrabeam / SteppIR) can cover — the follow
|
// Bands a motorized HF/6 m antenna (Ultrabeam / SteppIR) can cover — the follow
|
||||||
// filter is a subset of these. Must match motorBands in app.go, low → high.
|
// filter is a subset of these. Must match motorBands in app.go, low → high.
|
||||||
const MOTOR_BANDS = ['40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];
|
const MOTOR_BANDS = ['40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];
|
||||||
|
// Shown as placeholders only — the backend owns these values (motorBands in
|
||||||
|
// app.go) and resolves what a band button actually commands. Duplicated here
|
||||||
|
// purely so an empty box can say what leaving it empty will do.
|
||||||
|
const MOTOR_BAND_DEFAULT_KHZ: Record<string, number> = {
|
||||||
|
'40m': 7100, '30m': 10125, '20m': 14150, '17m': 18110,
|
||||||
|
'15m': 21150, '12m': 24930, '10m': 28400, '6m': 50150,
|
||||||
|
};
|
||||||
const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 5);
|
const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 5);
|
||||||
|
|
||||||
// Live status + OPERATE/STANDBY toggle for ONE configured amplifier (by config
|
// Live status + OPERATE/STANDBY toggle for ONE configured amplifier (by config
|
||||||
@@ -1250,8 +1257,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
// Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
|
// Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
|
||||||
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; tx_inhibit: boolean; bands: string[]; freq_min_mhz: number; freq_max_mhz: number }>({
|
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; track_mode: string; band_freqs: Record<string, number>; tx_inhibit: boolean; bands: string[]; freq_min_mhz: number; freq_max_mhz: number }>({
|
||||||
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, tx_inhibit: false, bands: [], freq_min_mhz: 13, freq_max_mhz: 54,
|
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, track_mode: 'step', band_freqs: {}, tx_inhibit: false, bands: [], freq_min_mhz: 13, freq_max_mhz: 54,
|
||||||
});
|
});
|
||||||
const [ubTesting, setUbTesting] = useState(false);
|
const [ubTesting, setUbTesting] = useState(false);
|
||||||
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
@@ -3140,45 +3147,96 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={ultrabeam.follow} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, follow: !!c }))} />
|
<Checkbox checked={ultrabeam.follow} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, follow: !!c }))} />
|
||||||
Follow rig frequency (auto-tune the antenna)
|
{t('hw.motorFollow')}
|
||||||
</label>
|
</label>
|
||||||
{ultrabeam.follow && (
|
{ultrabeam.follow && (
|
||||||
<div className="flex items-center gap-3 pl-6">
|
<div className="space-y-2 pl-6">
|
||||||
<Label className="text-sm">Re-tune step</Label>
|
<div className="flex items-center gap-3">
|
||||||
<Select value={String(ultrabeam.step_khz)} onValueChange={(v) => setUltrabeam((s) => ({ ...s, step_khz: parseInt(v, 10) || 50 }))}>
|
<Label className="text-sm">{t('station.trackModeTip')}</Label>
|
||||||
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
|
<Select value={ultrabeam.track_mode || 'step'} onValueChange={(v) => setUltrabeam((s) => ({ ...s, track_mode: v }))}>
|
||||||
<SelectContent>
|
<SelectTrigger className="h-8 w-52"><SelectValue /></SelectTrigger>
|
||||||
<SelectItem value="25">25 kHz</SelectItem>
|
<SelectContent>
|
||||||
<SelectItem value="50">50 kHz</SelectItem>
|
<SelectItem value="always">{t('station.trackAlways')}</SelectItem>
|
||||||
<SelectItem value="100">100 kHz</SelectItem>
|
<SelectItem value="step">{t('station.trackStep')}</SelectItem>
|
||||||
</SelectContent>
|
<SelectItem value="band">{t('station.trackBand')}</SelectItem>
|
||||||
</Select>
|
</SelectContent>
|
||||||
<span className="text-xs text-muted-foreground">re-tune only when the frequency moves this far</span>
|
</Select>
|
||||||
|
</div>
|
||||||
|
{/* The step is only a question in step mode — the other two modes
|
||||||
|
have nothing to threshold. */}
|
||||||
|
{(ultrabeam.track_mode || 'step') === 'step' && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Label className="text-sm">{t('hw.motorStep')}</Label>
|
||||||
|
<Select value={String(ultrabeam.step_khz)} onValueChange={(v) => setUltrabeam((s) => ({ ...s, step_khz: parseInt(v, 10) || 50 }))}>
|
||||||
|
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="25">25 kHz</SelectItem>
|
||||||
|
<SelectItem value="50">50 kHz</SelectItem>
|
||||||
|
<SelectItem value="100">100 kHz</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{(ultrabeam.track_mode || 'step') === 'always' ? t('station.trackAlwaysTip')
|
||||||
|
: (ultrabeam.track_mode || 'step') === 'band' ? t('station.trackBandTip')
|
||||||
|
: t('station.trackStepTipMode')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(isSteppir || ultrabeam.type === 'ultrabeam') && (
|
{(isSteppir || ultrabeam.type === 'ultrabeam') && (
|
||||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
<Label className="text-sm">{t('hw.motorBands')}</Label>
|
<Label className="text-sm">{t('hw.motorBands')}</Label>
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
{/* Band, and under it the frequency its button tunes to — the
|
||||||
|
layout of the SteppIR controller's own Bands and Frequencies
|
||||||
|
table, which is where operators expect to find this. The box
|
||||||
|
only appears on a selected band: a tune frequency for a band the
|
||||||
|
antenna is not allowed on is a setting with no effect. Left
|
||||||
|
empty it shows the default as placeholder, so the field is
|
||||||
|
self-documenting and clearing it is how you go back. */}
|
||||||
|
<div className="flex items-start gap-1.5 flex-wrap">
|
||||||
{MOTOR_BANDS.map((b) => {
|
{MOTOR_BANDS.map((b) => {
|
||||||
const on = ultrabeam.bands.includes(b);
|
const on = ultrabeam.bands.includes(b);
|
||||||
return (
|
return (
|
||||||
<button key={b} type="button"
|
<div key={b} className="flex flex-col gap-1">
|
||||||
onClick={() => setUltrabeam((s) => ({
|
<button type="button"
|
||||||
...s,
|
onClick={() => setUltrabeam((s) => ({
|
||||||
bands: on ? s.bands.filter((x) => x !== b) : [...s.bands, b],
|
...s,
|
||||||
}))}
|
bands: on ? s.bands.filter((x) => x !== b) : [...s.bands, b],
|
||||||
className={`h-8 min-w-[3rem] rounded-md border px-2 text-sm font-medium transition-colors ${
|
}))}
|
||||||
on
|
className={`h-8 w-[4.5rem] rounded-md border px-2 text-sm font-medium transition-colors ${
|
||||||
? 'border-primary bg-primary/15 text-primary'
|
on
|
||||||
: 'border-input bg-background text-muted-foreground hover:bg-muted'
|
? 'border-primary bg-primary/15 text-primary'
|
||||||
}`}>
|
: 'border-input bg-background text-muted-foreground hover:bg-muted'
|
||||||
{b}
|
}`}>
|
||||||
</button>
|
{b}
|
||||||
|
</button>
|
||||||
|
{on && (
|
||||||
|
<input
|
||||||
|
type="text" inputMode="numeric"
|
||||||
|
value={ultrabeam.band_freqs?.[b] ? String(ultrabeam.band_freqs[b]) : ''}
|
||||||
|
placeholder={String(MOTOR_BAND_DEFAULT_KHZ[b] ?? '')}
|
||||||
|
title={t('hw.motorBandFreqHint')}
|
||||||
|
onChange={(e) => {
|
||||||
|
// Keep only digits, and store nothing for an empty box
|
||||||
|
// so it round-trips to "use the default" rather than
|
||||||
|
// to a zero the backend would have to interpret.
|
||||||
|
const digits = e.target.value.replace(/[^0-9]/g, '');
|
||||||
|
setUltrabeam((s) => {
|
||||||
|
const next = { ...(s.band_freqs || {}) };
|
||||||
|
if (digits === '') delete next[b]; else next[b] = parseInt(digits, 10);
|
||||||
|
return { ...s, band_freqs: next };
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-7 w-[4.5rem] rounded-md border border-input bg-background px-1.5 text-center text-xs font-mono outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('hw.motorBandFreqHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="border-t border-border/60 pt-3 space-y-1">
|
<div className="border-t border-border/60 pt-3 space-y-1">
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[]; follow?: boolean; step_khz?: number; bands?: string[] };
|
type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[]; follow?: boolean; step_khz?: number; track_mode?: string; bands?: string[]; band_freqs?: Record<string, number> };
|
||||||
|
|
||||||
// Where each band button points the antenna.
|
// Where each band button points the antenna.
|
||||||
//
|
//
|
||||||
@@ -241,7 +241,10 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
|
|||||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">{t('station.bands')}</div>
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">{t('station.bands')}</div>
|
||||||
<div className="grid grid-cols-5 gap-1">
|
<div className="grid grid-cols-5 gap-1">
|
||||||
{(ant.bands ?? []).map((b: string) => {
|
{(ant.bands ?? []).map((b: string) => {
|
||||||
const khz = ANT_BAND_KHZ[b];
|
// Where this band tunes is resolved by the backend — the operator's
|
||||||
|
// per-band choice from Settings, or the default. ANT_BAND_KHZ is only
|
||||||
|
// the floor for a status poll that hasn't landed yet.
|
||||||
|
const khz = ant.band_freqs?.[b] || ANT_BAND_KHZ[b];
|
||||||
if (!khz) return null;
|
if (!khz) return null;
|
||||||
// "On this band" from the antenna's own frequency, not the rig's:
|
// "On this band" from the antenna's own frequency, not the rig's:
|
||||||
// the widget must show where the ANTENNA is, which is the whole
|
// the widget must show where the ANTENNA is, which is the whole
|
||||||
@@ -277,23 +280,38 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
|
|||||||
|
|
||||||
{/* Tracking. Here rather than only in Settings because it is an operating
|
{/* Tracking. Here rather than only in Settings because it is an operating
|
||||||
decision — off to park the antenna, on to resume — not something set
|
decision — off to park the antenna, on to resume — not something set
|
||||||
up once. The step only shows when tracking is on: a threshold for
|
up once. Mode and step only show when tracking is on: settings for
|
||||||
something switched off is a question the operator cannot act on. */}
|
something switched off are questions the operator cannot act on. And
|
||||||
<div className="flex items-center gap-2">
|
the step only shows in step mode, where it is the one thing it means. */}
|
||||||
<button type="button"
|
<div className="space-y-1.5">
|
||||||
onClick={() => run(SetMotorFollow(!ant.follow, 0))}
|
<div className="flex items-center gap-2">
|
||||||
className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors',
|
<button type="button"
|
||||||
ant.follow ? 'bg-success-muted text-success-muted-foreground border-success-border' : 'border-border hover:bg-muted')}>
|
onClick={() => run(SetMotorFollow(!ant.follow, 0, ''))}
|
||||||
{ant.follow ? t('station.trackOn') : t('station.trackOff')}
|
className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors',
|
||||||
</button>
|
ant.follow ? 'bg-success-muted text-success-muted-foreground border-success-border' : 'border-border hover:bg-muted')}>
|
||||||
|
{ant.follow ? t('station.trackOn') : t('station.trackOff')}
|
||||||
|
</button>
|
||||||
|
{ant.follow && (ant.track_mode || 'step') === 'step' && (
|
||||||
|
<select
|
||||||
|
value={String(ant.step_khz || 50)}
|
||||||
|
onChange={(e) => run(SetMotorFollow(true, parseInt(e.target.value, 10), ''))}
|
||||||
|
className="h-[30px] rounded-md border border-border bg-background px-1 text-xs font-mono"
|
||||||
|
title={t('station.trackStepTip')}
|
||||||
|
>
|
||||||
|
{[25, 50, 100].map((s) => <option key={s} value={s}>{s} kHz</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{ant.follow && (
|
{ant.follow && (
|
||||||
<select
|
<select
|
||||||
value={String(ant.step_khz || 50)}
|
value={ant.track_mode || 'step'}
|
||||||
onChange={(e) => run(SetMotorFollow(true, parseInt(e.target.value, 10)))}
|
onChange={(e) => run(SetMotorFollow(true, 0, e.target.value))}
|
||||||
className="h-[30px] rounded-md border border-border bg-background px-1 text-xs font-mono"
|
className="w-full h-[30px] rounded-md border border-border bg-background px-1.5 text-xs"
|
||||||
title={t('station.trackStepTip')}
|
title={t('station.trackModeTip')}
|
||||||
>
|
>
|
||||||
{[25, 50, 100].map((s) => <option key={s} value={s}>{s} kHz</option>)}
|
<option value="always" title={t('station.trackAlwaysTip')}>{t('station.trackAlways')}</option>
|
||||||
|
<option value="step" title={t('station.trackStepTipMode')}>{t('station.trackStep')}</option>
|
||||||
|
<option value="band" title={t('station.trackBandTip')}>{t('station.trackBand')}</option>
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -13,7 +13,17 @@
|
|||||||
|
|
||||||
export type SpotDisplayOptions = { muteWorked: boolean; slotHighlight: boolean };
|
export type SpotDisplayOptions = { muteWorked: boolean; slotHighlight: boolean };
|
||||||
|
|
||||||
|
// Both options are withdrawn from the filter panel for now. The machinery below
|
||||||
|
// is deliberately kept whole — it is correct and hard-won — so putting the two
|
||||||
|
// switches back is this one flag and the block they came from in App.tsx.
|
||||||
|
//
|
||||||
|
// The saved preferences are left untouched in localStorage rather than cleared:
|
||||||
|
// an operator who had either turned on gets them back exactly as they were the
|
||||||
|
// day the options return, instead of silently starting from off.
|
||||||
|
export const SPOT_DISPLAY_OPTIONS_EXPOSED = false;
|
||||||
|
|
||||||
export function readSpotDisplayOptions(): SpotDisplayOptions {
|
export function readSpotDisplayOptions(): SpotDisplayOptions {
|
||||||
|
if (!SPOT_DISPLAY_OPTIONS_EXPOSED) return { muteWorked: false, slotHighlight: false };
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1',
|
muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1',
|
||||||
|
|||||||
@@ -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.24.6';
|
export const APP_VERSION = '0.24.7';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+1
-1
@@ -982,7 +982,7 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
|||||||
|
|
||||||
export function SetKenwoodKeySpeed(arg1:number):Promise<void>;
|
export function SetKenwoodKeySpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetMotorFollow(arg1:boolean,arg2:number):Promise<void>;
|
export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<void>;
|
||||||
|
|
||||||
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
|
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
|
|||||||
@@ -1906,8 +1906,8 @@ export function SetKenwoodKeySpeed(arg1) {
|
|||||||
return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1);
|
return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SetMotorFollow(arg1, arg2) {
|
export function SetMotorFollow(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['SetMotorFollow'](arg1, arg2);
|
return window['go']['main']['App']['SetMotorFollow'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SetOpsLogQSLReceived(arg1, arg2) {
|
export function SetOpsLogQSLReceived(arg1, arg2) {
|
||||||
|
|||||||
@@ -3283,8 +3283,10 @@ export namespace main {
|
|||||||
baud: number;
|
baud: number;
|
||||||
follow: boolean;
|
follow: boolean;
|
||||||
step_khz: number;
|
step_khz: number;
|
||||||
|
track_mode: string;
|
||||||
tx_inhibit: boolean;
|
tx_inhibit: boolean;
|
||||||
bands: string[];
|
bands: string[];
|
||||||
|
band_freqs: Record<string, number>;
|
||||||
freq_min_mhz: number;
|
freq_min_mhz: number;
|
||||||
freq_max_mhz: number;
|
freq_max_mhz: number;
|
||||||
|
|
||||||
@@ -3303,8 +3305,10 @@ export namespace main {
|
|||||||
this.baud = source["baud"];
|
this.baud = source["baud"];
|
||||||
this.follow = source["follow"];
|
this.follow = source["follow"];
|
||||||
this.step_khz = source["step_khz"];
|
this.step_khz = source["step_khz"];
|
||||||
|
this.track_mode = source["track_mode"];
|
||||||
this.tx_inhibit = source["tx_inhibit"];
|
this.tx_inhibit = source["tx_inhibit"];
|
||||||
this.bands = source["bands"];
|
this.bands = source["bands"];
|
||||||
|
this.band_freqs = source["band_freqs"];
|
||||||
this.freq_min_mhz = source["freq_min_mhz"];
|
this.freq_min_mhz = source["freq_min_mhz"];
|
||||||
this.freq_max_mhz = source["freq_max_mhz"];
|
this.freq_max_mhz = source["freq_max_mhz"];
|
||||||
}
|
}
|
||||||
@@ -3320,7 +3324,9 @@ export namespace main {
|
|||||||
elements: number[];
|
elements: number[];
|
||||||
follow: boolean;
|
follow: boolean;
|
||||||
step_khz: number;
|
step_khz: number;
|
||||||
|
track_mode: string;
|
||||||
bands: string[];
|
bands: string[];
|
||||||
|
band_freqs: Record<string, number>;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new UltrabeamStatusInfo(source);
|
return new UltrabeamStatusInfo(source);
|
||||||
@@ -3338,7 +3344,9 @@ export namespace main {
|
|||||||
this.elements = source["elements"];
|
this.elements = source["elements"];
|
||||||
this.follow = source["follow"];
|
this.follow = source["follow"];
|
||||||
this.step_khz = source["step_khz"];
|
this.step_khz = source["step_khz"];
|
||||||
|
this.track_mode = source["track_mode"];
|
||||||
this.bands = source["bands"];
|
this.bands = source["bands"];
|
||||||
|
this.band_freqs = source["band_freqs"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class UpdateInfo {
|
export class UpdateInfo {
|
||||||
|
|||||||
+50
-5
@@ -449,6 +449,34 @@ type RefMeta struct {
|
|||||||
Pattern string
|
Pattern string
|
||||||
re *regexp.Regexp
|
re *regexp.Regexp
|
||||||
Valid bool
|
Valid bool
|
||||||
|
// Per-reference validity window, ISO "2006-01-02". A reference is not
|
||||||
|
// forever: a park is delisted, a county is merged, a castle loses its
|
||||||
|
// reference number. A QSO made while it existed still counts — it was a valid
|
||||||
|
// contact on the day — and one made after it stopped existing does not.
|
||||||
|
//
|
||||||
|
// Empty means "no window of its own", and the award's own ValidFrom/ValidTo
|
||||||
|
// then govern, as they already do for every QSO in the award (see inScope).
|
||||||
|
// That fallback is deliberately NOT duplicated here: two places enforcing the
|
||||||
|
// same dates is two places for them to disagree.
|
||||||
|
ValidFrom string
|
||||||
|
ValidTo string
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeOn reports whether the reference existed on the day of the QSO.
|
||||||
|
//
|
||||||
|
// Compared as ISO date strings rather than parsed times on purpose: the stored
|
||||||
|
// values are "2025-08-01"-shaped and lexical order on that shape IS
|
||||||
|
// chronological order, so this cannot fail on a malformed date the way a parse
|
||||||
|
// can — a reference with a typo in its window keeps counting instead of silently
|
||||||
|
// vanishing from an operator's totals.
|
||||||
|
func (m RefMeta) activeOn(day string) bool {
|
||||||
|
if m.ValidFrom != "" && day < m.ValidFrom {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if m.ValidTo != "" && day > m.ValidTo {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRefList builds the engine's reference view from (code, meta) pairs.
|
// NewRefList builds the engine's reference view from (code, meta) pairs.
|
||||||
@@ -956,11 +984,13 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
|
|||||||
// describes is worse than no trace, because it is believed.
|
// describes is worse than no trace, because it is believed.
|
||||||
func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool, ex *Explanation) []string {
|
func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool, ex *Explanation) []string {
|
||||||
predefined := hasList && !d.Dynamic
|
predefined := hasList && !d.Dynamic
|
||||||
|
// The day of the contact, for per-reference validity windows.
|
||||||
|
day := q.QSODate.Format("2006-01-02")
|
||||||
|
|
||||||
// run executes one rule and, when tracing, records it.
|
// run executes one rule and, when tracing, records it.
|
||||||
run := func(label, field, matchBy, pattern string, rex *regexp.Regexp, exact bool, leading, trailing, prefix string) []string {
|
run := func(label, field, matchBy, pattern string, rex *regexp.Regexp, exact bool, leading, trailing, prefix string) []string {
|
||||||
raw := searchOne(field, matchBy, rex, exact, leading, trailing, prefix, q, rl, predefined)
|
raw := searchOne(field, matchBy, rex, exact, leading, trailing, prefix, q, rl, predefined)
|
||||||
kept := keepRefs(predefined, rl, raw)
|
kept := keepRefs(predefined, rl, raw, day)
|
||||||
if ex != nil {
|
if ex != nil {
|
||||||
s := Step{Rule: label, Field: field, MatchBy: matchBy, Exact: exact, Pattern: pattern,
|
s := Step{Rule: label, Field: field, MatchBy: matchBy, Exact: exact, Pattern: pattern,
|
||||||
FieldValue: strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing)),
|
FieldValue: strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing)),
|
||||||
@@ -974,7 +1004,7 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
|
|||||||
if _, ok := keptSet[n]; ok {
|
if _, ok := keptSet[n]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.Rejected = append(s.Rejected, rejection(predefined, rl, n))
|
s.Rejected = append(s.Rejected, rejection(predefined, rl, n, day))
|
||||||
}
|
}
|
||||||
ex.Steps = append(ex.Steps, s)
|
ex.Steps = append(ex.Steps, s)
|
||||||
}
|
}
|
||||||
@@ -1026,7 +1056,7 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
|
|||||||
// hand. Applied HERE (not just in MatchQSO) so Compute — which powers the
|
// hand. Applied HERE (not just in MatchQSO) so Compute — which powers the
|
||||||
// awards panel and the per-QSO refs editor — honours overrides too. For a
|
// awards panel and the per-QSO refs editor — honours overrides too. For a
|
||||||
// predefined award the ref is still validated against the list below.
|
// predefined award the ref is still validated against the list below.
|
||||||
manual := keepRefs(predefined, rl, manualRefs(q, d.Code))
|
manual := keepRefs(predefined, rl, manualRefs(q, d.Code), day)
|
||||||
if ex != nil {
|
if ex != nil {
|
||||||
ex.Manual = manual
|
ex.Manual = manual
|
||||||
}
|
}
|
||||||
@@ -1065,7 +1095,7 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
|
|||||||
// become a reference. "Nothing matched" is the least useful thing a matcher can
|
// become a reference. "Nothing matched" is the least useful thing a matcher can
|
||||||
// say; every one of this week's award bugs was a rejection with a plain reason
|
// say; every one of this week's award bugs was a rejection with a plain reason
|
||||||
// that nothing was printing.
|
// that nothing was printing.
|
||||||
func rejection(predefined bool, rl refList, code string) Rejected {
|
func rejection(predefined bool, rl refList, code, day string) Rejected {
|
||||||
switch {
|
switch {
|
||||||
case code == "":
|
case code == "":
|
||||||
return Rejected{Candidate: code, Reason: "empty"}
|
return Rejected{Candidate: code, Reason: "empty"}
|
||||||
@@ -1076,6 +1106,17 @@ func rejection(predefined bool, rl refList, code string) Rejected {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return Rejected{Candidate: code, Reason: "not in the award's reference list"}
|
return Rejected{Candidate: code, Reason: "not in the award's reference list"}
|
||||||
}
|
}
|
||||||
|
// Spell the dates out. "Did not count" on a contact the operator remembers
|
||||||
|
// making is exactly the moment they need to be told it is the REFERENCE that
|
||||||
|
// has a window, not their log that is wrong.
|
||||||
|
if !m.activeOn(day) {
|
||||||
|
switch {
|
||||||
|
case m.ValidTo != "" && day > m.ValidTo:
|
||||||
|
return Rejected{Candidate: code, Reason: fmt.Sprintf("the reference ceased to exist on %s, after this QSO of %s", m.ValidTo, day)}
|
||||||
|
default:
|
||||||
|
return Rejected{Candidate: code, Reason: fmt.Sprintf("the reference did not exist until %s, after this QSO of %s", m.ValidFrom, day)}
|
||||||
|
}
|
||||||
|
}
|
||||||
if !m.Valid {
|
if !m.Valid {
|
||||||
return Rejected{Candidate: code, Reason: "listed but disabled"}
|
return Rejected{Candidate: code, Reason: "listed but disabled"}
|
||||||
}
|
}
|
||||||
@@ -1088,7 +1129,7 @@ func rejection(predefined bool, rl refList, code string) Rejected {
|
|||||||
// so we do NOT additionally require the QSO's entity to match the reference's own
|
// so we do NOT additionally require the QSO's entity to match the reference's own
|
||||||
// DXCC — that wrongly excluded e.g. WAS Alaska (state AK is DXCC entity 6, not
|
// DXCC — that wrongly excluded e.g. WAS Alaska (state AK is DXCC entity 6, not
|
||||||
// 291). Per-reference DXCC stays metadata for the picker.
|
// 291). Per-reference DXCC stays metadata for the picker.
|
||||||
func keepRefs(predefined bool, rl refList, found []string) []string {
|
func keepRefs(predefined bool, rl refList, found []string, day string) []string {
|
||||||
if !predefined {
|
if !predefined {
|
||||||
out := make([]string, 0, len(found))
|
out := make([]string, 0, len(found))
|
||||||
for _, c := range found {
|
for _, c := range found {
|
||||||
@@ -1106,6 +1147,10 @@ func keepRefs(predefined bool, rl refList, found []string) []string {
|
|||||||
if !ok || !m.Valid {
|
if !ok || !m.Valid {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// The reference has to have existed on the day of the contact.
|
||||||
|
if !m.activeOn(day) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if _, dup := seen[c]; dup {
|
if _, dup := seen[c]; dup {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package award
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
func day(s string) time.Time {
|
||||||
|
t, err := time.Parse("2006-01-02", s)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reference is not forever. A park is delisted, a district is merged, a castle
|
||||||
|
// loses its number. A contact made while it existed still counts — it was a
|
||||||
|
// valid contact on the day — and one made after it stopped existing does not.
|
||||||
|
func TestRefValidityWindow(t *testing.T) {
|
||||||
|
m := RefMeta{Code: "KL-01", Valid: true, ValidTo: "2025-08-31"}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
day string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"2019-01-01", true},
|
||||||
|
{"2025-08-31", true}, // the last day it existed still counts
|
||||||
|
{"2025-09-01", false},
|
||||||
|
{"2026-08-12", false},
|
||||||
|
} {
|
||||||
|
if got := m.activeOn(tc.day); got != tc.want {
|
||||||
|
t.Errorf("KL-01 on %s: active=%v, want %v", tc.day, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reference that only came into being partway through.
|
||||||
|
n := RefMeta{Code: "KL-99", Valid: true, ValidFrom: "2025-01-15"}
|
||||||
|
if n.activeOn("2025-01-14") {
|
||||||
|
t.Error("counted a QSO from before the reference existed")
|
||||||
|
}
|
||||||
|
if !n.activeOn("2025-01-15") {
|
||||||
|
t.Error("the first day it existed must count")
|
||||||
|
}
|
||||||
|
|
||||||
|
// No window of its own: the award's own dates govern, as they already do for
|
||||||
|
// every QSO in the award. Nothing here may narrow that.
|
||||||
|
if !(RefMeta{Code: "X", Valid: true}).activeOn("1970-01-01") {
|
||||||
|
t.Error("a reference with no window must count on any date")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point: the same QSO counts before the cutoff and does not after.
|
||||||
|
func TestExpiredRefStopsCountingForLaterQSOs(t *testing.T) {
|
||||||
|
d := &Def{
|
||||||
|
Code: "RDA", Name: "Russian District Award", Valid: true,
|
||||||
|
Type: TypeQSOFields, Field: "note", MatchBy: "code",
|
||||||
|
Confirm: []string{"lotw"},
|
||||||
|
}
|
||||||
|
metas := []RefMeta{
|
||||||
|
{Code: "KL-01", Name: "Petrozavodsk", Valid: true, ValidTo: "2025-08-31"},
|
||||||
|
{Code: "KL-04", Name: "Kostomuksha", Valid: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
q := func(ref, on string) *qso.QSO {
|
||||||
|
return &qso.QSO{Callsign: "RA1ABC", Band: "20m", Notes: ref, QSODate: day(on)}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := MatchQSO(*d, metas, q("KL-01", "2025-06-01")); len(got) != 1 || got[0] != "KL-01" {
|
||||||
|
t.Errorf("a QSO made while KL-01 existed must count: got %v", got)
|
||||||
|
}
|
||||||
|
if got := MatchQSO(*d, metas, q("KL-01", "2025-09-15")); len(got) != 0 {
|
||||||
|
t.Errorf("a QSO made after KL-01 ceased to exist must not count: got %v", got)
|
||||||
|
}
|
||||||
|
if got := MatchQSO(*d, metas, q("KL-04", "2025-09-15")); len(got) != 1 || got[0] != "KL-04" {
|
||||||
|
t.Errorf("a reference with no window is unaffected: got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The claim that an empty per-reference window "inherits the award's" has to be
|
||||||
|
// a fact about the code, not a comment. Compute and MatchQSO both gate on
|
||||||
|
// inScope, which enforces the award's own dates — so a reference with no window
|
||||||
|
// of its own is already bounded by them, and duplicating the check per reference
|
||||||
|
// would only create a second place for the same dates to disagree.
|
||||||
|
func TestEmptyRefWindowInheritsTheAward(t *testing.T) {
|
||||||
|
d := Def{
|
||||||
|
Code: "RDA", Name: "Russian District Award", Valid: true,
|
||||||
|
Type: TypeQSOFields, Field: "note", MatchBy: "code",
|
||||||
|
Confirm: []string{"lotw"},
|
||||||
|
ValidFrom: "1991-06-12", // the award itself starts here
|
||||||
|
}
|
||||||
|
metas := []RefMeta{{Code: "KL-04", Name: "Kostomuksha", Valid: true}} // no window of its own
|
||||||
|
|
||||||
|
q := func(on string) *qso.QSO {
|
||||||
|
return &qso.QSO{Callsign: "RA1ABC", Band: "20m", Notes: "KL-04", QSODate: day(on)}
|
||||||
|
}
|
||||||
|
if got := MatchQSO(d, metas, q("1991-06-11")); len(got) != 0 {
|
||||||
|
t.Errorf("a QSO before the AWARD's start counted for a reference with no window of its own: %v", got)
|
||||||
|
}
|
||||||
|
if got := MatchQSO(d, metas, q("1991-06-12")); len(got) != 1 {
|
||||||
|
t.Errorf("a QSO on the award's first day must count: %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a reference window NARROWER than the award's still applies on top.
|
||||||
|
metas[0].ValidTo = "2025-08-31"
|
||||||
|
if got := MatchQSO(d, metas, q("2025-09-01")); len(got) != 0 {
|
||||||
|
t.Errorf("the reference's own end date was ignored: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,28 @@ var watched = map[string]bool{"10m": true, "6m": true, "4m": true, "2m": true}
|
|||||||
// Watched reports whether a band is one the detector looks at.
|
// Watched reports whether a band is one the detector looks at.
|
||||||
func Watched(band string) bool { return watched[strings.ToLower(strings.TrimSpace(band))] }
|
func Watched(band string) bool { return watched[strings.ToLower(strings.TrimSpace(band))] }
|
||||||
|
|
||||||
|
// maxTerrestrialKm is the longest path a band can carry through the atmosphere.
|
||||||
|
// Zero means no limit.
|
||||||
|
//
|
||||||
|
// The flat 2400 km ceiling was removed because it threw away real multi-hop Es
|
||||||
|
// on 6 m, and that was right — but "no limit anywhere" then let something else
|
||||||
|
// through. A 2 m opening was announced at 9650 km towards Japan, on stations
|
||||||
|
// that were unmistakably working EME: the moon is not an opening, and pointing
|
||||||
|
// an antenna at that bearing would find nothing.
|
||||||
|
//
|
||||||
|
// So the limit is per band, and it is physics rather than a threshold. Two
|
||||||
|
// metres reaches a few thousand kilometres by tropospheric duct or a chain of Es
|
||||||
|
// clouds and no further; beyond that the path went via the moon or a satellite,
|
||||||
|
// neither of which says anything about the band. Six and ten metres have no
|
||||||
|
// ceiling at all — multi-hop Es and F2 genuinely go round the world.
|
||||||
|
var maxTerrestrialKm = map[string]int{
|
||||||
|
"2m": 3500,
|
||||||
|
"4m": 4000,
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxKmFor returns the plausibility ceiling for a band, 0 for none.
|
||||||
|
func MaxKmFor(band string) int { return maxTerrestrialKm[strings.ToLower(strings.TrimSpace(band))] }
|
||||||
|
|
||||||
// Opening is a detected opening, ready to be announced.
|
// Opening is a detected opening, ready to be announced.
|
||||||
type Opening struct {
|
type Opening struct {
|
||||||
Band string `json:"band"`
|
Band string `json:"band"`
|
||||||
@@ -130,6 +152,12 @@ func (d *Detector) Add(s Spot, lat float64) *Opening {
|
|||||||
if s.DistKm < d.cfg.MinKm || (d.cfg.MaxKm > 0 && s.DistKm > d.cfg.MaxKm) {
|
if s.DistKm < d.cfg.MinKm || (d.cfg.MaxKm > 0 && s.DistKm > d.cfg.MaxKm) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// Past what the atmosphere can carry on this band, the path went via the moon
|
||||||
|
// or a satellite. Those are real contacts and real reports; they are simply
|
||||||
|
// not evidence about the band.
|
||||||
|
if m := MaxKmFor(band); m > 0 && s.DistKm > m {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
d.recent = append(d.recent, s)
|
d.recent = append(d.recent, s)
|
||||||
d.prune(s.At)
|
d.prune(s.At)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package bandopen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A 2 m "opening" was announced at 9650 km towards Japan on stations that were
|
||||||
|
// plainly working EME. The moon is not an opening: an operator pointing an
|
||||||
|
// antenna at that bearing finds nothing.
|
||||||
|
func TestEMEIsNotAnOpeningOnTwoMetres(t *testing.T) {
|
||||||
|
d := New(DefaultConfig())
|
||||||
|
base := time.Date(2026, 8, 12, 6, 0, 0, 0, time.UTC)
|
||||||
|
for i, call := range []string{"7M4RRM", "JA7RPC", "JF1AWC", "JK1TPA", "JH1JCQ"} {
|
||||||
|
if op := d.Add(Spot{
|
||||||
|
Call: call, Band: "2m", DistKm: 9650, Bearing: 40 + i*3,
|
||||||
|
At: base.Add(time.Duration(i) * time.Minute),
|
||||||
|
}, 47.0); op != nil {
|
||||||
|
t.Fatalf("a 9650 km 2 m path was announced as an opening: %+v", op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// But a real 2 m opening — an Es chain at a plausible distance — must survive.
|
||||||
|
func TestLongButPlausibleTwoMetresStillCounts(t *testing.T) {
|
||||||
|
d := New(DefaultConfig())
|
||||||
|
base := time.Date(2026, 6, 20, 18, 0, 0, 0, time.UTC)
|
||||||
|
var got *Opening
|
||||||
|
for i, call := range []string{"EA1AA", "CT1BB", "EA7CC", "CT7DD"} {
|
||||||
|
if op := d.Add(Spot{
|
||||||
|
Call: call, Band: "2m", DistKm: 2000 + i*30, Bearing: 200 + i*4,
|
||||||
|
At: base.Add(time.Duration(i) * time.Minute),
|
||||||
|
}, 47.0); op != nil {
|
||||||
|
got = op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("a 2000 km 2 m burst in one sector was not reported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Six metres keeps no ceiling: multi-hop Es genuinely goes that far, which is
|
||||||
|
// why the flat limit was removed in the first place.
|
||||||
|
func TestSixMetresHasNoCeiling(t *testing.T) {
|
||||||
|
if MaxKmFor("6m") != 0 || MaxKmFor("10m") != 0 {
|
||||||
|
t.Error("6 m and 10 m must have no distance ceiling")
|
||||||
|
}
|
||||||
|
if MaxKmFor("2m") == 0 {
|
||||||
|
t.Error("2 m must have one")
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-1
@@ -440,7 +440,31 @@ func (k *Kenwood) SetFrequency(hz int64) error {
|
|||||||
if k.curVFO == "B" {
|
if k.curVFO == "B" {
|
||||||
cmd = "FB"
|
cmd = "FB"
|
||||||
}
|
}
|
||||||
return k.write(fmt.Sprintf("%s%011d;", cmd, hz))
|
if err := k.write(fmt.Sprintf("%s%011d;", cmd, hz)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Remember what we just commanded.
|
||||||
|
//
|
||||||
|
// While PTT is held the poll is skipped and State() hands back lastState — the
|
||||||
|
// rig answers "?;" to IF; mid-transmission, and reading that as a fault used
|
||||||
|
// to drop the whole link. But a frequency SET during that window then went
|
||||||
|
// unrecorded, so the cache kept describing the dial as it was before.
|
||||||
|
//
|
||||||
|
// WSJT-X's "Fake It" is exactly that sequence: move the dial, key, transmit,
|
||||||
|
// and afterwards put it back. Polling during the over, it was told the rig was
|
||||||
|
// still on the receive frequency — so there was nothing to put back, and the
|
||||||
|
// dial stayed on the transmit frequency for good. Every following over
|
||||||
|
// started from there, which is the drift that was reported.
|
||||||
|
//
|
||||||
|
// Only simplex is updated here. Under split, FreqHz means the transmit
|
||||||
|
// frequency while this write lands on whichever VFO the operator is on, and
|
||||||
|
// guessing which side moved would be worse than a stale value the next poll
|
||||||
|
// corrects on its own.
|
||||||
|
if !k.lastState.Split {
|
||||||
|
k.curFreq = hz
|
||||||
|
k.lastState.FreqHz = hz
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *Kenwood) SetMode(mode string) error {
|
func (k *Kenwood) SetMode(mode string) error {
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// WSJT-X "Fake It" against the transmit-window cache.
|
||||||
|
//
|
||||||
|
// Fake It keeps the radio on one dial frequency and shifts it only for the
|
||||||
|
// duration of each over: set the transmit frequency, key, transmit, unkey, set
|
||||||
|
// it back. The restore is not unconditional — WSJT-X reads the frequency back
|
||||||
|
// and puts the dial where it believes it should be.
|
||||||
|
//
|
||||||
|
// That read lands inside the window where this backend deliberately stops
|
||||||
|
// polling, because a Kenwood answers "?;" to IF; while it is transmitting and
|
||||||
|
// treating that as a fault used to drop the whole shared link. The cache
|
||||||
|
// answers instead. So the cache has to account for frequency SETS made during
|
||||||
|
// the window, or it describes the dial as it was before the over — and WSJT-X,
|
||||||
|
// told the radio is already on the receive frequency, has nothing to restore.
|
||||||
|
//
|
||||||
|
// This reproduces the sequence from a reported session: the dial stayed on the
|
||||||
|
// transmit frequency after the first over and every later one started there.
|
||||||
|
func TestKenwoodFakeItRestoresAfterTransmit(t *testing.T) {
|
||||||
|
const (
|
||||||
|
rxHz = 7074000 // where the operator is listening
|
||||||
|
txHz = 7075500 // where Fake It moves the dial to transmit
|
||||||
|
)
|
||||||
|
|
||||||
|
rig := &ts2000{vfoA: rxHz, mode: '2'}
|
||||||
|
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||||
|
k.dialPort = dialTo(rig)
|
||||||
|
if err := k.Connect(); err != nil {
|
||||||
|
t.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
defer k.Disconnect()
|
||||||
|
|
||||||
|
if s, err := k.ReadState(); err != nil || s.FreqHz != rxHz {
|
||||||
|
t.Fatalf("before the over: %d (err %v) — want %d", s.FreqHz, err, rxHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The over: shift the dial, then key.
|
||||||
|
if err := k.SetFrequency(txHz); err != nil {
|
||||||
|
t.Fatalf("set transmit frequency: %v", err)
|
||||||
|
}
|
||||||
|
if err := k.SetPTT(true); err != nil {
|
||||||
|
t.Fatalf("ptt on: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSJT-X reads back mid-over. The wire is not polled here — this is the
|
||||||
|
// cache talking, and it must not still be saying rxHz.
|
||||||
|
s, err := k.ReadState()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read during the over: %v", err)
|
||||||
|
}
|
||||||
|
if s.FreqHz != txHz {
|
||||||
|
t.Errorf("during the over the backend reported %d, want %d — "+
|
||||||
|
"reporting the pre-over frequency is what stops Fake It restoring the dial", s.FreqHz, txHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := k.SetPTT(false); err != nil {
|
||||||
|
t.Fatalf("ptt off: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The restore, once the over is done.
|
||||||
|
if err := k.SetFrequency(rxHz); err != nil {
|
||||||
|
t.Fatalf("restore: %v", err)
|
||||||
|
}
|
||||||
|
if rig.vfoA != rxHz {
|
||||||
|
t.Errorf("dial left on %d after the over, want %d", rig.vfoA, rxHz)
|
||||||
|
}
|
||||||
|
if s, err := k.ReadState(); err != nil || s.FreqHz != rxHz {
|
||||||
|
t.Errorf("after the over: %d (err %v) — want %d", s.FreqHz, err, rxHz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Under split the same write must NOT touch the cache: FreqHz means the
|
||||||
|
// transmit frequency while the write lands on whichever VFO the operator is on,
|
||||||
|
// so guessing which side moved would put a wrong number in front of the
|
||||||
|
// operator. A stale one survives only until the next poll.
|
||||||
|
func TestKenwoodSplitCacheLeftToThePoll(t *testing.T) {
|
||||||
|
rig := &ts2000{vfoA: 14025000, vfoB: 14030000, mode: '3', split: true}
|
||||||
|
k := NewKenwood("COM-TEST", 9600, "CW")
|
||||||
|
k.dialPort = dialTo(rig)
|
||||||
|
if err := k.Connect(); err != nil {
|
||||||
|
t.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
defer k.Disconnect()
|
||||||
|
|
||||||
|
s, err := k.ReadState()
|
||||||
|
if err != nil || !s.Split {
|
||||||
|
t.Fatalf("split not seen: %+v (err %v)", s, err)
|
||||||
|
}
|
||||||
|
before := s.FreqHz
|
||||||
|
|
||||||
|
if err := k.SetFrequency(14026000); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
if k.lastState.FreqHz != before {
|
||||||
|
t.Errorf("split cache moved to %d on a VFO write, want it left at %d for the poll",
|
||||||
|
k.lastState.FreqHz, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A per-band tune frequency goes straight to the antenna as a command, so a
|
||||||
|
// value that is not actually in that band has to be refused rather than obeyed.
|
||||||
|
// One wrong digit sends the elements travelling to a length that is wrong for
|
||||||
|
// the band the operator is on — and on a SteppIR that journey inhibits transmit
|
||||||
|
// the whole way.
|
||||||
|
func TestNormMotorBandFreqsRefusesOutOfBand(t *testing.T) {
|
||||||
|
in := map[string]int{
|
||||||
|
"20m": 14050, // fine, CW end
|
||||||
|
"40m": 7005, // fine
|
||||||
|
"6m": 50313, // fine, FT8
|
||||||
|
"15m": 1450, // a digit lost — lands in the broadcast band
|
||||||
|
"10m": 28400000, // Hz typed where kHz was asked
|
||||||
|
"17m": 14100, // right number, wrong band
|
||||||
|
"30m": 0, // not set
|
||||||
|
"80m": 3750, // not a band this antenna covers at all
|
||||||
|
"bogus": 14100, // not a band
|
||||||
|
}
|
||||||
|
got := normMotorBandFreqs(in)
|
||||||
|
want := map[string]int{"40m": 7005, "20m": 14050, "6m": 50313}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("kept %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
for k, v := range want {
|
||||||
|
if got[k] != v {
|
||||||
|
t.Errorf("%s = %d, want %d", k, got[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stored form round-trips, in canonical band order rather than map order so
|
||||||
|
// the settings row does not churn between saves.
|
||||||
|
func TestMotorBandFreqsRoundTrip(t *testing.T) {
|
||||||
|
m := map[string]int{"20m": 14050, "40m": 7005, "6m": 50313}
|
||||||
|
enc := encodeMotorBandFreqs(m)
|
||||||
|
if enc != "40m=7005,20m=14050,6m=50313" {
|
||||||
|
t.Errorf("encoded %q — want canonical low→high order", enc)
|
||||||
|
}
|
||||||
|
back := decodeMotorBandFreqs(enc)
|
||||||
|
for k, v := range m {
|
||||||
|
if back[k] != v {
|
||||||
|
t.Errorf("round trip lost %s: %d → %d", k, v, back[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Garbage in one entry must cost only that entry.
|
||||||
|
part := decodeMotorBandFreqs("40m=7005,20m=oops,6m=50313")
|
||||||
|
if part["40m"] != 7005 || part["6m"] != 50313 {
|
||||||
|
t.Errorf("one bad entry took the others down: %v", part)
|
||||||
|
}
|
||||||
|
if _, ok := part["20m"]; ok {
|
||||||
|
t.Errorf("kept an unparseable entry: %v", part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unset band falls back to its default, which is what makes the Settings box
|
||||||
|
// safe to leave empty.
|
||||||
|
func TestMotorTuneKHzForBandFallsBack(t *testing.T) {
|
||||||
|
m := map[string]int{"20m": 14050}
|
||||||
|
if got := motorTuneKHzForBand(m, "20m"); got != 14050 {
|
||||||
|
t.Errorf("chosen frequency ignored: %d", got)
|
||||||
|
}
|
||||||
|
if got := motorTuneKHzForBand(m, "15m"); got != 21150 {
|
||||||
|
t.Errorf("15m = %d, want the 21150 default", got)
|
||||||
|
}
|
||||||
|
if got := motorTuneKHzForBand(m, "80m"); got != 0 {
|
||||||
|
t.Errorf("80m = %d, want 0 — not a motor band", got)
|
||||||
|
}
|
||||||
|
// Every default must itself be in its band, or the fallback ships the very
|
||||||
|
// fault normMotorBandFreqs exists to catch.
|
||||||
|
for _, b := range motorBands {
|
||||||
|
if got := bandForHz(int64(b.defKHz) * 1000); got != b.name {
|
||||||
|
t.Errorf("default %d kHz for %s reads as %q", b.defKHz, b.name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The tracking mode decides how often a motorized antenna's elements run, so a
|
||||||
|
// value that fails to parse must not silently become the most aggressive
|
||||||
|
// setting. Anything unrecognised — including the empty string every config
|
||||||
|
// written before this option existed contains — has to land on the threshold
|
||||||
|
// mode, which is exactly what those configs already did.
|
||||||
|
func TestNormMotorTrackMode(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ in, want string }{
|
||||||
|
{"always", motorTrackAlways},
|
||||||
|
{"ALWAYS", motorTrackAlways},
|
||||||
|
{" band ", motorTrackBand},
|
||||||
|
{"step", motorTrackStep},
|
||||||
|
{"", motorTrackStep}, // never configured — behaves as before
|
||||||
|
{"everytime", motorTrackStep}, // near-miss, not "always"
|
||||||
|
{"per-band", motorTrackStep}, // near-miss, not "band"
|
||||||
|
{"25", motorTrackStep}, // a step value fed in by mistake
|
||||||
|
} {
|
||||||
|
if got := normMotorTrackMode(tc.in); got != tc.want {
|
||||||
|
t.Errorf("normMotorTrackMode(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Band mode compares the band the rig is on against the band the antenna was
|
||||||
|
// last commanded for. That comparison is bandForHz, and the property it has to
|
||||||
|
// have is that two frequencies far apart within one band agree while two
|
||||||
|
// frequencies close together across a band edge do not — otherwise the antenna
|
||||||
|
// either never moves or moves on every QSY.
|
||||||
|
func TestBandForHzDrivesBandTracking(t *testing.T) {
|
||||||
|
same := [][2]int64{
|
||||||
|
{14000000, 14350000}, // both ends of 20 m — one band, no move
|
||||||
|
{7000000, 7200000}, // 40 m
|
||||||
|
{50000000, 52000000}, // 6 m, a wide one
|
||||||
|
}
|
||||||
|
for _, p := range same {
|
||||||
|
if a, b := bandForHz(p[0]), bandForHz(p[1]); a != b || a == "" {
|
||||||
|
t.Errorf("%.3f MHz is %q but %.3f MHz is %q — band mode would re-tune inside one band",
|
||||||
|
float64(p[0])/1e6, a, float64(p[1])/1e6, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A small QSY that crosses from 30 m into 20 m has to read as a band change.
|
||||||
|
if a, b := bandForHz(10150000), bandForHz(14000000); a == b {
|
||||||
|
t.Errorf("30 m and 20 m both read %q — band mode would never re-tune between them", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.24.6"
|
appVersion = "0.24.7"
|
||||||
|
|
||||||
// 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