Compare commits
4
Commits
v0.20.1
...
4ab4f70349
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ab4f70349 | ||
|
|
64e80986ea | ||
|
|
816c6ffcf1 | ||
|
|
2166d1aa4b |
@@ -1850,13 +1850,12 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
||||||
a.applyQSLDefaults(&q)
|
a.applyQSLDefaults(&q)
|
||||||
a.applySolar(&q) // stamp SFI / A / K (and SSN as an extra) from live space-weather
|
a.applySolar(&q) // stamp SFI / A / K (and SSN as an extra) from live space-weather
|
||||||
// Fill the contacted operator's e-mail from the (cached) lookup so the
|
// NOTE: the contacted operator's e-mail already rides in on the QSO — the entry
|
||||||
// recording can be auto-sent. Cheap: the entry already looked the call up.
|
// lookup (when you typed the call) fetched it along with name/QTH/grid and the
|
||||||
if strings.TrimSpace(q.Email) == "" && a.lookup != nil {
|
// form carries it here. There is deliberately NO second lookup at log time: it
|
||||||
if lr, e := a.lookup.Lookup(a.ctx, q.Callsign); e == nil && lr.Email != "" {
|
// was redundant with the entry lookup and only slowed logging (a call not yet in
|
||||||
q.Email = lr.Email
|
// the cache made AddQSO wait on QRZ/HamQTH). If the entry lookup hadn't finished
|
||||||
}
|
// when you logged (fast CW: type → Enter), the e-mail is simply blank — fine.
|
||||||
}
|
|
||||||
id, err = a.qso.Add(a.ctx, q)
|
id, err = a.qso.Add(a.ctx, q)
|
||||||
if err != nil && db.IsConnLost(err) {
|
if err != nil && db.IsConnLost(err) {
|
||||||
// The database is UNREACHABLE (not a data error) — park the QSO in the
|
// The database is UNREACHABLE (not a data error) — park the QSO in the
|
||||||
@@ -4528,7 +4527,16 @@ func (a *App) GetQSORate() QSORate {
|
|||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return QSORate{}
|
return QSORate{}
|
||||||
}
|
}
|
||||||
counts, err := a.qso.RecentRate(a.ctx, time.Now(), 10*time.Minute, 60*time.Minute)
|
// Per-operator on a shared logbook: count only the ACTIVE profile's operator
|
||||||
|
// so each op sees their own performance, not the cumulative station rate. An
|
||||||
|
// empty operator (single-op / station owner) matches all their QSOs.
|
||||||
|
operator := ""
|
||||||
|
if a.profiles != nil {
|
||||||
|
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||||
|
operator = p.Operator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
counts, err := a.qso.RecentRate(a.ctx, time.Now(), operator, 10*time.Minute, 60*time.Minute)
|
||||||
if err != nil || len(counts) < 2 {
|
if err != nil || len(counts) < 2 {
|
||||||
return QSORate{}
|
return QSORate{}
|
||||||
}
|
}
|
||||||
@@ -5292,7 +5300,14 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
|
|||||||
if a.lookup == nil {
|
if a.lookup == nil {
|
||||||
return lookup.Result{}, fmt.Errorf("lookup not initialized")
|
return lookup.Result{}, fmt.Errorf("lookup not initialized")
|
||||||
}
|
}
|
||||||
r, err := a.lookup.Lookup(a.ctx, callsign)
|
// Bound the whole lookup: give the providers a couple of seconds, then let
|
||||||
|
// Lookup fall through to cty.dat (country/zones). Without this a call that isn't
|
||||||
|
// in QRZ.com — or a slow/unresponsive provider — left the "looking up" spinner
|
||||||
|
// turning for 10 s+ before the cty.dat fallback showed. The providers respect
|
||||||
|
// the context, so they're cancelled at the deadline and cty.dat answers instantly.
|
||||||
|
ctx, cancel := context.WithTimeout(a.ctx, 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
r, err := a.lookup.Lookup(ctx, callsign)
|
||||||
if errors.Is(err, lookup.ErrNotFound) {
|
if errors.Is(err, lookup.ErrNotFound) {
|
||||||
return lookup.Result{}, fmt.Errorf("callsign not found")
|
return lookup.Result{}, fmt.Errorf("callsign not found")
|
||||||
}
|
}
|
||||||
@@ -5822,8 +5837,10 @@ func (a *App) saveQSORecording(q *qso.QSO) {
|
|||||||
if q.Extras == nil {
|
if q.Extras == nil {
|
||||||
q.Extras = map[string]string{}
|
q.Extras = map[string]string{}
|
||||||
}
|
}
|
||||||
q.Extras["APP_OPSLOG_RECORDING"] = name
|
q.Extras["APP_OPSLOG_RECORDING"] = name // in-memory copy for the encode goroutine
|
||||||
if err := a.qso.Update(a.ctx, *q); err != nil {
|
// Persist ONLY this extras key (targeted) — a full-row Update from this
|
||||||
|
// in-memory copy could revert a column a concurrent post-log action changed.
|
||||||
|
if err := a.qso.SetExtra(a.ctx, q.ID, "APP_OPSLOG_RECORDING", name); err != nil {
|
||||||
applog.Printf("qso-rec: store recording path: %v", err)
|
applog.Printf("qso-rec: store recording path: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6695,17 +6712,10 @@ func (a *App) markRecordingSent(id int64) {
|
|||||||
if a.qso == nil || id == 0 {
|
if a.qso == nil || id == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
q, err := a.qso.GetByID(a.ctx, id)
|
// Targeted extras write — never a full-row Update, which (from a stale copy)
|
||||||
if err != nil {
|
// could revert a clublog/qrz upload-status another action just stamped.
|
||||||
applog.Printf("qso-rec: mark sent: load %d: %v", id, err)
|
if err := a.qso.SetExtra(a.ctx, id, "APP_OPSLOG_RECORDING_SENT", time.Now().UTC().Format("2006-01-02")); err != nil {
|
||||||
return
|
applog.Printf("qso-rec: mark sent %d: %v", id, err)
|
||||||
}
|
|
||||||
if q.Extras == nil {
|
|
||||||
q.Extras = map[string]string{}
|
|
||||||
}
|
|
||||||
q.Extras["APP_OPSLOG_RECORDING_SENT"] = time.Now().UTC().Format("2006-01-02")
|
|
||||||
if err := a.qso.Update(a.ctx, q); err != nil {
|
|
||||||
applog.Printf("qso-rec: mark sent: update %d: %v", id, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-11
@@ -437,17 +437,16 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
|
|||||||
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
|
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field —
|
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field — NOT
|
||||||
// NOT the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay
|
// the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay independent.
|
||||||
// independent. q came straight from GetByID, so a full Update rewrites the
|
//
|
||||||
// row unchanged apart from this field.
|
// Stamp ONLY this extras key (targeted UPDATE), never a full-row write. The `q`
|
||||||
if q.Extras == nil {
|
// read up top is now stale after the slow e-mail send, and rewriting the whole
|
||||||
q.Extras = map[string]string{}
|
// row would revert any column an auto-upload changed meanwhile — that's how
|
||||||
}
|
// sending a QSL card was flipping clublog_qso_upload_status back from Y to R.
|
||||||
q.Extras[appQSLCardSentField] = time.Now().UTC().Format(time.RFC3339)
|
if err := a.qso.SetExtra(a.ctx, qsoID, appQSLCardSentField, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||||
if err := a.qso.Update(a.ctx, q); err != nil {
|
applog.Printf("qsl: card sent to %s but marking failed: %v", q.Callsign, err)
|
||||||
applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err)
|
return fmt.Errorf("QSL card sent but status not saved: %w", err)
|
||||||
return fmt.Errorf("eQSL sent but status not saved: %w", err)
|
|
||||||
}
|
}
|
||||||
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
|
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
|
||||||
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
|
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
|
||||||
|
|||||||
+34
-8
@@ -605,6 +605,11 @@ export default function App() {
|
|||||||
QSOAudioResetClock().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
|
QSOAudioResetClock().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
|
||||||
};
|
};
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
// Synchronous re-entrancy guard: `saving` is React state (updates async), so it
|
||||||
|
// can't stop a burst of Enter presses / clicks fired before the re-render — each
|
||||||
|
// would run a full AddQSO and log the SAME contact several times when a slow QRZ
|
||||||
|
// lookup made the log take seconds. This ref blocks the repeat immediately.
|
||||||
|
const savingRef = useRef(false);
|
||||||
const [filterCallsign, setFilterCallsign] = useState('');
|
const [filterCallsign, setFilterCallsign] = useState('');
|
||||||
// Advanced filter builder (replaces the old band/mode dropdowns).
|
// Advanced filter builder (replaces the old band/mode dropdowns).
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
@@ -1153,6 +1158,10 @@ export default function App() {
|
|||||||
const [lookupError, setLookupError] = useState('');
|
const [lookupError, setLookupError] = useState('');
|
||||||
const lookupTimerRef = useRef<number | null>(null);
|
const lookupTimerRef = useRef<number | null>(null);
|
||||||
const wbTimerRef = useRef<number | null>(null);
|
const wbTimerRef = useRef<number | null>(null);
|
||||||
|
// Bumped whenever the entry is cleared (ESC) or a new call starts, so a still
|
||||||
|
// in-flight lookup discards its result when it finally returns instead of
|
||||||
|
// re-populating a field the operator just cleared.
|
||||||
|
const lookupGenRef = useRef(0);
|
||||||
const [wb, setWb] = useState<WB | null>(null);
|
const [wb, setWb] = useState<WB | null>(null);
|
||||||
const [wbBusy, setWbBusy] = useState(false);
|
const [wbBusy, setWbBusy] = useState(false);
|
||||||
|
|
||||||
@@ -2155,7 +2164,9 @@ export default function App() {
|
|||||||
}, [spots]);
|
}, [spots]);
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
if (savingRef.current) return; // a log is already in flight — ignore the repeat
|
||||||
if (!callsign.trim()) { setError('Callsign required'); return; }
|
if (!callsign.trim()) { setError('Callsign required'); return; }
|
||||||
|
savingRef.current = true;
|
||||||
setSaving(true); setError('');
|
setSaving(true); setError('');
|
||||||
try {
|
try {
|
||||||
const freqHz = freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined;
|
const freqHz = freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined;
|
||||||
@@ -2225,13 +2236,21 @@ export default function App() {
|
|||||||
await refresh();
|
await refresh();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(String(e?.message ?? e));
|
setError(String(e?.message ?? e));
|
||||||
} finally { setSaving(false); }
|
} finally { setSaving(false); savingRef.current = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// resetEntry clears the form for the next QSO. Triggered after a
|
// resetEntry clears the form for the next QSO. Triggered after a
|
||||||
// successful log AND by ESC. Locked values (band/mode/freq/start/end)
|
// successful log AND by ESC. Locked values (band/mode/freq/start/end)
|
||||||
// are preserved so backdated batches stay productive.
|
// are preserved so backdated batches stay productive.
|
||||||
function resetEntry() {
|
function resetEntry() {
|
||||||
|
// Stop any callsign lookup DEAD: cancel the pending debounce, hide the "looking
|
||||||
|
// up" spinner immediately, and invalidate any request already in flight so its
|
||||||
|
// late result can't re-fill the field we're about to clear (the "ESC clears the
|
||||||
|
// QSO but the QRZ lookup keeps going" bug).
|
||||||
|
if (lookupTimerRef.current) { window.clearTimeout(lookupTimerRef.current); lookupTimerRef.current = null; }
|
||||||
|
if (wbTimerRef.current) { window.clearTimeout(wbTimerRef.current); wbTimerRef.current = null; }
|
||||||
|
lookupGenRef.current++;
|
||||||
|
setLookupBusy(false);
|
||||||
// Discard any in-progress QSO recording (no-op if it was already saved on
|
// Discard any in-progress QSO recording (no-op if it was already saved on
|
||||||
// log, or if the recorder is off).
|
// log, or if the recorder is off).
|
||||||
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
|
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
|
||||||
@@ -2436,14 +2455,15 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
async function runLookup(call: string) {
|
async function runLookup(call: string) {
|
||||||
if (call !== lastLookedUpRef.current) resetAutoFill();
|
if (call !== lastLookedUpRef.current) resetAutoFill();
|
||||||
|
const gen = lookupGenRef.current; // invalidated by ESC / resetEntry
|
||||||
setLookupBusy(true);
|
setLookupBusy(true);
|
||||||
try {
|
try {
|
||||||
const r = await LookupCallsign(call);
|
const r = await LookupCallsign(call);
|
||||||
// Discard a STALE result: the operator already moved to another call
|
// Discard a STALE result: the operator already moved to another call
|
||||||
// (clicked a new spot / typed) while this lookup was in flight. Applying it
|
// (clicked a new spot / typed) OR cleared the entry (ESC) while this lookup
|
||||||
// would clobber the current call's fields and zoom the map to the wrong
|
// was in flight. Applying it would clobber the current fields and zoom the
|
||||||
// station — the bug where replacing a call didn't re-zoom the map.
|
// map to the wrong station.
|
||||||
if (call !== callsignValRef.current.trim().toUpperCase()) return;
|
if (gen !== lookupGenRef.current || call !== callsignValRef.current.trim().toUpperCase()) return;
|
||||||
lastLookedUpRef.current = call;
|
lastLookedUpRef.current = call;
|
||||||
// cty.dat carries ONLY DXCC-entity data (country / CQ / ITU zones / continent).
|
// cty.dat carries ONLY DXCC-entity data (country / CQ / ITU zones / continent).
|
||||||
// A QRZ/HamQTH hit is far richer (name, QTH, grid, address, image). When the
|
// A QRZ/HamQTH hit is far richer (name, QTH, grid, address, image). When the
|
||||||
@@ -2496,9 +2516,15 @@ export default function App() {
|
|||||||
QSOAudioBegin().then(setRecording).catch(() => {});
|
QSOAudioBegin().then(setRecording).catch(() => {});
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setLookupResult(null);
|
if (gen === lookupGenRef.current && call === callsignValRef.current.trim().toUpperCase()) {
|
||||||
setLookupError(String(e?.message ?? e));
|
setLookupResult(null);
|
||||||
} finally { setLookupBusy(false); }
|
setLookupError(String(e?.message ?? e));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Only clear the spinner if we're still the current lookup — a newer one
|
||||||
|
// (or an ESC that already reset it) owns the busy state otherwise.
|
||||||
|
if (gen === lookupGenRef.current) setLookupBusy(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function scheduleLookup(value: string, force?: boolean) {
|
function scheduleLookup(value: string, force?: boolean) {
|
||||||
setLookupError('');
|
setLookupError('');
|
||||||
|
|||||||
@@ -136,6 +136,11 @@ type icomNet struct {
|
|||||||
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
|
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
|
||||||
lastRx atomic.Int64
|
lastRx atomic.Int64
|
||||||
|
|
||||||
|
// dead is set when the rig explicitly tears the session down (control 0x05):
|
||||||
|
// Alive() then returns false immediately so ReadState fails on the next poll and
|
||||||
|
// the manager reconnects cleanly, instead of waiting out the 6 s lastRx timeout.
|
||||||
|
dead atomic.Bool
|
||||||
|
|
||||||
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
|
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
|
||||||
// Torn down alongside the CI-V/control streams in Close.
|
// Torn down alongside the CI-V/control streams in Close.
|
||||||
audio *icomAudio
|
audio *icomAudio
|
||||||
@@ -178,6 +183,9 @@ func (n *icomNet) markRx() { n.lastRx.Store(time.Now().UnixNano()) }
|
|||||||
// the radio — is gone. Independent of CI-V replies, so a powered-off rig still
|
// the radio — is gone. Independent of CI-V replies, so a powered-off rig still
|
||||||
// reads as Alive and the session isn't torn down. Satisfies aliveTransport.
|
// reads as Alive and the session isn't torn down. Satisfies aliveTransport.
|
||||||
func (n *icomNet) Alive() bool {
|
func (n *icomNet) Alive() bool {
|
||||||
|
if n.dead.Load() {
|
||||||
|
return false // rig sent an explicit disconnect — reconnect now, don't wait
|
||||||
|
}
|
||||||
last := n.lastRx.Load()
|
last := n.lastRx.Load()
|
||||||
if last == 0 {
|
if last == 0 {
|
||||||
return true // just connected, nothing received yet — give it a chance
|
return true // just connected, nothing received yet — give it a chance
|
||||||
@@ -292,6 +300,7 @@ func (n *icomNet) ctrlPump() {
|
|||||||
n.ctrlResend(icnLE.Uint16(buf[6:]))
|
n.ctrlResend(icnLE.Uint16(buf[6:]))
|
||||||
}
|
}
|
||||||
case 0x05: // rig-initiated disconnect — it dropped US
|
case 0x05: // rig-initiated disconnect — it dropped US
|
||||||
|
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
|
||||||
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
|
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
|
||||||
default:
|
default:
|
||||||
// Anything else on the control stream is (almost always) the rig's
|
// Anything else on the control stream is (almost always) the rig's
|
||||||
@@ -367,6 +376,7 @@ func (n *icomNet) civPump() {
|
|||||||
n.resend(icnLE.Uint16(buf[6:]))
|
n.resend(icnLE.Uint16(buf[6:]))
|
||||||
}
|
}
|
||||||
case typ == 0x05: // rig-initiated disconnect — it dropped US
|
case typ == 0x05: // rig-initiated disconnect — it dropped US
|
||||||
|
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
|
||||||
debugLog.Printf("icom net: rig sent DISCONNECT on CI-V stream — session dropped by the rig")
|
debugLog.Printf("icom net: rig sent DISCONNECT on CI-V stream — session dropped by the rig")
|
||||||
case typ == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data
|
case typ == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data
|
||||||
n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit
|
n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit
|
||||||
|
|||||||
+17
-1
@@ -32,6 +32,7 @@ type OmniRig struct {
|
|||||||
omnirig *ole.IDispatch
|
omnirig *ole.IDispatch
|
||||||
rig *ole.IDispatch
|
rig *ole.IDispatch
|
||||||
lastSig string // last logged Split/VFO signature — only log on change
|
lastSig string // last logged Split/VFO signature — only log on change
|
||||||
|
rigType string // OmniRig's RigType string (the .ini title), e.g. "IC-7610"
|
||||||
|
|
||||||
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
|
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
|
||||||
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
|
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
|
||||||
@@ -80,11 +81,20 @@ func (o *OmniRig) Connect() error {
|
|||||||
o.rig = rigVar.ToIDispatch()
|
o.rig = rigVar.ToIDispatch()
|
||||||
|
|
||||||
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
|
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
|
||||||
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, rt.ToString())
|
o.rigType = rt.ToString()
|
||||||
|
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, o.rigType)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isIC7610 reports whether the connected rig is an IC-7610. OmniRig's generic
|
||||||
|
// Freq property reads the wrong VFO on the 7610 (its Main/Sub model confuses the
|
||||||
|
// stock ini), so we read VFO A explicitly for it instead — matching what Log4OM
|
||||||
|
// shows.
|
||||||
|
func (o *OmniRig) isIC7610() bool {
|
||||||
|
return strings.Contains(strings.ToUpper(o.rigType), "7610")
|
||||||
|
}
|
||||||
|
|
||||||
func (o *OmniRig) Disconnect() {
|
func (o *OmniRig) Disconnect() {
|
||||||
if o.rig != nil {
|
if o.rig != nil {
|
||||||
o.rig.Release()
|
o.rig.Release()
|
||||||
@@ -199,6 +209,12 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
|||||||
s.Split = false
|
s.Split = false
|
||||||
s.RxFreqHz = 0
|
s.RxFreqHz = 0
|
||||||
s.FreqHz = freqMain
|
s.FreqHz = freqMain
|
||||||
|
// IC-7610 quirk: OmniRig's generic Freq reports VFO B (its Main/Sub model
|
||||||
|
// confuses the stock ini), so OpsLog showed the wrong VFO. Read VFO A
|
||||||
|
// explicitly for the 7610 — what the operator actually wants to see.
|
||||||
|
if o.isIC7610() && freqA != 0 {
|
||||||
|
s.FreqHz = freqA
|
||||||
|
}
|
||||||
if s.FreqHz == 0 {
|
if s.FreqHz == 0 {
|
||||||
if s.Vfo == "B" || s.Vfo == "BB" {
|
if s.Vfo == "B" || s.Vfo == "BB" {
|
||||||
s.FreqHz = freqB
|
s.FreqHz = freqB
|
||||||
|
|||||||
+24
-62
@@ -87,77 +87,39 @@ type Manager struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cfg ExternalServices
|
cfg ExternalServices
|
||||||
rnd *rand.Rand
|
rnd *rand.Rand
|
||||||
|
|
||||||
// uploadCh serialises immediate auto-uploads through a single worker. Firing a
|
|
||||||
// goroutine per QSO meant a pileup / ADIF-import burst hit a service with dozens
|
|
||||||
// of concurrent requests at once — Club Log's nginx answers that with 403, the
|
|
||||||
// upload is counted as failed and the QSO stays at "R" despite the others going
|
|
||||||
// through. One-at-a-time with a small gap keeps every upload under the limit.
|
|
||||||
uploadCh chan uploadJob
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// uploadJob is one queued auto-upload.
|
// maxUploadAttempts bounds retries of a transient upload failure.
|
||||||
type uploadJob struct {
|
const maxUploadAttempts = 4
|
||||||
svc Service
|
|
||||||
id int64
|
|
||||||
cfg ServiceConfig
|
|
||||||
attempt int // 0 on first try; incremented on each retry
|
|
||||||
}
|
|
||||||
|
|
||||||
// uploadGap spaces serialized uploads so a burst never trips a service's per-IP
|
|
||||||
// rate limiter. maxUploadAttempts bounds retries of a transient failure.
|
|
||||||
const (
|
|
||||||
uploadGap = 250 * time.Millisecond
|
|
||||||
maxUploadAttempts = 4
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewManager(deps Deps) *Manager {
|
func NewManager(deps Deps) *Manager {
|
||||||
if deps.Client == nil {
|
if deps.Client == nil {
|
||||||
deps.Client = &http.Client{Timeout: 20 * time.Second}
|
deps.Client = &http.Client{Timeout: 20 * time.Second}
|
||||||
}
|
}
|
||||||
m := &Manager{
|
return &Manager{
|
||||||
deps: deps,
|
deps: deps,
|
||||||
// Seeded from the clock; the delay only needs to be unpredictable
|
// Seeded from the clock; the delay only needs to be unpredictable
|
||||||
// enough to spread bursts, not cryptographically random.
|
// enough to spread bursts, not cryptographically random.
|
||||||
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||||
uploadCh: make(chan uploadJob, 4096),
|
|
||||||
}
|
}
|
||||||
go m.uploadWorker()
|
|
||||||
return m
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// uploadWorker drains the queue one upload at a time, spacing them so a burst of
|
// attemptUpload uploads a QSO in its OWN goroutine and, on a TRANSIENT failure
|
||||||
// freshly-logged QSOs can't hammer (and get 403'd by) a service. A transient
|
// (rate-limit / network), re-arms itself with exponential back-off. Each upload is
|
||||||
// failure is re-queued with an exponential back-off, so a QSO that hit a
|
// independent — never serialised through a shared worker, because a single slow
|
||||||
// momentary rate-limit still ends up marked instead of stuck at "R".
|
// upload (LoTW signs via TQSL; a service on a 30 s timeout) would otherwise block
|
||||||
func (m *Manager) uploadWorker() {
|
// every following QSO's upload and strand them all at "R" (the regression that hit
|
||||||
for job := range m.uploadCh {
|
// the operator on the newest build while everyone on the old concurrent path was
|
||||||
ok, retryable := m.upload(job.svc, job.id, job.cfg)
|
// fine).
|
||||||
if !ok && retryable && job.attempt+1 < maxUploadAttempts {
|
func (m *Manager) attemptUpload(svc Service, id int64, cfg ServiceConfig, attempt int) {
|
||||||
next := job
|
go func() {
|
||||||
next.attempt++
|
ok, retryable := m.upload(svc, id, cfg)
|
||||||
backoff := time.Duration(1<<uint(job.attempt)) * time.Second // 1s, 2s, 4s…
|
if !ok && retryable && attempt+1 < maxUploadAttempts {
|
||||||
m.logf("extsvc: %s upload of QSO %d will retry (attempt %d) in %s", job.svc, job.id, next.attempt+1, backoff)
|
backoff := time.Duration(1<<uint(attempt)) * time.Second // 1s, 2s, 4s…
|
||||||
time.AfterFunc(backoff, func() { m.enqueueJob(next) })
|
m.logf("extsvc: %s upload of QSO %d will retry (attempt %d) in %s", svc, id, attempt+2, backoff)
|
||||||
|
time.AfterFunc(backoff, func() { m.attemptUpload(svc, id, cfg, attempt+1) })
|
||||||
}
|
}
|
||||||
time.Sleep(uploadGap)
|
}()
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// enqueueUpload queues a first-attempt upload without blocking the logging path.
|
|
||||||
func (m *Manager) enqueueUpload(svc Service, id int64, cfg ServiceConfig) {
|
|
||||||
m.enqueueJob(uploadJob{svc: svc, id: id, cfg: cfg})
|
|
||||||
}
|
|
||||||
|
|
||||||
// enqueueJob queues a job (possibly a retry). If the queue is somehow full (an
|
|
||||||
// enormous burst), it falls back to a goroutine rather than dropping the upload —
|
|
||||||
// a dropped upload would leave the QSO stuck at "R".
|
|
||||||
func (m *Manager) enqueueJob(job uploadJob) {
|
|
||||||
select {
|
|
||||||
case m.uploadCh <- job:
|
|
||||||
default:
|
|
||||||
go m.upload(job.svc, job.id, job.cfg)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) logf(format string, args ...any) {
|
func (m *Manager) logf(format string, args ...any) {
|
||||||
@@ -234,17 +196,17 @@ func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
|
|||||||
m.scheduleUpload(svc, id, cfg)
|
m.scheduleUpload(svc, id, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// scheduleUpload either queues the upload now (immediate) or arms a timer that
|
// scheduleUpload uploads now (immediate) or after a random fuse (delayed). Each
|
||||||
// queues it later (delayed). Both go through the serialised worker so uploads are
|
// upload runs in its own goroutine (attemptUpload) — never serialised — so a slow
|
||||||
// never fired concurrently in a burst.
|
// one never holds up the rest.
|
||||||
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
||||||
if cfg.UploadMode == ModeDelayed {
|
if cfg.UploadMode == ModeDelayed {
|
||||||
d := m.delaySeconds()
|
d := m.delaySeconds()
|
||||||
m.logf("extsvc: %s upload of QSO %d scheduled in %s", svc, id, d)
|
m.logf("extsvc: %s upload of QSO %d scheduled in %s", svc, id, d)
|
||||||
time.AfterFunc(d, func() { m.enqueueUpload(svc, id, cfg) })
|
time.AfterFunc(d, func() { m.attemptUpload(svc, id, cfg, 0) })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.enqueueUpload(svc, id, cfg)
|
m.attemptUpload(svc, id, cfg, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// onCloseServices returns the services configured for on-close auto-upload,
|
// onCloseServices returns the services configured for on-close auto-upload,
|
||||||
|
|||||||
+49
-10
@@ -781,6 +781,38 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetExtra merges ONE key into a QSO's extras JSON without rewriting the rest of
|
||||||
|
// the row. A slow caller that only wants to stamp its own app field (e-mailing a
|
||||||
|
// QSL card, saving a recording) must not do a full-row Update: the row it read may
|
||||||
|
// be seconds stale, and writing it all back silently reverts any column another
|
||||||
|
// action changed meanwhile — e.g. an auto-upload flipping clublog_qso_upload_status
|
||||||
|
// from R to Y. Touching only `extras` makes that impossible. Empty value deletes
|
||||||
|
// the key.
|
||||||
|
func (r *Repo) SetExtra(ctx context.Context, id int64, key, value string) error {
|
||||||
|
if id == 0 || strings.TrimSpace(key) == "" {
|
||||||
|
return fmt.Errorf("missing id or key")
|
||||||
|
}
|
||||||
|
var extrasJSON sql.NullString
|
||||||
|
if err := r.db.QueryRowContext(ctx, `SELECT extras FROM qso WHERE id = ?`, id).Scan(&extrasJSON); err != nil {
|
||||||
|
return fmt.Errorf("load extras: %w", err)
|
||||||
|
}
|
||||||
|
m := decodeExtras(extrasJSON.String)
|
||||||
|
if m == nil {
|
||||||
|
m = map[string]string{}
|
||||||
|
}
|
||||||
|
if value == "" {
|
||||||
|
delete(m, key)
|
||||||
|
} else {
|
||||||
|
m[key] = value
|
||||||
|
}
|
||||||
|
if _, err := r.db.ExecContext(ctx,
|
||||||
|
`UPDATE qso SET extras = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
encodeExtras(m), db.NowISO(), id); err != nil {
|
||||||
|
return fmt.Errorf("set extra %s: %w", key, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
|
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
|
||||||
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
||||||
if q.ID == 0 {
|
if q.ID == 0 {
|
||||||
@@ -1864,25 +1896,32 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RecentRate counts QSOs whose start time falls within each trailing window from
|
// RecentRate counts QSOs whose start time falls within each trailing window from
|
||||||
// `now` — the live "QSO rate" meter shown in the header. It scans only the most
|
// `now` — the live "QSO rate" meter shown in the header. When operator is non-empty
|
||||||
// recently inserted rows (ORDER BY id DESC LIMIT), since any QSO in the last hour
|
// (multi-op on a shared logbook) only that operator's QSOs are counted, so each op
|
||||||
// was inserted recently; that keeps it cheap even on a large log. qso_date is the
|
// sees their OWN performance, not the cumulative rate; empty operator matches every
|
||||||
// repo's text column, parsed with parseTimeLoose (backend-format agnostic).
|
// QSO. It scans only the most recently inserted rows (ORDER BY id DESC LIMIT), since
|
||||||
func (r *Repo) RecentRate(ctx context.Context, now time.Time, windows ...time.Duration) ([]int, error) {
|
// any QSO in the last hour was inserted recently; that keeps it cheap even on a large
|
||||||
|
// log. qso_date is the repo's text column, parsed with parseTimeLoose (backend-format
|
||||||
|
// agnostic).
|
||||||
|
func (r *Repo) RecentRate(ctx context.Context, now time.Time, operator string, windows ...time.Duration) ([]int, error) {
|
||||||
counts := make([]int, len(windows))
|
counts := make([]int, len(windows))
|
||||||
// 400 rows covers a full hour even at a blistering contest rate (>300/h); any
|
// 2000 rows covers a full hour for one operator even in a busy multi-op run
|
||||||
// QSO inside the trailing windows is among the most recently inserted.
|
// (other operators' rows are discarded before counting).
|
||||||
rows, err := r.db.QueryContext(ctx, `SELECT qso_date FROM qso ORDER BY id DESC LIMIT 400`)
|
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 2000`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return counts, err
|
return counts, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
now = now.UTC()
|
now = now.UTC()
|
||||||
|
opFilter := strings.ToUpper(strings.TrimSpace(operator))
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var dateStr sql.NullString
|
var oper, dateStr sql.NullString
|
||||||
if err := rows.Scan(&dateStr); err != nil {
|
if err := rows.Scan(&oper, &dateStr); err != nil {
|
||||||
return counts, err
|
return counts, err
|
||||||
}
|
}
|
||||||
|
if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter {
|
||||||
|
continue // a different operator's QSO — not part of my rate
|
||||||
|
}
|
||||||
t := parseTimeLoose(dateStr.String).UTC()
|
t := parseTimeLoose(dateStr.String).UTC()
|
||||||
if t.IsZero() || t.After(now) {
|
if t.IsZero() || t.After(now) {
|
||||||
continue
|
continue
|
||||||
|
|||||||
Reference in New Issue
Block a user