Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbddba6e74 | ||
|
|
b659f6f16d | ||
|
|
b4ad12bdc6 | ||
|
|
5dd3532074 | ||
|
|
dd7b63c059 | ||
|
|
8fc6673611 | ||
|
|
d33291b521 | ||
|
|
1b32b1ddec | ||
|
|
65bbaa85f3 | ||
|
|
99d903eb44 | ||
|
|
13515c58c0 | ||
|
|
e4014e11d2 | ||
|
|
025472820b | ||
|
|
8b66030c89 | ||
|
|
d4f23a52af | ||
|
|
b4b9674d8c | ||
|
|
f7d2de0777 | ||
|
|
344e8b2091 | ||
|
|
c5e0ec9033 | ||
|
|
210a99983e | ||
|
|
2a6e09a1d7 | ||
|
|
1b7f8ec9c1 | ||
|
|
75a2f73992 | ||
|
|
deee8c4618 | ||
|
|
92a5f30ac0 | ||
|
|
d3a405f4f6 | ||
|
|
f7b9bfd0bc | ||
|
|
102097c5c4 | ||
|
|
3b90ef6b7a |
@@ -40,6 +40,7 @@ import (
|
|||||||
"hamlog/internal/dxcc"
|
"hamlog/internal/dxcc"
|
||||||
"hamlog/internal/email"
|
"hamlog/internal/email"
|
||||||
"hamlog/internal/extsvc"
|
"hamlog/internal/extsvc"
|
||||||
|
"hamlog/internal/geo"
|
||||||
"hamlog/internal/integrations/udp"
|
"hamlog/internal/integrations/udp"
|
||||||
"hamlog/internal/lookup"
|
"hamlog/internal/lookup"
|
||||||
"hamlog/internal/lotwusers"
|
"hamlog/internal/lotwusers"
|
||||||
@@ -232,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)
|
||||||
@@ -746,48 +749,16 @@ type App struct {
|
|||||||
udpLastMode string
|
udpLastMode string
|
||||||
}
|
}
|
||||||
|
|
||||||
// gridToLatLon parses a Maidenhead locator (4 or 6 chars) and returns the
|
// gridToLatLon and haversineKm live in internal/geo, which the internal
|
||||||
// centre lat/lon in degrees. Returns ok=false on malformed input.
|
// packages can import — package main cannot be imported by anything. These
|
||||||
func gridToLatLon(grid string) (lat, lon float64, ok bool) {
|
// forward so there is exactly ONE implementation: the PSK Reporter watcher and
|
||||||
g := strings.ToUpper(strings.TrimSpace(grid))
|
// the web publisher measure the same path the cluster does, and a bearing that
|
||||||
if len(g) < 4 {
|
// disagrees with itself between two panels is a fault nobody reports, because
|
||||||
return 0, 0, false
|
// each screen looks plausible alone.
|
||||||
}
|
func gridToLatLon(grid string) (lat, lon float64, ok bool) { return geo.GridToLatLon(grid) }
|
||||||
A := g[0] - 'A'
|
|
||||||
B := g[1] - 'A'
|
|
||||||
C := g[2] - '0'
|
|
||||||
D := g[3] - '0'
|
|
||||||
if A > 17 || B > 17 || C > 9 || D > 9 {
|
|
||||||
return 0, 0, false
|
|
||||||
}
|
|
||||||
lon = -180 + float64(A)*20 + float64(C)*2
|
|
||||||
lat = -90 + float64(B)*10 + float64(D)*1
|
|
||||||
if len(g) >= 6 {
|
|
||||||
E := g[4] - 'A'
|
|
||||||
F := g[5] - 'A'
|
|
||||||
if E <= 23 && F <= 23 {
|
|
||||||
lon += float64(E)*(5.0/60.0) + 2.5/60.0
|
|
||||||
lat += float64(F)*(2.5/60.0) + 1.25/60.0
|
|
||||||
return lat, lon, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 4-char locator: aim at the centre of the square.
|
|
||||||
lon += 1
|
|
||||||
lat += 0.5
|
|
||||||
return lat, lon, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// haversineKm returns the great-circle distance between two lat/lon pairs
|
|
||||||
// in kilometres. Standard Haversine, mean Earth radius 6371 km.
|
|
||||||
func haversineKm(lat1, lon1, lat2, lon2 float64) float64 {
|
func haversineKm(lat1, lon1, lat2, lon2 float64) float64 {
|
||||||
const R = 6371.0
|
return geo.HaversineKm(lat1, lon1, lat2, lon2)
|
||||||
rad := math.Pi / 180.0
|
|
||||||
dLat := (lat2 - lat1) * rad
|
|
||||||
dLon := (lon2 - lon1) * rad
|
|
||||||
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
|
||||||
math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
|
||||||
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
|
||||||
return R * c
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// initialBearingDeg returns the initial great-circle bearing (azimuth) in
|
// initialBearingDeg returns the initial great-circle bearing (azimuth) in
|
||||||
@@ -1407,6 +1378,8 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
// PSK Reporter, when the opening watch is on. After the operator's grid is
|
// PSK Reporter, when the opening watch is on. After the operator's grid is
|
||||||
// known: without it there is no distance to measure and the feed stays down.
|
// known: without it there is no distance to measure and the feed stays down.
|
||||||
a.startBandOpenFeed()
|
a.startBandOpenFeed()
|
||||||
|
// One-time tidy-up of a field nothing used to record. Background, once.
|
||||||
|
a.backfillDistancesOnce()
|
||||||
|
|
||||||
fmt.Println("OpsLog: db ready at", a.dbPath)
|
fmt.Println("OpsLog: db ready at", a.dbPath)
|
||||||
}
|
}
|
||||||
@@ -2651,6 +2624,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
a.applyStationDefaults(&q, true)
|
a.applyStationDefaults(&q, true)
|
||||||
|
fillDistance(&q)
|
||||||
a.applyDXCCNumber(&q)
|
a.applyDXCCNumber(&q)
|
||||||
a.applyULSCounty(&q) // fill blank US county/grid from the offline ULS store
|
a.applyULSCounty(&q) // fill blank US county/grid from the offline ULS store
|
||||||
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
|
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
|
||||||
@@ -2749,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
|
||||||
@@ -2994,6 +2977,27 @@ func (a *App) refineDistrictZones(q *qso.QSO) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fillDistance computes DISTANCE from the two locators when nothing supplied one.
|
||||||
|
//
|
||||||
|
// Its own function, NOT part of applyStationDefaults, because the import only
|
||||||
|
// applies those when the operator ticks the box — and a distance is not a
|
||||||
|
// station default. It is derived from the QSO's own two grids and is true
|
||||||
|
// whatever the operator chose about profile fields.
|
||||||
|
//
|
||||||
|
// Nothing recorded it before, so the field went out empty in every ADIF export
|
||||||
|
// and left the same gap in whoever imported the file: a hole that travels. An
|
||||||
|
// imported value always wins, having come from the log that made the contact,
|
||||||
|
// which knew the real positions rather than two four-character squares.
|
||||||
|
func fillDistance(q *qso.QSO) {
|
||||||
|
if q == nil || (q.Distance != nil && *q.Distance > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if km, ok := geo.DistanceBetweenGrids(q.MyGrid, q.Grid); ok && km > 0 {
|
||||||
|
v := math.Round(km)
|
||||||
|
q.Distance = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// applyStationDefaults fills any empty MY_* / station field on q with the
|
// applyStationDefaults fills any empty MY_* / station field on q with the
|
||||||
// currently-active profile's values. Multi-profile support means a user
|
// currently-active profile's values. Multi-profile support means a user
|
||||||
// can be /P with a different callsign + grid + SOTA ref than home — the
|
// can be /P with a different callsign + grid + SOTA ref than home — the
|
||||||
@@ -4482,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
|
||||||
@@ -6169,6 +6174,12 @@ var bulkFieldColumns = map[string]string{
|
|||||||
"iota": "iota",
|
"iota": "iota",
|
||||||
"sig": "sig",
|
"sig": "sig",
|
||||||
"sig_info": "sig_info",
|
"sig_info": "sig_info",
|
||||||
|
// The contact itself — repair fields. Setting mode also clears submode, in
|
||||||
|
// qso.BulkSetField: a submode left over from the old mode contradicts the new.
|
||||||
|
"mode": "mode",
|
||||||
|
"submode": "submode",
|
||||||
|
"rst_sent": "rst_sent",
|
||||||
|
"rst_rcvd": "rst_rcvd",
|
||||||
// Misc text
|
// Misc text
|
||||||
"comment": "comment",
|
"comment": "comment",
|
||||||
"notes": "notes",
|
"notes": "notes",
|
||||||
@@ -6485,6 +6496,8 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStatio
|
|||||||
a.applyClublogException(q, true) // force: explicit import-time correction
|
a.applyClublogException(q, true) // force: explicit import-time correction
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Unconditional: see fillDistance.
|
||||||
|
fillDistance(q)
|
||||||
if applyStation {
|
if applyStation {
|
||||||
// Backfill empty MY_* descriptive fields from the active profile
|
// Backfill empty MY_* descriptive fields from the active profile
|
||||||
// (identity fields left alone to keep mixed-call routing intact).
|
// (identity fields left alone to keep mixed-call routing intact).
|
||||||
@@ -10368,6 +10381,97 @@ func (a *App) DownloadULSCounties() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BackfillUSCountiesResult summarises a bulk county/grid backfill over the log.
|
// BackfillUSCountiesResult summarises a bulk county/grid backfill over the log.
|
||||||
|
// keyDistanceBackfilled marks the one-time distance fill as done.
|
||||||
|
//
|
||||||
|
// A migration, not a setting. It was briefly a button in Preferences, which was
|
||||||
|
// the wrong shape twice over: a maintenance chore does not belong beside the
|
||||||
|
// options an operator actually chooses, and nobody should have to be TOLD their
|
||||||
|
// log is missing a field before it gets filled in. It runs once, in the
|
||||||
|
// background, and never asks.
|
||||||
|
const keyDistanceBackfilled = "migr.distance_from_grids.v1"
|
||||||
|
|
||||||
|
// backfillDistancesOnce fills DISTANCE across the log the first time this
|
||||||
|
// version runs, then records that it is done.
|
||||||
|
//
|
||||||
|
// In the background: on a large log over a remote MySQL this is thousands of
|
||||||
|
// row updates, and startup must not wait for a tidy-up. Marked done only on
|
||||||
|
// success — a run cut short by a closed program should try again next time
|
||||||
|
// rather than leave half the log filled for ever.
|
||||||
|
func (a *App) backfillDistancesOnce() {
|
||||||
|
if a.settings == nil || a.qso == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v, _ := a.settings.GetGlobal(a.ctx, keyDistanceBackfilled); v == "1" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
res, err := a.BackfillDistances()
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("distance backfill: %v — will try again next start", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = a.settings.SetGlobal(a.ctx, keyDistanceBackfilled, "1")
|
||||||
|
applog.Printf("distance backfill: done once for this log (%d filled)", res.Filled)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackfillDistancesResult reports what a distance backfill did.
|
||||||
|
type BackfillDistancesResult struct {
|
||||||
|
Scanned int `json:"scanned"` // QSOs examined
|
||||||
|
Filled int `json:"filled"` // QSOs that gained a distance
|
||||||
|
NoGrid int `json:"no_grid"` // skipped: one of the two locators is missing
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackfillDistances computes DISTANCE for past QSOs that have both locators and
|
||||||
|
// no distance recorded.
|
||||||
|
//
|
||||||
|
// Nothing has ever computed it when logging — the column is only filled by an
|
||||||
|
// ADIF import that carried one — so a log made in OpsLog has it empty
|
||||||
|
// throughout. The published web page works around that by deriving the distance
|
||||||
|
// as it renders, but the column still travels empty into every ADIF export, and
|
||||||
|
// that is what leaves the gap in someone else's log.
|
||||||
|
//
|
||||||
|
// Only fills what is EMPTY. A stored distance came from the log that recorded
|
||||||
|
// the contact, which knew the real positions; two four-character squares are a
|
||||||
|
// worse answer and must not overwrite a better one.
|
||||||
|
func (a *App) BackfillDistances() (BackfillDistancesResult, error) {
|
||||||
|
var res BackfillDistancesResult
|
||||||
|
if a.qso == nil {
|
||||||
|
return res, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
rows, err := a.qso.List(a.ctx, qso.ListFilter{Limit: 1_000_000})
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
for i := range rows {
|
||||||
|
q := rows[i]
|
||||||
|
res.Scanned++
|
||||||
|
if q.Distance != nil && *q.Distance > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
km, ok := geo.DistanceBetweenGrids(q.MyGrid, q.Grid)
|
||||||
|
if !ok || km <= 0 {
|
||||||
|
res.NoGrid++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Whole kilometres: the squares are tens of kilometres across and a
|
||||||
|
// decimal would assert an accuracy the grids do not carry.
|
||||||
|
v := math.Round(km)
|
||||||
|
q.Distance = &v
|
||||||
|
if err := a.qso.Update(a.ctx, q); err != nil {
|
||||||
|
applog.Printf("backfill distance: QSO %d: %v", q.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.Filled++
|
||||||
|
}
|
||||||
|
applog.Printf("backfill distance: %d scanned, %d filled, %d without both grids",
|
||||||
|
res.Scanned, res.Filled, res.NoGrid)
|
||||||
|
if res.Filled > 0 {
|
||||||
|
a.invalidateAwardStats()
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
type BackfillUSCountiesResult struct {
|
type BackfillUSCountiesResult struct {
|
||||||
Scanned int `json:"scanned"` // US QSOs examined
|
Scanned int `json:"scanned"` // US QSOs examined
|
||||||
County int `json:"county"` // QSOs that gained a county
|
County int `json:"county"` // QSOs that gained a county
|
||||||
@@ -11731,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
|
||||||
@@ -12254,6 +12368,17 @@ func (a *App) ultrabeamFollowNow(freqHz int64) {
|
|||||||
if ref <= 0 {
|
if ref <= 0 {
|
||||||
ref = c.LastSetKHz()
|
ref = c.LastSetKHz()
|
||||||
}
|
}
|
||||||
|
switch normMotorTrackMode(s.TrackMode) {
|
||||||
|
case motorTrackAlways:
|
||||||
|
// Every frequency change means every frequency change, including this one.
|
||||||
|
case motorTrackBand:
|
||||||
|
// The antenna is already resonant somewhere in this band — that is all the
|
||||||
|
// operator asked for in band mode, so a spot click inside it moves nothing.
|
||||||
|
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
|
diff := khz - ref
|
||||||
if diff < 0 {
|
if diff < 0 {
|
||||||
diff = -diff
|
diff = -diff
|
||||||
@@ -12262,6 +12387,7 @@ func (a *App) ultrabeamFollowNow(freqHz int64) {
|
|||||||
applog.Printf("ultrabeam: followNow within deadband (%d kHz vs ref %d, step %d) — no move", khz, ref, 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
|
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 {
|
||||||
applog.Printf("ultrabeam: immediate re-tune to %d kHz failed: %v", khz, err)
|
applog.Printf("ultrabeam: immediate re-tune to %d kHz failed: %v", khz, err)
|
||||||
@@ -14542,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
|
||||||
@@ -14549,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.
|
||||||
@@ -14556,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
|
||||||
}
|
}
|
||||||
@@ -14589,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
|
||||||
@@ -14654,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),
|
||||||
@@ -14739,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).
|
||||||
@@ -14893,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
|
||||||
@@ -14954,6 +15213,20 @@ func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, bands []string, s
|
|||||||
ref = c.LastSetKHz()
|
ref = c.LastSetKHz()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Band mode ignores the threshold entirely: the antenna is re-tuned once
|
||||||
|
// on entering a band and then left alone however far the rig roams
|
||||||
|
// 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
|
||||||
|
// move the operator made by hand, would never be reconciled.
|
||||||
|
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
|
diff := rigKHz - ref
|
||||||
if diff < 0 {
|
if diff < 0 {
|
||||||
diff = -diff
|
diff = -diff
|
||||||
@@ -14961,12 +15234,14 @@ func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, bands []string, s
|
|||||||
if ref > 0 && diff < stepKHz {
|
if ref > 0 && diff < stepKHz {
|
||||||
continue // within the deadband — leave the motors alone
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14988,10 +15263,15 @@ type UltrabeamStatusInfo struct {
|
|||||||
// 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.
|
||||||
@@ -15002,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
|
||||||
@@ -15107,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
|
||||||
@@ -15119,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
|
||||||
@@ -15140,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
|
||||||
}
|
}
|
||||||
@@ -15157,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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A bulk-editable field passes through THREE tables: the field list in the UI,
|
||||||
|
// bulkFieldColumns here, and bulkEditableCols in internal/qso. Mode and RST were
|
||||||
|
// added to the first and the third and not the second, so the dialog offered
|
||||||
|
// them and the save failed with "unknown field" — reported from the field.
|
||||||
|
//
|
||||||
|
// Nothing warns about that: each table is valid on its own. This is the check.
|
||||||
|
func TestEveryMappedBulkFieldIsWhitelisted(t *testing.T) {
|
||||||
|
for id, col := range bulkFieldColumns {
|
||||||
|
if !qso.BulkEditable(col) {
|
||||||
|
t.Errorf("bulk field %q maps to column %q, which internal/qso refuses to write", id, col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the reverse: a column the qso layer allows but nothing maps to is dead
|
||||||
|
// weight — it looks supported from the inside and cannot be reached from outside.
|
||||||
|
func TestNoUnreachableBulkColumn(t *testing.T) {
|
||||||
|
mapped := map[string]bool{}
|
||||||
|
for _, col := range bulkFieldColumns {
|
||||||
|
mapped[col] = true
|
||||||
|
}
|
||||||
|
for _, col := range qso.BulkEditableColumns() {
|
||||||
|
if !mapped[col] {
|
||||||
|
t.Errorf("column %q is bulk-writable but no field maps to it — unreachable", col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,50 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Bulk edit can now set mode, submode and RST. They were excluded as per-QSO fields, which missed the point: bulk edit is for repairing a batch — an import that mapped every contact to SSB, an ADIF with no mode at all — and refusing meant editing a hundred rows one at a time. Setting the mode clears the submode, since one left over from the old mode contradicts the new one. Band stays with frequency, which already sets the two together.",
|
||||||
|
"Web publishing: the page now widens to fit the table. It was capped at a comfortable reading width, so with more than about eight columns the rest sat behind a scrollbar on a screen wide enough to show them all — and Windows hides that scrollbar until something moves, which made a table that scrolls look like a table missing columns. Columns also take the width their contents need instead of being squeezed to fit first.",
|
||||||
|
"Every QSO now carries its distance. Nothing ever recorded one, so the field went out empty in every ADIF export and left the Distance column blank on a published page. It is computed from the two locators when a contact is logged and when an ADIF is imported, and a one-time pass fills in the QSOs already in your log the first time this version runs — in the background, without asking. A distance the imported file supplied is always kept.",
|
||||||
|
"RDA: 1015 districts were filed under the wrong DXCC entity. Every reference sat on European Russia; 991 belong to Asiatic Russia and the 24 KA- districts to Kaliningrad, which is a separate entity altogether. Corrected against the reference list, and Kaliningrad added to the award filter so those 24 can be claimed at all.",
|
||||||
|
"QSO filter: asking a field to equal nothing now finds the empty ones. SQL answers that question with nothing at all — a missing value never equals an empty one — so the filter looked broken rather than wrong. Empty on a numeric field also covers zero as well as missing, which SQLite and MySQL disagreed about.",
|
||||||
|
"Callsign lookup: a /QRP call now returns its locator. The lookup falls back to the home callsign when the slashed form is not registered, and then cleared the location — right for /P and /M, where the operator is somewhere other than their registered address, and wrong for /QRP, which says something about power and nothing about place. The grid came back empty while the same call without the suffix answered perfectly."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"L édition groupée sait enfin régler le mode, le sous-mode et le RST. Ils étaient exclus comme champs propres à chaque QSO, ce qui manquait l essentiel : l édition groupée sert à RÉPARER un lot — un import qui a tout mis en SSB, un ADIF sans aucun mode — et refuser obligeait à corriger cent lignes une par une. Régler le mode efface le sous-mode, celui de l ancien mode contredisant le nouveau. La bande reste avec la fréquence, qui pose déjà les deux ensemble.",
|
||||||
|
"Publication web : la page s élargit désormais à la taille du tableau. Elle était bridée à une largeur de lecture confortable, donc au-delà de huit colonnes environ le reste passait derrière une barre de défilement sur un écran assez large pour tout montrer — et Windows masque cette barre tant que rien ne bouge, si bien qu un tableau qui défile ressemblait à un tableau amputé. Les colonnes prennent aussi la largeur qu il leur faut au lieu d être comprimées d abord.",
|
||||||
|
"Chaque QSO porte désormais sa distance. Rien ne l enregistrait, elle partait donc vide dans chaque export ADIF et laissait la colonne Distance blanche sur une page publiée. Elle est calculée depuis les deux locators à l enregistrement d un contact et à l import d un ADIF, et une passe unique complète les QSO déjà présents au premier lancement de cette version — en tâche de fond, sans rien demander. Une distance fournie par le fichier importé est toujours conservée.",
|
||||||
|
"RDA : 1015 districts étaient rangés sous la mauvaise entité DXCC. Toutes les références étaient sur la Russie européenne ; 991 relèvent de la Russie asiatique et les 24 districts KA- de Kaliningrad, qui est une entité à part entière. Corrigé d après la liste de référence, et Kaliningrad ajouté au filtre de l award pour que ces 24 puissent être revendiqués.",
|
||||||
|
"Filtre QSO : demander à un champ d être égal à rien trouve désormais les vides. SQL répond à cette question par rien du tout — une valeur absente n est jamais égale à une valeur vide — et le filtre paraissait cassé plutôt que mal posé. Vide sur un champ numérique couvre aussi le zéro autant que l absence, ce sur quoi SQLite et MySQL n étaient pas d accord.",
|
||||||
|
"Recherche d indicatif : un indicatif en /QRP rend enfin son locator. La recherche se rabat sur l indicatif de base quand la forme avec barre n est pas enregistrée, puis effaçait la localisation — ce qui est juste pour /P et /M, où l opérateur n est pas à son adresse déclarée, et faux pour /QRP, qui parle de puissance et pas de lieu. Le locator revenait vide alors que le même indicatif sans le suffixe répondait parfaitement."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.24.5",
|
"version": "0.24.5",
|
||||||
"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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -172,8 +172,17 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
() => (lat == null || lon == null ? null : sunTimes(new Date(), lat, lon)),
|
() => (lat == null || lon == null ? null : sunTimes(new Date(), lat, lon)),
|
||||||
[lat, lon],
|
[lat, lon],
|
||||||
);
|
);
|
||||||
|
// Stacked, not side by side. Laid out in a row this cost about 150 px of a
|
||||||
|
// header that has to hold the callsign, the badges and the band grid, and it
|
||||||
|
// was what pushed the whole row onto a second line. Two short times one above
|
||||||
|
// the other take a fraction of that and no extra height: the row is already
|
||||||
|
// taller than one line of text.
|
||||||
|
//
|
||||||
|
// "UTC" moves into the tooltip with them — the times are monospaced and always
|
||||||
|
// UTC everywhere in OpsLog, so the label was spending width to repeat a
|
||||||
|
// convention the operator already lives by.
|
||||||
const sunBlock = sun ? (
|
const sunBlock = sun ? (
|
||||||
<div className="ml-auto flex items-center gap-3 text-xs shrink-0"
|
<div className="ml-auto flex flex-col items-end leading-tight text-xs shrink-0"
|
||||||
title="Sunrise / sunset at the DX station (UTC)">
|
title="Sunrise / sunset at the DX station (UTC)">
|
||||||
{sun.polarDay ? (
|
{sun.polarDay ? (
|
||||||
<span className="font-semibold text-warning">midnight sun</span>
|
<span className="font-semibold text-warning">midnight sun</span>
|
||||||
@@ -182,14 +191,13 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Sunrise className="size-3.5 text-warning" />
|
<Sunrise className="size-3 text-warning" />
|
||||||
<span className="font-mono tabular-nums">{sun.rise || '—'}</span>
|
<span className="font-mono tabular-nums">{sun.rise || '—'}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Sunset className="size-3.5 text-info" />
|
<Sunset className="size-3 text-info" />
|
||||||
<span className="font-mono tabular-nums">{sun.set || '—'}</span>
|
<span className="font-mono tabular-nums">{sun.set || '—'}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground">UTC</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,6 +91,17 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'iota', label: 'bulk.fIota', group: 'Contacted station', kind: 'text', upper: true },
|
{ id: 'iota', label: 'bulk.fIota', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
|
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
|
||||||
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
|
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
|
||||||
|
// The contact itself — repair fields, not description fields. An import that
|
||||||
|
// mapped every QSO to SSB, or an ADIF with no MODE at all, is fixed here
|
||||||
|
// instead of one row at a time.
|
||||||
|
//
|
||||||
|
// Band is deliberately absent: it travels with the frequency below, because a
|
||||||
|
// band contradicting its own frequency is invalid ADIF and every export would
|
||||||
|
// carry the contradiction.
|
||||||
|
{ id: 'mode', label: 'bulk.fMode', group: 'The contact', kind: 'text', upper: true },
|
||||||
|
{ id: 'submode', label: 'bulk.fSubmode', group: 'The contact', kind: 'text', upper: true },
|
||||||
|
{ id: 'rst_sent', label: 'bulk.fRstSent', group: 'The contact', kind: 'text' },
|
||||||
|
{ id: 'rst_rcvd', label: 'bulk.fRstRcvd', group: 'The contact', kind: 'text' },
|
||||||
// Misc
|
// Misc
|
||||||
// Frequency (MHz) — sets freq_hz AND recomputes band. Main use: fixing a batch
|
// Frequency (MHz) — sets freq_hz AND recomputes band. Main use: fixing a batch
|
||||||
// logged on a stale/default frequency after CAT dropped.
|
// logged on a stale/default frequency after CAT dropped.
|
||||||
@@ -110,7 +121,12 @@ const STATUS_VALUES: { v: string; label: string }[] = [
|
|||||||
{ v: '_', label: 'bulk.statusBlank' },
|
{ v: '_', label: 'bulk.statusBlank' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const GROUPS = ['QSL / upload', 'My station', 'Contacted station', 'Contest', 'Propagation', 'Misc'];
|
// Derived from the fields themselves, in the order they are declared.
|
||||||
|
//
|
||||||
|
// This used to be a hand-written list, and a group added to FIELDS but not to it
|
||||||
|
// simply never rendered — the fields existed, passed every check, and could not
|
||||||
|
// be picked. Two lists that must agree, with nothing to make them.
|
||||||
|
const GROUPS = [...new Set(FIELDS.map((f) => f.group))];
|
||||||
// Maps the internal group key → its i18n label key.
|
// Maps the internal group key → its i18n label key.
|
||||||
const GROUP_LABELS: Record<string, string> = {
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
'QSL / upload': 'bulk.groupQsl',
|
'QSL / upload': 'bulk.groupQsl',
|
||||||
@@ -119,6 +135,7 @@ const GROUP_LABELS: Record<string, string> = {
|
|||||||
'Contest': 'bulk.groupContest',
|
'Contest': 'bulk.groupContest',
|
||||||
'Propagation': 'bulk.groupPropagation',
|
'Propagation': 'bulk.groupPropagation',
|
||||||
'Misc': 'bulk.groupMisc',
|
'Misc': 'bulk.groupMisc',
|
||||||
|
'The contact': 'bulk.groupContact',
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -177,7 +194,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{GROUPS.map((g) => (
|
{GROUPS.map((g) => (
|
||||||
<div key={g}>
|
<div key={g}>
|
||||||
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{t(GROUP_LABELS[g])}</div>
|
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{GROUP_LABELS[g] ? t(GROUP_LABELS[g]) : g}</div>
|
||||||
{FIELDS.filter((f) => f.group === g)
|
{FIELDS.filter((f) => f.group === g)
|
||||||
.map((f) => ({ f, txt: t(f.label) }))
|
.map((f) => ({ f, txt: t(f.label) }))
|
||||||
.sort((a, b) => a.txt.localeCompare(b.txt))
|
.sort((a, b) => a.txt.localeCompare(b.txt))
|
||||||
|
|||||||
@@ -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,11 +3147,26 @@ 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">
|
||||||
|
<Label className="text-sm">{t('station.trackModeTip')}</Label>
|
||||||
|
<Select value={ultrabeam.track_mode || 'step'} onValueChange={(v) => setUltrabeam((s) => ({ ...s, track_mode: v }))}>
|
||||||
|
<SelectTrigger className="h-8 w-52"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="always">{t('station.trackAlways')}</SelectItem>
|
||||||
|
<SelectItem value="step">{t('station.trackStep')}</SelectItem>
|
||||||
|
<SelectItem value="band">{t('station.trackBand')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</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 }))}>
|
<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>
|
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -3153,32 +3175,68 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<SelectItem value="100">100 kHz</SelectItem>
|
<SelectItem value="100">100 kHz</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<span className="text-xs text-muted-foreground">re-tune only when the frequency moves this far</span>
|
</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">
|
||||||
|
<button type="button"
|
||||||
onClick={() => setUltrabeam((s) => ({
|
onClick={() => setUltrabeam((s) => ({
|
||||||
...s,
|
...s,
|
||||||
bands: on ? s.bands.filter((x) => x !== b) : [...s.bands, b],
|
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 ${
|
className={`h-8 w-[4.5rem] rounded-md border px-2 text-sm font-medium transition-colors ${
|
||||||
on
|
on
|
||||||
? 'border-primary bg-primary/15 text-primary'
|
? 'border-primary bg-primary/15 text-primary'
|
||||||
: 'border-input bg-background text-muted-foreground hover:bg-muted'
|
: 'border-input bg-background text-muted-foreground hover:bg-muted'
|
||||||
}`}>
|
}`}>
|
||||||
{b}
|
{b}
|
||||||
</button>
|
</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,19 +280,21 @@ 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
|
||||||
|
the step only shows in step mode, where it is the one thing it means. */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onClick={() => run(SetMotorFollow(!ant.follow, 0))}
|
onClick={() => run(SetMotorFollow(!ant.follow, 0, ''))}
|
||||||
className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors',
|
className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors',
|
||||||
ant.follow ? 'bg-success-muted text-success-muted-foreground border-success-border' : 'border-border hover:bg-muted')}>
|
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')}
|
{ant.follow ? t('station.trackOn') : t('station.trackOff')}
|
||||||
</button>
|
</button>
|
||||||
{ant.follow && (
|
{ant.follow && (ant.track_mode || 'step') === 'step' && (
|
||||||
<select
|
<select
|
||||||
value={String(ant.step_khz || 50)}
|
value={String(ant.step_khz || 50)}
|
||||||
onChange={(e) => run(SetMotorFollow(true, parseInt(e.target.value, 10)))}
|
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"
|
className="h-[30px] rounded-md border border-border bg-background px-1 text-xs font-mono"
|
||||||
title={t('station.trackStepTip')}
|
title={t('station.trackStepTip')}
|
||||||
>
|
>
|
||||||
@@ -297,6 +302,19 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
|
|||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{ant.follow && (
|
||||||
|
<select
|
||||||
|
value={ant.track_mode || 'step'}
|
||||||
|
onChange={(e) => run(SetMotorFollow(true, 0, e.target.value))}
|
||||||
|
className="w-full h-[30px] rounded-md border border-border bg-background px-1.5 text-xs"
|
||||||
|
title={t('station.trackModeTip')}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="button" disabled={!ant.connected}
|
<button type="button" disabled={!ant.connected}
|
||||||
onClick={() => run(UltrabeamRetract())}
|
onClick={() => run(UltrabeamRetract())}
|
||||||
|
|||||||
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.5';
|
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
+3
-1
@@ -88,6 +88,8 @@ export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Reco
|
|||||||
|
|
||||||
export function AwardsFolder():Promise<string>;
|
export function AwardsFolder():Promise<string>;
|
||||||
|
|
||||||
|
export function BackfillDistances():Promise<main.BackfillDistancesResult>;
|
||||||
|
|
||||||
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
|
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
|
||||||
|
|
||||||
export function BandSlotQSOs(arg1:string,arg2:number,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
|
export function BandSlotQSOs(arg1:string,arg2:number,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
|
||||||
@@ -980,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>;
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,10 @@ export function AwardsFolder() {
|
|||||||
return window['go']['main']['App']['AwardsFolder']();
|
return window['go']['main']['App']['AwardsFolder']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BackfillDistances() {
|
||||||
|
return window['go']['main']['App']['BackfillDistances']();
|
||||||
|
}
|
||||||
|
|
||||||
export function BackfillUSCounties() {
|
export function BackfillUSCounties() {
|
||||||
return window['go']['main']['App']['BackfillUSCounties']();
|
return window['go']['main']['App']['BackfillUSCounties']();
|
||||||
}
|
}
|
||||||
@@ -1902,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) {
|
||||||
|
|||||||
@@ -1931,6 +1931,22 @@ export namespace main {
|
|||||||
this.to = source["to"];
|
this.to = source["to"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class BackfillDistancesResult {
|
||||||
|
scanned: number;
|
||||||
|
filled: number;
|
||||||
|
no_grid: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new BackfillDistancesResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.scanned = source["scanned"];
|
||||||
|
this.filled = source["filled"];
|
||||||
|
this.no_grid = source["no_grid"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class BackfillUSCountiesResult {
|
export class BackfillUSCountiesResult {
|
||||||
scanned: number;
|
scanned: number;
|
||||||
county: number;
|
county: number;
|
||||||
@@ -3267,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;
|
||||||
|
|
||||||
@@ -3287,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"];
|
||||||
}
|
}
|
||||||
@@ -3304,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);
|
||||||
@@ -3322,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
|
||||||
}
|
}
|
||||||
|
|||||||
+1017
-1016
File diff suppressed because it is too large
Load Diff
@@ -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,69 @@
|
|||||||
|
// Package geo is the one place that turns Maidenhead locators into positions,
|
||||||
|
// and positions into distances and bearings.
|
||||||
|
//
|
||||||
|
// It exists because there were about to be three copies. These functions lived
|
||||||
|
// in package main, which the internal packages cannot import, so the PSK
|
||||||
|
// Reporter watcher had its geometry injected from main and the web publisher
|
||||||
|
// was about to grow its own. A bearing that disagrees with itself between two
|
||||||
|
// panels is the kind of fault nobody reports, because each screen looks
|
||||||
|
// plausible on its own.
|
||||||
|
package geo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GridToLatLon parses a Maidenhead locator (4 or 6 characters) and returns the
|
||||||
|
// centre of that square in degrees. ok=false on malformed input.
|
||||||
|
func GridToLatLon(grid string) (lat, lon float64, ok bool) {
|
||||||
|
g := strings.ToUpper(strings.TrimSpace(grid))
|
||||||
|
if len(g) < 4 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
A := g[0] - 'A'
|
||||||
|
B := g[1] - 'A'
|
||||||
|
C := g[2] - '0'
|
||||||
|
D := g[3] - '0'
|
||||||
|
if A > 17 || B > 17 || C > 9 || D > 9 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
lon = -180 + float64(A)*20 + float64(C)*2
|
||||||
|
lat = -90 + float64(B)*10 + float64(D)*1
|
||||||
|
if len(g) >= 6 {
|
||||||
|
E := g[4] - 'A'
|
||||||
|
F := g[5] - 'A'
|
||||||
|
if E <= 23 && F <= 23 {
|
||||||
|
lon += float64(E)*(5.0/60.0) + 2.5/60.0
|
||||||
|
lat += float64(F)*(2.5/60.0) + 1.25/60.0
|
||||||
|
return lat, lon, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 4-character locator: aim at the centre of the square.
|
||||||
|
lon += 1
|
||||||
|
lat += 0.5
|
||||||
|
return lat, lon, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// HaversineKm returns the great-circle distance between two positions in
|
||||||
|
// kilometres. Mean Earth radius 6371 km.
|
||||||
|
func HaversineKm(lat1, lon1, lat2, lon2 float64) float64 {
|
||||||
|
const R = 6371.0
|
||||||
|
rad := math.Pi / 180.0
|
||||||
|
dLat := (lat2 - lat1) * rad
|
||||||
|
dLon := (lon2 - lon1) * rad
|
||||||
|
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||||||
|
math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||||
|
return R * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DistanceBetweenGrids is the distance in kilometres between two locators,
|
||||||
|
// ok=false when either cannot be parsed.
|
||||||
|
func DistanceBetweenGrids(a, b string) (km float64, ok bool) {
|
||||||
|
lat1, lon1, ok1 := GridToLatLon(a)
|
||||||
|
lat2, lon2, ok2 := GridToLatLon(b)
|
||||||
|
if !ok1 || !ok2 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return HaversineKm(lat1, lon1, lat2, lon2), true
|
||||||
|
}
|
||||||
@@ -176,12 +176,20 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
|||||||
r.Callsign = call
|
r.Callsign = call
|
||||||
r.Source = p.Name()
|
r.Source = p.Name()
|
||||||
r.FetchedAt = time.Now().UTC()
|
r.FetchedAt = time.Now().UTC()
|
||||||
// The home record's location is the operator's HOME, not where they
|
// The home record's location is the operator's HOME — clear it so
|
||||||
// are portable now — clear it so cty.dat fills the real entity.
|
// cty.dat fills in where they actually are.
|
||||||
|
//
|
||||||
|
// UNLESS the suffix says nothing about location. /QRP is a statement
|
||||||
|
// about power, not about place: M0BFS/QRP is M0BFS, at home, running
|
||||||
|
// five watts. Wiping the grid there threw away the one field the
|
||||||
|
// operator was looking the call up for, and it came back empty while
|
||||||
|
// the same lookup without the suffix answered perfectly.
|
||||||
|
if !saysNothingAboutLocation(call) {
|
||||||
r.Country, r.Continent = "", ""
|
r.Country, r.Continent = "", ""
|
||||||
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
|
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
|
||||||
r.Lat, r.Lon = 0, 0
|
r.Lat, r.Lon = 0, 0
|
||||||
r.Grid, r.State, r.County = "", "", ""
|
r.Grid, r.State, r.County = "", "", ""
|
||||||
|
}
|
||||||
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
|
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
|
||||||
normalizeNames(&r)
|
normalizeNames(&r)
|
||||||
_ = m.cache.Put(ctx, r)
|
_ = m.cache.Put(ctx, r)
|
||||||
@@ -232,6 +240,33 @@ var LogSink = func(string, ...any) {}
|
|||||||
// right and must be looked up exactly as entered.
|
// right and must be looked up exactly as entered.
|
||||||
var opSuffixes = map[string]bool{"M": true, "MM": true, "AM": true, "P": true, "QRP": true}
|
var opSuffixes = map[string]bool{"M": true, "MM": true, "AM": true, "P": true, "QRP": true}
|
||||||
|
|
||||||
|
// nonLocationSuffixes say nothing about WHERE the operator is.
|
||||||
|
//
|
||||||
|
// /QRP is a statement about power. /M and /P and their kin are not: mobile and
|
||||||
|
// portable both mean "somewhere other than the home station", which is exactly
|
||||||
|
// why the home record's location is discarded for them. Keeping that distinction
|
||||||
|
// is the difference between a grid that is stale and a grid that is absent.
|
||||||
|
var nonLocationSuffixes = map[string]bool{"QRP": true}
|
||||||
|
|
||||||
|
// saysNothingAboutLocation reports a call whose every suffix leaves the operator
|
||||||
|
// at their registered address — so the home record's location can be trusted.
|
||||||
|
func saysNothingAboutLocation(call string) bool {
|
||||||
|
parts := strings.Split(strings.ToUpper(strings.TrimSpace(call)), "/")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
base := strings.TrimSpace(parts[0])
|
||||||
|
if len(base) < 3 || !strings.ContainsAny(base, "0123456789") {
|
||||||
|
return false // "JW/OR1A": the first part is a prefix — a location change
|
||||||
|
}
|
||||||
|
for _, p := range parts[1:] {
|
||||||
|
if !nonLocationSuffixes[strings.TrimSpace(p)] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// stripOpSuffix returns the bare callsign when call carries nothing but
|
// stripOpSuffix returns the bare callsign when call carries nothing but
|
||||||
// operational suffixes ("F4LYI/M" → "F4LYI", true). Reports false for anything
|
// operational suffixes ("F4LYI/M" → "F4LYI", true). Reports false for anything
|
||||||
// that changes entity or area ("JW/OR1A", "F4BPO/8"), and for a call whose base
|
// that changes entity or area ("JW/OR1A", "F4BPO/8"), and for a call whose base
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package lookup
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A grid came back empty for M0BFS/QRP while the same lookup without the suffix
|
||||||
|
// answered perfectly. The home-call pass wiped the location on the grounds that
|
||||||
|
// a portable operator is not at their registered address — true for /P and /M,
|
||||||
|
// and simply wrong for /QRP, which is a statement about power.
|
||||||
|
func TestSaysNothingAboutLocation(t *testing.T) {
|
||||||
|
keep := []string{"M0BFS/QRP", "f4bpo/qrp", "G0ABC/QRP"}
|
||||||
|
for _, c := range keep {
|
||||||
|
if !saysNothingAboutLocation(c) {
|
||||||
|
t.Errorf("%s: the home location should be kept — /QRP does not move anyone", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// These DO move the operator, or change the entity outright.
|
||||||
|
drop := []string{"F4BPO/P", "F4BPO/M", "F4BPO/MM", "F4BPO/AM", "JW/OR1A", "VP8/F4BPO", "F4BPO/8", "F4BPO"}
|
||||||
|
for _, c := range drop {
|
||||||
|
if saysNothingAboutLocation(c) {
|
||||||
|
t.Errorf("%s: the home location must NOT be trusted", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A power suffix on top of a portable one still moves them.
|
||||||
|
if saysNothingAboutLocation("F4BPO/P/QRP") {
|
||||||
|
t.Error("F4BPO/P/QRP is portable — location must not be kept")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package qso
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Mode, submode and RST are repair fields: an import that mapped every contact
|
||||||
|
// to SSB, or an ADIF that carried no MODE, is fixed in one pass instead of one
|
||||||
|
// row at a time. They were excluded as "per-QSO", which confused describing a
|
||||||
|
// QSO with repairing a batch of them.
|
||||||
|
func TestModeAndRSTAreBulkEditable(t *testing.T) {
|
||||||
|
for _, col := range []string{"mode", "submode", "rst_sent", "rst_rcvd"} {
|
||||||
|
if !bulkEditableCols[col] {
|
||||||
|
t.Errorf("%s should be bulk-editable", col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Band must NOT be bulk-editable on its own: it travels with the frequency
|
||||||
|
// through BulkSetFrequency. A band contradicting its own frequency is invalid
|
||||||
|
// ADIF, and every export would carry the contradiction out into the world.
|
||||||
|
func TestBandIsNotBulkEditableAlone(t *testing.T) {
|
||||||
|
for _, col := range []string{"band", "freq_hz", "callsign", "qso_date"} {
|
||||||
|
if bulkEditableCols[col] {
|
||||||
|
t.Errorf("%s must not be bulk-editable on its own", col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package qso
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// "equals nothing" and "is empty" are the same question. SQL answers the first
|
||||||
|
// with nothing at all — a NULL never equals ” — so a filter written in plain
|
||||||
|
// words returned zero rows and looked broken rather than wrong.
|
||||||
|
func TestEqualsBlankMeansEmpty(t *testing.T) {
|
||||||
|
sql, args, err := conditionSQL(Condition{Field: "freq_hz", Op: "eq", Value: ""})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("eq blank: %v", err)
|
||||||
|
}
|
||||||
|
if len(args) != 0 || !strings.Contains(sql, "IS NULL") {
|
||||||
|
t.Errorf("sql = %q args = %v — want the empty test", sql, args)
|
||||||
|
}
|
||||||
|
sql, _, _ = conditionSQL(Condition{Field: "name", Op: "ne", Value: " "})
|
||||||
|
if !strings.Contains(sql, "<> ''") {
|
||||||
|
t.Errorf("ne blank on text gave %q — want the not-empty test", sql)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A numeric column is empty when NULL *or* zero, and that must be explicit:
|
||||||
|
// SQLite compares 0 against ” as false while MySQL calls it true, so one
|
||||||
|
// expression would answer two different questions depending on the backend.
|
||||||
|
func TestEmptyOnNumericCoversZeroAndNull(t *testing.T) {
|
||||||
|
sql, _, err := conditionSQL(Condition{Field: "freq_hz", Op: "empty"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("empty: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(sql, "IS NULL") || !strings.Contains(sql, "= 0") {
|
||||||
|
t.Errorf("sql = %q — want both NULL and zero", sql)
|
||||||
|
}
|
||||||
|
// Text keeps the string test: '' is a real value there, 0 is not.
|
||||||
|
sql, _, _ = conditionSQL(Condition{Field: "name", Op: "empty"})
|
||||||
|
if !strings.Contains(sql, "IFNULL") || strings.Contains(sql, "= 0") {
|
||||||
|
t.Errorf("text empty gave %q", sql)
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
-8
@@ -734,13 +734,26 @@ func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// bulkEditableCols whitelists the columns BulkSetField may write. Limited to
|
// bulkEditableCols whitelists the columns BulkSetField may write.
|
||||||
// TEXT fields where setting one value across many QSOs is meaningful: the
|
//
|
||||||
// per-service QSL/upload status fields, plus "my station"/operator fields that
|
// Mostly TEXT fields where one value across many QSOs is meaningful: the
|
||||||
// are naturally constant across a run (grid, antenna, rig, address, …). It
|
// per-service QSL/upload status fields, plus "my station"/operator fields
|
||||||
// deliberately excludes per-QSO fields (callsign, band, mode, date, RST, the
|
// naturally constant across a run (grid, antenna, rig, address, …).
|
||||||
// contacted station's details) and numeric columns (power, zones, lat/lon),
|
//
|
||||||
// which would be corrupted or meaningless if bulk-set to a single value.
|
// Mode, submode and RST are here too, which the original rule excluded as
|
||||||
|
// "per-QSO". That rule confused two different things. Bulk edit is not for
|
||||||
|
// describing QSOs, it is for REPAIRING a batch — an import that mapped every
|
||||||
|
// contact to SSB, an ADIF with no MODE at all — and refusing to fix a hundred
|
||||||
|
// rows because a hundred rows should not normally share a value leaves the
|
||||||
|
// operator editing them one at a time.
|
||||||
|
//
|
||||||
|
// Band is NOT here, and frequency is not either: both go through
|
||||||
|
// BulkSetFrequency, which writes the pair together. A band that contradicts its
|
||||||
|
// own frequency is invalid ADIF, and every export would carry the contradiction.
|
||||||
|
//
|
||||||
|
// Still excluded, and this part of the rule stands: callsign and date, which
|
||||||
|
// identify the contact rather than describe it, and the numeric columns (power,
|
||||||
|
// zones, lat/lon) that are meaningless shared.
|
||||||
var bulkEditableCols = map[string]bool{
|
var bulkEditableCols = map[string]bool{
|
||||||
// QSL / upload status
|
// QSL / upload status
|
||||||
"lotw_sent": true,
|
"lotw_sent": true,
|
||||||
@@ -820,6 +833,14 @@ var bulkEditableCols = map[string]bool{
|
|||||||
"iota": true,
|
"iota": true,
|
||||||
"sig": true,
|
"sig": true,
|
||||||
"sig_info": true,
|
"sig_info": true,
|
||||||
|
// The contact itself. Repair fields: an import that mapped everything to SSB,
|
||||||
|
// or an ADIF that carried no MODE. Setting mode CLEARS submode (see
|
||||||
|
// BulkSetField) — a submode left over from the old mode contradicts the new
|
||||||
|
// one, and "FT8 / USB" is not a thing.
|
||||||
|
"mode": true,
|
||||||
|
"submode": true,
|
||||||
|
"rst_sent": true,
|
||||||
|
"rst_rcvd": true,
|
||||||
// Misc text
|
// Misc text
|
||||||
"comment": true,
|
"comment": true,
|
||||||
"notes": true,
|
"notes": true,
|
||||||
@@ -843,8 +864,16 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
|
|||||||
ph[i] = "?"
|
ph[i] = "?"
|
||||||
args = append(args, id)
|
args = append(args, id)
|
||||||
}
|
}
|
||||||
|
set := column + " = ?, updated_at = ?"
|
||||||
|
if column == "mode" {
|
||||||
|
// A submode belongs to the mode it was recorded under. Left behind, it
|
||||||
|
// contradicts the new one — "FT8" with a submode of "USB" is not a thing,
|
||||||
|
// and it is the submode that most ADIF readers believe. Clearing it is the
|
||||||
|
// only outcome that leaves the row meaning what the operator asked for.
|
||||||
|
set += ", submode = ''"
|
||||||
|
}
|
||||||
res, err := r.db.ExecContext(ctx,
|
res, err := r.db.ExecContext(ctx,
|
||||||
`UPDATE qso SET `+column+` = ?, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
|
`UPDATE qso SET `+set+` WHERE id IN (`+strings.Join(ph, ",")+`)`,
|
||||||
args...)
|
args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("bulk set %s: %w", column, err)
|
return 0, fmt.Errorf("bulk set %s: %w", column, err)
|
||||||
@@ -867,6 +896,23 @@ var bulkEditableExtras = map[string]string{
|
|||||||
// BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "".
|
// BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "".
|
||||||
func BulkExtraKey(field string) string { return bulkEditableExtras[field] }
|
func BulkExtraKey(field string) string { return bulkEditableExtras[field] }
|
||||||
|
|
||||||
|
// BulkEditable reports whether a COLUMN may be bulk-written. Exported so the
|
||||||
|
// app layer can check its own field mapping against this whitelist: the two
|
||||||
|
// lists are separate, valid on their own, and a field present in one and absent
|
||||||
|
// from the other fails only when an operator tries to use it.
|
||||||
|
func BulkEditable(column string) bool { return bulkEditableCols[column] }
|
||||||
|
|
||||||
|
// BulkEditableColumns lists every bulk-writable column, for the same check from
|
||||||
|
// the other side: a column nothing maps to looks supported and cannot be used.
|
||||||
|
func BulkEditableColumns() []string {
|
||||||
|
out := make([]string, 0, len(bulkEditableCols))
|
||||||
|
for c := range bulkEditableCols {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// BulkSetExtra sets one whitelisted extras_json field on every listed QSO,
|
// BulkSetExtra sets one whitelisted extras_json field on every listed QSO,
|
||||||
// leaving the other extras untouched. An empty value REMOVES the key rather than
|
// leaving the other extras untouched. An empty value REMOVES the key rather than
|
||||||
// storing a blank — an empty extra would otherwise be carried into every export.
|
// storing a blank — an empty extra would otherwise be carried into every export.
|
||||||
@@ -1220,6 +1266,17 @@ var filterableColumns = map[string]bool{
|
|||||||
// value compares on the date part (see conditionSQL) so day filters are exact.
|
// value compares on the date part (see conditionSQL) so day filters are exact.
|
||||||
var dateColumns = map[string]bool{"qso_date": true, "qso_date_off": true}
|
var dateColumns = map[string]bool{"qso_date": true, "qso_date_off": true}
|
||||||
|
|
||||||
|
// numericColumns are the filterable columns holding numbers rather than text.
|
||||||
|
//
|
||||||
|
// "Empty" means something different for them: NULL *or* zero. It has to be said
|
||||||
|
// explicitly because the two backends disagree — SQLite compares 0 against ”
|
||||||
|
// as false, MySQL calls it true — so one expression would quietly answer two
|
||||||
|
// different questions depending on where the logbook lives.
|
||||||
|
var numericColumns = map[string]bool{
|
||||||
|
"freq_hz": true, "freq_rx_hz": true, "dxcc": true, "cqz": true, "ituz": true,
|
||||||
|
"srx": true, "stx": true, "tx_pwr": true,
|
||||||
|
}
|
||||||
|
|
||||||
// bareDateRe matches a plain calendar date with no time component.
|
// bareDateRe matches a plain calendar date with no time component.
|
||||||
var bareDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
var bareDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||||||
|
|
||||||
@@ -1320,6 +1377,18 @@ func conditionSQL(c Condition) (string, []any, error) {
|
|||||||
col = "substr(" + col + ",1,10)"
|
col = "substr(" + col + ",1,10)"
|
||||||
v = strings.TrimSpace(v)
|
v = strings.TrimSpace(v)
|
||||||
}
|
}
|
||||||
|
// "equals nothing" and "is empty" are the same question, and SQL answers the
|
||||||
|
// first with nothing at all: a NULL never equals '', so a filter written that
|
||||||
|
// way returns zero rows and looks broken rather than wrong. Asking it in
|
||||||
|
// plain words is not a mistake worth punishing.
|
||||||
|
if strings.TrimSpace(v) == "" {
|
||||||
|
switch c.Op {
|
||||||
|
case "eq":
|
||||||
|
c.Op = "empty"
|
||||||
|
case "ne":
|
||||||
|
c.Op = "notempty"
|
||||||
|
}
|
||||||
|
}
|
||||||
switch c.Op {
|
switch c.Op {
|
||||||
case "eq":
|
case "eq":
|
||||||
return col + " = ?", []any{v}, nil
|
return col + " = ?", []any{v}, nil
|
||||||
@@ -1370,8 +1439,18 @@ func conditionSQL(c Condition) (string, []any, error) {
|
|||||||
}
|
}
|
||||||
return col + " IN (" + ph + ")", args, nil
|
return col + " IN (" + ph + ")", args, nil
|
||||||
case "empty":
|
case "empty":
|
||||||
|
// A numeric column is empty when it is NULL *or* zero, and that has to be
|
||||||
|
// said explicitly: SQLite compares 0 against '' as false while MySQL calls
|
||||||
|
// it true, so IFNULL(col,'')='' quietly means different things on the two
|
||||||
|
// backends OpsLog supports.
|
||||||
|
if numericColumns[strings.ToLower(strings.TrimSpace(c.Field))] {
|
||||||
|
return "(" + col + " IS NULL OR " + col + " = 0)", nil, nil
|
||||||
|
}
|
||||||
return "IFNULL(" + col + ",'') = ''", nil, nil
|
return "IFNULL(" + col + ",'') = ''", nil, nil
|
||||||
case "notempty":
|
case "notempty":
|
||||||
|
if numericColumns[strings.ToLower(strings.TrimSpace(c.Field))] {
|
||||||
|
return "(" + col + " IS NOT NULL AND " + col + " <> 0)", nil, nil
|
||||||
|
}
|
||||||
return "IFNULL(" + col + ",'') <> ''", nil, nil
|
return "IFNULL(" + col + ",'') <> ''", nil, nil
|
||||||
default:
|
default:
|
||||||
return "", nil, fmt.Errorf("unknown operator %q", c.Op)
|
return "", nil, fmt.Errorf("unknown operator %q", c.Op)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jlaffaye/ftp"
|
"github.com/jlaffaye/ftp"
|
||||||
|
|
||||||
|
"hamlog/internal/geo"
|
||||||
"hamlog/internal/qso"
|
"hamlog/internal/qso"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -93,6 +94,26 @@ func flt(p *float64) string {
|
|||||||
return strconv.FormatFloat(*p, 'f', -1, 64)
|
return strconv.FormatFloat(*p, 'f', -1, 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// distanceKm is the path length for the Distance column.
|
||||||
|
//
|
||||||
|
// The stored DISTANCE field is only ever filled by an ADIF import that carried
|
||||||
|
// one — OpsLog does not compute it when logging — so publishing it straight gave
|
||||||
|
// an empty column for every QSO made here, which is how this was reported.
|
||||||
|
//
|
||||||
|
// So it falls back to the two locators, which are on the QSO already. Rounded to
|
||||||
|
// whole kilometres: the grids are squares tens of kilometres across, and a
|
||||||
|
// decimal on a figure that imprecise claims an accuracy nobody has.
|
||||||
|
func distanceKm(q *qso.QSO) string {
|
||||||
|
if q.Distance != nil && *q.Distance > 0 {
|
||||||
|
return flt(q.Distance)
|
||||||
|
}
|
||||||
|
km, ok := geo.DistanceBetweenGrids(q.MyGrid, q.Grid)
|
||||||
|
if !ok || km <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.FormatFloat(km, 'f', 0, 64)
|
||||||
|
}
|
||||||
|
|
||||||
func stamp(t time.Time) string {
|
func stamp(t time.Time) string {
|
||||||
if t.IsZero() {
|
if t.IsZero() {
|
||||||
return ""
|
return ""
|
||||||
@@ -218,7 +239,7 @@ var Columns = []Column{
|
|||||||
{"my_sig_info", "My sig info", "My station", func(q *qso.QSO) string { return q.MySIGInfo }},
|
{"my_sig_info", "My sig info", "My station", func(q *qso.QSO) string { return q.MySIGInfo }},
|
||||||
{"wwff_ref", "WWFF", "Awards", func(q *qso.QSO) string { return q.WWFFRef }},
|
{"wwff_ref", "WWFF", "Awards", func(q *qso.QSO) string { return q.WWFFRef }},
|
||||||
{"my_wwff_ref", "My wwff ref", "My station", func(q *qso.QSO) string { return q.MyWWFFRef }},
|
{"my_wwff_ref", "My wwff ref", "My station", func(q *qso.QSO) string { return q.MyWWFFRef }},
|
||||||
{"distance", "Distance", "Location", func(q *qso.QSO) string { return flt(q.Distance) }},
|
{"distance", "Distance", "Location", distanceKm},
|
||||||
{"rx_pwr", "RX pwr", "QSO", func(q *qso.QSO) string { return flt(q.RXPower) }},
|
{"rx_pwr", "RX pwr", "QSO", func(q *qso.QSO) string { return flt(q.RXPower) }},
|
||||||
{"a_index", "A", "QSO", func(q *qso.QSO) string { return flt(q.AIndex) }},
|
{"a_index", "A", "QSO", func(q *qso.QSO) string { return flt(q.AIndex) }},
|
||||||
{"k_index", "K", "QSO", func(q *qso.QSO) string { return flt(q.KIndex) }},
|
{"k_index", "K", "QSO", func(q *qso.QSO) string { return flt(q.KIndex) }},
|
||||||
@@ -380,11 +401,25 @@ func renderHTML(cfg Config, cols []Column, qsos []qso.QSO, stationCall string) [
|
|||||||
*{box-sizing:border-box}
|
*{box-sizing:border-box}
|
||||||
body{margin:0;padding:1.5rem 1rem;background:var(--bg);color:var(--fg);
|
body{margin:0;padding:1.5rem 1rem;background:var(--bg);color:var(--fg);
|
||||||
font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
||||||
.wrap{max-width:1100px;margin:0 auto}
|
/* The page grows with its table instead of being capped at a comfortable
|
||||||
|
READING width. 1100px suits eight columns and hides the rest behind a
|
||||||
|
scrollbar on a screen wide enough to show them all — which is how this was
|
||||||
|
reported. max-content lets a wide table use the window; min-width keeps a
|
||||||
|
narrow one from collapsing to nothing on a large display. */
|
||||||
|
.wrap{max-width:max-content;min-width:min(1100px,100%);margin:0 auto}
|
||||||
h1{margin:0 0 .25rem;font-size:1.35rem}
|
h1{margin:0 0 .25rem;font-size:1.35rem}
|
||||||
.meta{margin:0 0 1rem;color:var(--mut);font-size:.8rem}
|
.meta{margin:0 0 1rem;color:var(--mut);font-size:.8rem}
|
||||||
.scroll{overflow-x:auto;border:1px solid var(--line);border-radius:8px}
|
/* A visible scrollbar. Windows hides overlay scrollbars until something moves,
|
||||||
table{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums}
|
so a table that scrolls looks exactly like a table that is missing columns. */
|
||||||
|
.scroll{overflow-x:auto;border:1px solid var(--line);border-radius:8px;
|
||||||
|
scrollbar-width:thin;scrollbar-color:var(--line) transparent}
|
||||||
|
.scroll::-webkit-scrollbar{height:10px}
|
||||||
|
.scroll::-webkit-scrollbar-thumb{background:var(--line);border-radius:5px}
|
||||||
|
/* width:auto, not 100%: columns take the width their contents need. With
|
||||||
|
width:100% the browser squeezes them to fit the container first and only then
|
||||||
|
overflows, so a callsign could end up wrapped while empty space sat further
|
||||||
|
along the row. min-width keeps a two-column table filling the frame. */
|
||||||
|
table{border-collapse:collapse;width:auto;min-width:100%;font-variant-numeric:tabular-nums}
|
||||||
th,td{padding:.45rem .6rem;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap}
|
th,td{padding:.45rem .6rem;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap}
|
||||||
th{position:sticky;top:0;background:var(--head);font-size:.72rem;letter-spacing:.05em;
|
th{position:sticky;top:0;background:var(--head);font-size:.72rem;letter-spacing:.05em;
|
||||||
text-transform:uppercase;color:var(--mut);cursor:pointer;user-select:none}
|
text-transform:uppercase;color:var(--mut);cursor:pointer;user-select:none}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package webpub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Distance column was published empty for every QSO logged in OpsLog: the
|
||||||
|
// stored DISTANCE field is only ever filled by an ADIF import that carried one,
|
||||||
|
// and nothing computes it when logging. It falls back to the two locators.
|
||||||
|
func TestDistanceFallsBackToTheGrids(t *testing.T) {
|
||||||
|
// JN36 (French Alps) to IO91 (southern England): a few hundred kilometres.
|
||||||
|
got := distanceKm(&qso.QSO{MyGrid: "JN36DG", Grid: "IO91"})
|
||||||
|
if got == "" {
|
||||||
|
t.Fatal("no distance from two perfectly good locators")
|
||||||
|
}
|
||||||
|
if got == "0" {
|
||||||
|
t.Errorf("distance = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stored distance wins: it came from the log that recorded the QSO, which knew
|
||||||
|
// more than two four-character squares do.
|
||||||
|
func TestStoredDistanceWins(t *testing.T) {
|
||||||
|
d := 1234.0
|
||||||
|
if got := distanceKm(&qso.QSO{Distance: &d, MyGrid: "JN36", Grid: "IO91"}); got != "1234" {
|
||||||
|
t.Errorf("distance = %q, want the stored 1234", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No grids, no invention. An empty cell is honest; a zero is a claim.
|
||||||
|
func TestNoGridsNoDistance(t *testing.T) {
|
||||||
|
for _, q := range []qso.QSO{{}, {MyGrid: "JN36"}, {Grid: "IO91"}, {MyGrid: "??", Grid: "IO91"}} {
|
||||||
|
if got := distanceKm(&q); got != "" {
|
||||||
|
t.Errorf("distance(%+v) = %q, want empty", q, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.5"
|
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