Compare commits
5
Commits
be66ac1e19
...
v0.20.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7afe40a48e | ||
|
|
311ea8341f | ||
|
|
a2958d458e | ||
|
|
d9b7e48e83 | ||
|
|
3e9ebdb89a |
@@ -9455,6 +9455,15 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
|||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return 0, fmt.Errorf("db not initialized")
|
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
|
// 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
|
// always send a single QSO per UDP packet (no header) but we tolerate
|
||||||
// either form via adif.Parse.
|
// either form via adif.Parse.
|
||||||
@@ -9597,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
|
// a minute (the two apps stamp their own time), so a minute-exact key
|
||||||
// missed it and the contact got duplicated.
|
// missed it and the contact got duplicated.
|
||||||
//
|
//
|
||||||
// The check + insert is guarded by udpLogMu: MSHV/WSJT can deliver the same
|
// The check + insert is covered by udpLogMu (taken at the top of this function):
|
||||||
// logged-QSO packet twice in quick succession (re-broadcast, or two
|
// MSHV/WSJT can deliver the same logged-QSO packet twice in quick succession
|
||||||
// listeners), and without serialisation both goroutines read the dedup set
|
// (re-broadcast, or two listeners), and without serialisation both goroutines
|
||||||
// BEFORE either inserts, both pass, and the QSO lands twice.
|
// read the dedup set BEFORE either inserts, both pass, and the QSO lands twice.
|
||||||
a.udpLogMu.Lock()
|
|
||||||
defer a.udpLogMu.Unlock()
|
|
||||||
seen, err := a.qso.ExistingDedupeKeys(a.ctx)
|
seen, err := a.qso.ExistingDedupeKeys(a.ctx)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
base := q.QSODate.UTC()
|
base := q.QSODate.UTC()
|
||||||
@@ -9616,16 +9623,35 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
|||||||
|
|
||||||
id, err := a.qso.Add(a.ctx, q)
|
id, err := a.qso.Add(a.ctx, q)
|
||||||
if err != nil {
|
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)
|
return 0, fmt.Errorf("insert qso: %w", err)
|
||||||
}
|
}
|
||||||
q.ID = id
|
q.ID = id
|
||||||
a.noteLiveQSO() // multi-op: flip this operator back "online"
|
a.noteLiveQSO() // multi-op: flip this operator back "online"
|
||||||
a.materializeAwardRefs(q)
|
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)
|
a.saveQSORecording(&q)
|
||||||
if a.extsvc != nil {
|
if a.extsvc != nil {
|
||||||
a.extsvc.OnQSOLogged(id)
|
a.extsvc.OnQSOLogged(id)
|
||||||
}
|
}
|
||||||
a.maybeAutoSendEQSL(q)
|
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
|
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",
|
"version": "0.20.5",
|
||||||
"date": "2026-07-20",
|
"date": "2026-07-20",
|
||||||
|
|||||||
@@ -452,9 +452,13 @@ export default function App() {
|
|||||||
const load = () => GetLiveStations().then((s) => setLiveStations((s ?? []) as LiveStation[])).catch(() => {});
|
const load = () => GetLiveStations().then((s) => setLiveStations((s ?? []) as LiveStation[])).catch(() => {});
|
||||||
load();
|
load();
|
||||||
const id = window.setInterval(load, 5 * 1000);
|
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); });
|
const offLogged = EventsOn('qso:logged', () => { window.setTimeout(load, 1500); });
|
||||||
return () => { window.clearInterval(id); offLogged?.(); };
|
return () => { window.clearInterval(id); offStatus?.(); offLogged?.(); };
|
||||||
}, [liveStationsOn]);
|
}, [liveStationsOn]);
|
||||||
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
|
// 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
|
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// 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).
|
// 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.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
+19
-7
@@ -198,14 +198,26 @@ func migrate(conn *sql.DB, translate func(string) string) error {
|
|||||||
}
|
}
|
||||||
sort.Strings(names)
|
sort.Strings(names)
|
||||||
|
|
||||||
for _, name := range names {
|
// Fetch every applied migration name in ONE query rather than one round-trip
|
||||||
var dummy string
|
// per migration. On a high-latency remote MySQL those 20+ SELECTs dominated the
|
||||||
err := conn.QueryRow(`SELECT name FROM schema_migrations WHERE name = ?`, name).Scan(&dummy)
|
// connect time (each RTT × migration count) — the whole logbook connect could
|
||||||
if err == nil {
|
// take tens of seconds. One SELECT + an in-memory set is effectively instant.
|
||||||
continue // already applied
|
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 {
|
rows.Close()
|
||||||
return fmt.Errorf("check migration %s: %w", name, err)
|
} 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)
|
content, err := migrationsFS.ReadFile("migrations/" + name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+20
-5
@@ -137,10 +137,6 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
|
|||||||
if !validDBIdent(name) {
|
if !validDBIdent(name) {
|
||||||
return nil, fmt.Errorf("invalid database name %q (letters, digits, underscore only)", 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())
|
conn, err := sql.Open("mysql", c.dsn())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open mysql: %w", err)
|
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
|
// (surfacing as "Unknown database"). Idle connections are still recycled
|
||||||
// after 90s, and the driver retries stale pooled connections.
|
// after 90s, and the driver retries stale pooled connections.
|
||||||
conn.SetConnMaxLifetime(0)
|
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 {
|
if err := conn.Ping(); err != nil {
|
||||||
_ = conn.Close()
|
_ = 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
|
// Set the dialect before migrating so the runner takes the MySQL path
|
||||||
// (per-statement, idempotent) rather than the SQLite transaction path.
|
// (per-statement, idempotent) rather than the SQLite transaction path.
|
||||||
|
|||||||
@@ -6,9 +6,21 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
"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
|
// 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).
|
// (e.g. a special-event call like TM74FR with several ops on different bands).
|
||||||
// Each OpsLog instance heartbeats its current activity (operator call + station
|
// 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 {
|
if _, err := a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op); err != nil {
|
||||||
applog.Printf("livestatus: offline DELETE failed: %v", err)
|
applog.Printf("livestatus: offline DELETE failed: %v", err)
|
||||||
}
|
}
|
||||||
|
a.emitLiveStatusChanged()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastQSOArg := lastQSO.UTC()
|
lastQSOArg := lastQSO.UTC()
|
||||||
@@ -246,6 +259,7 @@ func (a *App) publishLiveStatus() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s ON AIR", op, station, freqHz, band, mode)
|
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.
|
// LiveStation is one operator's live status for the multi-op "who's on air" widget.
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// 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
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user