chore: release v0.23.2
This commit is contained in:
@@ -648,6 +648,18 @@ func parseShowDX(line string) (Spot, bool) {
|
||||
}
|
||||
|
||||
func parseSpot(line string) (Spot, bool) {
|
||||
// A cluster prints its prompt WITHOUT a trailing newline, so the first spot
|
||||
// to arrive gets glued onto it and the line reads
|
||||
// "login: DX de YO2CK-#: 21074.0 PY4PTO FT8 …". The anchor then fails
|
||||
// and the spot is dropped — silently, apart from an UNPARSED log line.
|
||||
//
|
||||
// Cutting everything ahead of "DX de" rather than un-anchoring the pattern:
|
||||
// an un-anchored match would also accept a talk/announce line that merely
|
||||
// QUOTES a spot, and start injecting other people's conversations as real
|
||||
// spots.
|
||||
if i := strings.Index(line, "DX de"); i > 0 {
|
||||
line = line[i:]
|
||||
}
|
||||
m := spotRE.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
return Spot{}, false
|
||||
|
||||
@@ -14,6 +14,12 @@ func TestParseSpot(t *testing.T) {
|
||||
`DX de DK0SWL: 14195.5 W1AW CQ Field Day 1745Z`,
|
||||
"W1AW", 14195.5, "20m", "",
|
||||
},
|
||||
{
|
||||
// The cluster prints its prompt with no trailing newline, so the
|
||||
// first spot arrives glued to it. Seen live on 2026-08-02.
|
||||
`login: DX de YO2CK-#: 21074.0 PY4PTO FT8 -16 dB 1733Z`,
|
||||
"PY4PTO", 21074.0, "15m", "",
|
||||
},
|
||||
{
|
||||
`DX de F4XYZ-#: 7074.0 3DA0RU FT8 -10 0823Z JN18`,
|
||||
"3DA0RU", 7074.0, "40m", "JN18",
|
||||
|
||||
@@ -86,7 +86,13 @@ type Server struct {
|
||||
stopped bool
|
||||
mu sync.Mutex
|
||||
|
||||
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets
|
||||
// WSJT: dial frequency from the last Status, added to Decode audio offsets —
|
||||
// keyed by the WSJT-X instance id, NOT one value per listener. Two instances
|
||||
// share one multicast group, so a single value was overwritten by whichever
|
||||
// sent Status last: decodes from the 50.313 receiver were placed on the
|
||||
// 50.400 panadapter. WSJT-X requires --rig-name for a second instance, so the
|
||||
// id is distinct whenever there is more than one.
|
||||
dialHz map[string]int64
|
||||
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
|
||||
}
|
||||
|
||||
@@ -211,15 +217,21 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
// offsets can be turned into RF frequencies for the panadapter.
|
||||
if w.FreqHz > 0 && !w.IsDecode {
|
||||
s.mu.Lock()
|
||||
s.lastDialHz = w.FreqHz
|
||||
if s.dialHz == nil {
|
||||
s.dialHz = map[string]int64{}
|
||||
}
|
||||
s.dialHz[w.ProgramID] = w.FreqHz
|
||||
s.mu.Unlock()
|
||||
}
|
||||
if w.IsDecode {
|
||||
s.mu.Lock()
|
||||
dial := s.lastDialHz
|
||||
dial := s.dialHz[w.ProgramID]
|
||||
s.mu.Unlock()
|
||||
if dial <= 0 {
|
||||
return // no dial freq yet (Status not seen) → can't place the spot
|
||||
// No Status from THIS instance yet. Guessing with another
|
||||
// instance's dial is what produced the wrong-panadapter spots,
|
||||
// so drop the decode and wait — Status arrives every second.
|
||||
return
|
||||
}
|
||||
ev.DecodeCall = w.DecodeCall
|
||||
ev.DecodeFreqHz = dial + w.DeltaFreqHz
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# QRZ.com XML API — what a free account returns, and what a subscription adds
|
||||
|
||||
Captured 2026-08-02 on F4BPO's two accounts, same callsign looked up twice
|
||||
within a minute. Kept because the difference is the answer to a question that
|
||||
comes back regularly: *"someone told me their logger gets the 6-character
|
||||
locator from QRZ without paying"*. It does not.
|
||||
|
||||
## Free account — `SubExp: non-subscriber`
|
||||
|
||||
```xml
|
||||
<Callsign>
|
||||
<call>F4BPO</call>
|
||||
<fname>Gregory</fname>
|
||||
<name>Salaun</name>
|
||||
<addr2>Veigy-Foncenex</addr2>
|
||||
<country>France</country>
|
||||
</Callsign>
|
||||
<Session>
|
||||
<SubExp>non-subscriber</SubExp>
|
||||
<Message>A subscription is required to access the complete record.</Message>
|
||||
</Session>
|
||||
```
|
||||
|
||||
**Five fields.** No `grid`, no `lat`/`lon`, no `dxcc`, no zones, no email, no
|
||||
street, no image.
|
||||
|
||||
## Subscriber — `SubExp: Sat Jul 3 21:45:38 2027`
|
||||
|
||||
```xml
|
||||
<Callsign>
|
||||
<call>F4BPO</call> <dxcc>227</dxcc> <nickname>Greg</nickname>
|
||||
<fname>Gregory</fname> <name>Salaun</name>
|
||||
<addr2>Veigy-Foncenex</addr2><zip>74140</zip> <country>France</country>
|
||||
<lat>46.103333</lat> <lon>6.290000</lon> <grid>JN36dc</grid>
|
||||
<ccode>97</ccode> <land>France</land> <class>2</class>
|
||||
<codes>E</codes> <qslmgr>Lotw, Buro, Direct</qslmgr>
|
||||
<email>…</email> <u_views>56966</u_views>
|
||||
<bio>4414</bio> <biodate>2026-07-08 15:30:58</biodate>
|
||||
<image>https://cdn-xml.qrz.com/o/f4bpo/F4BPO_Greg_png.jpg</image>
|
||||
<imageinfo>800:1200:221041</imageinfo>
|
||||
<moddate>2025-08-24 19:55:31</moddate>
|
||||
<eqsl>0</eqsl> <mqsl>1</mqsl> <lotw>1</lotw>
|
||||
<cqzone>14</cqzone> <ituzone>27</ituzone>
|
||||
<geoloc>user</geoloc> <name_fmt>Gregory "Greg" Salaun</name_fmt>
|
||||
<serial>1000134</serial>
|
||||
</Callsign>
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
- **A free QRZ account can never fill the locator, the coordinates, the zones
|
||||
or the e-mail.** If someone reports a logger doing it on a free account, they
|
||||
are either using HamQTH (free, returns a 6-character `grid`) or they hold an
|
||||
XML subscription without realising it — the QRZ.com account and the "XML
|
||||
Logbook Data" subscription are sold separately. `SubExp` in the session block
|
||||
settles it in one request.
|
||||
- **Profile edits take a few minutes to reach the XML feed.** Changing the grid
|
||||
on the website and looking the callsign up immediately returns the OLD value,
|
||||
which looks exactly like a stale cache. Re-test after a few minutes before
|
||||
suspecting the logger. (This cost an hour on 2026-08-02.)
|
||||
|
||||
## Fields OpsLog does not use yet
|
||||
|
||||
`qrz.go` maps call, name, addr1/2, zip, state, county, country, grid, lat, lon,
|
||||
dxcc, cqzone, ituzone, cont, email, qslmgr and image. Left on the table:
|
||||
|
||||
- **`geoloc`** — `user` when the operator entered the position by hand, versus a
|
||||
geocoded value derived from the postal address. This is a direct statement of
|
||||
how much the coordinates and the locator can be trusted, and it is exactly
|
||||
the question the grid-precision rule in App.tsx tries to answer by comparing
|
||||
string lengths. Worth wiring if the locator ever misbehaves again.
|
||||
- **`eqsl` / `mqsl` / `lotw`** — the station's QSL preferences as flags, more
|
||||
reliable than parsing the free-text `qslmgr`.
|
||||
- `nickname` / `name_fmt`, `class`, `moddate`, `bio`.
|
||||
@@ -29,6 +29,13 @@ type Info struct {
|
||||
IsUser bool `json:"is_user"`
|
||||
LastUpload string `json:"last_upload,omitempty"` // YYYY-MM-DD
|
||||
DaysAgo int `json:"days_ago"` // days since last upload (-1 if unknown)
|
||||
// FeedDate is the newest upload date anywhere in ARRL's file, i.e. when ARRL
|
||||
// last regenerated it, and FeedStale says that date is more than two days old.
|
||||
// Without them "last uploaded 7 days ago" is unreadable: it may mean the
|
||||
// station went quiet, or that the FEED has been frozen for a week — which is
|
||||
// exactly what ARRL served on 2026-08-02, its newest line dated 2026-07-27.
|
||||
FeedDate string `json:"feed_date,omitempty"`
|
||||
FeedStale bool `json:"feed_stale"`
|
||||
}
|
||||
|
||||
// Manager holds the parsed list + cache location.
|
||||
@@ -36,6 +43,7 @@ type Manager struct {
|
||||
mu sync.RWMutex
|
||||
users map[string]time.Time // UPPER(callsign) → last-upload date (UTC)
|
||||
updated time.Time // when the cache was last refreshed
|
||||
newest time.Time // newest upload date in the file = when ARRL built it
|
||||
dir string
|
||||
client *http.Client
|
||||
}
|
||||
@@ -100,6 +108,7 @@ func (m *Manager) Download(ctx context.Context) (int, error) {
|
||||
// parse loads the CSV bytes into the map and returns the count.
|
||||
func (m *Manager) parse(data []byte) int {
|
||||
users := make(map[string]time.Time, 1<<20)
|
||||
var newest time.Time
|
||||
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
@@ -120,16 +129,29 @@ func (m *Manager) parse(data []byte) int {
|
||||
continue
|
||||
}
|
||||
users[call] = t
|
||||
if t.After(newest) {
|
||||
newest = t
|
||||
}
|
||||
}
|
||||
if len(users) == 0 {
|
||||
return 0
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.users = users
|
||||
m.newest = newest
|
||||
m.mu.Unlock()
|
||||
return len(users)
|
||||
}
|
||||
|
||||
// feedInfo fills the feed-age fields shared by every Lookup result.
|
||||
func (m *Manager) feedInfo(i Info) Info {
|
||||
if !m.newest.IsZero() {
|
||||
i.FeedDate = m.newest.Format("2006-01-02")
|
||||
i.FeedStale = time.Since(m.newest) > 48*time.Hour
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// Lookup reports whether callsign is a LoTW user and how long ago it uploaded.
|
||||
// Tries the exact call, then (for portable calls like "EA8/DL1ABC" or "F5ABC/P")
|
||||
// the longest slash-separated segment — the base call LoTW is keyed on.
|
||||
@@ -154,13 +176,13 @@ func (m *Manager) Lookup(callsign string) Info {
|
||||
t, ok = m.users[base]
|
||||
}
|
||||
if !ok {
|
||||
return Info{DaysAgo: -1}
|
||||
return m.feedInfo(Info{DaysAgo: -1})
|
||||
}
|
||||
days := int(time.Since(t).Hours() / 24)
|
||||
if days < 0 {
|
||||
days = 0
|
||||
}
|
||||
return Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days}
|
||||
return m.feedInfo(Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days})
|
||||
}
|
||||
|
||||
// Count returns how many callsigns are loaded.
|
||||
|
||||
Reference in New Issue
Block a user