Compare commits

...
17 Commits
Author SHA1 Message Date
rouggy 24a5a0480d chore: release v0.20.7 2026-07-21 18:26:34 +02:00
rouggy 828f99b8ac docs: add Discord server badge to the top of the README (EN/FR) 2026-07-21 17:15:18 +02:00
rouggy 9e5868b839 fix: raise MySQL pool 8->16 — 8 starved the instance's own live-sync polling (revision + grid refresh + live_status + chat), so the grid stopped showing other operators' QSOs; 16 avoids the stall and still fits ~15 ops under max_connections=300 2026-07-21 16:50:48 +02:00
rouggy 40d0ca57c3 diag: log slow QSO inserts (>2s) with call/band/mode; rotate the app log to .1 at 10MB instead of deleting it, so the previous session's diagnostics survive 2026-07-21 16:24:50 +02:00
rouggy aa537f9eea fix: log returns as soon as the QSO row is inserted; award_refs/recording/uploads/eQSL now run off the critical path (both manual and UDP) — a busy multi-op MySQL no longer freezes the entry form ~40s waiting on the award_refs UPDATE 2026-07-21 16:20:49 +02:00
rouggy ea1bd2a66d feat: sync the host CW keyer speed (wkWpm) to the FlexRadio's actual CW speed — including changes made on the radio/SmartSDR, not just the panel slider — so CW-length estimates (auto-call gap) match reality when using Flex CWX 2026-07-21 16:10:15 +02:00
rouggy 1f54656256 fix: <LOGQSO> logs immediately at its position instead of waiting the full CW-duration estimate first (buffered keyers keep sending; logging never interrupts them) — was delaying the log up to ~10s; only the final segment still waits, for auto-call timing 2026-07-21 16:01:15 +02:00
rouggy 54dd109288 docs: changelog entry for 0.20.7 (multi-op MySQL connection fix, By-Continent stats total) 2026-07-21 15:12:44 +02:00
rouggy 2c1d7235f0 fix: shrink MySQL connection pool (50->8) so a multi-op event doesn't exhaust the shared server's max_connections (Error 1040: Too many connections), which was silently failing the on-air widget/award_refs/grid reads and making the UI look frozen 2026-07-21 12:23:38 +02:00
rouggy adf9844d1b fix: stats By Continent donut totals now match the QSO count (bucket blank/unknown continents as 'Unknown' instead of dropping them; keep the Continents metric counting real continents only) 2026-07-21 12:10:00 +02:00
rouggy 7afe40a48e chore: release v0.20.6 2026-07-21 11:57:20 +02:00
rouggy 311ea8341f docs: changelog entry for 0.20.6 (amp card in Flex panel, faster CAT+MySQL startup, no lost QSOs, instant stations-on-air, LOGQSO position, relay fixes) 2026-07-21 11:51:51 +02:00
rouggy a2958d458e fix: 'stations on air' widget updates instantly after a QSO (UDP/FT8 path now emits qso:logged; publishLiveStatus emits livestatus:updated so the widget re-reads exactly when the shared row changes) — was lagging ~15-45s behind the ON-AIR badge 2026-07-21 11:32:16 +02:00
rouggy d9b7e48e83 fix: UDP-logged QSOs fall back to the offline outbox on DB failure (were silently lost); speed up MySQL connect (batch migration checks into 1 query, connect direct-to-DB first) — was ~40s on a high-latency link 2026-07-21 11:18:36 +02:00
rouggy 3e9ebdb89a fix: serialise UDP QSO logging (lookup+dedup+insert) so multi-stream MSHV (4 QSOs at once) queues instead of firing concurrent lookups/DB writes that got 'too many requests' 2026-07-21 11:09:43 +02:00
rouggy be66ac1e19 fix: auto-call gap respected again (wkSend already waits for the send; runAutoCall no longer double-counts it, so a 4s gap is 4s not ~13s) 2026-07-21 10:26:28 +02:00
rouggy 6aab4a6989 fix: start CAT before the MySQL logbook connect — Flex/CAT no longer waits ~30s behind a slow remote-MySQL dial at launch 2026-07-21 10:26:27 +02:00
20 changed files with 1191 additions and 292 deletions
+4
View File
@@ -1,5 +1,9 @@
# OpsLog
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
<img src="https://img.shields.io/badge/Discord-Rejoindre%20le%20serveur-5865F2?logo=discord&logoColor=white" alt="Rejoindre notre Discord" />
</a>
Un logiciel de log radioamateur moderne et rapide pour Windows — saisie façon
Log4OM, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif,
**Icom CI-V** natif (USB **et** à distance par internet, en remplacement de
+4
View File
@@ -1,5 +1,9 @@
# OpsLog
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
<img src="https://img.shields.io/badge/Discord-Join%20the%20server-5865F2?logo=discord&logoColor=white" alt="Join our Discord" />
</a>
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and**
remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert Electronics),
+144 -50
View File
@@ -19,6 +19,7 @@ import (
"sync/atomic"
"time"
"hamlog/internal/acom"
"hamlog/internal/adif"
"hamlog/internal/alerts"
"hamlog/internal/antgenius"
@@ -44,15 +45,15 @@ import (
"hamlog/internal/operating"
"hamlog/internal/pota"
"hamlog/internal/powergenius"
"hamlog/internal/spe"
"hamlog/internal/profile"
"hamlog/internal/qslcard"
"hamlog/internal/qso"
"hamlog/internal/rotator/pst"
"hamlog/internal/relaydev"
"hamlog/internal/rotator/pst"
"hamlog/internal/rotgenius"
"hamlog/internal/settings"
"hamlog/internal/solar"
"hamlog/internal/spe"
"hamlog/internal/steppir"
"hamlog/internal/uls"
"hamlog/internal/ultrabeam"
@@ -466,6 +467,7 @@ type App struct {
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
spe *spe.Client // SPE Expert amplifier (serial/TCP); nil when disabled or not SPE
acom *acom.Client // ACOM 500S/600S/700S/1200S/2020S amplifier (serial/TCP); nil when disabled or not ACOM
audioMgr *audio.Manager
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
@@ -512,6 +514,8 @@ type App struct {
liveMode string
livePublishTimer *time.Timer // debounced live-status publish on activity change
liveLastQSOAt time.Time // when this operator last logged a NEW contact — drives online/offline
liveTableMu sync.Mutex // guards liveTableFor
liveTableFor *sql.DB // logbook whose live_status DDL has been ensured (once per connection, not per call)
awardSnapMu sync.Mutex // guards the award QSO snapshot
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
@@ -814,6 +818,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 1030s 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 +939,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.
@@ -1929,7 +1938,13 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
// was redundant with the entry lookup and only slowed logging (a call not yet in
// 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.
insT0 := time.Now()
id, err = a.qso.Add(a.ctx, q)
if d := time.Since(insT0); d > 2*time.Second {
// Surface a genuinely slow INSERT (DB lock / overloaded server) — this is the
// value we need when someone reports "logging took N seconds".
applog.Printf("log: SLOW qso insert took %s (call=%s band=%s mode=%s)", d.Round(time.Millisecond), q.Callsign, q.Band, q.Mode)
}
if err != nil && db.IsConnLost(err) {
// The database is UNREACHABLE (not a data error) — park the QSO in the
// offline outbox rather than lose it. Returns id = -1 so the UI can say
@@ -1943,25 +1958,36 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
}
if err == nil {
q.ID = id
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh
a.noteLiveQSO() // multi-op: flip this operator back "online"
a.materializeAwardRefs(q) // stamp award_refs so the grid columns show at once
// Announce the log so UI widgets can react (e.g. the Flex panel zeroing RIT).
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh (in-memory)
a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async)
// Announce the log RIGHT AWAY so the grid/UI refresh at once and the entry
// form clears immediately — the operator is not made to wait on the DB.
wruntime.EventsEmit(a.ctx, "qso:logged", id)
a.saveQSORecording(&q)
// Everything below writes to (or reads from) the shared DB. Run it OFF the
// critical path: on a busy multi-op MySQL the award_refs UPDATE alone could
// block ~40s (and hit "Too many connections"), which used to freeze the log
// (form stuck, "…" on the button) until it finished. The QSO row is already
// safely inserted; these just enrich it, so they can lag a beat.
qc := q
go func() {
a.materializeAwardRefs(qc) // fills the award_refs column
a.saveQSORecording(&qc)
if a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
}
a.maybeAutoSendEQSL(q)
// Forward the ADIF of this QSO to any outbound "ADIF message" UDP rows.
a.maybeAutoSendEQSL(qc)
if a.udp != nil {
go a.udp.EmitLoggedADIF(adif.SingleRecordADIF(q))
a.udp.EmitLoggedADIF(adif.SingleRecordADIF(qc))
}
// A successful write means the link is healthy again — if QSOs are still
// parked, get them in now rather than waiting for the next tick.
// Nudge the grid again so the award_refs columns appear once materialised.
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qso:logged", id)
}
// A successful write means the link is healthy — flush any parked QSOs.
if a.offlineMode && a.offlineQ != nil && a.offlineQ.Count() > 0 {
go func() { _, _ = a.replayOfflineQueue() }()
_, _ = a.replayOfflineQueue()
}
}()
}
return id, err
}
@@ -9450,6 +9476,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 +9627,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 +9644,39 @@ 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)
a.saveQSORecording(&q)
a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async)
// Announce the log AT ONCE so the grid / ON-AIR badge / stations-on-air widget
// refresh immediately, then run the DB-heavy enrichment off the critical path
// (same reasoning as the manual path — award_refs on a busy shared MySQL blocked).
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qso:logged", id)
}
qc := q
go func() {
a.materializeAwardRefs(qc)
a.saveQSORecording(&qc)
if a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
}
a.maybeAutoSendEQSL(q)
a.maybeAutoSendEQSL(qc)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qso:logged", id) // refresh again so award_refs show
}
if a.offlineMode && a.offlineQ != nil && a.offlineQ.Count() > 0 {
_, _ = a.replayOfflineQueue()
}
}()
return id, nil
}
@@ -11290,7 +11346,6 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
return nil
}
// RotatorHeading is the live antenna heading for the status bar.
type RotatorHeading struct {
Enabled bool `json:"enabled"`
@@ -12217,11 +12272,12 @@ func (a *App) AntGeniusDeselect(port int) error {
// ── PowerGenius XL (4O3A) amplifier fan control (TCP, default port 9008) ─────
// PGXLSettings is the JSON shape for the Hardware → Amplifier panel. It covers
// the 4O3A PowerGenius XL (TCP) and the SPE Expert amps (USB serial or an
// RS232-to-Ethernet bridge). The type name is kept for binding compatibility.
// the 4O3A PowerGenius XL (TCP), the SPE Expert amps and the ACOM S-series amps
// (both: USB serial or an RS232-to-Ethernet bridge). The type name is kept for
// binding compatibility.
type PGXLSettings struct {
Enabled bool `json:"enabled"`
Type string `json:"type"` // "pgxl" | "spe13" | "spe15" | "spe2k"
Type string `json:"type"` // "pgxl" | "spe13" | "spe15" | "spe2k" | "acom500" | "acom600" | "acom700" | "acom1200" | "acom2020"
Transport string `json:"transport"` // "tcp" | "serial"
Host string `json:"host"` // TCP transport
Port int `json:"port"` // TCP transport
@@ -12302,6 +12358,10 @@ func (a *App) startPGXL() {
go a.spe.Stop()
a.spe = nil
}
if a.acom != nil {
go a.acom.Stop()
a.acom = nil
}
s, err := a.GetPGXLSettings()
if err != nil || !s.Enabled {
return
@@ -12314,13 +12374,18 @@ func (a *App) startPGXL() {
_ = a.pgxl.Start()
return
}
// SPE Expert — USB serial or an RS232-to-Ethernet bridge.
// SPE Expert / ACOM — USB serial or an RS232-to-Ethernet bridge.
if s.Transport == "serial" && strings.TrimSpace(s.ComPort) == "" {
return
}
if s.Transport == "tcp" && strings.TrimSpace(s.Host) == "" {
return
}
if model, ok := strings.CutPrefix(s.Type, "acom"); ok {
a.acom = acom.New(acom.Config{Model: model + "S", Transport: s.Transport, ComPort: s.ComPort, Baud: s.Baud, Host: s.Host, Port: s.Port})
_ = a.acom.Start()
return
}
a.spe = spe.New(spe.Config{Transport: s.Transport, ComPort: s.ComPort, Baud: s.Baud, Host: s.Host, Port: s.Port})
_ = a.spe.Start()
}
@@ -12361,6 +12426,35 @@ func (a *App) SPESetPowerLevel(level string) error {
return a.spe.SetPowerLevel(level)
}
// GetACOMStatus returns the ACOM amplifier state for the UI poll.
func (a *App) GetACOMStatus() acom.Status {
if a.acom == nil {
return acom.Status{}
}
return a.acom.GetStatus()
}
// ACOMSetOperate puts the ACOM amp in OPERATE (true) or STANDBY (false) — the
// protocol has explicit commands for each, unlike the SPE's toggle key.
func (a *App) ACOMSetOperate(on bool) error {
if a.acom == nil {
return fmt.Errorf("ACOM amplifier not connected — enable it in Settings → Amplifier")
}
return a.acom.Operate(on)
}
// ACOMSetPower turns the ACOM amp on (true, a DTR/RTS pulse — serial only, needs
// the power-on pins wired in the cable) or off (false, the OFF data command).
func (a *App) ACOMSetPower(on bool) error {
if a.acom == nil {
return fmt.Errorf("ACOM amplifier not connected — enable it in Settings → Amplifier")
}
if on {
return a.acom.PowerOn()
}
return a.acom.PowerOff()
}
// GetPGXLStatus returns the amp's fan/connection state for the UI poll.
func (a *App) GetPGXLStatus() powergenius.Status {
if a.pgxl == nil {
+46
View File
@@ -1,4 +1,50 @@
[
{
"version": "0.20.7",
"date": "2026-07-21",
"en": [
"New: ACOM amplifier support (500S / 600S / 700S / 1200S / 2020S) — OPERATE/STANDBY/OFF, live telemetry (power, SWR, PA temperature, band, fan) in Settings and the radio panel, over USB serial or an RS232-to-Ethernet bridge. Power-ON works over serial when the cable wires the DTR/RTS pins. The Amplifier settings now pick Brand then Model.",
"Multi-operator live sync fixed: the shared-MySQL connection pool was too small, so after a while the grid, chat and 'stations on air' silently stopped updating. The pool is now sized for real multi-op use (with 5 operators it stays well within a max_connections=300 server).",
"Live operator status is now always published on a shared MySQL logbook — the Settings toggle is gone. You still go off-air automatically 5 minutes after your last QSO, and the 'stations on air' panel refreshes noticeably faster (a redundant table check on every read was removed).",
"The By-Continent statistics donut now totals the same as your QSO count — QSOs whose continent couldn't be resolved are shown as an 'Unknown' slice instead of being dropped (the Continents count still lists only real continents)."
],
"fr": [
"Nouveau : prise en charge des amplificateurs ACOM (500S / 600S / 700S / 1200S / 2020S) — OPERATE/STANDBY/OFF, télémétrie en direct (puissance, ROS, température PA, bande, ventilateur) dans les Réglages et le panneau radio, en USB série ou via un pont RS232-vers-Ethernet. La mise en marche fonctionne en série si le câble relie les broches DTR/RTS. Les réglages Amplificateur se font maintenant par Marque puis Modèle.",
"Synchronisation multi-opérateurs corrigée : le pool de connexions MySQL partagé était trop petit, et au bout d'un moment la grille, le chat et « stations on air » cessaient silencieusement de se mettre à jour. Le pool est maintenant dimensionné pour un vrai multi-op (à 5 opérateurs, on reste largement sous un serveur à max_connections=300).",
"Le statut opérateur en direct est maintenant toujours publié sur un logbook MySQL partagé — l'option des Réglages a disparu. Vous passez toujours hors antenne automatiquement 5 minutes après votre dernier QSO, et le panneau « stations on air » se rafraîchit nettement plus vite (une vérification de table redondante à chaque lecture a été supprimée).",
"La donut « Par continent » des statistiques totalise maintenant le même nombre que le total de QSO — les QSO dont le continent n'a pas pu être résolu apparaissent dans un segment « Unknown » au lieu d'être ignorés (le compteur Continents ne liste toujours que les vrais continents)."
]
},
{
"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",
+41 -40
View File
@@ -42,7 +42,7 @@ import {
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
GetAwardDefs,
GetUIPref, GetActiveProfile, QuitApp,
ReportLiveActivity, GetLiveStatusEnabled, LiveLastQSOAgeSec,
ReportLiveActivity, LiveLastQSOAgeSec,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs';
@@ -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
@@ -1117,22 +1121,22 @@ export default function App() {
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
// "ON AIR" status-bar badge: mirrors the multi-op live status this operator
// publishes — online (blinking) when a QSO was logged in the last 5 min, else
// offline. Only shown when live-status publishing is enabled (Settings→General).
const [liveStatusOn, setLiveStatusOn] = useState(false);
// offline. Publishing is always on for a shared MySQL logbook (no user toggle:
// 5 min without a QSO removes the row automatically), so the badge simply
// follows the backend.
const [onAir, setOnAir] = useState(false);
useEffect(() => { if (!showSettings) GetLiveStatusEnabled().then((v) => setLiveStatusOn(!!v)).catch(() => {}); }, [showSettings]);
useEffect(() => {
// Read the ON-AIR state straight from the backend (single source of truth:
// liveLastQSOAt, stamped on every log and seeded from the DB at launch). Poll
// it + refresh on each logged QSO — no fragile frontend timestamp to drift.
const refresh = () => LiveLastQSOAgeSec()
.then((sec: number) => setOnAir(liveStatusOn && typeof sec === 'number' && sec >= 0 && sec < 300))
.then((sec: number) => setOnAir(typeof sec === 'number' && sec >= 0 && sec < 300))
.catch(() => {});
refresh();
const off = EventsOn('qso:logged', refresh);
const id = window.setInterval(refresh, 5 * 1000); // responsive without hammering (cheap 400-row scan)
const id = window.setInterval(refresh, 5 * 1000); // responsive without hammering (single indexed row)
return () => { off(); window.clearInterval(id); };
}, [liveStatusOn]);
}, []);
// QSO-rate meter (10/60 min) in the header — opt-in via Settings→General.
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]);
@@ -2143,39 +2147,45 @@ export default function App() {
// Split the macro on <LOGQSO> so the log happens AT the token's position, not
// only at the very end: "TU <LOGQSO> QRZ" sends "TU", logs, then sends "QRZ".
// Each split boundary (every part except the last) is one <LOGQSO>.
const parts = rawText.split(/<LOGQSO>/i);
// RESOLVE every segment up front — while the form still holds the callsign — so a
// segment AFTER the <LOGQSO> (which logs and clears the form) still expands its
// variables correctly.
const parts = rawText.split(/<LOGQSO>/i).map((pt) => resolveCW(pt));
const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex';
for (let p = 0; p < parts.length; p++) {
if (aborted()) return; // ESC / Stop before this segment → stop sending, don't log
const resolved = resolveCW(parts[p]);
const resolved = parts[p];
const isLast = p === parts.length - 1;
const logAfter = !isLast; // a <LOGQSO> follows this segment
if (resolved) {
// Trailing word space so back-to-back sends don't run together in the keyer
// buffer ("CQ"+"TEST" → "CQTEST"); the keyer keys it as a word gap at the
// current WPM, so it scales automatically.
const keyed = resolved + ' ';
setWkSent(resolved);
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : null;
if (sendFn) await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
else await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
// Only WAIT for the CW to finish on the FINAL segment — auto-call needs the
// send done before its gap+resend. A segment a <LOGQSO> follows is NOT waited
// on: the keyer has already buffered the CW, so logging happens AT ONCE and
// never interrupts what's being sent. (Gating the log on a CW-length ESTIMATE
// made <LOGQSO> log up to ~10s late when the estimate ran long.)
if (isLast) {
if (isRig) {
// The rig keyer (Icom 0x17 / Flex CWX) gives no busy echo we track, so
// wait the ESTIMATED send duration before moving on / logging.
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : IcomSendCW;
await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + (p < parts.length - 1 ? 200 : 600));
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + 600);
} else {
await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
// Wait for the keyer to actually finish this segment before we log /
// continue. We'd like to watch busy rise then fall, but over a
// remote/serial-over-IP link that echo can lag tens of seconds, so cap
// the wait at the ESTIMATED send duration + slack: proceed when busy
// clears OR the estimate elapses, whichever comes first.
// WinKeyer: watch the busy echo, capped by the estimate (+slack) since
// over a remote/serial-over-IP link that echo can lag tens of seconds.
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500;
for (let i = 0; i < 20 && !wkBusyRef.current && !aborted(); i++) await sleep(50); // ≤1s to start
const deadline = Date.now() + capMs;
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
}
}
}
if (aborted()) return; // aborted while this segment was sending → don't log
// A <LOGQSO> token follows every part except the last → log the contact here.
if (p < parts.length - 1) void save();
if (logAfter) void save(); // log NOW — the buffered CW keeps sending
}
}
// stopAutoCall cancels any running auto-call loop.
@@ -2197,20 +2207,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
}
@@ -3674,7 +3675,7 @@ export default function App() {
case 'flex':
return (
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
<FlexPanel onCWSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }}
<FlexPanel onCWSpeed={(w) => { if (cwSourceRef.current === 'flex') setWkWpm(w); }}
onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
</div>
);
@@ -4992,7 +4993,7 @@ export default function App() {
backend is a FlexRadio. */}
{catState.backend === 'flex' && (
<TabsContent value="flex" className="flex-1 min-h-0 p-0">
<FlexPanel onCWSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }}
<FlexPanel onCWSpeed={(w) => { if (cwSourceRef.current === 'flex') setWkWpm(w); }}
onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
</TabsContent>
)}
@@ -5121,7 +5122,7 @@ export default function App() {
disabled={!rotatorHeading.enabled}
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
/>
{liveStatusOn && (
{liveStationsOn && (
<div
className={cn('inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider shrink-0 transition-colors',
onAir ? 'border-danger-border bg-danger-muted text-danger-muted-foreground' : 'border-border text-muted-foreground')}
+73 -4
View File
@@ -5,6 +5,7 @@ import {
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode, GetPGXLSettings, GetSPEStatus, SPESetOperate, SPESetPower, SPESetPowerLevel,
GetACOMStatus, ACOMSetOperate, ACOMSetPower,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
@@ -288,6 +289,18 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
const { t } = useI18n();
const [st, setSt] = useState<FlexState>(ZERO);
const hold = useRef<Record<string, number>>({});
// Keep the host's keyer speed (used for CW-length estimates and the keyer panel)
// in step with the radio's ACTUAL CW speed — including when it's changed on the
// radio / in SmartSDR, not just via the slider below. Notify only on a real change.
const lastCwSpeed = useRef<number | null>(null);
useEffect(() => {
const w = st.cw_speed;
if (typeof w === 'number' && w > 0 && w !== lastCwSpeed.current) {
lastCwSpeed.current = w;
onCWSpeed?.(w);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [st.cw_speed]);
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
// read steadily instead of jumping every poll.
const peak = useRef<Record<string, { v: number; t: number }>>({});
@@ -340,7 +353,8 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
const id = window.setInterval(load, 5000);
return () => { alive = false; window.clearInterval(id); };
}, []);
const isSPE = ampEnabled && ampType !== 'pgxl';
const isACOM = ampEnabled && ampType.startsWith('acom');
const isSPE = ampEnabled && !isACOM && ampType !== 'pgxl';
// SPE Expert live status (only polled when an SPE amp is configured).
const [spe, setSpe] = useState<any>({ connected: false });
useEffect(() => {
@@ -351,6 +365,16 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
const id = window.setInterval(tick, 1500);
return () => { alive = false; window.clearInterval(id); };
}, [isSPE]);
// ACOM live status (only polled when an ACOM amp is configured).
const [acom, setAcom] = useState<any>({ connected: false });
useEffect(() => {
if (!isACOM) return;
let alive = true;
const tick = () => GetACOMStatus().then((s: any) => alive && setAcom(s || { connected: false })).catch(() => {});
tick();
const id = window.setInterval(tick, 1500);
return () => { alive = false; window.clearInterval(id); };
}, [isACOM]);
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
hold.current[key] = Date.now() + 900;
@@ -860,10 +884,55 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
</Card>
)}
{/* ACOM amplifier (serial/TCP) — shown when it's the configured amp. Driven
by OpsLog's own ACOM link (the Flex doesn't report ACOM amps). */}
{isACOM && (
<Card icon={Flame} title={`${t('flxp.amplifier')} · ACOM${acom.model ? ' ' + acom.model : ''}`} accent="#ea580c">
<div className="flex items-center gap-3 flex-wrap">
<button type="button" disabled={!acom.connected}
onClick={() => ACOMSetOperate(!acom.operate).catch(() => {})}
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
acom.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{acom.operate ? 'OPERATE' : 'STANDBY'}
</button>
{/* Power ON is a DTR/RTS pulse — serial only, and it works while the amp
is off (the port stays open), so gate on port_open not connected. */}
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
<button type="button" disabled={!(acom.port_open && acom.transport === 'serial')}
onClick={() => ACOMSetPower(true).catch(() => {})}
title={acom.transport === 'serial' ? 'Power on (DTR/RTS pulse — needs the power-on pins wired)' : 'Power-on needs the serial DTR/RTS lines — not available over a network bridge'}
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
<button type="button" disabled={!acom.connected}
onClick={() => ACOMSetPower(false).catch(() => {})}
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
</div>
<span className={cn('inline-flex items-center gap-1.5 text-sm', acom.connected ? 'text-muted-foreground' : 'text-danger')}>
<span className={cn('size-2 rounded-full', acom.connected ? 'bg-success' : 'bg-danger')} />
{acom.connected ? acom.state : t('flxp.acomOffline')}
</span>
{acom.connected && (
<span className="text-sm font-mono text-muted-foreground tabular-nums">
{acom.band ? `${acom.band} · ` : ''}{acom.fwd_w}W · SWR {Number(acom.swr ?? 0).toFixed(1)} · {acom.temp_c}°C · Fan {acom.fan}
</span>
)}
<div className="flex-1" />
{acom.err_text && (
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold"> {acom.err_text}</span>
)}
</div>
{acom.connected && (
<MeterBar label={t('flxp.outputPower')} value={Number(acom.fwd_w) || 0} unit="W"
lo={0} hi={Number(acom.max_w) || 800}
display={`${Number(acom.fwd_w) || 0} W`}
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
)}
</Card>
)}
{/* External amplifier (PowerGenius XL) — only when the Flex reports one AND
PowerGenius is the selected amp type. Running an SPE Expert hides this
Flex-reported card so the two amps don't both show. */}
{st.amp_available && !isSPE && (
PowerGenius is the selected amp type. Running an SPE Expert or ACOM hides
this Flex-reported card so two amps don't both show. */}
{st.amp_available && !isSPE && !isACOM && (
<Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
<div className="flex items-center gap-3">
<button type="button" disabled={off}
+106 -34
View File
@@ -13,7 +13,7 @@ import {
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
GetAntGeniusSettings, SaveAntGeniusSettings,
GetPGXLSettings, SavePGXLSettings, GetSPEStatus, SPESetOperate,
GetPGXLSettings, SavePGXLSettings, GetSPEStatus, SPESetOperate, GetACOMStatus, ACOMSetOperate,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
@@ -32,7 +32,6 @@ import {
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
GetTelemetryEnabled, SetTelemetryEnabled,
GetLiveStatusEnabled, SetLiveStatusEnabled,
GetQSLDefaults, SaveQSLDefaults,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
GetPOTAToken, SavePOTAToken,
@@ -566,26 +565,6 @@ function TelemetryToggle() {
);
}
// LiveStatusToggle publishes this operator's current activity (call + band +
// freq + mode) to the shared MySQL `live_status` table every ~15s, for multi-op
// events — a small web script on your server renders it for the QRZ page. Only
// useful on a MySQL logbook. Self-contained component (owns its async state).
function LiveStatusToggle() {
const { t } = useI18n();
const [on, setOn] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
GetLiveStatusEnabled().then((v) => setOn(!!v)).catch(() => {}).finally(() => setLoaded(true));
}, []);
return (
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={on} disabled={!loaded}
onCheckedChange={(c) => { const v = !!c; setOn(v); SetLiveStatusEnabled(v).catch(() => {}); }} />
{t('settings.liveStatus')} <span className="text-xs text-muted-foreground">({t('settings.liveStatusHint')})</span>
</label>
);
}
// ADIFMonitorPanel watches a list of external ADIF files (fldigi RTTY, N1MM,
// VarAC…) and auto-imports newly appended QSOs — deliberately option-free: a
// contact that arrives is imported and uploaded automatically like any log entry.
@@ -691,6 +670,47 @@ function SPEStatusCard() {
</div>
);
}
// Live ACOM amplifier status + OPERATE/STANDBY. Module-scoped for the same
// remount reason as SPEStatusCard. Polls once a second while shown.
function ACOMStatusCard() {
const [st, setSt] = useState<any>({ connected: false });
useEffect(() => {
let alive = true;
const tick = () => GetACOMStatus().then((s) => alive && setSt(s || {})).catch(() => {});
tick();
const id = window.setInterval(tick, 1000);
return () => { alive = false; window.clearInterval(id); };
}, []);
const operate = !!st.operate;
return (
<div className="rounded-md border border-border p-3 space-y-2 text-xs max-w-xl">
<div className="flex items-center gap-2">
<span className={cn('size-2 rounded-full', st.connected ? 'bg-success animate-pulse' : 'bg-danger')} />
<span className="font-semibold">{st.connected ? `ACOM ${st.model || ''}` : `ACOM ${st.model || ''} — not connected`}</span>
{!st.connected && st.last_error && <span className="text-danger truncate text-[10px]">{st.last_error}</span>}
<span className="flex-1" />
<Button size="sm" variant={operate ? 'default' : 'outline'} disabled={!st.connected}
onClick={() => ACOMSetOperate(!operate).catch(() => {})}
title="Toggle OPERATE / STANDBY">
{operate ? 'OPERATE' : 'STANDBY'}
</Button>
</div>
{st.connected && (
<div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]">
<div>{st.state}</div>
<div>Band {st.band || '—'}</div>
<div>{st.fwd_w} W</div>
<div>SWR {Number(st.swr ?? 0).toFixed(2)}</div>
<div>Refl {st.refl_w} W</div>
<div>Drive {st.drive_w} W</div>
<div>{st.temp_c}°C</div>
<div>Fan {st.fan}</div>
{st.err_text && <div className="col-span-4 text-warning"> {st.err_text} ({st.err_code})</div>}
</div>
)}
</div>
);
}
function RelayAutoPanel() {
const { t } = useI18n();
const [enabled, setEnabled] = useState(false);
@@ -2690,7 +2710,32 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
function PGXLPanelSettings() {
const isPGXL = pgxl.type === 'pgxl';
const isACOM = (pgxl.type || '').startsWith('acom');
const isSerial = pgxl.transport === 'serial';
// The stored `type` stays the single flat value ("spe13", "acom700", "pgxl" —
// binding compatibility); the UI presents it as brand + model.
const brand = isPGXL ? 'pgxl' : isACOM ? 'acom' : 'spe';
const brandModels: Record<string, { value: string; label: string }[]> = {
spe: [
{ value: 'spe13', label: 'Expert 1.3K-FA' },
{ value: 'spe15', label: 'Expert 1.5K-FA' },
{ value: 'spe2k', label: 'Expert 2K-FA' },
],
acom: [
{ value: 'acom500', label: '500S' },
{ value: 'acom600', label: '600S' },
{ value: 'acom700', label: '700S' },
{ value: 'acom1200', label: '1200S' },
{ value: 'acom2020', label: '2020S' },
],
};
// Each family has a fixed serial speed: SPE talks 115200, the ACOM S-series is
// 9600 8N1 — preset it so switching brand just works. PGXL is TCP-only.
const applyType = (v: string) => setPgxl((s) => ({
...s, type: v,
transport: v === 'pgxl' ? 'tcp' : s.transport,
baud: v.startsWith('acom') ? 9600 : v.startsWith('spe') ? 115200 : s.baud,
}));
return (
<>
<SectionHeader title="Amplifier" />
@@ -2700,21 +2745,44 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
Enable amplifier control
</label>
<div className="grid grid-cols-2 gap-3">
{/* Connection gets the widest column — "Network (RS232-to-Ethernet)" was
truncated with 3 equal columns. */}
<div className="grid grid-cols-[1fr_1fr_1.7fr] gap-3">
<div className="space-y-1">
<Label>Amplifier</Label>
<Select value={pgxl.type} onValueChange={(v) => setPgxl((s) => ({ ...s, type: v, transport: v === 'pgxl' ? 'tcp' : s.transport }))}>
<Label>Brand</Label>
<Select value={brand} onValueChange={(b) => {
if (b === brand) return;
applyType(b === 'pgxl' ? 'pgxl' : brandModels[b][0].value);
}}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pgxl">4O3A PowerGenius XL</SelectItem>
<SelectItem value="spe13">SPE Expert 1.3K-FA</SelectItem>
<SelectItem value="spe15">SPE Expert 1.5K-FA</SelectItem>
<SelectItem value="spe2k">SPE Expert 2K-FA</SelectItem>
<SelectItem value="pgxl">4O3A</SelectItem>
<SelectItem value="spe">SPE</SelectItem>
<SelectItem value="acom">ACOM</SelectItem>
</SelectContent>
</Select>
</div>
{/* PowerGenius is TCP-only; SPE amps connect over USB serial or an
RS232-to-Ethernet bridge, so they offer both. */}
<div className="space-y-1">
<Label>Model</Label>
{isPGXL ? (
// The PowerGenius is the brand's single model — fixed, no choice.
<Select value="pgxl" disabled>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pgxl">PowerGenius XL</SelectItem>
</SelectContent>
</Select>
) : (
<Select value={pgxl.type} onValueChange={applyType}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{brandModels[brand].map((m) => <SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>)}
</SelectContent>
</Select>
)}
</div>
{/* PowerGenius is TCP-only; SPE and ACOM amps connect over USB serial or
an RS232-to-Ethernet bridge, so they offer both. */}
{!isPGXL && (
<div className="space-y-1">
<Label>Connection</Label>
@@ -2767,12 +2835,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
)}
{!isPGXL && pgxl.enabled && <SPEStatusCard />}
{!isPGXL && (
{!isPGXL && pgxl.enabled && (isACOM ? <ACOMStatusCard /> : <SPEStatusCard />)}
{!isPGXL && !isACOM && (
<p className="text-[10px] text-muted-foreground">
SPE control uses the amplifier's proprietary serial protocol (OPERATE toggle + live status). Save to (re)connect. Band / power-level / antenna are still managed on the amp from the transceiver CAT.
</p>
)}
{isACOM && (
<p className="text-[10px] text-muted-foreground">
ACOM control uses the amplifier's serial protocol (9600 8N1): OPERATE / STANDBY / OFF + live telemetry. Save to (re)connect. Power-ON needs the serial DTR/RTS pins wired in the cable (not available over a network bridge). Band tracking stays on the amp itself.
</p>
)}
</div>
</>
);
@@ -4515,7 +4588,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
{t('gen.checkUpdates')} <span className="text-xs text-muted-foreground">{t('gen.checkUpdatesHint')}</span>
</label>
<TelemetryToggle />
<LiveStatusToggle />
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} icomAvailable={icomAvailable} />
+2 -6
View File
@@ -92,8 +92,6 @@ const en: Dict = {
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
'settings.telemetry': 'Send anonymous usage statistics',
'settings.telemetryHint': 'install ID + version + OS, once a day — no callsign or QSO data',
'settings.liveStatus': 'Publish live operator status',
'settings.liveStatusHint': 'multi-op on shared MySQL — feeds a QRZ live page',
'settings.mainView': 'Main view',
'settings.mainViewHint': 'Choose what the Main tab shows on each side (per profile).',
'settings.leftPane': 'Left pane', 'settings.rightPane': 'Right pane',
@@ -283,7 +281,7 @@ const en: Dict = {
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band',
'flxp.ritHint': 'RIT — shifts your RECEIVE frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.xitHint': 'XIT — shifts your TRANSMIT frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.',
'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay',
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.voltage': 'VOLTAGE', 'flxp.paTemp': 'PA TEMP', 'flxp.txFilter': 'TX filter', 'flxp.micProfile': 'Mic profile', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER', 'flxp.outputPower': 'OUTPUT POWER', 'flxp.speOffline': 'SPE offline',
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.voltage': 'VOLTAGE', 'flxp.paTemp': 'PA TEMP', 'flxp.txFilter': 'TX filter', 'flxp.micProfile': 'Mic profile', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER', 'flxp.outputPower': 'OUTPUT POWER', 'flxp.speOffline': 'SPE offline', 'flxp.acomOffline': 'ACOM offline',
'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope 50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?',
'rst.clickToFill': 'Click to set RST tx from the signal',
'qrz.openTitle': 'Open {call} on QRZ.com',
@@ -407,8 +405,6 @@ const fr: Dict = {
'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.",
'settings.telemetry': "Envoyer des statistiques d'usage anonymes",
'settings.telemetryHint': "ID d'installation + version + OS, une fois par jour — aucun indicatif ni donnée QSO",
'settings.liveStatus': 'Publier le statut opérateur en direct',
'settings.liveStatusHint': 'multi-op sur MySQL partagé — alimente une page live QRZ',
'settings.mainView': 'Vue principale',
'settings.mainViewHint': "Choisis ce que l'onglet Principal affiche de chaque côté (par profil).",
'settings.leftPane': 'Volet gauche', 'settings.rightPane': 'Volet droit',
@@ -581,7 +577,7 @@ const fr: Dict = {
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour nafficher que la bande courante',
'flxp.ritHint': "RIT — décale uniquement ta fréquence de RÉCEPTION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.xitHint': "XIT — décale uniquement ta fréquence d'ÉMISSION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.",
'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai',
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.voltage': 'TENSION', 'flxp.paTemp': 'TEMP PA', 'flxp.txFilter': 'Filtre TX', 'flxp.micProfile': 'Profil micro', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR', 'flxp.outputPower': 'PUISSANCE DE SORTIE', 'flxp.speOffline': 'SPE hors ligne',
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.voltage': 'TENSION', 'flxp.paTemp': 'TEMP PA', 'flxp.txFilter': 'Filtre TX', 'flxp.micProfile': 'Profil micro', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR', 'flxp.outputPower': 'PUISSANCE DE SORTIE', 'flxp.speOffline': 'SPE hors ligne', 'flxp.acomOffline': 'ACOM hors ligne',
'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope 50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?',
'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal',
'qrz.openTitle': 'Ouvrir {call} sur QRZ.com',
+1 -1
View File
@@ -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.7';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+7 -4
View File
@@ -5,6 +5,7 @@ import {qso} from '../models';
import {main} from '../models';
import {cat} from '../models';
import {profile} from '../models';
import {acom} from '../models';
import {antgenius} from '../models';
import {award} from '../models';
import {awardref} from '../models';
@@ -23,6 +24,10 @@ import {lotwusers} from '../models';
import {lookup} from '../models';
import {netctl} from '../models';
export function ACOMSetOperate(arg1:boolean):Promise<void>;
export function ACOMSetPower(arg1:boolean):Promise<void>;
export function ADIFFields():Promise<Array<adif.FieldDef>>;
export function ADIFVersion():Promise<string>;
@@ -289,6 +294,8 @@ export function FlexStopCW():Promise<void>;
export function FlexTune(arg1:boolean):Promise<void>;
export function GetACOMStatus():Promise<acom.Status>;
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
export function GetActiveProfile():Promise<profile.Profile>;
@@ -365,8 +372,6 @@ export function GetListsSettings():Promise<main.ListsSettings>;
export function GetLiveStations():Promise<Array<main.LiveStation>>;
export function GetLiveStatusEnabled():Promise<boolean>;
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
export function GetLogFilePath():Promise<string>;
@@ -813,8 +818,6 @@ export function SetCompactMode(arg1:boolean):Promise<void>;
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
export function SetLiveStatusEnabled(arg1:boolean):Promise<void>;
export function SetPassphrase(arg1:string):Promise<void>;
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
+12 -8
View File
@@ -2,6 +2,14 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function ACOMSetOperate(arg1) {
return window['go']['main']['App']['ACOMSetOperate'](arg1);
}
export function ACOMSetPower(arg1) {
return window['go']['main']['App']['ACOMSetPower'](arg1);
}
export function ADIFFields() {
return window['go']['main']['App']['ADIFFields']();
}
@@ -534,6 +542,10 @@ export function FlexTune(arg1) {
return window['go']['main']['App']['FlexTune'](arg1);
}
export function GetACOMStatus() {
return window['go']['main']['App']['GetACOMStatus']();
}
export function GetADIFMonitor() {
return window['go']['main']['App']['GetADIFMonitor']();
}
@@ -686,10 +698,6 @@ export function GetLiveStations() {
return window['go']['main']['App']['GetLiveStations']();
}
export function GetLiveStatusEnabled() {
return window['go']['main']['App']['GetLiveStatusEnabled']();
}
export function GetLoTWUsersStatus() {
return window['go']['main']['App']['GetLoTWUsersStatus']();
}
@@ -1582,10 +1590,6 @@ export function SetDVKLabel(arg1, arg2) {
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
}
export function SetLiveStatusEnabled(arg1) {
return window['go']['main']['App']['SetLiveStatusEnabled'](arg1);
}
export function SetPassphrase(arg1) {
return window['go']['main']['App']['SetPassphrase'](arg1);
}
+55
View File
@@ -1,3 +1,58 @@
export namespace acom {
export class Status {
connected: boolean;
port_open: boolean;
transport: string;
last_error?: string;
model?: string;
state?: string;
operate: boolean;
tx: boolean;
fwd_w: number;
refl_w: number;
swr: number;
drive_w: number;
dc_w: number;
temp_c: number;
band?: string;
fan: number;
err_code: number;
err_text?: string;
nominal_w: number;
max_w: number;
static createFrom(source: any = {}) {
return new Status(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.connected = source["connected"];
this.port_open = source["port_open"];
this.transport = source["transport"];
this.last_error = source["last_error"];
this.model = source["model"];
this.state = source["state"];
this.operate = source["operate"];
this.tx = source["tx"];
this.fwd_w = source["fwd_w"];
this.refl_w = source["refl_w"];
this.swr = source["swr"];
this.drive_w = source["drive_w"];
this.dc_w = source["dc_w"];
this.temp_c = source["temp_c"];
this.band = source["band"];
this.fan = source["fan"];
this.err_code = source["err_code"];
this.err_text = source["err_text"];
this.nominal_w = source["nominal_w"];
this.max_w = source["max_w"];
}
}
}
export namespace adif {
export class ExportResult {
+504
View File
@@ -0,0 +1,504 @@
// Package acom drives the ACOM solid-state amplifiers (500S / 600S / 700S /
// 1200S / 2020S) over their (unpublished) RS-232 protocol, reverse-engineered by
// the ACOM-Controller project (bjornekelund, C#). The amp is reached either
// directly over a serial COM port (9600 8N1) or over TCP via an RS232-to-Ethernet
// bridge — both are just an io.ReadWriteCloser to this code, same as the SPE
// backend.
//
// Wire format (host → amp): fixed raw byte strings, validated by the same rule as
// telemetry (sum of ALL frame bytes ≡ 0 mod 256):
//
// enable telemetry: 55 92 04 15
// disable telemetry: 55 91 04 16
// OPERATE: 55 81 08 02 00 06 00 1A
// STANDBY: 55 81 08 02 00 05 00 1B
// OFF (power down): 55 81 08 02 00 0A 00 16
//
// Power ON is NOT a data command: it is a hardware pulse on the serial DTR/RTS
// lines (the amp's remote power-on pins) — serial transport only, and only if the
// cable wires those pins (a telemetry-only cable uses just RX/TX/GND).
//
// IMPORTANT: DTR and RTS must be held LOW at all times otherwise — asserting them
// permanently blocks the amplifier's front-panel power button.
//
// Telemetry (amp → host): once enabled, the amp streams 72-byte frames starting
// 0x55 0x2F, valid when the sum of all 72 bytes ≡ 0 (mod 256). Field offsets are
// decoded in decodeFrame below.
package acom
import (
"fmt"
"io"
"net"
"strconv"
"strings"
"sync"
"time"
"go.bug.st/serial"
"hamlog/internal/applog"
)
var (
cmdEnableTelemetry = []byte{0x55, 0x92, 0x04, 0x15}
cmdDisableTelemetry = []byte{0x55, 0x91, 0x04, 0x16}
cmdOperate = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x06, 0x00, 0x1A}
cmdStandby = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x05, 0x00, 0x1B}
cmdOff = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x0A, 0x00, 0x16}
)
const (
frameLen = 72
sync0 = 0x55
sync1 = 0x2F
dialTimeout = 5 * time.Second
ioTimeout = 3 * time.Second
// The amp streams roughly 4-5 frames/s once telemetry is on; if nothing valid
// arrives for this long the link (or the amp) is considered down.
staleAfter = 5 * time.Second
)
// model carries the per-model constants: the temperature word offset and the
// nominal/max forward power (for UI bar scaling).
type model struct {
Name string
TempOffset int
NominalW int
MaxW int
}
var models = map[string]model{
"500S": {"500S", 282, 500, 600},
"600S": {"600S", 273, 600, 700},
"700S": {"700S", 282, 700, 800},
"1200S": {"1200S", 281, 1200, 1400},
"2020S": {"2020S", 282, 1800, 2000},
}
// paStatusNames maps the PAstatus nibble to a display string.
var paStatusNames = map[int]string{
1: "RESET", 2: "INIT", 3: "DEBUG", 4: "SERVICE",
5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 9: "SYSTEM", 10: "OFF",
}
// acomBands maps the band nibble to a band label.
var acomBands = []string{"?", "160m", "80m", "40/60m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m"}
// errText translates the error code (frame byte 66) shown on the amp's display.
// 0xFF means NO error — everything else, including 0x00, is a fault/warning.
func errText(code int) string {
switch code {
case 0xFF:
return ""
case 0x00, 0x08:
return "Hot switching"
case 0x03:
return "Drive power at wrong time"
case 0x04, 0x05:
return "Reflected power warning"
case 0x06, 0x07:
return "Drive power too high"
case 0x0C:
return "RF power at wrong time"
case 0x0E:
return "Stop transmission first"
case 0x0F:
return "Remove drive power"
case 0x24, 0x25, 0x39, 0x44, 0x45, 0x59:
return "Excessive PAM current"
case 0x70:
return "CAT error"
default:
return "ERROR — see display"
}
}
// Status is the decoded amplifier state for the UI.
type Status struct {
Connected bool `json:"connected"` // valid telemetry is flowing
PortOpen bool `json:"port_open"` // transport is open (serial port / TCP socket) — power-on possible even when the amp itself is off
Transport string `json:"transport"` // "serial" | "tcp" — the UI disables power-ON over tcp (no DTR line)
LastError string `json:"last_error,omitempty"`
Model string `json:"model,omitempty"`
State string `json:"state,omitempty"` // STANDBY / RECEIVE / TRANSMIT / OFF / …
Operate bool `json:"operate"` // RECEIVE or TRANSMIT (vs STANDBY)
TX bool `json:"tx"`
FwdW int `json:"fwd_w"` // forward/output power
ReflW int `json:"refl_w"` // reflected power
SWR float64 `json:"swr"`
DriveW int `json:"drive_w"`
DCPowerW int `json:"dc_w"`
TempC int `json:"temp_c"` // PA temperature
Band string `json:"band,omitempty"`
FanLevel int `json:"fan"` // 1..4
ErrCode int `json:"err_code"` // raw code from the frame; 0xFF = none
ErrText string `json:"err_text,omitempty"` // human message; empty = no error
NominalW int `json:"nominal_w"`
MaxW int `json:"max_w"`
}
// Config selects the transport and amplifier model.
type Config struct {
Model string // "500S" | "600S" | "700S" | "1200S" | "2020S"
Transport string // "serial" | "tcp"
ComPort string // serial
Baud int // serial (the amp is fixed 9600 8N1)
Host string // tcp (RS232-to-Ethernet bridge)
Port int // tcp
}
type Client struct {
cfg Config
mdl model
mu sync.Mutex // serialises access to the connection
conn io.ReadWriteCloser
statusMu sync.RWMutex
status Status
// Diagnostics: raw byte count + first-bytes capture, so a hardware session log
// tells apart "nothing on the wire" (cable/COM/amp) from "bytes but no valid
// frame" (framing/checksum) without a serial sniffer.
rawSeen int64
dbgBytes []byte
dbgLogged bool
ckFails int
frameLogged bool
stop chan struct{}
running bool
}
func New(cfg Config) *Client {
if cfg.Baud <= 0 {
cfg.Baud = 9600
}
mdl, ok := models[strings.ToUpper(strings.TrimSpace(cfg.Model))]
if !ok {
mdl = models["700S"]
}
c := &Client{cfg: cfg, mdl: mdl, stop: make(chan struct{})}
c.status.Model = mdl.Name
c.status.Transport = cfg.Transport
c.status.NominalW = mdl.NominalW
c.status.MaxW = mdl.MaxW
return c
}
func (c *Client) Start() error {
if c.running {
return nil
}
c.running = true
go c.readLoop()
return nil
}
func (c *Client) Stop() {
if !c.running {
return
}
c.running = false
close(c.stop)
c.mu.Lock()
if c.conn != nil {
_, _ = c.conn.Write(cmdDisableTelemetry) // best effort: stop the stream
}
c.dropLocked()
c.mu.Unlock()
}
func (c *Client) GetStatus() Status {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.status
}
func (c *Client) setErr(msg string) {
c.statusMu.Lock()
c.status.Connected = false
c.status.LastError = msg
c.statusMu.Unlock()
}
// Operate puts the amp in OPERATE (true) or STANDBY (false). Unlike the SPE's
// single toggle key, the ACOM protocol has explicit commands for each state.
func (c *Client) Operate(on bool) error {
if on {
return c.send(cmdOperate)
}
return c.send(cmdStandby)
}
// PowerOff sends the power-down command (same as pressing OFF on the amp).
func (c *Client) PowerOff() error { return c.send(cmdOff) }
// PowerOn pulses the serial DTR/RTS lines — the amp's remote power-on pins, wired
// like a press of the front-panel power button. Hardware line ⇒ serial transport
// only (an RS232-to-Ethernet bridge doesn't forward DTR), and the cable must have
// those pins connected. Pulse length to be confirmed on real hardware.
func (c *Client) PowerOn() error {
c.mu.Lock()
defer c.mu.Unlock()
sp, ok := c.conn.(serial.Port)
if !ok {
return fmt.Errorf("power-on needs the serial DTR/RTS lines — not available over a network bridge")
}
if err := sp.SetDTR(true); err != nil {
return err
}
if err := sp.SetRTS(true); err != nil {
_ = sp.SetDTR(false)
return err
}
time.Sleep(2500 * time.Millisecond)
err1 := sp.SetDTR(false)
err2 := sp.SetRTS(false)
if err1 != nil {
return err1
}
return err2
}
func (c *Client) send(cmd []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return fmt.Errorf("not connected")
}
if nc, ok := c.conn.(net.Conn); ok {
_ = nc.SetWriteDeadline(time.Now().Add(ioTimeout))
}
_, err := c.conn.Write(cmd)
return err
}
// readLoop keeps the link up and consumes the telemetry stream. When no valid
// frame arrives for a while it re-arms telemetry (the amp forgets the enable
// across a power cycle) and, on TCP, reconnects. A serial port is kept open even
// with the amp off, so the DTR power-on pulse stays available.
func (c *Client) readLoop() {
var lastFrame time.Time
var lastEnable time.Time
for {
select {
case <-c.stop:
return
default:
}
if err := c.ensureConn(); err != nil {
c.setErr("connect: " + err.Error())
select {
case <-c.stop:
return
case <-time.After(2 * time.Second):
}
continue
}
frame, err := c.readFrame()
now := time.Now()
if err == nil {
lastFrame = now
if !c.frameLogged {
c.frameLogged = true
applog.Printf("acom: telemetry up — first valid frame received")
}
c.decodeFrame(frame)
continue
}
// No valid frame. Re-send the telemetry enable briskly — there is no way to
// know whether the amp has it on (the reference controller re-sends every
// 200ms until frames flow), and it revives the stream after a power cycle.
if now.Sub(lastEnable) >= 500*time.Millisecond {
lastEnable = now
_ = c.send(cmdEnableTelemetry)
}
if lastFrame.IsZero() || now.Sub(lastFrame) > staleAfter {
if c.cfg.Transport == "tcp" {
// The bridge socket may be dead — force a reconnect.
c.mu.Lock()
c.dropLocked()
c.mu.Unlock()
}
if prev := c.GetStatus(); prev.Connected || prev.LastError == "" {
applog.Printf("acom: no telemetry (raw bytes seen so far: %d, checksum fails: %d)", c.rawSeen, c.ckFails)
}
c.setErr("no telemetry — amplifier off or cable/bridge issue")
}
}
}
func (c *Client) ensureConn() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
return nil
}
if c.cfg.Transport == "tcp" {
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout)
if err != nil {
return err
}
c.conn = nc
} else {
sp, err := serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
if err != nil {
return err
}
// CRITICAL: hold DTR/RTS LOW. Windows may assert them on open, and while
// asserted the amp's front-panel power button is blocked (they are its
// remote power-on lines).
_ = sp.SetDTR(false)
_ = sp.SetRTS(false)
// Short read timeout so the frame reader can poll the stop channel and
// detect staleness rather than blocking forever.
_ = sp.SetReadTimeout(200 * time.Millisecond)
c.conn = sp
}
c.statusMu.Lock()
c.status.PortOpen = true
c.statusMu.Unlock()
applog.Printf("acom: %s link open (%s)", c.mdl.Name, c.cfg.Transport)
_, _ = c.conn.Write(cmdEnableTelemetry)
return nil
}
func (c *Client) dropLocked() {
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.statusMu.Lock()
c.status.PortOpen = false
c.statusMu.Unlock()
}
// readByte reads a single byte, honouring the transport's short timeout. A serial
// read that times out returns (0, nil) with go.bug.st/serial — mapped to an error
// here so callers can distinguish "no data yet".
var errNoData = fmt.Errorf("no data")
func (c *Client) readByte(deadline time.Time) (byte, error) {
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
if conn == nil {
return 0, fmt.Errorf("not connected")
}
buf := make([]byte, 1)
for {
if nc, ok := conn.(net.Conn); ok {
_ = nc.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
}
n, err := conn.Read(buf)
if n == 1 {
c.rawSeen++
// Capture the first 144 raw bytes (~2 frames) once, so a session log shows
// what the wire actually carries when frames won't validate.
if !c.dbgLogged {
c.dbgBytes = append(c.dbgBytes, buf[0])
if len(c.dbgBytes) >= 144 {
c.dbgLogged = true
applog.Printf("acom: first raw bytes: % X", c.dbgBytes)
c.dbgBytes = nil
}
}
return buf[0], nil
}
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Timeout() {
err = nil // treat like the serial short-timeout: just no data yet
} else {
return 0, err
}
}
select {
case <-c.stop:
return 0, fmt.Errorf("stopped")
default:
}
if time.Now().After(deadline) {
return 0, errNoData
}
}
}
// readFrame syncs on 0x55 0x2F, reads the rest of the 72-byte frame and verifies
// the mod-256 checksum (sum of ALL 72 bytes ≡ 0).
func (c *Client) readFrame() ([]byte, error) {
deadline := time.Now().Add(ioTimeout)
// Sync: hunt for 0x55 followed by 0x2F.
for {
b, err := c.readByte(deadline)
if err != nil {
return nil, err
}
if b != sync0 {
continue
}
b2, err := c.readByte(deadline)
if err != nil {
return nil, err
}
if b2 == sync1 {
break
}
}
frame := make([]byte, frameLen)
frame[0], frame[1] = sync0, sync1
for i := 2; i < frameLen; i++ {
b, err := c.readByte(deadline)
if err != nil {
return nil, err
}
frame[i] = b
}
var sum int
for _, b := range frame {
sum += int(b)
}
if sum%256 != 0 {
c.ckFails++
if c.ckFails <= 3 {
applog.Printf("acom: bad checksum (fail #%d): % X", c.ckFails, frame)
}
return nil, fmt.Errorf("bad checksum")
}
return frame, nil
}
// decodeFrame extracts the telemetry fields (see the package comment for the
// reverse-engineered layout; 16-bit values are little-endian lo + hi*256).
func (c *Client) decodeFrame(f []byte) {
u16 := func(i int) int { return int(f[i]) + int(f[i+1])*256 }
paStatus := int(f[3]&0xF0) >> 4
state := paStatusNames[paStatus]
if state == "" {
state = fmt.Sprintf("?%d", paStatus)
}
bandIdx := int(f[69] & 0x0F)
band := ""
if bandIdx > 0 && bandIdx < len(acomBands) {
band = acomBands[bandIdx]
}
c.statusMu.Lock()
defer c.statusMu.Unlock()
c.status.Connected = true
c.status.LastError = ""
c.status.State = state
c.status.Operate = paStatus == 6 || paStatus == 7
c.status.TX = paStatus == 7
c.status.DCPowerW = u16(8) / 10
c.status.TempC = u16(16) - c.mdl.TempOffset
c.status.DriveW = u16(20)
c.status.FwdW = u16(22)
c.status.ReflW = u16(24)
c.status.SWR = float64(u16(26)) / 100
c.status.ErrCode = int(f[66])
c.status.ErrText = errText(int(f[66]))
c.status.Band = band
c.status.FanLevel = int(f[69]&0xF0) >> 4
}
+9 -4
View File
@@ -47,10 +47,15 @@ func Init(dataDir string) (string, error) {
_ = os.Rename(oldLog, logPath)
}
}
// Truncate if the file grew past ~5MB so we don't accumulate logs
// forever. We keep one file — simple and adequate for diagnostics.
if fi, err := os.Stat(logPath); err == nil && fi.Size() > 5*1024*1024 {
_ = os.Remove(logPath)
// Rotate (don't delete) once the file grows past ~10MB: rename it to
// opslog.log.1 so the PREVIOUS session's log survives. Deleting it outright used
// to erase exactly the diagnostics we needed when a user reported an issue from
// the session that just ended. One generation kept — enough, without unbounded growth.
if fi, err := os.Stat(logPath); err == nil && fi.Size() > 10*1024*1024 {
_ = os.Remove(logPath + ".1") // drop the older generation
if err := os.Rename(logPath, logPath+".1"); err != nil {
_ = os.Remove(logPath) // rename failed (locked?) → fall back to the old behaviour
}
}
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
+19 -7
View File
@@ -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 {
+39 -16
View File
@@ -129,6 +129,28 @@ func translateTextColumns(s string) string {
// OpenMySQL opens the shared MySQL logbook, creating the database if needed,
// then applies the (translated) embedded migrations. multiStatements is enabled
// so multi-statement migration files run in a single Exec.
// tuneMySQLPool sizes the connection pool for a SHARED server. Two opposing needs:
// - A big per-instance pool (we shipped 50) let a handful of ops exhaust the
// server's global max_connections — "Error 1040: Too many connections".
// - Too SMALL a pool starves this instance's OWN concurrent UI queries — the live
// multi-op sync (revision poll every 2s + a 3-query grid refresh), live_status,
// chat, stats, cluster. Under real multi-op load, 8 and even 16 stalled: hot
// paths (WorkedBefore fires ~10 sequential round-trips; every poll uses the
// app-lifetime ctx with no per-query timeout) hold a connection for the whole
// chain of remote-MySQL latency, so a handful of concurrent UI bursts drained
// the pool and "after a while nothing updated" — grid, chat, live_status froze.
// 40 fits the actual deployment (max 5 ops on a server with max_connections=300 →
// 40*5 = 200, leaving ~100 for phpMyAdmin/server overhead). This is the per-INSTANCE
// pool, so keep max_connections >= pool*ops + headroom.
// Brisk idle reaping returns slots to the server; no max lifetime so a slow first
// migration on one connection isn't reaped mid-run (drops the selected database).
func tuneMySQLPool(conn *sql.DB) {
conn.SetMaxOpenConns(40)
conn.SetMaxIdleConns(8)
conn.SetConnMaxIdleTime(30 * time.Second)
conn.SetConnMaxLifetime(0)
}
func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
if strings.TrimSpace(c.Host) == "" {
return nil, fmt.Errorf("host is required")
@@ -137,30 +159,31 @@ 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)
}
// The UI fires bursts of concurrent queries (the Preferences dialog alone
// loads ~8 settings in parallel, plus the grid and live cluster status). A
// low cap turns such a burst into a deadlock-like stall against a remote
// server, so keep a generous pool with idle reaping. SQLite ran uncapped.
conn.SetMaxOpenConns(50)
conn.SetMaxIdleConns(10)
conn.SetConnMaxIdleTime(90 * time.Second)
// No max lifetime: a slow server's first migration can run for minutes on a
// single connection, and reaping it mid-migration drops the selected database
// (surfacing as "Unknown database"). Idle connections are still recycled
// after 90s, and the driver retries stale pooled connections.
conn.SetConnMaxLifetime(0)
tuneMySQLPool(conn)
// 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()
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)
}
tuneMySQLPool(conn)
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.
Dialect = "mysql"
+11 -13
View File
@@ -1954,24 +1954,22 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
// operator who just worked someone before (re)starting OpsLog shows online right
// away instead of waiting for their next QSO. Empty operator matches every QSO.
func (r *Repo) LastQSOTime(ctx context.Context, operator string) (time.Time, bool) {
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 400`)
if err != nil {
return time.Time{}, false
}
defer rows.Close()
// ONE indexed row, not 400 filtered in Go: this runs every ~5 s (the ON-AIR
// badge) and ~15 s (live-status publish), so pulling 400 rows each time over a
// remote MySQL hammered the connection pool and, on a busy multi-op event, was a
// big part of "after a while nothing updates". Match the operator the same
// (case/space-insensitive) way, newest first.
opFilter := strings.ToUpper(strings.TrimSpace(operator))
for rows.Next() {
var oper, dateStr sql.NullString
if err := rows.Scan(&oper, &dateStr); err != nil {
return time.Time{}, false
}
if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter {
continue
var dateStr sql.NullString
err := r.db.QueryRowContext(ctx,
`SELECT qso_date FROM qso WHERE UPPER(TRIM(operator)) = ? AND qso_date IS NOT NULL AND qso_date <> '' ORDER BY id DESC LIMIT 1`,
opFilter).Scan(&dateStr)
if err != nil {
return time.Time{}, false // ErrNoRows or a real error — no known last QSO
}
if t := parseTimeLoose(dateStr.String).UTC(); !t.IsZero() {
return t, true
}
}
return time.Time{}, false
}
+8
View File
@@ -392,8 +392,13 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
stationC[st]++
}
// Bucket a blank/unknown continent under "Unknown" rather than dropping it,
// so the continent breakdown totals the same as the QSO count (a handful of
// QSOs with no resolved continent made the donut read a few short).
if c := strings.ToUpper(strings.TrimSpace(cont.String)); c != "" {
contC[c]++
} else {
contC["Unknown"]++
}
if c := strings.TrimSpace(country.String); c != "" {
entityC[c]++
@@ -436,6 +441,9 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
s.UniqueCalls = len(calls)
s.Entities = len(entities)
s.Continents = len(contC)
if _, ok := contC["Unknown"]; ok {
s.Continents-- // "Unknown" is a catch-all bucket, not a real continent
}
if !first.IsZero() {
s.FirstQSO = first.UTC().Format(time.RFC3339)
s.LastQSO = last.UTC().Format(time.RFC3339)
+40 -39
View File
@@ -2,13 +2,24 @@ package main
import (
"database/sql"
"fmt"
"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
@@ -18,8 +29,6 @@ import (
// to the DB — it is not a web server. Rows older than a couple of minutes are
// "stale" (operator went offline); the web side ignores them.
const keyLiveStatusEnabled = "livestatus.enabled"
// liveOnlineWindow is how long after the last logged contact an operator still
// counts as "on air". Leaving the log open without working anyone flips them
// offline once this elapses; logging a new QSO flips them back online.
@@ -37,37 +46,6 @@ func (a *App) noteLiveQSO() {
}
}
// GetLiveStatusEnabled reports whether this operator publishes live status.
func (a *App) GetLiveStatusEnabled() bool {
if a.settings == nil {
return false
}
v, _ := a.settings.Get(a.ctx, keyLiveStatusEnabled)
return strings.TrimSpace(v) == "1"
}
// SetLiveStatusEnabled turns live-status publishing on or off (off also removes
// this operator's row immediately).
func (a *App) SetLiveStatusEnabled(on bool) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
val := "0"
if on {
val = "1"
}
if err := a.settings.Set(a.ctx, keyLiveStatusEnabled, val); err != nil {
return err
}
if on {
applog.Printf("livestatus: enabled (logbook backend=%q, mysql conn=%v)", a.dbBackend, a.logDb != nil)
go a.publishLiveStatus() // show up right away
} else {
a.clearLiveStatus()
}
return nil
}
// seedLiveLastQSO primes liveLastQSOAt from the DB at launch, so an operator who
// worked someone shortly before (re)starting OpsLog is shown "on air" right away
// instead of offline until their next QSO.
@@ -131,9 +109,11 @@ func (a *App) liveStatusLoop() {
}
}
// liveStatusActive reports whether publishing should run (MySQL logbook + on).
// liveStatusActive reports whether publishing should run. Always on for a shared
// MySQL logbook (no user toggle: going offline is automatic — no QSO for 5 min
// removes the row — so there is nothing to opt out of).
func (a *App) liveStatusActive() bool {
return a.logDb != nil && a.dbBackend == "mysql" && a.GetLiveStatusEnabled()
return a.logDb != nil && a.dbBackend == "mysql"
}
// liveStatusOperator returns this instance's operator id (the operator callsign,
@@ -185,9 +165,6 @@ func (a *App) publishLiveStatus() {
if a.logDb == nil || a.dbBackend != "mysql" {
return // not a MySQL logbook — nothing to do (silent, runs every 15s)
}
if !a.GetLiveStatusEnabled() {
return // disabled (silent)
}
op, station := a.liveStatusOperator()
if op == "" {
applog.Printf("livestatus: nothing published — no operator/callsign set (Settings → Station)")
@@ -231,6 +208,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 +224,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.
@@ -302,7 +281,29 @@ func (a *App) GetLiveStations() []LiveStation {
return out
}
// ensureLiveStatusTable creates/upgrades the live_status table ONCE per logbook
// connection. It used to run the CREATE + 3 ALTERs on every call — and it is
// called from every publish (15s heartbeat) AND every GetLiveStations poll (5s),
// so that was 4 wasted round-trips per call on a remote MySQL, adding latency to
// the "who's on air" widget and holding pool connections for nothing.
func (a *App) ensureLiveStatusTable() error {
db := a.logDb
a.liveTableMu.Lock()
done := a.liveTableFor == db // re-ensure after a profile switch swaps the logbook
a.liveTableMu.Unlock()
if done {
return nil
}
if err := a.ensureLiveStatusTableDDL(); err != nil {
return err
}
a.liveTableMu.Lock()
a.liveTableFor = db
a.liveTableMu.Unlock()
return nil
}
func (a *App) ensureLiveStatusTableDDL() error {
if _, err := a.logDb.ExecContext(a.ctx,
"CREATE TABLE IF NOT EXISTS live_status ("+
"operator VARCHAR(32) PRIMARY KEY, "+
+1 -1
View File
@@ -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.7"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.