Compare commits

..
28 Commits
Author SHA1 Message Date
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
22 changed files with 791 additions and 155 deletions
+37 -7
View File
@@ -112,7 +112,10 @@ Affiché uniquement quand le backend CAT est une FlexRadio :
parole (NOR/DX/DX+), VOX (+ niveau + délai), moniteur (+ niveau), gain micro. parole (NOR/DX/DX+), VOX (+ niveau + délai), moniteur (+ niveau), gain micro.
- **Réception (slice active) :** mode/seuil AGC, niveau audio, NB / NR / ANF. - **Réception (slice active) :** mode/seuil AGC, niveau audio, NB / NR / ANF.
- **Coupleur d'antenne (ATU) :** accord / bypass / mémoires. - **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 - **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. directe (W), ROS, ALC, température PA, tension, plus les mesures de l'ampli.
@@ -159,9 +162,10 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
## Keyers & audio ## Keyers & audio
- **Keyer CW** avec macros et macros sur touches F. Le moteur du keyer est - **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 sélectionnable : **WinKeyer** (K1EL WK1/2/3 sur port COM), **FlexRadio CWX**
intégré de la radio via CI-V — sans matériel supplémentaire, fonctionne aussi à (le keyer intégré de la radio via l'API SmartSDR — type-ahead et retour arrière,
distance) ou **TCI**. 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 - **Keyer vocal numérique** (DVK) : enregistrer les messages vocaux F1F6 et les
émettre. émettre.
- **Enregistrement audio des QSO :** capture continue en tampon glissant ; au - **Enregistrement audio des QSO :** capture continue en tampon glissant ; au
@@ -170,10 +174,24 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
## Amplis & commutateurs ## Amplis & commutateurs
- Amplificateur **PowerGenius XL** (4O3A) — TCP direct : operate/standby, - **Amplificateurs** (Réglages → Amplificateur — la carte de commande apparaît
sélecteur de mode ventilateur et affichage des défauts. 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 - Commutateur d'antenne **Antenna Genius** (4O3A) via TCP/GSCP — un widget de
commutation A/B ancré. 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 ## QSL & diplômes
@@ -201,6 +219,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 un début/fin de fenêtre, signale les doublons et tient un tableau de score en
direct. 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) ## 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. Pour un indicatif d'événement spécial multi-op sur un journal MySQL partagé (ex.
@@ -247,7 +274,10 @@ ce n'est pas un serveur web.
- **Démarrage automatique :** lancer des programmes externes (WSJT-X, JTAlert, - **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. contrôle de rotor…) au démarrage d'OpsLog, en sautant ceux déjà lancés.
- **Sauvegarde :** sauvegarde optionnelle base + ADIF à la fermeture. - **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 - **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'installation aléatoire + version + OS — aucune donnée d'indicatif ou de QSO ;
désactivable dans les Préférences). désactivable dans les Préférences).
+35 -6
View File
@@ -100,7 +100,10 @@ Shown only when the CAT backend is a FlexRadio:
VOX (+ level + delay), monitor (+ level), mic gain. VOX (+ level + delay), monitor (+ level), mic gain.
- **Receive (active slice):** AGC mode/threshold, audio level, NB / NR / ANF. - **Receive (active slice):** AGC mode/threshold, audio level, NB / NR / ANF.
- **Antenna tuner (ATU):** tune / bypass / memories. - **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 - **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. (W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
@@ -141,18 +144,33 @@ as Mumble.)
## Keyers & audio ## Keyers & audio
- **CW keyer** with macros and F-key macros. The keyer engine is selectable: - **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 **WinKeyer** (K1EL WK1/2/3 over a COM port), **FlexRadio CWX** (the radio's
over CI-V — no extra hardware, works over the remote link too) or **TCI**. 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. - **Digital Voice Keyer** (DVK): record F1F6 voice messages and transmit them.
- **QSO audio recording:** continuous rolling capture; on *Log QSO* the contact - **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. is saved to a per-QSO WAV (`CALL_YYYYMMDD_HHMMSS.wav`); mixes RX + mic.
## Amplifiers & switches ## Amplifiers & switches
- **PowerGenius XL** (4O3A) amplifier — direct TCP: operate/standby, fan-mode - **Amplifiers** (Settings → Amplifier — the control card appears on the
selector and fault display. 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 Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
antenna-switch widget. 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 ## QSL & awards
@@ -176,6 +194,14 @@ as Mumble.)
and the sent/received serials (`STX` / `SRX`), enforces a window start/end, and the sent/received serials (`STX` / `SRX`), enforces a window start/end,
flags dupes and keeps a live scoreboard. 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) ## Multi-operator live status (special events)
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**): For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**):
@@ -218,7 +244,10 @@ only writes to the DB — it is not a web server.
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at - **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
OpsLog startup, skipping any already running. OpsLog startup, skipping any already running.
- **Backup:** optional database + ADIF backup at shutdown. - **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 + - **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
version + OS — no callsign or QSO data; opt-out in Preferences). version + OS — no callsign or QSO data; opt-out in Preferences).
+165 -34
View File
@@ -490,6 +490,8 @@ type App struct {
relayAutoMu sync.Mutex // serialises relay auto-control evaluation 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 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 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 pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle 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) pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
@@ -812,6 +814,33 @@ func (a *App) startup(ctx context.Context) {
a.lookup = lookup.NewManager(a.cache) a.lookup = lookup.NewManager(a.cache)
a.reloadLookupProviders() 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 // The QSO logbook lives where the ACTIVE PROFILE points it: the local SQLite
// file, or a per-profile shared MySQL database. Switching profiles switches // file, or a per-profile shared MySQL database. Switching profiles switches
// the logbook (see switchLogbook). One-time: adopt any legacy config.json // the logbook (see switchLogbook). One-time: adopt any legacy config.json
@@ -906,28 +935,6 @@ func (a *App) startup(ctx context.Context) {
fmt.Printf("OpsLog: clublog cty.xml loaded — %d exceptions (%s)\n", n, d) 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 // POTA: background poller of api.pota.app so cluster spots can be tagged
// when the DX station is currently activating a park. Best-effort. // when the DX station is currently activating a park. Best-effort.
@@ -1191,8 +1198,15 @@ func (a *App) runShutdownTasks(ctx context.Context, steps []shutdownStep) {
a.emitShutdownEvent("shutdown:update", steps) a.emitShutdownEvent("shutdown:update", steps)
} }
a.emitShutdownEvent("shutdown:done", steps) a.emitShutdownEvent("shutdown:done", steps)
// Give the UI a moment to show the "done" state before we yank the // Cleanly tear down hardware links before quitting. Especially the CAT
// window away. 600ms feels purposeful without being annoying. // 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) time.Sleep(600 * time.Millisecond)
wruntime.Quit(ctx) wruntime.Quit(ctx)
} }
@@ -9441,6 +9455,15 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
if a.qso == nil { if a.qso == nil {
return 0, fmt.Errorf("db not initialized") return 0, fmt.Errorf("db not initialized")
} }
// Serialise the WHOLE operation — parse, callsign LOOKUP, dedup and insert —
// so simultaneous UDP QSOs are processed one at a time (a queue). A multi-stream
// MSHV finishing 4 QSOs at once used to fire 4 concurrent callsign lookups and DB
// writes, which the QRZ/HamQTH service (and a remote DB) rejected with "too many
// requests". Holding the lock across the lookup spaces them out; the UDP reader
// runs on its own goroutine, so this never blocks packet reception — callers just
// queue behind each other. (adifwatch shares this lock for the same reason.)
a.udpLogMu.Lock()
defer a.udpLogMu.Unlock()
// Pull the first record out of the payload. WSJT-X / JTDX / MSHV // Pull the first record out of the payload. WSJT-X / JTDX / MSHV
// always send a single QSO per UDP packet (no header) but we tolerate // always send a single QSO per UDP packet (no header) but we tolerate
// either form via adif.Parse. // either form via adif.Parse.
@@ -9583,12 +9606,10 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
// a minute (the two apps stamp their own time), so a minute-exact key // a minute (the two apps stamp their own time), so a minute-exact key
// missed it and the contact got duplicated. // missed it and the contact got duplicated.
// //
// The check + insert is guarded by udpLogMu: MSHV/WSJT can deliver the same // The check + insert is covered by udpLogMu (taken at the top of this function):
// logged-QSO packet twice in quick succession (re-broadcast, or two // MSHV/WSJT can deliver the same logged-QSO packet twice in quick succession
// listeners), and without serialisation both goroutines read the dedup set // (re-broadcast, or two listeners), and without serialisation both goroutines
// BEFORE either inserts, both pass, and the QSO lands twice. // read the dedup set BEFORE either inserts, both pass, and the QSO lands twice.
a.udpLogMu.Lock()
defer a.udpLogMu.Unlock()
seen, err := a.qso.ExistingDedupeKeys(a.ctx) seen, err := a.qso.ExistingDedupeKeys(a.ctx)
if err == nil { if err == nil {
base := q.QSODate.UTC() base := q.QSODate.UTC()
@@ -9602,16 +9623,35 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
id, err := a.qso.Add(a.ctx, q) id, err := a.qso.Add(a.ctx, q)
if err != nil { if err != nil {
// DB UNREACHABLE (drop / timeout on a slow-or-broken MySQL) — park the QSO
// in the offline outbox rather than lose it, exactly like the manual log
// path. Without this, a laggy shared MySQL silently dropped UDP-logged QSOs
// from a multi-stream MSHV. Returns -1 so the caller treats it as "saved,
// waiting to sync", not a failure.
if db.IsConnLost(err) && a.queueOffline(q, err) {
return -1, nil
}
return 0, fmt.Errorf("insert qso: %w", err) return 0, fmt.Errorf("insert qso: %w", err)
} }
q.ID = id q.ID = id
a.noteLiveQSO() // multi-op: flip this operator back "online" a.noteLiveQSO() // multi-op: flip this operator back "online"
a.materializeAwardRefs(q) a.materializeAwardRefs(q)
// Announce the log so UI widgets refresh AT ONCE (the Recent-QSOs grid, the
// ON-AIR badge and the "stations on air" widget). The manual log path always
// did this; the UDP/FT8 path did not, so a station running MSHV saw the top
// "on air" list lag ~15-45s behind the instant bottom badge.
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qso:logged", id)
}
a.saveQSORecording(&q) a.saveQSORecording(&q)
if a.extsvc != nil { if a.extsvc != nil {
a.extsvc.OnQSOLogged(id) a.extsvc.OnQSOLogged(id)
} }
a.maybeAutoSendEQSL(q) a.maybeAutoSendEQSL(q)
// A successful write means the link is healthy again — flush anything parked.
if a.offlineMode && a.offlineQ != nil && a.offlineQ.Count() > 0 {
go func() { _, _ = a.replayOfflineQueue() }()
}
return id, nil return id, nil
} }
@@ -11433,8 +11473,15 @@ func relayCountFor(typ string) int {
} }
} }
// deviceDriver builds the wire driver for a configured device. // cachedRelay is a live driver plus the config signature it was built from, so a
func deviceDriver(d StationDevice) relaydev.Device { // 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 { switch d.Type {
case "kmtronic": case "kmtronic":
return relaydev.NewKMTronic(d.Host, d.User, d.Pass) return relaydev.NewKMTronic(d.Host, d.User, d.Pass)
@@ -11449,12 +11496,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 // ListDenkoviDevices returns the FTDI serial numbers of connected Denkovi/FTDI
// boards, for the settings picker. Windows-only (FTDI D2XX). // boards, for the settings picker. Windows-only (FTDI D2XX).
func (a *App) ListDenkoviDevices() ([]string, error) { func (a *App) ListDenkoviDevices() ([]string, error) {
return relaydev.ListDenkovi() 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). // GetStationDevices returns the configured relay boards (without live state).
func (a *App) GetStationDevices() []StationDevice { func (a *App) GetStationDevices() []StationDevice {
out := []StationDevice{} out := []StationDevice{}
@@ -11497,6 +11606,9 @@ func (a *App) SaveStationDevices(devs []StationDevice) error {
if err != nil { if err != nil {
return err 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)) return a.settings.SetGlobal(a.ctx, keyStationDevices, string(b))
} }
@@ -11532,7 +11644,7 @@ func (a *App) GetStationStatus() []StationDeviceStatus {
ds := StationDeviceStatus{ID: d.ID, Name: d.Name, Type: d.Type} ds := StationDeviceStatus{ID: d.ID, Name: d.Name, Type: d.Type}
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second) ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
defer cancel() defer cancel()
states, err := deviceDriver(d).Status(ctx) states, err := a.driverFor(d).Status(ctx)
if err != nil { if err != nil {
ds.Error = err.Error() ds.Error = err.Error()
} else { } else {
@@ -11563,7 +11675,7 @@ func (a *App) StationSetRelay(id string, relay int, on bool) error {
if d.ID == id { if d.ID == id {
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second) ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
defer cancel() 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) return fmt.Errorf("station device %q not found", id)
@@ -12261,6 +12373,25 @@ func (a *App) SPESetOperate(on bool) error {
return a.spe.Operate(on) 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)
}
// GetPGXLStatus returns the amp's fan/connection state for the UI poll. // GetPGXLStatus returns the amp's fan/connection state for the UI poll.
func (a *App) GetPGXLStatus() powergenius.Status { func (a *App) GetPGXLStatus() powergenius.Status {
if a.pgxl == nil { if a.pgxl == nil {
+30
View File
@@ -1,4 +1,34 @@
[ [
{
"version": "0.20.6",
"date": "2026-07-21",
"en": [
"Amplifier controls now live right in the FlexRadio panel: OPERATE/STANDBY, ON/OFF, Low/Mid/High power level, an output-power bar and live band / SWR / temperature — for both PowerGenius XL and SPE Expert. The card is hidden when no amplifier is selected.",
"Faster startup: the radio (CAT) connects immediately instead of waiting behind the shared-MySQL connection, and MySQL itself connects much faster.",
"No more lost QSOs: an FT8/MSHV QSO that can't reach a laggy shared database is parked and re-logged automatically once it recovers; simultaneous QSOs from a multi-stream are now queued instead of failing with 'too many requests'.",
"The 'stations on air' widget now updates within a second of a QSO (it used to lag up to ~45s behind the ON-AIR badge).",
"Relay boards (Denkovi / USB-serial) stay connected reliably now, with a Test-connection button and detect feedback in the Station Control setup.",
"<LOGQSO> in a CW macro logs the contact at that exact point in the macro, and pressing ESC before it runs cancels the log.",
"Auto-call now waits exactly the gap you set between calls (it was waiting far longer).",
"Statistics: the redundant per-band chart was merged into the CW / phone / data split.",
"Cluster self-spot pop-ups now show the band.",
"The bottom status bar is no longer hidden behind a scrollbar in a small window.",
"Update check now also runs when you open Help - About, and every 5 minutes."
],
"fr": [
"Les commandes d'amplificateur sont maintenant directement dans le panneau FlexRadio : OPERATE/STANDBY, Marche/Arrêt, niveau Low/Mid/High, une barre de puissance de sortie et l'état en direct bande / ROS / température — pour PowerGenius XL comme pour SPE Expert. La carte est masquée si aucun ampli n'est sélectionné.",
"Démarrage plus rapide : la radio (CAT) se connecte immédiatement au lieu d'attendre derrière la connexion MySQL partagée, et MySQL lui-même se connecte bien plus vite.",
"Plus de QSO perdus : un QSO FT8/MSHV qui n'atteint pas une base partagée lente est mis de côté et ré-enregistré automatiquement dès qu'elle répond ; les QSO simultanés d'un multi-stream sont maintenant mis en file au lieu d'échouer avec « too many requests ».",
"Le widget « stations on air » se met à jour en une seconde après un QSO (il pouvait accuser jusqu'à ~45s de retard sur le badge ON AIR).",
"Les cartes relais (Denkovi / USB-série) restent connectées de façon fiable, avec un bouton « Tester la connexion » et un retour de détection dans la configuration Station Control.",
"<LOGQSO> dans une macro CW enregistre le contact à cet endroit précis de la macro, et appuyer sur ÉCHAP avant son exécution annule l'enregistrement.",
"L'appel automatique respecte maintenant exactement l'intervalle réglé entre les appels (il attendait bien plus longtemps).",
"Statistiques : le graphique par bande redondant a été fusionné avec la répartition CW / phone / data.",
"Les popups d'auto-spot du cluster affichent maintenant la bande.",
"La barre de statut du bas n'est plus masquée par une barre de défilement en fenêtre réduite.",
"La vérification de mise à jour se déclenche aussi à l'ouverture d'Aide - À propos, et toutes les 5 minutes."
]
},
{ {
"version": "0.20.5", "version": "0.20.5",
"date": "2026-07-20", "date": "2026-07-20",
+94 -57
View File
@@ -452,9 +452,13 @@ export default function App() {
const load = () => GetLiveStations().then((s) => setLiveStations((s ?? []) as LiveStation[])).catch(() => {}); const load = () => GetLiveStations().then((s) => setLiveStations((s ?? []) as LiveStation[])).catch(() => {});
load(); load();
const id = window.setInterval(load, 5 * 1000); const id = window.setInterval(load, 5 * 1000);
// Small delay lets the async publishLiveStatus MySQL write land before we read. // Refresh the instant the backend actually publishes a row change (a QSO put
// someone on air, or a 5-min silence took them off) — so this widget tracks the
// bottom ON-AIR badge instead of lagging behind its poll. Keep the qso:logged
// fallback too (a small delay lets the async publish land) for older paths.
const offStatus = EventsOn('livestatus:updated', load);
const offLogged = EventsOn('qso:logged', () => { window.setTimeout(load, 1500); }); const offLogged = EventsOn('qso:logged', () => { window.setTimeout(load, 1500); });
return () => { window.clearInterval(id); offLogged?.(); }; return () => { window.clearInterval(id); offStatus?.(); offLogged?.(); };
}, [liveStationsOn]); }, [liveStationsOn]);
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig // Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default // can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
@@ -811,6 +815,11 @@ export default function App() {
const wkEscClearsRef = useRef(true); const wkEscClearsRef = useRef(true);
const wkBusyRef = useRef(false); // live "keyer is sending" flag, for the <LOGQSO> wait-then-log const wkBusyRef = useRef(false); // live "keyer is sending" flag, for the <LOGQSO> wait-then-log
useEffect(() => { wkBusyRef.current = wkStatus.busy; }, [wkStatus.busy]); 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(() => { useEffect(() => {
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected) const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected)
: cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected) : cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected)
@@ -1150,15 +1159,28 @@ export default function App() {
// "What's new": the changelog for the version(s) since the operator last ran, // "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). // 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); 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(() => { useEffect(() => {
GetWhatsNew().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); }).catch(() => {}); GetWhatsNew().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); }).catch(() => {});
}, []); }, []);
const [showDuplicates, setShowDuplicates] = useState(false); const [showDuplicates, setShowDuplicates] = useState(false);
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null); 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 [updating, setUpdating] = useState(false);
const [updateProgress, setUpdateProgress] = useState(0); const [updateProgress, setUpdateProgress] = useState(0);
const [updateError, setUpdateError] = useState(''); 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. // disabled in General). Best effort — silent on failure.
useEffect(() => { useEffect(() => {
if (localStorage.getItem('opslog.checkUpdates') === '0') return; if (localStorage.getItem('opslog.checkUpdates') === '0') return;
@@ -1166,7 +1188,7 @@ export default function App() {
if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') }); if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') });
}).catch(() => {}); }).catch(() => {});
check(); check();
const id = window.setInterval(check, 10 * 60 * 1000); const id = window.setInterval(check, 5 * 60 * 1000);
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, []); }, []);
// Live download progress for the in-app updater. // Live download progress for the in-app updater.
@@ -1921,7 +1943,8 @@ export default function App() {
if (mine && (sp.dx_call ?? '').toUpperCase() === mine) { if (mine && (sp.dx_call ?? '').toUpperCase() === mine) {
const by = cleanSpotter(sp.spotter ?? '') || '?'; const by = cleanSpotter(sp.spotter ?? '') || '?';
const c = (sp.comment ?? '').trim(); 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 () => { return () => {
@@ -2112,44 +2135,59 @@ export default function App() {
return out.replace(/\s+/g, ' ').trim(); return out.replace(/\s+/g, ' ').trim();
} }
async function wkSend(rawText: string) { async function wkSend(rawText: string) {
const gen = ++wkSendGenRef.current; // this send supersedes any previous; abort bumps it too
setWkSent(''); 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)); const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
if (cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex') { const aborted = () => gen !== wkSendGenRef.current;
// The rig keyer (Icom 0x17 / Flex CWX) gives no busy echo we track, so show // Cancellable wait: sleeps up to ms, but bails out immediately on abort.
// the text we sent and, for <LOGQSO>, wait the estimated send duration const waitMs = async (ms: number) => {
// before logging. const end = Date.now() + ms;
setWkSent(resolved); while (Date.now() < end && !aborted()) await sleep(50);
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : IcomSendCW; };
await sendFn(keyed).catch((e) => setError(String(e?.message ?? e))); // Split the macro on <LOGQSO> so the log happens AT the token's position, not
if (doLog) { await sleep(Math.round(estimateCwMs(resolved, wkWpm)) + 600); void save(); } // only at the very end: "TU <LOGQSO> QRZ" sends "TU", logs, then sends "QRZ".
return; // Each split boundary (every part except the last) is one <LOGQSO>.
const parts = rawText.split(/<LOGQSO>/i);
const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex';
for (let p = 0; p < parts.length; p++) {
if (aborted()) return; // ESC / Stop before this segment → stop sending, don't log
const resolved = resolveCW(parts[p]);
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);
if (isRig) {
// The rig keyer (Icom 0x17 / Flex CWX) gives no busy echo we track, so
// wait the ESTIMATED send duration before moving on / logging.
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : IcomSendCW;
await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + (p < parts.length - 1 ? 200 : 600));
} else {
await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
// Wait for the keyer to actually finish this segment before we log /
// continue. We'd like to watch busy rise then fall, but over a
// remote/serial-over-IP link that echo can lag tens of seconds, so cap
// the wait at the ESTIMATED send duration + slack: proceed when busy
// clears OR the estimate elapses, whichever comes first.
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500;
for (let i = 0; i < 20 && !wkBusyRef.current && !aborted(); i++) await sleep(50); // ≤1s to start
const deadline = Date.now() + capMs;
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
}
}
if (aborted()) return; // aborted while this segment was sending → don't log
// A <LOGQSO> token follows every part except the last → log the contact here.
if (p < parts.length - 1) void save();
} }
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. // stopAutoCall cancels any running auto-call loop.
function stopAutoCall() { autoCallMacroRef.current = -1; autoCallGenRef.current++; } function stopAutoCall() { autoCallMacroRef.current = -1; autoCallGenRef.current++; }
// stopKeyerTx aborts the CW being sent RIGHT NOW, routed to the active engine // 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). // (was WinKeyer-only in a few places, so a Flex CWX / Icom macro kept going).
function stopKeyerTx() { function stopKeyerTx() {
wkSendGenRef.current++; // cancel any in-flight macro send (and its pending <LOGQSO> log)
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {}); if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {}); else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
else WinkeyerStop().catch(() => {}); else WinkeyerStop().catch(() => {});
@@ -2163,20 +2201,11 @@ export default function App() {
while (autoCallMacroRef.current === i && gen === autoCallGenRef.current && wkActiveRef.current) { while (autoCallMacroRef.current === i && gen === autoCallGenRef.current && wkActiveRef.current) {
const m = wkMacros[i]; const m = wkMacros[i];
if (!m) break; 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); 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; if (gen !== autoCallGenRef.current) break;
await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call
} }
@@ -2831,7 +2860,7 @@ export default function App() {
case 'tools.duplicates': setShowDuplicates(true); break; case 'tools.duplicates': setShowDuplicates(true); break;
case 'tools.refreshCty': refreshCtyDat(); break; case 'tools.refreshCty': refreshCtyDat(); break;
case 'tools.downloadRefs': downloadRefs(); 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; case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
} }
} }
@@ -2889,6 +2918,7 @@ export default function App() {
// ESC didn't stop the Icom or Flex keyer). // ESC didn't stop the Icom or Flex keyer).
if (keyerLive) { if (keyerLive) {
stopAutoCall(); stopAutoCall();
wkSendGenRef.current++; // abort an in-flight macro send so a pending <LOGQSO> won't fire
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {}); if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {}); else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
else WinkeyerStop().catch(() => {}); else WinkeyerStop().catch(() => {});
@@ -3682,7 +3712,7 @@ export default function App() {
}; };
return ( return (
<div className="flex flex-col h-screen bg-background"> <div className="flex flex-col h-screen overflow-hidden bg-background">
<ShutdownProgress /> <ShutdownProgress />
{/* ===== TOPBAR ===== */} {/* ===== TOPBAR ===== */}
{compact ? ( {compact ? (
@@ -4122,12 +4152,14 @@ export default function App() {
</div> </div>
<p className="text-sm text-muted-foreground">Ham-radio logbook</p> <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> <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"> <button onClick={() => updateInfo.url && BrowserOpenURL(updateInfo.url)} className="mt-1 text-xs text-primary underline hover:opacity-80">
Update available: v{updateInfo.latest} download Update available: v{updateInfo.latest} download
</button> </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"> <p className="mt-3 text-sm">
Developed by <span className="font-semibold text-primary">{APP_AUTHOR}</span> Developed by <span className="font-semibold text-primary">{APP_AUTHOR}</span>
@@ -4148,7 +4180,15 @@ export default function App() {
<Zap className="size-5 text-primary" /> <Zap className="size-5 text-primary" />
<h2 className="text-lg font-bold tracking-tight">{t('whatsnew.title')}</h2> <h2 className="text-lg font-bold tracking-tight">{t('whatsnew.title')}</h2>
<span className="flex-1" /> <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>
<div className="overflow-y-auto px-5 py-4 space-y-5"> <div className="overflow-y-auto px-5 py-4 space-y-5">
{whatsNew.map((e) => ( {whatsNew.map((e) => (
@@ -4158,7 +4198,7 @@ export default function App() {
{e.date && <span className="text-[11px] text-muted-foreground">{e.date}</span>} {e.date && <span className="text-[11px] text-muted-foreground">{e.date}</span>}
</div> </div>
<ul className="space-y-1.5 text-sm"> <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"> <li key={i} className="flex gap-2">
<span className="text-primary mt-1.5 size-1.5 rounded-full bg-primary shrink-0" /> <span className="text-primary mt-1.5 size-1.5 rounded-full bg-primary shrink-0" />
<span>{line}</span> <span>{line}</span>
@@ -4456,10 +4496,7 @@ export default function App() {
}} }}
onSend={wkSend} onSend={wkSend}
onSendMacro={wkSendMacro} onSendMacro={wkSendMacro}
onStop={() => { stopAutoCall(); onStop={() => { stopAutoCall(); stopKeyerTx(); }}
if (cwSource === 'icom') IcomStopCW().catch(() => {});
else if (cwSource === 'flex') FlexStopCW().catch(() => {});
else WinkeyerStop().catch(() => {}); }}
onClose={() => wkSetEnabled(false)} onClose={() => wkSetEnabled(false)}
sendOnType={wkSendOnType} sendOnType={wkSendOnType}
onToggleSendOnType={wkToggleSendOnType} onToggleSendOnType={wkToggleSendOnType}
+106 -4
View File
@@ -4,7 +4,7 @@ import {
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay, GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic, FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexAmpOperate, FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode, GetPGXLStatus, PGXLSetFanMode, GetPGXLSettings, GetSPEStatus, SPESetOperate, SPESetPower, SPESetPowerLevel,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice, FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq, FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel, FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
@@ -263,6 +263,25 @@ 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 // onCWSpeed (optional): notified when the operator changes CW speed here, so the
// host can keep the WinKeyer (which actually sends the macros) in sync. // 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 } = {}) { export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
@@ -310,6 +329,29 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
return () => { alive = false; window.clearInterval(id); }; 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 isSPE = ampEnabled && 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]);
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => { const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
hold.current[key] = Date.now() + 900; hold.current[key] = Date.now() + 900;
setSt((p) => ({ ...p, [key]: val })); setSt((p) => ({ ...p, [key]: val }));
@@ -760,8 +802,68 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
</Card> </Card>
</div> </div>
{/* External amplifier (PowerGenius XL) — only when detected. */} {/* SPE Expert amplifier (serial/TCP) — shown when it's the configured amp.
{st.amp_available && ( 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>
)}
{/* External amplifier (PowerGenius XL) — only when the Flex reports one AND
PowerGenius is the selected amp type. Running an SPE Expert hides this
Flex-reported card so the two amps don't both show. */}
{st.amp_available && !isSPE && (
<Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c"> <Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button type="button" disabled={off} <button type="button" disabled={off}
@@ -776,7 +878,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
{/* Fan mode — shown when the PowerGenius is configured (Settings → {/* Fan mode — shown when the PowerGenius is configured (Settings →
PowerGenius). The dot shows the direct-connection state; the PowerGenius). The dot shows the direct-connection state; the
selector is disabled until connected (hover it for the error). */} 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'))}> <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={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> <span className="text-muted-foreground">{t('flxp.fan')}</span>
@@ -12,7 +12,7 @@ import {
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay, GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
GetRotatorHeading, RotatorGoTo, RotatorStop, GetRotatorHeading, RotatorGoTo, RotatorStop,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements, GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
ListDenkoviDevices, ListSerialPorts, ListDenkoviDevices, ListSerialPorts, TestStationDevice,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null }; type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
@@ -473,16 +473,33 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
// Detected FTDI serials for the Denkovi picker. // Detected FTDI serials for the Denkovi picker.
const [denkoviSerials, setDenkoviSerials] = useState<string[]>([]); const [denkoviSerials, setDenkoviSerials] = useState<string[]>([]);
const [detecting, setDetecting] = useState(false); const [detecting, setDetecting] = useState(false);
const [detectMsg, setDetectMsg] = useState('');
const detectDenkovi = async () => { const detectDenkovi = async () => {
setDetecting(true); setDetecting(true);
setDetectMsg('');
try { try {
const list = ((await ListDenkoviDevices()) ?? []) as string[]; const list = ((await ListDenkoviDevices()) ?? []) as string[];
setDenkoviSerials(list); setDenkoviSerials(list);
// Auto-fill when there's exactly one and nothing chosen yet. // Auto-fill when there's exactly one and nothing chosen yet.
if (list.length >= 1 && !device.host.trim()) onChange({ ...device, host: list[0] }); 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); } 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]); useEffect(() => { if (isDenkovi) void detectDenkovi(); /* eslint-disable-next-line */ }, [isDenkovi]);
return ( return (
<div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3"> <div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3">
@@ -534,6 +551,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
</Button> </Button>
</div> </div>
<p className="text-[10px] text-muted-foreground">{t('station.ftdiHint')}</p> <p className="text-[10px] text-muted-foreground">{t('station.ftdiHint')}</p>
{detectMsg && <p className="text-[11px] font-medium text-muted-foreground">{detectMsg}</p>}
</div> </div>
) : isUsbRelay ? ( ) : isUsbRelay ? (
<div className="space-y-1 max-w-md"> <div className="space-y-1 max-w-md">
@@ -581,9 +599,21 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
))} ))}
</div> </div>
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button> {testMsg && (
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button> <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>
</div> </div>
); );
+2 -5
View File
@@ -810,15 +810,12 @@ export function StatsPanel() {
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5"> <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)"> <Card title={t('stats.bandSplit')} sub={t('stats.bandSplitSub')} accent="var(--chart-3)">
<VBars data={stats.by_band} empty={empty} showValues colorful /> <StackedBandBars data={stats.by_band_category} empty={empty} />
</Card> </Card>
<Card title={t('stats.byMode')} accent="var(--chart-2)"> <Card title={t('stats.byMode')} accent="var(--chart-2)">
<HBars data={stats.by_mode} max={8} empty={empty} colorful /> <HBars data={stats.by_mode} max={8} empty={empty} colorful />
</Card> </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} /> <ActivityCard stats={stats} t={t} empty={empty} />
+6 -5
View File
@@ -22,7 +22,7 @@ const en: Dict = {
'upd.install': 'Update now', 'upd.download': 'Download', 'upd.later': 'Later', 'upd.install': 'Update now', 'upd.download': 'Download', 'upd.later': 'Later',
'upd.downloading': 'Downloading…', 'upd.installing': 'Installing…', 'upd.downloading': 'Downloading…', 'upd.installing': 'Installing…',
'upd.restartNote': 'OpsLog will restart on the new version.', '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', 'rate.title': 'QSO rate (QSOs/hour) — projected from the last 10 / 60 minutes',
'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)', 'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools', 'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
@@ -131,7 +131,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.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.backfillRun': 'Fill missing counties',
'uscty.backfillDone': '{s} US QSOs scanned · {c} counties, {g} grids filled.', '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.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.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplifier', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
'sec.relayauto': 'Relay auto-control', 'sec.relayauto': 'Relay auto-control',
@@ -283,7 +283,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', '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.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.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',
'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?', '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', 'rst.clickToFill': 'Click to set RST tx from the signal',
'qrz.openTitle': 'Open {call} on QRZ.com', 'qrz.openTitle': 'Open {call} on QRZ.com',
@@ -369,6 +369,7 @@ const fr: Dict = {
'btn.logQso': 'Enregistrer', 'btn.clear': 'Effacer', 'btn.spot': 'Spot', 'btn.saving': '…', '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.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.', '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.", 'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
'stats.tab': 'Statistiques', 'stats.title': 'Statistiques du journal', 'stats.loading': 'Analyse du journal…', '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', 'stats.noData': 'Aucune donnée', 'stats.charts': 'Graphiques', 'stats.table': 'Tableau', 'stats.refresh': 'Rafraîchir',
@@ -444,7 +445,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.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.backfillRun': 'Remplir les comtés manquants',
'uscty.backfillDone': '{s} QSO US analysés · {c} comtés, {g} locators remplis.', '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.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.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplificateur', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
'sec.relayauto': 'Relais automatiques', 'sec.relayauto': 'Relais automatiques',
@@ -580,7 +581,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', '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.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.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',
'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 ?', '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', 'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal',
'qrz.openTitle': 'Ouvrir {call} sur QRZ.com', '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). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.20.5'; export const APP_VERSION = '0.20.6';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+6
View File
@@ -721,6 +721,10 @@ export function RunBackupNow():Promise<string>;
export function SPESetOperate(arg1:boolean):Promise<void>; 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 SaveADIFFile():Promise<string>;
export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>; export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
@@ -845,6 +849,8 @@ export function TestQRZUpload():Promise<string>;
export function TestRotator(arg1:main.RotatorSettings):Promise<void>; 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 TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
export function ULSStatus():Promise<main.ULSStatusResult>; export function ULSStatus():Promise<main.ULSStatusResult>;
+12
View File
@@ -1398,6 +1398,14 @@ export function SPESetOperate(arg1) {
return window['go']['main']['App']['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() { export function SaveADIFFile() {
return window['go']['main']['App']['SaveADIFFile'](); return window['go']['main']['App']['SaveADIFFile']();
} }
@@ -1646,6 +1654,10 @@ export function TestRotator(arg1) {
return window['go']['main']['App']['TestRotator'](arg1); return window['go']['main']['App']['TestRotator'](arg1);
} }
export function TestStationDevice(arg1) {
return window['go']['main']['App']['TestStationDevice'](arg1);
}
export function TestUltrabeam(arg1) { export function TestUltrabeam(arg1) {
return window['go']['main']['App']['TestUltrabeam'](arg1); return window['go']['main']['App']['TestUltrabeam'](arg1);
} }
+16
View File
@@ -2746,6 +2746,22 @@ export namespace main {
this.my_pota_ref = source["my_pota_ref"]; 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 { export class ULSStatusResult {
count: number; count: number;
updated_at: string; updated_at: string;
+19 -7
View File
@@ -198,14 +198,26 @@ func migrate(conn *sql.DB, translate func(string) string) error {
} }
sort.Strings(names) sort.Strings(names)
for _, name := range names { // Fetch every applied migration name in ONE query rather than one round-trip
var dummy string // per migration. On a high-latency remote MySQL those 20+ SELECTs dominated the
err := conn.QueryRow(`SELECT name FROM schema_migrations WHERE name = ?`, name).Scan(&dummy) // connect time (each RTT × migration count) — the whole logbook connect could
if err == nil { // take tens of seconds. One SELECT + an in-memory set is effectively instant.
continue // already applied applied := map[string]bool{}
if rows, err := conn.Query(`SELECT name FROM schema_migrations`); err == nil {
for rows.Next() {
var n string
if rows.Scan(&n) == nil {
applied[n] = true
}
} }
if err != sql.ErrNoRows { rows.Close()
return fmt.Errorf("check migration %s: %w", name, err) } else {
return fmt.Errorf("read applied migrations: %w", err)
}
for _, name := range names {
if applied[name] {
continue // already applied
} }
content, err := migrationsFS.ReadFile("migrations/" + name) content, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil { if err != nil {
+20 -5
View File
@@ -137,10 +137,6 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
if !validDBIdent(name) { if !validDBIdent(name) {
return nil, fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name) return nil, fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
} }
// Ensure the database exists (connect server-level first).
if err := PingMySQL(c); err != nil {
return nil, err
}
conn, err := sql.Open("mysql", c.dsn()) conn, err := sql.Open("mysql", c.dsn())
if err != nil { if err != nil {
return nil, fmt.Errorf("open mysql: %w", err) return nil, fmt.Errorf("open mysql: %w", err)
@@ -157,9 +153,28 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
// (surfacing as "Unknown database"). Idle connections are still recycled // (surfacing as "Unknown database"). Idle connections are still recycled
// after 90s, and the driver retries stale pooled connections. // after 90s, and the driver retries stale pooled connections.
conn.SetConnMaxLifetime(0) conn.SetConnMaxLifetime(0)
// Connect DIRECTLY to the database first. Only if that fails — the DB doesn't
// exist yet (first-time setup) or the server is unreachable — do we pay for the
// server-level connect + CREATE DATABASE (two more handshakes). On a normal
// startup against an existing DB this halves the connection round-trips, which
// matters a lot on a high-latency remote MySQL (each handshake is several RTTs).
if err := conn.Ping(); err != nil { if err := conn.Ping(); err != nil {
_ = conn.Close() _ = conn.Close()
return nil, fmt.Errorf("connect to %s: %w", name, err) if perr := PingMySQL(c); perr != nil {
return nil, perr // creates the DB (or returns a clear "cannot create" error)
}
conn, err = sql.Open("mysql", c.dsn())
if err != nil {
return nil, fmt.Errorf("open mysql: %w", err)
}
conn.SetMaxOpenConns(50)
conn.SetMaxIdleConns(10)
conn.SetConnMaxIdleTime(90 * time.Second)
conn.SetConnMaxLifetime(0)
if err := conn.Ping(); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("connect to %s: %w", name, err)
}
} }
// Set the dialect before migrating so the runner takes the MySQL path // Set the dialect before migrating so the runner takes the MySQL path
// (per-statement, idempotent) rather than the SQLite transaction path. // (per-statement, idempotent) rather than the SQLite transaction path.
+2 -1
View File
@@ -20,7 +20,8 @@ func NewDenkovi(serial string, count int) Device {
return denkoviStub{count: count} 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) { func (denkoviStub) Status(context.Context) ([]bool, error) {
return nil, fmt.Errorf("Denkovi USB relay board is only supported on Windows") 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() 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) { func (d *denkovi) Status(ctx context.Context) ([]bool, error) {
d.mu.Lock() d.mu.Lock()
defer d.mu.Unlock() defer d.mu.Unlock()
+9 -2
View File
@@ -27,6 +27,11 @@ type Device interface {
Count() int // number of user-controllable relays Count() int // number of user-controllable relays
Status(ctx context.Context) ([]bool, error) // state of each relay (index 0 = relay 1) 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 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} } 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). // NewWebswitch builds a WebSwitch 1216H client (5 relays).
func NewWebswitch(host string) Device { return &webswitch{host: host, count: 5} } 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 { func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
if relay < 1 || relay > w.count { 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} 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 { func (k *kmtronic) Set(ctx context.Context, relay int, on bool) error {
if relay < 1 || relay > k.count { 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 } 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 { func (s *serialRelay) ensureOpen() error {
if s.port != nil { if s.port != nil {
return nil return nil
+155 -15
View File
@@ -25,12 +25,22 @@ import (
"time" "time"
"go.bug.st/serial" "go.bug.st/serial"
"hamlog/internal/applog"
) )
const ( const (
cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE (confirmed on hw)
cmdStatus byte = 0x90 // request the status string 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 syncHost = 0x55
syncAmp = 0xAA syncAmp = 0xAA
@@ -77,6 +87,7 @@ type Client struct {
statusMu sync.RWMutex statusMu sync.RWMutex
status Status status Status
lastRaw string // last raw status payload logged (log only on change)
stop chan struct{} stop chan struct{}
running bool running bool
@@ -135,6 +146,57 @@ func (c *Client) Operate(on bool) error {
// ToggleOperate flips STANDBY/OPERATE unconditionally. // ToggleOperate flips STANDBY/OPERATE unconditionally.
func (c *Client) ToggleOperate() error { return c.sendCmd(cmdOperate) } 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() { func (c *Client) pollLoop() {
t := time.NewTicker(pollEvery) t := time.NewTicker(pollEvery)
defer t.Stop() defer t.Stop()
@@ -147,6 +209,11 @@ func (c *Client) pollLoop() {
c.setErr(fmt.Errorf("connect: %w", err)) c.setErr(fmt.Errorf("connect: %w", err))
continue 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 { if err := c.sendCmd(cmdStatus); err != nil {
c.mu.Lock() c.mu.Lock()
c.dropLocked() 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 // sendCmd frames one keystroke code and writes it. Single-byte payload → CHK is
// the code itself. // the code itself.
func (c *Client) sendCmd(code byte) error { func (c *Client) sendCmd(code byte) error {
@@ -268,28 +361,75 @@ func (c *Client) decodeCSV(payload string) {
c.statusMu.Lock() c.statusMu.Lock()
defer c.statusMu.Unlock() 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.Connected = true
c.status.LastError = "" c.status.LastError = ""
c.status.Model = get(0) // The real frame carries a leading empty field (it starts with a comma), so the
c.status.Operate = get(1) == "O" // 19 documented fields live at indices 1..19, not 0..18. Verified against a live
c.status.TX = get(2) == "T" // 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.Input = get(4) c.status.Model = get(1)
c.status.Band = get(5) c.status.Operate = get(2) == "O" // "O" = OPERATE, "S" = STANDBY
c.status.PowerLevel = get(8) c.status.TX = get(3) == "T" // "T" = transmit, "R" = receive
c.status.OutputW = pi(get(9)) c.status.Input = get(5)
c.status.SWRATU = pf(get(10)) c.status.Band = bandName(get(6))
c.status.SWRAnt = pf(get(11)) c.status.PowerLevel = get(9)
c.status.VoltPA = pf(get(12)) c.status.OutputW = pi(get(10))
c.status.CurrPA = pf(get(13)) c.status.SWRATU = pf(get(11))
c.status.TempC = pi(get(14)) c.status.SWRAnt = pf(get(12))
if w := get(17); w != "" && w != "N" { 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 c.status.Warnings = w
} else { } else {
c.status.Warnings = "" c.status.Warnings = ""
} }
if a := get(18); a != "" && a != "N" { if a := get(19); a != "" && a != "N" {
c.status.Alarms = a c.status.Alarms = a
} else { } else {
c.status.Alarms = "" 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
}
}
+14
View File
@@ -6,9 +6,21 @@ import (
"strings" "strings"
"time" "time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"hamlog/internal/applog" "hamlog/internal/applog"
) )
// emitLiveStatusChanged nudges the frontend to re-read GetLiveStations right when
// the shared table actually changes (a row appeared or was removed) — so the
// "stations on air" widget updates the instant a QSO is published, matching the
// bottom ON-AIR badge instead of waiting for its poll.
func (a *App) emitLiveStatusChanged() {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "livestatus:updated", nil)
}
}
// Live operator status — for multi-operator events on a SHARED MySQL logbook // Live operator status — for multi-operator events on a SHARED MySQL logbook
// (e.g. a special-event call like TM74FR with several ops on different bands). // (e.g. a special-event call like TM74FR with several ops on different bands).
// Each OpsLog instance heartbeats its current activity (operator call + station // Each OpsLog instance heartbeats its current activity (operator call + station
@@ -231,6 +243,7 @@ func (a *App) publishLiveStatus() {
if _, err := a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op); err != nil { if _, err := a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op); err != nil {
applog.Printf("livestatus: offline DELETE failed: %v", err) applog.Printf("livestatus: offline DELETE failed: %v", err)
} }
a.emitLiveStatusChanged()
return return
} }
lastQSOArg := lastQSO.UTC() lastQSOArg := lastQSO.UTC()
@@ -246,6 +259,7 @@ func (a *App) publishLiveStatus() {
return return
} }
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s ON AIR", op, station, freqHz, band, mode) applog.Printf("livestatus: published op=%s station=%s %dHz %s %s ON AIR", op, station, freqHz, band, mode)
a.emitLiveStatusChanged()
} }
// LiveStation is one operator's live status for the multi-op "who's on air" widget. // LiveStation is one operator's live status for the multi-op "who's on air" widget.
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.20.5" appVersion = "0.20.6"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.