Compare commits

...
40 Commits
Author SHA1 Message Date
rouggy 1b5b0c2e90 feat: clicking the status-bar amplifier chip toggles OPERATE <-> STANDBY (SPE/ACOM, optimistic flip reconciled by the poll; disabled offline) — PGXL still opens Settings, it has no standby command 2026-07-21 18:54:48 +02:00
rouggy 0385aed760 feat: amplifier controls without a Flex/Icom panel — Station Control widget (SPE/ACOM full control, PGXL fan) + status-bar chip (green OPERATE / orange STANDBY / red offline, click opens Settings); warn with a toast when activating a profile whose MySQL is unreachable instead of silently staying on the old logbook 2026-07-21 18:47:31 +02:00
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
rouggy 9c62bf0152 fix: cleanly stop CAT (and release relay handles) on shutdown — FlexRadio kept our API client registered, making the next launch take ~30s to reconnect 2026-07-21 10:15:02 +02:00
rouggy 1630c8fdf4 feat: include band in the self-spot toast (Spotted by X on 20m with ...) 2026-07-21 10:08:11 +02:00
rouggy 408791ddf5 fix: <LOGQSO> now logs at its position in the macro (splits the macro there), and ESC/Stop before it completes cancels the pending log 2026-07-21 09:54:54 +02:00
rouggy 3de47a8825 fix: pin bottom status bar — root no longer shows a horizontal scrollbar that overlapped it in a small window 2026-07-21 09:49:49 +02:00
rouggy 880ecdbbb5 fix: cache relay-board drivers so Denkovi/USB-serial boards stay connected
The station-control code rebuilt a fresh driver on every status poll and every
relay set. Stateful boards (Denkovi FTDI D2XX, USB-serial) hold an OS handle only
one opener can own, so the first poll opened the board and leaked the handle, and
every poll after failed with 'device in use' — the relays greyed out a second
after Save and auto-control never switched. Drivers are now cached per device and
reused, closed on config change. Adds a Test-connection button + detect feedback
in the device editor (reported by VK4MA).
2026-07-21 09:31:08 +02:00
rouggy 615df0dc10 docs: update README (EN/FR) — SPE Expert amp, Station Control relays, Flex CWX keyer, statistics dashboard, What's new dialog 2026-07-21 09:24:28 +02:00
rouggy 2194279602 fix: drain SPE status backlog so display is current (was ~15s stale); ON now pulses DTR (0x0B is level-only); power-level cycle waits for real status change 2026-07-21 01:38:39 +02:00
rouggy 51c4bda71a fix: SPE card shows 'SPE offline' instead of 'PowerGenius offline' 2026-07-21 01:31:38 +02:00
rouggy 7d7d175ede feat: SPE amp ON/OFF buttons and Low/Mid/High power-level control (keystroke codes best-guess from APG, pending hw verification) 2026-07-21 01:25:55 +02:00
rouggy be1ae76eb3 fix: correct SPE band-code map (00=160m,01=80m,02=60m...confirmed on hw); add output-power bar to SPE amp card 2026-07-21 01:19:24 +02:00
rouggy 50157a25d3 feat: show band on SPE amp card, spell out power level (Low/Mid/High), larger status text 2026-07-21 01:13:02 +02:00
rouggy fe1a77a54d fix: use official SPE band-code table from the manual (00=60m,01=160m,...) 2026-07-21 01:10:12 +02:00
rouggy b818a2d947 fix: correct SPE status CSV field alignment (leading empty field shifts indices +1); decode band code; OPERATE/STANDBY now reflects real amp state 2026-07-21 01:09:13 +02:00
rouggy 5c4ae0cfd7 fix: put Mode Split By Band and By Mode side by side on the same row 2026-07-21 01:02:34 +02:00
rouggy 69635a15bc refactor: remove redundant By Band chart, keep Mode Split By Band in its place 2026-07-21 00:59:25 +02:00
rouggy 78220e700f fix: hide the entire Flex-reported amp card when PowerGenius is not the selected amp type 2026-07-21 00:57:16 +02:00
rouggy 42a6b9c76a debug: log raw SPE status frame on change (to verify CSV field alignment on real hw) 2026-07-21 00:54:13 +02:00
rouggy 56affa4bed feat: SPE Expert amplifier card in the Flex panel (OPERATE/STANDBY + live status)
When the configured amplifier is an SPE Expert (not the PowerGenius), the Flex
panel now shows an SPE control card — the Flex doesn't report SPE amps, so it's
driven by OpsLog's own SPE link. The PowerGenius fan-mode control is hidden when
the amp type isn't PowerGenius.
2026-07-21 00:49:48 +02:00
rouggy aa5af4fc75 ui: EN/FR toggle in the What's-new dialog (defaults to UI language) 2026-07-21 00:36:39 +02:00
rouggy 46772e54fe chore: check for updates every 5 minutes (was 10) 2026-07-21 00:32:19 +02:00
rouggy a0cea352ff feat: re-check for updates when opening Help - About (no more stale 'up to date')
Opening About now fires a fresh CheckForUpdate with a 'Checking…' state, and
clears the update banner when the latest check finds nothing — so it reflects
reality instead of the last periodic check.
2026-07-21 00:26:54 +02:00
27 changed files with 1994 additions and 373 deletions
+41 -7
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
@@ -112,7 +116,10 @@ Affiché uniquement quand le backend CAT est une FlexRadio :
parole (NOR/DX/DX+), VOX (+ niveau + délai), moniteur (+ niveau), gain micro.
- **Réception (slice active) :** mode/seuil AGC, niveau audio, NB / NR / ANF.
- **Coupleur d'antenne (ATU) :** accord / bypass / mémoires.
- **Amplificateur :** PowerGenius XL operate/standby + défaut.
- **Amplificateur :** la carte de commande suit l'ampli configuré —
**PowerGenius XL** (operate/standby, mode ventilateur, défaut) ou
**SPE Expert** (operate/standby, Marche/Arrêt, niveau Low/Mid/High, barre de
puissance de sortie, bande & température).
- **Mesures en direct** via le flux UDP VITA-49 : S-mètre (unités S), puissance
directe (W), ROS, ALC, température PA, tension, plus les mesures de l'ampli.
@@ -159,9 +166,10 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
## Keyers & audio
- **Keyer CW** avec macros et macros sur touches F. Le moteur du keyer est
sélectionnable : **WinKeyer** (K1EL WK1/2/3 sur port COM), **Icom** (le keyer
intégré de la radio via CI-V — sans matériel supplémentaire, fonctionne aussi à
distance) ou **TCI**.
sélectionnable : **WinKeyer** (K1EL WK1/2/3 sur port COM), **FlexRadio CWX**
(le keyer intégré de la radio via l'API SmartSDR — type-ahead et retour arrière,
sans WinKeyer ni SmartCAT), **Icom** (le keyer intégré de la radio via CI-V —
sans matériel supplémentaire, fonctionne aussi à distance) ou **TCI**.
- **Keyer vocal numérique** (DVK) : enregistrer les messages vocaux F1F6 et les
émettre.
- **Enregistrement audio des QSO :** capture continue en tampon glissant ; au
@@ -170,10 +178,24 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
## Amplis & commutateurs
- Amplificateur **PowerGenius XL** (4O3A) — TCP direct : operate/standby,
sélecteur de mode ventilateur et affichage des défauts.
- **Amplificateurs** (Réglages → Amplificateur — la carte de commande apparaît
sur l'onglet FlexRadio, ou seule quand ni Flex ni Icom n'est actif) :
- **PowerGenius XL** (4O3A) en TCP direct — operate/standby, sélecteur de mode
ventilateur et affichage des défauts.
- **SPE Expert** (1.3K-FA / 1.5K-FA / 2K-FA) via **USB** (COM virtuel) ou le
**réseau** (pont RS232-Ethernet) — operate/standby, Marche/Arrêt, niveau de
sortie Low / Mid / High, une barre de puissance et le statut en direct (bande,
ROS, courant PA, température, avertissements/alarmes).
- Commutateur d'antenne **Antenna Genius** (4O3A) via TCP/GSCP — un widget de
commutation A/B ancré.
- Panneau **Station Control** (ancrable, widgets réordonnables par glisser) : le
**rotor**, la commande d'éléments **Ultrabeam** et des **cartes relais**
WebSwitch 1216H, KMTronic, **Denkovi** USB (bit-bang FT245 D2XX, 4 ou 8 relais)
et USB-série générique (CH340 / LCUS, protocole A0) — pour l'alimentation, les
antennes et accessoires de la station.
- **Relais automatiques** (Réglages) : bascule les relais de Station Control
automatiquement selon la fréquence / bande de la radio (comme PstRotator) — par
relais, une fenêtre de fréquence ou un ensemble de bandes.
## QSL & diplômes
@@ -201,6 +223,15 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
un début/fin de fenêtre, signale les doublons et tient un tableau de score en
direct.
## Statistiques
- **Tableau de bord des statistiques :** tuiles de synthèse (QSO, indicatifs
uniques, entités DXCC, continents, % confirmés) plus des graphiques — QSO **par
mode**, un split **CW / phonie / data** par bande, l'**activité dans le temps**
(vues glissantes jour / 7 jours / 30 jours / 12 mois), par opérateur et par
continent. Filtres par plage de dates, par opérateur et par concours, et une vue
**Table** qui reprend chaque graphique.
## Statut opérateur en direct (événements spéciaux)
Pour un indicatif d'événement spécial multi-op sur un journal MySQL partagé (ex.
@@ -247,7 +278,10 @@ ce n'est pas un serveur web.
- **Démarrage automatique :** lancer des programmes externes (WSJT-X, JTAlert,
contrôle de rotor…) au démarrage d'OpsLog, en sautant ceux déjà lancés.
- **Sauvegarde :** sauvegarde optionnelle base + ADIF à la fermeture.
- **Vérification de mise à jour** au démarrage avec un toast (désactivable).
- **Vérification de mise à jour** au démarrage et toutes les 5 minutes (et à
l'ouverture d'Aide → À propos), avec un toast (désactivable), plus une fenêtre
**Nouveautés** qui affiche le changelog (anglais / français) au premier
lancement après une mise à jour — réouvrable à tout moment depuis le menu Aide.
- **Télémétrie d'usage anonyme** (un battement de cœur quotidien : ID
d'installation aléatoire + version + OS — aucune donnée d'indicatif ou de QSO ;
désactivable dans les Préférences).
+39 -6
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),
@@ -100,7 +104,10 @@ Shown only when the CAT backend is a FlexRadio:
VOX (+ level + delay), monitor (+ level), mic gain.
- **Receive (active slice):** AGC mode/threshold, audio level, NB / NR / ANF.
- **Antenna tuner (ATU):** tune / bypass / memories.
- **Amplifier:** PowerGenius XL operate/standby + fault.
- **Amplifier:** the amp card follows whichever amplifier is configured —
**PowerGenius XL** (operate/standby, fan mode, fault) or **SPE Expert**
(operate/standby, ON/OFF, Low/Mid/High level, output-power bar, band &
temperature).
- **Live meters** over the UDP VITA-49 stream: S-meter (S-units), forward power
(W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
@@ -141,18 +148,33 @@ as Mumble.)
## Keyers & audio
- **CW keyer** with macros and F-key macros. The keyer engine is selectable:
**WinKeyer** (K1EL WK1/2/3 over a COM port), **Icom** (the radio's own keyer
over CI-V — no extra hardware, works over the remote link too) or **TCI**.
**WinKeyer** (K1EL WK1/2/3 over a COM port), **FlexRadio CWX** (the radio's
built-in keyer over the SmartSDR API — type-ahead and backspace, no WinKeyer or
SmartCAT needed), **Icom** (the radio's own keyer over CI-V — no extra hardware,
works over the remote link too) or **TCI**.
- **Digital Voice Keyer** (DVK): record F1F6 voice messages and transmit them.
- **QSO audio recording:** continuous rolling capture; on *Log QSO* the contact
is saved to a per-QSO WAV (`CALL_YYYYMMDD_HHMMSS.wav`); mixes RX + mic.
## Amplifiers & switches
- **PowerGenius XL** (4O3A) amplifier — direct TCP: operate/standby, fan-mode
selector and fault display.
- **Amplifiers** (Settings → Amplifier — the control card appears on the
FlexRadio tab, or on its own when neither Flex nor Icom is active):
- **PowerGenius XL** (4O3A) over direct TCP — operate/standby, fan-mode
selector and fault display.
- **SPE Expert** (1.3K-FA / 1.5K-FA / 2K-FA) over **USB** (virtual COM) or the
**network** (RS232-to-Ethernet bridge) — operate/standby, ON/OFF,
Low / Mid / High output level, an output-power bar and live status (band,
SWR, PA current, temperature, warnings/alarms).
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
antenna-switch widget.
- **Station Control** panel (dockable, drag-to-reorder widgets): the **rotator**,
**Ultrabeam** element control and **relay boards** — WebSwitch 1216H, KMTronic,
**Denkovi** USB (FT245 D2XX bit-bang, 4 or 8 relays) and generic USB-serial
(CH340 / LCUS, A0 protocol) — for station power, antennas and accessories.
- **Relay auto-control** (Settings): switch Station-Control relays automatically
from the rig frequency / band (like PstRotator) — per relay, a frequency window
or a set of bands.
## QSL & awards
@@ -176,6 +198,14 @@ as Mumble.)
and the sent/received serials (`STX` / `SRX`), enforces a window start/end,
flags dupes and keeps a live scoreboard.
## Statistics
- **Logbook statistics dashboard:** headline tiles (QSOs, unique callsigns, DXCC
entities, continents, % confirmed) plus charts — QSOs **by mode**, a per-band
**CW / phone / data** split, **activity over time** (rolling day / 7-day /
30-day / 12-month views), by operator and by continent. Date-range, per-operator
and per-contest filters, and a **Table** view that mirrors every chart.
## Multi-operator live status (special events)
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**):
@@ -218,7 +248,10 @@ only writes to the DB — it is not a web server.
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
OpsLog startup, skipping any already running.
- **Backup:** optional database + ADIF backup at shutdown.
- **Update check** at startup with a toast (toggleable).
- **Update check** at startup and every 5 minutes (and on opening Help → About),
with a toast (toggleable), plus a **What's new** dialog that shows the changelog
(English / French) on the first launch after an update — reopenable any time
from the Help menu.
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
version + OS — no callsign or QSO data; opt-out in Preferences).
+311 -110
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"
@@ -446,8 +447,8 @@ type App struct {
// wcbm is an in-memory "CALL|BAND|MODE" worked-index so the alert engine never
// queries the DB per cluster spot (an FT8 firehose would swamp a remote MySQL).
// Loaded once, appended to on each log, rebuilt after bulk changes.
wcbm map[string]struct{}
wcbmMu sync.RWMutex
wcbm map[string]struct{}
wcbmMu sync.RWMutex
pota *pota.Cache
uls *uls.Store // US callsign→county/grid (offline FCC ULS), lazily opened
awardRefs *awardref.Repo
@@ -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
@@ -481,25 +483,27 @@ type App struct {
alertStore *alerts.Store // DX-cluster spot alert rules (global JSON)
startupProfile string // --profile <name> from the command line (activate at startup)
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
startupProfile string // --profile <name> from the command line (activate at startup)
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
pttMu sync.Mutex
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets)
relayAutoMu sync.Mutex // serialises relay auto-control evaluation
relayAutoLast map[string]bool // deviceID|relay → last applied on/off, so we only switch on a real change
relayAutoOn atomic.Bool // cached "auto-control enabled" so the CAT hot path skips work when off
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
startupErr string // captured for surfacing to the frontend
dbPath string // active database file (may be a user-chosen location)
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
offlineQ *offlineq.Queue // ADIF outbox: QSOs logged while the DB was unreachable
offlineMode bool // last write failed because the DB was unreachable
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets)
relayAutoMu sync.Mutex // serialises relay auto-control evaluation
relayAutoLast map[string]bool // deviceID|relay → last applied on/off, so we only switch on a real change
relayAutoOn atomic.Bool // cached "auto-control enabled" so the CAT hot path skips work when off
relayDrvMu sync.Mutex // guards the cached relay drivers below
relayDrv map[string]cachedRelay // deviceID → live driver (reused across polls; stateful boards can't be reopened per call)
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
startupErr string // captured for surfacing to the frontend
dbPath string // active database file (may be a user-chosen location)
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
offlineQ *offlineq.Queue // ADIF outbox: QSOs logged while the DB was unreachable
offlineMode bool // last write failed because the DB was unreachable
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter
@@ -509,11 +513,13 @@ type App struct {
liveBand string
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
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)
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
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)
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
// shuttingDown gates beforeClose re-entry: the first user attempt to
// close fires shutdown tasks (backup, future LoTW upload, ...) while
@@ -812,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
@@ -830,9 +863,9 @@ func (a *App) startup(ctx context.Context) {
applog.Printf("startup: logbook backend = %s", backend)
a.logDb = logbookConn
a.qso = qso.NewRepo(logbookConn)
a.backfillAwardRefsOnce() // one-time: materialise award_refs for pre-existing QSOs
go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks
go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs
a.backfillAwardRefsOnce() // one-time: materialise award_refs for pre-existing QSOs
go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks
go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs
a.relayAutoOn.Store(a.GetRelayAuto().Enabled) // prime the relay auto-control hot-path flag
// cty.dat for offline DXCC / country resolution. Cached on disk; first
@@ -906,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.
@@ -1191,8 +1202,15 @@ func (a *App) runShutdownTasks(ctx context.Context, steps []shutdownStep) {
a.emitShutdownEvent("shutdown:update", steps)
}
a.emitShutdownEvent("shutdown:done", steps)
// Give the UI a moment to show the "done" state before we yank the
// window away. 600ms feels purposeful without being annoying.
// Cleanly tear down hardware links before quitting. Especially the CAT
// backend: a FlexRadio keeps our API client registered until it notices the
// TCP is gone, and just letting the process exit left the slot stale — the
// NEXT launch then spent ~30s retrying the connect before the radio freed it.
// cat.Stop() closes the socket with a proper FIN so the radio drops us at once.
if a.cat != nil {
a.cat.Stop()
}
a.closeRelayDrivers() // release FTDI/serial relay handles for the next launch
time.Sleep(600 * time.Millisecond)
wruntime.Quit(ctx)
}
@@ -1920,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
@@ -1934,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)
if a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
}
a.maybeAutoSendEQSL(q)
// Forward the ADIF of this QSO to any outbound "ADIF message" UDP rows.
if a.udp != nil {
go a.udp.EmitLoggedADIF(adif.SingleRecordADIF(q))
}
// 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.
if a.offlineMode && a.offlineQ != nil && a.offlineQ.Count() > 0 {
go func() { _, _ = a.replayOfflineQueue() }()
}
// 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(qc)
if a.udp != nil {
a.udp.EmitLoggedADIF(adif.SingleRecordADIF(qc))
}
// 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 {
_, _ = a.replayOfflineQueue()
}
}()
}
return id, err
}
@@ -4303,8 +4338,8 @@ func (a *App) ExportAwardForCatalog(code string, version int) (string, error) {
return "", fmt.Errorf("load references: %w", err)
}
entry := struct {
Def award.Def `json:"def"`
References []awardref.Ref `json:"references,omitempty"`
Def award.Def `json:"def"`
References []awardref.Ref `json:"references,omitempty"`
}{Def: def, References: refs}
b, err := json.MarshalIndent(entry, "", " ")
if err != nil {
@@ -6278,7 +6313,7 @@ type clusterQueue struct {
mu sync.Mutex
cond *sync.Cond
buf []clusterEvent
head int // index of the next event to pop (waste reclaimed by compaction)
head int // index of the next event to pop (waste reclaimed by compaction)
closed bool
}
@@ -9441,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.
@@ -9583,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()
@@ -9602,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)
if a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
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)
}
a.maybeAutoSendEQSL(q)
qc := q
go func() {
a.materializeAwardRefs(qc)
a.saveQSORecording(&qc)
if a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
}
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
}
@@ -11149,7 +11214,14 @@ func (a *App) ActivateProfile(id int64) error {
// target (local SQLite or its own MySQL) so QSOs go to the right logbook.
if p, err := a.profiles.Get(a.ctx, id); err == nil {
if err := a.switchLogbook(p); err != nil {
// The switch failed (typically: this profile's MySQL is unreachable right
// now) and the PREVIOUS logbook stays live. Silently staying on the old
// database is how QSOs end up in the wrong log — tell the operator.
applog.Printf("activate profile %d: logbook switch failed: %v", id, err)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf(
"Profil %q : connexion à sa base impossible — l'ancienne base reste active ! (%v)", p.Name, err))
}
}
}
// Re-apply every settings-dependent subsystem for the new profile.
@@ -11281,7 +11353,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"`
@@ -11400,14 +11471,14 @@ func boolStr(b bool) string {
// StationDevice is one configured relay board for the Station Control tab.
type StationDevice struct {
ID string `json:"id"`
Type string `json:"type"` // "webswitch" | "kmtronic" | "denkovi" | "usbrelay"
Name string `json:"name"`
Host string `json:"host"` // IP for network boards; FTDI serial for denkovi; COM port for usbrelay
User string `json:"user,omitempty"` // KMTronic HTTP auth (blank = none)
Pass string `json:"pass,omitempty"`
Channels int `json:"channels,omitempty"` // usbrelay only: number of relays (1/2/4/8/16)
Labels []string `json:"labels"` // per-relay label (index 0 = relay 1)
ID string `json:"id"`
Type string `json:"type"` // "webswitch" | "kmtronic" | "denkovi" | "usbrelay"
Name string `json:"name"`
Host string `json:"host"` // IP for network boards; FTDI serial for denkovi; COM port for usbrelay
User string `json:"user,omitempty"` // KMTronic HTTP auth (blank = none)
Pass string `json:"pass,omitempty"`
Channels int `json:"channels,omitempty"` // usbrelay only: number of relays (1/2/4/8/16)
Labels []string `json:"labels"` // per-relay label (index 0 = relay 1)
}
// deviceRelayCount is the relay count for a configured device — fixed by type,
@@ -11433,8 +11504,15 @@ func relayCountFor(typ string) int {
}
}
// deviceDriver builds the wire driver for a configured device.
func deviceDriver(d StationDevice) relaydev.Device {
// cachedRelay is a live driver plus the config signature it was built from, so a
// changed device (new COM port, serial or channel count) rebuilds it.
type cachedRelay struct {
key string
dev relaydev.Device
}
// buildDeviceDriver builds a fresh wire driver for a configured device.
func buildDeviceDriver(d StationDevice) relaydev.Device {
switch d.Type {
case "kmtronic":
return relaydev.NewKMTronic(d.Host, d.User, d.Pass)
@@ -11449,12 +11527,74 @@ func deviceDriver(d StationDevice) relaydev.Device {
}
}
// deviceKey is the config signature that, when unchanged, lets us reuse a device's
// open driver (and its OS handle) instead of rebuilding it every poll.
func deviceKey(d StationDevice) string {
return fmt.Sprintf("%s|%s|%s|%s|%d", d.Type, d.Host, d.User, d.Pass, deviceRelayCount(d))
}
// driverFor returns the cached, still-open driver for a device, building it once
// and reusing it thereafter. Stateful boards (Denkovi FTDI, USB-serial) hold an OS
// handle that only one opener can own, so rebuilding a driver every call — as the
// old code did — leaked the handle and made every poll after the first fail with
// "device in use", greying the relays out. Reuse fixes that.
func (a *App) driverFor(d StationDevice) relaydev.Device {
key := deviceKey(d)
a.relayDrvMu.Lock()
defer a.relayDrvMu.Unlock()
if a.relayDrv == nil {
a.relayDrv = map[string]cachedRelay{}
}
if c, ok := a.relayDrv[d.ID]; ok {
if c.key == key {
return c.dev
}
_ = c.dev.Close() // config changed → release the old handle before rebuilding
delete(a.relayDrv, d.ID)
}
dev := buildDeviceDriver(d)
a.relayDrv[d.ID] = cachedRelay{key: key, dev: dev}
return dev
}
// closeRelayDrivers closes and drops every cached driver (e.g. after the device
// list changes) so stale handles are released and rebuilt fresh on next use.
func (a *App) closeRelayDrivers() {
a.relayDrvMu.Lock()
defer a.relayDrvMu.Unlock()
for id, c := range a.relayDrv {
_ = c.dev.Close()
delete(a.relayDrv, id)
}
}
// ListDenkoviDevices returns the FTDI serial numbers of connected Denkovi/FTDI
// boards, for the settings picker. Windows-only (FTDI D2XX).
func (a *App) ListDenkoviDevices() ([]string, error) {
return relaydev.ListDenkovi()
}
// StationTestResult is the outcome of probing one relay board, for the settings
// dialog's Connect/Test button so the user gets a clear connected/failed message.
type StationTestResult struct {
OK bool `json:"ok"`
Relays int `json:"relays"`
Error string `json:"error,omitempty"`
}
// TestStationDevice opens the given board and reads its state, returning a clear
// success/failure so the settings UI can tell the user whether it's reachable.
// Reuses the cached driver (no double-open conflict with the live poll).
func (a *App) TestStationDevice(d StationDevice) StationTestResult {
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
defer cancel()
states, err := a.driverFor(d).Status(ctx)
if err != nil {
return StationTestResult{OK: false, Error: err.Error()}
}
return StationTestResult{OK: true, Relays: len(states)}
}
// GetStationDevices returns the configured relay boards (without live state).
func (a *App) GetStationDevices() []StationDevice {
out := []StationDevice{}
@@ -11497,6 +11637,9 @@ func (a *App) SaveStationDevices(devs []StationDevice) error {
if err != nil {
return err
}
// Release any open board handles so the new config reopens cleanly (a changed
// COM port / FTDI serial must not stay held by a stale driver).
a.closeRelayDrivers()
return a.settings.SetGlobal(a.ctx, keyStationDevices, string(b))
}
@@ -11532,7 +11675,7 @@ func (a *App) GetStationStatus() []StationDeviceStatus {
ds := StationDeviceStatus{ID: d.ID, Name: d.Name, Type: d.Type}
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
defer cancel()
states, err := deviceDriver(d).Status(ctx)
states, err := a.driverFor(d).Status(ctx)
if err != nil {
ds.Error = err.Error()
} else {
@@ -11563,7 +11706,7 @@ func (a *App) StationSetRelay(id string, relay int, on bool) error {
if d.ID == id {
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
defer cancel()
return deviceDriver(d).Set(ctx, relay, on)
return a.driverFor(d).Set(ctx, relay, on)
}
}
return fmt.Errorf("station device %q not found", id)
@@ -11608,12 +11751,12 @@ type motorAntenna interface {
// convention, so commands pass straight through).
type ubAdapter struct{ c *ultrabeam.Client }
func (a ubAdapter) Start() error { return a.c.Start() }
func (a ubAdapter) Stop() { a.c.Stop() }
func (a ubAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d) }
func (a ubAdapter) SetDirection(d int) error { return a.c.SetDirection(d) }
func (a ubAdapter) Retract() error { return a.c.Retract() }
func (a ubAdapter) LastSetKHz() int { return a.c.LastSetKHz() }
func (a ubAdapter) Start() error { return a.c.Start() }
func (a ubAdapter) Stop() { a.c.Stop() }
func (a ubAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d) }
func (a ubAdapter) SetDirection(d int) error { return a.c.SetDirection(d) }
func (a ubAdapter) Retract() error { return a.c.Retract() }
func (a ubAdapter) LastSetKHz() int { return a.c.LastSetKHz() }
func (a ubAdapter) SetElement(n, mm int) error { return a.c.ModifyElement(n, mm) }
func (a ubAdapter) ReadElements() ([]int, error) { return a.c.ReadElements() }
func (a ubAdapter) Elements() []int {
@@ -12136,11 +12279,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
@@ -12221,6 +12365,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
@@ -12233,13 +12381,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()
}
@@ -12261,6 +12414,54 @@ func (a *App) SPESetOperate(on bool) error {
return a.spe.Operate(on)
}
// SPESetPower turns the SPE amp on (true, POWER key) or off (false, OFF key).
func (a *App) SPESetPower(on bool) error {
if a.spe == nil {
return fmt.Errorf("SPE amplifier not connected — enable it in Settings → Amplifier")
}
if on {
return a.spe.PowerOn()
}
return a.spe.PowerOff()
}
// SPESetPowerLevel selects the SPE output power level ("L"/"M"/"H").
func (a *App) SPESetPowerLevel(level string) error {
if a.spe == nil {
return fmt.Errorf("SPE amplifier not connected — enable it in Settings → Amplifier")
}
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",
+174 -76
View File
@@ -42,7 +42,8 @@ import {
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
GetAwardDefs,
GetUIPref, GetActiveProfile, QuitApp,
ReportLiveActivity, GetLiveStatusEnabled, LiveLastQSOAgeSec,
ReportLiveActivity, LiveLastQSOAgeSec,
GetPGXLSettings, GetPGXLStatus, GetSPEStatus, GetACOMStatus, SPESetOperate, ACOMSetOperate,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs';
@@ -436,6 +437,27 @@ export default function App() {
// click reverts the UI and the click looks like it did nothing.
const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
// Amplifier chip in the status bar: name + green OPERATE / orange STANDBY /
// red offline. Config re-read every 5s (so enabling the amp in Settings makes
// the chip appear); status polled every 2s while enabled.
const [ampCfg, setAmpCfg] = useState<{ enabled: boolean; type: string }>({ enabled: false, type: 'pgxl' });
const [ampSt, setAmpSt] = useState<any>({ connected: false });
useEffect(() => {
let alive = true;
const load = () => GetPGXLSettings().then((s: any) => { if (alive) setAmpCfg({ enabled: !!s?.enabled, type: s?.type || 'pgxl' }); }).catch(() => {});
load();
const id = window.setInterval(load, 5000);
return () => { alive = false; window.clearInterval(id); };
}, []);
useEffect(() => {
if (!ampCfg.enabled) { setAmpSt({ connected: false }); return; }
let alive = true;
const get = ampCfg.type === 'pgxl' ? GetPGXLStatus : ampCfg.type.startsWith('acom') ? GetACOMStatus : GetSPEStatus;
const tick = () => get().then((s: any) => alive && setAmpSt(s || { connected: false })).catch(() => {});
tick();
const id = window.setInterval(tick, 2000);
return () => { alive = false; window.clearInterval(id); };
}, [ampCfg.enabled, ampCfg.type]);
// Multi-op "who's on air" widget: every operator's live status from the shared
// MySQL logbook (freq/mode/version). Only polled on a MySQL logbook.
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
@@ -452,9 +474,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
@@ -811,6 +837,11 @@ export default function App() {
const wkEscClearsRef = useRef(true);
const wkBusyRef = useRef(false); // live "keyer is sending" flag, for the <LOGQSO> wait-then-log
useEffect(() => { wkBusyRef.current = wkStatus.busy; }, [wkStatus.busy]);
// Generation token for an in-flight macro send: bumped on every new send AND on
// any abort (ESC / Stop / callsign cleared). wkSend checks it before each CW
// segment and before every <LOGQSO> log, so aborting mid-macro stops sending AND
// skips the log that hasn't happened yet.
const wkSendGenRef = useRef(0);
useEffect(() => {
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected)
: cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected)
@@ -1112,22 +1143,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]);
@@ -1150,15 +1181,28 @@ export default function App() {
// "What's new": the changelog for the version(s) since the operator last ran,
// shown once on the first launch after an update (EN/FR per the UI language).
const [whatsNew, setWhatsNew] = useState<Array<{ version: string; date: string; en: string[]; fr: string[] }> | null>(null);
// Which language the What's-new modal shows — defaults to the UI language but
// toggleable, so a French operator running the English UI can still read it.
const [clLang, setClLang] = useState<'en' | 'fr'>(lang);
useEffect(() => { setClLang(lang); }, [lang]);
useEffect(() => {
GetWhatsNew().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); }).catch(() => {});
}, []);
const [showDuplicates, setShowDuplicates] = useState(false);
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
const [checkingUpdate, setCheckingUpdate] = useState(false);
// Fresh update check on demand (opening About), so it never shows a stale
// "you're up to date". Clears updateInfo when the latest check finds nothing.
const checkUpdateNow = useCallback(() => {
setCheckingUpdate(true);
CheckForUpdate().then((u: any) => {
setUpdateInfo(u?.available && u?.latest ? { latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') } : null);
}).catch(() => {}).finally(() => setCheckingUpdate(false));
}, []);
const [updating, setUpdating] = useState(false);
const [updateProgress, setUpdateProgress] = useState(0);
const [updateError, setUpdateError] = useState('');
// Check GitHub for a newer release at startup AND every 10 minutes (unless
// Check GitHub for a newer release at startup AND every 5 minutes (unless
// disabled in General). Best effort — silent on failure.
useEffect(() => {
if (localStorage.getItem('opslog.checkUpdates') === '0') return;
@@ -1166,7 +1210,7 @@ export default function App() {
if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') });
}).catch(() => {});
check();
const id = window.setInterval(check, 10 * 60 * 1000);
const id = window.setInterval(check, 5 * 60 * 1000);
return () => window.clearInterval(id);
}, []);
// Live download progress for the in-app updater.
@@ -1921,7 +1965,8 @@ export default function App() {
if (mine && (sp.dx_call ?? '').toUpperCase() === mine) {
const by = cleanSpotter(sp.spotter ?? '') || '?';
const c = (sp.comment ?? '').trim();
showToast(`Spotted by ${by}${c ? ` with ${c}` : ''}`);
const bnd = (sp.band ?? '').trim();
showToast(`Spotted by ${by}${bnd ? ` on ${bnd}` : ''}${c ? ` with ${c}` : ''}`);
}
});
return () => {
@@ -2112,44 +2157,65 @@ export default function App() {
return out.replace(/\s+/g, ' ').trim();
}
async function wkSend(rawText: string) {
const gen = ++wkSendGenRef.current; // this send supersedes any previous; abort bumps it too
setWkSent('');
const resolved = resolveCW(rawText);
// Trailing word space so two macros fired back-to-back don't run together in
// the keyer buffer ("CQ" + "TEST" → "CQTEST"). The keyer keys a space as a
// word gap at the CURRENT speed, so it scales with WPM automatically.
const keyed = resolved ? resolved + ' ' : resolved;
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
if (cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex') {
// The rig keyer (Icom 0x17 / Flex CWX) gives no busy echo we track, so show
// the text we sent and, for <LOGQSO>, wait the estimated send duration
// before logging.
setWkSent(resolved);
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : IcomSendCW;
await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
if (doLog) { await sleep(Math.round(estimateCwMs(resolved, wkWpm)) + 600); void save(); }
return;
const aborted = () => gen !== wkSendGenRef.current;
// Cancellable wait: sleeps up to ms, but bails out immediately on abort.
const waitMs = async (ms: number) => {
const end = Date.now() + ms;
while (Date.now() < end && !aborted()) await sleep(50);
};
// 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>.
// 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 = 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) {
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + 600);
} else {
// 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
if (logAfter) void save(); // log NOW — the buffered CW keeps sending
}
await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
// <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has
// finished sending — so the QSO isn't logged (and the form cleared) while CW
// is still going out. We'd like to wait for the busy flag to rise then fall,
// but over a remote/serial-over-IP link that status echo can lag by tens of
// seconds, which used to delay logging ~50s. So cap the wait at the ESTIMATED
// send duration (text length × WPM) plus slack: log when busy clears OR the
// estimate elapses, whichever comes first.
if (!doLog) return;
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500; // slack for keyer buffering/lag
for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start
const deadline = Date.now() + capMs;
while (wkBusyRef.current && Date.now() < deadline) await sleep(50);
void save();
}
// stopAutoCall cancels any running auto-call loop.
function stopAutoCall() { autoCallMacroRef.current = -1; autoCallGenRef.current++; }
// stopKeyerTx aborts the CW being sent RIGHT NOW, routed to the active engine
// (was WinKeyer-only in a few places, so a Flex CWX / Icom macro kept going).
function stopKeyerTx() {
wkSendGenRef.current++; // cancel any in-flight macro send (and its pending <LOGQSO> log)
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
else WinkeyerStop().catch(() => {});
@@ -2163,20 +2229,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
}
@@ -2831,7 +2888,7 @@ export default function App() {
case 'tools.duplicates': setShowDuplicates(true); break;
case 'tools.refreshCty': refreshCtyDat(); break;
case 'tools.downloadRefs': downloadRefs(); break;
case 'help.about': setShowAbout(true); break;
case 'help.about': setShowAbout(true); checkUpdateNow(); break;
case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
}
}
@@ -2889,6 +2946,7 @@ export default function App() {
// ESC didn't stop the Icom or Flex keyer).
if (keyerLive) {
stopAutoCall();
wkSendGenRef.current++; // abort an in-flight macro send so a pending <LOGQSO> won't fire
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
else WinkeyerStop().catch(() => {});
@@ -3639,7 +3697,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>
);
@@ -3682,7 +3740,7 @@ export default function App() {
};
return (
<div className="flex flex-col h-screen bg-background">
<div className="flex flex-col h-screen overflow-hidden bg-background">
<ShutdownProgress />
{/* ===== TOPBAR ===== */}
{compact ? (
@@ -4122,12 +4180,14 @@ export default function App() {
</div>
<p className="text-sm text-muted-foreground">Ham-radio logbook</p>
<p className="mt-3 font-mono text-sm">version <span className="font-semibold text-foreground">{APP_VERSION}</span></p>
{updateInfo ? (
{checkingUpdate ? (
<p className="mt-1 text-[11px] text-muted-foreground inline-flex items-center gap-1"><Loader2 className="size-3 animate-spin" /> {t('upd.checking')}</p>
) : updateInfo ? (
<button onClick={() => updateInfo.url && BrowserOpenURL(updateInfo.url)} className="mt-1 text-xs text-primary underline hover:opacity-80">
Update available: v{updateInfo.latest} download
</button>
) : (
<p className="mt-1 text-[11px] text-success">You're up to date</p>
<p className="mt-1 text-[11px] text-success">{t('upd.upToDate')}</p>
)}
<p className="mt-3 text-sm">
Developed by <span className="font-semibold text-primary">{APP_AUTHOR}</span>
@@ -4148,7 +4208,15 @@ export default function App() {
<Zap className="size-5 text-primary" />
<h2 className="text-lg font-bold tracking-tight">{t('whatsnew.title')}</h2>
<span className="flex-1" />
<button onClick={() => setWhatsNew(null)} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
<div className="inline-flex rounded-md border border-border overflow-hidden text-[11px] font-semibold">
{(['en', 'fr'] as const).map((l) => (
<button key={l} onClick={() => setClLang(l)}
className={cn('px-2 py-0.5 uppercase', clLang === l ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
{l}
</button>
))}
</div>
<button onClick={() => setWhatsNew(null)} className="text-muted-foreground hover:text-foreground ml-1"><X className="size-4" /></button>
</div>
<div className="overflow-y-auto px-5 py-4 space-y-5">
{whatsNew.map((e) => (
@@ -4158,7 +4226,7 @@ export default function App() {
{e.date && <span className="text-[11px] text-muted-foreground">{e.date}</span>}
</div>
<ul className="space-y-1.5 text-sm">
{(lang === 'fr' ? e.fr : e.en).map((line, i) => (
{(clLang === 'fr' ? e.fr : e.en).map((line, i) => (
<li key={i} className="flex gap-2">
<span className="text-primary mt-1.5 size-1.5 rounded-full bg-primary shrink-0" />
<span>{line}</span>
@@ -4456,10 +4524,7 @@ export default function App() {
}}
onSend={wkSend}
onSendMacro={wkSendMacro}
onStop={() => { stopAutoCall();
if (cwSource === 'icom') IcomStopCW().catch(() => {});
else if (cwSource === 'flex') FlexStopCW().catch(() => {});
else WinkeyerStop().catch(() => {}); }}
onStop={() => { stopAutoCall(); stopKeyerTx(); }}
onClose={() => wkSetEnabled(false)}
sendOnType={wkSendOnType}
onToggleSendOnType={wkToggleSendOnType}
@@ -4950,7 +5015,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>
)}
@@ -5079,16 +5144,49 @@ export default function App() {
disabled={!rotatorHeading.enabled}
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
/>
{liveStatusOn && (
<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')}
title={onAir ? t('live.onAirTip') : t('live.offlineTip')}
>
<span className={cn('size-2 rounded-full', onAir ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')} />
{onAir ? t('live.onAir') : t('live.offline')}
</div>
)}
{/* Amplifier chip: green = OPERATE, orange = STANDBY, red = offline.
CLICK toggles OPERATE STANDBY (SPE/ACOM; optimistic flip, the 2s
poll reconciles). The PGXL has no standby command here its chip
opens Settings instead, and shows green when connected. */}
{ampCfg.enabled && (() => {
const isPGXL = ampCfg.type === 'pgxl';
const name = isPGXL ? 'PGXL'
: ampCfg.type.startsWith('acom') ? `ACOM ${ampSt.model || ''}`.trim()
: `SPE ${ampSt.model || ''}`.trim();
const dot = !ampSt.connected ? 'bg-danger' : (isPGXL || ampSt.operate) ? 'bg-success' : 'bg-warning';
const state = !ampSt.connected ? (ampSt.last_error || 'offline') : (isPGXL ? (ampSt.state || 'connected') : ampSt.operate ? 'OPERATE' : 'STANDBY');
const toggle = () => {
if (isPGXL) { setSettingsSection('pgxl'); setShowSettings(true); return; }
const want = !ampSt.operate;
setAmpSt((s: any) => ({ ...s, operate: want }));
(ampCfg.type.startsWith('acom') ? ACOMSetOperate(want) : SPESetOperate(want)).catch(() => {});
};
return (
<button
type="button"
disabled={!isPGXL && !ampSt.connected}
title={`${name}: ${state}${!isPGXL && ampSt.connected ? (ampSt.operate ? ' — click for STANDBY' : ' — click for OPERATE') : ''}`}
onClick={toggle}
className={cn('inline-flex items-center gap-1.5 px-2 h-5 rounded border text-[11px] transition-colors',
(!isPGXL && !ampSt.connected) ? 'opacity-60 cursor-default border-border' : 'border-border hover:bg-muted cursor-pointer')}
>
<span className={cn('size-2 rounded-full', dot)} />
{name}
</button>
);
})()}
{/* ON AIR badge: "did I log a QSO in the last 5 min" meaningful on ANY
logbook backend (only the live_status PUBLISHING is MySQL-specific),
so it is always shown. Gating it on MySQL made it vanish for
local-SQLite operators when the Settings toggle was removed. */}
<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')}
title={onAir ? t('live.onAirTip') : t('live.offlineTip')}
>
<span className={cn('size-2 rounded-full', onAir ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')} />
{onAir ? t('live.onAir') : t('live.offline')}
</div>
{/* Toasts / errors: the status bar's free space is far wider than the
header band they used to sit in. Still one line (the bar is 28px),
but CLICK opens the full text wrapped long messages (a TQSL or
+175 -4
View File
@@ -4,7 +4,8 @@ import {
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode,
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,
@@ -263,12 +264,43 @@ function Card({ icon: Icon, title, accent, children }: { icon: any; title: strin
);
}
// speMaxW is the amp's rated output, used as the power-bar full scale. Derived from
// the model string the amp reports (13K→1.3kW, 15K→1.5kW, 2K/20K→2kW).
function speMaxW(model?: string): number {
const m = (model || '').toUpperCase();
if (m.includes('20K') || m.includes('2K')) return 2000;
if (m.includes('15K')) return 1500;
return 1300; // 1.3K-FA default
}
// powerLevelLabel spells out the SPE power-level code (L/M/H) in full.
function powerLevelLabel(pl?: string): string {
switch ((pl || '').trim().toUpperCase()) {
case 'L': return 'Low';
case 'M': return 'Mid';
case 'H': return 'High';
default: return pl || '';
}
}
// onCWSpeed (optional): notified when the operator changes CW speed here, so the
// host can keep the WinKeyer (which actually sends the macros) in sync.
export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
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 }>>({});
@@ -310,6 +342,40 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
return () => { alive = false; window.clearInterval(id); };
}, []);
// Configured amplifier type (pgxl / spe*), so we show the SPE control when the
// operator runs an SPE Expert instead of the PowerGenius.
const [ampType, setAmpType] = useState<string>('pgxl');
const [ampEnabled, setAmpEnabled] = useState(false);
useEffect(() => {
let alive = true;
const load = () => GetPGXLSettings().then((s: any) => { if (alive) { setAmpType(s?.type || 'pgxl'); setAmpEnabled(!!s?.enabled); } }).catch(() => {});
load();
const id = window.setInterval(load, 5000);
return () => { alive = false; window.clearInterval(id); };
}, []);
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(() => {
if (!isSPE) return;
let alive = true;
const tick = () => GetSPEStatus().then((s: any) => alive && setSpe(s || { connected: false })).catch(() => {});
tick();
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;
setSt((p) => ({ ...p, [key]: val }));
@@ -760,8 +826,113 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
</Card>
</div>
{/* External amplifier (PowerGenius XL) — only when detected. */}
{st.amp_available && (
{/* SPE Expert amplifier (serial/TCP) — shown when it's the configured amp.
The Flex doesn't report SPE amps, so this card is driven by OpsLog's own
SPE link rather than the Flex amplifier object. */}
{isSPE && (
<Card icon={Flame} title={`${t('flxp.amplifier')} · SPE${spe.model ? ' ' + spe.model : ''}`} accent="#ea580c">
<div className="flex items-center gap-3 flex-wrap">
<button type="button" disabled={!spe.connected}
onClick={() => SPESetOperate(!spe.operate).catch(() => {})}
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
spe.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')}>
{spe.operate ? 'OPERATE' : 'STANDBY'}
</button>
{/* Power ON / OFF (best-guess keystrokes from the APG — verify on hw). */}
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
<button type="button" disabled={!spe.connected}
onClick={() => SPESetPower(true).catch(() => {})}
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={!spe.connected}
onClick={() => SPESetPower(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>
{/* Output power level: Low / Mid / High. */}
<div className="inline-flex rounded-lg overflow-hidden border border-border">
{(['L', 'M', 'H'] as const).map((lvl, i) => {
const active = (spe.power_level || '').trim().toUpperCase() === lvl;
return (
<button key={lvl} type="button" disabled={!spe.connected}
onClick={() => SPESetPowerLevel(lvl).catch(() => {})}
className={cn('px-3 py-2 text-sm font-bold disabled:opacity-30', i > 0 && 'border-l border-border',
active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{powerLevelLabel(lvl)}
</button>
);
})}
</div>
<span className={cn('inline-flex items-center gap-1.5 text-sm', spe.connected ? 'text-muted-foreground' : 'text-danger')}>
<span className={cn('size-2 rounded-full', spe.connected ? 'bg-success' : 'bg-danger')} />
{spe.connected ? (spe.tx ? 'TX' : 'RX') : t('flxp.speOffline')}
</span>
{spe.connected && (
<span className="text-sm font-mono text-muted-foreground tabular-nums">
{spe.band ? `${spe.band} · ` : ''}{spe.output_w}W · SWR {Number(spe.swr_ant ?? 0).toFixed(1)} · {spe.temp_c}°C · {powerLevelLabel(spe.power_level)}
</span>
)}
<div className="flex-1" />
{(spe.warnings || spe.alarms) && (
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold"> {spe.warnings} {spe.alarms}</span>
)}
</div>
{spe.connected && (
<MeterBar label={t('flxp.outputPower')} value={Number(spe.output_w) || 0} unit="W"
lo={0} hi={speMaxW(spe.model)}
display={`${Number(spe.output_w) || 0} W`}
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
)}
</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 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}
@@ -776,7 +947,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
{/* Fan mode — shown when the PowerGenius is configured (Settings →
PowerGenius). The dot shows the direct-connection state; the
selector is disabled until connected (hover it for the error). */}
{(pg.host || pg.connected) && (
{ampType === 'pgxl' && (pg.host || pg.connected) && (
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
<span className="text-muted-foreground">{t('flxp.fan')}</span>
+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} />
+158 -7
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw } from 'lucide-react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, Flame } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -12,7 +12,10 @@ import {
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
GetRotatorHeading, RotatorGoTo, RotatorStop,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
ListDenkoviDevices, ListSerialPorts,
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
GetPGXLSettings, GetPGXLStatus, PGXLSetFanMode,
GetSPEStatus, SPESetOperate, SPESetPower, SPESetPowerLevel,
GetACOMStatus, ACOMSetOperate, ACOMSetPower,
} from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
@@ -246,6 +249,110 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
);
}
// AmplifierWidget brings the amplifier controls (Settings → Amplifier) into the
// Station Control tab, so an operator WITHOUT a FlexRadio/Icom panel still has
// them (the FlexPanel card only exists when a Flex is the rig). Same backends:
// SPE Expert / ACOM (full control) and PowerGenius XL (fan mode + state).
function AmplifierWidget({ ampType, t }: { ampType: string; t: (k: string, v?: any) => string }) {
const isACOM = ampType.startsWith('acom');
const isPGXL = ampType === 'pgxl';
const [st, setSt] = useState<any>({ connected: false });
useEffect(() => {
let alive = true;
const get = isPGXL ? GetPGXLStatus : isACOM ? GetACOMStatus : GetSPEStatus;
const tick = () => get().then((s: any) => alive && setSt(s || { connected: false })).catch(() => {});
tick();
const id = window.setInterval(tick, 1500);
return () => { alive = false; window.clearInterval(id); };
}, [ampType, isACOM, isPGXL]);
const title = isPGXL ? 'PowerGenius XL' : isACOM ? `ACOM ${st.model || ''}` : `SPE ${st.model || 'Expert'}`;
const maxW = isACOM ? (Number(st.max_w) || 800) : ({ '13K': 1300, '15K': 1500, '2K': 2000 } as Record<string, number>)[st.model] || 1500;
const outW = Number(isACOM ? st.fwd_w : st.output_w) || 0;
const frac = Math.min(1, outW / maxW);
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Flame className="size-4 text-primary" />
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{t('flxp.amplifier')}</div>
<div className="text-[10px] text-muted-foreground font-mono truncate">{title}</div>
</div>
<span className={cn('ml-auto size-2 rounded-full shrink-0', st.connected ? 'bg-success' : 'bg-muted-foreground/40')}
title={st.connected ? t('station.online') : (st.last_error || t('station.offline'))} />
</div>
<div className="p-3 space-y-2">
{isPGXL ? (
<div className="flex items-center gap-2 flex-wrap">
{(['STANDARD', 'CONTEST', 'BROADCAST'] as const).map((m) => (
<button key={m} type="button" disabled={!st.connected}
onClick={() => PGXLSetFanMode(m).catch(() => {})}
className={cn('px-2.5 py-1.5 rounded-md border text-xs font-semibold disabled:opacity-40',
st.fan_mode === m ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted/30 border-border hover:bg-muted')}>
{m === 'STANDARD' ? t('flxp.fanStandard') : m === 'CONTEST' ? t('flxp.fanContest') : t('flxp.fanBroadcast')}
</button>
))}
<span className="text-xs text-muted-foreground font-mono">{st.state || ''}{st.temperature ? ` · ${Math.round(st.temperature)}°C` : ''}</span>
</div>
) : (
<>
<div className="flex items-center gap-2 flex-wrap">
<button type="button" disabled={!st.connected}
onClick={() => (isACOM ? ACOMSetOperate(!st.operate) : SPESetOperate(!st.operate)).catch(() => {})}
className={cn('px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 transition-all disabled:opacity-40',
st.operate ? 'bg-warning text-warning-foreground border-warning' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{st.operate ? 'OPERATE' : 'STANDBY'}
</button>
<div className="inline-flex rounded-lg overflow-hidden border border-success/70">
<button type="button"
disabled={isACOM ? !(st.port_open && st.transport === 'serial') : !st.connected}
onClick={() => (isACOM ? ACOMSetPower(true) : SPESetPower(true)).catch(() => {})}
className="px-2.5 py-1.5 text-xs font-bold bg-card text-success hover:bg-success/15 disabled:opacity-40">ON</button>
<button type="button" disabled={!st.connected}
onClick={() => (isACOM ? ACOMSetPower(false) : SPESetPower(false)).catch(() => {})}
className="px-2.5 py-1.5 text-xs font-bold bg-card text-danger border-l border-success/70 hover:bg-danger/15 disabled:opacity-40">OFF</button>
</div>
{!isACOM && (
<div className="inline-flex rounded-lg overflow-hidden border border-border">
{(['L', 'M', 'H'] as const).map((lvl, i) => (
<button key={lvl} type="button" disabled={!st.connected}
onClick={() => SPESetPowerLevel(lvl).catch(() => {})}
className={cn('px-2.5 py-1.5 text-xs font-bold disabled:opacity-40', i > 0 && 'border-l border-border',
(st.power_level || '').trim().toUpperCase() === lvl ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{lvl}
</button>
))}
</div>
)}
</div>
<div className="text-xs font-mono text-muted-foreground tabular-nums">
{st.connected
? <>
{isACOM ? (st.state || '') : (st.tx ? 'TX' : 'RX')}
{st.band ? ` · ${st.band}` : ''} · {outW}W · SWR {Number((isACOM ? st.swr : st.swr_ant) ?? 0).toFixed(1)} · {st.temp_c}°C
{isACOM && st.fan ? ` · Fan ${st.fan}` : ''}
</>
: (isACOM ? t('flxp.acomOffline') : t('flxp.speOffline'))}
</div>
{st.connected && (
<div>
<div className="h-2 rounded bg-muted overflow-hidden">
<div className={cn('h-full transition-all', frac > 0.9 ? 'bg-danger' : frac > 0.75 ? 'bg-warning' : 'bg-primary')}
style={{ width: `${Math.round(frac * 100)}%` }} />
</div>
<div className="text-[10px] text-muted-foreground mt-0.5">{t('flxp.outputPower')} {outW} W / {maxW} W</div>
</div>
)}
{(st.err_text || st.warnings || st.alarms) && (
<div className="text-[11px] font-bold text-danger"> {st.err_text || `${st.warnings || ''} ${st.alarms || ''}`}</div>
)}
</>
)}
</div>
</div>
);
}
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
const { t } = useI18n();
const [devices, setDevices] = useState<Device[]>([]);
@@ -260,6 +367,17 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
});
const dragId = useRef<string | null>(null);
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
// Amplifier (Settings → Amplifier): shown here so operators without a
// FlexRadio panel still get the controls. Re-read every 5s so enabling the
// amp in Settings makes the widget appear without reopening the tab.
const [amp, setAmp] = useState<{ enabled: boolean; type: string }>({ enabled: false, type: 'pgxl' });
useEffect(() => {
let alive = true;
const load = () => GetPGXLSettings().then((s: any) => { if (alive) setAmp({ enabled: !!s?.enabled, type: s?.type || 'pgxl' }); }).catch(() => {});
load();
const id = window.setInterval(load, 5000);
return () => { alive = false; window.clearInterval(id); };
}, []);
const loadDevices = useCallback(async () => {
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
@@ -380,13 +498,16 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
if (ant.enabled) {
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
}
if (amp.enabled) {
widgets.push({ id: 'amplifier', node: <AmplifierWidget ampType={amp.type} t={t} /> });
}
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
const widgetIds = ordered.map((w) => w.id);
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled;
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && !amp.enabled;
// Column count controls the layout: with 4 widgets, choosing "2" lays them out
// 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to
@@ -473,16 +594,33 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
// Detected FTDI serials for the Denkovi picker.
const [denkoviSerials, setDenkoviSerials] = useState<string[]>([]);
const [detecting, setDetecting] = useState(false);
const [detectMsg, setDetectMsg] = useState('');
const detectDenkovi = async () => {
setDetecting(true);
setDetectMsg('');
try {
const list = ((await ListDenkoviDevices()) ?? []) as string[];
setDenkoviSerials(list);
// Auto-fill when there's exactly one and nothing chosen yet.
if (list.length >= 1 && !device.host.trim()) onChange({ ...device, host: list[0] });
} catch { setDenkoviSerials([]); }
setDetectMsg(list.length === 0 ? t('station.detectNone') : t('station.detectFound', { n: list.length }));
} catch (e: any) { setDenkoviSerials([]); setDetectMsg(String(e?.message || e || t('station.detectNone'))); }
finally { setDetecting(false); }
};
// Connection test result for the current device config.
const [testing, setTesting] = useState(false);
const [testMsg, setTestMsg] = useState<{ ok: boolean; text: string } | null>(null);
const testDevice = async () => {
setTesting(true);
setTestMsg(null);
try {
const r: any = await TestStationDevice(device as any);
setTestMsg(r?.ok
? { ok: true, text: t('station.testOk', { n: r.relays }) }
: { ok: false, text: r?.error || t('station.testFail') });
} catch (e: any) { setTestMsg({ ok: false, text: String(e?.message || e || t('station.testFail')) }); }
finally { setTesting(false); }
};
useEffect(() => { if (isDenkovi) void detectDenkovi(); /* eslint-disable-next-line */ }, [isDenkovi]);
return (
<div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3">
@@ -534,6 +672,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
</Button>
</div>
<p className="text-[10px] text-muted-foreground">{t('station.ftdiHint')}</p>
{detectMsg && <p className="text-[11px] font-medium text-muted-foreground">{detectMsg}</p>}
</div>
) : isUsbRelay ? (
<div className="space-y-1 max-w-md">
@@ -581,9 +720,21 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
))}
</div>
</div>
<div className="flex justify-end gap-2">
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
<div className="flex items-center gap-2">
{testMsg && (
<span className={cn('inline-flex items-center gap-1.5 text-xs font-medium', testMsg.ok ? 'text-success' : 'text-danger')}>
<span className={cn('size-2 rounded-full', testMsg.ok ? 'bg-success' : 'bg-danger')} />
{testMsg.text}
</span>
)}
<div className="ml-auto flex gap-2">
<Button size="sm" variant="outline" onClick={testDevice} disabled={testing || !device.host.trim()}>
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <PlugZap className="size-3.5 mr-1" />}
{t('station.test')}
</Button>
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
</div>
</div>
</div>
);
+2 -5
View File
@@ -810,15 +810,12 @@ export function StatsPanel() {
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')} accent="var(--chart-3)">
<VBars data={stats.by_band} empty={empty} showValues colorful />
<Card title={t('stats.bandSplit')} sub={t('stats.bandSplitSub')} accent="var(--chart-3)">
<StackedBandBars data={stats.by_band_category} empty={empty} />
</Card>
<Card title={t('stats.byMode')} accent="var(--chart-2)">
<HBars data={stats.by_mode} max={8} empty={empty} colorful />
</Card>
<Card title={t('stats.bandSplit')} sub={t('stats.bandSplitSub')} className="lg:col-span-2" accent="var(--chart-3)">
<StackedBandBars data={stats.by_band_category} empty={empty} />
</Card>
<ActivityCard stats={stats} t={t} empty={empty} />
+6 -9
View File
@@ -22,7 +22,7 @@ const en: Dict = {
'upd.install': 'Update now', 'upd.download': 'Download', 'upd.later': 'Later',
'upd.downloading': 'Downloading…', 'upd.installing': 'Installing…',
'upd.restartNote': 'OpsLog will restart on the new version.',
'upd.retry': 'Retry', 'upd.browser': 'Open page',
'upd.retry': 'Retry', 'upd.browser': 'Open page', 'upd.checking': 'Checking for updates…', 'upd.upToDate': "You're up to date",
'rate.title': 'QSO rate (QSOs/hour) — projected from the last 10 / 60 minutes',
'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
@@ -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',
@@ -131,7 +129,7 @@ const en: Dict = {
'uscty.backfillIntro': 'Resolve county (and grid) for US QSOs already in your log that are missing them. Existing values are kept — only blanks are filled.',
'uscty.backfillRun': 'Fill missing counties',
'uscty.backfillDone': '{s} US QSOs scanned · {c} counties, {g} grids filled.',
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save',
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save', 'station.test': 'Test connection', 'station.testOk': 'Connected — {n} relays', 'station.testFail': 'Not connected', 'station.detectNone': 'No FTDI board found — check the cable and that the D2XX driver is installed.', 'station.detectFound': '{n} board(s) detected.',
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplifier', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
'sec.relayauto': 'Relay auto-control',
@@ -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.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',
@@ -369,6 +367,7 @@ const fr: Dict = {
'btn.logQso': 'Enregistrer', 'btn.clear': 'Effacer', 'btn.spot': 'Spot', 'btn.saving': '…',
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': 'Nouveautés', 'whatsnew.close': 'Compris', 'whatsnew.none': 'Aucune nouveauté pour cette version pour le moment.',
'upd.checking': 'Recherche de mises à jour…', 'upd.upToDate': 'Vous êtes à jour',
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
'stats.tab': 'Statistiques', 'stats.title': 'Statistiques du journal', 'stats.loading': 'Analyse du journal…',
'stats.noData': 'Aucune donnée', 'stats.charts': 'Graphiques', 'stats.table': 'Tableau', 'stats.refresh': 'Rafraîchir',
@@ -406,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',
@@ -444,7 +441,7 @@ const fr: Dict = {
'uscty.backfillIntro': "Résout le comté (et le locator) pour les QSO US déjà dans ton log qui n'en ont pas. Les valeurs existantes sont conservées — seuls les vides sont remplis.",
'uscty.backfillRun': 'Remplir les comtés manquants',
'uscty.backfillDone': '{s} QSO US analysés · {c} comtés, {g} locators remplis.',
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer',
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer', 'station.test': 'Tester la connexion', 'station.testOk': 'Connecté — {n} relais', 'station.testFail': 'Non connecté', 'station.detectNone': 'Aucune carte FTDI trouvée — vérifie le câble et que le driver D2XX est installé.', 'station.detectFound': '{n} carte(s) détectée(s).',
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplificateur', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
'sec.relayauto': 'Relais automatiques',
@@ -580,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.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';
+13 -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>;
@@ -721,6 +726,10 @@ export function RunBackupNow():Promise<string>;
export function SPESetOperate(arg1:boolean):Promise<void>;
export function SPESetPower(arg1:boolean):Promise<void>;
export function SPESetPowerLevel(arg1:string):Promise<void>;
export function SaveADIFFile():Promise<string>;
export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
@@ -809,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>;
@@ -845,6 +852,8 @@ export function TestQRZUpload():Promise<string>;
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
export function ULSStatus():Promise<main.ULSStatusResult>;
+24 -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']();
}
@@ -1398,6 +1406,14 @@ export function SPESetOperate(arg1) {
return window['go']['main']['App']['SPESetOperate'](arg1);
}
export function SPESetPower(arg1) {
return window['go']['main']['App']['SPESetPower'](arg1);
}
export function SPESetPowerLevel(arg1) {
return window['go']['main']['App']['SPESetPowerLevel'](arg1);
}
export function SaveADIFFile() {
return window['go']['main']['App']['SaveADIFFile']();
}
@@ -1574,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);
}
@@ -1646,6 +1658,10 @@ export function TestRotator(arg1) {
return window['go']['main']['App']['TestRotator'](arg1);
}
export function TestStationDevice(arg1) {
return window['go']['main']['App']['TestStationDevice'](arg1);
}
export function TestUltrabeam(arg1) {
return window['go']['main']['App']['TestUltrabeam'](arg1);
}
+71
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 {
@@ -2746,6 +2801,22 @@ export namespace main {
this.my_pota_ref = source["my_pota_ref"];
}
}
export class StationTestResult {
ok: boolean;
relays: number;
error?: string;
static createFrom(source: any = {}) {
return new StationTestResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.ok = source["ok"];
this.relays = source["relays"];
this.error = source["error"];
}
}
export class ULSStatusResult {
count: number;
updated_at: string;
+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 {
+40 -17
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,29 +159,30 @@ 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()
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)
}
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.
+14 -16
View File
@@ -1954,23 +1954,21 @@ 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
}
if t := parseTimeLoose(dateStr.String).UTC(); !t.IsZero() {
return t, true
}
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)
+2 -1
View File
@@ -20,7 +20,8 @@ func NewDenkovi(serial string, count int) Device {
return denkoviStub{count: count}
}
func (s denkoviStub) Count() int { return s.count }
func (s denkoviStub) Count() int { return s.count }
func (denkoviStub) Close() error { return nil }
func (denkoviStub) Status(context.Context) ([]bool, error) {
return nil, fmt.Errorf("Denkovi USB relay board is only supported on Windows")
}
+13
View File
@@ -121,6 +121,19 @@ func (d *denkovi) Set(ctx context.Context, relay int, on bool) error {
return d.writeLocked()
}
// Close releases the FTDI handle so the board can be reopened later (only one
// handle may hold a D2XX device at a time).
func (d *denkovi) Close() error {
d.mu.Lock()
defer d.mu.Unlock()
if d.opened {
procClose.Call(d.h)
d.opened = false
d.h = 0
}
return nil
}
func (d *denkovi) Status(ctx context.Context) ([]bool, error) {
d.mu.Lock()
defer d.mu.Unlock()
+9 -2
View File
@@ -27,6 +27,11 @@ type Device interface {
Count() int // number of user-controllable relays
Status(ctx context.Context) ([]bool, error) // state of each relay (index 0 = relay 1)
Set(ctx context.Context, relay int, on bool) error // relay is 1-based
// Close releases any OS handle the driver holds (serial port, FTDI handle).
// Network boards hold nothing and no-op. MUST be called when a cached driver is
// discarded so the port/handle is freed for the next open — stateful boards
// (Denkovi, USB-serial) can only be opened by one handle at a time.
Close() error
}
func httpClient() *http.Client { return &http.Client{Timeout: 5 * time.Second} }
@@ -62,7 +67,8 @@ type webswitch struct {
// NewWebswitch builds a WebSwitch 1216H client (5 relays).
func NewWebswitch(host string) Device { return &webswitch{host: host, count: 5} }
func (w *webswitch) Count() int { return w.count }
func (w *webswitch) Count() int { return w.count }
func (w *webswitch) Close() error { return nil } // stateless HTTP, nothing to release
func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
if relay < 1 || relay > w.count {
@@ -118,7 +124,8 @@ func NewKMTronic(host, user, pass string) Device {
return &kmtronic{host: host, user: user, pass: pass, count: 8}
}
func (k *kmtronic) Count() int { return k.count }
func (k *kmtronic) Count() int { return k.count }
func (k *kmtronic) Close() error { return nil } // stateless HTTP, nothing to release
func (k *kmtronic) Set(ctx context.Context, relay int, on bool) error {
if relay < 1 || relay > k.count {
+13
View File
@@ -37,6 +37,19 @@ func NewSerialRelay(port string, count int) Device {
func (s *serialRelay) Count() int { return s.count }
// Close releases the COM port so it can be reopened later (only one handle may
// hold a serial port at a time).
func (s *serialRelay) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.port != nil {
err := s.port.Close()
s.port = nil
return err
}
return nil
}
func (s *serialRelay) ensureOpen() error {
if s.port != nil {
return nil
+155 -15
View File
@@ -25,12 +25,22 @@ import (
"time"
"go.bug.st/serial"
"hamlog/internal/applog"
)
const (
cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE
cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE (confirmed on hw)
cmdStatus byte = 0x90 // request the status string
// Best-guess keystroke codes reconstructed from the APG command table after
// correcting the PDF's note-wrap misalignment (anchored to the confirmed
// OPERATE=0x0D): OFF=0x0A, POWER=0x0B. The POWER key doubles as power-on (when
// the amp is off) and the output-level cycle L→M→H (when it's on) — same as the
// single physical POWER button. To be verified on hardware.
cmdOff byte = 0x0A // OFF key — switch the amplifier off
cmdPower byte = 0x0B // POWER key — turn on / cycle output power level
syncHost = 0x55
syncAmp = 0xAA
@@ -77,6 +87,7 @@ type Client struct {
statusMu sync.RWMutex
status Status
lastRaw string // last raw status payload logged (log only on change)
stop chan struct{}
running bool
@@ -135,6 +146,57 @@ func (c *Client) Operate(on bool) error {
// ToggleOperate flips STANDBY/OPERATE unconditionally.
func (c *Client) ToggleOperate() error { return c.sendCmd(cmdOperate) }
// PowerOn turns the amplifier on. Per the SPE manual this is NOT a data command
// but a hardware DTR pulse on the serial line (the "Remote_ON" pin), which needs
// the special SPE cable — so it only applies to the serial transport. Over TCP
// there is no DTR line and power-on isn't possible remotely.
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 line (special SPE cable); not available over network")
}
// A single positive pulse (~1.2s) on DTR, as the manual describes.
if err := sp.SetDTR(true); err != nil {
return err
}
time.Sleep(1200 * time.Millisecond)
return sp.SetDTR(false)
}
// PowerOff presses the OFF key (switches the amp off).
func (c *Client) PowerOff() error { return c.sendCmd(cmdOff) }
// SetPowerLevel cycles the output power level (L/M/H) to the requested one by
// tapping the POWER key (0x0B) and WAITING for the amp to actually report the new
// level before the next tap — the status is streamed, so we poll GetStatus rather
// than sleeping a fixed time. `level` is "L", "M" or "H" (case-insensitive).
func (c *Client) SetPowerLevel(level string) error {
want := strings.ToUpper(strings.TrimSpace(level))
if want == "" {
return nil
}
// At most 3 taps to walk the 3-way L→M→H cycle around to the target.
for i := 0; i < 3; i++ {
if strings.ToUpper(strings.TrimSpace(c.GetStatus().PowerLevel)) == want {
return nil
}
prev := strings.ToUpper(strings.TrimSpace(c.GetStatus().PowerLevel))
if err := c.sendCmd(cmdPower); err != nil {
return err
}
// Wait (up to ~2s) for the streamed status to reflect the change.
for w := 0; w < 20; w++ {
time.Sleep(100 * time.Millisecond)
if strings.ToUpper(strings.TrimSpace(c.GetStatus().PowerLevel)) != prev {
break
}
}
}
return nil
}
func (c *Client) pollLoop() {
t := time.NewTicker(pollEvery)
defer t.Stop()
@@ -147,6 +209,11 @@ func (c *Client) pollLoop() {
c.setErr(fmt.Errorf("connect: %w", err))
continue
}
// The amp streams status frames faster than one per poll, so a backlog
// builds up and we'd read stale frames (the display lagged ~15s behind
// the real amp). Flush anything pending, THEN request + read exactly one
// fresh frame — the status we show is always current.
c.drainInput()
if err := c.sendCmd(cmdStatus); err != nil {
c.mu.Lock()
c.dropLocked()
@@ -190,6 +257,32 @@ func (c *Client) dropLocked() {
}
}
// drainInput discards everything currently pending on the link (OS buffer + the
// bufio reader) so the next read returns a fresh frame rather than a queued stale
// one. Called before each status request.
func (c *Client) drainInput() {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return
}
if sp, ok := c.conn.(serial.Port); ok {
_ = sp.ResetInputBuffer()
} else if nc, ok := c.conn.(net.Conn); ok {
_ = nc.SetReadDeadline(time.Now().Add(15 * time.Millisecond))
buf := make([]byte, 4096)
for {
n, err := nc.Read(buf)
if n == 0 || err != nil {
break
}
}
}
if c.r != nil {
c.r.Reset(c.conn)
}
}
// sendCmd frames one keystroke code and writes it. Single-byte payload → CHK is
// the code itself.
func (c *Client) sendCmd(code byte) error {
@@ -268,28 +361,75 @@ func (c *Client) decodeCSV(payload string) {
c.statusMu.Lock()
defer c.statusMu.Unlock()
// Log the raw status frame ONCE per change, so field alignment can be verified
// against real hardware (the doc's 19-field layout may differ per firmware).
if payload != c.lastRaw {
c.lastRaw = payload
applog.Printf("spe: status raw=%q fields=%d", payload, len(f))
}
c.status.Connected = true
c.status.LastError = ""
c.status.Model = get(0)
c.status.Operate = get(1) == "O"
c.status.TX = get(2) == "T"
c.status.Input = get(4)
c.status.Band = get(5)
c.status.PowerLevel = get(8)
c.status.OutputW = pi(get(9))
c.status.SWRATU = pf(get(10))
c.status.SWRAnt = pf(get(11))
c.status.VoltPA = pf(get(12))
c.status.CurrPA = pf(get(13))
c.status.TempC = pi(get(14))
if w := get(17); w != "" && w != "N" {
// The real frame carries a leading empty field (it starts with a comma), so the
// 19 documented fields live at indices 1..19, not 0..18. Verified against a live
// 1.3K-FA: ",13K,S,R,A,1,05,1b,0r,M,0000, 0.00, 0.00, 1.3, 0.0, 26,000,000,N,N,".
c.status.Model = get(1)
c.status.Operate = get(2) == "O" // "O" = OPERATE, "S" = STANDBY
c.status.TX = get(3) == "T" // "T" = transmit, "R" = receive
c.status.Input = get(5)
c.status.Band = bandName(get(6))
c.status.PowerLevel = get(9)
c.status.OutputW = pi(get(10))
c.status.SWRATU = pf(get(11))
c.status.SWRAnt = pf(get(12))
c.status.VoltPA = pf(get(13))
c.status.CurrPA = pf(get(14))
c.status.TempC = pi(get(15))
if w := get(18); w != "" && w != "N" {
c.status.Warnings = w
} else {
c.status.Warnings = ""
}
if a := get(18); a != "" && a != "N" {
if a := get(19); a != "" && a != "N" {
c.status.Alarms = a
} else {
c.status.Alarms = ""
}
}
// bandName maps the SPE 2-digit band index to a human band label, falling back to
// the raw code for anything unrecognised.
func bandName(code string) string {
// SPE band index, verified against a live 1.3K-FA: the status frame reports the
// band as a 2-digit decimal string ordered by descending wavelength — 80m→"01"
// and 20m→"05" were both confirmed on hardware (the printed manual's table was
// off by one). 00=160m, 01=80m, 02=60m, 03=40m, 04=30m, 05=20m, 06=17m, 07=15m,
// 08=12m, 09=10m, 10=6m.
switch strings.TrimSpace(code) {
case "00":
return "160m"
case "01":
return "80m"
case "02":
return "60m"
case "03":
return "40m"
case "04":
return "30m"
case "05":
return "20m"
case "06":
return "17m"
case "07":
return "15m"
case "08":
return "12m"
case "09":
return "10m"
case "10":
return "6m"
case "":
return ""
default:
return code
}
}
+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.