feat: Winkeyer
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
package adif
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Loggers (notably Log4OM's UDP/exported ADIF) sometimes declare a field
|
||||
// length as the CHARACTER count instead of the UTF-8 byte count, truncating
|
||||
// multibyte values mid-rune. The parser must recover the full value.
|
||||
func TestCharCountLengthRepair(t *testing.T) {
|
||||
cases := []struct{ name, wantQTH, wantName, adi string }{
|
||||
{
|
||||
name: "latin",
|
||||
wantQTH: "Tóalmás", // 7 chars / 9 bytes, declared 7
|
||||
wantName: "Laci Budai",
|
||||
adi: "<EOH>\n<CALL:5>HA5XY<QTH:7>Tóalmás<NAME:10>Laci Budai<EOR>\n",
|
||||
},
|
||||
{
|
||||
name: "cyrillic",
|
||||
wantQTH: "Дзержинск", // 9 chars / 18 bytes, declared 9
|
||||
wantName: "Александр Чайка", // 15 chars / 29 bytes, declared 15
|
||||
adi: "<EOH>\n<CALL:6>UA3TFS<NAME:15>Александр Чайка<QTH:9>Дзержинск<EOR>\n",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var got Record
|
||||
if err := Parse(strings.NewReader(c.adi), func(r Record) error { got = r; return nil }); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got["qth"] != c.wantQTH {
|
||||
t.Errorf("qth = %q, want %q", got["qth"], c.wantQTH)
|
||||
}
|
||||
if got["name"] != c.wantName {
|
||||
t.Errorf("name = %q, want %q", got["name"], c.wantName)
|
||||
}
|
||||
if !utf8.ValidString(got["name"]) || !utf8.ValidString(got["qth"]) {
|
||||
t.Errorf("result not valid UTF-8: name=%q qth=%q", got["name"], got["qth"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+19
-4
@@ -27,6 +27,13 @@ type Exporter struct {
|
||||
// AppName / AppVersion populate the ADIF header comments. Optional.
|
||||
AppName string
|
||||
AppVersion string
|
||||
|
||||
// IncludeAppFields controls whether application-specific fields (ADIF
|
||||
// "APP_<programid>_<name>" tags, e.g. Log4OM's APP_LOG4OM_* or our own
|
||||
// OpsLog extensions) are written. Leave false for a clean standard-ADIF
|
||||
// export destined for another logger; set true for a full OpsLog→OpsLog
|
||||
// round-trip that preserves everything.
|
||||
IncludeAppFields bool
|
||||
}
|
||||
|
||||
// ExportFile creates path (overwriting if it exists) and writes every QSO.
|
||||
@@ -70,7 +77,7 @@ func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
|
||||
|
||||
count := 0
|
||||
err := e.Repo.IterateAll(ctx, func(q qso.QSO) error {
|
||||
writeRecord(bw, q)
|
||||
writeRecord(bw, q, e.IncludeAppFields)
|
||||
count++
|
||||
return nil
|
||||
})
|
||||
@@ -84,7 +91,8 @@ func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
|
||||
func SingleRecordADIF(q qso.QSO) string {
|
||||
var b strings.Builder
|
||||
bw := bufio.NewWriter(&b)
|
||||
writeRecord(bw, q)
|
||||
// Uploads target other services — keep it standard (no app-specific tags).
|
||||
writeRecord(bw, q, false)
|
||||
bw.Flush()
|
||||
return b.String()
|
||||
}
|
||||
@@ -93,7 +101,7 @@ func SingleRecordADIF(q qso.QSO) string {
|
||||
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
||||
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
||||
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
|
||||
func writeRecord(bw *bufio.Writer, q qso.QSO) {
|
||||
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool) {
|
||||
// --- Core ---
|
||||
writeField(bw, "CALL", q.Callsign)
|
||||
|
||||
@@ -218,8 +226,15 @@ func writeRecord(bw *bufio.Writer, q qso.QSO) {
|
||||
writeField(bw, "NOTES", q.Notes)
|
||||
|
||||
// --- Extras (unpromoted ADIF fields preserved verbatim) ---
|
||||
// In standard mode we drop application-specific tags (APP_*) so the file
|
||||
// stays portable to other loggers; in full mode they're kept for a
|
||||
// lossless OpsLog round-trip.
|
||||
for k, v := range q.Extras {
|
||||
writeField(bw, strings.ToUpper(k), v)
|
||||
tag := strings.ToUpper(k)
|
||||
if !includeApp && strings.HasPrefix(tag, "APP_") {
|
||||
continue
|
||||
}
|
||||
writeField(bw, tag, v)
|
||||
}
|
||||
|
||||
bw.WriteString("<EOR>\n")
|
||||
|
||||
+90
-1
@@ -20,6 +20,7 @@ import (
|
||||
type ImportResult struct {
|
||||
Total int `json:"total"` // records found in the file
|
||||
Imported int `json:"imported"` // successfully inserted
|
||||
Updated int `json:"updated"` // existing QSOs refreshed (update-duplicates mode)
|
||||
Skipped int `json:"skipped"` // dropped (missing required fields)
|
||||
Duplicates int `json:"duplicates"` // matched an existing or earlier-in-file QSO
|
||||
DuplicateSamples []string `json:"duplicate_samples"` // up to maxDuplicateSamples human-readable IDs of skipped duplicates
|
||||
@@ -36,6 +37,19 @@ type Importer struct {
|
||||
Repo *qso.Repo
|
||||
BatchSize int // 0 → 500
|
||||
SkipDuplicates bool // when true, records matching an existing or earlier-in-file QSO are skipped; otherwise all are inserted
|
||||
// UpdateDuplicates, when true, takes precedence over SkipDuplicates:
|
||||
// a record matching an existing QSO MERGES its non-empty fields onto
|
||||
// that QSO (refreshes QSL/confirmation statuses on re-sync) instead of
|
||||
// being skipped or re-inserted.
|
||||
UpdateDuplicates bool
|
||||
// Enrich, when set, is called on each parsed QSO before dedup/insert.
|
||||
// Used to recompute country / zones from cty.dat so a bad COUNTRY in the
|
||||
// source file (common with contest loggers) is corrected on the way in.
|
||||
Enrich func(*qso.QSO)
|
||||
// OnProgress, when set, is called periodically with (processed, total)
|
||||
// record counts so the UI can show a progress bar. total is an estimate
|
||||
// from counting <EOR> tags up front.
|
||||
OnProgress func(processed, total int)
|
||||
}
|
||||
|
||||
// ImportFile reads the file at path and imports it into the repo. The
|
||||
@@ -62,6 +76,14 @@ func (im *Importer) ImportFile(ctx context.Context, path string) (ImportResult,
|
||||
// Windows-1252 (É is one byte 0xC9). Pre-decoding to UTF-8 would make É
|
||||
// two bytes, and the parser reading 7 bytes after the tag would chop the
|
||||
// É in half → "YAOUND" + an orphan 0xC3 byte → "YAOUND�" after JSON.
|
||||
// ValueDecoderFor returns the per-field byte decoder appropriate for a raw
|
||||
// ADIF payload: identity when it's valid UTF-8, otherwise a Windows-1252
|
||||
// decoder. Exposed so non-file ingest paths (UDP auto-log from Log4OM /
|
||||
// JTAlert) transcode accented NAME/QTH fields the same way file import does.
|
||||
func ValueDecoderFor(data []byte) func([]byte) string {
|
||||
return pickValueDecoder(data)
|
||||
}
|
||||
|
||||
func pickValueDecoder(data []byte) func([]byte) string {
|
||||
if utf8.Valid(data) {
|
||||
return nil // identity
|
||||
@@ -97,6 +119,15 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
||||
res := ImportResult{}
|
||||
batch := make([]qso.QSO, 0, im.BatchSize)
|
||||
|
||||
// Up-front record-count estimate (count <EOR> tags, case-insensitive) so
|
||||
// the UI progress bar has a denominator. Cheap single scan.
|
||||
total := countEOR(data)
|
||||
reportProgress := func(force bool) {
|
||||
if im.OnProgress != nil && (force || res.Total%200 == 0) {
|
||||
im.OnProgress(res.Total, total)
|
||||
}
|
||||
}
|
||||
|
||||
// One upfront query for every existing dedup key — cheaper than N
|
||||
// per-record EXISTS calls. The same map gets new keys appended as we
|
||||
// import so duplicates inside the file are caught too. Loaded
|
||||
@@ -107,6 +138,16 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
||||
return res, fmt.Errorf("load dedupe keys: %w", err)
|
||||
}
|
||||
|
||||
// Update-duplicates mode needs the existing row's ID per key so it can
|
||||
// fetch, merge and write it back. Loaded only when needed (extra query).
|
||||
var keyIDs map[string]int64
|
||||
if im.UpdateDuplicates {
|
||||
keyIDs, err = im.Repo.DedupeKeyIDs(ctx)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("load dedupe ids: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
flush := func() error {
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
@@ -119,6 +160,7 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
||||
|
||||
err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error {
|
||||
res.Total++
|
||||
reportProgress(false)
|
||||
q, ok := recordToQSO(rec)
|
||||
if !ok {
|
||||
res.Skipped++
|
||||
@@ -128,6 +170,9 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if im.Enrich != nil {
|
||||
im.Enrich(&q)
|
||||
}
|
||||
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
|
||||
if _, dup := seen[key]; dup {
|
||||
res.Duplicates++
|
||||
@@ -138,6 +183,28 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
||||
q.QSODate.UTC().Format("2006-01-02 15:04"),
|
||||
q.Band, q.Mode))
|
||||
}
|
||||
if im.UpdateDuplicates {
|
||||
if id, ok := keyIDs[key]; ok {
|
||||
existing, gerr := im.Repo.GetByID(ctx, id)
|
||||
if gerr != nil {
|
||||
if len(res.Errors) < maxErrors {
|
||||
res.Errors = append(res.Errors,
|
||||
fmt.Sprintf("record %d (%s): load existing: %v", res.Total, q.Callsign, gerr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
qso.MergeNonZero(&existing, q)
|
||||
if uerr := im.Repo.Update(ctx, existing); uerr != nil {
|
||||
if len(res.Errors) < maxErrors {
|
||||
res.Errors = append(res.Errors,
|
||||
fmt.Sprintf("record %d (%s): update: %v", res.Total, q.Callsign, uerr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
res.Updated++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if im.SkipDuplicates {
|
||||
return nil
|
||||
}
|
||||
@@ -159,9 +226,28 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
||||
if err := flush(); err != nil {
|
||||
return res, err
|
||||
}
|
||||
reportProgress(true) // final 100%
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// countEOR estimates the record count by counting case-insensitive <EOR>
|
||||
// tags. Used only to give the import progress bar a denominator.
|
||||
func countEOR(data []byte) int {
|
||||
n := 0
|
||||
for i := 0; i+4 <= len(data); i++ {
|
||||
if data[i] != '<' {
|
||||
continue
|
||||
}
|
||||
if (data[i+1] == 'e' || data[i+1] == 'E') &&
|
||||
(data[i+2] == 'o' || data[i+2] == 'O') &&
|
||||
(data[i+3] == 'r' || data[i+3] == 'R') &&
|
||||
(i+4 < len(data) && (data[i+4] == '>' || data[i+4] == ':')) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// adifPromoted lists every lowercase ADIF tag that maps to a promoted column.
|
||||
// Anything not in this set ends up in Extras.
|
||||
var adifPromoted = stringSet(
|
||||
@@ -178,7 +264,7 @@ var adifPromoted = stringSet(
|
||||
"age", "lat", "lon", "rig", "ant",
|
||||
// QSL
|
||||
"qsl_sent", "qsl_rcvd",
|
||||
"qslsdate", "qslrdate", "qsl_via", "qslmsg", "qslmsg_rcvd",
|
||||
"qslsdate", "qslrdate", "qsl_via", "qsl_sent_via", "qsl_rcvd_via", "qslmsg", "qslmsg_rcvd",
|
||||
"lotw_qsl_sent", "lotw_qsl_rcvd", "lotw_qslsdate", "lotw_qslrdate",
|
||||
"eqsl_qsl_sent", "eqsl_qsl_rcvd", "eqsl_qslsdate", "eqsl_qslrdate",
|
||||
"clublog_qso_upload_date", "clublog_qso_upload_status",
|
||||
@@ -300,6 +386,9 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
||||
q.QSLSentDate = rec["qslsdate"]
|
||||
q.QSLRcvdDate = rec["qslrdate"]
|
||||
q.QSLVia = rec["qsl_via"]
|
||||
if q.QSLVia == "" { // many loggers (Log4OM) write QSL_SENT_VIA instead
|
||||
q.QSLVia = rec["qsl_sent_via"]
|
||||
}
|
||||
q.QSLMsg = rec["qslmsg"]
|
||||
q.QSLMsgRcvd = rec["qslmsg_rcvd"]
|
||||
q.LOTWSent = rec["lotw_qsl_sent"]
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Record is a single ADIF record. Keys are lowercased field names.
|
||||
@@ -83,6 +84,17 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
||||
if _, err := io.ReadFull(br, val); err != nil {
|
||||
return fmt.Errorf("read field %s: %w", name, err)
|
||||
}
|
||||
// Repair character-count lengths. The ADIF spec says LENGTH is a
|
||||
// byte count, but some loggers (notably Log4OM's UDP "ADIF
|
||||
// message") write the CHARACTER count instead. For UTF-8 values
|
||||
// with accented chars that truncates mid-rune — e.g. "<QTH:7>
|
||||
// Tóalmás" is 9 bytes but says 7, leaving an orphan byte that
|
||||
// renders as "Tóalm�". When we're in UTF-8 mode (no Windows-1252
|
||||
// decoder) and the naive byte read isn't valid UTF-8, keep reading
|
||||
// until the value holds `length` whole runes (or the next tag).
|
||||
if decodeValue == nil && !utf8.Valid(val) {
|
||||
val = extendToRunes(br, val, length)
|
||||
}
|
||||
if headerDone && name != "" {
|
||||
if decodeValue != nil {
|
||||
rec[name] = decodeValue(val)
|
||||
@@ -94,6 +106,37 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
||||
}
|
||||
}
|
||||
|
||||
// extendToRunes recovers a value whose declared length was a character count
|
||||
// rather than a byte count. `have` holds the first `wantRunes` BYTES of the
|
||||
// value, which turned out to be invalid UTF-8 (a multibyte rune was cut). We
|
||||
// append bytes from br until the value holds `wantRunes` complete runes — or
|
||||
// until the next '<' (start of the following tag) / EOF, so we never cross
|
||||
// into another field. Capped so a genuinely-corrupt value can't run away.
|
||||
func extendToRunes(br *bufio.Reader, have []byte, wantRunes int) []byte {
|
||||
const maxExtra = 8 // at most ~4 extra bytes/rune for the few cut runes
|
||||
limit := len(have) + maxExtra*wantRunes + maxExtra
|
||||
for len(have) < limit {
|
||||
// Stop only when the value is complete UTF-8 (no partial trailing
|
||||
// rune) AND holds enough runes. Checking utf8.RuneCount alone is a
|
||||
// trap: a trailing orphan lead byte (e.g. the D0 of a cut Cyrillic
|
||||
// "а") counts as one rune, so the loop would stop one continuation
|
||||
// byte short → "Чайк�". Requiring utf8.Valid forces us to read it.
|
||||
if utf8.Valid(have) && utf8.RuneCount(have) >= wantRunes {
|
||||
break
|
||||
}
|
||||
b, err := br.ReadByte()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if b == '<' {
|
||||
_ = br.UnreadByte() // belongs to the next tag — leave it
|
||||
break
|
||||
}
|
||||
have = append(have, b)
|
||||
}
|
||||
return have
|
||||
}
|
||||
|
||||
// parseSpec splits "callsign:5", "callsign:5:S" or "eor" into name and length.
|
||||
// name is lowercased; length is 0 for control tags or when missing.
|
||||
func parseSpec(spec string) (name string, length int) {
|
||||
|
||||
@@ -53,7 +53,9 @@ const (
|
||||
//
|
||||
// QRZ.com → APIKey, ForceStationCallsign
|
||||
// Club Log → Email, Password, Callsign, APIKey
|
||||
// LoTW → TQSLPath, StationLocation, KeyPassword (signs+uploads via TQSL)
|
||||
// LoTW → TQSLPath, StationLocation, ForceStationCallsign, KeyPassword
|
||||
// (signs+uploads via TQSL; ForceStationCallsign overrides
|
||||
// STATION_CALLSIGN so one cert can sign F4BPO / F4BPO/P / TM2Q)
|
||||
//
|
||||
// AutoUpload + UploadMode are common to all (timing is per-service, so the
|
||||
// user can run e.g. Club Log immediate and QRZ delayed).
|
||||
@@ -63,7 +65,7 @@ type ServiceConfig struct {
|
||||
Username string `json:"username"` // LoTW website login (for confirmation download)
|
||||
Password string `json:"password"` // Club Log account / LoTW website password
|
||||
Callsign string `json:"callsign"` // Club Log logbook (owner) callsign
|
||||
ForceStationCallsign string `json:"force_station_callsign"` // QRZ
|
||||
ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN
|
||||
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
||||
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
||||
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional)
|
||||
|
||||
@@ -196,7 +196,8 @@ func (m *Manager) flushLoTWBatch(ids []int64, cfg ServiceConfig) int {
|
||||
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceLoTW, id) {
|
||||
continue
|
||||
}
|
||||
if rec, ok := m.deps.BuildADIF(id, ""); ok {
|
||||
// Override STATION_CALLSIGN so /P etc. signs against the base cert.
|
||||
if rec, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign); ok {
|
||||
records = append(records, rec)
|
||||
kept = append(kept, id)
|
||||
}
|
||||
@@ -259,8 +260,9 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
||||
}
|
||||
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
|
||||
case ServiceLoTW:
|
||||
// LoTW signs the QSO's own station call via TQSL — no override.
|
||||
record, ok := m.deps.BuildADIF(id, "")
|
||||
// LoTW signs via TQSL; an optional force-call overrides STATION_CALLSIGN
|
||||
// so the same cert can sign F4BPO, F4BPO/P, TM2Q… per profile.
|
||||
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
||||
if !ok {
|
||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||
return false
|
||||
|
||||
+45
-44
@@ -6,10 +6,39 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MergeNonZero copies every non-zero field of src onto dst. Zero-value src
|
||||
// fields (empty string, nil pointer, zero time, zero number) are skipped so
|
||||
// existing data is preserved. Maps (Extras) are merged key-by-key rather than
|
||||
// replaced. Because an imported QSO has ID==0 and CreatedAt zero, dst's
|
||||
// identity is naturally preserved. Used by the importer's "update duplicates"
|
||||
// mode so re-importing an ADIF refreshes QSL/confirmation statuses without
|
||||
// clobbering fields the source file doesn't carry.
|
||||
func MergeNonZero(dst *QSO, src QSO) {
|
||||
dv := reflect.ValueOf(dst).Elem()
|
||||
sv := reflect.ValueOf(src)
|
||||
for i := 0; i < dv.NumField(); i++ {
|
||||
df, sf := dv.Field(i), sv.Field(i)
|
||||
if !df.CanSet() || sf.IsZero() {
|
||||
continue
|
||||
}
|
||||
if sf.Kind() == reflect.Map {
|
||||
if df.IsNil() {
|
||||
df.Set(reflect.MakeMap(sf.Type()))
|
||||
}
|
||||
for _, k := range sf.MapKeys() {
|
||||
df.SetMapIndex(k, sf.MapIndex(k))
|
||||
}
|
||||
continue
|
||||
}
|
||||
df.Set(sf)
|
||||
}
|
||||
}
|
||||
|
||||
// QSO represents a contact. Fields are aligned on ADIF naming for
|
||||
// import/export. Pointers are used to distinguish "absent" from "zero".
|
||||
// Anything in ADIF that is not a promoted column lands in Extras.
|
||||
@@ -569,7 +598,7 @@ type WorkedBefore struct {
|
||||
Bands []string `json:"bands"` // distinct bands for this call
|
||||
Modes []string `json:"modes"` // distinct modes for this call
|
||||
BandModes []BandMode `json:"band_modes"` // distinct (band, mode) pairs
|
||||
Entries []WorkedEntry `json:"entries"` // up to maxWorkedEntries most recent
|
||||
Entries []QSO `json:"entries"` // up to maxWorkedEntries most recent (full records)
|
||||
|
||||
// --- Per-DXCC entity (populated when DXCC is known) ---
|
||||
DXCC int `json:"dxcc,omitempty"`
|
||||
@@ -615,19 +644,6 @@ type BandMode struct {
|
||||
|
||||
// WorkedEntry is one prior contact row, lean enough to ship to the UI for
|
||||
// rendering a recent-contacts mini-list.
|
||||
type WorkedEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
QSODate time.Time `json:"qso_date"`
|
||||
Band string `json:"band"`
|
||||
Mode string `json:"mode"`
|
||||
RSTSent string `json:"rst_sent,omitempty"`
|
||||
RSTRcvd string `json:"rst_rcvd,omitempty"`
|
||||
QSLSent string `json:"qsl_sent,omitempty"`
|
||||
QSLRcvd string `json:"qsl_rcvd,omitempty"`
|
||||
LOTWSent string `json:"lotw_sent,omitempty"`
|
||||
LOTWRcvd string `json:"lotw_rcvd,omitempty"`
|
||||
}
|
||||
|
||||
const maxWorkedEntries = 50
|
||||
|
||||
// WorkedBefore returns aggregated history at both callsign and DXCC level.
|
||||
@@ -640,7 +656,7 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
||||
Bands: []string{},
|
||||
Modes: []string{},
|
||||
BandModes: []BandMode{},
|
||||
Entries: []WorkedEntry{},
|
||||
Entries: []QSO{},
|
||||
DXCCBands: []string{},
|
||||
DXCCModes: []string{},
|
||||
DXCCBandModes: []BandMode{},
|
||||
@@ -655,9 +671,9 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
||||
return wb, fmt.Errorf("count worked: %w", err)
|
||||
}
|
||||
if wb.Count > 0 {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, qso_date, band, mode, rst_sent, rst_rcvd,
|
||||
qsl_sent, qsl_rcvd, lotw_sent, lotw_rcvd
|
||||
// Pull the full QSO records (same columns as the Recent QSOs list) so
|
||||
// the Worked-before grid can offer the same rich column picker.
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT `+selectCols+`
|
||||
FROM qso WHERE upper(trim(callsign)) = ?
|
||||
ORDER BY qso_date DESC, id DESC
|
||||
LIMIT ?`, wb.Callsign, maxWorkedEntries)
|
||||
@@ -668,40 +684,25 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
||||
modesSet := map[string]struct{}{}
|
||||
bmSet := map[string]BandMode{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
e WorkedEntry
|
||||
dateStr string
|
||||
band, mode sql.NullString
|
||||
rstS, rstR, qslS, qslR, lotwS, lotwR sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&e.ID, &dateStr, &band, &mode,
|
||||
&rstS, &rstR, &qslS, &qslR, &lotwS, &lotwR); err != nil {
|
||||
q, err := scanQSO(rows)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return wb, fmt.Errorf("scan worked: %w", err)
|
||||
}
|
||||
e.QSODate = parseTimeLoose(dateStr)
|
||||
e.Band = band.String
|
||||
e.Mode = mode.String
|
||||
e.RSTSent = rstS.String
|
||||
e.RSTRcvd = rstR.String
|
||||
e.QSLSent = qslS.String
|
||||
e.QSLRcvd = qslR.String
|
||||
e.LOTWSent = lotwS.String
|
||||
e.LOTWRcvd = lotwR.String
|
||||
wb.Entries = append(wb.Entries, e)
|
||||
if e.Band != "" {
|
||||
bandsSet[e.Band] = struct{}{}
|
||||
wb.Entries = append(wb.Entries, q)
|
||||
if q.Band != "" {
|
||||
bandsSet[q.Band] = struct{}{}
|
||||
}
|
||||
if e.Mode != "" {
|
||||
modesSet[e.Mode] = struct{}{}
|
||||
if q.Mode != "" {
|
||||
modesSet[q.Mode] = struct{}{}
|
||||
}
|
||||
if e.Band != "" && e.Mode != "" {
|
||||
bmSet[e.Band+"|"+e.Mode] = BandMode{Band: e.Band, Mode: e.Mode}
|
||||
if q.Band != "" && q.Mode != "" {
|
||||
bmSet[q.Band+"|"+q.Mode] = BandMode{Band: q.Band, Mode: q.Mode}
|
||||
}
|
||||
if wb.Last.IsZero() {
|
||||
wb.Last = e.QSODate
|
||||
wb.Last = q.QSODate
|
||||
}
|
||||
wb.First = e.QSODate
|
||||
wb.First = q.QSODate
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
// Package winkeyer drives a K1EL WinKeyer (WK1/WK2/WK3) CW keyer over a
|
||||
// serial port — the same hardware Log4OM, N1MM and fldigi talk to. It opens
|
||||
// the host-mode interface, applies the operator's keying parameters (speed,
|
||||
// weight, lead-in/tail, sidetone, paddle mode…), sends arbitrary text as
|
||||
// Morse, and aborts mid-message on demand.
|
||||
//
|
||||
// Protocol reference: K1EL "WinKeyer USB / WK3 Interface Description". The
|
||||
// host link is 1200 baud 8N1. Bytes 0x00–0x1F are commands; printable ASCII
|
||||
// is keyed directly. The device streams status bytes back (busy/idle, the
|
||||
// speed-pot value, and an echo of each character as it's sent) which we
|
||||
// surface to the UI via the OnStatus callback.
|
||||
package winkeyer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
// Mode selects the paddle keying mode (WinKey "mode register" low bits).
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
ModeIambicB Mode = "iambic_b"
|
||||
ModeIambicA Mode = "iambic_a"
|
||||
ModeUltimatic Mode = "ultimatic"
|
||||
ModeBug Mode = "bug"
|
||||
)
|
||||
|
||||
// Config is the keyer configuration the UI persists and applies on connect.
|
||||
type Config struct {
|
||||
Port string `json:"port"` // e.g. "COM6"
|
||||
Baud int `json:"baud"` // 1200 for WK2, also fine for WK3
|
||||
WPM int `json:"wpm"` // 5..99
|
||||
Weight int `json:"weight"` // 10..90, 50 = normal
|
||||
LeadInMs int `json:"lead_in_ms"` // PTT lead-in, 10 ms units sent to device
|
||||
TailMs int `json:"tail_ms"` // PTT tail
|
||||
Ratio int `json:"ratio"` // dah/dit ratio 33..66 (50 = 3:1)
|
||||
Farnsworth int `json:"farnsworth"` // Farnsworth WPM (0 = off)
|
||||
Sidetone int `json:"sidetone_hz"` // 0 = off; else target Hz (mapped to WK code)
|
||||
Mode Mode `json:"mode"` // paddle mode
|
||||
Swap bool `json:"swap"` // swap dit/dah paddles
|
||||
AutoSpace bool `json:"autospace"` // auto letter-space
|
||||
UsePTT bool `json:"use_ptt"` // key PTT (Key/PTT output)
|
||||
SerialEcho bool `json:"serial_echo"` // device echoes sent chars back to host
|
||||
}
|
||||
|
||||
func (c Config) normalised() Config {
|
||||
if c.Baud <= 0 {
|
||||
c.Baud = 1200
|
||||
}
|
||||
if c.WPM < 5 {
|
||||
c.WPM = 20
|
||||
}
|
||||
if c.WPM > 99 {
|
||||
c.WPM = 99
|
||||
}
|
||||
if c.Weight < 10 || c.Weight > 90 {
|
||||
c.Weight = 50
|
||||
}
|
||||
if c.Ratio < 33 || c.Ratio > 66 {
|
||||
c.Ratio = 50
|
||||
}
|
||||
switch c.Mode {
|
||||
case ModeIambicA, ModeIambicB, ModeUltimatic, ModeBug:
|
||||
default:
|
||||
c.Mode = ModeIambicB
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Status is pushed to the UI whenever the link state or keyer activity changes.
|
||||
type Status struct {
|
||||
Connected bool `json:"connected"`
|
||||
Busy bool `json:"busy"` // device is currently sending CW
|
||||
WPM int `json:"wpm"` // current speed (tracks the speed pot)
|
||||
Version int `json:"version"` // host firmware version byte
|
||||
Port string `json:"port"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Manager owns the serial link. Safe for concurrent use.
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
port serial.Port
|
||||
cfg Config
|
||||
status Status
|
||||
stopRead chan struct{}
|
||||
doneRead chan struct{}
|
||||
|
||||
onStatus func(Status)
|
||||
onEcho func(string) // chars the device echoes back as it keys them
|
||||
}
|
||||
|
||||
func NewManager(onStatus func(Status), onEcho func(string)) *Manager {
|
||||
return &Manager{onStatus: onStatus, onEcho: onEcho}
|
||||
}
|
||||
|
||||
// ListPorts returns the available serial port names (COM3, COM6, …).
|
||||
func ListPorts() ([]string, error) {
|
||||
ports, err := serial.GetPortsList()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ports, nil
|
||||
}
|
||||
|
||||
// Status returns a snapshot.
|
||||
func (m *Manager) Snapshot() Status {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.status
|
||||
}
|
||||
|
||||
func (m *Manager) emit() {
|
||||
if m.onStatus != nil {
|
||||
m.onStatus(m.status)
|
||||
}
|
||||
}
|
||||
|
||||
// Connect opens the port, performs the host-open handshake and applies cfg.
|
||||
func (m *Manager) Connect(cfg Config) error {
|
||||
cfg = cfg.normalised()
|
||||
if strings.TrimSpace(cfg.Port) == "" {
|
||||
return fmt.Errorf("winkeyer: no serial port selected")
|
||||
}
|
||||
m.Disconnect() // drop any existing link first
|
||||
|
||||
p, err := serial.Open(cfg.Port, &serial.Mode{
|
||||
BaudRate: cfg.Baud,
|
||||
DataBits: 8,
|
||||
Parity: serial.NoParity,
|
||||
StopBits: serial.OneStopBit,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("winkeyer: open %s: %w", cfg.Port, err)
|
||||
}
|
||||
_ = p.SetReadTimeout(200 * time.Millisecond)
|
||||
|
||||
// Host Open: <0x00 0x02>. Device replies with its firmware version byte.
|
||||
if _, err := p.Write([]byte{0x00, 0x02}); err != nil {
|
||||
_ = p.Close()
|
||||
return fmt.Errorf("winkeyer: host open: %w", err)
|
||||
}
|
||||
ver := 0
|
||||
buf := make([]byte, 16)
|
||||
_ = p.SetReadTimeout(1 * time.Second)
|
||||
if n, _ := p.Read(buf); n > 0 {
|
||||
ver = int(buf[0])
|
||||
}
|
||||
_ = p.SetReadTimeout(200 * time.Millisecond)
|
||||
|
||||
m.mu.Lock()
|
||||
m.port = p
|
||||
m.cfg = cfg
|
||||
m.status = Status{Connected: true, WPM: cfg.WPM, Version: ver, Port: cfg.Port}
|
||||
m.stopRead = make(chan struct{})
|
||||
m.doneRead = make(chan struct{})
|
||||
stop, done := m.stopRead, m.doneRead
|
||||
m.mu.Unlock()
|
||||
|
||||
applog.Printf("winkeyer: connected on %s (firmware byte %d)", cfg.Port, ver)
|
||||
go m.readLoop(p, stop, done)
|
||||
|
||||
if err := m.applyConfig(cfg); err != nil {
|
||||
applog.Printf("winkeyer: applyConfig: %v", err)
|
||||
}
|
||||
m.emit()
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyConfig pushes the keying parameters to the device.
|
||||
func (m *Manager) applyConfig(c Config) error {
|
||||
cmds := [][]byte{
|
||||
{0x0E, modeRegister(c)}, // set mode register (paddle mode, swap, autospace…)
|
||||
{0x02, byte(c.WPM)}, // set speed (WPM)
|
||||
{0x03, byte(c.Weight)}, // set weighting
|
||||
{0x04, byte(c.LeadInMs / 10), byte(c.TailMs / 10)}, // PTT lead-in / tail (10 ms units)
|
||||
{0x11, byte(c.Ratio)}, // set dit/dah ratio
|
||||
}
|
||||
// Sidetone: <0x01 n>. Bit6 enables, low nibble selects the pitch divisor.
|
||||
cmds = append(cmds, []byte{0x01, sidetoneCode(c.Sidetone)})
|
||||
if c.Farnsworth > 0 {
|
||||
cmds = append(cmds, []byte{0x0D, byte(c.Farnsworth)}) // Farnsworth WPM
|
||||
}
|
||||
for _, cmd := range cmds {
|
||||
if err := m.write(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// modeRegister builds the WinKey mode-register byte (command 0x0E).
|
||||
// bits 1..0 : paddle mode (00 Iambic-B, 01 Iambic-A, 10 Ultimatic, 11 Bug)
|
||||
// bit 3 : paddle swap
|
||||
// bit 0/... : (autospace is bit 0 of a separate group on some firmwares)
|
||||
// We keep to the widely-compatible WK2 layout.
|
||||
func modeRegister(c Config) byte {
|
||||
var b byte
|
||||
switch c.Mode {
|
||||
case ModeIambicB:
|
||||
b |= 0x00
|
||||
case ModeIambicA:
|
||||
b |= 0x10
|
||||
case ModeUltimatic:
|
||||
b |= 0x20
|
||||
case ModeBug:
|
||||
b |= 0x30
|
||||
}
|
||||
if c.Swap {
|
||||
b |= 0x08 // bit3 paddle swap
|
||||
}
|
||||
if c.AutoSpace {
|
||||
b |= 0x02 // bit1 autospace
|
||||
}
|
||||
if c.SerialEcho {
|
||||
b |= 0x04 // bit2 serial echoback — device echoes keyed chars to host
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// sidetoneCode maps a target Hz to the WinKey sidetone control byte. 0 = off.
|
||||
func sidetoneCode(hz int) byte {
|
||||
if hz <= 0 {
|
||||
return 0x00 // sidetone off
|
||||
}
|
||||
// WK sidetone = 4000 / n Hz, n = 1..10. Pick the nearest n, enable bit6.
|
||||
best, bestErr := 1, 1<<30
|
||||
for n := 1; n <= 10; n++ {
|
||||
f := 4000 / n
|
||||
e := f - hz
|
||||
if e < 0 {
|
||||
e = -e
|
||||
}
|
||||
if e < bestErr {
|
||||
bestErr, best = e, n
|
||||
}
|
||||
}
|
||||
return 0x80 | byte(best) // bit7 paddle-only sidetone on; low nibble = divisor
|
||||
}
|
||||
|
||||
// SetSpeed changes the WPM live (command 0x02).
|
||||
func (m *Manager) SetSpeed(wpm int) error {
|
||||
if wpm < 5 {
|
||||
wpm = 5
|
||||
}
|
||||
if wpm > 99 {
|
||||
wpm = 99
|
||||
}
|
||||
if err := m.write([]byte{0x02, byte(wpm)}); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.cfg.WPM = wpm
|
||||
m.status.WPM = wpm
|
||||
m.mu.Unlock()
|
||||
m.emit()
|
||||
return nil
|
||||
}
|
||||
|
||||
// allowedCW is the set of characters WinKey can key (everything else dropped).
|
||||
const allowedCW = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 .,?/=+-:();\"'@"
|
||||
|
||||
// Send keys the given text as Morse. The text is upper-cased and filtered to
|
||||
// keyable characters. Non-keyable input is silently dropped.
|
||||
func (m *Manager) Send(text string) error {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToUpper(text) {
|
||||
if strings.ContainsRune(allowedCW, r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if out == "" {
|
||||
return nil
|
||||
}
|
||||
return m.write([]byte(out))
|
||||
}
|
||||
|
||||
// Stop aborts the current message and clears the keyer buffer (command 0x0A).
|
||||
func (m *Manager) Stop() error {
|
||||
return m.write([]byte{0x0A})
|
||||
}
|
||||
|
||||
// Backspace removes the most recent character from the keyer's send buffer,
|
||||
// IF it hasn't been keyed yet (command 0x08). Used by "send on typing" mode
|
||||
// so a fast typo can be corrected before it goes on the air.
|
||||
func (m *Manager) Backspace() error {
|
||||
return m.write([]byte{0x08})
|
||||
}
|
||||
|
||||
func (m *Manager) write(b []byte) error {
|
||||
m.mu.Lock()
|
||||
p := m.port
|
||||
m.mu.Unlock()
|
||||
if p == nil {
|
||||
return fmt.Errorf("winkeyer: not connected")
|
||||
}
|
||||
_, err := p.Write(b)
|
||||
return err
|
||||
}
|
||||
|
||||
// Disconnect sends Host Close and releases the port.
|
||||
func (m *Manager) Disconnect() {
|
||||
m.mu.Lock()
|
||||
p := m.port
|
||||
stop, done := m.stopRead, m.doneRead
|
||||
m.port = nil
|
||||
m.stopRead = nil
|
||||
m.doneRead = nil
|
||||
connected := m.status.Connected
|
||||
m.status = Status{Connected: false}
|
||||
m.mu.Unlock()
|
||||
|
||||
if p != nil {
|
||||
_, _ = p.Write([]byte{0x00, 0x03}) // Host Close
|
||||
_ = p.Close()
|
||||
}
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
}
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
if connected {
|
||||
applog.Printf("winkeyer: disconnected")
|
||||
m.emit()
|
||||
}
|
||||
}
|
||||
|
||||
// readLoop drains device→host status bytes. WK status frames have bit7 set
|
||||
// (0xC0 + flags); 0x80–0xBF carry the speed-pot value; printable bytes are
|
||||
// the echo of characters being sent. We track busy/idle and the speed pot.
|
||||
func (m *Manager) readLoop(p serial.Port, stop, done chan struct{}) {
|
||||
defer close(done)
|
||||
buf := make([]byte, 64)
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
n, err := p.Read(buf)
|
||||
if err != nil {
|
||||
// Timeout is normal (no data); a real error ends the loop.
|
||||
if isTimeout(err) {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
b := buf[i]
|
||||
switch {
|
||||
case b&0xC0 == 0xC0: // status byte
|
||||
busy := b&0x04 != 0 // bit2 = busy (sending)
|
||||
m.mu.Lock()
|
||||
changed := m.status.Busy != busy
|
||||
m.status.Busy = busy
|
||||
m.mu.Unlock()
|
||||
if changed {
|
||||
m.emit()
|
||||
}
|
||||
case b&0xC0 == 0x80: // speed-pot value: 0x80 | (wpm-min)
|
||||
// Reported relative to the configured pot range; surfaced as-is.
|
||||
default:
|
||||
// Echo of a keyed character (serial echo). Surface printable
|
||||
// ones so the UI can show the text as it's transmitted.
|
||||
if b >= 0x20 && b < 0x7F && m.onEcho != nil {
|
||||
m.onEcho(string(rune(b)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isTimeout(err error) bool {
|
||||
type timeout interface{ Timeout() bool }
|
||||
if t, ok := err.(timeout); ok {
|
||||
return t.Timeout()
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "timeout")
|
||||
}
|
||||
Reference in New Issue
Block a user