Compare commits

..
14 Commits
25 changed files with 4913 additions and 159 deletions
+351 -18
View File
@@ -54,6 +54,7 @@ import (
"hamlog/internal/settings" "hamlog/internal/settings"
"hamlog/internal/solar" "hamlog/internal/solar"
"hamlog/internal/steppir" "hamlog/internal/steppir"
"hamlog/internal/uls"
"hamlog/internal/ultrabeam" "hamlog/internal/ultrabeam"
"hamlog/internal/winkeyer" "hamlog/internal/winkeyer"
@@ -437,6 +438,7 @@ type App struct {
clusterEventCh chan clusterEvent clusterEventCh chan clusterEvent
clusterDropped int64 // spots/lines dropped when the queue was full (atomic) clusterDropped int64 // spots/lines dropped when the queue was full (atomic)
pota *pota.Cache pota *pota.Cache
uls *uls.Store // US callsign→county/grid (offline FCC ULS), lazily opened
awardRefs *awardref.Repo awardRefs *awardref.Repo
qslTemplates *qslcard.Repo qslTemplates *qslcard.Repo
operating *operating.Repo operating *operating.Repo
@@ -761,6 +763,14 @@ func (a *App) startup(ctx context.Context) {
} }
} }
a.settings.SetProfile(active.ID) a.settings.SetProfile(active.ID)
// US county resolver — its own local SQLite (data/uls.db), populated on demand
// by DownloadULSCounties. Opening (creating an empty store) is cheap and never
// fatal: county resolution simply stays inert until the operator downloads it.
if store, err := uls.Open(filepath.Join(a.dataDir, "uls.db")); err != nil {
applog.Printf("uls: open failed (county resolution disabled): %v", err)
} else {
a.uls = store
}
a.awardRefs = awardref.NewRepo(conn) a.awardRefs = awardref.NewRepo(conn)
a.qslTemplates = qslcard.NewRepo(conn) a.qslTemplates = qslcard.NewRepo(conn)
a.migrateAwardDefs() // upgrade legacy award definitions (enable + new fields) a.migrateAwardDefs() // upgrade legacy award definitions (enable + new fields)
@@ -1815,6 +1825,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
}() }()
a.applyStationDefaults(&q, true) a.applyStationDefaults(&q, true)
a.applyDXCCNumber(&q) a.applyDXCCNumber(&q)
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
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)
@@ -2385,6 +2396,22 @@ func (a *App) migrateAwardDefs() {
a.setSettingGlobal(keyAwardEditsSeeded, "1") a.setSettingGlobal(keyAwardEditsSeeded, "1")
} }
// One-time: the US Counties award was briefly carried under the code
// "USCOUNTIES" before being renamed to the official "USA-CA". Drop the orphan
// (def + references) so the operator isn't left with a stale, empty duplicate.
// It never shipped beyond development, so this is unconditional.
for i := 0; i < len(migrated); i++ {
if strings.EqualFold(strings.TrimSpace(migrated[i].Code), "USCOUNTIES") {
migrated = append(migrated[:i], migrated[i+1:]...)
i--
changed = true
if a.awardRefs != nil {
_, _ = a.awardRefs.ReplaceAll(a.ctx, "USCOUNTIES", nil)
}
applog.Printf("awards: removed legacy USCOUNTIES award (renamed to USA-CA)")
}
}
// Reconcile with the catalog: awards ADDED since this operator last saved, and // Reconcile with the catalog: awards ADDED since this operator last saved, and
// shipped awards whose definition we have since FIXED (a higher Version). Both // shipped awards whose definition we have since FIXED (a higher Version). Both
// must be written back, or the editor would keep showing the old set. // must be written back, or the editor would keep showing the old set.
@@ -4244,7 +4271,7 @@ func freeAwardCode(code string, taken map[string]int) string {
// builtinRefsVersion is bumped whenever the built-in reference data changes // builtinRefsVersion is bumped whenever the built-in reference data changes
// (e.g. the West Malaysia 155→299 fix) so existing installs re-seed the // (e.g. the West Malaysia 155→299 fix) so existing installs re-seed the
// derived lists. Bump this after correcting BuiltinRefs / the DXCC name table. // derived lists. Bump this after correcting BuiltinRefs / the DXCC name table.
const builtinRefsVersion = "2" const builtinRefsVersion = "5"
// seedBuiltinReferences populates the reference lists of built-in awards. // seedBuiltinReferences populates the reference lists of built-in awards.
// - First run (no version stored), or already up to date: seed only awards that // - First run (no version stored), or already up to date: seed only awards that
@@ -4680,7 +4707,9 @@ var bulkFieldColumns = map[string]string{
"prop_mode": "prop_mode", "prop_mode": "prop_mode",
"sat_name": "sat_name", "sat_name": "sat_name",
"sat_mode": "sat_mode", "sat_mode": "sat_mode",
// Contacted station activation refs / SIG // Contacted station location + activation refs / SIG
"state": "state",
"cnty": "cnty",
"pota_ref": "pota_ref", "pota_ref": "pota_ref",
"sota_ref": "sota_ref", "sota_ref": "sota_ref",
"wwff_ref": "wwff_ref", "wwff_ref": "wwff_ref",
@@ -7760,9 +7789,222 @@ type ConfirmationItem struct {
Country string `json:"country"` Country string `json:"country"`
NewDXCC bool `json:"new_dxcc"` NewDXCC bool `json:"new_dxcc"`
NewBand bool `json:"new_band"` NewBand bool `json:"new_band"`
NewMode bool `json:"new_mode"` // new mode CLASS (Phone/CW/Digital) for the entity
NewSlot bool `json:"new_slot"` NewSlot bool `json:"new_slot"`
} }
// GetSlotStats returns the worked/confirmed slot + DXCC tallies for the QSL
// Manager. It counts over the SAME data path as the DXCC award, using the
// award's confirmation test (award.Confirmed with the DXCC sources lotw+qsl),
// so the numbers agree with the DXCC award matrix. A slot is a distinct
// DXCC × band (mode-agnostic) — i.e. the award's WORKED/CONFIRMED "Grand"
// totals, NOT split per emission.
func (a *App) GetSlotStats() qso.SlotStats {
if a.qso == nil {
return qso.SlotStats{}
}
// Delegate to the exact same matrix the DXCC award draws: the "WORKED"/
// "CONFIRMED" (ALL-emission) rows give DXCC entities (Total) and slots =
// entity × band (GrandTotal). Guaranteed to equal the award's Grand column.
res, err := a.GetAwardStats("DXCC")
if err != nil {
return qso.SlotStats{}
}
var st qso.SlotStats
for _, r := range res.Rows {
switch r.Label {
case "WORKED":
st.SlotsWorked, st.DXCCWorked = r.GrandTotal, r.Total
case "CONFIRMED":
st.SlotsConfirmed, st.DXCCConfirmed = r.GrandTotal, r.Total
}
}
return st
}
// --- US Counties (ULS) -----------------------------------------------------
// applyULSCounty fills a US QSO's blank county (and blank grid) from the offline
// ULS store. Non-US QSOs, an un-downloaded store, or already-filled fields make
// it a no-op — a value the operator or QRZ supplied is never overwritten, since
// the ZIP-derived county is only ~98% and the logged one is usually better. The
// award normalises whatever shape cnty holds, so stamping "ST,County" is safe.
func (a *App) applyULSCounty(q *qso.QSO) {
if a.uls == nil || q.DXCC == nil {
return
}
switch *q.DXCC {
case 291, 110, 6: // United States, Hawaii, Alaska
default:
return
}
haveCounty := strings.TrimSpace(q.County) != ""
haveGrid := strings.TrimSpace(q.Grid) != ""
if haveCounty && haveGrid {
return
}
loc, ok := a.uls.Resolve(q.Callsign)
if !ok {
return
}
if !haveCounty {
q.County = loc.CNTY()
if strings.TrimSpace(q.State) == "" {
q.State = loc.State
}
}
if !haveGrid && loc.Grid != "" {
q.Grid = loc.Grid
}
}
// ULSStatusResult reports whether the county database is loaded, and how fresh.
type ULSStatusResult struct {
Count int `json:"count"`
UpdatedAt string `json:"updated_at"` // RFC3339, empty if never downloaded
}
// ULSStatus returns the state of the offline US county database.
func (a *App) ULSStatus() ULSStatusResult {
if a.uls == nil {
return ULSStatusResult{}
}
var updated string
if t := a.uls.UpdatedAt(); !t.IsZero() {
updated = t.Format(time.RFC3339)
}
return ULSStatusResult{Count: a.uls.Count(), UpdatedAt: updated}
}
// DownloadULSCounties downloads and (re)builds the offline US county database in
// the background, emitting "uls:progress" ({stage,pct}) and "uls:done" ({ok,
// error,count}) so the Settings panel can show progress and reuse one flow.
func (a *App) DownloadULSCounties() error {
if a.uls == nil {
return fmt.Errorf("county database unavailable")
}
go func() {
defer func() {
if r := recover(); r != nil {
applog.Printf("PANIC in DownloadULSCounties: %v\n%s", r, debug.Stack())
wruntime.EventsEmit(a.ctx, "uls:done", map[string]any{"ok": false, "error": fmt.Sprintf("%v", r)})
}
}()
err := a.uls.Import(a.ctx, a.dataDir, func(stage string, pct int) {
wruntime.EventsEmit(a.ctx, "uls:progress", map[string]any{"stage": stage, "pct": pct})
})
res := map[string]any{"ok": err == nil, "count": a.uls.Count()}
if err != nil {
applog.Printf("uls: import failed: %v", err)
res["error"] = err.Error()
} else {
applog.Printf("uls: import complete — %d callsigns", a.uls.Count())
}
wruntime.EventsEmit(a.ctx, "uls:done", res)
}()
return nil
}
// BackfillUSCountiesResult summarises a bulk county/grid backfill over the log.
type BackfillUSCountiesResult struct {
Scanned int `json:"scanned"` // US QSOs examined
County int `json:"county"` // QSOs that gained a county
Grid int `json:"grid"` // QSOs that gained a grid
}
// BackfillUSCounties resolves county (and grid) for existing US QSOs that lack
// them, using the offline ULS store. It only FILLS blanks — an existing county
// is left as the operator logged it. Returns how many rows were updated.
func (a *App) BackfillUSCounties() (BackfillUSCountiesResult, error) {
var res BackfillUSCountiesResult
if a.uls == nil || a.uls.Count() == 0 {
return res, fmt.Errorf("county database not downloaded")
}
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
}
// Set of valid USA-CA county keys, so we can tell a good county from a bad one
// (a wrong county for the state, a bare city, "0", …) and re-resolve only the
// bad ones.
validRef := map[string]bool{}
if refs, ok := awardref.BuiltinRefs("USA-CA"); ok {
for _, r := range refs {
validRef[r.Code] = true
}
}
for i := range rows {
q := &rows[i]
if q.DXCC == nil {
continue
}
switch *q.DXCC {
case 291, 110, 6:
default:
continue
}
res.Scanned++
// A county "needs" filling when it maps to NO valid USA-CA county: empty,
// garbage, or a wrong county for the state. A legitimate DC operation
// (state=DC) is left alone — DC simply isn't a USA-CA county, not an error.
curKey := award.USCountyKey(q.State, q.County)
stateUp := strings.ToUpper(strings.TrimSpace(q.State))
needCounty := !validRef[curKey] && stateUp != "DC"
needGrid := strings.TrimSpace(q.Grid) == ""
if !needCounty && !needGrid {
continue
}
loc, ok := a.uls.Resolve(q.Callsign)
if !ok {
continue
}
changed := false
if needCounty {
// The logged county maps to no real USA-CA county (wrong state, wrong
// county, empty…). Fix it from the ULS, PREFERRING to keep the operator's
// county and correct only the state — their county often came from QRZ
// and is finer than the ZIP-derived one (e.g. K1LZ logged ME,Middlesex →
// Middlesex is a MA county, so → MA,Middlesex). Fall back to the ULS
// county when even that is invalid. Never stamp an unrecognised value
// (e.g. a Connecticut 2022 "planning region" GeoNames now returns). A
// portable op keeps a suffix that won't resolve in the ULS, so only
// fixed-station bad data is ever touched.
name := q.County
if i := strings.IndexByte(name, ','); i >= 0 {
name = name[i+1:]
}
name = strings.TrimSpace(name)
var stState, stName string
if name != "" && validRef[award.USCountyKey(loc.State, name)] {
stState, stName = loc.State, name
} else if validRef[award.USCountyKey(loc.State, loc.County)] {
stState, stName = loc.State, loc.County
}
if stName != "" {
q.State = stState
q.County = stState + "," + stName
res.County++
changed = true
}
}
if needGrid && loc.Grid != "" {
q.Grid = loc.Grid
res.Grid++
changed = true
}
if changed {
if err := a.qso.Update(a.ctx, *q); err != nil {
applog.Printf("uls backfill: update qso %d failed: %v", q.ID, err)
}
}
}
a.invalidateAwardStats() // county changes affect the US Counties matrix
return res, nil
}
// DownloadConfirmations pulls confirmed QSOs from a service and updates the // DownloadConfirmations pulls confirmed QSOs from a service and updates the
// matching local QSOs' received status. LoTW only for now (the canonical // matching local QSOs' received status. LoTW only for now (the canonical
// confirmation system); runs in the background emitting the same // confirmation system); runs in the background emitting the same
@@ -7887,12 +8129,17 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
Country: q.Country, Country: q.Country,
} }
if dxccNum != 0 { if dxccNum != 0 {
// Flags use mode CLASS (Phone/CW/Digital), not raw mode: a DIGI
// confirmation is "new mode/slot" only if no digital mode was
// confirmed there before (RTTY and FT8 are the same class here).
it.NewDXCC = !sets.DXCC[dxccNum] it.NewDXCC = !sets.DXCC[dxccNum]
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)] it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] it.NewMode = !sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)]
it.NewSlot = !sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)]
sets.DXCC[dxccNum] = true sets.DXCC[dxccNum] = true
sets.Band[qso.BandKey(dxccNum, q.Band)] = true sets.Band[qso.BandKey(dxccNum, q.Band)] = true
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)] = true
sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)] = true
} }
items = append(items, it) items = append(items, it)
return nil return nil
@@ -8047,12 +8294,17 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
Band: q.Band, Mode: q.Mode, Country: q.Country, Band: q.Band, Mode: q.Mode, Country: q.Country,
} }
if dxccNum != 0 { if dxccNum != 0 {
// Flags use mode CLASS (Phone/CW/Digital), not raw mode: a DIGI
// confirmation is "new mode/slot" only if no digital mode was
// confirmed there before (RTTY and FT8 are the same class here).
it.NewDXCC = !sets.DXCC[dxccNum] it.NewDXCC = !sets.DXCC[dxccNum]
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)] it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] it.NewMode = !sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)]
it.NewSlot = !sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)]
sets.DXCC[dxccNum] = true sets.DXCC[dxccNum] = true
sets.Band[qso.BandKey(dxccNum, q.Band)] = true sets.Band[qso.BandKey(dxccNum, q.Band)] = true
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)] = true
sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)] = true
} }
items = append(items, it) items = append(items, it)
return nil return nil
@@ -8163,12 +8415,17 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
Band: q.Band, Mode: q.Mode, Country: q.Country, Band: q.Band, Mode: q.Mode, Country: q.Country,
} }
if dxccNum != 0 { if dxccNum != 0 {
// Flags use mode CLASS (Phone/CW/Digital), not raw mode: a DIGI
// confirmation is "new mode/slot" only if no digital mode was
// confirmed there before (RTTY and FT8 are the same class here).
it.NewDXCC = !sets.DXCC[dxccNum] it.NewDXCC = !sets.DXCC[dxccNum]
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)] it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] it.NewMode = !sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)]
it.NewSlot = !sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)]
sets.DXCC[dxccNum] = true sets.DXCC[dxccNum] = true
sets.Band[qso.BandKey(dxccNum, q.Band)] = true sets.Band[qso.BandKey(dxccNum, q.Band)] = true
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)] = true
sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)] = true
} }
items = append(items, it) items = append(items, it)
return nil return nil
@@ -10747,6 +11004,14 @@ type motorAntenna interface {
Retract() error Retract() error
LastSetKHz() int LastSetKHz() int
Status() motorStatus Status() motorStatus
// Elements returns the per-element lengths (mm) when the antenna exposes them
// (Ultrabeam), or nil when it doesn't (SteppIR tunes its elements from the
// frequency and has no individual-element command).
Elements() []int
SetElement(num, lengthMm int) error
// ReadElements queries the controller for the current element lengths on demand
// (Ultrabeam CMD_READ_BANDS); SteppIR returns an error.
ReadElements() ([]int, error)
} }
// ubAdapter / steppirAdapter wrap each concrete client to the shared interface, // ubAdapter / steppirAdapter wrap each concrete client to the shared interface,
@@ -10760,6 +11025,14 @@ func (a ubAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d)
func (a ubAdapter) SetDirection(d int) error { return a.c.SetDirection(d) } func (a ubAdapter) SetDirection(d int) error { return a.c.SetDirection(d) }
func (a ubAdapter) Retract() error { return a.c.Retract() } func (a ubAdapter) Retract() error { return a.c.Retract() }
func (a ubAdapter) LastSetKHz() int { return a.c.LastSetKHz() } func (a ubAdapter) LastSetKHz() int { return a.c.LastSetKHz() }
func (a ubAdapter) SetElement(n, mm int) error { return a.c.ModifyElement(n, mm) }
func (a ubAdapter) ReadElements() ([]int, error) { return a.c.ReadElements() }
func (a ubAdapter) Elements() []int {
if st, err := a.c.GetStatus(); err == nil && st != nil {
return st.ElementLengths
}
return nil
}
func (a ubAdapter) Status() motorStatus { func (a ubAdapter) Status() motorStatus {
st, err := a.c.GetStatus() st, err := a.c.GetStatus()
if err != nil || st == nil { if err != nil || st == nil {
@@ -10776,6 +11049,13 @@ func (a steppirAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k
func (a steppirAdapter) SetDirection(d int) error { return a.c.SetDirection(d) } func (a steppirAdapter) SetDirection(d int) error { return a.c.SetDirection(d) }
func (a steppirAdapter) Retract() error { return a.c.Retract() } func (a steppirAdapter) Retract() error { return a.c.Retract() }
func (a steppirAdapter) LastSetKHz() int { return a.c.LastSetKHz() } func (a steppirAdapter) LastSetKHz() int { return a.c.LastSetKHz() }
func (a steppirAdapter) Elements() []int { return nil } // SteppIR: no per-element control
func (a steppirAdapter) SetElement(_, _ int) error {
return fmt.Errorf("individual element control is not available on the SteppIR")
}
func (a steppirAdapter) ReadElements() ([]int, error) {
return nil, fmt.Errorf("individual element control is not available on the SteppIR")
}
func (a steppirAdapter) Status() motorStatus { func (a steppirAdapter) Status() motorStatus {
st, err := a.c.GetStatus() st, err := a.c.GetStatus()
if err != nil || st == nil { if err != nil || st == nil {
@@ -11062,19 +11342,22 @@ func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, stop <-chan struc
// direction control). Enabled mirrors the setting; the rest comes from the // direction control). Enabled mirrors the setting; the rest comes from the
// device's most recent status poll. // device's most recent status poll.
type UltrabeamStatusInfo struct { type UltrabeamStatusInfo struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Connected bool `json:"connected"` Type string `json:"type"` // "ultrabeam" | "steppir"
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bidirectional Connected bool `json:"connected"`
Frequency int `json:"frequency"` // KHz Direction int `json:"direction"` // 0=normal, 1=180°, 2=bidirectional
Band int `json:"band"` Frequency int `json:"frequency"` // KHz
Moving bool `json:"moving"` Band int `json:"band"`
Moving bool `json:"moving"`
Elements []int `json:"elements"` // per-element lengths (mm); empty when unsupported
} }
// GetUltrabeamStatus returns the antenna's current state for the UI poll. // GetUltrabeamStatus returns the antenna's current state for the UI poll.
func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo { func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo {
out := UltrabeamStatusInfo{} out := UltrabeamStatusInfo{Elements: []int{}}
s, _ := a.GetUltrabeamSettings() s, _ := a.GetUltrabeamSettings()
out.Enabled = s.Enabled out.Enabled = s.Enabled
out.Type = s.Type
if a.motorAnt == nil { if a.motorAnt == nil {
return out return out
} }
@@ -11084,9 +11367,31 @@ func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo {
out.Frequency = st.Frequency out.Frequency = st.Frequency
out.Band = st.Band out.Band = st.Band
out.Moving = st.Moving out.Moving = st.Moving
if el := a.motorAnt.Elements(); el != nil {
out.Elements = el
}
return out return out
} }
// MotorSetElement sets one element's length (mm) on an Ultrabeam. Returns an
// error on the SteppIR, which has no per-element command.
func (a *App) MotorSetElement(num, lengthMm int) error {
if a.motorAnt == nil {
return fmt.Errorf("antenna not connected — enable it in Settings → Antenna")
}
a.noteMotorMoveCommanded()
return a.motorAnt.SetElement(num, lengthMm)
}
// MotorReadElements queries the controller for the current element lengths (mm),
// so the operator adjusts from the real values instead of blind. Ultrabeam only.
func (a *App) MotorReadElements() ([]int, error) {
if a.motorAnt == nil {
return nil, fmt.Errorf("antenna not connected — enable it in Settings → Antenna")
}
return a.motorAnt.ReadElements()
}
// SetUltrabeamDirection switches the antenna pattern: 0=normal, 1=180°, // SetUltrabeamDirection switches the antenna pattern: 0=normal, 1=180°,
// 2=bidirectional (re-issues the current frequency with the new direction). // 2=bidirectional (re-issues the current frequency with the new direction).
func (a *App) SetUltrabeamDirection(direction int) error { func (a *App) SetUltrabeamDirection(direction int) error {
@@ -11774,9 +12079,10 @@ func (a *App) GetClusterStatus() []cluster.ServerStatus {
// SpotQuery is one (call, band, mode) tuple sent for status colouring. // SpotQuery is one (call, band, mode) tuple sent for status colouring.
type SpotQuery struct { type SpotQuery struct {
Call string `json:"call"` Call string `json:"call"`
Band string `json:"band"` Band string `json:"band"`
Mode string `json:"mode"` Mode string `json:"mode"`
POTARef string `json:"pota_ref,omitempty"` // park id if the spot is a POTA activation
} }
// SpotStatus is the per-tuple result. Status is one of: // SpotStatus is the per-tuple result. Status is one of:
@@ -11798,6 +12104,12 @@ type SpotStatus struct {
// (any band, any mode). Drives the per-call text highlight, in // (any band, any mode). Drives the per-call text highlight, in
// addition to the entity-level Status (NEW / NEW BAND / …). // addition to the entity-level Status (NEW / NEW BAND / …).
WorkedCall bool `json:"worked_call"` WorkedCall bool `json:"worked_call"`
// NewCounty/NewPOTA are ORTHOGONAL to Status: a station already worked for
// its DXCC entity can still be a never-worked US county or POTA park. County
// is resolved from the callsign via the offline ULS store (US only, and only
// when that database has been downloaded); POTA from the spot's tagged park.
NewCounty bool `json:"new_county"`
NewPOTA bool `json:"new_pota"`
} }
// ClusterSpotStatuses takes a batch of spots and returns slot status for // ClusterSpotStatuses takes a batch of spots and returns slot status for
@@ -11842,6 +12154,10 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
// "I've already QSO'd this exact station" even when the band/mode // "I've already QSO'd this exact station" even when the band/mode
// makes the entity check say "new-band" or "new-slot". // makes the entity check say "new-band" or "new-slot".
workedCalls, _ := a.qso.WorkedCallsigns(a.ctx) workedCalls, _ := a.qso.WorkedCallsigns(a.ctx)
// Orthogonal dimensions: worked US counties (for the ULS callsign→county
// lookup) and worked POTA parks. Both built once per batch.
workedCounties, _ := a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
workedPOTA, _ := a.qso.WorkedPOTARefs(a.ctx)
for i, q := range spots { for i, q := range spots {
out[i] = SpotStatus{ out[i] = SpotStatus{
Call: q.Call, Call: q.Call,
@@ -11851,6 +12167,23 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok { if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok {
out[i].WorkedCall = true out[i].WorkedCall = true
} }
// NEW POTA: the spot's tagged park, never worked before.
if ref := strings.ToUpper(strings.TrimSpace(q.POTARef)); ref != "" {
if _, done := workedPOTA[ref]; !done {
out[i].NewPOTA = true
}
}
// NEW COUNTY: resolve the callsign's home county from the offline ULS
// store (US only; inert until downloaded) and flag if never worked.
if a.uls != nil {
if loc, ok := a.uls.Resolve(q.Call); ok {
if key := award.USCountyKey(loc.State, loc.County); key != "" {
if _, done := workedCounties[key]; !done {
out[i].NewCounty = true
}
}
}
}
if a.dxcc == nil { if a.dxcc == nil {
continue continue
} }
+100
View File
@@ -0,0 +1,100 @@
package main
// One-shot generator: reads the FIPS county CSV and emits
// internal/awardref/uscounties_gen.go. Not part of the build.
import (
"bufio"
"fmt"
"os"
"sort"
"strings"
"hamlog/internal/award"
)
// dxccForState maps the two US "states" that are separate DXCC entities.
func dxccForState(st string) int {
switch st {
case "AK":
return 6
case "HI":
return 110
default:
return 291
}
}
// territories we exclude entirely — plus DC, which USA-CA does not count as a
// county.
var skipState = map[string]bool{"PR": true, "GU": true, "VI": true, "AS": true, "MP": true, "NA": true, "DC": true}
// excludedUSACA reports county-equivalents that the CQ USA-CA award does NOT
// count as counties: the independent cities of Virginia and Carson City (NV).
// (Baltimore MD and St. Louis MO ARE counted, so they are kept.) A contact in
// one of these counts toward a bordering county under the award rules.
func excludedUSACA(state, name string) bool {
n := strings.ToLower(strings.TrimSpace(name))
switch state {
case "VA":
return strings.HasSuffix(n, " city")
case "NV":
return n == "carson city"
}
return false
}
func main() {
f, err := os.Open(os.Args[1])
if err != nil {
panic(err)
}
defer f.Close()
type row struct{ code, name string; dxcc int }
var rows []row
seen := map[string]bool{}
sc := bufio.NewScanner(f)
sc.Scan() // header
for sc.Scan() {
line := sc.Text()
parts := strings.SplitN(line, ",", 3)
if len(parts) < 3 {
continue
}
name := strings.TrimSpace(parts[1])
st := strings.TrimSpace(parts[2])
if skipState[st] || len(st) != 2 || strings.EqualFold(name, "UNITED STATES") {
continue
}
if excludedUSACA(st, name) {
continue
}
// State header rows have an all-caps state NAME and state code "NA"
// (already skipped). County rows have a 2-letter state code.
code := award.USCountyKey(st, name)
if code == "" || seen[code] {
continue
}
seen[code] = true
rows = append(rows, row{code: code, name: name + ", " + st, dxcc: dxccForState(st)})
}
sort.Slice(rows, func(i, j int) bool { return rows[i].code < rows[j].code })
var b strings.Builder
b.WriteString("// Code generated by cmd/cntygen from FIPS county data. DO NOT EDIT.\n")
b.WriteString("package awardref\n\n")
b.WriteString("// usCounties is the US Counties (USA-CA) reference list: one entry per county\n")
b.WriteString("// in the 50 states, keyed by the canonical \"STATE,COUNTY\" match code that\n")
b.WriteString("// award.usCountyKey produces from a QSO's state + cnty fields.\n")
fmt.Fprintf(&b, "func usCounties() []Ref {\n\treturn []Ref{\n")
for _, r := range rows {
fmt.Fprintf(&b, "\t\tref(%q, %q, %d),\n", r.code, r.name, r.dxcc)
}
b.WriteString("\t}\n}\n")
if err := os.WriteFile(os.Args[2], []byte(b.String()), 0o644); err != nil {
panic(err)
}
fmt.Printf("wrote %d counties to %s\n", len(rows), os.Args[2])
}
+24 -17
View File
@@ -1063,7 +1063,7 @@ export default function App() {
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked". // Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on // Keyed by `${call}|${band}|${mode}` so two spots of the same call on
// different slots don't share the same colour. // different slots don't share the same colour.
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean }>>({}); const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; new_county?: boolean; new_pota?: boolean }>>({});
// === Modals === // === Modals ===
const [editingQSO, setEditingQSO] = useState<QSO | null>(null); const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
@@ -2010,14 +2010,14 @@ export default function App() {
// "new-slot" because the lookup key carried mode="". // "new-slot" because the lookup key carried mode="".
useEffect(() => { useEffect(() => {
const t = window.setTimeout(async () => { const t = window.setTimeout(async () => {
const unknown: { call: string; band: string; mode: string }[] = []; const unknown: { call: string; band: string; mode: string; pota_ref: string }[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const s of spots) { for (const s of spots) {
const mode = inferSpotMode(s.comment ?? '', s.freq_hz); const mode = inferSpotMode(s.comment ?? '', s.freq_hz);
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz); const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
if (seen.has(k) || spotStatus[k]) continue; if (seen.has(k) || spotStatus[k]) continue;
seen.add(k); seen.add(k);
unknown.push({ call: s.dx_call, band: s.band ?? '', mode }); unknown.push({ call: s.dx_call, band: s.band ?? '', mode, pota_ref: (s as any).pota_ref ?? '' });
} }
if (unknown.length === 0) return; if (unknown.length === 0) return;
try { try {
@@ -2031,6 +2031,8 @@ export default function App() {
country: r.country, country: r.country,
continent: (r as any).continent, continent: (r as any).continent,
worked_call: !!(r as any).worked_call, worked_call: !!(r as any).worked_call,
new_county: !!(r as any).new_county,
new_pota: !!(r as any).new_pota,
}; };
} }
return next; return next;
@@ -2866,12 +2868,12 @@ export default function App() {
</div> </div>
</> </>
) : ( ) : (
<div className="flex flex-col flex-[0.55] min-w-[70px]"><Label className="mb-1 h-3.5">{t('field.qth')}</Label> <div className="flex flex-col flex-1 min-w-[130px]"><Label className="mb-1 h-3.5">{t('field.qth')}</Label>
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} /> <Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
</div> </div>
); );
const gridBlock = ( const gridBlock = (
<div className="flex flex-col w-28 shrink-0"><Label className="mb-1 h-3.5">{t('field.grid')}</Label> <div className="flex flex-col w-[76px] shrink-0"><Label className="mb-1 h-3.5">{t('field.grid')}</Label>
<Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} /> <Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
</div> </div>
); );
@@ -2885,8 +2887,8 @@ export default function App() {
: d < 30 ? 'border-warning text-warning bg-warning/15' : d < 30 ? 'border-warning text-warning bg-warning/15'
: 'border-danger text-danger bg-danger/15'; : 'border-danger text-danger bg-danger/15';
return ( return (
<div className="self-end shrink-0" title={t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })}> <div className="shrink-0" title={t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })}>
<span className={cn('inline-flex items-center rounded-md border px-1.5 py-1.5 text-[10px] font-extrabold tracking-wide leading-none', cls)}> <span className={cn('inline-flex items-center rounded-md border px-1.5 py-1 text-[9px] font-extrabold tracking-wide leading-none', cls)}>
LoTW LoTW
</span> </span>
</div> </div>
@@ -2901,7 +2903,7 @@ export default function App() {
// worked-before check doesn't include these, and see what's waiting. // worked-before check doesn't include these, and see what's waiting.
const offlinePendingCount = offlineStatus.pending ?? 0; const offlinePendingCount = offlineStatus.pending ?? 0;
const offlineBlock = offlinePendingCount === 0 ? null : ( const offlineBlock = offlinePendingCount === 0 ? null : (
<div className="relative self-end mb-0.5 shrink-0"> <div className="relative shrink-0">
<button type="button" onClick={() => { setPendingOpen((o) => !o); GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {}); }} <button type="button" onClick={() => { setPendingOpen((o) => !o); GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {}); }}
title={t('offline.tip', { n: offlinePendingCount })} title={t('offline.tip', { n: offlinePendingCount })}
className="relative inline-flex h-8 items-center gap-1.5 rounded-full border border-danger bg-danger/15 px-2.5 text-danger transition-colors hover:bg-danger/25"> className="relative inline-flex h-8 items-center gap-1.5 rounded-full border border-danger bg-danger/15 px-2.5 text-danger transition-colors hover:bg-danger/25">
@@ -2954,7 +2956,7 @@ export default function App() {
const hasAlerts = recentAlerts.length > 0; const hasAlerts = recentAlerts.length > 0;
// Only show the bell when there are pending alerts — hidden otherwise. // Only show the bell when there are pending alerts — hidden otherwise.
const alertLedBlock = !hasAlerts ? null : ( const alertLedBlock = !hasAlerts ? null : (
<div className="relative self-end mb-0.5 shrink-0"> <div className="relative shrink-0">
<button type="button" onClick={() => setAlertsPanelOpen((o) => !o)} <button type="button" onClick={() => setAlertsPanelOpen((o) => !o)}
title={hasAlerts ? t('alert.pending', { n: recentAlerts.length }) : t('alert.noneShort')} title={hasAlerts ? t('alert.pending', { n: recentAlerts.length }) : t('alert.noneShort')}
className={cn('relative inline-flex size-8 items-center justify-center rounded-full border transition-colors', className={cn('relative inline-flex size-8 items-center justify-center rounded-full border transition-colors',
@@ -3109,19 +3111,19 @@ export default function App() {
const logButtons = ( const logButtons = (
<div className="flex flex-col"> <div className="flex flex-col">
<Label className="mb-1 h-3.5">&nbsp;</Label> <Label className="mb-1 h-3.5">&nbsp;</Label>
<div className="flex gap-2"> <div className="flex gap-1.5">
{clusterServerStatuses.some((s) => s.state === 'connected') && ( {clusterServerStatuses.some((s) => s.state === 'connected') && (
<Button type="button" variant="outline" onClick={() => setShowSpotModal(true)} className="h-8" title="Send a DX spot to the master cluster"> <Button type="button" size="sm" variant="outline" onClick={() => setShowSpotModal(true)} className="h-8 px-2.5 text-xs" title="Send a DX spot to the master cluster">
<Satellite className="size-3.5" /> <Satellite className="size-3.5" />
{t('btn.spot')} {t('btn.spot')}
</Button> </Button>
)} )}
<Button type="button" variant="outline" onClick={() => { resetEntry(); callsignRef.current?.focus(); }} className="h-8" <Button type="button" size="sm" variant="outline" onClick={() => { resetEntry(); callsignRef.current?.focus(); }} className="h-8 px-2.5 text-xs"
title="Clear the QSO entry (always — unlike Esc which may be reserved for the CW keyer)"> title="Clear the QSO entry (always — unlike Esc which may be reserved for the CW keyer)">
<Eraser className="size-3.5" /> <Eraser className="size-3.5" />
{t('btn.clear')} {t('btn.clear')}
</Button> </Button>
<Button onClick={save} disabled={saving} className="h-8"> <Button size="sm" onClick={save} disabled={saving} className="h-8 px-3 text-xs">
<Send className="size-3.5" /> <Send className="size-3.5" />
{saving ? '…' : t('btn.logQso')} {saving ? '…' : t('btn.logQso')}
</Button> </Button>
@@ -3910,9 +3912,6 @@ export default function App() {
</div> </div>
{qthBlock} {qthBlock}
{gridBlock} {gridBlock}
{lotwBlock}
{offlineBlock}
{alertLedBlock}
</div> </div>
{/* Row 3: tight left detail column (Band/Mode/Country) and {/* Row 3: tight left detail column (Band/Mode/Country) and
@@ -3929,11 +3928,19 @@ export default function App() {
</div> </div>
</div> </div>
{/* Bottom: TX freq, RX freq, RX band — plus the action buttons. */} {/* Bottom: TX freq, RX freq, RX band then the alert LED + LoTW badge
(vertically centred on the input row), then the action buttons. */}
<div className="flex gap-2 items-end"> <div className="flex gap-2 items-end">
{freqBlock} {freqBlock}
{rxFreqBlock} {rxFreqBlock}
{bandRxBlock} {bandRxBlock}
{(alertLedBlock || lotwBlock || offlineBlock) && (
<div className="flex items-center gap-2 h-9 self-end">
{alertLedBlock}
{lotwBlock}
{offlineBlock}
</div>
)}
<div className="ml-auto">{logButtons}</div> <div className="ml-auto">{logButtons}</div>
</div> </div>
</> </>
+3 -1
View File
@@ -64,7 +64,9 @@ const FIELDS: FieldDef[] = [
{ id: 'prop_mode', label: 'bulk.fPropMode', group: 'Propagation', kind: 'text', upper: true }, { id: 'prop_mode', label: 'bulk.fPropMode', group: 'Propagation', kind: 'text', upper: true },
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true }, { id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true }, { id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
// Contacted station (activation refs / SIG) // Contacted station (location / activation refs / SIG)
{ id: 'state', label: 'bulk.fState', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'cnty', label: 'bulk.fCnty', group: 'Contacted station', kind: 'text' },
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true }, { id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'sota_ref', label: 'bulk.fSotaRef', group: 'Contacted station', kind: 'text', upper: true }, { id: 'sota_ref', label: 'bulk.fSotaRef', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'wwff_ref', label: 'bulk.fWwffRef', group: 'Contacted station', kind: 'text', upper: true }, { id: 'wwff_ref', label: 'bulk.fWwffRef', group: 'Contacted station', kind: 'text', upper: true },
+49 -21
View File
@@ -51,6 +51,8 @@ export type SpotStatusEntry = {
country?: string; country?: string;
continent?: string; continent?: string;
worked_call?: boolean; worked_call?: boolean;
new_county?: boolean;
new_pota?: boolean;
}; };
type Props = { type Props = {
@@ -101,13 +103,23 @@ function statusFor(p: any): SpotStatusEntry | undefined {
// Status column, using the same colours as the per-cell fills (NEW DXCC = // Status column, using the same colours as the per-cell fills (NEW DXCC =
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when // call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
// there's nothing notable to show. // there's nothing notable to show.
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null { type Badge = { text: string; fg: string; bg: string; bd: string };
// tok builds a badge from a semantic status token (success/warning/caution/
// danger/info/neutral) so the colours adapt to every theme via CSS variables,
// instead of the hard-coded light-theme pastels that looked wrong in dark mode.
function tok(name: string, text: string): Badge {
if (name === 'neutral') return { text, fg: 'var(--muted-foreground)', bg: 'var(--muted)', bd: 'var(--border)' };
return { text, fg: `var(--${name}-muted-foreground)`, bg: `var(--${name}-muted)`, bd: `var(--${name}-border)` };
}
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null {
switch (s?.status) { switch (s?.status) {
case 'new': return { text: t('clg2.newDxcc'), fg: '#be123c', bg: '#ffe4e6' }; case 'new': return tok('danger', t('clg2.newDxcc'));
case 'new-band': return { text: t('clg2.newBand'), fg: '#92400e', bg: '#fde68a' }; case 'new-band': return tok('warning', t('clg2.newBand'));
case 'new-mode': return { text: t('clg2.newMode'), fg: '#854d0e', bg: '#fef08a' }; case 'new-mode': return tok('caution', t('clg2.newMode'));
case 'new-slot': return { text: t('clg2.newSlot'), fg: '#854d0e', bg: '#fef08a' }; case 'new-slot': return tok('caution', t('clg2.newSlot'));
default: return s?.worked_call ? { text: t('clg2.wkdCall'), fg: '#0369a1', bg: '#e0f2fe' } : null; default: return s?.worked_call ? tok('neutral', t('clg2.wkdCall')) : null;
} }
} }
@@ -130,8 +142,8 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
// the rest of the row across every theme instead of always shouting orange. // the rest of the row across every theme instead of always shouting orange.
cellStyle: (p: any): any => { cellStyle: (p: any): any => {
const s = statusFor(p); const s = statusFor(p);
if (s?.status === 'new') return { backgroundColor: '#ffe4e6', color: '#be123c', fontWeight: 700 }; if (s?.status === 'new') return { backgroundColor: 'var(--danger-muted)', color: 'var(--danger-muted-foreground)', fontWeight: 700 };
if (s?.worked_call) return { color: '#0369a1', fontWeight: 700 }; if (s?.worked_call) return { color: 'var(--info)', fontWeight: 700 };
return { fontWeight: 700 }; return { fontWeight: 700 };
}, },
tooltipValueGetter: (p: any) => { tooltipValueGetter: (p: any) => {
@@ -141,26 +153,42 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
}, },
{ {
group: 'Spot', label: t('clg2.c.status'), colId: 'status', group: 'Spot', label: t('clg2.c.status'), colId: 'status',
headerName: t('clg2.c.status'), width: 96, sortable: true, headerName: t('clg2.c.status'), width: 120, sortable: true,
defaultVisible: true, defaultVisible: true,
// Spells out the slot status as a text badge so NEW SLOT (and the others) // Spells out the slot status as a text badge so NEW SLOT (and the others)
// is obvious at the row level, not just a single coloured cell. // is obvious at the row level, not just a single coloured cell. NEW COUNTY
// and NEW POTA are orthogonal, so they stack as extra badges.
valueGetter: (p: any) => { valueGetter: (p: any) => {
const s = statusFor(p); const s = statusFor(p);
if (s?.status === 'new') return t('clg2.newDxcc'); const parts: string[] = [];
if (s?.status === 'new-band') return t('clg2.newBand'); if (s?.status === 'new') parts.push(t('clg2.newDxcc'));
if (s?.status === 'new-mode') return t('clg2.newMode'); else if (s?.status === 'new-band') parts.push(t('clg2.newBand'));
if (s?.status === 'new-slot') return t('clg2.newSlot'); else if (s?.status === 'new-mode') parts.push(t('clg2.newMode'));
return s?.worked_call ? t('clg2.wkdCall') : ''; else if (s?.status === 'new-slot') parts.push(t('clg2.newSlot'));
else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
if (s?.new_county) parts.push(t('clg2.newCounty'));
if (s?.new_pota) parts.push(t('clg2.newPota'));
return parts.join(' ');
}, },
cellRenderer: (p: any) => { cellRenderer: (p: any) => {
const b = statusBadge(t, statusFor(p)); const s = statusFor(p);
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}></span>; const badges: Badge[] = [];
const b = statusBadge(t, s);
if (b) badges.push(b);
if (s?.new_county) badges.push(tok('info', t('clg2.newCounty')));
if (s?.new_pota) badges.push(tok('success', t('clg2.newPota')));
if (badges.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
return ( return (
<span style={{ <span style={{ display: 'flex', height: '100%', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
backgroundColor: b.bg, color: b.fg, fontWeight: 700, fontSize: 10, {badges.map((bd, i) => (
padding: '1px 6px', borderRadius: 4, letterSpacing: 0.3, whiteSpace: 'nowrap', <span key={i} style={{
}}>{b.text}</span> display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1,
backgroundColor: bd.bg, color: bd.fg, border: `1px solid ${bd.bd}`,
fontWeight: 700, fontSize: 9,
padding: '0 5px', borderRadius: 999, letterSpacing: 0.3, whiteSpace: 'nowrap',
}}>{bd.text}</span>
))}
</span>
); );
}, },
tooltipValueGetter: (p: any) => { tooltipValueGetter: (p: any) => {
+18 -54
View File
@@ -14,6 +14,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { QSOEditModal } from '@/components/QSOEditModal'; import { QSOEditModal } from '@/components/QSOEditModal';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import type { QSOForm } from '@/types'; import type { QSOForm } from '@/types';
import { import {
@@ -59,13 +60,13 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses). // Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null); const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]);
// Add/edit-contact dialog. // Add/edit-contact dialog.
const [contactOpen, setContactOpen] = useState(false); const [contactOpen, setContactOpen] = useState(false);
const [contact, setContact] = useState<Station>(emptyStation()); const [contact, setContact] = useState<Station>(emptyStation());
const [looking, setLooking] = useState(false); const [looking, setLooking] = useState(false);
const activeGrid = useRef<any>(null);
const rosterGrid = useRef<any>(null); const rosterGrid = useRef<any>(null);
// Worked-before for the clicked station (see if/when we contacted it before). // Worked-before for the clicked station (see if/when we contacted it before).
@@ -230,18 +231,6 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
} catch (e: any) { setError(String(e?.message ?? e)); } } catch (e: any) { setError(String(e?.message ?? e)); }
} }
const activeCols = useMemo<ColDef<QSOForm>[]>(() => [
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
{ headerName: 'QTH', field: 'qth', flex: 1 },
{ headerName: t('ncp.colTimeOn'), valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' },
{ headerName: t('ncp.colBand'), field: 'band', width: 70, cellClass: 'font-mono' },
{ headerName: t('ncp.colMode'), field: 'mode', width: 70, cellClass: 'font-mono' },
{ headerName: 'RST S', field: 'rst_sent', width: 70, cellClass: 'font-mono' },
{ headerName: 'RST R', field: 'rst_rcvd', width: 70, cellClass: 'font-mono' },
{ headerName: t('ncp.colComment'), field: 'comment', flex: 1.5 },
], [t]);
const rosterCols = useMemo<ColDef<Station>[]>(() => [ const rosterCols = useMemo<ColDef<Station>[]>(() => [
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' }, { headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
{ headerName: t('ncp.colName'), field: 'name', flex: 1 }, { headerName: t('ncp.colName'), field: 'name', flex: 1 },
@@ -292,26 +281,20 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
{t('ncp.onAirActive')} {t('ncp.onAirActive')}
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span> <span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
</div> </div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}> <div className="flex flex-col min-h-0 flex-1">
<div style={{ position: 'absolute', inset: 0 }}> <RecentQSOsGrid
<AgGridReact<QSOForm> rows={active}
ref={activeGrid} total={active.length}
theme={hamlogTheme} storageKey="net.onair"
rowData={active} onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
columnDefs={activeCols} onRowDoubleClicked={(q) => setEditingDraft(q)}
defaultColDef={defaultColDef} onRowSelected={setSelectedActiveIds}
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)} />
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
animateRows={false}
getRowId={(p) => String((p.data as any).id)}
/>
</div>
</div> </div>
{isOpen && active.length > 0 && ( {isOpen && active.length > 0 && (
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2"> <div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
<Button variant="ghost" size="sm" className="h-7 text-[11px]" <Button variant="ghost" size="sm" className="h-7 text-[11px]"
onClick={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as QSOForm; if (r) deactivate(r.id as number); }}> onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')} <MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
</Button> </Button>
</div> </div>
@@ -335,7 +318,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
</span> </span>
)} )}
</div> </div>
<div className="flex-1 min-h-0 overflow-auto"> <div className="flex-1 min-h-0 flex flex-col">
{!wbCall ? ( {!wbCall ? (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbHint')}</div> <div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbHint')}</div>
) : wbBusy ? ( ) : wbBusy ? (
@@ -343,30 +326,11 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
) : !wb || wb.count === 0 ? ( ) : !wb || wb.count === 0 ? (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbNone')} {wbCall}</div> <div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbNone')} {wbCall}</div>
) : ( ) : (
<table className="w-full text-xs"> <RecentQSOsGrid
<thead className="sticky top-0 bg-muted/30 text-muted-foreground"> rows={(wb.entries ?? []) as any}
<tr className="text-left"> total={wb.count}
<th className="px-3 py-1 font-semibold">{t('ncp.colDate')}</th> storageKey="net.wb"
<th className="px-2 py-1 font-semibold">{t('ncp.colTimeOn')}</th> />
<th className="px-2 py-1 font-semibold">{t('ncp.colBand')}</th>
<th className="px-2 py-1 font-semibold">{t('ncp.colMode')}</th>
<th className="px-2 py-1 font-semibold">RST</th>
<th className="px-2 py-1 font-semibold">{t('ncp.colComment')}</th>
</tr>
</thead>
<tbody>
{((wb.entries ?? []) as any[]).map((q, i) => (
<tr key={q.id ?? i} className="border-t border-border/20 hover:bg-muted/30">
<td className="px-3 py-1 font-mono">{String(q.qso_date ?? '').slice(0, 10)}</td>
<td className="px-2 py-1 font-mono">{String(q.time_on ?? '').slice(0, 5)}</td>
<td className="px-2 py-1">{q.band}</td>
<td className="px-2 py-1">{q.mode}</td>
<td className="px-2 py-1 font-mono">{(q.rst_sent ?? '')}/{(q.rst_rcvd ?? '')}</td>
<td className="px-2 py-1 truncate max-w-[360px]">{q.comment}</td>
</tr>
))}
</tbody>
</table>
)} )}
</div> </div>
</div> </div>
+32 -4
View File
@@ -8,7 +8,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App'; import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime'; import { EventsOn } from '../../wailsjs/runtime/runtime';
@@ -32,7 +32,7 @@ const UPLOAD_COLS: ColDef<UploadRow>[] = [
type Confirmation = { type Confirmation = {
callsign: string; qso_date: string; band: string; mode: string; country: string; callsign: string; qso_date: string; band: string; mode: string; country: string;
new_dxcc: boolean; new_band: boolean; new_slot: boolean; new_dxcc: boolean; new_band: boolean; new_mode: boolean; new_slot: boolean;
}; };
const SERVICES = [ const SERVICES = [
@@ -208,18 +208,27 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload'); const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
// Worked/confirmed tallies. Slots are counted by mode CLASS (PH/CW/DIGI) — the
// raw-mode granularity (RTTY ≠ FT8) stays only in the cluster/matrix new-slot flag.
const [stats, setStats] = useState<any>(
{ slots_worked: 0, slots_confirmed: 0, dxcc_worked: 0, dxcc_confirmed: 0, ph_worked: 0, ph_confirmed: 0, cw_worked: 0, cw_confirmed: 0, dig_worked: 0, dig_confirmed: 0 });
const refreshCounts = useCallback(() => { GetSlotStats().then((c: any) => setStats(c)).catch(() => {}); }, []);
useEffect(() => { refreshCounts(); }, [refreshCounts]);
useEffect(() => { useEffect(() => {
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line])); const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
const offDone = EventsOn('qslmgr:done', (d: any) => { const offDone = EventsOn('qslmgr:done', (d: any) => {
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0}`]); setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0}`]);
setBusy(false); setBusy(false);
refreshCounts(); // a download may have added confirmations
}); });
const offConf = EventsOn('qslmgr:confirmations', (list: any) => { const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
setConfirmations((list ?? []) as Confirmation[]); setConfirmations((list ?? []) as Confirmation[]);
setViewMode('confirmations'); setViewMode('confirmations');
refreshCounts();
}); });
return () => { offLog(); offDone(); offConf(); }; return () => { offLog(); offDone(); offConf(); };
}, []); }, [refreshCounts]);
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]); const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
@@ -231,9 +240,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const shownConfs = useMemo(() => confirmations.filter((c) => { const shownConfs = useMemo(() => confirmations.filter((c) => {
switch (confFilter) { switch (confFilter) {
case 'new': return c.new_dxcc || c.new_band || c.new_slot; case 'new': return c.new_dxcc || c.new_band || c.new_mode || c.new_slot;
case 'dxcc': return c.new_dxcc; case 'dxcc': return c.new_dxcc;
case 'band': return c.new_band; case 'band': return c.new_band;
case 'mode': return c.new_mode;
case 'slot': return c.new_slot; case 'slot': return c.new_slot;
default: return true; default: return true;
} }
@@ -353,6 +363,22 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
</> </>
)} )}
<div className="flex-1" /> <div className="flex-1" />
{/* Worked / confirmed tallies. Slots by mode class (PH/CW/DIGI), with the
per-class breakdown so the totals are checkable. */}
<div className="self-center flex items-center gap-3 text-[11px] font-mono" title={t('qslm.countsTip')}>
<span className="flex items-center gap-1">
<span className="text-muted-foreground uppercase text-[9px] tracking-wider">{t('qslm.slots')}</span>
<span className="font-semibold">{stats.slots_worked}</span>
<span className="text-muted-foreground">/</span>
<span className="text-success font-semibold">{stats.slots_confirmed}</span>
</span>
<span className="flex items-center gap-1">
<span className="text-muted-foreground uppercase text-[9px] tracking-wider">DXCC</span>
<span className="font-semibold">{stats.dxcc_worked}</span>
<span className="text-muted-foreground">/</span>
<span className="text-success font-semibold">{stats.dxcc_confirmed}</span>
</span>
</div>
{service === 'pota' && potaRes && ( {service === 'pota' && potaRes && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{t('qslm.potaSummaryShort', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched })}{potaRes.skipped_other_call > 0 ? t('qslm.potaOtherCall', { n: potaRes.skipped_other_call }) : ''} / {potaRes.fetched} {t('qslm.potaSummaryShort', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched })}{potaRes.skipped_other_call > 0 ? t('qslm.potaOtherCall', { n: potaRes.skipped_other_call }) : ''} / {potaRes.fetched}
@@ -371,6 +397,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
<SelectItem value="new">{t('qslm.filterNew')}</SelectItem> <SelectItem value="new">{t('qslm.filterNew')}</SelectItem>
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem> <SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem> <SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
<SelectItem value="mode">{t('qslm.filterNewMode')}</SelectItem>
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem> <SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -491,6 +518,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
<td className="py-1 px-2"> <td className="py-1 px-2">
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-danger-muted text-danger-muted-foreground border border-danger-border">{t('qslm.newDxcc')}</span> {c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-danger-muted text-danger-muted-foreground border border-danger-border">{t('qslm.newDxcc')}</span>
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border">{t('qslm.newBand')}</span> : c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border">{t('qslm.newBand')}</span>
: c.new_mode ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-info-muted text-info-muted-foreground border border-info-border">{t('qslm.newMode')}</span>
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-caution-muted text-caution-muted-foreground border border-caution-border">{t('qslm.newSlot')}</span> : c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-caution-muted text-caution-muted-foreground border border-caution-border">{t('qslm.newSlot')}</span>
: <span className="text-muted-foreground/50"></span>} : <span className="text-muted-foreground/50"></span>}
</td> </td>
+11 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Trash2, Search, Loader2 } from 'lucide-react'; import { Trash2, Search, Loader2 } from 'lucide-react';
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs } from '../../wailsjs/go/main/App'; import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
import { rstOptions, type RSTLists } from '@/lib/rst';
import { AwardRefSelector } from '@/components/AwardRefSelector'; import { AwardRefSelector } from '@/components/AwardRefSelector';
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor'; import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
import { applyAwardRefs } from '@/lib/awardRefs'; import { applyAwardRefs } from '@/lib/awardRefs';
@@ -175,6 +176,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
return base; return base;
}, [modes, qso.mode]); }, [modes, qso.mode]);
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso))); const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
// Per-mode RST dropdown choices, loaded from the same settings as the entry form.
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
useEffect(() => {
GetListsSettings()
.then((l: any) => setRstLists({ phone: l?.rst_phone ?? [], cw: l?.rst_cw ?? [], digital: l?.rst_digital ?? [] }))
.catch(() => {});
}, []);
// Frequencies are edited as kHz + Hz (Log4OM style) and recombined on save. // Frequencies are edited as kHz + Hz (Log4OM style) and recombined on save.
const splitHz = (hz?: number) => hz const splitHz = (hz?: number) => hz
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') } ? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
@@ -404,9 +412,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} /> value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
</div> </div>
<div className="flex flex-col w-20"><Label>S</Label> <div className="flex flex-col w-20"><Label>S</Label>
<Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} className="font-mono" /></div> <Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} allowFreeText commitOnType onChange={(v) => set('rst_sent', v)} /></div>
<div className="flex flex-col w-20"><Label>R</Label> <div className="flex flex-col w-20"><Label>R</Label>
<Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} className="font-mono" /></div> <Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} allowFreeText commitOnType onChange={(v) => set('rst_rcvd', v)} /></div>
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking} <Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
title={t('qedit.fetchTitle')}> title={t('qedit.fetchTitle')}>
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')} {looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
+27 -11
View File
@@ -30,7 +30,12 @@ type Props = {
// Bump this number to programmatically select every row (e.g. after a search // Bump this number to programmatically select every row (e.g. after a search
// in the QSL Manager, where the default is "all selected"). // in the QSL Manager, where the default is "all selected").
selectAllSignal?: number; selectAllSignal?: number;
// storageKey scopes the persisted column layout/visibility. Omit for the main
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
// reuse this grid elsewhere with its OWN independent column config.
storageKey?: string;
onRowDoubleClicked?: (q: QSOForm) => void; onRowDoubleClicked?: (q: QSOForm) => void;
onRowClicked?: (q: QSOForm) => void;
onRowSelected?: (ids: number[]) => void; onRowSelected?: (ids: number[]) => void;
onUpdateFromCty?: (ids: number[]) => void; onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void; onUpdateFromQRZ?: (ids: number[]) => void;
@@ -49,7 +54,7 @@ type Props = {
awardCols?: { code: string; name: string }[]; awardCols?: { code: string; name: string }[];
}; };
const COL_STATE_KEY = 'hamlog.qsoColState.v2'; const BASE_COLSTATE_KEY = 'hamlog.qsoColState.v2';
function fmtMhzDots(hz?: number): string { function fmtMhzDots(hz?: number): string {
if (!hz) return ''; if (!hz) return '';
@@ -233,10 +238,20 @@ const GRP_KEYS: Record<string, string> = {
}; };
export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g); export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) { // Award columns are governed SOLELY by the awardShown code-set, never by AG
// Grid's saved column state. Stripping them here (on both save and restore)
// stops a stale saved state from re-hiding a shown award column on every
// awardCols rebuild — the desync that made award columns vanish mid-session.
const stripAwardCols = (st: any[] | null | undefined): any[] =>
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const gridRef = useRef<any>(null); const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
// Column-layout persistence keys — scoped by storageKey so a reused instance
// (e.g. the Net panel) keeps its own layout independent of the main log.
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
const [menu, setMenu] = useState<QSOMenuState>(null); const [menu, setMenu] = useState<QSOMenuState>(null);
// Localized column catalog — rebuilt when the language changes. // Localized column catalog — rebuilt when the language changes.
@@ -276,7 +291,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
// across a columnDefs rebuild instead of re-reading `hide` — which is exactly // across a columnDefs rebuild instead of re-reading `hide` — which is exactly
// why previously-shown award columns kept vanishing on reopen. A dedicated set // why previously-shown award columns kept vanishing on reopen. A dedicated set
// is deterministic: it drives `hide` directly and is re-enforced below. // is deterministic: it drives `hide` directly and is re-enforced below.
const AWARD_SHOWN_KEY = 'hamlog.awardColsShown'; const AWARD_SHOWN_KEY = storageKey ? `hamlog.awardColsShown.${storageKey}` : 'hamlog.awardColsShown';
const [awardShown, setAwardShown] = useState<Set<string>>(() => new Set((loadLocal(AWARD_SHOWN_KEY) ?? []).map((s) => String(s).toUpperCase()))); const [awardShown, setAwardShown] = useState<Set<string>>(() => new Set((loadLocal(AWARD_SHOWN_KEY) ?? []).map((s) => String(s).toUpperCase())));
useEffect(() => { useEffect(() => {
// Fresh machine: hydrate the set from the portable DB copy, then seed the cache. // Fresh machine: hydrate the set from the portable DB copy, then seed the cache.
@@ -334,21 +349,21 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
}), []); }), []);
function onGridReady(e: GridReadyEvent) { function onGridReady(e: GridReadyEvent) {
const local = loadLocal(COL_STATE_KEY); const local = loadLocal(colStateKey);
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true }); if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
// Fall back to the portable DB copy when the local cache is empty // Fall back to the portable DB copy when the local cache is empty
// (fresh machine / after a reinstall), then re-seed the cache. // (fresh machine / after a reinstall), then re-seed the cache.
loadRemote(COL_STATE_KEY).then((remote) => { loadRemote(colStateKey).then((remote) => {
if (remote && !local) { if (remote && !local) {
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true }); e.api.applyColumnState({ state: stripAwardCols(remote) as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote); seedLocal(colStateKey, remote);
} }
}); });
} }
const saveColumnState = useCallback(() => { const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState(); const state = gridRef.current?.api?.getColumnState();
if (state) saveState(COL_STATE_KEY, state); if (state) saveState(colStateKey, stripAwardCols(state));
}, []); }, []);
// The award columns load asynchronously; when they arrive (or change) the // The award columns load asynchronously; when they arrive (or change) the
@@ -358,8 +373,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
// every rebuild so the user's choices win. No-op before the grid is ready. // every rebuild so the user's choices win. No-op before the grid is ready.
useEffect(() => { useEffect(() => {
const api = gridRef.current?.api; const api = gridRef.current?.api;
const local = loadLocal(COL_STATE_KEY); const local = loadLocal(colStateKey);
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true }); if (api && local) api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
// Re-enable saving once AG Grid has settled the column events from the rebuild. // Re-enable saving once AG Grid has settled the column events from the rebuild.
const t = window.setTimeout(() => { restoringRef.current = false; }, 0); const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t); return () => window.clearTimeout(t);
@@ -458,6 +473,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
onColumnVisible={saveColumnState} onColumnVisible={saveColumnState}
onSortChanged={saveColumnState} onSortChanged={saveColumnState}
onRowDoubleClicked={handleRowDoubleClicked} onRowDoubleClicked={handleRowDoubleClicked}
onRowClicked={(e) => e.data && onRowClicked?.(e.data)}
onSelectionChanged={onSelectionChanged} onSelectionChanged={onSelectionChanged}
onCellContextMenu={onCellContextMenu} onCellContextMenu={onCellContextMenu}
preventDefaultOnContextMenu preventDefaultOnContextMenu
+106 -2
View File
@@ -3,7 +3,7 @@ import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2, ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight, ChevronDown, ChevronRight,
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon, User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff,
} from 'lucide-react'; } from 'lucide-react';
import { import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider, GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
@@ -38,6 +38,7 @@ import {
GetPOTAToken, SavePOTAToken, GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations, TestLoTWUpload, ListTQSLStationLocations,
DownloadLoTWUsers, GetLoTWUsersStatus, DownloadLoTWUsers, GetLoTWUsersStatus,
DownloadULSCounties, ULSStatus, BackfillUSCounties,
ComputeStationInfo, ComputeStationInfo,
GetUIPref, SetUIPref, GetUIPref, SetUIPref,
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
@@ -175,6 +176,7 @@ type SectionId =
| 'backup' | 'backup'
| 'database' | 'database'
| 'autostart' | 'autostart'
| 'uscounties'
| 'awards' | 'awards'
| 'cat' | 'cat'
| 'rotator' | 'rotator'
@@ -223,6 +225,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
]}, ]},
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' }, { kind: 'item', label: t('sec.cluster'), id: 'cluster' },
{ kind: 'item', label: t('sec.udp'), id: 'udp' }, { kind: 'item', label: t('sec.udp'), id: 'udp' },
{ kind: 'item', label: t('sec.uscounties'), id: 'uscounties' },
{ kind: 'item', label: t('sec.database'), id: 'database' }, { kind: 'item', label: t('sec.database'), id: 'database' },
{ kind: 'item', label: t('sec.autostart'), id: 'autostart' }, { kind: 'item', label: t('sec.autostart'), id: 'autostart' },
], ],
@@ -238,6 +241,7 @@ const SECTION_KEY: Partial<Record<SectionId, string>> = {
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations', station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
'external-services': 'sec.external', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes', 'external-services': 'sec.external', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp', cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
uscounties: 'sec.uscounties',
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna', awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
antgenius: 'sec.antgenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email', antgenius: 'sec.antgenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email',
}; };
@@ -941,6 +945,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '', from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
}); });
const [emailMsg, setEmailMsg] = useState(''); const [emailMsg, setEmailMsg] = useState('');
const [showSmtpPass, setShowSmtpPass] = useState(false);
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch })); const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
// eQSL card e-mail (subject/body templates + auto-send on log). // eQSL card e-mail (subject/body templates + auto-send on log).
type EQSLCfg = { subject: string; body: string; auto_send: boolean }; type EQSLCfg = { subject: string; body: string; auto_send: boolean };
@@ -1010,6 +1015,34 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); } catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); }
finally { setLotwUsersBusy(false); } finally { setLotwUsersBusy(false); }
}; };
// US Counties (offline FCC ULS) — download progress arrives via events.
const [ulsStatus, setUlsStatus] = useState<{ count: number; updated_at?: string }>({ count: 0 });
const [ulsBusy, setUlsBusy] = useState(false);
const [ulsProgress, setUlsProgress] = useState<{ stage: string; pct: number } | null>(null);
const [ulsMsg, setUlsMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [backfillBusy, setBackfillBusy] = useState(false);
const [backfillMsg, setBackfillMsg] = useState<string | null>(null);
useEffect(() => { ULSStatus().then((s) => setUlsStatus(s as any)).catch(() => {}); }, []);
useEffect(() => {
const off1 = EventsOn('uls:progress', (p: any) => setUlsProgress({ stage: p?.stage ?? '', pct: p?.pct ?? 0 }));
const off2 = EventsOn('uls:done', async (r: any) => {
setUlsBusy(false); setUlsProgress(null);
setUlsMsg(r?.ok ? { ok: true, text: t('uscty.done', { n: r?.count ?? 0 }) } : { ok: false, text: r?.error || 'error' });
try { const s = await ULSStatus(); setUlsStatus(s as any); } catch {}
});
return () => { off1(); off2(); };
}, []);
const downloadUls = async () => {
setUlsBusy(true); setUlsMsg(null); setUlsProgress({ stage: '', pct: 0 });
try { await DownloadULSCounties(); }
catch (e: any) { setUlsBusy(false); setUlsProgress(null); setUlsMsg({ ok: false, text: String(e?.message ?? e) }); }
};
const runBackfill = async () => {
setBackfillBusy(true); setBackfillMsg(null);
try { const r: any = await BackfillUSCounties(); setBackfillMsg(t('uscty.backfillDone', { c: r?.county ?? 0, g: r?.grid ?? 0, s: r?.scanned ?? 0 })); }
catch (e: any) { setBackfillMsg(String(e?.message ?? e)); }
finally { setBackfillBusy(false); }
};
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null); const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [hrdlogTesting, setHrdlogTesting] = useState(false); const [hrdlogTesting, setHrdlogTesting] = useState(false);
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null); const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
@@ -4293,7 +4326,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Label className="text-sm">{t('em.username')}</Label> <Label className="text-sm">{t('em.username')}</Label>
<Input className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_user} onChange={(e) => setEmailField({ smtp_user: e.target.value })} /> <Input className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_user} onChange={(e) => setEmailField({ smtp_user: e.target.value })} />
<Label className="text-sm">{t('es.password')}</Label> <Label className="text-sm">{t('es.password')}</Label>
<Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} /> <div className="relative">
<Input type={showSmtpPass ? 'text' : 'password'} className="h-8 pr-9" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
<button type="button" tabIndex={-1} onClick={() => setShowSmtpPass((v) => !v)}
title={showSmtpPass ? t('es.hidePass') : t('es.showPass')}
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-muted-foreground hover:text-foreground disabled:opacity-40"
disabled={!emailCfg.auth}>
{showSmtpPass ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
<Label className="text-sm">{t('em.fromAddr')}</Label> <Label className="text-sm">{t('em.fromAddr')}</Label>
<Input className="h-8" placeholder="[email protected]" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} /> <Input className="h-8" placeholder="[email protected]" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
<Label className="text-sm">{t('em.replyTo')}</Label> <Label className="text-sm">{t('em.replyTo')}</Label>
@@ -4332,6 +4373,68 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
); );
} }
function USCountiesPanel() {
const loaded = ulsStatus.count > 0;
return (
<div className="max-w-2xl space-y-5">
<div>
<h3 className="text-sm font-semibold mb-1">{t('uscty.title')}</h3>
<p className="text-xs text-muted-foreground leading-relaxed">{t('uscty.intro')}</p>
</div>
{/* Required-download notice */}
<div className="rounded-md border border-warning/40 bg-warning/10 p-3 text-xs text-foreground/90 leading-relaxed">
{t('uscty.needDownload')}
</div>
{/* Status + download */}
<div className="rounded-md border border-border p-3 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xs">
<div className="font-medium">{t('uscty.dbStatus')}</div>
<div className="text-muted-foreground">
{loaded
? t('uscty.loaded', { n: ulsStatus.count.toLocaleString(), date: ulsStatus.updated_at ? new Date(ulsStatus.updated_at).toLocaleDateString() : '—' })
: t('uscty.notLoaded')}
</div>
</div>
<Button size="sm" onClick={downloadUls} disabled={ulsBusy}>
{ulsBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : <ArrowDown className="size-3.5 mr-1.5" />}
{loaded ? t('uscty.update') : t('uscty.download')}
</Button>
</div>
{ulsProgress && (
<div className="space-y-1">
<div className="text-[11px] text-muted-foreground flex justify-between">
<span>{ulsProgress.stage}</span><span>{ulsProgress.pct}%</span>
</div>
<div className="h-1.5 w-full rounded bg-muted overflow-hidden">
<div className="h-full bg-primary transition-all" style={{ width: `${ulsProgress.pct}%` }} />
</div>
</div>
)}
{ulsMsg && (
<div className={cn('text-xs', ulsMsg.ok ? 'text-success' : 'text-destructive')}>{ulsMsg.text}</div>
)}
</div>
{/* Backfill existing QSOs */}
<div className="rounded-md border border-border p-3 space-y-2">
<div className="text-xs font-medium">{t('uscty.backfillTitle')}</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('uscty.backfillIntro')}</p>
<div className="flex items-center gap-3">
<Button size="sm" variant="secondary" onClick={runBackfill} disabled={backfillBusy || !loaded}>
{backfillBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
{t('uscty.backfillRun')}
</Button>
{backfillMsg && <span className="text-xs text-muted-foreground">{backfillMsg}</span>}
</div>
</div>
</div>
);
}
// Map sections to their content + icon (for placeholder). // Map sections to their content + icon (for placeholder).
const PANELS: Record<SectionId, () => JSX.Element> = { const PANELS: Record<SectionId, () => JSX.Element> = {
general: GeneralPanel, general: GeneralPanel,
@@ -4348,6 +4451,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
udp: UDPIntegrationsPanelWrapper, udp: UDPIntegrationsPanelWrapper,
backup: BackupPanel, backup: BackupPanel,
database: DatabasePanel, database: DatabasePanel,
uscounties: USCountiesPanel,
autostart: () => <AutostartPanelComponent />, autostart: () => <AutostartPanelComponent />,
awards: () => <ComingSoon id="awards" icon={Award} />, awards: () => <ComingSoon id="awards" icon={Award} />,
cat: CATPanel, cat: CATPanel,
+187 -11
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square } from 'lucide-react'; import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -11,6 +11,7 @@ import { RotorCompass } from '@/components/RotorCompass';
import { import {
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay, GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
GetRotatorHeading, RotatorGoTo, RotatorStop, GetRotatorHeading, RotatorGoTo, RotatorStop,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null }; type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
@@ -91,6 +92,154 @@ 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[] };
// MotorAntennaWidget controls a motorized antenna (Ultrabeam / SteppIR) from the
// Station Control tab: pattern (Normal / 180° / Bi), Retract, and — Ultrabeam
// only — per-element length adjustment. Heading/state is polled by the panel.
const ELEMENT_STEP = 2; // the physical console adjusts 2 mm per press
// elementName maps an element index to a ham-radio name: 0 = reflector,
// 1 = driven element, then Director 1, 2, 3…
function elementName(i: number, t: (k: string, v?: any) => string): string {
if (i === 0) return t('station.reflector');
if (i === 1) return t('station.driven');
return `${t('station.director')} ${i - 1}`;
}
function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () => void; t: (k: string, v?: any) => string }) {
const [err, setErr] = useState('');
const [lengths, setLengths] = useState<number[]>([]); // current element lengths (mm), from ReadElements
const [reading, setReading] = useState(false);
const [busyEl, setBusyEl] = useState<number | null>(null);
const [editingEl, setEditingEl] = useState<number | null>(null); // element whose mm is being typed
const [editVal, setEditVal] = useState('');
const run = (p: Promise<any>) => p.then(refetch).catch((e) => setErr(String(e?.message ?? e)));
const isUB = ant.type !== 'steppir';
const readLengths = useCallback(async () => {
setReading(true); setErr('');
try { setLengths(((await MotorReadElements()) ?? []) as number[]); }
catch (e: any) { setErr(String(e?.message ?? e)); }
finally { setReading(false); }
}, []);
// Read the current lengths once when the Ultrabeam widget mounts/connects, so
// +/- starts from the real values rather than blind.
useEffect(() => { if (isUB && ant.connected) readLengths(); }, [isUB, ant.connected, readLengths]);
// Send an absolute length to one element and remember it as the new baseline.
const setLen = async (i: number, mm: number) => {
const next = Math.max(0, Math.round(mm));
const prev = lengths[i] ?? 0;
setBusyEl(i); setErr('');
setLengths((ls) => { const c = [...ls]; c[i] = next; return c; }); // optimistic
try { await MotorSetElement(i, next); refetch(); }
catch (e: any) {
// Rejected by the controller — most often the element is at its travel
// limit for this band (extending on a low band). Revert the optimistic
// value so the display stays truthful, and explain the likely cause when
// the failed move was an extension.
setLengths((ls) => { const c = [...ls]; c[i] = prev; return c; });
setErr(next > prev ? t('station.atMax') : String(e?.message ?? e));
}
finally { setBusyEl(null); }
};
// Nudge one element by ±2 mm from its current known length.
const nudge = (i: number, delta: number) => setLen(i, (lengths[i] ?? 0) + delta);
// Commit a typed exact length (click on the mm value). Lets the operator fix
// the baseline when the auto-read is off, so +/- then work reliably.
const commitEdit = (i: number) => {
const v = parseInt(editVal, 10);
setEditingEl(null);
if (!isNaN(v) && v >= 0 && v !== lengths[i]) setLen(i, v);
};
const dirs: [number, string][] = [[0, 'N'], [1, '180°'], [2, t('station.bi')]];
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<AntennaIcon className="size-4 text-primary" />
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{isUB ? 'Ultrabeam' : 'SteppIR'}</div>
{ant.frequency > 0 && <div className="text-[10px] text-muted-foreground font-mono">{(ant.frequency / 1000).toFixed(3)} MHz</div>}
</div>
{ant.moving && <span className="ml-auto text-[10px] font-semibold text-warning animate-pulse">{t('station.moving')}</span>}
<span className={cn('size-2 rounded-full shrink-0', ant.moving ? '' : 'ml-auto', ant.connected ? 'bg-success' : 'bg-muted-foreground/40')}
title={ant.connected ? t('station.online') : t('station.offline')} />
</div>
<div className="p-3 space-y-3">
<div>
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">{t('station.pattern')}</div>
<div className="flex gap-1">
{dirs.map(([d, lbl]) => (
<button key={d} type="button" disabled={!ant.connected}
onClick={() => run(SetUltrabeamDirection(d))}
className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors disabled:opacity-40',
ant.direction === d ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted')}>
{lbl}
</button>
))}
</div>
</div>
<button type="button" disabled={!ant.connected}
onClick={() => run(UltrabeamRetract())}
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1.5 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
</button>
{isUB && (
<div>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{t('station.elements')}</span>
<button type="button" onClick={readLengths} disabled={!ant.connected || reading}
className="text-[10px] text-primary hover:underline inline-flex items-center gap-1 disabled:opacity-40" title={t('station.readLengths')}>
<RefreshCw className={cn('size-3', reading && 'animate-spin')} /> {t('station.read')}
</button>
</div>
{lengths.length === 0 ? (
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
) : (
<div className="space-y-1.5">
{/* Hide 0-length elements — an Ultrabeam reports 6 slots but a
3-element beam only uses the first few. */}
{lengths.map((mm, i) => ({ mm, i })).filter((e) => e.mm > 0).map(({ mm, i }) => (
<div key={i} className="flex items-center gap-2">
<span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
<button type="button" disabled={!ant.connected || busyEl !== null}
onClick={() => nudge(i, -ELEMENT_STEP)}
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
<Minus className="size-3.5" />
</button>
{editingEl === i ? (
<input autoFocus type="number" value={editVal}
onChange={(e) => setEditVal(e.target.value)}
onBlur={() => commitEdit(i)}
onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(i); if (e.key === 'Escape') setEditingEl(null); }}
className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums bg-transparent border border-primary rounded px-1" />
) : (
<span className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums cursor-pointer hover:underline"
title={t('station.setExactLen')}
onClick={() => { setEditingEl(i); setEditVal(String(mm)); }}>
{busyEl === i ? <Loader2 className="size-3.5 animate-spin inline" /> : `${mm} mm`}
</span>
)}
<button type="button" disabled={!ant.connected || busyEl !== null}
onClick={() => nudge(i, ELEMENT_STEP)}
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
<Plus className="size-3.5" />
</button>
</div>
))}
</div>
)}
<p className="text-[10px] text-muted-foreground mt-1">{t('station.elementsHint')}</p>
</div>
)}
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
</div>
</div>
);
}
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) { export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
const { t } = useI18n(); const { t } = useI18n();
const [devices, setDevices] = useState<Device[]>([]); const [devices, setDevices] = useState<Device[]>([]);
@@ -98,11 +247,13 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
const [editing, setEditing] = useState<Device | null>(null); // device being added/edited const [editing, setEditing] = useState<Device | null>(null); // device being added/edited
const [busy, setBusy] = useState<Record<string, boolean>>({}); // per-relay in-flight const [busy, setBusy] = useState<Record<string, boolean>>({}); // per-relay in-flight
const [rot, setRot] = useState<Heading>({ enabled: false, ok: false, azimuth: 0 }); const [rot, setRot] = useState<Heading>({ enabled: false, ok: false, azimuth: 0 });
const [ant, setAnt] = useState<AntStatus>({ enabled: false, type: '', connected: false, direction: 0, frequency: 0, moving: false, elements: [] });
// Widget order (rotator + device ids), drag-and-drop reorderable, persisted. // Widget order (rotator + device ids), drag-and-drop reorderable, persisted.
const [order, setOrder] = useState<string[]>(() => { const [order, setOrder] = useState<string[]>(() => {
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
}); });
const dragId = useRef<string | null>(null); const dragId = useRef<string | null>(null);
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
const loadDevices = useCallback(async () => { const loadDevices = useCallback(async () => {
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ } try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
@@ -117,13 +268,16 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
const pollRot = useCallback(async () => { const pollRot = useCallback(async () => {
try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ } try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ }
}, []); }, []);
const pollAnt = useCallback(async () => {
try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ }
}, []);
useEffect(() => { loadDevices(); }, [loadDevices]); useEffect(() => { loadDevices(); }, [loadDevices]);
useEffect(() => { useEffect(() => {
poll(); pollRot(); poll(); pollRot(); pollAnt();
const id = window.setInterval(() => { poll(); pollRot(); }, 3000); const id = window.setInterval(() => { poll(); pollRot(); pollAnt(); }, 3000);
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, [poll, pollRot, devices.length]); }, [poll, pollRot, pollAnt, devices.length]);
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); }; const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
// Reorder so `dragged` lands just before `target`. // Reorder so `dragged` lands just before `target`.
@@ -217,21 +371,43 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
if (rot.enabled) { if (rot.enabled) {
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> }); widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
} }
if (ant.enabled) {
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
}
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) }); for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; }; const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i)); const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
const widgetIds = ordered.map((w) => w.id); const widgetIds = ordered.map((w) => w.id);
const noDevices = devices.length === 0 && !rot.enabled; const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled;
// Column count controls the layout: with 4 widgets, choosing "2" lays them out
// 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to
// fill however many columns are available.
const gridCols: Record<string, string> = {
auto: 'sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4',
'1': 'grid-cols-1', '2': 'grid-cols-2', '3': 'grid-cols-3', '4': 'grid-cols-4',
};
return ( return (
<div className="flex-1 min-h-0 overflow-auto p-4"> <div className="flex-1 min-h-0 overflow-auto p-4">
<div className="flex items-center justify-between mb-3 max-w-4xl"> <div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2> <h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}> <div className="flex items-center gap-2">
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')} <div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
</Button> {(['auto', '2', '3', '4'] as const).map((c) => (
<button key={c} type="button"
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
{c === 'auto' ? t('station.colsAuto') : c}
</button>
))}
</div>
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
</Button>
</div>
</div> </div>
{noDevices && !editing && ( {noDevices && !editing && (
@@ -240,7 +416,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
</div> </div>
)} )}
<div className="grid gap-3 max-w-4xl md:grid-cols-2 items-start"> <div className={cn('grid gap-3 items-start', gridCols[cols] ?? gridCols.auto)}>
{ordered.map((w) => ( {ordered.map((w) => (
<div key={w.id} draggable <div key={w.id} draggable
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }} onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
@@ -253,7 +429,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
</div> </div>
{!noDevices && ( {!noDevices && (
<p className="text-[11px] text-muted-foreground mt-2 max-w-4xl">{t('station.dragHint')}</p> <p className="text-[11px] text-muted-foreground mt-2">{t('station.dragHint')}</p>
)} )}
{editing && ( {editing && (
File diff suppressed because one or more lines are too long
+22
View File
@@ -14,3 +14,25 @@ export function sMeterRST(s: number, overDb: number, mode?: string): string {
if (overDb > 0) return `59+${Math.ceil(overDb / 5) * 5}`; if (overDb > 0) return `59+${Math.ceil(overDb / 5) * 5}`;
return `5${strength}`; return `5${strength}`;
} }
// RST dropdown lists, shared by the entry form and the QSO editor so both offer
// the same per-mode choices (Settings → Modes → RST report lists).
export type RSTLists = { phone: string[]; cw: string[]; digital: string[] };
// rstCategory maps an ADIF mode to its RST family (phone / cw / digital).
export function rstCategory(mode: string): keyof RSTLists {
const m = (mode || '').toUpperCase();
const digital = ['FT8', 'FT4', 'JT65', 'JT9', 'JS8', 'Q65', 'MSK144', 'FST4', 'FST4W', 'MFSK', 'OLIVIA', 'JT4', 'WSPR'];
if (digital.includes(m)) return 'digital';
if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw';
return 'phone';
}
// rstOptions returns the valid report choices for a mode from the user's
// editable lists, with a tiny fallback before they load.
export function rstOptions(mode: string, lists: RSTLists): string[] {
const cat = rstCategory(mode);
const l = lists[cat];
if (l && l.length) return l;
return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20'];
}
+12
View File
@@ -64,6 +64,8 @@ export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Reco
export function AwardsFolder():Promise<string>; export function AwardsFolder():Promise<string>;
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
export function BrowseExecutable():Promise<string>; export function BrowseExecutable():Promise<string>;
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>; export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
@@ -150,6 +152,8 @@ export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Prom
export function DownloadLoTWUsers():Promise<number>; export function DownloadLoTWUsers():Promise<number>;
export function DownloadULSCounties():Promise<void>;
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>; export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>; export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
@@ -382,6 +386,8 @@ export function GetRotatorSettings():Promise<main.RotatorSettings>;
export function GetSecretStatus():Promise<main.SecretStatus>; export function GetSecretStatus():Promise<main.SecretStatus>;
export function GetSlotStats():Promise<qso.SlotStats>;
export function GetSolarData():Promise<solar.Data>; export function GetSolarData():Promise<solar.Data>;
export function GetStartupStatus():Promise<main.StartupStatus>; export function GetStartupStatus():Promise<main.StartupStatus>;
@@ -538,6 +544,10 @@ export function LogUDPLoggedADIF(arg1:string):Promise<number>;
export function LookupCallsign(arg1:string):Promise<lookup.Result>; export function LookupCallsign(arg1:string):Promise<lookup.Result>;
export function MotorReadElements():Promise<Array<number>>;
export function MotorSetElement(arg1:number,arg2:number):Promise<void>;
export function MoveDatabase(arg1:string):Promise<void>; export function MoveDatabase(arg1:string):Promise<void>;
export function NetActivate(arg1:string):Promise<qso.QSO>; export function NetActivate(arg1:string):Promise<qso.QSO>;
@@ -802,6 +812,8 @@ export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>; export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
export function ULSStatus():Promise<main.ULSStatusResult>;
export function UltrabeamRetract():Promise<void>; export function UltrabeamRetract():Promise<void>;
export function UnlockSecrets(arg1:string):Promise<void>; export function UnlockSecrets(arg1:string):Promise<void>;
+24
View File
@@ -86,6 +86,10 @@ export function AwardsFolder() {
return window['go']['main']['App']['AwardsFolder'](); return window['go']['main']['App']['AwardsFolder']();
} }
export function BackfillUSCounties() {
return window['go']['main']['App']['BackfillUSCounties']();
}
export function BrowseExecutable() { export function BrowseExecutable() {
return window['go']['main']['App']['BrowseExecutable'](); return window['go']['main']['App']['BrowseExecutable']();
} }
@@ -258,6 +262,10 @@ export function DownloadLoTWUsers() {
return window['go']['main']['App']['DownloadLoTWUsers'](); return window['go']['main']['App']['DownloadLoTWUsers']();
} }
export function DownloadULSCounties() {
return window['go']['main']['App']['DownloadULSCounties']();
}
export function DuplicateProfile(arg1, arg2) { export function DuplicateProfile(arg1, arg2) {
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2); return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
} }
@@ -722,6 +730,10 @@ export function GetSecretStatus() {
return window['go']['main']['App']['GetSecretStatus'](); return window['go']['main']['App']['GetSecretStatus']();
} }
export function GetSlotStats() {
return window['go']['main']['App']['GetSlotStats']();
}
export function GetSolarData() { export function GetSolarData() {
return window['go']['main']['App']['GetSolarData'](); return window['go']['main']['App']['GetSolarData']();
} }
@@ -1034,6 +1046,14 @@ export function LookupCallsign(arg1) {
return window['go']['main']['App']['LookupCallsign'](arg1); return window['go']['main']['App']['LookupCallsign'](arg1);
} }
export function MotorReadElements() {
return window['go']['main']['App']['MotorReadElements']();
}
export function MotorSetElement(arg1, arg2) {
return window['go']['main']['App']['MotorSetElement'](arg1, arg2);
}
export function MoveDatabase(arg1) { export function MoveDatabase(arg1) {
return window['go']['main']['App']['MoveDatabase'](arg1); return window['go']['main']['App']['MoveDatabase'](arg1);
} }
@@ -1562,6 +1582,10 @@ export function TestUltrabeam(arg1) {
return window['go']['main']['App']['TestUltrabeam'](arg1); return window['go']['main']['App']['TestUltrabeam'](arg1);
} }
export function ULSStatus() {
return window['go']['main']['App']['ULSStatus']();
}
export function UltrabeamRetract() { export function UltrabeamRetract() {
return window['go']['main']['App']['UltrabeamRetract'](); return window['go']['main']['App']['UltrabeamRetract']();
} }
+70
View File
@@ -1575,6 +1575,22 @@ export namespace main {
this.to = source["to"]; this.to = source["to"];
} }
} }
export class BackfillUSCountiesResult {
scanned: number;
county: number;
grid: number;
static createFrom(source: any = {}) {
return new BackfillUSCountiesResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.scanned = source["scanned"];
this.county = source["county"];
this.grid = source["grid"];
}
}
export class BackupSettings { export class BackupSettings {
enabled: boolean; enabled: boolean;
folder: string; folder: string;
@@ -2371,6 +2387,7 @@ export namespace main {
call: string; call: string;
band: string; band: string;
mode: string; mode: string;
pota_ref?: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new SpotQuery(source); return new SpotQuery(source);
@@ -2381,6 +2398,7 @@ export namespace main {
this.call = source["call"]; this.call = source["call"];
this.band = source["band"]; this.band = source["band"];
this.mode = source["mode"]; this.mode = source["mode"];
this.pota_ref = source["pota_ref"];
} }
} }
export class SpotStatus { export class SpotStatus {
@@ -2391,6 +2409,8 @@ export namespace main {
continent?: string; continent?: string;
status: string; status: string;
worked_call: boolean; worked_call: boolean;
new_county: boolean;
new_pota: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new SpotStatus(source); return new SpotStatus(source);
@@ -2405,6 +2425,8 @@ export namespace main {
this.continent = source["continent"]; this.continent = source["continent"];
this.status = source["status"]; this.status = source["status"];
this.worked_call = source["worked_call"]; this.worked_call = source["worked_call"];
this.new_county = source["new_county"];
this.new_pota = source["new_pota"];
} }
} }
export class StartupStatus { export class StartupStatus {
@@ -2548,6 +2570,20 @@ export namespace main {
this.my_pota_ref = source["my_pota_ref"]; this.my_pota_ref = source["my_pota_ref"];
} }
} }
export class ULSStatusResult {
count: number;
updated_at: string;
static createFrom(source: any = {}) {
return new ULSStatusResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.count = source["count"];
this.updated_at = source["updated_at"];
}
}
export class UltrabeamSettings { export class UltrabeamSettings {
enabled: boolean; enabled: boolean;
type: string; type: string;
@@ -2580,11 +2616,13 @@ export namespace main {
} }
export class UltrabeamStatusInfo { export class UltrabeamStatusInfo {
enabled: boolean; enabled: boolean;
type: string;
connected: boolean; connected: boolean;
direction: number; direction: number;
frequency: number; frequency: number;
band: number; band: number;
moving: boolean; moving: boolean;
elements: number[];
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new UltrabeamStatusInfo(source); return new UltrabeamStatusInfo(source);
@@ -2593,11 +2631,13 @@ export namespace main {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"]; this.enabled = source["enabled"];
this.type = source["type"];
this.connected = source["connected"]; this.connected = source["connected"];
this.direction = source["direction"]; this.direction = source["direction"];
this.frequency = source["frequency"]; this.frequency = source["frequency"];
this.band = source["band"]; this.band = source["band"];
this.moving = source["moving"]; this.moving = source["moving"];
this.elements = source["elements"];
} }
} }
export class UpdateInfo { export class UpdateInfo {
@@ -3655,6 +3695,36 @@ export namespace qso {
return a; return a;
} }
} }
export class SlotStats {
slots_worked: number;
slots_confirmed: number;
dxcc_worked: number;
dxcc_confirmed: number;
ph_worked: number;
ph_confirmed: number;
cw_worked: number;
cw_confirmed: number;
dig_worked: number;
dig_confirmed: number;
static createFrom(source: any = {}) {
return new SlotStats(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.slots_worked = source["slots_worked"];
this.slots_confirmed = source["slots_confirmed"];
this.dxcc_worked = source["dxcc_worked"];
this.dxcc_confirmed = source["dxcc_confirmed"];
this.ph_worked = source["ph_worked"];
this.ph_confirmed = source["ph_confirmed"];
this.cw_worked = source["cw_worked"];
this.cw_confirmed = source["cw_confirmed"];
this.dig_worked = source["dig_worked"];
this.dig_confirmed = source["dig_confirmed"];
}
}
export class Stats { export class Stats {
total: number; total: number;
unique_calls: number; unique_calls: number;
+60 -1
View File
@@ -318,7 +318,7 @@ func Migrate(defs []Def) ([]Def, bool) {
func Fields() []string { func Fields() []string {
return []string{ return []string{
"dxcc", "cqz", "ituz", "prefix", "callsign", "dxcc", "cqz", "ituz", "prefix", "callsign",
"state", "cont", "country", "grid", "grid4", "state", "us_county", "cont", "country", "grid", "grid4",
"iota", "sota_ref", "pota_ref", "wwff", "iota", "sota_ref", "pota_ref", "wwff",
"name", "qth", "address", "comment", "note", "name", "qth", "address", "comment", "note",
} }
@@ -643,6 +643,63 @@ func InScope(d Def, q *qso.QSO) bool { return inScope(&d, q) }
// EmissionOf maps an ADIF mode to its broad category (CW|PHONE|DIGITAL). // EmissionOf maps an ADIF mode to its broad category (CW|PHONE|DIGITAL).
func EmissionOf(mode string) string { return emissionOf(mode) } func EmissionOf(mode string) string { return emissionOf(mode) }
// USCountyKey normalises a QSO's state + cnty into the canonical "STATE,COUNTY"
// match code used by the US Counties (USA-CA) award, so the reference list (in
// that same form) matches whatever shape the logbook holds. It is the SINGLE
// source of truth: cmd/cntygen builds the reference codes by calling it, so the
// two sides can never drift.
//
// Real logs are a mess — "MA,MIDDLESEX", bare "Middlesex" with the state in its
// own column, mixed case, county-type suffixes, the odd "0", plus the FIPS list
// abbreviating "Saint"→"St." and hyphenating "Matanuska-Susitna". The rules:
// - if cnty already carries "ST,County", split on the first comma; else take
// the state from the STATE column;
// - upper-case; drop periods and apostrophes; hyphens→space; strip a trailing
// County/Parish/Borough/Census Area/Municipality; fold Saint(e)→St(e);
// collapse whitespace;
// - require a 2-letter state and a non-empty county, else no match ("").
func USCountyKey(state, cnty string) string {
s := strings.TrimSpace(cnty)
if s == "" || s == "0" {
return ""
}
var st, co string
if i := strings.IndexByte(s, ','); i >= 0 {
st, co = strings.TrimSpace(s[:i]), strings.TrimSpace(s[i+1:])
} else {
st, co = strings.TrimSpace(state), s
}
co = strings.ToUpper(co)
co = strings.ReplaceAll(co, ".", "")
co = strings.ReplaceAll(co, "'", "")
co = strings.ReplaceAll(co, "-", " ")
for _, suf := range []string{" COUNTY", " PARISH", " BOROUGH", " CENSUS AREA", " MUNICIPALITY"} {
if strings.HasSuffix(co, suf) {
co = strings.TrimSuffix(co, suf)
break
}
}
switch {
case strings.HasPrefix(co, "SAINTE "):
co = "STE " + co[len("SAINTE "):]
case strings.HasPrefix(co, "SAINT "):
co = "ST " + co[len("SAINT "):]
}
// Drop ALL internal spaces last: FIPS writes DeKalb/DuPage/LaSalle as one
// word, logs often split them ("De Kalb"). Removing spaces on both sides
// folds those together and can't collide two real counties in one state.
co = strings.ReplaceAll(co, " ", "")
st = strings.ToUpper(st)
if len(st) != 2 || co == "" {
return ""
}
// Separator is "/", NOT ",": the QSOFIELDS matcher splits a field value on
// commas/semicolons (n-fer POTA "US-1,US-2"), which would shatter "AL,AUTAUGA"
// into two non-matching tokens. The stored ADIF cnty keeps its comma; only
// this internal match key uses "/".
return st + "/" + co
}
// labelRef fills a worked reference's name/group from the reference list (or the // labelRef fills a worked reference's name/group from the reference list (or the
// name resolver as a fallback). // name resolver as a fallback).
func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) { func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) {
@@ -1145,6 +1202,8 @@ func fieldRaw(field string, q *qso.QSO) string {
return q.Callsign return q.Callsign
case "state": case "state":
return q.State return q.State
case "us_county":
return USCountyKey(q.State, q.County)
case "cont": case "cont":
return q.Continent return q.Continent
case "country": case "country":
+30
View File
@@ -0,0 +1,30 @@
{
"def": {
"code": "USA-CA",
"name": "USA-CA (US Counties)",
"description": "CQ United States of America Counties Award. Matches the QSO's county, tolerating LoTW \"ST,County\" and bare county-name shapes. Independent cities of Virginia/Nevada and DC are excluded per the award rules.",
"valid": true,
"protected": true,
"type": "QSOFIELDS",
"field": "us_county",
"match_by": "code",
"exact_match": true,
"pattern": "",
"ref_display": "name",
"url": "https://cq-amateur-radio.com/cq_awards/cq_usa_ca_awards/cq_usa_ca_awards.html",
"dxcc_filter": [
291,
110,
6
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw"
],
"total": 3077,
"builtin": true
}
}
+27
View File
@@ -0,0 +1,27 @@
package award
import "testing"
func TestUSCountyKey(t *testing.T) {
cases := []struct {
state, cnty, want string
}{
{"MA", "MA,MIDDLESEX", "MA/MIDDLESEX"}, // LoTW "ST,County" shape
{"NJ", "Middlesex", "NJ/MIDDLESEX"}, // bare name + state column
{"TX", "Montgomery", "TX/MONTGOMERY"}, // title case
{"FL", "Saint Lucie", "FL/STLUCIE"}, // Saint→St, space dropped
{"FL", "St. Lucie", "FL/STLUCIE"}, // FIPS abbreviation, period dropped
{"AK", "Matanuska-Susitna", "AK/MATANUSKASUSITNA"}, // hyphen→space→dropped
{"IL", "De Kalb", "IL/DEKALB"}, // split name
{"IL", "DeKalb County", "IL/DEKALB"}, // suffix + one word
{"LA", "Acadia Parish", "LA/ACADIA"}, // parish suffix
{"", "Honolulu", ""}, // no state → no match
{"HI", "0", ""}, // garbage
{"HI", "", ""}, // empty
}
for _, c := range cases {
if got := USCountyKey(c.state, c.cnty); got != c.want {
t.Errorf("USCountyKey(%q,%q) = %q, want %q", c.state, c.cnty, got, c.want)
}
}
}
+3
View File
@@ -17,6 +17,7 @@ import (
// - WAC → continent code ("EU", "NA", …) // - WAC → continent code ("EU", "NA", …)
// - WAS → ADIF STATE code ("AL", …) // - WAS → ADIF STATE code ("AL", …)
// - DDFM → "D06" (the award pattern captures the leading D) // - DDFM → "D06" (the award pattern captures the leading D)
// - USA-CA → canonical "STATE/COUNTY" key (see award.USCountyKey)
func BuiltinRefs(code string) ([]Ref, bool) { func BuiltinRefs(code string) ([]Ref, bool) {
switch code { switch code {
case "DXCC": case "DXCC":
@@ -29,6 +30,8 @@ func BuiltinRefs(code string) ([]Ref, bool) {
return usStates().Refs, true return usStates().Refs, true
case "DDFM": case "DDFM":
return frenchDepartments(), true return frenchDepartments(), true
case "USA-CA":
return usCounties(), true
} }
return nil, false return nil, false
} }
File diff suppressed because it is too large Load Diff
+141 -6
View File
@@ -1605,7 +1605,7 @@ func (r *Repo) IterateAll(ctx context.Context, fn func(QSO) error) error {
// column to this list AND populate it in scanAwardQSO below, or that award will // column to this list AND populate it in scanAwardQSO below, or that award will
// silently see an empty value during stats/computation. // silently see an empty value during stats/computation.
const awardCols = `id, callsign, qso_date, band, freq_hz, mode, ` + const awardCols = `id, callsign, qso_date, band, freq_hz, mode, ` +
`grid, vucc_grids, country, state, cont, cqz, ituz, dxcc, iota, sota_ref, pota_ref, ` + `grid, vucc_grids, country, state, cnty, cont, cqz, ituz, dxcc, iota, sota_ref, pota_ref, ` +
`name, qth, address, comment, notes, ` + `name, qth, address, comment, notes, ` +
`qsl_rcvd, lotw_rcvd, eqsl_rcvd, extras_json` `qsl_rcvd, lotw_rcvd, eqsl_rcvd, extras_json`
@@ -1640,6 +1640,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
qsoDateStr string qsoDateStr string
freqHz sql.NullInt64 freqHz sql.NullInt64
grid, vucc, country, state sql.NullString grid, vucc, country, state sql.NullString
cnty sql.NullString
cont, iotaRef, sota, pota sql.NullString cont, iotaRef, sota, pota sql.NullString
dxcc, cqz, ituz sql.NullInt64 dxcc, cqz, ituz sql.NullInt64
name, qth, address sql.NullString name, qth, address sql.NullString
@@ -1649,7 +1650,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
) )
if err := s.Scan( if err := s.Scan(
&q.ID, &q.Callsign, &qsoDateStr, &q.Band, &freqHz, &q.Mode, &q.ID, &q.Callsign, &qsoDateStr, &q.Band, &freqHz, &q.Mode,
&grid, &vucc, &country, &state, &cont, &cqz, &ituz, &dxcc, &iotaRef, &sota, &pota, &grid, &vucc, &country, &state, &cnty, &cont, &cqz, &ituz, &dxcc, &iotaRef, &sota, &pota,
&name, &qth, &address, &comment, &notes, &name, &qth, &address, &comment, &notes,
&qslRcvd, &lotwRcvd, &eqslRcvd, &extrasJSON, &qslRcvd, &lotwRcvd, &eqslRcvd, &extrasJSON,
); err != nil { ); err != nil {
@@ -1664,6 +1665,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
q.VUCCGrids = vucc.String q.VUCCGrids = vucc.String
q.Country = country.String q.Country = country.String
q.State = state.String q.State = state.String
q.County = cnty.String
q.Continent = cont.String q.Continent = cont.String
if cqz.Valid { if cqz.Valid {
v := int(cqz.Int64) v := int(cqz.Int64)
@@ -1782,6 +1784,56 @@ func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error)
return out, rows.Err() return out, rows.Err()
} }
// WorkedCountyKeys returns the set of counties already worked, keyed by the
// caller-supplied normaliser (award.USCountyKey — passed in to avoid importing
// the award package here). Only US-entity QSOs (DXCC 291/110/6) with a county
// are considered. Empty keys (unresolvable state/county) are skipped.
func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty string) string) (map[string]struct{}, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT COALESCE(state,''), COALESCE(cnty,'') FROM qso
WHERE dxcc IN (291,110,6) AND cnty IS NOT NULL AND cnty != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]struct{}, 1024)
for rows.Next() {
var state, cnty string
if err := rows.Scan(&state, &cnty); err != nil {
return nil, err
}
if k := keyFn(state, cnty); k != "" {
out[k] = struct{}{}
}
}
return out, rows.Err()
}
// WorkedPOTARefs returns the set of POTA park references already worked
// (upper-cased). A QSO's pota_ref may hold several comma-separated parks
// (an n-fer); each is added separately.
func (r *Repo) WorkedPOTARefs(ctx context.Context) (map[string]struct{}, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT pota_ref FROM qso WHERE pota_ref IS NOT NULL AND pota_ref != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]struct{}, 256)
for rows.Next() {
var ref string
if err := rows.Scan(&ref); err != nil {
return nil, err
}
for _, p := range strings.Split(ref, ",") {
if p = strings.ToUpper(strings.TrimSpace(p)); p != "" {
out[p] = struct{}{}
}
}
}
return out, rows.Err()
}
// Count returns the total number of QSOs in the database. // Count returns the total number of QSOs in the database.
func (r *Repo) Count(ctx context.Context) (int64, error) { func (r *Repo) Count(ctx context.Context) (int64, error) {
var n int64 var n int64
@@ -1958,18 +2010,100 @@ func closestRef(refs []matchRef, when time.Time, window time.Duration) (int64, b
// ConfirmedSets captures which DXCC / band / slot combinations are already // ConfirmedSets captures which DXCC / band / slot combinations are already
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be // confirmed (by any QSL system), so a freshly-downloaded confirmation can be
// flagged as a NEW DXCC / NEW BAND / NEW SLOT. // flagged as a NEW DXCC / NEW BAND / NEW SLOT.
// ConfirmedSets captures confirmed combinations for the QSL Manager's NEW flags.
// Modes are grouped into CLASSES (Phone/CW/Digital) — a digital confirmation is a
// "new mode/slot" only if no digital mode was confirmed there before (RTTY and FT8
// are the same DIGI class). Raw-mode granularity lives only in the cluster/matrix.
type ConfirmedSets struct { type ConfirmedSets struct {
DXCC map[int]bool // dxcc entity confirmed DXCC map[int]bool // dxcc entity confirmed
Band map[string]bool // "dxcc|band" Band map[string]bool // "dxcc|band"
Slot map[string]bool // "dxcc|band|mode" Mode map[string]bool // "dxcc|class"
Slot map[string]bool // "dxcc|band|class"
} }
// SlotKey / BandKey build the composite keys used in ConfirmedSets. // Key builders for ConfirmedSets. Band is mode-agnostic; Mode/Slot use the class.
func BandKey(dxcc int, band string) string { return fmt.Sprintf("%d|%s", dxcc, strings.ToLower(band)) } func BandKey(dxcc int, band string) string { return fmt.Sprintf("%d|%s", dxcc, strings.ToLower(band)) }
func ModeClassKey(dxcc int, mode string) string {
return fmt.Sprintf("%d|%s", dxcc, modeClass(mode))
}
func SlotClassKey(dxcc int, band, mode string) string {
return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), modeClass(mode))
}
// SlotKey is the RAW-mode slot key, kept for the cluster/matrix new-slot flag.
func SlotKey(dxcc int, band, mode string) string { func SlotKey(dxcc int, band, mode string) string {
return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), strings.ToUpper(mode)) return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), strings.ToUpper(mode))
} }
// SlotStats is the worked/confirmed tally for the QSL Manager, counting slots by
// mode CLASS (Phone / CW / Digital) — NOT raw mode. Raw-mode granularity (RTTY ≠
// FT8) is kept only for the cluster/matrix "new slot" flag; the totals here match
// how slots are conventionally counted (a band in a class, not in each digital
// sub-mode).
type SlotStats struct {
SlotsWorked int `json:"slots_worked"` // distinct DXCC × band × class, all QSOs
SlotsConfirmed int `json:"slots_confirmed"` // distinct DXCC × band × class, confirmed
DXCCWorked int `json:"dxcc_worked"` // distinct DXCC entities worked
DXCCConfirmed int `json:"dxcc_confirmed"` // distinct DXCC entities confirmed
// Per-class slot breakdown (Phone / CW / Digital) so the numbers are checkable.
PHWorked int `json:"ph_worked"`
PHConfirmed int `json:"ph_confirmed"`
CWWorked int `json:"cw_worked"`
CWConfirmed int `json:"cw_confirmed"`
DIGWorked int `json:"dig_worked"`
DIGConfirmed int `json:"dig_confirmed"`
}
// GetSlotStats computes the worked/confirmed slot and DXCC tallies in one pass.
// "Confirmed" = LoTW or paper QSL received (the award-valid sources).
func (r *Repo) GetSlotStats(ctx context.Context) (SlotStats, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT COALESCE(dxcc,0), LOWER(COALESCE(band,'')), UPPER(COALESCE(mode,'')),
CASE WHEN lotw_rcvd='Y' OR qsl_rcvd='Y' THEN 1 ELSE 0 END
FROM qso`)
if err != nil {
return SlotStats{}, err
}
defer rows.Close()
dxccW, slotW := map[int]bool{}, map[string]bool{}
dxccC, slotC := map[int]bool{}, map[string]bool{}
// Per-class distinct slots (worked "w" / confirmed "c").
clsW := map[string]map[string]bool{"PH": {}, "CW": {}, "DIG": {}}
clsC := map[string]map[string]bool{"PH": {}, "CW": {}, "DIG": {}}
for rows.Next() {
var dxcc, conf int
var band, mode string
if err := rows.Scan(&dxcc, &band, &mode, &conf); err != nil {
return SlotStats{}, err
}
if dxcc == 0 {
continue
}
dxccW[dxcc] = true
if conf == 1 {
dxccC[dxcc] = true
}
if band == "" {
continue // no band → counts for DXCC but not for a slot
}
key := SlotClassKey(dxcc, band, mode)
cl := modeClass(mode)
slotW[key] = true
clsW[cl][key] = true
if conf == 1 {
slotC[key] = true
clsC[cl][key] = true
}
}
return SlotStats{
SlotsWorked: len(slotW), SlotsConfirmed: len(slotC),
DXCCWorked: len(dxccW), DXCCConfirmed: len(dxccC),
PHWorked: len(clsW["PH"]), PHConfirmed: len(clsC["PH"]),
CWWorked: len(clsW["CW"]), CWConfirmed: len(clsC["CW"]),
DIGWorked: len(clsW["DIG"]), DIGConfirmed: len(clsC["DIG"]),
}, rows.Err()
}
// confirmedCols whitelists the received-status columns ConfirmedSlots may // confirmedCols whitelists the received-status columns ConfirmedSlots may
// OR together (guards the dynamic SQL). // OR together (guards the dynamic SQL).
var confirmedCols = map[string]bool{ var confirmedCols = map[string]bool{
@@ -1985,7 +2119,7 @@ var confirmedCols = map[string]bool{
// {lotw_rcvd, qsl_rcvd} (the award-valid sources), QRZ uses // {lotw_rcvd, qsl_rcvd} (the award-valid sources), QRZ uses
// {qrzcom_qso_download_status}. // {qrzcom_qso_download_status}.
func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets, error) { func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets, error) {
sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Slot: map[string]bool{}} sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Mode: map[string]bool{}, Slot: map[string]bool{}}
var conds []string var conds []string
for _, c := range cols { for _, c := range cols {
if confirmedCols[c] { if confirmedCols[c] {
@@ -2014,7 +2148,8 @@ func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets
} }
sets.DXCC[dxcc] = true sets.DXCC[dxcc] = true
sets.Band[BandKey(dxcc, band)] = true sets.Band[BandKey(dxcc, band)] = true
sets.Slot[SlotKey(dxcc, band, mode)] = true sets.Mode[ModeClassKey(dxcc, mode)] = true
sets.Slot[SlotClassKey(dxcc, band, mode)] = true
} }
return sets, rows.Err() return sets, rows.Err()
} }
+403
View File
@@ -0,0 +1,403 @@
// Package uls resolves a US amateur callsign to its county and grid, offline,
// from the FCC ULS licence database cross-referenced with a ZIP→county/lat-lon
// table. It backs the US Counties (USA-CA) award and county hunting: an FCC
// spot or a bare CW/SSB spot carries only a callsign, and this turns that into
// a county with no per-lookup API call.
//
// Data lives in its OWN local SQLite file (data/uls.db), never in the logbook:
// it is ~800k rows of static reference data that would only bloat the log (and
// crawl over a remote MySQL link). It is downloaded on demand, not shipped.
//
// Sources, both public and free:
// - FCC ULS Amateur, full database: l_amat.zip → EN.dat (pipe-delimited).
// Fields used: [4] call_sign, [17] state, [18] zip_code.
// - GeoNames US postal codes: US.zip → US.txt (tab-delimited).
// Fields used: [1] zip, [4] state, [5] county, [9] lat, [10] lon.
//
// The county from a ZIP is the ZIP's primary county — a ZIP can straddle a line,
// so this is ~98% right for fixed stations (rovers/portables need the from-air
// grid, which arrives separately via heard_geo). Good enough to hunt with.
package uls
import (
"archive/zip"
"bufio"
"context"
"database/sql"
"fmt"
"io"
"math"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
// Default download URLs (overridable in Import for tests).
const (
fccAmateurURL = "https://data.fcc.gov/download/pub/uls/complete/l_amat.zip"
geoNamesURL = "https://download.geonames.org/export/zip/US.zip"
)
// Location is a resolved callsign's home county + grid.
type Location struct {
State string `json:"state"`
County string `json:"county"` // GeoNames county name (e.g. "Middlesex")
Grid string `json:"grid"` // 6-char Maidenhead from the ZIP centroid
}
// CNTY renders the ADIF "STATE,County" form for stamping a QSO's cnty field.
func (l Location) CNTY() string {
if l.State == "" || l.County == "" {
return ""
}
return l.State + "," + l.County
}
// Store owns the local uls.db connection.
type Store struct {
db *sql.DB
mu sync.RWMutex // guards the whole DB during a re-import (DELETE+bulk INSERT)
}
// Open opens (creating if needed) the ULS SQLite store at path.
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
if err != nil {
return nil, err
}
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS uls_callsign (
callsign TEXT PRIMARY KEY,
state TEXT NOT NULL DEFAULT '',
county TEXT NOT NULL DEFAULT '',
grid TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS uls_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);`); err != nil {
db.Close()
return nil, err
}
return &Store{db: db}, nil
}
func (s *Store) Close() error { return s.db.Close() }
// Count returns how many callsigns are loaded.
func (s *Store) Count() int {
s.mu.RLock()
defer s.mu.RUnlock()
var n int
s.db.QueryRow(`SELECT COUNT(*) FROM uls_callsign`).Scan(&n)
return n
}
// UpdatedAt returns when the store was last imported (zero if never).
func (s *Store) UpdatedAt() time.Time {
s.mu.RLock()
defer s.mu.RUnlock()
var v string
if err := s.db.QueryRow(`SELECT value FROM uls_meta WHERE key='updated_at'`).Scan(&v); err != nil {
return time.Time{}
}
t, _ := time.Parse(time.RFC3339, v)
return t
}
// Resolve looks up a callsign's home county + grid. ok=false if unknown or the
// store is empty.
func (s *Store) Resolve(callsign string) (Location, bool) {
call := strings.ToUpper(strings.TrimSpace(callsign))
if call == "" {
return Location{}, false
}
s.mu.RLock()
defer s.mu.RUnlock()
var l Location
err := s.db.QueryRow(`SELECT state, county, grid FROM uls_callsign WHERE callsign=?`, call).
Scan(&l.State, &l.County, &l.Grid)
if err != nil || l.County == "" {
return Location{}, false
}
return l, true
}
// Progress reports import stages to the caller (0..100 within a stage).
type Progress func(stage string, pct int)
// zipRow is one ZIP's primary county + centroid.
type zipRow struct {
state, county string
lat, lon float64
}
// Import downloads the FCC ULS + GeoNames data, rebuilds the callsign→county
// table, and records the timestamp. It replaces the table atomically: on any
// error the previous contents are left intact. tmpDir is where the (large) zips
// are streamed; "" uses the OS temp dir.
func (s *Store) Import(ctx context.Context, tmpDir string, prog Progress) error {
if prog == nil {
prog = func(string, int) {}
}
if tmpDir == "" {
tmpDir = os.TempDir()
}
// 1) ZIP→county/lat-lon crosswalk (small).
prog("Downloading ZIP crosswalk", 0)
geoPath := filepath.Join(tmpDir, "opslog_geonames_us.zip")
if err := download(ctx, geoNamesURL, geoPath, nil); err != nil {
return fmt.Errorf("download GeoNames: %w", err)
}
defer os.Remove(geoPath)
prog("Parsing ZIP crosswalk", 50)
zipmap, err := parseGeoNames(geoPath)
if err != nil {
return fmt.Errorf("parse GeoNames: %w", err)
}
if len(zipmap) == 0 {
return fmt.Errorf("GeoNames crosswalk is empty")
}
// 2) FCC ULS full amateur database (large).
prog("Downloading FCC ULS database", 0)
amatPath := filepath.Join(tmpDir, "opslog_l_amat.zip")
if err := download(ctx, fccAmateurURL, amatPath, func(pct int) { prog("Downloading FCC ULS database", pct) }); err != nil {
return fmt.Errorf("download FCC ULS: %w", err)
}
defer os.Remove(amatPath)
// 3) Parse EN.dat, join the crosswalk, rebuild the table.
prog("Building county database", 0)
return s.rebuild(ctx, amatPath, zipmap, prog)
}
// rebuild streams EN.dat out of the FCC zip and replaces uls_callsign in one
// transaction (old data survives a failure).
func (s *Store) rebuild(ctx context.Context, amatZip string, zipmap map[string]zipRow, prog Progress) error {
zr, err := zip.OpenReader(amatZip)
if err != nil {
return fmt.Errorf("open FCC zip: %w", err)
}
defer zr.Close()
var en *zip.File
for _, f := range zr.File {
if strings.EqualFold(filepath.Base(f.Name), "EN.dat") {
en = f
break
}
}
if en == nil {
return fmt.Errorf("EN.dat not found in FCC zip")
}
rc, err := en.Open()
if err != nil {
return fmt.Errorf("open EN.dat: %w", err)
}
defer rc.Close()
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM uls_callsign`); err != nil {
return err
}
stmt, err := tx.PrepareContext(ctx, `INSERT OR REPLACE INTO uls_callsign(callsign,state,county,grid) VALUES(?,?,?,?)`)
if err != nil {
return err
}
defer stmt.Close()
sc := bufio.NewScanner(rc)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var n, kept int
for sc.Scan() {
if ctx.Err() != nil {
return ctx.Err()
}
n++
f := strings.Split(sc.Text(), "|")
if len(f) < 19 {
continue
}
call := strings.ToUpper(strings.TrimSpace(f[4]))
if call == "" {
continue
}
zip5 := zip5Of(f[18])
zr, ok := zipmap[zip5]
if !ok {
continue // no crosswalk entry → can't place it
}
state := strings.ToUpper(strings.TrimSpace(f[17]))
if state == "" {
state = zr.state
}
if _, err := stmt.ExecContext(ctx, call, state, zr.county, grid6(zr.lat, zr.lon)); err != nil {
return err
}
kept++
if kept%50000 == 0 {
prog("Building county database", int(math.Min(99, float64(kept)/8000)))
}
}
if err := sc.Err(); err != nil {
return fmt.Errorf("read EN.dat: %w", err)
}
if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO uls_meta(key,value) VALUES('updated_at',?)`,
time.Now().UTC().Format(time.RFC3339)); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
prog("Done", 100)
return nil
}
// parseGeoNames reads US.txt out of the GeoNames zip into a zip→row map,
// keeping the first (primary) county seen for each ZIP.
func parseGeoNames(zipPath string) (map[string]zipRow, error) {
zr, err := zip.OpenReader(zipPath)
if err != nil {
return nil, err
}
defer zr.Close()
var txt *zip.File
for _, f := range zr.File {
if strings.EqualFold(filepath.Base(f.Name), "US.txt") {
txt = f
break
}
}
if txt == nil {
return nil, fmt.Errorf("US.txt not found")
}
rc, err := txt.Open()
if err != nil {
return nil, err
}
defer rc.Close()
out := make(map[string]zipRow, 45000)
sc := bufio.NewScanner(rc)
sc.Buffer(make([]byte, 0, 64*1024), 256*1024)
for sc.Scan() {
f := strings.Split(sc.Text(), "\t")
if len(f) < 11 {
continue
}
zip5 := strings.TrimSpace(f[1])
if zip5 == "" {
continue
}
if _, dup := out[zip5]; dup {
continue
}
lat := parseFloat(f[9])
lon := parseFloat(f[10])
out[zip5] = zipRow{
state: strings.ToUpper(strings.TrimSpace(f[4])),
county: strings.TrimSpace(f[5]),
lat: lat,
lon: lon,
}
}
return out, sc.Err()
}
// download streams url to dest, reporting percent when the content length is
// known and prog is non-nil.
func download(ctx context.Context, url, dest string, prog func(pct int)) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
}
f, err := os.Create(dest)
if err != nil {
return err
}
defer f.Close()
var body io.Reader = resp.Body
if prog != nil && resp.ContentLength > 0 {
body = &progReader{r: resp.Body, total: resp.ContentLength, prog: prog}
}
_, err = io.Copy(f, body)
return err
}
type progReader struct {
r io.Reader
total int64
read int64
last int
prog func(pct int)
}
func (p *progReader) Read(b []byte) (int, error) {
n, err := p.r.Read(b)
p.read += int64(n)
if pct := int(p.read * 100 / p.total); pct != p.last {
p.last = pct
p.prog(pct)
}
return n, err
}
func zip5Of(z string) string {
z = strings.TrimSpace(z)
if len(z) > 5 {
z = z[:5]
}
return z
}
func parseFloat(s string) float64 {
var v float64
fmt.Sscanf(strings.TrimSpace(s), "%g", &v)
return v
}
// grid6 converts latitude/longitude to a 6-character Maidenhead locator.
func grid6(lat, lon float64) string {
if lat == 0 && lon == 0 {
return ""
}
lon += 180
lat += 90
if lon < 0 || lon >= 360 || lat < 0 || lat >= 180 {
return ""
}
f0 := int(lon / 20)
f1 := int(lat / 10)
sq0 := int(math.Mod(lon, 20) / 2)
sq1 := int(math.Mod(lat, 10) / 1)
su0 := int(math.Mod(lon, 2) / (2.0 / 24))
su1 := int(math.Mod(lat, 1) / (1.0 / 24))
return string([]byte{
byte('A' + f0), byte('A' + f1),
byte('0' + sq0), byte('0' + sq1),
byte('a' + su0), byte('a' + su1),
})
}
+42
View File
@@ -0,0 +1,42 @@
package uls
import (
"os"
"testing"
)
func TestGrid6(t *testing.T) {
cases := []struct {
lat, lon float64
want string
}{
{38.90, -77.03, "FM18lw"}, // Washington DC
{40.71, -74.00, "FN30xr"}, // New York
{34.05, -118.24, "DM04vd"},// Los Angeles
}
for _, c := range cases {
if got := grid6(c.lat, c.lon); got[:4] != c.want[:4] {
t.Errorf("grid6(%v,%v)=%q want field/square %q", c.lat, c.lon, got, c.want[:4])
}
}
}
// TestParseGeoNames runs against the real GeoNames US.zip if present in the
// scratchpad (downloaded during development); skipped otherwise.
func TestParseGeoNames(t *testing.T) {
path := os.Getenv("GEONAMES_ZIP")
if path == "" {
t.Skip("set GEONAMES_ZIP to the US.zip path to run")
}
m, err := parseGeoNames(path)
if err != nil {
t.Fatal(err)
}
if len(m) < 30000 {
t.Fatalf("expected >30k ZIPs, got %d", len(m))
}
// A known ZIP: 20500 = The White House, DC.
if r, ok := m["20500"]; !ok || r.state != "DC" {
t.Errorf("ZIP 20500 = %+v (ok=%v)", r, ok)
}
}
+21
View File
@@ -470,6 +470,27 @@ func (c *Client) queryProgress() ([]int, error) {
return []int{total, current}, nil return []int{total, current}, nil
} }
// ReadElements reads the current per-element lengths for the active band
// (CMD_READ_BANDS). The controller is write-only for ModifyElement, so this is
// the only way to see the current lengths — needed so the operator isn't
// adjusting blind. The reply payload layout is not documented in the code, so we
// LOG it verbatim (once) and parse a best guess: element lengths as 16-bit
// little-endian values, matching how ModifyElement WRITES a length. Confirm the
// format from the logged bytes on real hardware, then tighten the parse.
func (c *Client) ReadElements() ([]int, error) {
payload, err := c.sendCommand(CMD_READ_BANDS, nil)
if err != nil {
return nil, err
}
log.Printf("Ultrabeam: READ_BANDS payload (% X) — %d bytes", payload, len(payload))
// Best-guess parse: consecutive 16-bit LE values = element lengths in mm.
out := make([]int, 0, len(payload)/2)
for i := 0; i+1 < len(payload); i += 2 {
out = append(out, int(payload[i])|int(payload[i+1])<<8)
}
return out, nil
}
// SetFrequency changes frequency and optional direction (command 3) // SetFrequency changes frequency and optional direction (command 3)
func (c *Client) SetFrequency(freqKhz int, direction int) error { func (c *Client) SetFrequency(freqKhz int, direction int) error {
// Trace WHO asked for the change — the caller's function + line — so an // Trace WHO asked for the change — the caller's function + line — so an