Compare commits

...
16 Commits
Author SHA1 Message Date
rouggy ac4039393d feat: RIT back to 0 once a qso is logged 2026-07-18 04:06:43 +02:00
rouggy bb53085c21 fix: wider space for country names in statistics 2026-07-18 03:52:14 +02:00
rouggy 031dfa8f46 feat: Adding show password for SMTP server 2026-07-17 20:50:05 +02:00
rouggy 6322a425d8 feat: Added columns selection in the Net Control 2026-07-17 20:21:29 +02:00
rouggy 9f08df1c39 fix: fix a bug adjusting the length of Ultrabeam 2026-07-17 20:19:54 +02:00
rouggy aeeb658269 fix: Improved status badges in cluster 2026-07-17 19:35:30 +02:00
rouggy 8e088576c7 fix: Better matching of the counties for the USA-CA 2026-07-17 16:28:32 +02:00
rouggy eb2ff8ed59 feat: New badges in cluster view for new Counties and new POTA 2026-07-17 15:28:36 +02:00
rouggy dd3b51a2ae feat: Added support for US Counties in OpsLog / Extra feature with DXHunter 2026-07-17 13:23:35 +02:00
rouggy 1a155e3627 fix: bugs 2026-07-17 11:55:40 +02:00
rouggy cd13921322 fix: QSL Manager properly calculating new slot (Digi modes are grouped together) 2026-07-16 23:26:04 +02:00
rouggy 46e3619a38 fix: relocated Lotw and Alerts 2026-07-16 22:56:21 +02:00
rouggy dcf006905c fix: QTH field is wider to see the full city names 2026-07-16 22:47:24 +02:00
rouggy 7cf2dfeaf9 feat: For steppir & Ultrabeam,possibility to change each element length 2026-07-16 22:32:29 +02:00
rouggy 69229964f4 fix: bug 2026-07-16 22:13:21 +02:00
rouggy 09848adddc feat: Added Ultrabeam/Steppir to Station Control with function to retract elements. 2026-07-16 22:10:36 +02:00
27 changed files with 4950 additions and 163 deletions
+353 -18
View File
@@ -54,6 +54,7 @@ import (
"hamlog/internal/settings"
"hamlog/internal/solar"
"hamlog/internal/steppir"
"hamlog/internal/uls"
"hamlog/internal/ultrabeam"
"hamlog/internal/winkeyer"
@@ -437,6 +438,7 @@ type App struct {
clusterEventCh chan clusterEvent
clusterDropped int64 // spots/lines dropped when the queue was full (atomic)
pota *pota.Cache
uls *uls.Store // US callsign→county/grid (offline FCC ULS), lazily opened
awardRefs *awardref.Repo
qslTemplates *qslcard.Repo
operating *operating.Repo
@@ -761,6 +763,14 @@ func (a *App) startup(ctx context.Context) {
}
}
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.qslTemplates = qslcard.NewRepo(conn)
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.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.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
a.applyQSLDefaults(&q)
@@ -1840,6 +1851,8 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
}
if err == nil {
q.ID = id
// Announce the log so UI widgets can react (e.g. the Flex panel zeroing RIT).
wruntime.EventsEmit(a.ctx, "qso:logged", id)
a.saveQSORecording(&q)
if a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
@@ -2385,6 +2398,22 @@ func (a *App) migrateAwardDefs() {
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
// 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.
@@ -4244,7 +4273,7 @@ func freeAwardCode(code string, taken map[string]int) string {
// builtinRefsVersion is bumped whenever the built-in reference data changes
// (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.
const builtinRefsVersion = "2"
const builtinRefsVersion = "5"
// seedBuiltinReferences populates the reference lists of built-in awards.
// - First run (no version stored), or already up to date: seed only awards that
@@ -4680,7 +4709,9 @@ var bulkFieldColumns = map[string]string{
"prop_mode": "prop_mode",
"sat_name": "sat_name",
"sat_mode": "sat_mode",
// Contacted station activation refs / SIG
// Contacted station location + activation refs / SIG
"state": "state",
"cnty": "cnty",
"pota_ref": "pota_ref",
"sota_ref": "sota_ref",
"wwff_ref": "wwff_ref",
@@ -7760,9 +7791,222 @@ type ConfirmationItem struct {
Country string `json:"country"`
NewDXCC bool `json:"new_dxcc"`
NewBand bool `json:"new_band"`
NewMode bool `json:"new_mode"` // new mode CLASS (Phone/CW/Digital) for the entity
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
// matching local QSOs' received status. LoTW only for now (the canonical
// confirmation system); runs in the background emitting the same
@@ -7887,12 +8131,17 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
Country: q.Country,
}
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.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.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)
return nil
@@ -8047,12 +8296,17 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
Band: q.Band, Mode: q.Mode, Country: q.Country,
}
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.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.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)
return nil
@@ -8163,12 +8417,17 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
Band: q.Band, Mode: q.Mode, Country: q.Country,
}
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.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.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)
return nil
@@ -10747,6 +11006,14 @@ type motorAntenna interface {
Retract() error
LastSetKHz() int
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,
@@ -10760,6 +11027,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) Retract() error { return a.c.Retract() }
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 {
st, err := a.c.GetStatus()
if err != nil || st == nil {
@@ -10776,6 +11051,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) Retract() error { return a.c.Retract() }
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 {
st, err := a.c.GetStatus()
if err != nil || st == nil {
@@ -11062,19 +11344,22 @@ func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, stop <-chan struc
// direction control). Enabled mirrors the setting; the rest comes from the
// device's most recent status poll.
type UltrabeamStatusInfo struct {
Enabled bool `json:"enabled"`
Connected bool `json:"connected"`
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bidirectional
Frequency int `json:"frequency"` // KHz
Band int `json:"band"`
Moving bool `json:"moving"`
Enabled bool `json:"enabled"`
Type string `json:"type"` // "ultrabeam" | "steppir"
Connected bool `json:"connected"`
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bidirectional
Frequency int `json:"frequency"` // KHz
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.
func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo {
out := UltrabeamStatusInfo{}
out := UltrabeamStatusInfo{Elements: []int{}}
s, _ := a.GetUltrabeamSettings()
out.Enabled = s.Enabled
out.Type = s.Type
if a.motorAnt == nil {
return out
}
@@ -11084,9 +11369,31 @@ func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo {
out.Frequency = st.Frequency
out.Band = st.Band
out.Moving = st.Moving
if el := a.motorAnt.Elements(); el != nil {
out.Elements = el
}
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°,
// 2=bidirectional (re-issues the current frequency with the new direction).
func (a *App) SetUltrabeamDirection(direction int) error {
@@ -11774,9 +12081,10 @@ func (a *App) GetClusterStatus() []cluster.ServerStatus {
// SpotQuery is one (call, band, mode) tuple sent for status colouring.
type SpotQuery struct {
Call string `json:"call"`
Band string `json:"band"`
Mode string `json:"mode"`
Call string `json:"call"`
Band string `json:"band"`
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:
@@ -11798,6 +12106,12 @@ type SpotStatus struct {
// (any band, any mode). Drives the per-call text highlight, in
// addition to the entity-level Status (NEW / NEW BAND / …).
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
@@ -11842,6 +12156,10 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
// "I've already QSO'd this exact station" even when the band/mode
// makes the entity check say "new-band" or "new-slot".
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 {
out[i] = SpotStatus{
Call: q.Call,
@@ -11851,6 +12169,23 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok {
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 {
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".
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
// 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 ===
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
@@ -2010,14 +2010,14 @@ export default function App() {
// "new-slot" because the lookup key carried mode="".
useEffect(() => {
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>();
for (const s of spots) {
const mode = inferSpotMode(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;
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;
try {
@@ -2031,6 +2031,8 @@ export default function App() {
country: r.country,
continent: (r as any).continent,
worked_call: !!(r as any).worked_call,
new_county: !!(r as any).new_county,
new_pota: !!(r as any).new_pota,
};
}
return next;
@@ -2866,12 +2868,12 @@ export default function App() {
</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'); }} />
</div>
);
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'); }} />
</div>
);
@@ -2885,8 +2887,8 @@ export default function App() {
: d < 30 ? 'border-warning text-warning bg-warning/15'
: 'border-danger text-danger bg-danger/15';
return (
<div className="self-end 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)}>
<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 text-[9px] font-extrabold tracking-wide leading-none', cls)}>
LoTW
</span>
</div>
@@ -2901,7 +2903,7 @@ export default function App() {
// worked-before check doesn't include these, and see what's waiting.
const offlinePendingCount = offlineStatus.pending ?? 0;
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(() => {}); }}
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">
@@ -2954,7 +2956,7 @@ export default function App() {
const hasAlerts = recentAlerts.length > 0;
// Only show the bell when there are pending alerts — hidden otherwise.
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)}
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',
@@ -3109,19 +3111,19 @@ export default function App() {
const logButtons = (
<div className="flex flex-col">
<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') && (
<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" />
{t('btn.spot')}
</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)">
<Eraser className="size-3.5" />
{t('btn.clear')}
</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" />
{saving ? '…' : t('btn.logQso')}
</Button>
@@ -3910,9 +3912,6 @@ export default function App() {
</div>
{qthBlock}
{gridBlock}
{lotwBlock}
{offlineBlock}
{alertLedBlock}
</div>
{/* Row 3: tight left detail column (Band/Mode/Country) and
@@ -3929,11 +3928,19 @@ export default function App() {
</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">
{freqBlock}
{rxFreqBlock}
{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>
</>
+3 -1
View File
@@ -64,7 +64,9 @@ const FIELDS: FieldDef[] = [
{ 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_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: '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 },
+49 -21
View File
@@ -51,6 +51,8 @@ export type SpotStatusEntry = {
country?: string;
continent?: string;
worked_call?: boolean;
new_county?: boolean;
new_pota?: boolean;
};
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 =
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
// 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) {
case 'new': return { text: t('clg2.newDxcc'), fg: '#be123c', bg: '#ffe4e6' };
case 'new-band': return { text: t('clg2.newBand'), fg: '#92400e', bg: '#fde68a' };
case 'new-mode': return { text: t('clg2.newMode'), fg: '#854d0e', bg: '#fef08a' };
case 'new-slot': return { text: t('clg2.newSlot'), fg: '#854d0e', bg: '#fef08a' };
default: return s?.worked_call ? { text: t('clg2.wkdCall'), fg: '#0369a1', bg: '#e0f2fe' } : null;
case 'new': return tok('danger', t('clg2.newDxcc'));
case 'new-band': return tok('warning', t('clg2.newBand'));
case 'new-mode': return tok('caution', t('clg2.newMode'));
case 'new-slot': return tok('caution', t('clg2.newSlot'));
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.
cellStyle: (p: any): any => {
const s = statusFor(p);
if (s?.status === 'new') return { backgroundColor: '#ffe4e6', color: '#be123c', fontWeight: 700 };
if (s?.worked_call) return { color: '#0369a1', fontWeight: 700 };
if (s?.status === 'new') return { backgroundColor: 'var(--danger-muted)', color: 'var(--danger-muted-foreground)', fontWeight: 700 };
if (s?.worked_call) return { color: 'var(--info)', fontWeight: 700 };
return { fontWeight: 700 };
},
tooltipValueGetter: (p: any) => {
@@ -141,26 +153,42 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
},
{
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,
// 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) => {
const s = statusFor(p);
if (s?.status === 'new') return t('clg2.newDxcc');
if (s?.status === 'new-band') return t('clg2.newBand');
if (s?.status === 'new-mode') return t('clg2.newMode');
if (s?.status === 'new-slot') return t('clg2.newSlot');
return s?.worked_call ? t('clg2.wkdCall') : '';
const parts: string[] = [];
if (s?.status === 'new') parts.push(t('clg2.newDxcc'));
else if (s?.status === 'new-band') parts.push(t('clg2.newBand'));
else if (s?.status === 'new-mode') parts.push(t('clg2.newMode'));
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) => {
const b = statusBadge(t, statusFor(p));
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}></span>;
const s = statusFor(p);
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 (
<span style={{
backgroundColor: b.bg, color: b.fg, fontWeight: 700, fontSize: 10,
padding: '1px 6px', borderRadius: 4, letterSpacing: 0.3, whiteSpace: 'nowrap',
}}>{b.text}</span>
<span style={{ display: 'flex', height: '100%', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
{badges.map((bd, i) => (
<span key={i} style={{
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) => {
+32 -1
View File
@@ -12,6 +12,7 @@ import {
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
} from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { sMeterRST } from '@/lib/rst';
@@ -189,7 +190,7 @@ function OffsetRow({ label, on, onToggle, hz, onHz, disabled, title }: {
return (
<div className="flex items-center gap-2" title={title}>
<Chip on={on} onClick={onToggle} label={label} disabled={disabled} accent="cyan" />
<div ref={ref} tabIndex={disabled || !on ? -1 : 0} onKeyDown={onKey}
<div ref={ref} data-offsetrow tabIndex={disabled || !on ? -1 : 0} onKeyDown={onKey}
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none',
'focus:outline-none focus:ring-2 focus:ring-info/50',
on && !disabled ? 'border-border bg-muted/40 cursor-ns-resize' : 'border-border/60 bg-muted/20 opacity-60')}>
@@ -317,6 +318,36 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
const off = !st.available;
const rxOff = off || !st.rx_avail;
// Radio-style global RIT tuning: Ctrl+←/→ nudges the RIT offset (Ctrl+Shift =
// ±100 Hz) from anywhere, without having to click the RIT field first. A
// focused offset row (data-offsetrow) or a text field handles its own keys, so
// this never double-fires. Only active while RIT is on and the RX is available.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey)) return;
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
const ae = document.activeElement as HTMLElement | null;
if (ae && (ae.hasAttribute('data-offsetrow') || /^(input|textarea|select)$/i.test(ae.tagName) || ae.isContentEditable)) return;
if (!st.rit || rxOff) return;
e.preventDefault();
const mag = e.shiftKey ? 100 : 10;
const next = (st.rit_freq || 0) + (e.key === 'ArrowRight' ? mag : -mag);
change('rit_freq', next, () => FlexSetRITFreq(next));
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [st.rit, st.rit_freq, rxOff]);
// Clear the RIT offset back to 0 whenever a QSO is logged (any path: Log
// button, keyer macro, WSJT-X), so the next station starts centred. Only acts
// when there's actually a non-zero offset to clear.
useEffect(() => {
const off = EventsOn('qso:logged', () => {
if (st.rit && (st.rit_freq || 0) !== 0) change('rit_freq', 0, () => FlexSetRITFreq(0));
});
return () => off();
}, [st.rit, st.rit_freq]);
const isCW = (st.mode || '').toUpperCase().includes('CW');
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
+18 -54
View File
@@ -14,6 +14,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { QSOEditModal } from '@/components/QSOEditModal';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { useI18n } from '@/lib/i18n';
import type { QSOForm } from '@/types';
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).
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]);
// Add/edit-contact dialog.
const [contactOpen, setContactOpen] = useState(false);
const [contact, setContact] = useState<Station>(emptyStation());
const [looking, setLooking] = useState(false);
const activeGrid = useRef<any>(null);
const rosterGrid = useRef<any>(null);
// 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)); }
}
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>[]>(() => [
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
@@ -292,26 +281,20 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
{t('ncp.onAirActive')}
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}>
<AgGridReact<QSOForm>
ref={activeGrid}
theme={hamlogTheme}
rowData={active}
columnDefs={activeCols}
defaultColDef={defaultColDef}
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 className="flex flex-col min-h-0 flex-1">
<RecentQSOsGrid
rows={active}
total={active.length}
storageKey="net.onair"
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
onRowDoubleClicked={(q) => setEditingDraft(q)}
onRowSelected={setSelectedActiveIds}
/>
</div>
{isOpen && active.length > 0 && (
<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]"
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')}
</Button>
</div>
@@ -335,7 +318,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
</span>
)}
</div>
<div className="flex-1 min-h-0 overflow-auto">
<div className="flex-1 min-h-0 flex flex-col">
{!wbCall ? (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbHint')}</div>
) : wbBusy ? (
@@ -343,30 +326,11 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
) : !wb || wb.count === 0 ? (
<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">
<thead className="sticky top-0 bg-muted/30 text-muted-foreground">
<tr className="text-left">
<th className="px-3 py-1 font-semibold">{t('ncp.colDate')}</th>
<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>
<RecentQSOsGrid
rows={(wb.entries ?? []) as any}
total={wb.count}
storageKey="net.wb"
/>
)}
</div>
</div>
+32 -4
View File
@@ -8,7 +8,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
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 { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime';
@@ -32,7 +32,7 @@ const UPLOAD_COLS: ColDef<UploadRow>[] = [
type Confirmation = {
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 = [
@@ -208,18 +208,27 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const [busy, setBusy] = useState(false);
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(() => {
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
const offDone = EventsOn('qslmgr:done', (d: any) => {
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0}`]);
setBusy(false);
refreshCounts(); // a download may have added confirmations
});
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
setConfirmations((list ?? []) as Confirmation[]);
setViewMode('confirmations');
refreshCounts();
});
return () => { offLog(); offDone(); offConf(); };
}, []);
}, [refreshCounts]);
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) => {
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 'band': return c.new_band;
case 'mode': return c.new_mode;
case 'slot': return c.new_slot;
default: return true;
}
@@ -353,6 +363,22 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
</>
)}
<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 && (
<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}
@@ -371,6 +397,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
<SelectItem value="new">{t('qslm.filterNew')}</SelectItem>
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
<SelectItem value="mode">{t('qslm.filterNewMode')}</SelectItem>
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
</SelectContent>
</Select>
@@ -491,6 +518,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
<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_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>
: <span className="text-muted-foreground/50"></span>}
</td>
+11 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from '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 { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
import { applyAwardRefs } from '@/lib/awardRefs';
@@ -175,6 +176,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
return base;
}, [modes, qso.mode]);
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.
const splitHz = (hz?: number) => hz
? { 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)} />
</div>
<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>
<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}
title={t('qedit.fetchTitle')}>
{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
// in the QSL Manager, where the default is "all selected").
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;
onRowClicked?: (q: QSOForm) => void;
onRowSelected?: (ids: number[]) => void;
onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void;
@@ -49,7 +54,7 @@ type Props = {
awardCols?: { code: string; name: string }[];
};
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
const BASE_COLSTATE_KEY = 'hamlog.qsoColState.v2';
function fmtMhzDots(hz?: number): string {
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 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 gridRef = useRef<any>(null);
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);
// 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
// why previously-shown award columns kept vanishing on reopen. A dedicated set
// 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())));
useEffect(() => {
// 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) {
const local = loadLocal(COL_STATE_KEY);
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
const local = loadLocal(colStateKey);
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
// (fresh machine / after a reinstall), then re-seed the cache.
loadRemote(COL_STATE_KEY).then((remote) => {
loadRemote(colStateKey).then((remote) => {
if (remote && !local) {
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote);
e.api.applyColumnState({ state: stripAwardCols(remote) as ColumnState[], applyOrder: true });
seedLocal(colStateKey, remote);
}
});
}
const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild
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
@@ -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.
useEffect(() => {
const api = gridRef.current?.api;
const local = loadLocal(COL_STATE_KEY);
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
const local = loadLocal(colStateKey);
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.
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t);
@@ -458,6 +473,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
onColumnVisible={saveColumnState}
onSortChanged={saveColumnState}
onRowDoubleClicked={handleRowDoubleClicked}
onRowClicked={(e) => e.data && onRowClicked?.(e.data)}
onSelectionChanged={onSelectionChanged}
onCellContextMenu={onCellContextMenu}
preventDefaultOnContextMenu
+106 -2
View File
@@ -3,7 +3,7 @@ import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight,
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';
import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
@@ -38,6 +38,7 @@ import {
GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations,
DownloadLoTWUsers, GetLoTWUsersStatus,
DownloadULSCounties, ULSStatus, BackfillUSCounties,
ComputeStationInfo,
GetUIPref, SetUIPref,
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
@@ -175,6 +176,7 @@ type SectionId =
| 'backup'
| 'database'
| 'autostart'
| 'uscounties'
| 'awards'
| 'cat'
| '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.udp'), id: 'udp' },
{ kind: 'item', label: t('sec.uscounties'), id: 'uscounties' },
{ kind: 'item', label: t('sec.database'), id: 'database' },
{ 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',
'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',
uscounties: 'sec.uscounties',
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',
};
@@ -941,6 +945,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
});
const [emailMsg, setEmailMsg] = useState('');
const [showSmtpPass, setShowSmtpPass] = useState(false);
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
// eQSL card e-mail (subject/body templates + auto-send on log).
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) }); }
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 [hrdlogTesting, setHrdlogTesting] = useState(false);
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>
<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>
<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>
<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>
@@ -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).
const PANELS: Record<SectionId, () => JSX.Element> = {
general: GeneralPanel,
@@ -4348,6 +4451,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
udp: UDPIntegrationsPanelWrapper,
backup: BackupPanel,
database: DatabasePanel,
uscounties: USCountiesPanel,
autostart: () => <AutostartPanelComponent />,
awards: () => <ComingSoon id="awards" icon={Award} />,
cat: CATPanel,
+187 -11
View File
@@ -1,5 +1,5 @@
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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -11,6 +11,7 @@ import { RotorCompass } from '@/components/RotorCompass';
import {
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
GetRotatorHeading, RotatorGoTo, RotatorStop,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
} from '../../wailsjs/go/main/App';
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) {
const { t } = useI18n();
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 [busy, setBusy] = useState<Record<string, boolean>>({}); // per-relay in-flight
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.
const [order, setOrder] = useState<string[]>(() => {
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
});
const dragId = useRef<string | null>(null);
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
const loadDevices = useCallback(async () => {
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 () => {
try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ }
}, []);
const pollAnt = useCallback(async () => {
try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ }
}, []);
useEffect(() => { loadDevices(); }, [loadDevices]);
useEffect(() => {
poll(); pollRot();
const id = window.setInterval(() => { poll(); pollRot(); }, 3000);
poll(); pollRot(); pollAnt();
const id = window.setInterval(() => { poll(); pollRot(); pollAnt(); }, 3000);
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)); };
// Reorder so `dragged` lands just before `target`.
@@ -217,21 +371,43 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
if (rot.enabled) {
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) });
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 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 (
<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>
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
</Button>
<div className="flex items-center gap-2">
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
{(['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>
{noDevices && !editing && (
@@ -240,7 +416,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
</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) => (
<div key={w.id} draggable
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
@@ -253,7 +429,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
</div>
{!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 && (
+3 -3
View File
@@ -80,7 +80,7 @@ function StatTile({ label, value, sub }: { label: string; value: string; sub?: s
// One series → one hue. The value is direct-labelled, so no reader ever depends
// on a tooltip to get a number.
function HBars({ data, max, empty, share }: { data: Bucket[]; max?: number; empty: string; share?: boolean }) {
function HBars({ data, max, empty, share, labelWidth = 'w-20' }: { data: Bucket[]; max?: number; empty: string; share?: boolean; labelWidth?: string }) {
// max is a display cap for long tails (top entities). Where EVERY row matters —
// the operators of a multi-op — it is deliberately not set: a capped chart would
// silently drop the 9th operator, and "who worked what" is the whole question.
@@ -92,7 +92,7 @@ function HBars({ data, max, empty, share }: { data: Bucket[]; max?: number; empt
<div className="flex flex-col gap-1.5 min-w-0">
{top.map((d) => (
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key}${nf(d.count)}`}>
<span className="w-20 shrink-0 truncate text-[11px] text-muted-foreground text-right">{d.key}</span>
<span className={`${labelWidth} shrink-0 truncate text-[11px] text-muted-foreground text-right`} title={d.key}>{d.key}</span>
<div className="flex-1 min-w-0 h-[14px] flex items-center">
<div
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
@@ -728,7 +728,7 @@ export function StatsPanel() {
</Card>
<Card title={t('stats.topEntities')}>
<HBars data={stats.top_entities} max={12} empty={empty} />
<HBars data={stats.top_entities} max={12} empty={empty} labelWidth="w-40" />
</Card>
<div className="flex flex-col gap-2.5 min-w-0">
<Card title={t('stats.confirmations')} className="flex-1">
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}`;
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 BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
export function BrowseExecutable():Promise<string>;
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 DownloadULSCounties():Promise<void>;
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
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 GetSlotStats():Promise<qso.SlotStats>;
export function GetSolarData():Promise<solar.Data>;
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 MotorReadElements():Promise<Array<number>>;
export function MotorSetElement(arg1:number,arg2:number):Promise<void>;
export function MoveDatabase(arg1:string):Promise<void>;
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 ULSStatus():Promise<main.ULSStatusResult>;
export function UltrabeamRetract():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']();
}
export function BackfillUSCounties() {
return window['go']['main']['App']['BackfillUSCounties']();
}
export function BrowseExecutable() {
return window['go']['main']['App']['BrowseExecutable']();
}
@@ -258,6 +262,10 @@ export function DownloadLoTWUsers() {
return window['go']['main']['App']['DownloadLoTWUsers']();
}
export function DownloadULSCounties() {
return window['go']['main']['App']['DownloadULSCounties']();
}
export function DuplicateProfile(arg1, arg2) {
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
}
@@ -722,6 +730,10 @@ export function GetSecretStatus() {
return window['go']['main']['App']['GetSecretStatus']();
}
export function GetSlotStats() {
return window['go']['main']['App']['GetSlotStats']();
}
export function GetSolarData() {
return window['go']['main']['App']['GetSolarData']();
}
@@ -1034,6 +1046,14 @@ export function 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) {
return window['go']['main']['App']['MoveDatabase'](arg1);
}
@@ -1562,6 +1582,10 @@ export function TestUltrabeam(arg1) {
return window['go']['main']['App']['TestUltrabeam'](arg1);
}
export function ULSStatus() {
return window['go']['main']['App']['ULSStatus']();
}
export function UltrabeamRetract() {
return window['go']['main']['App']['UltrabeamRetract']();
}
+70
View File
@@ -1575,6 +1575,22 @@ export namespace main {
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 {
enabled: boolean;
folder: string;
@@ -2371,6 +2387,7 @@ export namespace main {
call: string;
band: string;
mode: string;
pota_ref?: string;
static createFrom(source: any = {}) {
return new SpotQuery(source);
@@ -2381,6 +2398,7 @@ export namespace main {
this.call = source["call"];
this.band = source["band"];
this.mode = source["mode"];
this.pota_ref = source["pota_ref"];
}
}
export class SpotStatus {
@@ -2391,6 +2409,8 @@ export namespace main {
continent?: string;
status: string;
worked_call: boolean;
new_county: boolean;
new_pota: boolean;
static createFrom(source: any = {}) {
return new SpotStatus(source);
@@ -2405,6 +2425,8 @@ export namespace main {
this.continent = source["continent"];
this.status = source["status"];
this.worked_call = source["worked_call"];
this.new_county = source["new_county"];
this.new_pota = source["new_pota"];
}
}
export class StartupStatus {
@@ -2548,6 +2570,20 @@ export namespace main {
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 {
enabled: boolean;
type: string;
@@ -2580,11 +2616,13 @@ export namespace main {
}
export class UltrabeamStatusInfo {
enabled: boolean;
type: string;
connected: boolean;
direction: number;
frequency: number;
band: number;
moving: boolean;
elements: number[];
static createFrom(source: any = {}) {
return new UltrabeamStatusInfo(source);
@@ -2593,11 +2631,13 @@ export namespace main {
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.type = source["type"];
this.connected = source["connected"];
this.direction = source["direction"];
this.frequency = source["frequency"];
this.band = source["band"];
this.moving = source["moving"];
this.elements = source["elements"];
}
}
export class UpdateInfo {
@@ -3655,6 +3695,36 @@ export namespace qso {
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 {
total: number;
unique_calls: number;
+60 -1
View File
@@ -318,7 +318,7 @@ func Migrate(defs []Def) ([]Def, bool) {
func Fields() []string {
return []string{
"dxcc", "cqz", "ituz", "prefix", "callsign",
"state", "cont", "country", "grid", "grid4",
"state", "us_county", "cont", "country", "grid", "grid4",
"iota", "sota_ref", "pota_ref", "wwff",
"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).
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
// name resolver as a fallback).
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
case "state":
return q.State
case "us_county":
return USCountyKey(q.State, q.County)
case "cont":
return q.Continent
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", …)
// - WAS → ADIF STATE code ("AL", …)
// - DDFM → "D06" (the award pattern captures the leading D)
// - USA-CA → canonical "STATE/COUNTY" key (see award.USCountyKey)
func BuiltinRefs(code string) ([]Ref, bool) {
switch code {
case "DXCC":
@@ -29,6 +30,8 @@ func BuiltinRefs(code string) ([]Ref, bool) {
return usStates().Refs, true
case "DDFM":
return frenchDepartments(), true
case "USA-CA":
return usCounties(), true
}
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
// silently see an empty value during stats/computation.
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, ` +
`qsl_rcvd, lotw_rcvd, eqsl_rcvd, extras_json`
@@ -1640,6 +1640,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
qsoDateStr string
freqHz sql.NullInt64
grid, vucc, country, state sql.NullString
cnty sql.NullString
cont, iotaRef, sota, pota sql.NullString
dxcc, cqz, ituz sql.NullInt64
name, qth, address sql.NullString
@@ -1649,7 +1650,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
)
if err := s.Scan(
&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,
&qslRcvd, &lotwRcvd, &eqslRcvd, &extrasJSON,
); err != nil {
@@ -1664,6 +1665,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
q.VUCCGrids = vucc.String
q.Country = country.String
q.State = state.String
q.County = cnty.String
q.Continent = cont.String
if cqz.Valid {
v := int(cqz.Int64)
@@ -1782,6 +1784,56 @@ func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error)
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.
func (r *Repo) Count(ctx context.Context) (int64, error) {
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
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be
// 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 {
DXCC map[int]bool // dxcc entity confirmed
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 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 {
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
// OR together (guards the dynamic SQL).
var confirmedCols = map[string]bool{
@@ -1985,7 +2119,7 @@ var confirmedCols = map[string]bool{
// {lotw_rcvd, qsl_rcvd} (the award-valid sources), QRZ uses
// {qrzcom_qso_download_status}.
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
for _, c := range cols {
if confirmedCols[c] {
@@ -2014,7 +2148,8 @@ func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets
}
sets.DXCC[dxcc] = 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()
}
+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
}
// 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)
func (c *Client) SetFrequency(freqKhz int, direction int) error {
// Trace WHO asked for the change — the caller's function + line — so an