Compare commits
7
Commits
9c62bf0152
...
v0.20.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7afe40a48e | ||
|
|
311ea8341f | ||
|
|
a2958d458e | ||
|
|
d9b7e48e83 | ||
|
|
3e9ebdb89a | ||
|
|
be66ac1e19 | ||
|
|
6aab4a6989 |
@@ -814,6 +814,33 @@ func (a *App) startup(ctx context.Context) {
|
||||
a.lookup = lookup.NewManager(a.cache)
|
||||
a.reloadLookupProviders()
|
||||
|
||||
// CAT manager: emit pushes state to the frontend via Wails events, and
|
||||
// forwards frequency/mode to any outbound UDP emitters (PstRotator, N1MM).
|
||||
// Started HERE — before the (possibly slow, remote) MySQL logbook connect
|
||||
// below — because the rig link only needs the local settings, and blocking it
|
||||
// behind a 10–30s MySQL dial made the FlexRadio (and every CAT backend) take
|
||||
// that long to come up at launch.
|
||||
a.cat = cat.NewManager(func(s cat.RigState) {
|
||||
// DIAGNOSTIC: the manager only fires this on a USER-relevant change, so a
|
||||
// burst of these lines = the frontend is being re-rendered rapidly (the
|
||||
// "screen flickers" symptom). Shows WHAT is churning — connection flap,
|
||||
// or freq/split/mode oscillating between slices during FT8.
|
||||
applog.Printf("cat:state → connected=%v freq=%d rx=%d split=%v mode=%s band=%s",
|
||||
s.Connected, s.FreqHz, s.RxFreqHz, s.Split, s.Mode, s.Band)
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
||||
}
|
||||
a.emitRadioUDP(s)
|
||||
// Drive station relays by the current frequency/band (PstRotator-style
|
||||
// automatic control). Cheap cached-flag check keeps this a no-op when the
|
||||
// feature is off; when on, run off this callback so a slow relay board never
|
||||
// stalls rig-state processing.
|
||||
if a.relayAutoOn.Load() {
|
||||
go a.applyRelayAuto(s.FreqHz, s.Band)
|
||||
}
|
||||
})
|
||||
a.reloadCAT()
|
||||
|
||||
// The QSO logbook lives where the ACTIVE PROFILE points it: the local SQLite
|
||||
// file, or a per-profile shared MySQL database. Switching profiles switches
|
||||
// the logbook (see switchLogbook). One-time: adopt any legacy config.json
|
||||
@@ -908,28 +935,6 @@ func (a *App) startup(ctx context.Context) {
|
||||
fmt.Printf("OpsLog: clublog cty.xml loaded — %d exceptions (%s)\n", n, d)
|
||||
}
|
||||
}()
|
||||
// CAT manager: emit pushes state to the frontend via Wails events, and
|
||||
// forwards frequency/mode to any outbound UDP emitters (PstRotator, N1MM).
|
||||
a.cat = cat.NewManager(func(s cat.RigState) {
|
||||
// DIAGNOSTIC: the manager only fires this on a USER-relevant change, so a
|
||||
// burst of these lines = the frontend is being re-rendered rapidly (the
|
||||
// "screen flickers" symptom). Shows WHAT is churning — connection flap,
|
||||
// or freq/split/mode oscillating between slices during FT8.
|
||||
applog.Printf("cat:state → connected=%v freq=%d rx=%d split=%v mode=%s band=%s",
|
||||
s.Connected, s.FreqHz, s.RxFreqHz, s.Split, s.Mode, s.Band)
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
||||
}
|
||||
a.emitRadioUDP(s)
|
||||
// Drive station relays by the current frequency/band (PstRotator-style
|
||||
// automatic control). Cheap cached-flag check keeps this a no-op when the
|
||||
// feature is off; when on, run off this callback so a slow relay board never
|
||||
// stalls rig-state processing.
|
||||
if a.relayAutoOn.Load() {
|
||||
go a.applyRelayAuto(s.FreqHz, s.Band)
|
||||
}
|
||||
})
|
||||
a.reloadCAT()
|
||||
|
||||
// POTA: background poller of api.pota.app so cluster spots can be tagged
|
||||
// when the DX station is currently activating a park. Best-effort.
|
||||
@@ -9450,6 +9455,15 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
if a.qso == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
}
|
||||
// Serialise the WHOLE operation — parse, callsign LOOKUP, dedup and insert —
|
||||
// so simultaneous UDP QSOs are processed one at a time (a queue). A multi-stream
|
||||
// MSHV finishing 4 QSOs at once used to fire 4 concurrent callsign lookups and DB
|
||||
// writes, which the QRZ/HamQTH service (and a remote DB) rejected with "too many
|
||||
// requests". Holding the lock across the lookup spaces them out; the UDP reader
|
||||
// runs on its own goroutine, so this never blocks packet reception — callers just
|
||||
// queue behind each other. (adifwatch shares this lock for the same reason.)
|
||||
a.udpLogMu.Lock()
|
||||
defer a.udpLogMu.Unlock()
|
||||
// Pull the first record out of the payload. WSJT-X / JTDX / MSHV
|
||||
// always send a single QSO per UDP packet (no header) but we tolerate
|
||||
// either form via adif.Parse.
|
||||
@@ -9592,12 +9606,10 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
// a minute (the two apps stamp their own time), so a minute-exact key
|
||||
// missed it and the contact got duplicated.
|
||||
//
|
||||
// The check + insert is guarded by udpLogMu: MSHV/WSJT can deliver the same
|
||||
// logged-QSO packet twice in quick succession (re-broadcast, or two
|
||||
// listeners), and without serialisation both goroutines read the dedup set
|
||||
// BEFORE either inserts, both pass, and the QSO lands twice.
|
||||
a.udpLogMu.Lock()
|
||||
defer a.udpLogMu.Unlock()
|
||||
// The check + insert is covered by udpLogMu (taken at the top of this function):
|
||||
// MSHV/WSJT can deliver the same logged-QSO packet twice in quick succession
|
||||
// (re-broadcast, or two listeners), and without serialisation both goroutines
|
||||
// read the dedup set BEFORE either inserts, both pass, and the QSO lands twice.
|
||||
seen, err := a.qso.ExistingDedupeKeys(a.ctx)
|
||||
if err == nil {
|
||||
base := q.QSODate.UTC()
|
||||
@@ -9611,16 +9623,35 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
|
||||
id, err := a.qso.Add(a.ctx, q)
|
||||
if err != nil {
|
||||
// DB UNREACHABLE (drop / timeout on a slow-or-broken MySQL) — park the QSO
|
||||
// in the offline outbox rather than lose it, exactly like the manual log
|
||||
// path. Without this, a laggy shared MySQL silently dropped UDP-logged QSOs
|
||||
// from a multi-stream MSHV. Returns -1 so the caller treats it as "saved,
|
||||
// waiting to sync", not a failure.
|
||||
if db.IsConnLost(err) && a.queueOffline(q, err) {
|
||||
return -1, nil
|
||||
}
|
||||
return 0, fmt.Errorf("insert qso: %w", err)
|
||||
}
|
||||
q.ID = id
|
||||
a.noteLiveQSO() // multi-op: flip this operator back "online"
|
||||
a.materializeAwardRefs(q)
|
||||
// Announce the log so UI widgets refresh AT ONCE (the Recent-QSOs grid, the
|
||||
// ON-AIR badge and the "stations on air" widget). The manual log path always
|
||||
// did this; the UDP/FT8 path did not, so a station running MSHV saw the top
|
||||
// "on air" list lag ~15-45s behind the instant bottom badge.
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "qso:logged", id)
|
||||
}
|
||||
a.saveQSORecording(&q)
|
||||
if a.extsvc != nil {
|
||||
a.extsvc.OnQSOLogged(id)
|
||||
}
|
||||
a.maybeAutoSendEQSL(q)
|
||||
// A successful write means the link is healthy again — flush anything parked.
|
||||
if a.offlineMode && a.offlineQ != nil && a.offlineQ.Count() > 0 {
|
||||
go func() { _, _ = a.replayOfflineQueue() }()
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,34 @@
|
||||
[
|
||||
{
|
||||
"version": "0.20.6",
|
||||
"date": "2026-07-21",
|
||||
"en": [
|
||||
"Amplifier controls now live right in the FlexRadio panel: OPERATE/STANDBY, ON/OFF, Low/Mid/High power level, an output-power bar and live band / SWR / temperature — for both PowerGenius XL and SPE Expert. The card is hidden when no amplifier is selected.",
|
||||
"Faster startup: the radio (CAT) connects immediately instead of waiting behind the shared-MySQL connection, and MySQL itself connects much faster.",
|
||||
"No more lost QSOs: an FT8/MSHV QSO that can't reach a laggy shared database is parked and re-logged automatically once it recovers; simultaneous QSOs from a multi-stream are now queued instead of failing with 'too many requests'.",
|
||||
"The 'stations on air' widget now updates within a second of a QSO (it used to lag up to ~45s behind the ON-AIR badge).",
|
||||
"Relay boards (Denkovi / USB-serial) stay connected reliably now, with a Test-connection button and detect feedback in the Station Control setup.",
|
||||
"<LOGQSO> in a CW macro logs the contact at that exact point in the macro, and pressing ESC before it runs cancels the log.",
|
||||
"Auto-call now waits exactly the gap you set between calls (it was waiting far longer).",
|
||||
"Statistics: the redundant per-band chart was merged into the CW / phone / data split.",
|
||||
"Cluster self-spot pop-ups now show the band.",
|
||||
"The bottom status bar is no longer hidden behind a scrollbar in a small window.",
|
||||
"Update check now also runs when you open Help - About, and every 5 minutes."
|
||||
],
|
||||
"fr": [
|
||||
"Les commandes d'amplificateur sont maintenant directement dans le panneau FlexRadio : OPERATE/STANDBY, Marche/Arrêt, niveau Low/Mid/High, une barre de puissance de sortie et l'état en direct bande / ROS / température — pour PowerGenius XL comme pour SPE Expert. La carte est masquée si aucun ampli n'est sélectionné.",
|
||||
"Démarrage plus rapide : la radio (CAT) se connecte immédiatement au lieu d'attendre derrière la connexion MySQL partagée, et MySQL lui-même se connecte bien plus vite.",
|
||||
"Plus de QSO perdus : un QSO FT8/MSHV qui n'atteint pas une base partagée lente est mis de côté et ré-enregistré automatiquement dès qu'elle répond ; les QSO simultanés d'un multi-stream sont maintenant mis en file au lieu d'échouer avec « too many requests ».",
|
||||
"Le widget « stations on air » se met à jour en une seconde après un QSO (il pouvait accuser jusqu'à ~45s de retard sur le badge ON AIR).",
|
||||
"Les cartes relais (Denkovi / USB-série) restent connectées de façon fiable, avec un bouton « Tester la connexion » et un retour de détection dans la configuration Station Control.",
|
||||
"<LOGQSO> dans une macro CW enregistre le contact à cet endroit précis de la macro, et appuyer sur ÉCHAP avant son exécution annule l'enregistrement.",
|
||||
"L'appel automatique respecte maintenant exactement l'intervalle réglé entre les appels (il attendait bien plus longtemps).",
|
||||
"Statistiques : le graphique par bande redondant a été fusionné avec la répartition CW / phone / data.",
|
||||
"Les popups d'auto-spot du cluster affichent maintenant la bande.",
|
||||
"La barre de statut du bas n'est plus masquée par une barre de défilement en fenêtre réduite.",
|
||||
"La vérification de mise à jour se déclenche aussi à l'ouverture d'Aide - À propos, et toutes les 5 minutes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.20.5",
|
||||
"date": "2026-07-20",
|
||||
|
||||
+10
-15
@@ -452,9 +452,13 @@ export default function App() {
|
||||
const load = () => GetLiveStations().then((s) => setLiveStations((s ?? []) as LiveStation[])).catch(() => {});
|
||||
load();
|
||||
const id = window.setInterval(load, 5 * 1000);
|
||||
// Small delay lets the async publishLiveStatus MySQL write land before we read.
|
||||
// Refresh the instant the backend actually publishes a row change (a QSO put
|
||||
// someone on air, or a 5-min silence took them off) — so this widget tracks the
|
||||
// bottom ON-AIR badge instead of lagging behind its poll. Keep the qso:logged
|
||||
// fallback too (a small delay lets the async publish land) for older paths.
|
||||
const offStatus = EventsOn('livestatus:updated', load);
|
||||
const offLogged = EventsOn('qso:logged', () => { window.setTimeout(load, 1500); });
|
||||
return () => { window.clearInterval(id); offLogged?.(); };
|
||||
return () => { window.clearInterval(id); offStatus?.(); offLogged?.(); };
|
||||
}, [liveStationsOn]);
|
||||
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
|
||||
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
||||
@@ -2197,20 +2201,11 @@ export default function App() {
|
||||
while (autoCallMacroRef.current === i && gen === autoCallGenRef.current && wkActiveRef.current) {
|
||||
const m = wkMacros[i];
|
||||
if (!m) break;
|
||||
// wkSend now WAITS for the CW to finish sending on its own (per-segment, all
|
||||
// engines), so we must NOT re-wait the estimated duration here — doing both
|
||||
// double-counted the send time and made the gap between calls far longer than
|
||||
// configured (a 4 s gap became ~13 s). Just wait the configured gap.
|
||||
await wkSend(m.text);
|
||||
// Wait for the message to finish before the gap+resend. The Icom keyer has
|
||||
// no busy echo, so just wait the estimated send time. For the WinKeyer, cap
|
||||
// the wait at the ESTIMATED send time (not the busy flag alone): over a
|
||||
// remote/serial-over-IP link the "busy" status lags badly and stays stuck
|
||||
// true for tens of seconds, which made the next CQ fire ~120s late.
|
||||
if (cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex') {
|
||||
await sleep(Math.round(estimateCwMs(resolveCW(m.text), wkWpm)) + 300);
|
||||
} else {
|
||||
const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500;
|
||||
for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start
|
||||
const deadline = Date.now() + capMs;
|
||||
while (wkBusyRef.current && gen === autoCallGenRef.current && Date.now() < deadline) await sleep(50);
|
||||
}
|
||||
if (gen !== autoCallGenRef.current) break;
|
||||
await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.20.5';
|
||||
export const APP_VERSION = '0.20.6';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
+19
-7
@@ -198,14 +198,26 @@ func migrate(conn *sql.DB, translate func(string) string) error {
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, name := range names {
|
||||
var dummy string
|
||||
err := conn.QueryRow(`SELECT name FROM schema_migrations WHERE name = ?`, name).Scan(&dummy)
|
||||
if err == nil {
|
||||
continue // already applied
|
||||
// Fetch every applied migration name in ONE query rather than one round-trip
|
||||
// per migration. On a high-latency remote MySQL those 20+ SELECTs dominated the
|
||||
// connect time (each RTT × migration count) — the whole logbook connect could
|
||||
// take tens of seconds. One SELECT + an in-memory set is effectively instant.
|
||||
applied := map[string]bool{}
|
||||
if rows, err := conn.Query(`SELECT name FROM schema_migrations`); err == nil {
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if rows.Scan(&n) == nil {
|
||||
applied[n] = true
|
||||
}
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return fmt.Errorf("check migration %s: %w", name, err)
|
||||
rows.Close()
|
||||
} else {
|
||||
return fmt.Errorf("read applied migrations: %w", err)
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
if applied[name] {
|
||||
continue // already applied
|
||||
}
|
||||
content, err := migrationsFS.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
|
||||
+20
-5
@@ -137,10 +137,6 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
|
||||
if !validDBIdent(name) {
|
||||
return nil, fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
|
||||
}
|
||||
// Ensure the database exists (connect server-level first).
|
||||
if err := PingMySQL(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn, err := sql.Open("mysql", c.dsn())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open mysql: %w", err)
|
||||
@@ -157,9 +153,28 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
|
||||
// (surfacing as "Unknown database"). Idle connections are still recycled
|
||||
// after 90s, and the driver retries stale pooled connections.
|
||||
conn.SetConnMaxLifetime(0)
|
||||
// Connect DIRECTLY to the database first. Only if that fails — the DB doesn't
|
||||
// exist yet (first-time setup) or the server is unreachable — do we pay for the
|
||||
// server-level connect + CREATE DATABASE (two more handshakes). On a normal
|
||||
// startup against an existing DB this halves the connection round-trips, which
|
||||
// matters a lot on a high-latency remote MySQL (each handshake is several RTTs).
|
||||
if err := conn.Ping(); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("connect to %s: %w", name, err)
|
||||
if perr := PingMySQL(c); perr != nil {
|
||||
return nil, perr // creates the DB (or returns a clear "cannot create" error)
|
||||
}
|
||||
conn, err = sql.Open("mysql", c.dsn())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open mysql: %w", err)
|
||||
}
|
||||
conn.SetMaxOpenConns(50)
|
||||
conn.SetMaxIdleConns(10)
|
||||
conn.SetConnMaxIdleTime(90 * time.Second)
|
||||
conn.SetConnMaxLifetime(0)
|
||||
if err := conn.Ping(); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("connect to %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
// Set the dialect before migrating so the runner takes the MySQL path
|
||||
// (per-statement, idempotent) rather than the SQLite transaction path.
|
||||
|
||||
@@ -6,9 +6,21 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
// emitLiveStatusChanged nudges the frontend to re-read GetLiveStations right when
|
||||
// the shared table actually changes (a row appeared or was removed) — so the
|
||||
// "stations on air" widget updates the instant a QSO is published, matching the
|
||||
// bottom ON-AIR badge instead of waiting for its poll.
|
||||
func (a *App) emitLiveStatusChanged() {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "livestatus:updated", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Live operator status — for multi-operator events on a SHARED MySQL logbook
|
||||
// (e.g. a special-event call like TM74FR with several ops on different bands).
|
||||
// Each OpsLog instance heartbeats its current activity (operator call + station
|
||||
@@ -231,6 +243,7 @@ func (a *App) publishLiveStatus() {
|
||||
if _, err := a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op); err != nil {
|
||||
applog.Printf("livestatus: offline DELETE failed: %v", err)
|
||||
}
|
||||
a.emitLiveStatusChanged()
|
||||
return
|
||||
}
|
||||
lastQSOArg := lastQSO.UTC()
|
||||
@@ -246,6 +259,7 @@ func (a *App) publishLiveStatus() {
|
||||
return
|
||||
}
|
||||
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s ON AIR", op, station, freqHz, band, mode)
|
||||
a.emitLiveStatusChanged()
|
||||
}
|
||||
|
||||
// LiveStation is one operator's live status for the multi-op "who's on air" widget.
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.20.5"
|
||||
appVersion = "0.20.6"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user