chore: release v0.26.2
This commit is contained in:
@@ -2163,6 +2163,34 @@ func (f *Flex) startMeters(conn net.Conn) {
|
||||
debugLog.Printf("Flex: meters UDP local=:%d punch→%s", port, raddr)
|
||||
go f.udpReader(uc)
|
||||
go f.udpKeepalive(uc, raddr)
|
||||
go f.udpWatchdog(uc)
|
||||
}
|
||||
|
||||
// udpWatchdog says so when the meter stream never arrives.
|
||||
//
|
||||
// The meters come over VITA-49 on a UDP socket the radio sends TO US; the TCP
|
||||
// side can be perfectly healthy — handshake, slices, spots, everything — while
|
||||
// not one datagram gets through. On Windows that is almost always the firewall
|
||||
// refusing inbound UDP for a program it has not been asked about, and the only
|
||||
// symptom is meters that stay at zero. Nothing said so: an operator saw a
|
||||
// connected radio with dead meters and no reason anywhere.
|
||||
//
|
||||
// Five seconds is far longer than the stream's own 40 frames a second needs.
|
||||
func (f *Flex) udpWatchdog(uc *net.UDPConn) {
|
||||
time.Sleep(5 * time.Second)
|
||||
f.mu.Lock()
|
||||
cur, seen := f.udpConn, f.vitaSeen
|
||||
port := 0
|
||||
if uc.LocalAddr() != nil {
|
||||
port = uc.LocalAddr().(*net.UDPAddr).Port
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if cur != uc || seen > 0 {
|
||||
return // reconnected in the meantime, or the stream is flowing
|
||||
}
|
||||
debugLog.Printf("Flex: NO meter data after 5s — the radio's VITA-49 stream is not reaching UDP port %d. "+
|
||||
"The TCP link is fine, so this is almost always a firewall: allow OpsLog.exe inbound UDP "+
|
||||
"(Windows Defender Firewall → Allow an app), or open port %d for %s", port, port, f.host)
|
||||
}
|
||||
|
||||
func (f *Flex) udpReader(uc *net.UDPConn) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package udp
|
||||
|
||||
import "testing"
|
||||
|
||||
// A compound or non-standard callsign does not fit in a 77-bit FT8 message, so
|
||||
// it travels as a hash and is printed between angle brackets. Those decodes
|
||||
// used to be discarded whole — including the one message an operator is waiting
|
||||
// for, the reply that says a station answered them.
|
||||
func TestWsjtSenderHashedCallsign(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
msg, call string
|
||||
cq bool
|
||||
grid string
|
||||
}{
|
||||
// The reply that went missing: the sender is the bracketed call.
|
||||
{"F4BPO <ZA/IW2JOP> -24", "ZA/IW2JOP", false, ""},
|
||||
{"F4BPO <ZA/IW2JOP> R-14", "ZA/IW2JOP", false, ""},
|
||||
// The other way round: we are the hashed one, the sender is plain.
|
||||
{"<ZA/IW2JOP> F4BPO R-14", "F4BPO", false, ""},
|
||||
// A CQ from a hashed call, with and without a grid.
|
||||
{"CQ <ZA/IW2JOP> KM09", "ZA/IW2JOP", true, "KM09"},
|
||||
{"CQ <ZA/IW2JOP>", "ZA/IW2JOP", true, ""},
|
||||
{"CQ DX <ZA/IW2JOP> KM09", "ZA/IW2JOP", true, "KM09"},
|
||||
// The compound form: one station signs off with another while answering a
|
||||
// third. The sender is after the semicolon — reading from the left names
|
||||
// OK1UA, who did not send it.
|
||||
{"OK1UA RR73; DH1HRN <ZA/IW2JOP> -08", "ZA/IW2JOP", false, ""},
|
||||
{"K1ABC RR73; W9XYZ <KH1/KH7Z> -08", "KH1/KH7Z", false, ""},
|
||||
// Unchanged: the ordinary forms.
|
||||
{"F4BPO W6YGO R-03", "W6YGO", false, ""},
|
||||
{"CQ N2BJ EN61", "N2BJ", true, "EN61"},
|
||||
// A hash the receiver could not resolve is NOT a callsign, and must not
|
||||
// become one.
|
||||
{"F4BPO <...> -24", "", false, ""},
|
||||
} {
|
||||
call, cq, grid := wsjtSender(c.msg)
|
||||
if call != c.call || cq != c.cq || grid != c.grid {
|
||||
t.Errorf("wsjtSender(%q) = (%q, %v, %q), want (%q, %v, %q)",
|
||||
c.msg, call, cq, grid, c.call, c.cq, c.grid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -372,8 +372,29 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
|
||||
// third slot, never a locator.
|
||||
//
|
||||
// Returns "" for free-text / telemetry / hashed-call messages we can't resolve.
|
||||
// unbracket strips the angle brackets FT8 puts around a HASHED callsign.
|
||||
//
|
||||
// A compound or non-standard call does not fit in a 77-bit message, so the
|
||||
// protocol sends a hash of it and the receiving software prints what it
|
||||
// resolved between angle brackets: "F4BPO <ZA/IW2JOP> -24". The brackets are
|
||||
// notation, not part of the callsign — but every character test here rejected
|
||||
// them, so the whole decode was thrown away as unparseable. Every reply from a
|
||||
// station with a compound call was invisible: precisely the stations worth
|
||||
// seeing, and precisely the message that says one of them answered you.
|
||||
func unbracket(s string) string {
|
||||
return strings.TrimSuffix(strings.TrimPrefix(s, "<"), ">")
|
||||
}
|
||||
|
||||
func wsjtSender(message string) (call string, isCQ bool, grid string) {
|
||||
f := strings.Fields(strings.ToUpper(strings.TrimSpace(message)))
|
||||
msg := strings.ToUpper(strings.TrimSpace(message))
|
||||
// The compound form — "OK1UA RR73; DH1HRN <ZA/IW2JOP> -08" — is one station
|
||||
// signing off with another while answering a third. The sender is in the
|
||||
// part AFTER the semicolon; reading the line from the left names OK1UA, who
|
||||
// did not send it.
|
||||
if i := strings.LastIndexByte(msg, ';'); i >= 0 {
|
||||
msg = strings.TrimSpace(msg[i+1:])
|
||||
}
|
||||
f := strings.Fields(msg)
|
||||
if len(f) == 0 {
|
||||
return "", false, ""
|
||||
}
|
||||
@@ -381,11 +402,11 @@ func wsjtSender(message string) (call string, isCQ bool, grid string) {
|
||||
// Skip an optional modifier after CQ (DX / a region like NA / a zone like
|
||||
// 020) — it never looks like a callsign (no letter+digit mix).
|
||||
idx := 1
|
||||
if len(f) > 2 && !looksLikeCall(f[1]) {
|
||||
if len(f) > 2 && !looksLikeCall(unbracket(f[1])) {
|
||||
idx = 2
|
||||
}
|
||||
if idx < len(f) {
|
||||
c := f[idx]
|
||||
c := unbracket(f[idx])
|
||||
// A GRID sitting in the callsign slot means the real call was
|
||||
// unparseable and the skip above went one word too far. JN36 has letters
|
||||
// and digits and passes every shape test there is, so without this the
|
||||
@@ -411,11 +432,13 @@ func wsjtSender(message string) (call string, isCQ bool, grid string) {
|
||||
//
|
||||
// isGridField is what keeps the reports and sign-offs out: -05, R-05, RRR,
|
||||
// 73 and RR73 all fail it, on length, charset or by name.
|
||||
if len(f) >= 2 && looksLikeCall(f[1]) {
|
||||
if len(f) >= 3 && isGridField(f[2]) {
|
||||
return f[1], false, f[2]
|
||||
if len(f) >= 2 {
|
||||
if c := unbracket(f[1]); looksLikeCall(c) {
|
||||
if len(f) >= 3 && isGridField(f[2]) {
|
||||
return c, false, f[2]
|
||||
}
|
||||
return c, false, ""
|
||||
}
|
||||
return f[1], false, ""
|
||||
}
|
||||
return "", false, ""
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -142,6 +143,7 @@ func parseHamQTHSearch(body []byte) (Result, error) {
|
||||
QSLVia: s.QSLVia,
|
||||
Web: s.Web,
|
||||
ImageURL: s.Picture,
|
||||
RDA: rdaRef(s.Oblast, s.District),
|
||||
}
|
||||
r.Lat, _ = strconv.ParseFloat(s.Latitude, 64)
|
||||
r.Lon, _ = strconv.ParseFloat(s.Longitude, 64)
|
||||
@@ -151,6 +153,27 @@ func parseHamQTHSearch(body []byte) (Result, error) {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// rdaRefRe matches a Russian district reference: two letters, a hyphen, two
|
||||
// digits. Searched for rather than anchored, because the field is filled by
|
||||
// hand and arrives as "KE-29", "KE-29 Kemerovskaya obl." or with stray spaces
|
||||
// depending on which editor the operator used.
|
||||
var rdaRefRe = regexp.MustCompile(`\b([A-Z]{2}-[0-9]{2})\b`)
|
||||
|
||||
// rdaRef extracts a district reference from HamQTH's oblast/district fields.
|
||||
//
|
||||
// Strict about the SHAPE and silent otherwise: a value that is not a district
|
||||
// reference — an oblast name, a postal code, free text — must yield nothing.
|
||||
// Writing it into the award anyway would create a reference no list contains,
|
||||
// which counts for zero and has to be found and removed by hand later.
|
||||
func rdaRef(fields ...string) string {
|
||||
for _, f := range fields {
|
||||
if m := rdaRefRe.FindStringSubmatch(strings.ToUpper(strings.TrimSpace(f))); m != nil {
|
||||
return m[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *HamQTH) get(ctx context.Context, u string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
|
||||
if err != nil {
|
||||
@@ -191,15 +214,19 @@ type hamqthSearch struct {
|
||||
AdrCountry string `xml:"adr_country"`
|
||||
USState string `xml:"us_state"`
|
||||
USCounty string `xml:"us_county"`
|
||||
Grid string `xml:"grid"`
|
||||
Latitude string `xml:"latitude"`
|
||||
Longitude string `xml:"longitude"`
|
||||
DXCC string `xml:"adif"` // HamQTH exposes the ADIF/DXCC number under <adif>
|
||||
CQ string `xml:"cq"`
|
||||
ITU string `xml:"itu"`
|
||||
Continent string `xml:"continent"`
|
||||
Email string `xml:"email"`
|
||||
QSLVia string `xml:"qsl_via"`
|
||||
// Oblast is HamQTH's Russian district field; District is its generic one.
|
||||
// Both are read because operators fill whichever their editor showed them.
|
||||
Oblast string `xml:"oblast"`
|
||||
District string `xml:"district"`
|
||||
Grid string `xml:"grid"`
|
||||
Latitude string `xml:"latitude"`
|
||||
Longitude string `xml:"longitude"`
|
||||
DXCC string `xml:"adif"` // HamQTH exposes the ADIF/DXCC number under <adif>
|
||||
CQ string `xml:"cq"`
|
||||
ITU string `xml:"itu"`
|
||||
Continent string `xml:"continent"`
|
||||
Email string `xml:"email"`
|
||||
QSLVia string `xml:"qsl_via"`
|
||||
// AdrName is the full postal name. Many records carry it and no <name> at
|
||||
// all, so building the name from <nick> + <name> silently kept the first
|
||||
// name and dropped the rest.
|
||||
|
||||
@@ -45,6 +45,17 @@ type Result struct {
|
||||
// and nothing used to read it — and since no live activation feed exists for
|
||||
// IOTA the way it does for POTA, the callbook record is the practical source.
|
||||
IOTA string `json:"iota,omitempty"`
|
||||
// RDA is the Russian District Award reference (KE-29). HamQTH publishes it
|
||||
// as <oblast>, described in their own documentation as "something like
|
||||
// district (Russian stations)"; QRZ.com has no equivalent — its county and
|
||||
// state fields are USA-only.
|
||||
//
|
||||
// It is the CURRENT district, which is why it may only ever be applied to a
|
||||
// contact being made now. A Russian station can move, and stamping today's
|
||||
// district on a three-year-old QSO rewrites a correct contact into a wrong
|
||||
// one. DXLab solves the general case with dated activity records; we solve
|
||||
// the case that matters by never dating anything but the present.
|
||||
RDA string `json:"rda,omitempty"`
|
||||
// Zip is the postal code. HamQTH and QRZ both send one.
|
||||
Zip string `json:"zip,omitempty"`
|
||||
ImageURL string `json:"image_url,omitempty"` // profile picture URL
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package lookup
|
||||
|
||||
import "testing"
|
||||
|
||||
// The district only ever reaches the log if it has the RIGHT SHAPE. HamQTH's
|
||||
// oblast field is filled by hand, so it arrives in whatever form the operator's
|
||||
// editor encouraged — and anything that is not a district reference must yield
|
||||
// nothing at all rather than a reference no list contains.
|
||||
func TestRDARef(t *testing.T) {
|
||||
for _, c := range []struct{ in, want string }{
|
||||
{"KE-29", "KE-29"},
|
||||
{"ke-29", "KE-29"},
|
||||
{" KE-29 ", "KE-29"},
|
||||
{"KE-29 Kemerovskaya obl.", "KE-29"},
|
||||
{"Kemerovskaya obl. (KE-29)", "KE-29"},
|
||||
{"", ""},
|
||||
{"Kemerovskaya oblast", ""}, // a name, not a reference
|
||||
{"KE", ""}, // the oblast alone carries no district
|
||||
{"KE-1", ""}, // one digit is not an RDA
|
||||
{"KE-291", ""}, // three digits is not one either
|
||||
{"650000", ""}, // a postal code
|
||||
{"EU-064", ""}, // an IOTA reference in the wrong field
|
||||
} {
|
||||
if got := rdaRef(c.in); got != c.want {
|
||||
t.Errorf("rdaRef(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
// oblast wins over district; an empty first field falls through to the next.
|
||||
if got := rdaRef("", "MO-19"); got != "MO-19" {
|
||||
t.Errorf("fallback to district = %q, want MO-19", got)
|
||||
}
|
||||
if got := rdaRef("KE-29", "MO-19"); got != "KE-29" {
|
||||
t.Errorf("oblast must win, got %q", got)
|
||||
}
|
||||
}
|
||||
+38
-2
@@ -2301,7 +2301,7 @@ func (r *Repo) IterateAll(ctx context.Context, fn func(QSO) error) error {
|
||||
const awardCols = `id, callsign, qso_date, band, freq_hz, mode, ` +
|
||||
`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`
|
||||
`qsl_rcvd, lotw_rcvd, eqsl_rcvd, extras_json, award_refs`
|
||||
|
||||
// IterateForAwards streams a lightweight projection of every QSO — only the
|
||||
// fields award computation reads (see awardCols). All other QSO fields are left
|
||||
@@ -2326,6 +2326,37 @@ func (r *Repo) IterateForAwards(ctx context.Context, fn func(QSO) error) error {
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// IterateForAwardsWithRef streams only the QSOs whose stored award_refs already
|
||||
// mention one award code.
|
||||
//
|
||||
// For a change that RELABELS an award rather than re-matching it — the grid
|
||||
// column switching from the reference to its description — the rows that can
|
||||
// possibly change are exactly these. On a 30 000-QSO log with 2 488 French
|
||||
// departments in it, that is the difference between reading the whole logbook
|
||||
// and reading a twelfth of it.
|
||||
//
|
||||
// Matched with LIKE on the JSON key, which is exact enough: award_refs is a
|
||||
// compact object written by us, so a code appears as "DDFM": and nowhere else.
|
||||
func (r *Repo) IterateForAwardsWithRef(ctx context.Context, code string, fn func(QSO) error) error {
|
||||
like := "%\"" + strings.ToUpper(strings.TrimSpace(code)) + "\":%"
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT `+awardCols+` FROM qso WHERE award_refs LIKE ? ORDER BY qso_date ASC, id ASC`, like)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query qso (awards, one code): %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
q, err := scanAwardQSO(rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// scanAwardQSO reads one row produced by awardCols into a QSO, populating only
|
||||
// the award-relevant fields. Column order MUST match awardCols.
|
||||
func scanAwardQSO(s scanner) (QSO, error) {
|
||||
@@ -2341,16 +2372,21 @@ func scanAwardQSO(s scanner) (QSO, error) {
|
||||
comment, notes sql.NullString
|
||||
qslRcvd, lotwRcvd, eqslRcvd sql.NullString
|
||||
extrasJSON sql.NullString
|
||||
awardRefs sql.NullString
|
||||
)
|
||||
if err := s.Scan(
|
||||
&q.ID, &q.Callsign, &qsoDateStr, &q.Band, &freqHz, &q.Mode,
|
||||
&grid, &vucc, &country, &state, &cnty, &cont, &cqz, &ituz, &dxcc, &iotaRef, &sota, &pota,
|
||||
&name, &qth, &address, &comment, ¬es,
|
||||
&qslRcvd, &lotwRcvd, &eqslRcvd, &extrasJSON,
|
||||
&qslRcvd, &lotwRcvd, &eqslRcvd, &extrasJSON, &awardRefs,
|
||||
); err != nil {
|
||||
return QSO{}, fmt.Errorf("scan qso (awards): %w", err)
|
||||
}
|
||||
q.QSODate = parseTimeLoose(qsoDateStr)
|
||||
// award_refs rides along so a recompute can compare what it produced against
|
||||
// what is stored WITHOUT falling back to the full-record scan — the whole
|
||||
// point of this projection.
|
||||
q.AwardRefs = awardRefs.String
|
||||
if freqHz.Valid {
|
||||
v := freqHz.Int64
|
||||
q.FreqHz = &v
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// Package rda resolves a Russian callsign to the RDA district it operated from
|
||||
// ON A GIVEN DAY.
|
||||
//
|
||||
// # Why the day matters
|
||||
//
|
||||
// A Russian station can move district, and an expedition operates from a dozen
|
||||
// in a fortnight. Asking "which district is this callsign in?" therefore has no
|
||||
// single answer, and answering it with today's district would rewrite a correct
|
||||
// three-year-old contact into a false one — silently, with nothing downstream
|
||||
// able to tell. Every lookup here takes a date for that reason.
|
||||
//
|
||||
// # The data
|
||||
//
|
||||
// Compiled from RDA.mdb, the reference database distributed for the award
|
||||
// (58 188 callsigns). Two shapes live in its RDA column and they mean different
|
||||
// things:
|
||||
//
|
||||
// KK-08 the district the station operates from,
|
||||
// with no history — the compiler knows of
|
||||
// no change for this call
|
||||
// <2021-07-29,2021-07-29,TO-16>… dated activity records, one per period
|
||||
//
|
||||
// 43 294 callsigns are of the first kind and 14 894 of the second. A dated
|
||||
// record covering the QSO day is an ANSWER; a bare current district is an
|
||||
// assumption, true for a station that never moved and wrong for one that did.
|
||||
// Lookup reports which it gave, and the caller decides whether an assumption is
|
||||
// good enough — see Match.Dated.
|
||||
//
|
||||
// # Regenerating
|
||||
//
|
||||
// There is no updater: the source is an Access database, and reading one needs
|
||||
// cgo, which this project does not use. It is converted once, by hand, and the
|
||||
// result embedded. To redo it with a newer RDA.mdb (PowerShell, needs the ACE
|
||||
// OLEDB provider that ships with Office):
|
||||
//
|
||||
// $conn = New-Object System.Data.OleDb.OleDbConnection(
|
||||
// "Provider=Microsoft.ACE.OLEDB.12.0;Data Source=RDA.mdb;")
|
||||
// SELECT CallSign, RDA, DXCCID FROM [RDACallsigns] ORDER BY CallSign
|
||||
//
|
||||
// writing one line per callsign as
|
||||
//
|
||||
// CALL;DXCC;CURRENT;YYYYMMDD,YYYYMMDD,DIST;YYYYMMDD,YYYYMMDD,DIST…
|
||||
//
|
||||
// where CURRENT may be empty, then gzip it to rdadb.txt.gz.
|
||||
package rda
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"embed"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed rdadb.txt.gz
|
||||
var files embed.FS
|
||||
|
||||
// span is one dated activity record: the district, and the inclusive day range
|
||||
// it applies to as YYYYMMDD integers (comparing those is comparing dates, with
|
||||
// no timezone to get wrong).
|
||||
type span struct {
|
||||
from, to uint32
|
||||
district string
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
dxcc int
|
||||
current string // district with no history; "" when only dated records exist
|
||||
spans []span
|
||||
}
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
byCal map[string]entry
|
||||
loadN int
|
||||
)
|
||||
|
||||
// Match is what a lookup found.
|
||||
type Match struct {
|
||||
// District is the RDA reference, e.g. "KE-29".
|
||||
District string
|
||||
// Dated is true when a dated activity record covered the day asked for.
|
||||
//
|
||||
// False means the district came from the callsign's CURRENT entry, which
|
||||
// carries no dates at all. For the great majority of stations that is the
|
||||
// same thing — the database records a history precisely for the ones that
|
||||
// moved — but it is an assumption and not a fact, and a caller writing into
|
||||
// a log years old should be told which of the two it is getting.
|
||||
Dated bool
|
||||
// DXCC is the entity the database files the callsign under (15, 54 or 126).
|
||||
DXCC int
|
||||
}
|
||||
|
||||
func load() {
|
||||
byCal = make(map[string]entry, 60000)
|
||||
f, err := files.Open("rdadb.txt.gz")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
zr, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer zr.Close()
|
||||
sc := bufio.NewScanner(zr)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) // one expedition's history is long
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(line, ";")
|
||||
if len(parts) < 3 {
|
||||
continue
|
||||
}
|
||||
e := entry{current: parts[2]}
|
||||
e.dxcc, _ = strconv.Atoi(parts[1])
|
||||
for _, p := range parts[3:] {
|
||||
f := strings.Split(p, ",")
|
||||
if len(f) != 3 {
|
||||
continue
|
||||
}
|
||||
from, err1 := strconv.ParseUint(f[0], 10, 32)
|
||||
to, err2 := strconv.ParseUint(f[1], 10, 32)
|
||||
if err1 != nil || err2 != nil {
|
||||
continue
|
||||
}
|
||||
e.spans = append(e.spans, span{from: uint32(from), to: uint32(to), district: f[2]})
|
||||
}
|
||||
byCal[parts[0]] = e
|
||||
loadN++
|
||||
}
|
||||
}
|
||||
|
||||
// Count returns how many callsigns the database holds (0 before the first use).
|
||||
func Count() int {
|
||||
once.Do(load)
|
||||
return loadN
|
||||
}
|
||||
|
||||
// Lookup resolves a callsign on a given day.
|
||||
//
|
||||
// The match is EXACT on the callsign, deliberately. A suffix is not decoration:
|
||||
// RA3YG/P is somewhere other than RA3YG by definition, and quietly falling back
|
||||
// to the base call would answer with the home district for a contact made from
|
||||
// a field — which is the precise error this package exists to avoid.
|
||||
func Lookup(call string, day time.Time) (Match, bool) {
|
||||
once.Do(load)
|
||||
c := strings.ToUpper(strings.TrimSpace(call))
|
||||
if c == "" {
|
||||
return Match{}, false
|
||||
}
|
||||
e, ok := byCal[c]
|
||||
if !ok {
|
||||
return Match{}, false
|
||||
}
|
||||
d := ymd(day)
|
||||
// A dated record wins whenever one covers the day. Several can overlap — an
|
||||
// expedition working two districts in a day — and the FIRST is taken rather
|
||||
// than a guess between them: the database lists them in the order its
|
||||
// compiler recorded them, and inventing a tie-break here would be inventing
|
||||
// data.
|
||||
if d != 0 {
|
||||
for _, s := range e.spans {
|
||||
if d >= s.from && d <= s.to {
|
||||
return Match{District: s.district, Dated: true, DXCC: e.dxcc}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if e.current != "" {
|
||||
return Match{District: e.current, Dated: false, DXCC: e.dxcc}, true
|
||||
}
|
||||
// Dated records exist but none covers this day. That is an answer in itself
|
||||
// — the station was somewhere the database does not know about — and it is
|
||||
// reported as "not found" rather than by handing back a neighbouring period.
|
||||
return Match{}, false
|
||||
}
|
||||
|
||||
// ymd turns a time into YYYYMMDD, or 0 for the zero time.
|
||||
func ymd(t time.Time) uint32 {
|
||||
if t.IsZero() {
|
||||
return 0
|
||||
}
|
||||
t = t.UTC()
|
||||
return uint32(t.Year())*10000 + uint32(t.Month())*100 + uint32(t.Day())
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package rda
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func day(s string) time.Time {
|
||||
t, err := time.Parse("2006-01-02", s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
if n := Count(); n < 50000 {
|
||||
t.Fatalf("loaded %d callsigns, want the whole database (~58k)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A station with no history: the current district, reported as an assumption.
|
||||
func TestCurrentDistrict(t *testing.T) {
|
||||
m, ok := Lookup("R0AA", day("2020-05-01"))
|
||||
if !ok || m.District != "KK-08" {
|
||||
t.Fatalf("R0AA = %+v ok=%v, want KK-08", m, ok)
|
||||
}
|
||||
if m.Dated {
|
||||
t.Errorf("a district with no date range must not be reported as dated")
|
||||
}
|
||||
}
|
||||
|
||||
// An expedition: the day decides, and a day outside every period is NOT
|
||||
// answered with a neighbouring one.
|
||||
func TestDatedDistrict(t *testing.T) {
|
||||
m, ok := Lookup("4K4/EK250RA", day("1991-09-15"))
|
||||
if !ok || m.District != "CK-05" || !m.Dated {
|
||||
t.Fatalf("got %+v ok=%v, want CK-05 dated", m, ok)
|
||||
}
|
||||
m, ok = Lookup("4K4/EK250RA", day("1991-08-07"))
|
||||
if !ok || m.District != "CK-08" {
|
||||
t.Fatalf("got %+v ok=%v, want CK-08 on the one-day period", m, ok)
|
||||
}
|
||||
if _, ok := Lookup("4K4/EK250RA", day("2020-01-01")); ok {
|
||||
t.Errorf("a day outside every recorded period must find nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// The callsign is matched exactly: a suffix means somewhere else.
|
||||
func TestExactCallsign(t *testing.T) {
|
||||
if _, ok := Lookup("R0AA/P", day("2020-05-01")); ok {
|
||||
t.Errorf("R0AA/P must not resolve to R0AA's district")
|
||||
}
|
||||
if _, ok := Lookup("", day("2020-05-01")); ok {
|
||||
t.Errorf("an empty callsign must find nothing")
|
||||
}
|
||||
if _, ok := Lookup("F4BPO", day("2020-05-01")); ok {
|
||||
t.Errorf("a non-Russian callsign must find nothing")
|
||||
}
|
||||
// Case and stray spaces are the operator's, not the data's.
|
||||
if m, ok := Lookup(" r0aa ", day("2020-05-01")); !ok || m.District != "KK-08" {
|
||||
t.Errorf("lookup must be case- and space-insensitive, got %+v ok=%v", m, ok)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -187,6 +187,13 @@ func (s *Server) selfTest() {
|
||||
// what Stop() does on every settings save. Seen in the field: a K3 sat in
|
||||
// transmit for 29 s, until the CAT link happened to be rebuilt.
|
||||
//
|
||||
// Keyed reports whether a client currently has the rig transmitting.
|
||||
//
|
||||
// Asked before the server is torn down: pulling the socket out from under
|
||||
// WSJT-X mid-over is the one moment that costs a QSO — and, on the log that
|
||||
// prompted this, sometimes the whole application.
|
||||
func (s *Server) Keyed() bool { return s.ptt.Load() }
|
||||
|
||||
// Swap makes this once-only, so the Stop() path and the per-connection defer it
|
||||
// triggers can both call it without double-unkeying.
|
||||
func (s *Server) releasePTT(why string) {
|
||||
|
||||
@@ -190,6 +190,14 @@ func (s *Server) Start() error {
|
||||
// pttKnown is cleared whatever happens: after an emergency unkey the radio's
|
||||
// state is a guess, and the next command must reach it rather than be dismissed
|
||||
// as a repeat.
|
||||
// Keyed reports whether a client currently has the rig transmitting. See the
|
||||
// rigctld server's own Keyed for why the caller wants to know.
|
||||
func (s *Server) Keyed() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.ptt
|
||||
}
|
||||
|
||||
func (s *Server) releasePTT(why string) {
|
||||
s.mu.Lock()
|
||||
keyed := s.ptt
|
||||
|
||||
Reference in New Issue
Block a user