Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24a5a0480d | ||
|
|
828f99b8ac | ||
|
|
9e5868b839 | ||
|
|
40d0ca57c3 | ||
|
|
aa537f9eea | ||
|
|
ea1bd2a66d | ||
|
|
1f54656256 | ||
|
|
54dd109288 | ||
|
|
2c1d7235f0 | ||
|
|
adf9844d1b | ||
|
|
7afe40a48e | ||
|
|
311ea8341f | ||
|
|
a2958d458e | ||
|
|
d9b7e48e83 | ||
|
|
3e9ebdb89a | ||
|
|
be66ac1e19 | ||
|
|
6aab4a6989 | ||
|
|
9c62bf0152 | ||
|
|
1630c8fdf4 | ||
|
|
408791ddf5 | ||
|
|
3de47a8825 | ||
|
|
880ecdbbb5 | ||
|
|
615df0dc10 | ||
|
|
2194279602 | ||
|
|
51c4bda71a | ||
|
|
7d7d175ede | ||
|
|
be1ae76eb3 | ||
|
|
50157a25d3 | ||
|
|
fe1a77a54d | ||
|
|
b818a2d947 | ||
|
|
5c4ae0cfd7 | ||
|
|
69635a15bc | ||
|
|
78220e700f | ||
|
|
42a6b9c76a | ||
|
|
56affa4bed | ||
|
|
aa5af4fc75 | ||
|
|
46772e54fe | ||
|
|
a0cea352ff | ||
|
|
190b86eb1c | ||
|
|
cfc3d00ea1 | ||
|
|
9033e8518c | ||
|
|
bfbd9fa61a | ||
|
|
f0c4f22942 | ||
|
|
19c91f32a0 | ||
|
|
3ec23bc613 | ||
|
|
2fbb922bd2 | ||
|
|
f0afdcc498 | ||
|
|
22352c5748 | ||
|
|
6356d60a66 | ||
|
|
cafade0dbb | ||
|
|
2e39615554 | ||
|
|
666b933114 | ||
|
|
def59da748 | ||
|
|
fe69bc308c | ||
|
|
c07a17dc47 | ||
|
|
3cef885934 | ||
|
|
b8db653981 | ||
|
|
9729ef62ba | ||
|
|
cc6411a618 | ||
|
|
991831bdec | ||
|
|
1b2da95ad4 | ||
|
|
10d86db50a | ||
|
|
8538f48259 | ||
|
|
0fa91c3d5f | ||
|
|
c86d331bd9 | ||
|
|
77a752efe3 | ||
|
|
781fbfaa30 | ||
|
|
61c11c0fe3 | ||
|
|
64b746f007 | ||
|
|
9cc72c7575 | ||
|
|
9e4f43f648 |
+41
-7
@@ -1,5 +1,9 @@
|
|||||||
# OpsLog
|
# OpsLog
|
||||||
|
|
||||||
|
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
|
||||||
|
<img src="https://img.shields.io/badge/Discord-Rejoindre%20le%20serveur-5865F2?logo=discord&logoColor=white" alt="Rejoindre notre Discord" />
|
||||||
|
</a>
|
||||||
|
|
||||||
Un logiciel de log radioamateur moderne et rapide pour Windows — saisie façon
|
Un logiciel de log radioamateur moderne et rapide pour Windows — saisie façon
|
||||||
Log4OM, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif,
|
Log4OM, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif,
|
||||||
**Icom CI-V** natif (USB **et** à distance par internet, en remplacement de
|
**Icom CI-V** natif (USB **et** à distance par internet, en remplacement de
|
||||||
@@ -112,7 +116,10 @@ Affiché uniquement quand le backend CAT est une FlexRadio :
|
|||||||
parole (NOR/DX/DX+), VOX (+ niveau + délai), moniteur (+ niveau), gain micro.
|
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 +166,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 F1–F6 et les
|
- **Keyer vocal numérique** (DVK) : enregistrer les messages vocaux F1–F6 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 +178,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 +223,15 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
|
|||||||
un début/fin de fenêtre, signale les doublons et tient un tableau de score en
|
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 +278,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).
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# OpsLog
|
# OpsLog
|
||||||
|
|
||||||
|
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
|
||||||
|
<img src="https://img.shields.io/badge/Discord-Join%20the%20server-5865F2?logo=discord&logoColor=white" alt="Join our Discord" />
|
||||||
|
</a>
|
||||||
|
|
||||||
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
|
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
|
||||||
for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and**
|
for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and**
|
||||||
remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert Electronics),
|
remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert Electronics),
|
||||||
@@ -100,7 +104,10 @@ Shown only when the CAT backend is a FlexRadio:
|
|||||||
VOX (+ level + delay), monitor (+ level), mic gain.
|
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 +148,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 F1–F6 voice messages and transmit them.
|
- **Digital Voice Keyer** (DVK): record F1–F6 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
|
||||||
|
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.
|
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 +198,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 +248,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).
|
||||||
|
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
|
||||||
"hamlog/internal/audio"
|
|
||||||
"hamlog/internal/cwdecode"
|
|
||||||
|
|
||||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CW decoder: taps the RX audio device (the same "From radio" capture the DVK
|
|
||||||
// and QSO recorder use) and streams decoded Morse text to the UI. It is started
|
|
||||||
// only by the frontend, and only while the entry mode is CW.
|
|
||||||
//
|
|
||||||
// Pitch targeting: the single-channel decoder is far more reliable when it locks
|
|
||||||
// to a KNOWN pitch (a narrow filter at the signal frequency, like a skimmer)
|
|
||||||
// instead of auto-searching for the loudest tone. So we follow the radio's CW
|
|
||||||
// pitch (FlexRadio cw_pitch) when available — or a manual override — and fall
|
|
||||||
// back to auto-search otherwise.
|
|
||||||
|
|
||||||
// cwTargetPitch returns the pitch (Hz) the decoder should lock to: the manual
|
|
||||||
// override if set, else the FlexRadio's CW pitch when it's in CW, else 0 (auto).
|
|
||||||
func (a *App) cwTargetPitch() int {
|
|
||||||
if a.cwPitchHz > 0 {
|
|
||||||
return a.cwPitchHz
|
|
||||||
}
|
|
||||||
if a.cat != nil {
|
|
||||||
if st, ok := a.cat.FlexState(); ok && st.Available {
|
|
||||||
// Only trust the radio's pitch when it's actually in CW.
|
|
||||||
if st.Mode == "CW" || st.Mode == "CWL" || st.Mode == "CWU" {
|
|
||||||
if st.CWPitch > 0 {
|
|
||||||
return st.CWPitch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartCWDecoder begins decoding CW from the configured RX audio device. The
|
|
||||||
// frontend calls this when the decoder toggle is on AND the mode is CW. Safe to
|
|
||||||
// call repeatedly; a second call is a no-op while already running.
|
|
||||||
func (a *App) StartCWDecoder() error {
|
|
||||||
a.cwMu.Lock()
|
|
||||||
defer a.cwMu.Unlock()
|
|
||||||
if a.cwStop != nil {
|
|
||||||
return nil // already running
|
|
||||||
}
|
|
||||||
dev := ""
|
|
||||||
if a.settings != nil {
|
|
||||||
dev, _ = a.settings.Get(a.ctx, keyAudioFromRadio)
|
|
||||||
}
|
|
||||||
if dev == "" {
|
|
||||||
return fmt.Errorf("no RX audio device configured (set \"From radio\" in Audio settings)")
|
|
||||||
}
|
|
||||||
|
|
||||||
dec := cwdecode.New(audio.SampleRate,
|
|
||||||
func(text string) {
|
|
||||||
if a.ctx != nil {
|
|
||||||
wruntime.EventsEmit(a.ctx, "cw:text", text)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
func(st cwdecode.Status) {
|
|
||||||
if a.ctx != nil {
|
|
||||||
wruntime.EventsEmit(a.ctx, "cw:status", st)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
dec.SetTarget(a.cwTargetPitch())
|
|
||||||
a.cwDecoder = dec
|
|
||||||
|
|
||||||
stop := make(chan struct{})
|
|
||||||
a.cwStop = stop
|
|
||||||
go func() {
|
|
||||||
if err := audio.StreamCapture(dev, stop, dec.Process); err != nil {
|
|
||||||
applog.Printf("cw: capture failed: %v", err)
|
|
||||||
if a.ctx != nil {
|
|
||||||
wruntime.EventsEmit(a.ctx, "cw:error", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
a.cwMu.Lock()
|
|
||||||
if a.cwStop == stop {
|
|
||||||
a.cwStop = nil
|
|
||||||
a.cwDecoder = nil
|
|
||||||
}
|
|
||||||
a.cwMu.Unlock()
|
|
||||||
}()
|
|
||||||
// Follow the radio's CW pitch live (every second) while this run is active.
|
|
||||||
go a.cwFollowPitch(stop, dec)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// cwFollowPitch keeps the decoder locked to the current target pitch until stop.
|
|
||||||
func (a *App) cwFollowPitch(stop <-chan struct{}, dec *cwdecode.Decoder) {
|
|
||||||
t := time.NewTicker(time.Second)
|
|
||||||
defer t.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stop:
|
|
||||||
return
|
|
||||||
case <-t.C:
|
|
||||||
dec.SetTarget(a.cwTargetPitch())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// StopCWDecoder halts the CW decoder if running.
|
|
||||||
func (a *App) StopCWDecoder() {
|
|
||||||
a.cwMu.Lock()
|
|
||||||
stop := a.cwStop
|
|
||||||
a.cwStop = nil
|
|
||||||
a.cwDecoder = nil
|
|
||||||
a.cwMu.Unlock()
|
|
||||||
if stop != nil {
|
|
||||||
close(stop)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CWDecoderRunning reports whether the decoder is currently capturing.
|
|
||||||
func (a *App) CWDecoderRunning() bool {
|
|
||||||
a.cwMu.Lock()
|
|
||||||
defer a.cwMu.Unlock()
|
|
||||||
return a.cwStop != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetCWDecoderPitch sets a manual decode pitch (Hz); 0 returns to auto (follow
|
|
||||||
// the Flex CW pitch, or search). Applies live to a running decoder.
|
|
||||||
func (a *App) SetCWDecoderPitch(hz int) {
|
|
||||||
if hz < 0 {
|
|
||||||
hz = 0
|
|
||||||
}
|
|
||||||
a.cwMu.Lock()
|
|
||||||
a.cwPitchHz = hz
|
|
||||||
dec := a.cwDecoder
|
|
||||||
a.cwMu.Unlock()
|
|
||||||
if dec != nil {
|
|
||||||
dec.SetTarget(a.cwTargetPitch())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCWDecoderPitch returns the manual override (0 = auto / follow Flex).
|
|
||||||
func (a *App) GetCWDecoderPitch() int {
|
|
||||||
a.cwMu.Lock()
|
|
||||||
defer a.cwMu.Unlock()
|
|
||||||
return a.cwPitchHz
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed changelog.json
|
||||||
|
var changelogJSON []byte
|
||||||
|
|
||||||
|
// ChangelogEntry is one release's user-facing notes, in English and French. It's
|
||||||
|
// shown once on the first launch after an update (the "What's new" dialog),
|
||||||
|
// derived from the release's commit history.
|
||||||
|
type ChangelogEntry struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
EN []string `json:"en"`
|
||||||
|
FR []string `json:"fr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyChangelogSeen = "changelog.last_seen_version"
|
||||||
|
|
||||||
|
func loadChangelog() []ChangelogEntry {
|
||||||
|
var out []ChangelogEntry
|
||||||
|
if err := json.Unmarshal(changelogJSON, &out); err != nil {
|
||||||
|
applog.Printf("changelog: parse failed: %v", err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChangelog returns every changelog entry up to the running version (newest
|
||||||
|
// first), without touching the "seen" marker — for a "What's new" button that
|
||||||
|
// reopens the notes on demand.
|
||||||
|
func (a *App) GetChangelog() []ChangelogEntry {
|
||||||
|
out := []ChangelogEntry{}
|
||||||
|
for _, e := range loadChangelog() {
|
||||||
|
if strings.TrimSpace(e.Version) == "" || versionLess(appVersion, e.Version) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWhatsNew returns the changelog entries the operator hasn't seen yet — the
|
||||||
|
// notes for every version newer than the last one they ran, up to the current
|
||||||
|
// build — and records the current version as seen so it pops exactly once per
|
||||||
|
// update. Empty when there's nothing new.
|
||||||
|
func (a *App) GetWhatsNew() []ChangelogEntry {
|
||||||
|
if a.settings == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lastSeen, _ := a.settings.GetGlobal(a.ctx, keyChangelogSeen)
|
||||||
|
cur := appVersion
|
||||||
|
a.setSettingGlobal(keyChangelogSeen, cur) // advance the marker now
|
||||||
|
|
||||||
|
out := []ChangelogEntry{}
|
||||||
|
for _, e := range loadChangelog() {
|
||||||
|
if strings.TrimSpace(e.Version) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if versionLess(cur, e.Version) {
|
||||||
|
continue // don't preview notes for a version newer than we run
|
||||||
|
}
|
||||||
|
if lastSeen == "" {
|
||||||
|
// First run of the feature (fresh install, or upgrade from a build that
|
||||||
|
// predates it): show only the CURRENT version's notes, once.
|
||||||
|
if e.Version == cur {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if versionLess(lastSeen, e.Version) {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"version": "0.20.7",
|
||||||
|
"date": "2026-07-21",
|
||||||
|
"en": [
|
||||||
|
"New: ACOM amplifier support (500S / 600S / 700S / 1200S / 2020S) — OPERATE/STANDBY/OFF, live telemetry (power, SWR, PA temperature, band, fan) in Settings and the radio panel, over USB serial or an RS232-to-Ethernet bridge. Power-ON works over serial when the cable wires the DTR/RTS pins. The Amplifier settings now pick Brand then Model.",
|
||||||
|
"Multi-operator live sync fixed: the shared-MySQL connection pool was too small, so after a while the grid, chat and 'stations on air' silently stopped updating. The pool is now sized for real multi-op use (with 5 operators it stays well within a max_connections=300 server).",
|
||||||
|
"Live operator status is now always published on a shared MySQL logbook — the Settings toggle is gone. You still go off-air automatically 5 minutes after your last QSO, and the 'stations on air' panel refreshes noticeably faster (a redundant table check on every read was removed).",
|
||||||
|
"The By-Continent statistics donut now totals the same as your QSO count — QSOs whose continent couldn't be resolved are shown as an 'Unknown' slice instead of being dropped (the Continents count still lists only real continents)."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Nouveau : prise en charge des amplificateurs ACOM (500S / 600S / 700S / 1200S / 2020S) — OPERATE/STANDBY/OFF, télémétrie en direct (puissance, ROS, température PA, bande, ventilateur) dans les Réglages et le panneau radio, en USB série ou via un pont RS232-vers-Ethernet. La mise en marche fonctionne en série si le câble relie les broches DTR/RTS. Les réglages Amplificateur se font maintenant par Marque puis Modèle.",
|
||||||
|
"Synchronisation multi-opérateurs corrigée : le pool de connexions MySQL partagé était trop petit, et au bout d'un moment la grille, le chat et « stations on air » cessaient silencieusement de se mettre à jour. Le pool est maintenant dimensionné pour un vrai multi-op (à 5 opérateurs, on reste largement sous un serveur à max_connections=300).",
|
||||||
|
"Le statut opérateur en direct est maintenant toujours publié sur un logbook MySQL partagé — l'option des Réglages a disparu. Vous passez toujours hors antenne automatiquement 5 minutes après votre dernier QSO, et le panneau « stations on air » se rafraîchit nettement plus vite (une vérification de table redondante à chaque lecture a été supprimée).",
|
||||||
|
"La donut « Par continent » des statistiques totalise maintenant le même nombre que le total de QSO — les QSO dont le continent n'a pas pu être résolu apparaissent dans un segment « Unknown » au lieu d'être ignorés (le compteur Continents ne liste toujours que les vrais continents)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.20.6",
|
||||||
|
"date": "2026-07-21",
|
||||||
|
"en": [
|
||||||
|
"Amplifier controls now live right in the FlexRadio panel: OPERATE/STANDBY, ON/OFF, Low/Mid/High power level, an output-power bar and live band / SWR / temperature — for both PowerGenius XL and SPE Expert. The card is hidden when no amplifier is selected.",
|
||||||
|
"Faster startup: the radio (CAT) connects immediately instead of waiting behind the shared-MySQL connection, and MySQL itself connects much faster.",
|
||||||
|
"No more lost QSOs: an FT8/MSHV QSO that can't reach a laggy shared database is parked and re-logged automatically once it recovers; simultaneous QSOs from a multi-stream are now queued instead of failing with 'too many requests'.",
|
||||||
|
"The 'stations on air' widget now updates within a second of a QSO (it used to lag up to ~45s behind the ON-AIR badge).",
|
||||||
|
"Relay boards (Denkovi / USB-serial) stay connected reliably now, with a Test-connection button and detect feedback in the Station Control setup.",
|
||||||
|
"<LOGQSO> in a CW macro logs the contact at that exact point in the macro, and pressing ESC before it runs cancels the log.",
|
||||||
|
"Auto-call now waits exactly the gap you set between calls (it was waiting far longer).",
|
||||||
|
"Statistics: the redundant per-band chart was merged into the CW / phone / data split.",
|
||||||
|
"Cluster self-spot pop-ups now show the band.",
|
||||||
|
"The bottom status bar is no longer hidden behind a scrollbar in a small window.",
|
||||||
|
"Update check now also runs when you open Help - About, and every 5 minutes."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Les commandes d'amplificateur sont maintenant directement dans le panneau FlexRadio : OPERATE/STANDBY, Marche/Arrêt, niveau Low/Mid/High, une barre de puissance de sortie et l'état en direct bande / ROS / température — pour PowerGenius XL comme pour SPE Expert. La carte est masquée si aucun ampli n'est sélectionné.",
|
||||||
|
"Démarrage plus rapide : la radio (CAT) se connecte immédiatement au lieu d'attendre derrière la connexion MySQL partagée, et MySQL lui-même se connecte bien plus vite.",
|
||||||
|
"Plus de QSO perdus : un QSO FT8/MSHV qui n'atteint pas une base partagée lente est mis de côté et ré-enregistré automatiquement dès qu'elle répond ; les QSO simultanés d'un multi-stream sont maintenant mis en file au lieu d'échouer avec « too many requests ».",
|
||||||
|
"Le widget « stations on air » se met à jour en une seconde après un QSO (il pouvait accuser jusqu'à ~45s de retard sur le badge ON AIR).",
|
||||||
|
"Les cartes relais (Denkovi / USB-série) restent connectées de façon fiable, avec un bouton « Tester la connexion » et un retour de détection dans la configuration Station Control.",
|
||||||
|
"<LOGQSO> dans une macro CW enregistre le contact à cet endroit précis de la macro, et appuyer sur ÉCHAP avant son exécution annule l'enregistrement.",
|
||||||
|
"L'appel automatique respecte maintenant exactement l'intervalle réglé entre les appels (il attendait bien plus longtemps).",
|
||||||
|
"Statistiques : le graphique par bande redondant a été fusionné avec la répartition CW / phone / data.",
|
||||||
|
"Les popups d'auto-spot du cluster affichent maintenant la bande.",
|
||||||
|
"La barre de statut du bas n'est plus masquée par une barre de défilement en fenêtre réduite.",
|
||||||
|
"La vérification de mise à jour se déclenche aussi à l'ouverture d'Aide - À propos, et toutes les 5 minutes."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.20.5",
|
||||||
|
"date": "2026-07-20",
|
||||||
|
"en": [
|
||||||
|
"New FlexRadio CWX CW keyer: key CW straight over the SmartSDR connection — no WinKeyer or SmartCAT needed. Type-ahead and mid-send backspace supported.",
|
||||||
|
"ESC now stops CW on whichever keyer is active (WinKeyer / Icom / Flex), and typing a callsign aborts a running macro.",
|
||||||
|
"New Amplifier settings section (was 'Power Genius'): control SPE Expert amps (1.3K / 1.5K / 2K) over USB serial or an RS232-to-Ethernet bridge — OPERATE/STANDBY and live status. PowerGenius XL still supported.",
|
||||||
|
"Denkovi USB relay boards supported (4/8 relays, FT245), plus generic CH340/LCUS USB-serial relay boards.",
|
||||||
|
"Rename your database from Settings without losing any configuration.",
|
||||||
|
"Awards: DDFM now finds the French department from the address postal code; refs are stored on each QSO for faster columns; 'Publish to catalog' shares an edited award with your team on the next release; award columns are remembered per profile.",
|
||||||
|
"Statistics: per-band mode split (CW / phone / data), and an activity chart with a Day / Week / Month / Year selector (rolling, real dates).",
|
||||||
|
"'Stations on air' widget updates within seconds and fits more stations.",
|
||||||
|
"The audio CW decoder was removed.",
|
||||||
|
"Fixes: Antenna Genius buttons ignored clicks while a macro was sending; File → Exit; and more.",
|
||||||
|
"This 'What's new' summary now appears on the first launch after each update."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Nouveau keyer CW FlexRadio CWX : manipulation CW directement via la connexion SmartSDR — plus besoin de WinKeyer ni de SmartCAT. Type-ahead et retour arrière en cours d'envoi supportés.",
|
||||||
|
"ESC arrête maintenant le CW sur le moteur actif (WinKeyer / Icom / Flex), et taper un indicatif interrompt une macro en cours.",
|
||||||
|
"Nouvelle section Amplificateur (ex « Power Genius ») : pilotage des SPE Expert (1.3K / 1.5K / 2K) en USB série ou via un pont RS232→Ethernet — OPERATE/STANDBY et état en direct. PowerGenius XL toujours supporté.",
|
||||||
|
"Cartes relais USB Denkovi (4/8 relais, FT245), plus cartes USB-série génériques CH340/LCUS.",
|
||||||
|
"Renommer sa base depuis les réglages sans perdre la configuration.",
|
||||||
|
"Awards : DDFM trouve le département français depuis le code postal de l'adresse ; les réfs sont stockées sur chaque QSO (colonnes plus rapides) ; « Publier au catalogue » partage un award édité avec l'équipe à la prochaine mise à jour ; les colonnes d'award sont mémorisées par profil.",
|
||||||
|
"Statistiques : répartition des modes par bande (CW / phone / data), et un graphe d'activité avec sélecteur Jour / Semaine / Mois / Année (glissant, dates réelles).",
|
||||||
|
"Le widget « Stations on air » se met à jour en quelques secondes et affiche plus de stations.",
|
||||||
|
"Le décodeur CW audio a été retiré.",
|
||||||
|
"Corrections : les boutons Antenna Genius ignoraient les clics pendant l'envoi d'une macro ; Fichier → Quitter ; et d'autres.",
|
||||||
|
"Ce résumé « Nouveautés » s'affiche désormais au premier lancement après chaque mise à jour."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
+278
-212
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Eraser, Hash, Loader2, Lock,
|
||||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
|
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
||||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
||||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||||
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations,
|
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, GetWhatsNew, GetChangelog,
|
||||||
WorkedBefore,
|
WorkedBefore,
|
||||||
SetCompactMode,
|
SetCompactMode,
|
||||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
|
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
|
||||||
@@ -36,14 +36,13 @@ import {
|
|||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
||||||
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
|
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
|
||||||
IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
|
IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
|
||||||
|
FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
|
||||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||||
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
|
||||||
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
||||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
||||||
GetAwardDefs,
|
GetAwardDefs,
|
||||||
GetUIPref,
|
GetUIPref, GetActiveProfile, QuitApp,
|
||||||
ReportLiveActivity, GetLiveStatusEnabled, LiveLastQSOAgeSec,
|
ReportLiveActivity, LiveLastQSOAgeSec,
|
||||||
AwardRefsForQSOs,
|
|
||||||
} from '../wailsjs/go/main/App';
|
} from '../wailsjs/go/main/App';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||||
@@ -87,6 +86,7 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
|||||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||||
import { RotorCompass } from '@/components/RotorCompass';
|
import { RotorCompass } from '@/components/RotorCompass';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { setGridPrefsProfile } from '@/lib/gridPrefs';
|
||||||
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
|
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -135,6 +135,22 @@ const emptyDetails: DetailsState = {
|
|||||||
award_refs: '',
|
award_refs: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// parseAwardRefs turns the QSO row's materialised award_refs JSON string
|
||||||
|
// ({"DDFM":"74","WAJA":"12"}) into the code→ref object the grid columns read.
|
||||||
|
// Tolerant of empty / malformed values (returns {}), and passes an already-parsed
|
||||||
|
// object straight through.
|
||||||
|
function parseAwardRefs(s: any): Record<string, string> {
|
||||||
|
if (!s) return {};
|
||||||
|
if (typeof s === 'object') return s as Record<string, string>;
|
||||||
|
if (typeof s !== 'string') return {};
|
||||||
|
try {
|
||||||
|
const o = JSON.parse(s);
|
||||||
|
return o && typeof o === 'object' ? o : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function fmtDateUTC(s: any): string {
|
function fmtDateUTC(s: any): string {
|
||||||
if (!s) return '';
|
if (!s) return '';
|
||||||
const d = new Date(s);
|
const d = new Date(s);
|
||||||
@@ -267,7 +283,7 @@ function computePrefix(call: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
// === Lists from settings (fallback for first paint) ===
|
// === Lists from settings (fallback for first paint) ===
|
||||||
const [bands, setBands] = useState<string[]>(DEFAULT_BANDS);
|
const [bands, setBands] = useState<string[]>(DEFAULT_BANDS);
|
||||||
const [modes, setModes] = useState<string[]>(DEFAULT_MODES);
|
const [modes, setModes] = useState<string[]>(DEFAULT_MODES);
|
||||||
@@ -425,13 +441,25 @@ export default function App() {
|
|||||||
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
|
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
|
||||||
const [liveStations, setLiveStations] = useState<LiveStation[]>([]);
|
const [liveStations, setLiveStations] = useState<LiveStation[]>([]);
|
||||||
const [showLiveStations, setShowLiveStations] = useState(() => localStorage.getItem('opslog.showLiveStations') === '1');
|
const [showLiveStations, setShowLiveStations] = useState(() => localStorage.getItem('opslog.showLiveStations') === '1');
|
||||||
|
// Depend on the BOOLEAN (is-mysql), not the dbConn object: dbConn gets a fresh
|
||||||
|
// reference on several events, and depending on the object reset the 15s timer
|
||||||
|
// on every such refresh so it could ~never fire (the widget looked stuck for
|
||||||
|
// minutes). Also refresh right after we log a QSO so our own row shows without
|
||||||
|
// waiting for the next tick, like the ON AIR badge.
|
||||||
|
const liveStationsOn = dbConn?.backend === 'mysql';
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dbConn?.backend !== 'mysql') { setLiveStations([]); return; }
|
if (!liveStationsOn) { setLiveStations([]); return; }
|
||||||
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, 15 * 1000);
|
const id = window.setInterval(load, 5 * 1000);
|
||||||
return () => window.clearInterval(id);
|
// Refresh the instant the backend actually publishes a row change (a QSO put
|
||||||
}, [dbConn]);
|
// someone on air, or a 5-min silence took them off) — so this widget tracks the
|
||||||
|
// bottom ON-AIR badge instead of lagging behind its poll. Keep the qso:logged
|
||||||
|
// fallback too (a small delay lets the async publish land) for older paths.
|
||||||
|
const offStatus = EventsOn('livestatus:updated', load);
|
||||||
|
const offLogged = EventsOn('qso:logged', () => { window.setTimeout(load, 1500); });
|
||||||
|
return () => { window.clearInterval(id); offStatus?.(); offLogged?.(); };
|
||||||
|
}, [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
|
||||||
// in Preferences > Hardware > CAT interface.
|
// in Preferences > Hardware > CAT interface.
|
||||||
@@ -759,7 +787,7 @@ export default function App() {
|
|||||||
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
|
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
|
||||||
// auto-call and <LOGQSO> are shared; only the transport differs.
|
// auto-call and <LOGQSO> are shared; only the transport differs.
|
||||||
const [wkEngine, setWkEngine] = useState<string>('winkeyer');
|
const [wkEngine, setWkEngine] = useState<string>('winkeyer');
|
||||||
const cwSource: 'winkeyer' | 'icom' = wkEngine === 'icom' ? 'icom' : 'winkeyer';
|
const cwSource: 'winkeyer' | 'icom' | 'flex' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : 'winkeyer';
|
||||||
const cwSourceRef = useRef(cwSource);
|
const cwSourceRef = useRef(cwSource);
|
||||||
useEffect(() => { cwSourceRef.current = cwSource; }, [cwSource]);
|
useEffect(() => { cwSourceRef.current = cwSource; }, [cwSource]);
|
||||||
// CW break-in (0=OFF, 1=SEMI, 2=FULL) — must be on for the rig's 0x17 keyer to
|
// CW break-in (0=OFF, 1=SEMI, 2=FULL) — must be on for the rig's 0x17 keyer to
|
||||||
@@ -787,43 +815,20 @@ 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) : wkStatus.connected;
|
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected)
|
||||||
|
: cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected)
|
||||||
|
: wkStatus.connected;
|
||||||
wkActiveRef.current = wkEnabled && connected;
|
wkActiveRef.current = wkEnabled && connected;
|
||||||
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
|
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
|
||||||
useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);
|
useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);
|
||||||
|
|
||||||
// === Digital Voice Keyer (DVK) ===
|
// === Digital Voice Keyer (DVK) ===
|
||||||
// CW decoder: taps RX audio and decodes Morse. Runs only when enabled AND the
|
|
||||||
// mode is CW. The decoded text appears in a strip above the tabs.
|
|
||||||
const [cwEnabled, setCwEnabled] = useState(() => localStorage.getItem('opslog.cwDecoder') === '1');
|
|
||||||
const [cwText, setCwText] = useState('');
|
|
||||||
const [cwStatus, setCwStatus] = useState<{ wpm: number; pitch: number; level: number; active: boolean }>({ wpm: 0, pitch: 0, level: 0, active: false });
|
|
||||||
const cwOn = cwEnabled && mode === 'CW';
|
|
||||||
// Keep the decoded line scrolled to the newest text (left-aligned, no scrollbar).
|
|
||||||
const cwScrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
useEffect(() => { const el = cwScrollRef.current; if (el) el.scrollLeft = el.scrollWidth; }, [cwText]);
|
|
||||||
// Manual pitch override ('' = Auto: follow the radio's CW pitch / search).
|
|
||||||
const [cwPitch, setCwPitch] = useState(() => localStorage.getItem('opslog.cwPitch') || '');
|
|
||||||
useEffect(() => {
|
|
||||||
const hz = parseInt(cwPitch, 10);
|
|
||||||
SetCWDecoderPitch(Number.isFinite(hz) ? hz : 0).catch(() => {});
|
|
||||||
localStorage.setItem('opslog.cwPitch', cwPitch);
|
|
||||||
}, [cwPitch, cwOn]);
|
|
||||||
useEffect(() => {
|
|
||||||
const offT = EventsOn('cw:text', (t: string) => setCwText((s) => (s + t).slice(-200)));
|
|
||||||
const offS = EventsOn('cw:status', (st: any) => setCwStatus(st));
|
|
||||||
const offE = EventsOn('cw:error', (e: string) => { setError(String(e)); setCwEnabled(false); });
|
|
||||||
return () => { offT?.(); offS?.(); offE?.(); };
|
|
||||||
}, []);
|
|
||||||
// Start/stop the backend decoder as the (enabled, mode) combination changes.
|
|
||||||
useEffect(() => {
|
|
||||||
if (cwOn) { StartCWDecoder().catch((e: any) => { setError(String(e?.message ?? e)); setCwEnabled(false); }); }
|
|
||||||
else { StopCWDecoder().catch(() => {}); }
|
|
||||||
}, [cwOn]);
|
|
||||||
function toggleCwDecoder() {
|
|
||||||
setCwEnabled((v) => { const n = !v; localStorage.setItem('opslog.cwDecoder', n ? '1' : '0'); return n; });
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Multi-op chat (shared MySQL logbook) — docked panel like rotor/DVK ===
|
// === Multi-op chat (shared MySQL logbook) — docked panel like rotor/DVK ===
|
||||||
const [chatAvailable, setChatAvailable] = useState(false);
|
const [chatAvailable, setChatAvailable] = useState(false);
|
||||||
@@ -1116,22 +1121,22 @@ export default function App() {
|
|||||||
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
||||||
// "ON AIR" status-bar badge: mirrors the multi-op live status this operator
|
// "ON AIR" status-bar badge: mirrors the multi-op live status this operator
|
||||||
// publishes — online (blinking) when a QSO was logged in the last 5 min, else
|
// publishes — online (blinking) when a QSO was logged in the last 5 min, else
|
||||||
// offline. Only shown when live-status publishing is enabled (Settings→General).
|
// offline. Publishing is always on for a shared MySQL logbook (no user toggle:
|
||||||
const [liveStatusOn, setLiveStatusOn] = useState(false);
|
// 5 min without a QSO removes the row automatically), so the badge simply
|
||||||
|
// follows the backend.
|
||||||
const [onAir, setOnAir] = useState(false);
|
const [onAir, setOnAir] = useState(false);
|
||||||
useEffect(() => { if (!showSettings) GetLiveStatusEnabled().then((v) => setLiveStatusOn(!!v)).catch(() => {}); }, [showSettings]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Read the ON-AIR state straight from the backend (single source of truth:
|
// Read the ON-AIR state straight from the backend (single source of truth:
|
||||||
// liveLastQSOAt, stamped on every log and seeded from the DB at launch). Poll
|
// liveLastQSOAt, stamped on every log and seeded from the DB at launch). Poll
|
||||||
// it + refresh on each logged QSO — no fragile frontend timestamp to drift.
|
// it + refresh on each logged QSO — no fragile frontend timestamp to drift.
|
||||||
const refresh = () => LiveLastQSOAgeSec()
|
const refresh = () => LiveLastQSOAgeSec()
|
||||||
.then((sec: number) => setOnAir(liveStatusOn && typeof sec === 'number' && sec >= 0 && sec < 300))
|
.then((sec: number) => setOnAir(typeof sec === 'number' && sec >= 0 && sec < 300))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
refresh();
|
refresh();
|
||||||
const off = EventsOn('qso:logged', refresh);
|
const off = EventsOn('qso:logged', refresh);
|
||||||
const id = window.setInterval(refresh, 5 * 1000); // responsive without hammering (cheap 400-row scan)
|
const id = window.setInterval(refresh, 5 * 1000); // responsive without hammering (single indexed row)
|
||||||
return () => { off(); window.clearInterval(id); };
|
return () => { off(); window.clearInterval(id); };
|
||||||
}, [liveStatusOn]);
|
}, []);
|
||||||
// QSO-rate meter (10/60 min) in the header — opt-in via Settings→General.
|
// QSO-rate meter (10/60 min) in the header — opt-in via Settings→General.
|
||||||
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
||||||
useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]);
|
useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]);
|
||||||
@@ -1151,12 +1156,31 @@ export default function App() {
|
|||||||
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
|
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
|
||||||
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
||||||
const [showAbout, setShowAbout] = useState(false);
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
|
// "What's new": the changelog for the version(s) since the operator last ran,
|
||||||
|
// shown once on the first launch after an update (EN/FR per the UI language).
|
||||||
|
const [whatsNew, setWhatsNew] = useState<Array<{ version: string; date: string; en: string[]; fr: string[] }> | null>(null);
|
||||||
|
// Which language the What's-new modal shows — defaults to the UI language but
|
||||||
|
// toggleable, so a French operator running the English UI can still read it.
|
||||||
|
const [clLang, setClLang] = useState<'en' | 'fr'>(lang);
|
||||||
|
useEffect(() => { setClLang(lang); }, [lang]);
|
||||||
|
useEffect(() => {
|
||||||
|
GetWhatsNew().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); }).catch(() => {});
|
||||||
|
}, []);
|
||||||
const [showDuplicates, setShowDuplicates] = useState(false);
|
const [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;
|
||||||
@@ -1164,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.
|
||||||
@@ -1234,38 +1258,28 @@ export default function App() {
|
|||||||
// list once, then compute each shown QSO's reference per award and attach it
|
// list once, then compute each shown QSO's reference per award and attach it
|
||||||
// to the rows (the grids render one hideable column per award).
|
// to the rows (the grids render one hideable column per award).
|
||||||
const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]);
|
const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]);
|
||||||
// Bumped whenever award definitions are saved so the grid columns AND the
|
// Bumped when award definitions change (or the backend finishes a bulk
|
||||||
// per-QSO refs re-fetch — the ref/name display choice is computed live, so
|
// award_refs recompute) so the set of award COLUMNS re-reads from GetAwardDefs.
|
||||||
// changing it updates ALL contacts (old included) with no restart.
|
// The per-QSO values themselves ride on the row (award_refs) and refresh with
|
||||||
|
// the grid reload triggered alongside this bump.
|
||||||
const [awardsVersion, setAwardsVersion] = useState(0);
|
const [awardsVersion, setAwardsVersion] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
GetAwardDefs().then((defs: any[]) =>
|
GetAwardDefs().then((defs: any[]) =>
|
||||||
setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))),
|
setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))),
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
}, [awardsVersion]);
|
}, [awardsVersion]);
|
||||||
const [qsoAwardRefs, setQsoAwardRefs] = useState<Record<string, Record<string, string>>>({});
|
// Award references are now MATERIALISED on the QSO row (the award_refs JSON
|
||||||
useEffect(() => {
|
// column, written by the backend on log/edit and bulk-recomputed when awards
|
||||||
const ids = (qsos as any[]).map((q) => q.id).filter(Boolean);
|
// change). The grid reads them straight from the row — no per-page backend
|
||||||
if (ids.length === 0 || awardCols.length === 0) { setQsoAwardRefs({}); return; }
|
// recompute — so here we just parse the stored JSON string into the code→ref
|
||||||
let alive = true;
|
// object the award columns expect (keys are already upper-case).
|
||||||
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setQsoAwardRefs(m ?? {}); }).catch(() => {});
|
|
||||||
return () => { alive = false; };
|
|
||||||
}, [qsos, awardCols.length, awardsVersion]);
|
|
||||||
const qsosWithAwards = useMemo(
|
const qsosWithAwards = useMemo(
|
||||||
() => (qsos as any[]).map((q) => ({ ...q, award_refs: qsoAwardRefs[String(q.id)] })),
|
() => (qsos as any[]).map((q) => ({ ...q, award_refs: parseAwardRefs(q.award_refs) })),
|
||||||
[qsos, qsoAwardRefs],
|
[qsos],
|
||||||
);
|
);
|
||||||
const [wbAwardRefs, setWbAwardRefs] = useState<Record<string, Record<string, string>>>({});
|
|
||||||
useEffect(() => {
|
|
||||||
const ids = ((wb?.entries ?? []) as any[]).map((e) => e.id).filter(Boolean);
|
|
||||||
if (ids.length === 0 || awardCols.length === 0) { setWbAwardRefs({}); return; }
|
|
||||||
let alive = true;
|
|
||||||
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setWbAwardRefs(m ?? {}); }).catch(() => {});
|
|
||||||
return () => { alive = false; };
|
|
||||||
}, [wb, awardCols.length, awardsVersion]);
|
|
||||||
const wbWithAwards = useMemo(
|
const wbWithAwards = useMemo(
|
||||||
() => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: wbAwardRefs[String(e.id)] })) } : null),
|
() => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: parseAwardRefs(e.award_refs) })) } : null),
|
||||||
[wb, wbAwardRefs],
|
[wb],
|
||||||
);
|
);
|
||||||
// Always-current copy of the entry callsign, so the UDP event handlers
|
// Always-current copy of the entry callsign, so the UDP event handlers
|
||||||
// (which live in a []-deps effect with a stale `callsign` closure) can
|
// (which live in a []-deps effect with a stale `callsign` closure) can
|
||||||
@@ -1426,6 +1440,15 @@ export default function App() {
|
|||||||
return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
|
return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
// The backend bulk-recomputed the materialised award_refs (an award definition
|
||||||
|
// or reference list changed, or the one-time backfill ran). Reload the rows so
|
||||||
|
// the award columns show the new values, and bump awardsVersion so the set of
|
||||||
|
// award COLUMNS refreshes too (a new award may have appeared).
|
||||||
|
useEffect(() => {
|
||||||
|
const off = EventsOn('awards:recomputed', () => { refresh(); setAwardsVersion((v) => v + 1); });
|
||||||
|
return () => { off(); };
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
// Backend-emitted toast messages (e.g. recording auto-send result/skip).
|
// Backend-emitted toast messages (e.g. recording auto-send result/skip).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); });
|
const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); });
|
||||||
@@ -1920,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 () => {
|
||||||
@@ -2021,15 +2045,29 @@ export default function App() {
|
|||||||
setWkMacros((s.macros ?? []) as WKMacro[]);
|
setWkMacros((s.macros ?? []) as WKMacro[]);
|
||||||
setWkEscClears(s.esc_clears_call !== false);
|
setWkEscClears(s.esc_clears_call !== false);
|
||||||
setWkSendOnType(!!s.send_on_type);
|
setWkSendOnType(!!s.send_on_type);
|
||||||
setWkEngine(s.engine === 'icom' ? 'icom' : 'winkeyer');
|
setWkEngine(s.engine === 'icom' ? 'icom' : s.engine === 'flex' ? 'flex' : 'winkeyer');
|
||||||
} catch { /* keyer not configured */ }
|
} catch { /* keyer not configured */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Active profile id — scopes the grids' column layout (visibility / width /
|
||||||
|
// order) so each profile keeps its own. Seeded once on mount and updated on
|
||||||
|
// every profile switch; the grids take it as their React key so they remount
|
||||||
|
// and re-read the now-correct per-profile layout.
|
||||||
|
const [activeProfileId, setActiveProfileId] = useState<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
GetActiveProfile().then((p: any) => {
|
||||||
|
if (p && p.id != null) { setGridPrefsProfile(p.id); setActiveProfileId(p.id); }
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Every setting is per-profile, so when the active profile changes the whole
|
// Every setting is per-profile, so when the active profile changes the whole
|
||||||
// main UI re-reads its config (station identity, lists, CAT, keyer). The Go
|
// main UI re-reads its config (station identity, lists, CAT, keyer). The Go
|
||||||
// side reloads its managers; this keeps the React state in sync.
|
// side reloads its managers; this keeps the React state in sync.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const off = EventsOn('profile:changed', () => {
|
const off = EventsOn('profile:changed', (id: any) => {
|
||||||
|
// Re-scope the grid column layout BEFORE the grids remount (key change).
|
||||||
|
setGridPrefsProfile(id ?? null);
|
||||||
|
setActiveProfileId(typeof id === 'number' ? id : null);
|
||||||
loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes();
|
loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes();
|
||||||
// The chat is per shared logbook — clear the previous profile's messages
|
// The chat is per shared logbook — clear the previous profile's messages
|
||||||
// and reload for the new logbook (or hide if it isn't a MySQL log).
|
// and reload for the new logbook (or hide if it isn't a MySQL log).
|
||||||
@@ -2097,39 +2135,69 @@ 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') {
|
const aborted = () => gen !== wkSendGenRef.current;
|
||||||
// The rig's keyer gives no busy echo back, so show the text we sent and,
|
// Cancellable wait: sleeps up to ms, but bails out immediately on abort.
|
||||||
// for <LOGQSO>, wait the estimated send duration before logging.
|
const waitMs = async (ms: number) => {
|
||||||
|
const end = Date.now() + ms;
|
||||||
|
while (Date.now() < end && !aborted()) await sleep(50);
|
||||||
|
};
|
||||||
|
// Split the macro on <LOGQSO> so the log happens AT the token's position, not
|
||||||
|
// only at the very end: "TU <LOGQSO> QRZ" sends "TU", logs, then sends "QRZ".
|
||||||
|
// Each split boundary (every part except the last) is one <LOGQSO>.
|
||||||
|
// RESOLVE every segment up front — while the form still holds the callsign — so a
|
||||||
|
// segment AFTER the <LOGQSO> (which logs and clears the form) still expands its
|
||||||
|
// variables correctly.
|
||||||
|
const parts = rawText.split(/<LOGQSO>/i).map((pt) => resolveCW(pt));
|
||||||
|
const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex';
|
||||||
|
for (let p = 0; p < parts.length; p++) {
|
||||||
|
if (aborted()) return; // ESC / Stop before this segment → stop sending, don't log
|
||||||
|
const resolved = parts[p];
|
||||||
|
const isLast = p === parts.length - 1;
|
||||||
|
const logAfter = !isLast; // a <LOGQSO> follows this segment
|
||||||
|
if (resolved) {
|
||||||
|
// Trailing word space so back-to-back sends don't run together in the keyer
|
||||||
|
// buffer ("CQ"+"TEST" → "CQTEST"); the keyer keys it as a word gap at the
|
||||||
|
// current WPM, so it scales automatically.
|
||||||
|
const keyed = resolved + ' ';
|
||||||
setWkSent(resolved);
|
setWkSent(resolved);
|
||||||
await IcomSendCW(keyed).catch((e) => setError(String(e?.message ?? e)));
|
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : null;
|
||||||
if (doLog) { await sleep(Math.round(estimateCwMs(resolved, wkWpm)) + 600); void save(); }
|
if (sendFn) await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||||
return;
|
else await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||||
}
|
// Only WAIT for the CW to finish on the FINAL segment — auto-call needs the
|
||||||
await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
|
// send done before its gap+resend. A segment a <LOGQSO> follows is NOT waited
|
||||||
// <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has
|
// on: the keyer has already buffered the CW, so logging happens AT ONCE and
|
||||||
// finished sending — so the QSO isn't logged (and the form cleared) while CW
|
// never interrupts what's being sent. (Gating the log on a CW-length ESTIMATE
|
||||||
// is still going out. We'd like to wait for the busy flag to rise then fall,
|
// made <LOGQSO> log up to ~10s late when the estimate ran long.)
|
||||||
// but over a remote/serial-over-IP link that status echo can lag by tens of
|
if (isLast) {
|
||||||
// seconds, which used to delay logging ~50s. So cap the wait at the ESTIMATED
|
if (isRig) {
|
||||||
// send duration (text length × WPM) plus slack: log when busy clears OR the
|
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + 600);
|
||||||
// estimate elapses, whichever comes first.
|
} else {
|
||||||
if (!doLog) return;
|
// WinKeyer: watch the busy echo, capped by the estimate (+slack) since
|
||||||
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500; // slack for keyer buffering/lag
|
// over a remote/serial-over-IP link that echo can lag tens of seconds.
|
||||||
for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start
|
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;
|
const deadline = Date.now() + capMs;
|
||||||
while (wkBusyRef.current && Date.now() < deadline) await sleep(50);
|
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
|
||||||
void save();
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (aborted()) return; // aborted while this segment was sending → don't log
|
||||||
|
if (logAfter) void save(); // log NOW — the buffered CW keeps sending
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 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
|
||||||
|
// (was WinKeyer-only in a few places, so a Flex CWX / Icom macro kept going).
|
||||||
|
function stopKeyerTx() {
|
||||||
|
wkSendGenRef.current++; // cancel any in-flight macro send (and its pending <LOGQSO> log)
|
||||||
|
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
||||||
|
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
||||||
|
else WinkeyerStop().catch(() => {});
|
||||||
|
}
|
||||||
// runAutoCall sends macro i, waits for the keyer to finish, waits the chosen
|
// runAutoCall sends macro i, waits for the keyer to finish, waits the chosen
|
||||||
// gap, then resends — looping until cancelled (reply entered, Stop, unchecked).
|
// gap, then resends — looping until cancelled (reply entered, Stop, unchecked).
|
||||||
async function runAutoCall(i: number) {
|
async function runAutoCall(i: number) {
|
||||||
@@ -2139,20 +2207,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') {
|
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -2183,8 +2242,14 @@ export default function App() {
|
|||||||
writeUiPref('opslog.wkAutoCallSecs', String(v));
|
writeUiPref('opslog.wkAutoCallSecs', String(v));
|
||||||
}
|
}
|
||||||
// send-on-type: key the typed chars verbatim (no variable substitution).
|
// send-on-type: key the typed chars verbatim (no variable substitution).
|
||||||
function wkSendRaw(chars: string) { WinkeyerSend(chars).catch(() => {}); }
|
function wkSendRaw(chars: string) {
|
||||||
function wkBackspace() { WinkeyerBackspace().catch(() => {}); }
|
if (cwSourceRef.current === 'flex') { FlexSendCW(chars).catch(() => {}); return; }
|
||||||
|
WinkeyerSend(chars).catch(() => {});
|
||||||
|
}
|
||||||
|
function wkBackspace() {
|
||||||
|
if (cwSourceRef.current === 'flex') { FlexBackspaceCW(1).catch(() => {}); return; }
|
||||||
|
WinkeyerBackspace().catch(() => {});
|
||||||
|
}
|
||||||
function wkToggleSendOnType(on: boolean) { setWkSendOnType(on); saveWk({ send_on_type: on }); }
|
function wkToggleSendOnType(on: boolean) { setWkSendOnType(on); saveWk({ send_on_type: on }); }
|
||||||
|
|
||||||
// Resolve slot status for any spot we haven't seen yet — debounced so we
|
// Resolve slot status for any spot we haven't seen yet — debounced so we
|
||||||
@@ -2630,7 +2695,11 @@ export default function App() {
|
|||||||
// rather than finishing the buffered call. (autoCallMacroRef flips to -1 on
|
// rather than finishing the buffered call. (autoCallMacroRef flips to -1 on
|
||||||
// the first keystroke, so we only abort once.)
|
// the first keystroke, so we only abort once.)
|
||||||
if (v.trim() !== '') {
|
if (v.trim() !== '') {
|
||||||
if (autoCallMacroRef.current !== -1) WinkeyerStop().catch(() => {});
|
// Someone answered: on the FIRST character of a new call (the field was
|
||||||
|
// empty), abort whatever CW is being sent — a single macro OR an auto-call
|
||||||
|
// CQ loop — routed to the ACTIVE engine (was WinKeyer-only, so a Flex CWX /
|
||||||
|
// Icom macro kept keying over the answering station).
|
||||||
|
if (callsignValRef.current.trim() === '') stopKeyerTx();
|
||||||
stopAutoCall();
|
stopAutoCall();
|
||||||
}
|
}
|
||||||
// No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
|
// No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
|
||||||
@@ -2643,7 +2712,14 @@ export default function App() {
|
|||||||
// keeps the pre-roll from before this); clearing it discards the take.
|
// keeps the pre-roll from before this); clearing it discards the take.
|
||||||
// Recording START happens on blur (leaving the callsign field), NOT here —
|
// Recording START happens on blur (leaving the callsign field), NOT here —
|
||||||
// you may type a call and work it minutes later. Clearing it cancels.
|
// you may type a call and work it minutes later. Clearing it cancels.
|
||||||
if (v.trim() === '') { QSOAudioCancel(); setRecording(false); recordingCallRef.current = ""; }
|
if (v.trim() === '') {
|
||||||
|
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
|
||||||
|
// Callsign wiped → drop this contact's award references. They are auto-added
|
||||||
|
// per call (live detection merges pickable refs into award_refs), so without
|
||||||
|
// this they'd carry over to the NEXT call — e.g. IT9AOT's ref lingering when
|
||||||
|
// you then type F4BPO, showing both in the F3 Awards tab.
|
||||||
|
updateDetails({ award_refs: '' });
|
||||||
|
}
|
||||||
const isEmpty = v.trim() === '';
|
const isEmpty = v.trim() === '';
|
||||||
if (!isEmpty && !locks.start) {
|
if (!isEmpty && !locks.start) {
|
||||||
// Restart the start time on every callsign change (each keystroke, a
|
// Restart the start time on every callsign change (each keystroke, a
|
||||||
@@ -2726,7 +2802,7 @@ export default function App() {
|
|||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: t('file.deleteAll'), action: 'file.deleteall', disabled: total === 0 },
|
{ type: 'item', label: t('file.deleteAll'), action: 'file.deleteall', disabled: total === 0 },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: t('file.exit'), action: 'file.exit', shortcut: 'Ctrl+Q', disabled: true },
|
{ type: 'item', label: t('file.exit'), action: 'file.exit', shortcut: 'Ctrl+Q' },
|
||||||
]},
|
]},
|
||||||
{ name: 'edit', label: t('menu.edit'), items: [
|
{ name: 'edit', label: t('menu.edit'), items: [
|
||||||
{ type: 'item', label: t('edit.editSel'), action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
|
{ type: 'item', label: t('edit.editSel'), action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
|
||||||
@@ -2747,7 +2823,6 @@ export default function App() {
|
|||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
||||||
{ type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' },
|
{ type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' },
|
||||||
{ type: 'item', label: (cwEnabled ? '✓ ' : '') + t('tools.cwDecoder'), action: 'tools.cwdecoder' },
|
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' },
|
{ type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' },
|
||||||
{ type: 'item', label: (contestTabEnabled ? '✓ ' : '') + t('tools.contest'), action: 'tools.contest' },
|
{ type: 'item', label: (contestTabEnabled ? '✓ ' : '') + t('tools.contest'), action: 'tools.contest' },
|
||||||
@@ -2761,9 +2836,10 @@ export default function App() {
|
|||||||
{ type: 'item', label: refsDownloading ? 'Downloading reference lists…' : 'Download reference lists (IOTA/POTA/WWFF/SOTA)', action: 'tools.downloadRefs', disabled: refsDownloading },
|
{ type: 'item', label: refsDownloading ? 'Downloading reference lists…' : 'Download reference lists (IOTA/POTA/WWFF/SOTA)', action: 'tools.downloadRefs', disabled: refsDownloading },
|
||||||
]},
|
]},
|
||||||
{ name: 'help', label: t('menu.help'), items: [
|
{ name: 'help', label: t('menu.help'), items: [
|
||||||
|
{ type: 'item', label: t('whatsnew.title'), action: 'help.whatsnew' },
|
||||||
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
||||||
]},
|
]},
|
||||||
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, t]);
|
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, netEnabled, contestTabEnabled, t]);
|
||||||
|
|
||||||
function handleMenu(action: string) {
|
function handleMenu(action: string) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@@ -2771,6 +2847,7 @@ export default function App() {
|
|||||||
case 'file.export': exportAdif(); break;
|
case 'file.export': exportAdif(); break;
|
||||||
case 'file.exportCabrillo': exportCabrillo(); break;
|
case 'file.exportCabrillo': exportCabrillo(); break;
|
||||||
case 'file.deleteall': setShowDeleteAll(true); break;
|
case 'file.deleteall': setShowDeleteAll(true); break;
|
||||||
|
case 'file.exit': QuitApp(); break;
|
||||||
case 'view.refresh': refresh(); break;
|
case 'view.refresh': refresh(); break;
|
||||||
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
|
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
|
||||||
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
|
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
|
||||||
@@ -2783,14 +2860,14 @@ export default function App() {
|
|||||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||||
case 'tools.cwdecoder': toggleCwDecoder(); break;
|
|
||||||
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
|
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
|
||||||
case 'tools.contest': setContestTabEnabled((v) => { const nv = !v; if (nv) setActiveTab('contest'); else setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); return nv; }); break;
|
case 'tools.contest': setContestTabEnabled((v) => { const nv = !v; if (nv) setActiveTab('contest'); else setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); return nv; }); break;
|
||||||
case 'tools.alerts': setAlertsOpen(true); break;
|
case 'tools.alerts': setAlertsOpen(true); break;
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2843,7 +2920,15 @@ export default function App() {
|
|||||||
const keyerLive = wkActiveRef.current;
|
const keyerLive = wkActiveRef.current;
|
||||||
// ESC aborts the current CW transmission AND the auto-call loop, so it
|
// ESC aborts the current CW transmission AND the auto-call loop, so it
|
||||||
// won't resend after the gap — you must click a CQ macro to restart it.
|
// won't resend after the gap — you must click a CQ macro to restart it.
|
||||||
if (keyerLive) { stopAutoCall(); WinkeyerStop().catch(() => {}); }
|
// Route the abort to whichever engine is active (was WinKeyer-only, so
|
||||||
|
// ESC didn't stop the Icom or Flex keyer).
|
||||||
|
if (keyerLive) {
|
||||||
|
stopAutoCall();
|
||||||
|
wkSendGenRef.current++; // abort an in-flight macro send so a pending <LOGQSO> won't fire
|
||||||
|
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
||||||
|
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
||||||
|
else WinkeyerStop().catch(() => {});
|
||||||
|
}
|
||||||
if (!keyerLive || wkEscClearsRef.current) {
|
if (!keyerLive || wkEscClearsRef.current) {
|
||||||
resetEntry();
|
resetEntry();
|
||||||
callsignRef.current?.focus();
|
callsignRef.current?.focus();
|
||||||
@@ -3590,7 +3675,7 @@ export default function App() {
|
|||||||
case 'flex':
|
case 'flex':
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
|
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
|
||||||
<FlexPanel onCWSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }}
|
<FlexPanel onCWSpeed={(w) => { if (cwSourceRef.current === 'flex') setWkWpm(w); }}
|
||||||
onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -3610,6 +3695,7 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
|
key={`rqg-${activeProfileId ?? 'x'}`}
|
||||||
rows={qsosWithAwards as any}
|
rows={qsosWithAwards as any}
|
||||||
total={total}
|
total={total}
|
||||||
awardCols={awardCols}
|
awardCols={awardCols}
|
||||||
@@ -3632,7 +3718,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 ? (
|
||||||
@@ -3801,24 +3887,6 @@ export default function App() {
|
|||||||
<Zap className="size-4" />
|
<Zap className="size-4" />
|
||||||
{wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-warning animate-pulse" />}
|
{wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-warning animate-pulse" />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleCwDecoder}
|
|
||||||
title={
|
|
||||||
cwEnabled
|
|
||||||
? (mode === 'CW' ? 'CW decoder — on (decoding) · click to disable' : 'CW decoder — on, idle until CW mode · click to disable')
|
|
||||||
: 'CW decoder · click to enable (decodes RX audio in CW mode)'
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
|
||||||
cwOn && cwStatus.active ? 'border-success bg-success-muted text-success-muted-foreground'
|
|
||||||
: cwEnabled ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
|
|
||||||
: 'border-border text-muted-foreground hover:bg-muted',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Ear className="size-4" />
|
|
||||||
{cwOn && cwStatus.active && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success animate-pulse" />}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
|
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
|
||||||
@@ -4090,12 +4158,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>
|
||||||
@@ -4108,6 +4178,51 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* What's new — shown once on the first launch after an update. */}
|
||||||
|
{whatsNew && (
|
||||||
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={() => setWhatsNew(null)}>
|
||||||
|
<div className="w-full max-w-lg max-h-[85vh] flex flex-col rounded-xl border border-border bg-card shadow-2xl animate-in fade-in zoom-in-95" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-2 px-5 pt-5 pb-3 border-b border-border shrink-0">
|
||||||
|
<Zap className="size-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-bold tracking-tight">{t('whatsnew.title')}</h2>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden text-[11px] font-semibold">
|
||||||
|
{(['en', 'fr'] as const).map((l) => (
|
||||||
|
<button key={l} onClick={() => setClLang(l)}
|
||||||
|
className={cn('px-2 py-0.5 uppercase', clLang === l ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||||
|
{l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setWhatsNew(null)} className="text-muted-foreground hover:text-foreground ml-1"><X className="size-4" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto px-5 py-4 space-y-5">
|
||||||
|
{whatsNew.map((e) => (
|
||||||
|
<div key={e.version}>
|
||||||
|
<div className="flex items-baseline gap-2 mb-1.5">
|
||||||
|
<span className="font-mono font-bold text-primary">v{e.version}</span>
|
||||||
|
{e.date && <span className="text-[11px] text-muted-foreground">{e.date}</span>}
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1.5 text-sm">
|
||||||
|
{(clLang === 'fr' ? e.fr : e.en).map((line, i) => (
|
||||||
|
<li key={i} className="flex gap-2">
|
||||||
|
<span className="text-primary mt-1.5 size-1.5 rounded-full bg-primary shrink-0" />
|
||||||
|
<span>{line}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-3 border-t border-border shrink-0 text-right">
|
||||||
|
<button onClick={() => setWhatsNew(null)} className="h-8 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90">
|
||||||
|
{t('whatsnew.close')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Transient toasts (bottom-right). Errors stack on top of the green
|
{/* Transient toasts (bottom-right). Errors stack on top of the green
|
||||||
success toast; both auto-dismiss. */}
|
success toast; both auto-dismiss. */}
|
||||||
{/* Unlock encrypted passwords (set via Settings → Security). Dismissable:
|
{/* Unlock encrypted passwords (set via Settings → Security). Dismissable:
|
||||||
@@ -4284,31 +4399,27 @@ export default function App() {
|
|||||||
<X className="size-3.5" />
|
<X className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0 overflow-auto p-1.5 flex flex-col gap-1">
|
<div className="flex-1 min-h-0 overflow-auto p-1 flex flex-col gap-0.5">
|
||||||
{liveStations.filter((s) => s.online).length === 0 ? (
|
{liveStations.filter((s) => s.online).length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground italic px-1 py-2">{t('live.stationsEmpty')}</p>
|
<p className="text-xs text-muted-foreground italic px-1 py-2">{t('live.stationsEmpty')}</p>
|
||||||
) : liveStations.filter((s) => s.online).map((s, i) => {
|
) : liveStations.filter((s) => s.online).map((s, i) => {
|
||||||
const mc = modeAccent(s.mode);
|
const mc = modeAccent(s.mode);
|
||||||
|
// Single compact row so 5+ stations fit without scrolling.
|
||||||
return (
|
return (
|
||||||
<div key={i} className={cn('flex items-center gap-2 rounded-md px-2 py-1.5 border', s.online ? 'bg-muted/40 border-border' : 'border-transparent opacity-60')}>
|
<div key={i} className={cn('flex items-center gap-1.5 rounded-md px-1.5 py-1 border', s.online ? 'bg-muted/40 border-border' : 'border-transparent opacity-60')}>
|
||||||
<span className={cn('size-2 rounded-full shrink-0', s.online ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')}
|
<span className={cn('size-2 rounded-full shrink-0', s.online ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')}
|
||||||
title={s.online ? t('live.onAir') : t('live.offline')} />
|
title={s.online ? t('live.onAir') : t('live.offline')} />
|
||||||
<div className="min-w-0 flex-1">
|
<span className="text-xs font-bold font-mono truncate shrink-0 max-w-[72px]" title={s.operator}>{s.operator}</span>
|
||||||
<div className="flex items-baseline gap-1.5 min-w-0">
|
<span className="font-mono text-[11px] font-semibold tabular-nums shrink-0" style={{ color: mc }}>
|
||||||
<span className="text-xs font-bold font-mono truncate">{s.operator}</span>
|
|
||||||
{s.version && <span className="text-[9px] text-muted-foreground shrink-0 tabular-nums ml-auto">v{s.version}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 mt-0.5 min-w-0">
|
|
||||||
<span className="font-mono text-[11px] font-semibold tabular-nums" style={{ color: mc }}>
|
|
||||||
{s.freq_hz ? (s.freq_hz / 1e6).toFixed(3) : '—'}
|
{s.freq_hz ? (s.freq_hz / 1e6).toFixed(3) : '—'}
|
||||||
</span>
|
</span>
|
||||||
{s.mode && (
|
{s.mode && (
|
||||||
<span className="text-[9px] font-bold uppercase px-1.5 rounded-full leading-[15px] shrink-0"
|
<span className="text-[9px] font-bold uppercase px-1 rounded-full leading-[15px] shrink-0"
|
||||||
style={{ background: `${mc}22`, color: mc }}>{s.mode}</span>
|
style={{ background: `${mc}22`, color: mc }}>{s.mode}</span>
|
||||||
)}
|
)}
|
||||||
{s.band && <span className="text-[10px] text-muted-foreground shrink-0">{s.band}</span>}
|
{s.band && <span className="text-[10px] text-muted-foreground shrink-0">{s.band}</span>}
|
||||||
</div>
|
<span className="flex-1" />
|
||||||
</div>
|
{s.version && <span className="text-[9px] text-muted-foreground shrink-0 tabular-nums">v{s.version}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -4368,8 +4479,8 @@ export default function App() {
|
|||||||
{wkEnabled && (
|
{wkEnabled && (
|
||||||
<div className="w-[380px] shrink-0 min-h-0">
|
<div className="w-[380px] shrink-0 min-h-0">
|
||||||
<WinkeyerPanel
|
<WinkeyerPanel
|
||||||
status={cwSource === 'icom'
|
status={cwSource === 'icom' || cwSource === 'flex'
|
||||||
? { connected: catState.backend === 'icom' && catState.connected, busy: false, wpm: wkWpm, version: 0, port: 'CI-V' }
|
? { connected: catState.backend === cwSource && catState.connected, busy: false, wpm: wkWpm, version: 0, port: cwSource === 'flex' ? 'CWX' : 'CI-V' }
|
||||||
: wkStatus}
|
: wkStatus}
|
||||||
ports={wkPorts}
|
ports={wkPorts}
|
||||||
port={wkPort}
|
port={wkPort}
|
||||||
@@ -4386,11 +4497,12 @@ export default function App() {
|
|||||||
onSetSpeed={(w) => {
|
onSetSpeed={(w) => {
|
||||||
setWkWpm(w); saveWk({ wpm: w });
|
setWkWpm(w); saveWk({ wpm: w });
|
||||||
if (cwSource === 'icom') IcomSetKeySpeed(w).catch(() => {});
|
if (cwSource === 'icom') IcomSetKeySpeed(w).catch(() => {});
|
||||||
|
else if (cwSource === 'flex') FlexSetKeySpeed(w).catch(() => {});
|
||||||
else WinkeyerSetSpeed(w).catch(() => {});
|
else WinkeyerSetSpeed(w).catch(() => {});
|
||||||
}}
|
}}
|
||||||
onSend={wkSend}
|
onSend={wkSend}
|
||||||
onSendMacro={wkSendMacro}
|
onSendMacro={wkSendMacro}
|
||||||
onStop={() => { stopAutoCall(); if (cwSource === 'icom') IcomStopCW().catch(() => {}); else WinkeyerStop().catch(() => {}); }}
|
onStop={() => { stopAutoCall(); stopKeyerTx(); }}
|
||||||
onClose={() => wkSetEnabled(false)}
|
onClose={() => wkSetEnabled(false)}
|
||||||
sendOnType={wkSendOnType}
|
sendOnType={wkSendOnType}
|
||||||
onToggleSendOnType={wkToggleSendOnType}
|
onToggleSendOnType={wkToggleSendOnType}
|
||||||
@@ -4429,53 +4541,6 @@ export default function App() {
|
|||||||
</div>{/* /entry + aside row */}
|
</div>{/* /entry + aside row */}
|
||||||
|
|
||||||
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
|
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
|
||||||
{cwOn && (
|
|
||||||
<div className="ml-2.5 mt-1.5 -mb-1 w-[45%] flex items-center gap-2 rounded-md border border-success-border/70 bg-success-muted/60 px-2 py-1.5 text-xs">
|
|
||||||
<Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-success' : 'text-muted-foreground')} />
|
|
||||||
{/* Input-level meter — if this stays flat with a strong signal, the RX
|
|
||||||
audio device is wrong/silent rather than a decode problem. */}
|
|
||||||
<div className="shrink-0 w-12 h-1.5 rounded bg-muted overflow-hidden" title={`Audio level ${Math.round(cwStatus.level * 100)}%`}>
|
|
||||||
<div className="h-full bg-success transition-[width] duration-100" style={{ width: `${Math.min(100, Math.round(cwStatus.level * 100))}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums">
|
|
||||||
{cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'}
|
|
||||||
</span>
|
|
||||||
{/* Lock pitch: blank = Auto (follow the Flex CW pitch / search). */}
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={cwPitch}
|
|
||||||
onChange={(e) => setCwPitch(e.target.value)}
|
|
||||||
placeholder="auto"
|
|
||||||
title="Lock the decoder to this pitch (Hz). Blank = follow the radio's CW pitch / auto-search."
|
|
||||||
className="shrink-0 w-14 h-5 rounded border border-success-border/70 bg-card/60 px-1 text-[10px] font-mono text-center outline-none"
|
|
||||||
/>
|
|
||||||
{/* Left-aligned single line, no scrollbar; auto-scrolled to the newest
|
|
||||||
text (see cwScrollRef effect) so the latest stays in view. */}
|
|
||||||
<div ref={cwScrollRef} className="flex-1 min-w-0 overflow-hidden font-mono leading-none flex items-center">
|
|
||||||
{cwText.trim() === '' ? (
|
|
||||||
<span className="text-muted-foreground italic">listening…</span>
|
|
||||||
) : (
|
|
||||||
<div className="inline-flex items-center whitespace-nowrap">
|
|
||||||
{cwText.trim().split(/\s+/).map((tok, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
type="button"
|
|
||||||
className="mr-1 shrink-0 rounded px-1 leading-none hover:bg-success-muted/70"
|
|
||||||
title="Use as callsign"
|
|
||||||
onClick={() => onCallsignInput(tok, { force: true })}
|
|
||||||
>
|
|
||||||
{tok}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button type="button" className="shrink-0 text-muted-foreground hover:text-foreground" title="Clear" onClick={() => setCwText('')}>
|
|
||||||
<Eraser className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
|
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
|
||||||
{compact ? null : <>
|
{compact ? null : <>
|
||||||
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
|
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
|
||||||
@@ -4636,6 +4701,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
|
key={`rqg2-${activeProfileId ?? 'x'}`}
|
||||||
rows={qsosWithAwards as any}
|
rows={qsosWithAwards as any}
|
||||||
total={total}
|
total={total}
|
||||||
awardCols={awardCols}
|
awardCols={awardCols}
|
||||||
@@ -4927,7 +4993,7 @@ export default function App() {
|
|||||||
backend is a FlexRadio. */}
|
backend is a FlexRadio. */}
|
||||||
{catState.backend === 'flex' && (
|
{catState.backend === 'flex' && (
|
||||||
<TabsContent value="flex" className="flex-1 min-h-0 p-0">
|
<TabsContent value="flex" className="flex-1 min-h-0 p-0">
|
||||||
<FlexPanel onCWSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }}
|
<FlexPanel onCWSpeed={(w) => { if (cwSourceRef.current === 'flex') setWkWpm(w); }}
|
||||||
onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
@@ -5056,7 +5122,7 @@ export default function App() {
|
|||||||
disabled={!rotatorHeading.enabled}
|
disabled={!rotatorHeading.enabled}
|
||||||
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
||||||
/>
|
/>
|
||||||
{liveStatusOn && (
|
{liveStationsOn && (
|
||||||
<div
|
<div
|
||||||
className={cn('inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider shrink-0 transition-colors',
|
className={cn('inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider shrink-0 transition-colors',
|
||||||
onAir ? 'border-danger-border bg-danger-muted text-danger-muted-foreground' : 'border-border text-muted-foreground')}
|
onAir ? 'border-danger-border bg-danger-muted text-danger-muted-foreground' : 'border-border text-muted-foreground')}
|
||||||
|
|||||||
@@ -33,6 +33,37 @@ function pretty(name: string): string {
|
|||||||
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
|
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PortBtn is defined at MODULE scope on purpose. Defined inside AntGeniusPanel it
|
||||||
|
// would be a new component *type* on every render, so React would unmount and
|
||||||
|
// remount every port button each time the panel re-renders — harmless when that's
|
||||||
|
// rare, but with the CW decoder running the parent re-renders many times a second
|
||||||
|
// and the buttons were being torn down mid-click (mousedown and mouseup landing on
|
||||||
|
// different element instances), so antenna changes silently did nothing.
|
||||||
|
function PortBtn({ port, index, active, tx, onActivate, t }: {
|
||||||
|
port: 1 | 2; index: number; active: boolean; tx: boolean;
|
||||||
|
onActivate: (port: number, antenna: number) => void;
|
||||||
|
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||||
|
}) {
|
||||||
|
const letter = port === 1 ? 'A' : 'B';
|
||||||
|
const cls = tx
|
||||||
|
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
||||||
|
: active
|
||||||
|
? (port === 1
|
||||||
|
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
||||||
|
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
||||||
|
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onActivate(port, active ? 0 : index)}
|
||||||
|
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
|
||||||
|
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
|
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
|
||||||
// match the app's light theme with soft gradients + glows. Each antenna row has
|
// match the app's light theme with soft gradients + glows. Each antenna row has
|
||||||
// a port-A button (left) and port-B button (right). Colours: green = selected on
|
// a port-A button (left) and port-B button (right). Colours: green = selected on
|
||||||
@@ -61,27 +92,6 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
|
|||||||
if (filtered.length > 0) list = filtered;
|
if (filtered.length > 0) list = filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
|
|
||||||
const letter = port === 1 ? 'A' : 'B';
|
|
||||||
const cls = tx
|
|
||||||
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
|
||||||
: active
|
|
||||||
? (port === 1
|
|
||||||
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
|
||||||
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
|
||||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onActivate(port, active ? 0 : index)}
|
|
||||||
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
|
|
||||||
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
|
||||||
>
|
|
||||||
{letter}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||||
@@ -124,11 +134,11 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
|
|||||||
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
||||||
return (
|
return (
|
||||||
<div key={a.index} className="flex items-center gap-1.5">
|
<div key={a.index} className="flex items-center gap-1.5">
|
||||||
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} />
|
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} onActivate={onActivate} t={t} />
|
||||||
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
|
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
|
||||||
{pretty(a.name)}
|
{pretty(a.name)}
|
||||||
</div>
|
</div>
|
||||||
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} />
|
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} onActivate={onActivate} t={t} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ListCountries, DXCCForCountry, DXCCName,
|
ListCountries, DXCCForCountry, DXCCName,
|
||||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||||
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
||||||
|
ExportAwardForCatalog,
|
||||||
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ export type AwardDef = {
|
|||||||
or_rules?: AwardOrRule[];
|
or_rules?: AwardOrRule[];
|
||||||
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
||||||
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
||||||
total: number; builtin?: boolean;
|
total: number; builtin?: boolean; version?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AwardOrRule = {
|
type AwardOrRule = {
|
||||||
@@ -155,6 +156,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [updating, setUpdating] = useState<string | null>(null);
|
const [updating, setUpdating] = useState<string | null>(null);
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
|
// Version to stamp into a "publish for catalog" export — defaults to one past
|
||||||
|
// the selected award's current version whenever the selection changes.
|
||||||
|
const [catVer, setCatVer] = useState('1');
|
||||||
|
|
||||||
// The err banner doubles as a success/notice area (export path, import counts,
|
// The err banner doubles as a success/notice area (export path, import counts,
|
||||||
// "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
|
// "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
|
||||||
@@ -212,6 +216,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
|
|
||||||
const cur = defs[sel];
|
const cur = defs[sel];
|
||||||
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null;
|
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null;
|
||||||
|
useEffect(() => { setCatVer(String((cur?.version ?? 0) + 1)); }, [cur?.code]);
|
||||||
|
|
||||||
// ── Award tester: run the award's rules against a real QSO and show every step.
|
// ── Award tester: run the award's rules against a real QSO and show every step.
|
||||||
type Rejected = { candidate: string; reason: string };
|
type Rejected = { candidate: string; reason: string };
|
||||||
@@ -299,6 +304,19 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
if (p) setErr(t('awed.exportedTo', { path: p }));
|
if (p) setErr(t('awed.exportedTo', { path: p }));
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
|
||||||
|
// paste over internal/award/catalog/<code>.json. A new release then ships it to
|
||||||
|
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
|
||||||
|
async function exportForCatalog() {
|
||||||
|
setErr('');
|
||||||
|
if (!cur) return;
|
||||||
|
const v = Math.trunc(Number(catVer));
|
||||||
|
if (!Number.isFinite(v) || v < 1) { setErr(t('awed.catalogBadVersion')); return; }
|
||||||
|
try {
|
||||||
|
const p = await ExportAwardForCatalog(cur.code.trim().toUpperCase(), v);
|
||||||
|
if (p) setErr(t('awed.catalogExportedTo', { path: p }));
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
// Import: LOOK FIRST, then ask.
|
// Import: LOOK FIRST, then ask.
|
||||||
//
|
//
|
||||||
// This used to merge by code with "imported wins", silently — import a WAPC
|
// This used to merge by code with "imported wins", silently — import a WAPC
|
||||||
@@ -352,7 +370,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
<DialogContent className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||||
<DialogHeader className="px-5 py-3 border-b">
|
<DialogHeader className="px-5 py-3 border-b">
|
||||||
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -726,6 +744,18 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
title={t('awed.awardsFolderTip')}>
|
title={t('awed.awardsFolderTip')}>
|
||||||
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
|
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Publish the selected award to the catalog: stamp a version and write a
|
||||||
|
file to paste over internal/award/catalog/<code>.json, so a release
|
||||||
|
ships your change to the whole team. */}
|
||||||
|
{cur && (
|
||||||
|
<div className="flex items-center gap-1" title={t('awed.catalogPublishTip')}>
|
||||||
|
<input type="number" min={1} value={catVer} onChange={(e) => setCatVer(e.target.value)}
|
||||||
|
className="h-8 w-14 rounded border border-input bg-background px-1.5 text-xs font-mono" />
|
||||||
|
<Button variant="outline" onClick={exportForCatalog}>
|
||||||
|
<Download className="size-3.5 mr-1" /> {t('awed.catalogPublish')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
|
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
|
||||||
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
|
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ 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,
|
||||||
|
GetACOMStatus, ACOMSetOperate, ACOMSetPower,
|
||||||
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,12 +264,43 @@ function Card({ icon: Icon, title, accent, children }: { icon: any; title: strin
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// speMaxW is the amp's rated output, used as the power-bar full scale. Derived from
|
||||||
|
// the model string the amp reports (13K→1.3kW, 15K→1.5kW, 2K/20K→2kW).
|
||||||
|
function speMaxW(model?: string): number {
|
||||||
|
const m = (model || '').toUpperCase();
|
||||||
|
if (m.includes('20K') || m.includes('2K')) return 2000;
|
||||||
|
if (m.includes('15K')) return 1500;
|
||||||
|
return 1300; // 1.3K-FA default
|
||||||
|
}
|
||||||
|
|
||||||
|
// powerLevelLabel spells out the SPE power-level code (L/M/H) in full.
|
||||||
|
function powerLevelLabel(pl?: string): string {
|
||||||
|
switch ((pl || '').trim().toUpperCase()) {
|
||||||
|
case 'L': return 'Low';
|
||||||
|
case 'M': return 'Mid';
|
||||||
|
case 'H': return 'High';
|
||||||
|
default: return pl || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// onCWSpeed (optional): notified when the operator changes CW speed here, so the
|
// 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 } = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [st, setSt] = useState<FlexState>(ZERO);
|
const [st, setSt] = useState<FlexState>(ZERO);
|
||||||
const hold = useRef<Record<string, number>>({});
|
const hold = useRef<Record<string, number>>({});
|
||||||
|
// Keep the host's keyer speed (used for CW-length estimates and the keyer panel)
|
||||||
|
// in step with the radio's ACTUAL CW speed — including when it's changed on the
|
||||||
|
// radio / in SmartSDR, not just via the slider below. Notify only on a real change.
|
||||||
|
const lastCwSpeed = useRef<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const w = st.cw_speed;
|
||||||
|
if (typeof w === 'number' && w > 0 && w !== lastCwSpeed.current) {
|
||||||
|
lastCwSpeed.current = w;
|
||||||
|
onCWSpeed?.(w);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [st.cw_speed]);
|
||||||
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
|
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
|
||||||
// read steadily instead of jumping every poll.
|
// read steadily instead of jumping every poll.
|
||||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||||
@@ -310,6 +342,40 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Configured amplifier type (pgxl / spe*), so we show the SPE control when the
|
||||||
|
// operator runs an SPE Expert instead of the PowerGenius.
|
||||||
|
const [ampType, setAmpType] = useState<string>('pgxl');
|
||||||
|
const [ampEnabled, setAmpEnabled] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = () => GetPGXLSettings().then((s: any) => { if (alive) { setAmpType(s?.type || 'pgxl'); setAmpEnabled(!!s?.enabled); } }).catch(() => {});
|
||||||
|
load();
|
||||||
|
const id = window.setInterval(load, 5000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
const isACOM = ampEnabled && ampType.startsWith('acom');
|
||||||
|
const isSPE = ampEnabled && !isACOM && ampType !== 'pgxl';
|
||||||
|
// SPE Expert live status (only polled when an SPE amp is configured).
|
||||||
|
const [spe, setSpe] = useState<any>({ connected: false });
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSPE) return;
|
||||||
|
let alive = true;
|
||||||
|
const tick = () => GetSPEStatus().then((s: any) => alive && setSpe(s || { connected: false })).catch(() => {});
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1500);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, [isSPE]);
|
||||||
|
// ACOM live status (only polled when an ACOM amp is configured).
|
||||||
|
const [acom, setAcom] = useState<any>({ connected: false });
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isACOM) return;
|
||||||
|
let alive = true;
|
||||||
|
const tick = () => GetACOMStatus().then((s: any) => alive && setAcom(s || { connected: false })).catch(() => {});
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1500);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, [isACOM]);
|
||||||
|
|
||||||
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
|
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 +826,113 @@ 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ACOM amplifier (serial/TCP) — shown when it's the configured amp. Driven
|
||||||
|
by OpsLog's own ACOM link (the Flex doesn't report ACOM amps). */}
|
||||||
|
{isACOM && (
|
||||||
|
<Card icon={Flame} title={`${t('flxp.amplifier')} · ACOM${acom.model ? ' ' + acom.model : ''}`} accent="#ea580c">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button type="button" disabled={!acom.connected}
|
||||||
|
onClick={() => ACOMSetOperate(!acom.operate).catch(() => {})}
|
||||||
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
acom.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
|
{acom.operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</button>
|
||||||
|
{/* Power ON is a DTR/RTS pulse — serial only, and it works while the amp
|
||||||
|
is off (the port stays open), so gate on port_open not connected. */}
|
||||||
|
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||||
|
<button type="button" disabled={!(acom.port_open && acom.transport === 'serial')}
|
||||||
|
onClick={() => ACOMSetPower(true).catch(() => {})}
|
||||||
|
title={acom.transport === 'serial' ? 'Power on (DTR/RTS pulse — needs the power-on pins wired)' : 'Power-on needs the serial DTR/RTS lines — not available over a network bridge'}
|
||||||
|
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||||
|
<button type="button" disabled={!acom.connected}
|
||||||
|
onClick={() => ACOMSetPower(false).catch(() => {})}
|
||||||
|
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
||||||
|
</div>
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 text-sm', acom.connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||||
|
<span className={cn('size-2 rounded-full', acom.connected ? 'bg-success' : 'bg-danger')} />
|
||||||
|
{acom.connected ? acom.state : t('flxp.acomOffline')}
|
||||||
|
</span>
|
||||||
|
{acom.connected && (
|
||||||
|
<span className="text-sm font-mono text-muted-foreground tabular-nums">
|
||||||
|
{acom.band ? `${acom.band} · ` : ''}{acom.fwd_w}W · SWR {Number(acom.swr ?? 0).toFixed(1)} · {acom.temp_c}°C · Fan {acom.fan}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{acom.err_text && (
|
||||||
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">⚠ {acom.err_text}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{acom.connected && (
|
||||||
|
<MeterBar label={t('flxp.outputPower')} value={Number(acom.fwd_w) || 0} unit="W"
|
||||||
|
lo={0} hi={Number(acom.max_w) || 800}
|
||||||
|
display={`${Number(acom.fwd_w) || 0} W`}
|
||||||
|
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* External amplifier (PowerGenius XL) — only when the Flex reports one AND
|
||||||
|
PowerGenius is the selected amp type. Running an SPE Expert or ACOM hides
|
||||||
|
this Flex-reported card so two amps don't both show. */}
|
||||||
|
{st.amp_available && !isSPE && !isACOM && (
|
||||||
<Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
<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 +947,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>
|
||||||
|
|||||||
@@ -377,11 +377,13 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
if (state) saveState(colStateKey, stripAwardCols(state));
|
if (state) saveState(colStateKey, stripAwardCols(state));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// The award columns load asynchronously; when they arrive (or change) the
|
// columnDefs is rebuilt whenever the award columns load OR the user toggles an
|
||||||
// columnDefs memo is rebuilt and AG Grid re-applies each colDef's `hide`
|
// award column (both change the memo → restoringRef flips true at line 316). Each
|
||||||
// default — wiping the user's saved visibility (award columns reappear,
|
// rebuild makes AG Grid re-apply every colDef's `hide` default, wiping the user's
|
||||||
// manually-shown ones like LoTW sent vanish). Re-apply the saved state after
|
// saved visibility of the NON-award columns (QTH/Grid reappear, a manually-shown
|
||||||
// every rebuild so the user's choices win. No-op before the grid is ready.
|
// LoTW-sent vanishes). Re-apply the saved (award-stripped) state after EVERY such
|
||||||
|
// rebuild — hence awardShown in the deps, not just awardCols; without it, toggling
|
||||||
|
// an award reset the other columns AND left restoringRef stuck true (saving off).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = gridRef.current?.api;
|
const api = gridRef.current?.api;
|
||||||
const local = loadLocal(colStateKey);
|
const local = loadLocal(colStateKey);
|
||||||
@@ -389,7 +391,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
||||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [awardCols]);
|
}, [awardCols, awardShown]);
|
||||||
|
|
||||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
||||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||||
ChevronDown, ChevronRight,
|
ChevronDown, ChevronRight,
|
||||||
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
||||||
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff,
|
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff, Pencil,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||||
GetPGXLSettings, SavePGXLSettings,
|
GetPGXLSettings, SavePGXLSettings, GetSPEStatus, SPESetOperate, GetACOMStatus, ACOMSetOperate,
|
||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||||
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||||
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
||||||
@@ -28,11 +28,10 @@ import {
|
|||||||
ConnectClusterServer, DisconnectClusterServer,
|
ConnectClusterServer, DisconnectClusterServer,
|
||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase,
|
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase,
|
||||||
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
|
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
|
||||||
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||||
GetLiveStatusEnabled, SetLiveStatusEnabled,
|
|
||||||
GetQSLDefaults, SaveQSLDefaults,
|
GetQSLDefaults, SaveQSLDefaults,
|
||||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
|
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
|
||||||
GetPOTAToken, SavePOTAToken,
|
GetPOTAToken, SavePOTAToken,
|
||||||
@@ -276,7 +275,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
|||||||
winkeyer: 'CW Keyer',
|
winkeyer: 'CW Keyer',
|
||||||
antenna: 'Ultrabeam / Steppir',
|
antenna: 'Ultrabeam / Steppir',
|
||||||
antgenius: 'Antenna Genius',
|
antgenius: 'Antenna Genius',
|
||||||
pgxl: 'Power Genius',
|
pgxl: 'Amplifier',
|
||||||
flex: 'FlexRadio',
|
flex: 'FlexRadio',
|
||||||
relayauto: 'Relay auto-control',
|
relayauto: 'Relay auto-control',
|
||||||
audio: 'Audio devices',
|
audio: 'Audio devices',
|
||||||
@@ -566,26 +565,6 @@ function TelemetryToggle() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// LiveStatusToggle publishes this operator's current activity (call + band +
|
|
||||||
// freq + mode) to the shared MySQL `live_status` table every ~15s, for multi-op
|
|
||||||
// events — a small web script on your server renders it for the QRZ page. Only
|
|
||||||
// useful on a MySQL logbook. Self-contained component (owns its async state).
|
|
||||||
function LiveStatusToggle() {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const [on, setOn] = useState(false);
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
GetLiveStatusEnabled().then((v) => setOn(!!v)).catch(() => {}).finally(() => setLoaded(true));
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
|
||||||
<Checkbox checked={on} disabled={!loaded}
|
|
||||||
onCheckedChange={(c) => { const v = !!c; setOn(v); SetLiveStatusEnabled(v).catch(() => {}); }} />
|
|
||||||
{t('settings.liveStatus')} <span className="text-xs text-muted-foreground">({t('settings.liveStatusHint')})</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ADIFMonitorPanel watches a list of external ADIF files (fldigi RTTY, N1MM,
|
// ADIFMonitorPanel watches a list of external ADIF files (fldigi RTTY, N1MM,
|
||||||
// VarAC…) and auto-imports newly appended QSOs — deliberately option-free: a
|
// VarAC…) and auto-imports newly appended QSOs — deliberately option-free: a
|
||||||
// contact that arrives is imported and uploaded automatically like any log entry.
|
// contact that arrives is imported and uploaded automatically like any log entry.
|
||||||
@@ -647,7 +626,91 @@ function ADIFMonitorPanel() {
|
|||||||
type RelayRuleUI = { device_id: string; relay: number; mode: string; freq_lo_khz: number; freq_hi_khz: number; bands: string[] };
|
type RelayRuleUI = { device_id: string; relay: number; mode: string; freq_lo_khz: number; freq_hi_khz: number; bands: string[] };
|
||||||
type StationDevUI = { id: string; type: string; name: string; labels: string[] };
|
type StationDevUI = { id: string; type: string; name: string; labels: string[] };
|
||||||
const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
|
const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
|
||||||
const relayCountUI = (type: string) => (type === 'kmtronic' ? 8 : 5);
|
const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 5);
|
||||||
|
|
||||||
|
// Live SPE Expert amplifier status + OPERATE/STANDBY toggle. Module-scoped (not a
|
||||||
|
// nested component) so it isn't remounted on every parent render. Polls once a
|
||||||
|
// second while shown.
|
||||||
|
function SPEStatusCard() {
|
||||||
|
const [st, setSt] = useState<any>({ connected: false });
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = () => GetSPEStatus().then((s) => alive && setSt(s || {})).catch(() => {});
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
const operate = !!st.operate;
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border p-3 space-y-2 text-xs max-w-xl">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn('size-2 rounded-full', st.connected ? 'bg-success animate-pulse' : 'bg-danger')} />
|
||||||
|
<span className="font-semibold">{st.connected ? `SPE ${st.model || 'Expert'}` : 'SPE — not connected'}</span>
|
||||||
|
{!st.connected && st.last_error && <span className="text-danger truncate text-[10px]">{st.last_error}</span>}
|
||||||
|
<span className="flex-1" />
|
||||||
|
<Button size="sm" variant={operate ? 'default' : 'outline'} disabled={!st.connected}
|
||||||
|
onClick={() => SPESetOperate(!operate).catch(() => {})}
|
||||||
|
title="Toggle OPERATE / STANDBY">
|
||||||
|
{operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{st.connected && (
|
||||||
|
<div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]">
|
||||||
|
<div>{st.tx ? 'TX' : 'RX'}</div>
|
||||||
|
<div>Band {st.band}</div>
|
||||||
|
<div>Pwr {st.power_level}</div>
|
||||||
|
<div>{st.output_w} W</div>
|
||||||
|
<div>SWR {Number(st.swr_ant ?? 0).toFixed(2)}</div>
|
||||||
|
<div>{st.temp_c}°C</div>
|
||||||
|
<div>{st.volt_pa} V</div>
|
||||||
|
<div>{st.curr_pa} A</div>
|
||||||
|
{(st.warnings || st.alarms) && <div className="col-span-4 text-warning">⚠ {st.warnings} {st.alarms}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Live ACOM amplifier status + OPERATE/STANDBY. Module-scoped for the same
|
||||||
|
// remount reason as SPEStatusCard. Polls once a second while shown.
|
||||||
|
function ACOMStatusCard() {
|
||||||
|
const [st, setSt] = useState<any>({ connected: false });
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = () => GetACOMStatus().then((s) => alive && setSt(s || {})).catch(() => {});
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
const operate = !!st.operate;
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border p-3 space-y-2 text-xs max-w-xl">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn('size-2 rounded-full', st.connected ? 'bg-success animate-pulse' : 'bg-danger')} />
|
||||||
|
<span className="font-semibold">{st.connected ? `ACOM ${st.model || ''}` : `ACOM ${st.model || ''} — not connected`}</span>
|
||||||
|
{!st.connected && st.last_error && <span className="text-danger truncate text-[10px]">{st.last_error}</span>}
|
||||||
|
<span className="flex-1" />
|
||||||
|
<Button size="sm" variant={operate ? 'default' : 'outline'} disabled={!st.connected}
|
||||||
|
onClick={() => ACOMSetOperate(!operate).catch(() => {})}
|
||||||
|
title="Toggle OPERATE / STANDBY">
|
||||||
|
{operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{st.connected && (
|
||||||
|
<div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]">
|
||||||
|
<div>{st.state}</div>
|
||||||
|
<div>Band {st.band || '—'}</div>
|
||||||
|
<div>{st.fwd_w} W</div>
|
||||||
|
<div>SWR {Number(st.swr ?? 0).toFixed(2)}</div>
|
||||||
|
<div>Refl {st.refl_w} W</div>
|
||||||
|
<div>Drive {st.drive_w} W</div>
|
||||||
|
<div>{st.temp_c}°C</div>
|
||||||
|
<div>Fan {st.fan}</div>
|
||||||
|
{st.err_text && <div className="col-span-4 text-warning">⚠ {st.err_text} ({st.err_code})</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
function RelayAutoPanel() {
|
function RelayAutoPanel() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [enabled, setEnabled] = useState(false);
|
const [enabled, setEnabled] = useState(false);
|
||||||
@@ -698,7 +761,7 @@ function RelayAutoPanel() {
|
|||||||
<div key={dev.id} className="rounded-md border border-border">
|
<div key={dev.id} className="rounded-md border border-border">
|
||||||
<div className="px-3 py-1.5 border-b border-border bg-muted/40 text-xs font-semibold">{dev.name || dev.id}</div>
|
<div className="px-3 py-1.5 border-b border-border bg-muted/40 text-xs font-semibold">{dev.name || dev.id}</div>
|
||||||
<div className="divide-y divide-border/60">
|
<div className="divide-y divide-border/60">
|
||||||
{Array.from({ length: relayCountUI(dev.type) }, (_, i) => i + 1).map((relay) => {
|
{Array.from({ length: (dev.labels?.length || relayCountUI(dev.type)) }, (_, i) => i + 1).map((relay) => {
|
||||||
const r = ruleFor(dev.id, relay);
|
const r = ruleFor(dev.id, relay);
|
||||||
const label = (dev.labels?.[relay - 1] || '').trim() || `${t('relayauto.relay')} ${relay}`;
|
const label = (dev.labels?.[relay - 1] || '').trim() || `${t('relayauto.relay')} ${relay}`;
|
||||||
return (
|
return (
|
||||||
@@ -1013,8 +1076,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
|
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
|
||||||
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
||||||
|
|
||||||
// PowerGenius XL (4O3A) amp fan-control settings.
|
// Amplifier control settings (PowerGenius XL over TCP; SPE Expert over serial/IP).
|
||||||
const [pgxl, setPgxl] = useState<{ enabled: boolean; host: string; port: number }>({ enabled: false, host: '', port: 9008 });
|
const [pgxl, setPgxl] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number }>(
|
||||||
|
{ enabled: false, type: 'pgxl', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200 });
|
||||||
|
|
||||||
// WinKeyer CW keyer settings + macro editor.
|
// WinKeyer CW keyer settings + macro editor.
|
||||||
type WKMac = { label: string; text: string };
|
type WKMac = { label: string; text: string };
|
||||||
@@ -2645,36 +2709,143 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PGXLPanelSettings() {
|
function PGXLPanelSettings() {
|
||||||
|
const isPGXL = pgxl.type === 'pgxl';
|
||||||
|
const isACOM = (pgxl.type || '').startsWith('acom');
|
||||||
|
const isSerial = pgxl.transport === 'serial';
|
||||||
|
// The stored `type` stays the single flat value ("spe13", "acom700", "pgxl" —
|
||||||
|
// binding compatibility); the UI presents it as brand + model.
|
||||||
|
const brand = isPGXL ? 'pgxl' : isACOM ? 'acom' : 'spe';
|
||||||
|
const brandModels: Record<string, { value: string; label: string }[]> = {
|
||||||
|
spe: [
|
||||||
|
{ value: 'spe13', label: 'Expert 1.3K-FA' },
|
||||||
|
{ value: 'spe15', label: 'Expert 1.5K-FA' },
|
||||||
|
{ value: 'spe2k', label: 'Expert 2K-FA' },
|
||||||
|
],
|
||||||
|
acom: [
|
||||||
|
{ value: 'acom500', label: '500S' },
|
||||||
|
{ value: 'acom600', label: '600S' },
|
||||||
|
{ value: 'acom700', label: '700S' },
|
||||||
|
{ value: 'acom1200', label: '1200S' },
|
||||||
|
{ value: 'acom2020', label: '2020S' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
// Each family has a fixed serial speed: SPE talks 115200, the ACOM S-series is
|
||||||
|
// 9600 8N1 — preset it so switching brand just works. PGXL is TCP-only.
|
||||||
|
const applyType = (v: string) => setPgxl((s) => ({
|
||||||
|
...s, type: v,
|
||||||
|
transport: v === 'pgxl' ? 'tcp' : s.transport,
|
||||||
|
baud: v.startsWith('acom') ? 9600 : v.startsWith('spe') ? 115200 : s.baud,
|
||||||
|
}));
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader title="Amplifier" />
|
||||||
title="Power Genius XL"
|
|
||||||
/>
|
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={pgxl.enabled} onCheckedChange={(c) => setPgxl((s) => ({ ...s, enabled: !!c }))} />
|
<Checkbox checked={pgxl.enabled} onCheckedChange={(c) => setPgxl((s) => ({ ...s, enabled: !!c }))} />
|
||||||
Enable PowerGenius fan control
|
Enable amplifier control
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{/* Connection gets the widest column — "Network (RS232-to-Ethernet)" was
|
||||||
|
truncated with 3 equal columns. */}
|
||||||
|
<div className="grid grid-cols-[1fr_1fr_1.7fr] gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Brand</Label>
|
||||||
|
<Select value={brand} onValueChange={(b) => {
|
||||||
|
if (b === brand) return;
|
||||||
|
applyType(b === 'pgxl' ? 'pgxl' : brandModels[b][0].value);
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="pgxl">4O3A</SelectItem>
|
||||||
|
<SelectItem value="spe">SPE</SelectItem>
|
||||||
|
<SelectItem value="acom">ACOM</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Model</Label>
|
||||||
|
{isPGXL ? (
|
||||||
|
// The PowerGenius is the brand's single model — fixed, no choice.
|
||||||
|
<Select value="pgxl" disabled>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="pgxl">PowerGenius XL</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Select value={pgxl.type} onValueChange={applyType}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{brandModels[brand].map((m) => <SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* PowerGenius is TCP-only; SPE and ACOM amps connect over USB serial or
|
||||||
|
an RS232-to-Ethernet bridge, so they offer both. */}
|
||||||
|
{!isPGXL && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Connection</Label>
|
||||||
|
<Select value={pgxl.transport} onValueChange={(v) => setPgxl((s) => ({ ...s, transport: v }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="serial">USB (serial COM)</SelectItem>
|
||||||
|
<SelectItem value="tcp">Network (RS232-to-Ethernet)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSerial ? (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>COM port</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={pgxl.com_port || '_'} onValueChange={(v) => setPgxl((s) => ({ ...s, com_port: v === '_' ? '' : v }))}>
|
||||||
|
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||||
|
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
|
<ArrowDown className="size-3.5 rotate-90" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Baud</Label>
|
||||||
|
<Input type="number" min={1200} value={pgxl.baud}
|
||||||
|
onChange={(e) => setPgxl((s) => ({ ...s, baud: parseInt(e.target.value) || 115200 }))} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1 col-span-2">
|
||||||
<Label>Host / IP</Label>
|
<Label>Host / IP</Label>
|
||||||
<Input
|
<Input value={pgxl.host ?? ''} onChange={(e) => setPgxl((s) => ({ ...s, host: e.target.value }))}
|
||||||
value={pgxl.host ?? ''}
|
placeholder="192.168.1.70" className="font-mono" />
|
||||||
onChange={(e) => setPgxl((s) => ({ ...s, host: e.target.value }))}
|
|
||||||
placeholder="192.168.1.70"
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>TCP port</Label>
|
<Label>TCP port</Label>
|
||||||
<Input
|
<Input type="number" min={1} max={65535} value={pgxl.port}
|
||||||
type="number" min={1} max={65535}
|
onChange={(e) => setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9008 }))} className="font-mono" />
|
||||||
value={pgxl.port}
|
|
||||||
onChange={(e) => setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9008 }))}
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isPGXL && pgxl.enabled && (isACOM ? <ACOMStatusCard /> : <SPEStatusCard />)}
|
||||||
|
{!isPGXL && !isACOM && (
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
SPE control uses the amplifier's proprietary serial protocol (OPERATE toggle + live status). Save to (re)connect. Band / power-level / antenna are still managed on the amp from the transceiver CAT.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isACOM && (
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
ACOM control uses the amplifier's serial protocol (9600 8N1): OPERATE / STANDBY / OFF + live telemetry. Save to (re)connect. Power-ON needs the serial DTR/RTS pins wired in the cable (not available over a network bridge). Band tracking stays on the amp itself.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -2807,6 +2978,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="winkeyer">WinKeyer (serial)</SelectItem>
|
<SelectItem value="winkeyer">WinKeyer (serial)</SelectItem>
|
||||||
<SelectItem value="icom">Icom CI-V (rig keyer)</SelectItem>
|
<SelectItem value="icom">Icom CI-V (rig keyer)</SelectItem>
|
||||||
|
<SelectItem value="flex">FlexRadio (CWX)</SelectItem>
|
||||||
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
|
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -2837,6 +3009,26 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
) : wk.engine === 'flex' ? (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-muted-foreground -mt-2">
|
||||||
|
FlexRadio keys CW through the radio's <strong>CWX</strong> keyer over the existing SmartSDR CAT connection — no WinKeyer or SmartCAT needed. It reuses the connection set in Settings → CAT, so there's nothing else to wire up here. Put a slice in CW mode. Only the speed is set from here; weight, sidetone and break-in are configured on the radio (break-in must be on for CW to actually transmit).
|
||||||
|
</p>
|
||||||
|
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
|
||||||
|
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
||||||
|
<span aria-hidden>⚠</span>
|
||||||
|
<span>
|
||||||
|
Your CAT backend is set to <strong>{catCfg.enabled ? (catCfg.backend || 'none') : 'disabled'}</strong>. Flex CWX needs the CAT backend set to <strong>FlexRadio</strong> and connected — change it under Settings → CAT interface, otherwise sending CW will fail.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Speed (WPM)</Label>
|
||||||
|
<Input type="number" min={6} max={48} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
@@ -3978,6 +4170,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
setDbMsg(p);
|
setDbMsg(p);
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
// Rename the CURRENT database (keeps all config), unlike New database which
|
||||||
|
// starts empty. The old file is removed on the next launch.
|
||||||
|
async function renameDb() {
|
||||||
|
try {
|
||||||
|
const p = await PickSaveDatabase();
|
||||||
|
if (!p) return;
|
||||||
|
await RenameDatabase(p);
|
||||||
|
await refreshDb();
|
||||||
|
setDbMsg(p);
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
async function resetDefault() {
|
async function resetDefault() {
|
||||||
try {
|
try {
|
||||||
await ResetDatabaseToDefault();
|
await ResetDatabaseToDefault();
|
||||||
@@ -4056,6 +4259,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
|
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
|
||||||
<Button variant="outline" size="sm" onClick={openExisting}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
|
<Button variant="outline" size="sm" onClick={openExisting}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={renameDb} title={t('db.renameTip')}><Pencil className="size-3.5" /> {t('db.rename')}</Button>
|
||||||
<Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
|
<Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
|
||||||
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
|
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
|
||||||
</div>
|
</div>
|
||||||
@@ -4384,7 +4588,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
{t('gen.checkUpdates')} <span className="text-xs text-muted-foreground">{t('gen.checkUpdatesHint')}</span>
|
{t('gen.checkUpdates')} <span className="text-xs text-muted-foreground">{t('gen.checkUpdatesHint')}</span>
|
||||||
</label>
|
</label>
|
||||||
<TelemetryToggle />
|
<TelemetryToggle />
|
||||||
<LiveStatusToggle />
|
|
||||||
|
|
||||||
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} icomAvailable={icomAvailable} />
|
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} icomAvailable={icomAvailable} />
|
||||||
|
|
||||||
|
|||||||
@@ -12,16 +12,22 @@ 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, 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 };
|
||||||
|
|
||||||
type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; labels: string[] };
|
type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; channels?: number; labels: string[] };
|
||||||
type Relay = { number: number; label: string; on: boolean };
|
type Relay = { number: number; label: string; on: boolean };
|
||||||
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
||||||
|
|
||||||
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8 };
|
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8 };
|
||||||
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay' };
|
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)' };
|
||||||
|
|
||||||
|
// Relay count for a configured device: fixed by type, except Denkovi (4/8) and
|
||||||
|
// the generic USB-serial board, whose channel count the user picks.
|
||||||
|
const chanCount = (d: Device): number =>
|
||||||
|
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8) : (RELAY_COUNT[d.type] ?? 5);
|
||||||
|
|
||||||
function blankDevice(): Device {
|
function blankDevice(): Device {
|
||||||
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
||||||
@@ -445,11 +451,56 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
|
useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
|
||||||
const setType = (type: string) => {
|
const setType = (type: string) => {
|
||||||
const n = RELAY_COUNT[type] ?? 5;
|
const channels = (type === 'denkovi' || type === 'usbrelay') ? (device.channels || 8) : undefined;
|
||||||
|
const n = chanCount({ ...device, type, channels });
|
||||||
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||||||
onChange({ ...device, type, labels });
|
onChange({ ...device, type, channels, labels });
|
||||||
|
};
|
||||||
|
const setChannels = (channels: number) => {
|
||||||
|
const n = chanCount({ ...device, channels });
|
||||||
|
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||||||
|
onChange({ ...device, channels, labels });
|
||||||
};
|
};
|
||||||
const isKM = device.type === 'kmtronic';
|
const isKM = device.type === 'kmtronic';
|
||||||
|
const isDenkovi = device.type === 'denkovi';
|
||||||
|
const isUsbRelay = device.type === 'usbrelay';
|
||||||
|
// COM ports for the generic USB-serial relay picker.
|
||||||
|
const [serialPorts, setSerialPorts] = useState<string[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isUsbRelay) return;
|
||||||
|
ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => setSerialPorts([]));
|
||||||
|
}, [isUsbRelay]);
|
||||||
|
// Detected FTDI serials for the Denkovi picker.
|
||||||
|
const [denkoviSerials, setDenkoviSerials] = useState<string[]>([]);
|
||||||
|
const [detecting, setDetecting] = useState(false);
|
||||||
|
const [detectMsg, setDetectMsg] = useState('');
|
||||||
|
const detectDenkovi = async () => {
|
||||||
|
setDetecting(true);
|
||||||
|
setDetectMsg('');
|
||||||
|
try {
|
||||||
|
const list = ((await ListDenkoviDevices()) ?? []) as string[];
|
||||||
|
setDenkoviSerials(list);
|
||||||
|
// Auto-fill when there's exactly one and nothing chosen yet.
|
||||||
|
if (list.length >= 1 && !device.host.trim()) onChange({ ...device, host: list[0] });
|
||||||
|
setDetectMsg(list.length === 0 ? t('station.detectNone') : t('station.detectFound', { n: list.length }));
|
||||||
|
} catch (e: any) { setDenkoviSerials([]); setDetectMsg(String(e?.message || e || t('station.detectNone'))); }
|
||||||
|
finally { setDetecting(false); }
|
||||||
|
};
|
||||||
|
// Connection test result for the current device config.
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [testMsg, setTestMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
|
const testDevice = async () => {
|
||||||
|
setTesting(true);
|
||||||
|
setTestMsg(null);
|
||||||
|
try {
|
||||||
|
const r: any = await TestStationDevice(device as any);
|
||||||
|
setTestMsg(r?.ok
|
||||||
|
? { ok: true, text: t('station.testOk', { n: r.relays }) }
|
||||||
|
: { ok: false, text: r?.error || t('station.testFail') });
|
||||||
|
} catch (e: any) { setTestMsg({ ok: false, text: String(e?.message || e || t('station.testFail')) }); }
|
||||||
|
finally { setTesting(false); }
|
||||||
|
};
|
||||||
|
useEffect(() => { if (isDenkovi) void detectDenkovi(); /* eslint-disable-next-line */ }, [isDenkovi]);
|
||||||
return (
|
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">
|
||||||
<div className="text-sm font-semibold">{device.id ? t('station.editDevice') : t('station.addDevice')}</div>
|
<div className="text-sm font-semibold">{device.id ? t('station.editDevice') : t('station.addDevice')}</div>
|
||||||
@@ -461,6 +512,8 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
||||||
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
||||||
|
<SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
|
||||||
|
<SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -469,6 +522,55 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{(isDenkovi || isUsbRelay) && (
|
||||||
|
<div className="space-y-1 max-w-[10rem]">
|
||||||
|
<Label>{t('station.channels')}</Label>
|
||||||
|
<Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(isDenkovi ? [4, 8] : [1, 2, 4, 8, 16]).map((n) => (
|
||||||
|
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isDenkovi ? (
|
||||||
|
<div className="space-y-1 max-w-md">
|
||||||
|
<Label>{t('station.ftdiSerial')}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input className="font-mono flex-1" value={device.host} placeholder="DAE0006K"
|
||||||
|
list="denkovi-serials"
|
||||||
|
onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||||||
|
<datalist id="denkovi-serials">
|
||||||
|
{denkoviSerials.map((s) => <option key={s} value={s} />)}
|
||||||
|
</datalist>
|
||||||
|
<Button size="sm" variant="outline" onClick={detectDenkovi} disabled={detecting}>
|
||||||
|
{detecting ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
||||||
|
{t('station.detect')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{t('station.ftdiHint')}</p>
|
||||||
|
{detectMsg && <p className="text-[11px] font-medium text-muted-foreground">{detectMsg}</p>}
|
||||||
|
</div>
|
||||||
|
) : isUsbRelay ? (
|
||||||
|
<div className="space-y-1 max-w-md">
|
||||||
|
<Label>{t('station.comPort')}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={device.host || '_'} onValueChange={(v) => onChange({ ...device, host: v === '_' ? '' : v })}>
|
||||||
|
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{serialPorts.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
|
||||||
|
{serialPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
|
<RefreshCw className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
|
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
|
||||||
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
|
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
|
||||||
<Label>{t('station.host')}</Label>
|
<Label>{t('station.host')}</Label>
|
||||||
@@ -487,6 +589,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('station.labels')}</Label>
|
<Label>{t('station.labels')}</Label>
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
@@ -496,10 +599,22 @@ 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">
|
||||||
|
{testMsg && (
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 text-xs font-medium', testMsg.ok ? 'text-success' : 'text-danger')}>
|
||||||
|
<span className={cn('size-2 rounded-full', testMsg.ok ? 'bg-success' : 'bg-danger')} />
|
||||||
|
{testMsg.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={testDevice} disabled={testing || !device.host.trim()}>
|
||||||
|
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <PlugZap className="size-3.5 mr-1" />}
|
||||||
|
{t('station.test')}
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
|
<Button size="sm" 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>
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,14 +23,16 @@ import { cn } from '@/lib/utils';
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type Bucket = { key: string; count: number };
|
type Bucket = { key: string; count: number };
|
||||||
|
type BandCat = { band: string; cw: number; phone: number; data: number; total: number };
|
||||||
type Gap = { start: string; end: string; minutes: number };
|
type Gap = { start: string; end: string; minutes: number };
|
||||||
type ContestRun = { id: string; year: number; count: number; start: string; end: string };
|
type ContestRun = { id: string; year: number; count: number; start: string; end: string };
|
||||||
type Stats = {
|
type Stats = {
|
||||||
total: number; unique_calls: number; entities: number; continents: number;
|
total: number; unique_calls: number; entities: number; continents: number;
|
||||||
first_qso: string; last_qso: string;
|
first_qso: string; last_qso: string;
|
||||||
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
|
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
|
||||||
by_mode: Bucket[]; by_band: Bucket[]; by_operator: Bucket[]; by_station: Bucket[];
|
by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
|
||||||
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
||||||
|
by_hour: Bucket[]; by_day7: Bucket[]; by_day30: Bucket[]; by_month12: Bucket[];
|
||||||
// Period / contest metrics.
|
// Period / contest metrics.
|
||||||
window_start: string; window_end: string; window_hours: number;
|
window_start: string; window_end: string; window_hours: number;
|
||||||
avg_per_hour: number; avg_per_active: number;
|
avg_per_hour: number; avg_per_active: number;
|
||||||
@@ -157,6 +159,47 @@ function VBars({ data, empty, height = 150, showValues, colorful }: { data: Buck
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-band mode split (CW / phone / data) as stacked vertical bars. The three
|
||||||
|
// categories are the subject → categorical colour in a fixed order, matching the
|
||||||
|
// mode colours used elsewhere (CW gold, phone green, data blue).
|
||||||
|
const BAND_SPLIT = [
|
||||||
|
{ key: 'cw', label: 'CW', color: 'var(--chart-3)' },
|
||||||
|
{ key: 'phone', label: 'Phone', color: 'var(--chart-2)' },
|
||||||
|
{ key: 'data', label: 'Data', color: 'var(--chart-1)' },
|
||||||
|
] as const;
|
||||||
|
function StackedBandBars({ data, empty, height = 150 }: { data: BandCat[]; empty: string; height?: number }) {
|
||||||
|
if (!data || data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
const peak = Math.max(1, ...data.map((d) => d.total));
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-end gap-[3px] min-w-0" style={{ height }}>
|
||||||
|
{data.map((d) => (
|
||||||
|
<div key={d.band} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
|
||||||
|
title={`${d.band} — CW ${nf(d.cw)} · Phone ${nf(d.phone)} · Data ${nf(d.data)} (${nf(d.total)})`}>
|
||||||
|
<span className="text-[9px] font-semibold mb-0.5 tabular-nums text-foreground">{nf(d.total)}</span>
|
||||||
|
<div className="w-full flex flex-col justify-end rounded-t-[4px] overflow-hidden"
|
||||||
|
style={{ height: `${Math.max(2, (d.total / peak) * 100)}%` }}>
|
||||||
|
{BAND_SPLIT.map((s) => {
|
||||||
|
const v = d[s.key];
|
||||||
|
if (!v) return null;
|
||||||
|
return <div key={s.key} style={{ flexGrow: v, flexBasis: 0, minHeight: 1, background: s.color }} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">{d.band}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-center gap-3 text-[10px] text-muted-foreground">
|
||||||
|
{BAND_SPLIT.map((s) => (
|
||||||
|
<span key={s.key} className="inline-flex items-center gap-1">
|
||||||
|
<span className="size-2.5 rounded-[3px]" style={{ background: s.color }} /> {s.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Contest rate, stacked by operator ────────────────────────────────────────
|
// ── Contest rate, stacked by operator ────────────────────────────────────────
|
||||||
// The categories (the operators) ARE the subject, so this is categorical colour,
|
// The categories (the operators) ARE the subject, so this is categorical colour,
|
||||||
// in the FIXED order the backend sends (busiest first). An operator therefore
|
// in the FIXED order the backend sends (busiest first). An operator therefore
|
||||||
@@ -462,6 +505,38 @@ function periodRange(p: Period, from: string, to: string): [string, string] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ActivityCard — the "activity over time" chart with a granularity selector. Its
|
||||||
|
// own state, module-scoped so a parent re-render doesn't reset the choice. The
|
||||||
|
// timeline is the chronological month series; day/week/month/year are the cyclical
|
||||||
|
// distributions (hour-of-day, day-of-week, day-of-month, month-of-year).
|
||||||
|
const ACT_GRAN = [
|
||||||
|
{ key: 'timeline', tkey: 'stats.granTimeline' },
|
||||||
|
{ key: 'day', tkey: 'stats.granDay' },
|
||||||
|
{ key: 'week', tkey: 'stats.granWeek' },
|
||||||
|
{ key: 'month', tkey: 'stats.granMonth' },
|
||||||
|
{ key: 'year', tkey: 'stats.granYear' },
|
||||||
|
] as const;
|
||||||
|
function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
|
||||||
|
const [g, setG] = useState<string>('timeline');
|
||||||
|
const series = g === 'day' ? stats.by_hour : g === 'week' ? stats.by_day7 : g === 'month' ? stats.by_day30 : g === 'year' ? stats.by_month12 : [];
|
||||||
|
return (
|
||||||
|
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
|
||||||
|
<div className="mb-2 flex flex-wrap gap-1">
|
||||||
|
{ACT_GRAN.map((o) => (
|
||||||
|
<button key={o.key} type="button" onClick={() => setG(o.key)}
|
||||||
|
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||||
|
g === o.key ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||||
|
{t(o.tkey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{g === 'timeline'
|
||||||
|
? <AreaTrend data={stats.by_month} empty={empty} />
|
||||||
|
: <VBars data={series} empty={empty} showValues height={160} />}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
|
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
|
||||||
|
|
||||||
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
|
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
|
||||||
@@ -524,9 +599,10 @@ export function StatsPanel() {
|
|||||||
const arr = (v: any) => (Array.isArray(v) ? v : []);
|
const arr = (v: any) => (Array.isArray(v) ? v : []);
|
||||||
setStats({
|
setStats({
|
||||||
...raw,
|
...raw,
|
||||||
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_operator: arr(raw.by_operator),
|
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_band_category: arr(raw.by_band_category), by_operator: arr(raw.by_operator),
|
||||||
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
||||||
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
|
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
|
||||||
|
by_hour: arr(raw.by_hour), by_day7: arr(raw.by_day7), by_day30: arr(raw.by_day30), by_month12: arr(raw.by_month12),
|
||||||
rate: arr(raw.rate), gaps: arr(raw.gaps),
|
rate: arr(raw.rate), gaps: arr(raw.gaps),
|
||||||
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
|
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
|
||||||
} as Stats);
|
} as Stats);
|
||||||
@@ -734,16 +810,14 @@ 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.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
|
<ActivityCard stats={stats} t={t} empty={empty} />
|
||||||
<AreaTrend data={stats.by_month} empty={empty} />
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
||||||
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ interface Props {
|
|||||||
wpm: number;
|
wpm: number;
|
||||||
macros: WKMacro[];
|
macros: WKMacro[];
|
||||||
sent: string; // text echoed back by the keyer as it transmits
|
sent: string; // text echoed back by the keyer as it transmits
|
||||||
source: 'winkeyer' | 'icom'; // CW output engine (chosen in Settings → CW Keyer)
|
source: 'winkeyer' | 'icom' | 'flex'; // CW output engine (chosen in Settings → CW Keyer)
|
||||||
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||||
onSetBreakIn?: (mode: number) => void;
|
onSetBreakIn?: (mode: number) => void;
|
||||||
onSelectPort: (p: string) => void;
|
onSelectPort: (p: string) => void;
|
||||||
@@ -101,14 +101,16 @@ export function WinkeyerPanel({
|
|||||||
<Radio className="size-4 text-primary shrink-0" />
|
<Radio className="size-4 text-primary shrink-0" />
|
||||||
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||||
{source === 'icom' ? 'Icom CW' : 'WinKeyer'}
|
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : 'WinKeyer'}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
||||||
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{source === 'icom' ? (
|
{source === 'icom' || source === 'flex' ? (
|
||||||
<span className="text-[11px] font-medium text-muted-foreground">
|
<span className="text-[11px] font-medium text-muted-foreground">
|
||||||
{connected ? t('wkp.civReady') : t('wkp.civOffline')}
|
{source === 'flex'
|
||||||
|
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
||||||
|
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
||||||
</span>
|
</span>
|
||||||
) : !connected ? (
|
) : !connected ? (
|
||||||
<>
|
<>
|
||||||
@@ -183,7 +185,7 @@ export function WinkeyerPanel({
|
|||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
|
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
|
||||||
{t('wkp.cwText')}
|
{t('wkp.cwText')}
|
||||||
{source === 'winkeyer' && (
|
{(source === 'winkeyer' || source === 'flex') && (
|
||||||
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
||||||
title={t('wkp.sendOnTypeHint')}>
|
title={t('wkp.sendOnTypeHint')}>
|
||||||
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
||||||
|
|||||||
@@ -6,11 +6,32 @@
|
|||||||
// back to the DB copy and re-seed the cache.
|
// back to the DB copy and re-seed the cache.
|
||||||
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// The DB copy is ALREADY per-profile (the backend prefixes every ui.* key with
|
||||||
|
// the active profile id). The localStorage cache, however, is one namespace for
|
||||||
|
// the whole WebView, so without scoping it too a profile switch would keep
|
||||||
|
// serving the previous profile's cached layout and the correct per-profile DB
|
||||||
|
// value would never win. lsScope makes the cache per-profile as well; it's set
|
||||||
|
// once the active profile is known and updated on every profile switch.
|
||||||
|
let lsScope = '';
|
||||||
|
|
||||||
|
// setGridPrefsProfile scopes the localStorage cache to a profile. Call it before
|
||||||
|
// the grids read their state (at startup) and again whenever the active profile
|
||||||
|
// changes so each profile keeps its own column layout / widths.
|
||||||
|
export function setGridPrefsProfile(id: number | string | null | undefined): void {
|
||||||
|
lsScope = id == null || id === '' ? '' : `p${id}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// lsKey scopes ONLY the localStorage cache key. The DB key passed to
|
||||||
|
// GetUIPref/SetUIPref is left untouched — the backend already scopes it.
|
||||||
|
function lsKey(key: string): string {
|
||||||
|
return lsScope + key;
|
||||||
|
}
|
||||||
|
|
||||||
// loadLocal reads the cached column state synchronously (used in onGridReady
|
// loadLocal reads the cached column state synchronously (used in onGridReady
|
||||||
// to apply instantly, before the async DB round-trip).
|
// to apply instantly, before the async DB round-trip).
|
||||||
export function loadLocal(key: string): any[] | null {
|
export function loadLocal(key: string): any[] | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(key);
|
const raw = localStorage.getItem(lsKey(key));
|
||||||
const v = raw ? JSON.parse(raw) : null;
|
const v = raw ? JSON.parse(raw) : null;
|
||||||
return Array.isArray(v) ? v : null;
|
return Array.isArray(v) ? v : null;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -29,15 +50,16 @@ export async function loadRemote(key: string): Promise<any[] | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveState write-throughs to both the cache and the DB (fire-and-forget).
|
// saveState write-throughs to both the cache and the DB (fire-and-forget). Only
|
||||||
|
// the cache key is profile-scoped; the DB key is scoped by the backend.
|
||||||
export function saveState(key: string, state: any[]) {
|
export function saveState(key: string, state: any[]) {
|
||||||
const json = JSON.stringify(state);
|
const json = JSON.stringify(state);
|
||||||
try { localStorage.setItem(key, json); } catch { /* quota / private mode */ }
|
try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ }
|
||||||
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
|
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedLocal writes a value into the cache without touching the DB (used after
|
// seedLocal writes a value into the cache without touching the DB (used after
|
||||||
// hydrating the cache from the DB on a fresh machine).
|
// hydrating the cache from the DB on a fresh machine).
|
||||||
export function seedLocal(key: string, state: any[]) {
|
export function seedLocal(key: string, state: any[]) {
|
||||||
try { localStorage.setItem(key, JSON.stringify(state)); } catch { /* ignore */ }
|
try { localStorage.setItem(lsKey(key), JSON.stringify(state)); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-21
@@ -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',
|
||||||
@@ -54,18 +54,18 @@ const en: Dict = {
|
|||||||
'btn.logQso': 'Log QSO', 'btn.clear': 'Clear', 'btn.spot': 'Spot', 'btn.saving': '…',
|
'btn.logQso': 'Log QSO', 'btn.clear': 'Clear', 'btn.spot': 'Spot', 'btn.saving': '…',
|
||||||
// Language chooser
|
// Language chooser
|
||||||
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
|
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
|
||||||
'lang.english': 'English', 'lang.french': 'Français',
|
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': "What's new", 'whatsnew.close': 'Got it', 'whatsnew.none': 'No changelog available for this version yet.',
|
||||||
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
|
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
|
||||||
'stats.tab': 'Statistics', 'stats.title': 'Logbook statistics', 'stats.loading': 'Crunching the log…',
|
'stats.tab': 'Statistics', 'stats.title': 'Logbook statistics', 'stats.loading': 'Crunching the log…',
|
||||||
'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh',
|
'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh',
|
||||||
'stats.qsos': 'QSOs', 'stats.uniqueCalls': 'Unique callsigns', 'stats.entities': 'Entities',
|
'stats.qsos': 'QSOs', 'stats.uniqueCalls': 'Unique callsigns', 'stats.entities': 'Entities',
|
||||||
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmed',
|
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmed',
|
||||||
'stats.byBand': 'By band', 'stats.byBandSub': 'In band-plan order, not by size',
|
'stats.byBand': 'By band', 'stats.byBandSub': 'In band-plan order, not by size', 'stats.bandSplit': 'Mode split by band', 'stats.bandSplitSub': 'CW / phone / data per band',
|
||||||
'stats.byMode': 'By mode', 'stats.byOperator': 'By operator',
|
'stats.byMode': 'By mode', 'stats.byOperator': 'By operator',
|
||||||
'stats.byOperatorSub': '“—” = logged by the station owner (no OPERATOR set)',
|
'stats.byOperatorSub': '“—” = logged by the station owner (no OPERATOR set)',
|
||||||
'stats.byStation': 'By station callsign', 'stats.byContinent': 'By continent',
|
'stats.byStation': 'By station callsign', 'stats.byContinent': 'By continent',
|
||||||
'stats.topEntities': 'Top DXCC entities', 'stats.byYear': 'By year',
|
'stats.topEntities': 'Top DXCC entities', 'stats.byYear': 'By year',
|
||||||
'stats.overTime': 'Activity over time', 'stats.overTimeSub': 'QSOs per month',
|
'stats.overTime': 'Activity over time', 'stats.overTimeSub': 'Timeline, or the last day / 7 days / 30 days / 12 months', 'stats.granTimeline': 'Timeline', 'stats.granDay': 'Day', 'stats.granWeek': 'Week', 'stats.granMonth': 'Month', 'stats.granYear': 'Year',
|
||||||
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'Paper QSL',
|
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'Paper QSL',
|
||||||
|
|
||||||
'stats.byContinentSub': 'Share of the log',
|
'stats.byContinentSub': 'Share of the log',
|
||||||
@@ -92,8 +92,6 @@ const en: Dict = {
|
|||||||
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
|
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
|
||||||
'settings.telemetry': 'Send anonymous usage statistics',
|
'settings.telemetry': 'Send anonymous usage statistics',
|
||||||
'settings.telemetryHint': 'install ID + version + OS, once a day — no callsign or QSO data',
|
'settings.telemetryHint': 'install ID + version + OS, once a day — no callsign or QSO data',
|
||||||
'settings.liveStatus': 'Publish live operator status',
|
|
||||||
'settings.liveStatusHint': 'multi-op on shared MySQL — feeds a QRZ live page',
|
|
||||||
'settings.mainView': 'Main view',
|
'settings.mainView': 'Main view',
|
||||||
'settings.mainViewHint': 'Choose what the Main tab shows on each side (per profile).',
|
'settings.mainViewHint': 'Choose what the Main tab shows on each side (per profile).',
|
||||||
'settings.leftPane': 'Left pane', 'settings.rightPane': 'Right pane',
|
'settings.leftPane': 'Left pane', 'settings.rightPane': 'Right pane',
|
||||||
@@ -131,9 +129,9 @@ 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.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': 'Power Genius', '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',
|
||||||
'relayauto.hint': 'Automatically switch Station Control relays from the rig frequency / band (like PstRotator). Each relay: a frequency window (ON inside, OFF outside) or a set of bands. Relays are set up in the Station Control panel.',
|
'relayauto.hint': 'Automatically switch Station Control relays from the rig frequency / band (like PstRotator). Each relay: a frequency window (ON inside, OFF outside) or a set of bands. Relays are set up in the Station Control panel.',
|
||||||
'relayauto.enable': 'Enable relay auto-control',
|
'relayauto.enable': 'Enable relay auto-control',
|
||||||
@@ -244,7 +242,7 @@ const en: Dict = {
|
|||||||
'db.backend': 'Backend', 'db.configLocal': 'settings stay in the local SQLite file', 'db.connectUse': 'Connect & use',
|
'db.backend': 'Backend', 'db.configLocal': 'settings stay in the local SQLite file', 'db.connectUse': 'Connect & use',
|
||||||
'db.savedRestart': 'Saved. Restart OpsLog to open this logbook:', 'db.restartNow': 'Restart OpsLog', 'db.restartHint': '(reopens automatically)',
|
'db.savedRestart': 'Saved. Restart OpsLog to open this logbook:', 'db.restartNow': 'Restart OpsLog', 'db.restartHint': '(reopens automatically)',
|
||||||
'db.current': 'Current database', 'db.customLoc': '(custom location)', 'db.default': '(default)', 'db.defaultLabel': 'Default:',
|
'db.current': 'Current database', 'db.customLoc': '(custom location)', 'db.default': '(default)', 'db.defaultLabel': 'Default:',
|
||||||
'db.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.resetDefault': 'Reset to default', 'db.quitNow': 'Quit now',
|
'db.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.rename': 'Rename…', 'db.renameTip': 'Rename this database (keeps all your config). The old file is removed on the next launch.', 'db.resetDefault': 'Reset to default', 'db.quitNow': 'Quit now',
|
||||||
'db.host': 'Host', 'db.port': 'Port', 'db.database': 'Database', 'db.user': 'User', 'db.testCreate': 'Test & create database', 'db.testing': 'Testing…', 'db.connectedReady': 'Connected — database ready ✓', 'db.failed': 'Failed: ',
|
'db.host': 'Host', 'db.port': 'Port', 'db.database': 'Database', 'db.user': 'User', 'db.testCreate': 'Test & create database', 'db.testing': 'Testing…', 'db.connectedReady': 'Connected — database ready ✓', 'db.failed': 'Failed: ',
|
||||||
'db.mysqlHint': 'Several OpsLog instances pointed at one MySQL database see each other\'s QSOs live (refreshed every 2 s). Test & create the database, then Save & switch logbook above to start logging there.',
|
'db.mysqlHint': 'Several OpsLog instances pointed at one MySQL database see each other\'s QSOs live (refreshed every 2 s). Test & create the database, then Save & switch logbook above to start logging there.',
|
||||||
'db.dataLocation': 'Data location', 'db.currentDataDir': 'Current data directory',
|
'db.dataLocation': 'Data location', 'db.currentDataDir': 'Current data directory',
|
||||||
@@ -275,7 +273,7 @@ const en: Dict = {
|
|||||||
'adx.introPart1': 'Every ADIF 3.1.7 field not shown in the other tabs. Pick a field to add it, or type a custom/vendor tag (e.g. ', 'adx.introPart2': '). Stored losslessly and exported in the ', 'adx.fullMode': 'full', 'adx.introPart3': ' ADIF mode.', 'adx.addFieldPh': 'Add ADIF field…', 'adx.showDeprecated': 'Show deprecated', 'adx.noExtra': 'No extra ADIF fields. Use the picker above to add one.', 'adx.deprecated': 'deprecated', 'adx.intl': 'intl', 'adx.nonStandard': 'non-standard', 'adx.removeField': 'Remove field',
|
'adx.introPart1': 'Every ADIF 3.1.7 field not shown in the other tabs. Pick a field to add it, or type a custom/vendor tag (e.g. ', 'adx.introPart2': '). Stored losslessly and exported in the ', 'adx.fullMode': 'full', 'adx.introPart3': ' ADIF mode.', 'adx.addFieldPh': 'Add ADIF field…', 'adx.showDeprecated': 'Show deprecated', 'adx.noExtra': 'No extra ADIF fields. Use the picker above to add one.', 'adx.deprecated': 'deprecated', 'adx.intl': 'intl', 'adx.nonStandard': 'non-standard', 'adx.removeField': 'Remove field',
|
||||||
// Hardware panels (winkeyer / dvk / antgenius / flex / icom)
|
// Hardware panels (winkeyer / dvk / antgenius / flex / icom)
|
||||||
'wkp.sending': 'Sending…', 'wkp.connectedV': 'Connected (v{version})', 'wkp.disconnected': 'Disconnected', 'wkp.comPort': 'COM port', 'wkp.noPorts': 'No ports', 'wkp.refreshPorts': 'Refresh ports', 'wkp.connect': 'Connect', 'wkp.disconnect': 'Disconnect', 'wkp.hide': 'Hide / disable WinKeyer',
|
'wkp.sending': 'Sending…', 'wkp.connectedV': 'Connected (v{version})', 'wkp.disconnected': 'Disconnected', 'wkp.comPort': 'COM port', 'wkp.noPorts': 'No ports', 'wkp.refreshPorts': 'Refresh ports', 'wkp.connect': 'Connect', 'wkp.disconnect': 'Disconnect', 'wkp.hide': 'Hide / disable WinKeyer',
|
||||||
'wkp.sourceHint': 'CW output: WK = WinKeyer hardware · CI-V = the Icom rig’s own keyer (over CAT, no extra hardware)', 'wkp.civReady': 'Icom CI-V ready', 'wkp.civOffline': 'Icom not connected (Settings → CAT)',
|
'wkp.sourceHint': 'CW output: WK = WinKeyer hardware · CI-V = the Icom rig’s own keyer (over CAT, no extra hardware)', 'wkp.civReady': 'Icom CI-V ready', 'wkp.civOffline': 'Icom not connected (Settings → CAT)', 'wkp.cwxReady': 'Flex CWX ready', 'wkp.cwxOffline': 'Flex not connected (Settings → CAT)',
|
||||||
'wkp.cwSpeed': 'CW speed (WPM)', 'wkp.faster': 'Faster', 'wkp.slower': 'Slower', 'wkp.cwText': 'CW text', 'wkp.sendOnTypeHint': 'Key each character live as you type (backspace removes un-sent chars)', 'wkp.sendOnType': 'send on type', 'wkp.phLive': 'Type — sent live…', 'wkp.phEnter': 'Type and press Enter to send…', 'wkp.clear': 'Clear', 'wkp.send': 'Send', 'wkp.abort': 'Abort (clear keyer buffer)', 'wkp.stop': 'Stop',
|
'wkp.cwSpeed': 'CW speed (WPM)', 'wkp.faster': 'Faster', 'wkp.slower': 'Slower', 'wkp.cwText': 'CW text', 'wkp.sendOnTypeHint': 'Key each character live as you type (backspace removes un-sent chars)', 'wkp.sendOnType': 'send on type', 'wkp.phLive': 'Type — sent live…', 'wkp.phEnter': 'Type and press Enter to send…', 'wkp.clear': 'Clear', 'wkp.send': 'Send', 'wkp.abort': 'Abort (clear keyer buffer)', 'wkp.stop': 'Stop',
|
||||||
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL",
|
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL",
|
||||||
'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}',
|
'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}',
|
||||||
@@ -283,7 +281,7 @@ const en: Dict = {
|
|||||||
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band',
|
'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', 'flxp.acomOffline': 'ACOM offline',
|
||||||
'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope −50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?',
|
'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',
|
||||||
@@ -307,6 +305,10 @@ const en: Dict = {
|
|||||||
'awed.builtin': 'Built-in',
|
'awed.builtin': 'Built-in',
|
||||||
'awed.awardsFolder': 'Awards folder',
|
'awed.awardsFolder': 'Awards folder',
|
||||||
'awed.awardsFolderTip': 'Every award you create is saved here as JSON, automatically. To share one, send the file. To receive one, use Import.',
|
'awed.awardsFolderTip': 'Every award you create is saved here as JSON, automatically. To share one, send the file. To receive one, use Import.',
|
||||||
|
'awed.catalogPublish': 'Publish to catalog…',
|
||||||
|
'awed.catalogPublishTip': 'Export this award as a catalog file (with the version on the left), to paste over internal/award/catalog/<code>.json. A new release then ships it to the whole team — copies nobody edited auto-upgrade, edited ones are offered it.',
|
||||||
|
'awed.catalogExportedTo': 'Catalog file written to:\n{path}\n\nPaste it over internal/award/catalog/<code>.json, then build and release.',
|
||||||
|
'awed.catalogBadVersion': 'Enter a version number (1 or more).',
|
||||||
'awed.builtinTip': 'Tick before shipping this award in the catalog. Left off, a “Reset to defaults” DELETES it on the user machine — even though you shipped it.',
|
'awed.builtinTip': 'Tick before shipping this award in the catalog. Left off, a “Reset to defaults” DELETES it on the user machine — even though you shipped it.',
|
||||||
'awed.protectedFlag': 'Protected',
|
'awed.protectedFlag': 'Protected',
|
||||||
'awed.protectedTip': 'Protected awards cannot be deleted from the editor.',
|
'awed.protectedTip': 'Protected awards cannot be deleted from the editor.',
|
||||||
@@ -364,18 +366,19 @@ const fr: Dict = {
|
|||||||
'field.startUtc': 'Début UTC', 'field.endUtc': 'Fin UTC', 'field.snt': 'Env', 'field.rcv': 'Reç',
|
'field.startUtc': 'Début UTC', 'field.endUtc': 'Fin UTC', 'field.snt': 'Env', 'field.rcv': 'Reç',
|
||||||
'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',
|
'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',
|
||||||
'stats.qsos': 'QSO', 'stats.uniqueCalls': 'Indicatifs uniques', 'stats.entities': 'Entités',
|
'stats.qsos': 'QSO', 'stats.uniqueCalls': 'Indicatifs uniques', 'stats.entities': 'Entités',
|
||||||
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmés',
|
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmés',
|
||||||
'stats.byBand': 'Par bande', 'stats.byBandSub': "Dans l'ordre du plan de bande, pas par taille",
|
'stats.byBand': 'Par bande', 'stats.byBandSub': "Dans l'ordre du plan de bande, pas par taille", 'stats.bandSplit': 'Répartition des modes par bande', 'stats.bandSplitSub': 'CW / phone / data par bande',
|
||||||
'stats.byMode': 'Par mode', 'stats.byOperator': 'Par opérateur',
|
'stats.byMode': 'Par mode', 'stats.byOperator': 'Par opérateur',
|
||||||
'stats.byOperatorSub': '« — » = loggé par le titulaire (pas d’OPERATOR renseigné)',
|
'stats.byOperatorSub': '« — » = loggé par le titulaire (pas d’OPERATOR renseigné)',
|
||||||
'stats.byStation': 'Par indicatif de station', 'stats.byContinent': 'Par continent',
|
'stats.byStation': 'Par indicatif de station', 'stats.byContinent': 'Par continent',
|
||||||
'stats.topEntities': 'Top entités DXCC', 'stats.byYear': 'Par année',
|
'stats.topEntities': 'Top entités DXCC', 'stats.byYear': 'Par année',
|
||||||
'stats.overTime': 'Activité dans le temps', 'stats.overTimeSub': 'QSO par mois',
|
'stats.overTime': 'Activité dans le temps', 'stats.overTimeSub': 'Chronologie, ou les derniers jour / 7 jours / 30 jours / 12 mois', 'stats.granTimeline': 'Chronologie', 'stats.granDay': 'Jour', 'stats.granWeek': 'Semaine', 'stats.granMonth': 'Mois', 'stats.granYear': 'Année',
|
||||||
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'QSL papier',
|
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'QSL papier',
|
||||||
|
|
||||||
'stats.byContinentSub': 'Part du journal',
|
'stats.byContinentSub': 'Part du journal',
|
||||||
@@ -402,8 +405,6 @@ const fr: Dict = {
|
|||||||
'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.",
|
'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.",
|
||||||
'settings.telemetry': "Envoyer des statistiques d'usage anonymes",
|
'settings.telemetry': "Envoyer des statistiques d'usage anonymes",
|
||||||
'settings.telemetryHint': "ID d'installation + version + OS, une fois par jour — aucun indicatif ni donnée QSO",
|
'settings.telemetryHint': "ID d'installation + version + OS, une fois par jour — aucun indicatif ni donnée QSO",
|
||||||
'settings.liveStatus': 'Publier le statut opérateur en direct',
|
|
||||||
'settings.liveStatusHint': 'multi-op sur MySQL partagé — alimente une page live QRZ',
|
|
||||||
'settings.mainView': 'Vue principale',
|
'settings.mainView': 'Vue principale',
|
||||||
'settings.mainViewHint': "Choisis ce que l'onglet Principal affiche de chaque côté (par profil).",
|
'settings.mainViewHint': "Choisis ce que l'onglet Principal affiche de chaque côté (par profil).",
|
||||||
'settings.leftPane': 'Volet gauche', 'settings.rightPane': 'Volet droit',
|
'settings.leftPane': 'Volet gauche', 'settings.rightPane': 'Volet droit',
|
||||||
@@ -440,9 +441,9 @@ 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.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': 'Power Genius', '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',
|
||||||
'relayauto.hint': "Commute automatiquement les relais du Station Control selon la fréquence / bande du poste (comme PstRotator). Par relais : une plage de fréquence (ON dedans, OFF dehors) ou un ensemble de bandes. Les relais se configurent dans le panneau Station Control.",
|
'relayauto.hint': "Commute automatiquement les relais du Station Control selon la fréquence / bande du poste (comme PstRotator). Par relais : une plage de fréquence (ON dedans, OFF dehors) ou un ensemble de bandes. Les relais se configurent dans le panneau Station Control.",
|
||||||
'relayauto.enable': 'Activer les relais automatiques',
|
'relayauto.enable': 'Activer les relais automatiques',
|
||||||
@@ -540,7 +541,7 @@ const fr: Dict = {
|
|||||||
'db.backend': 'Base de données', 'db.configLocal': 'les réglages restent dans le fichier SQLite local', 'db.connectUse': 'Connecter et utiliser',
|
'db.backend': 'Base de données', 'db.configLocal': 'les réglages restent dans le fichier SQLite local', 'db.connectUse': 'Connecter et utiliser',
|
||||||
'db.savedRestart': 'Enregistré. Redémarrez OpsLog pour ouvrir cette base :', 'db.restartNow': 'Redémarrer OpsLog', 'db.restartHint': '(réouverture automatique)',
|
'db.savedRestart': 'Enregistré. Redémarrez OpsLog pour ouvrir cette base :', 'db.restartNow': 'Redémarrer OpsLog', 'db.restartHint': '(réouverture automatique)',
|
||||||
'db.current': 'Base actuelle', 'db.customLoc': '(emplacement personnalisé)', 'db.default': '(par défaut)', 'db.defaultLabel': 'Par défaut :',
|
'db.current': 'Base actuelle', 'db.customLoc': '(emplacement personnalisé)', 'db.default': '(par défaut)', 'db.defaultLabel': 'Par défaut :',
|
||||||
'db.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.resetDefault': 'Réinitialiser par défaut', 'db.quitNow': 'Quitter maintenant',
|
'db.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.rename': 'Renommer…', 'db.renameTip': 'Renomme cette base (garde toute ta config). L’ancien fichier est supprimé au prochain lancement.', 'db.resetDefault': 'Réinitialiser par défaut', 'db.quitNow': 'Quitter maintenant',
|
||||||
'db.host': 'Hôte', 'db.port': 'Port', 'db.database': 'Base', 'db.user': 'Utilisateur', 'db.testCreate': 'Tester & créer la base', 'db.testing': 'Test…', 'db.connectedReady': 'Connecté — base prête ✓', 'db.failed': 'Échec : ',
|
'db.host': 'Hôte', 'db.port': 'Port', 'db.database': 'Base', 'db.user': 'Utilisateur', 'db.testCreate': 'Tester & créer la base', 'db.testing': 'Test…', 'db.connectedReady': 'Connecté — base prête ✓', 'db.failed': 'Échec : ',
|
||||||
'db.mysqlHint': "Plusieurs instances d'OpsLog pointées vers une même base MySQL voient leurs QSO en direct (rafraîchi toutes les 2 s). Teste & crée la base, puis Enregistrer & basculer le journal ci-dessus pour commencer à logger là-bas.",
|
'db.mysqlHint': "Plusieurs instances d'OpsLog pointées vers une même base MySQL voient leurs QSO en direct (rafraîchi toutes les 2 s). Teste & crée la base, puis Enregistrer & basculer le journal ci-dessus pour commencer à logger là-bas.",
|
||||||
'db.dataLocation': 'Emplacement des données', 'db.currentDataDir': 'Dossier de données actuel',
|
'db.dataLocation': 'Emplacement des données', 'db.currentDataDir': 'Dossier de données actuel',
|
||||||
@@ -568,7 +569,7 @@ const fr: Dict = {
|
|||||||
'ctp.stopContest': 'Arrêter le contest', 'ctp.startContest': 'Démarrer le contest', 'ctp.activeHint': 'Le formulaire de saisie affiche Env/Reç et un badge DUPE ; les QSO sont marqués avec ce contest.', 'ctp.inactiveHint': 'Choisis un contest, règle la fenêtre, puis Démarrer.', 'ctp.scoreboard': 'Tableau des scores', 'ctp.estimate': 'estimation', 'ctp.qsos': 'QSO', 'ctp.mult': 'Mult', 'ctp.scoreEst': 'Score (est.)', 'ctp.last60': 'Dernières 60 min', 'ctp.band': 'Bande',
|
'ctp.stopContest': 'Arrêter le contest', 'ctp.startContest': 'Démarrer le contest', 'ctp.activeHint': 'Le formulaire de saisie affiche Env/Reç et un badge DUPE ; les QSO sont marqués avec ce contest.', 'ctp.inactiveHint': 'Choisis un contest, règle la fenêtre, puis Démarrer.', 'ctp.scoreboard': 'Tableau des scores', 'ctp.estimate': 'estimation', 'ctp.qsos': 'QSO', 'ctp.mult': 'Mult', 'ctp.scoreEst': 'Score (est.)', 'ctp.last60': 'Dernières 60 min', 'ctp.band': 'Bande',
|
||||||
'adx.introPart1': 'Tous les champs ADIF 3.1.7 non affichés dans les autres onglets. Choisis un champ à ajouter, ou saisis un tag personnalisé/constructeur (ex. ', 'adx.introPart2': '). Stocké sans perte et exporté en mode ADIF ', 'adx.fullMode': 'complet', 'adx.introPart3': '.', 'adx.addFieldPh': 'Ajouter un champ ADIF…', 'adx.showDeprecated': 'Afficher les obsolètes', 'adx.noExtra': 'Aucun champ ADIF supplémentaire. Utilise le sélecteur ci-dessus pour en ajouter un.', 'adx.deprecated': 'obsolète', 'adx.intl': 'intl', 'adx.nonStandard': 'non standard', 'adx.removeField': 'Supprimer le champ',
|
'adx.introPart1': 'Tous les champs ADIF 3.1.7 non affichés dans les autres onglets. Choisis un champ à ajouter, ou saisis un tag personnalisé/constructeur (ex. ', 'adx.introPart2': '). Stocké sans perte et exporté en mode ADIF ', 'adx.fullMode': 'complet', 'adx.introPart3': '.', 'adx.addFieldPh': 'Ajouter un champ ADIF…', 'adx.showDeprecated': 'Afficher les obsolètes', 'adx.noExtra': 'Aucun champ ADIF supplémentaire. Utilise le sélecteur ci-dessus pour en ajouter un.', 'adx.deprecated': 'obsolète', 'adx.intl': 'intl', 'adx.nonStandard': 'non standard', 'adx.removeField': 'Supprimer le champ',
|
||||||
'wkp.sending': 'Émission…', 'wkp.connectedV': 'Connecté (v{version})', 'wkp.disconnected': 'Déconnecté', 'wkp.comPort': 'Port COM', 'wkp.noPorts': 'Aucun port', 'wkp.refreshPorts': 'Rafraîchir les ports', 'wkp.connect': 'Connecter', 'wkp.disconnect': 'Déconnecter', 'wkp.hide': 'Masquer / désactiver le WinKeyer',
|
'wkp.sending': 'Émission…', 'wkp.connectedV': 'Connecté (v{version})', 'wkp.disconnected': 'Déconnecté', 'wkp.comPort': 'Port COM', 'wkp.noPorts': 'Aucun port', 'wkp.refreshPorts': 'Rafraîchir les ports', 'wkp.connect': 'Connecter', 'wkp.disconnect': 'Déconnecter', 'wkp.hide': 'Masquer / désactiver le WinKeyer',
|
||||||
'wkp.sourceHint': 'Sortie CW : WK = WinKeyer matériel · CI-V = le keyer interne de l’Icom (via CAT, sans matériel en plus)', 'wkp.civReady': 'Icom CI-V prêt', 'wkp.civOffline': 'Icom non connecté (Réglages → CAT)',
|
'wkp.sourceHint': 'Sortie CW : WK = WinKeyer matériel · CI-V = le keyer interne de l’Icom (via CAT, sans matériel en plus)', 'wkp.civReady': 'Icom CI-V prêt', 'wkp.civOffline': 'Icom non connecté (Réglages → CAT)', 'wkp.cwxReady': 'Flex CWX prêt', 'wkp.cwxOffline': 'Flex non connecté (Réglages → CAT)',
|
||||||
'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop',
|
'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop',
|
||||||
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL",
|
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL",
|
||||||
'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}',
|
'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}',
|
||||||
@@ -576,7 +577,7 @@ const fr: Dict = {
|
|||||||
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour n’afficher 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 n’afficher 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', 'flxp.acomOffline': 'ACOM hors ligne',
|
||||||
'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope −50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?',
|
'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',
|
||||||
@@ -598,6 +599,10 @@ const fr: Dict = {
|
|||||||
'awed.builtin': 'Intégré',
|
'awed.builtin': 'Intégré',
|
||||||
'awed.awardsFolder': 'Dossier awards',
|
'awed.awardsFolder': 'Dossier awards',
|
||||||
'awed.awardsFolderTip': 'Chaque diplôme que tu crées est enregistré ici en JSON, automatiquement. Pour en partager un : envoie le fichier. Pour en recevoir un : Importer.',
|
'awed.awardsFolderTip': 'Chaque diplôme que tu crées est enregistré ici en JSON, automatiquement. Pour en partager un : envoie le fichier. Pour en recevoir un : Importer.',
|
||||||
|
'awed.catalogPublish': 'Publier au catalogue…',
|
||||||
|
'awed.catalogPublishTip': "Exporte ce diplôme en fichier catalogue (avec la version à gauche), à coller par-dessus internal/award/catalog/<code>.json. Une nouvelle release le diffuse à toute l'équipe — les copies non modifiées s'upgradent seules, les modifiées se le voient proposer.",
|
||||||
|
'awed.catalogExportedTo': 'Fichier catalogue écrit dans :\n{path}\n\nColle-le par-dessus internal/award/catalog/<code>.json, puis build et release.',
|
||||||
|
'awed.catalogBadVersion': 'Entre un numéro de version (1 ou plus).',
|
||||||
'awed.builtinTip': 'À cocher avant de livrer ce diplôme dans le catalogue. Sans ça, un « Réinitialiser par défaut » le SUPPRIME chez l’utilisateur — alors que tu l’as livré.',
|
'awed.builtinTip': 'À cocher avant de livrer ce diplôme dans le catalogue. Sans ça, un « Réinitialiser par défaut » le SUPPRIME chez l’utilisateur — alors que tu l’as livré.',
|
||||||
'awed.protectedFlag': 'Protégé',
|
'awed.protectedFlag': 'Protégé',
|
||||||
'awed.protectedTip': 'Un diplôme protégé ne peut pas être supprimé depuis l’éditeur.',
|
'awed.protectedTip': 'Un diplôme protégé ne peut pas être supprimé depuis l’éditeur.',
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||||
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||||
'opslog.activeTab', // last selected tab
|
'opslog.activeTab', // last selected tab
|
||||||
'hamlog.awardColsShown', // which award columns are shown in the QSO grid
|
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
||||||
|
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
||||||
|
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
||||||
|
// through this global path would fight that per-profile scoping.
|
||||||
];
|
];
|
||||||
|
|
||||||
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
||||||
|
|||||||
@@ -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.3';
|
export const APP_VERSION = '0.20.7';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+38
-14
@@ -5,12 +5,14 @@ import {qso} from '../models';
|
|||||||
import {main} from '../models';
|
import {main} from '../models';
|
||||||
import {cat} from '../models';
|
import {cat} from '../models';
|
||||||
import {profile} from '../models';
|
import {profile} from '../models';
|
||||||
|
import {acom} from '../models';
|
||||||
import {antgenius} from '../models';
|
import {antgenius} from '../models';
|
||||||
import {award} from '../models';
|
import {award} from '../models';
|
||||||
import {awardref} from '../models';
|
import {awardref} from '../models';
|
||||||
import {cluster} from '../models';
|
import {cluster} from '../models';
|
||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
import {powergenius} from '../models';
|
import {powergenius} from '../models';
|
||||||
|
import {spe} from '../models';
|
||||||
import {solar} from '../models';
|
import {solar} from '../models';
|
||||||
import {winkeyer} from '../models';
|
import {winkeyer} from '../models';
|
||||||
import {alerts} from '../models';
|
import {alerts} from '../models';
|
||||||
@@ -22,6 +24,10 @@ import {lotwusers} from '../models';
|
|||||||
import {lookup} from '../models';
|
import {lookup} from '../models';
|
||||||
import {netctl} from '../models';
|
import {netctl} from '../models';
|
||||||
|
|
||||||
|
export function ACOMSetOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function ACOMSetPower(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
||||||
|
|
||||||
export function ADIFVersion():Promise<string>;
|
export function ADIFVersion():Promise<string>;
|
||||||
@@ -72,8 +78,6 @@ export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Prom
|
|||||||
|
|
||||||
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||||
|
|
||||||
export function CWDecoderRunning():Promise<boolean>;
|
|
||||||
|
|
||||||
export function ChatAvailable():Promise<boolean>;
|
export function ChatAvailable():Promise<boolean>;
|
||||||
|
|
||||||
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||||
@@ -168,6 +172,8 @@ export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):
|
|||||||
|
|
||||||
export function ExportAward(arg1:string):Promise<string>;
|
export function ExportAward(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function ExportAwardForCatalog(arg1:string,arg2:number):Promise<string>;
|
||||||
|
|
||||||
export function ExportAwards():Promise<string>;
|
export function ExportAwards():Promise<string>;
|
||||||
|
|
||||||
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
|
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
|
||||||
@@ -190,8 +196,12 @@ export function FlexAmpOperate(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
|
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexBackspaceCW(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexMox(arg1:boolean):Promise<void>;
|
export function FlexMox(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSendCW(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetAGCMode(arg1:string):Promise<void>;
|
export function FlexSetAGCMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
|
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
|
||||||
@@ -222,6 +232,8 @@ export function FlexSetCWSpeed(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
|
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetKeySpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetMic(arg1:number):Promise<void>;
|
export function FlexSetMic(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetMicProfile(arg1:string):Promise<void>;
|
export function FlexSetMicProfile(arg1:string):Promise<void>;
|
||||||
@@ -278,8 +290,12 @@ export function FlexSetXIT(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetXITFreq(arg1:number):Promise<void>;
|
export function FlexSetXITFreq(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexStopCW():Promise<void>;
|
||||||
|
|
||||||
export function FlexTune(arg1:boolean):Promise<void>;
|
export function FlexTune(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function GetACOMStatus():Promise<acom.Status>;
|
||||||
|
|
||||||
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
|
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
|
||||||
|
|
||||||
export function GetActiveProfile():Promise<profile.Profile>;
|
export function GetActiveProfile():Promise<profile.Profile>;
|
||||||
@@ -314,10 +330,10 @@ export function GetCATSettings():Promise<main.CATSettings>;
|
|||||||
|
|
||||||
export function GetCATState():Promise<cat.RigState>;
|
export function GetCATState():Promise<cat.RigState>;
|
||||||
|
|
||||||
export function GetCWDecoderPitch():Promise<number>;
|
|
||||||
|
|
||||||
export function GetCatalogCodes():Promise<Array<string>>;
|
export function GetCatalogCodes():Promise<Array<string>>;
|
||||||
|
|
||||||
|
export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
|
||||||
|
|
||||||
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||||
|
|
||||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||||
@@ -356,8 +372,6 @@ export function GetListsSettings():Promise<main.ListsSettings>;
|
|||||||
|
|
||||||
export function GetLiveStations():Promise<Array<main.LiveStation>>;
|
export function GetLiveStations():Promise<Array<main.LiveStation>>;
|
||||||
|
|
||||||
export function GetLiveStatusEnabled():Promise<boolean>;
|
|
||||||
|
|
||||||
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
||||||
|
|
||||||
export function GetLogFilePath():Promise<string>;
|
export function GetLogFilePath():Promise<string>;
|
||||||
@@ -396,6 +410,8 @@ export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
|||||||
|
|
||||||
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
||||||
|
|
||||||
|
export function GetSPEStatus():Promise<spe.Status>;
|
||||||
|
|
||||||
export function GetSecretStatus():Promise<main.SecretStatus>;
|
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||||
|
|
||||||
export function GetSlotStats():Promise<qso.SlotStats>;
|
export function GetSlotStats():Promise<qso.SlotStats>;
|
||||||
@@ -418,6 +434,8 @@ export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
|
|||||||
|
|
||||||
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
|
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
|
||||||
|
|
||||||
|
export function GetWhatsNew():Promise<Array<main.ChangelogEntry>>;
|
||||||
|
|
||||||
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
||||||
|
|
||||||
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||||
@@ -536,6 +554,8 @@ export function ListContests():Promise<Array<contest.Def>>;
|
|||||||
|
|
||||||
export function ListCountries():Promise<Array<string>>;
|
export function ListCountries():Promise<Array<string>>;
|
||||||
|
|
||||||
|
export function ListDenkoviDevices():Promise<Array<string>>;
|
||||||
|
|
||||||
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
||||||
|
|
||||||
export function ListProfiles():Promise<Array<profile.Profile>>;
|
export function ListProfiles():Promise<Array<profile.Profile>>;
|
||||||
@@ -666,6 +686,8 @@ export function QSOAudioRestart():Promise<boolean>;
|
|||||||
|
|
||||||
export function QuitApp():Promise<void>;
|
export function QuitApp():Promise<void>;
|
||||||
|
|
||||||
|
export function RecomputeAllAwardRefs():Promise<number>;
|
||||||
|
|
||||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
export function RefreshSolar():Promise<void>;
|
export function RefreshSolar():Promise<void>;
|
||||||
@@ -674,6 +696,8 @@ export function ReloadUDPIntegrations():Promise<Array<string>>;
|
|||||||
|
|
||||||
export function RemovePassphrase(arg1:string):Promise<void>;
|
export function RemovePassphrase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function RenameDatabase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
|
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
|
||||||
|
|
||||||
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
||||||
@@ -700,6 +724,12 @@ export function RotatorStop():Promise<void>;
|
|||||||
|
|
||||||
export function RunBackupNow():Promise<string>;
|
export function RunBackupNow():Promise<string>;
|
||||||
|
|
||||||
|
export function SPESetOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SPESetPower(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SPESetPowerLevel(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SaveADIFFile():Promise<string>;
|
export function SaveADIFFile():Promise<string>;
|
||||||
|
|
||||||
export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
|
export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
|
||||||
@@ -780,8 +810,6 @@ export function SetCATFrequency(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function SetCATMode(arg1:string):Promise<void>;
|
export function SetCATMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
|
||||||
|
|
||||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
||||||
@@ -790,8 +818,6 @@ export function SetCompactMode(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function SetLiveStatusEnabled(arg1:boolean):Promise<void>;
|
|
||||||
|
|
||||||
export function SetPassphrase(arg1:string):Promise<void>;
|
export function SetPassphrase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
||||||
@@ -800,12 +826,8 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
|||||||
|
|
||||||
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function StartCWDecoder():Promise<void>;
|
|
||||||
|
|
||||||
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||||
|
|
||||||
export function StopCWDecoder():Promise<void>;
|
|
||||||
|
|
||||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||||
@@ -830,6 +852,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>;
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
|
export function ACOMSetOperate(arg1) {
|
||||||
|
return window['go']['main']['App']['ACOMSetOperate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ACOMSetPower(arg1) {
|
||||||
|
return window['go']['main']['App']['ACOMSetPower'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function ADIFFields() {
|
export function ADIFFields() {
|
||||||
return window['go']['main']['App']['ADIFFields']();
|
return window['go']['main']['App']['ADIFFields']();
|
||||||
}
|
}
|
||||||
@@ -102,10 +110,6 @@ export function BulkUpdateQSL(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CWDecoderRunning() {
|
|
||||||
return window['go']['main']['App']['CWDecoderRunning']();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatAvailable() {
|
export function ChatAvailable() {
|
||||||
return window['go']['main']['App']['ChatAvailable']();
|
return window['go']['main']['App']['ChatAvailable']();
|
||||||
}
|
}
|
||||||
@@ -294,6 +298,10 @@ export function ExportAward(arg1) {
|
|||||||
return window['go']['main']['App']['ExportAward'](arg1);
|
return window['go']['main']['App']['ExportAward'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ExportAwardForCatalog(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['ExportAwardForCatalog'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function ExportAwards() {
|
export function ExportAwards() {
|
||||||
return window['go']['main']['App']['ExportAwards']();
|
return window['go']['main']['App']['ExportAwards']();
|
||||||
}
|
}
|
||||||
@@ -338,10 +346,18 @@ export function FlexApplyBandAntenna(arg1) {
|
|||||||
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
|
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexBackspaceCW(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexBackspaceCW'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexMox(arg1) {
|
export function FlexMox(arg1) {
|
||||||
return window['go']['main']['App']['FlexMox'](arg1);
|
return window['go']['main']['App']['FlexMox'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSendCW(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSendCW'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetAGCMode(arg1) {
|
export function FlexSetAGCMode(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetAGCMode'](arg1);
|
return window['go']['main']['App']['FlexSetAGCMode'](arg1);
|
||||||
}
|
}
|
||||||
@@ -402,6 +418,10 @@ export function FlexSetFilter(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
|
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetKeySpeed(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetKeySpeed'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetMic(arg1) {
|
export function FlexSetMic(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetMic'](arg1);
|
return window['go']['main']['App']['FlexSetMic'](arg1);
|
||||||
}
|
}
|
||||||
@@ -514,10 +534,18 @@ export function FlexSetXITFreq(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetXITFreq'](arg1);
|
return window['go']['main']['App']['FlexSetXITFreq'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexStopCW() {
|
||||||
|
return window['go']['main']['App']['FlexStopCW']();
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexTune(arg1) {
|
export function FlexTune(arg1) {
|
||||||
return window['go']['main']['App']['FlexTune'](arg1);
|
return window['go']['main']['App']['FlexTune'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetACOMStatus() {
|
||||||
|
return window['go']['main']['App']['GetACOMStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetADIFMonitor() {
|
export function GetADIFMonitor() {
|
||||||
return window['go']['main']['App']['GetADIFMonitor']();
|
return window['go']['main']['App']['GetADIFMonitor']();
|
||||||
}
|
}
|
||||||
@@ -586,14 +614,14 @@ export function GetCATState() {
|
|||||||
return window['go']['main']['App']['GetCATState']();
|
return window['go']['main']['App']['GetCATState']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetCWDecoderPitch() {
|
|
||||||
return window['go']['main']['App']['GetCWDecoderPitch']();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GetCatalogCodes() {
|
export function GetCatalogCodes() {
|
||||||
return window['go']['main']['App']['GetCatalogCodes']();
|
return window['go']['main']['App']['GetCatalogCodes']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetChangelog() {
|
||||||
|
return window['go']['main']['App']['GetChangelog']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetChatHistory(arg1) {
|
export function GetChatHistory(arg1) {
|
||||||
return window['go']['main']['App']['GetChatHistory'](arg1);
|
return window['go']['main']['App']['GetChatHistory'](arg1);
|
||||||
}
|
}
|
||||||
@@ -670,10 +698,6 @@ export function GetLiveStations() {
|
|||||||
return window['go']['main']['App']['GetLiveStations']();
|
return window['go']['main']['App']['GetLiveStations']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetLiveStatusEnabled() {
|
|
||||||
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GetLoTWUsersStatus() {
|
export function GetLoTWUsersStatus() {
|
||||||
return window['go']['main']['App']['GetLoTWUsersStatus']();
|
return window['go']['main']['App']['GetLoTWUsersStatus']();
|
||||||
}
|
}
|
||||||
@@ -750,6 +774,10 @@ export function GetRotatorSettings() {
|
|||||||
return window['go']['main']['App']['GetRotatorSettings']();
|
return window['go']['main']['App']['GetRotatorSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetSPEStatus() {
|
||||||
|
return window['go']['main']['App']['GetSPEStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetSecretStatus() {
|
export function GetSecretStatus() {
|
||||||
return window['go']['main']['App']['GetSecretStatus']();
|
return window['go']['main']['App']['GetSecretStatus']();
|
||||||
}
|
}
|
||||||
@@ -794,6 +822,10 @@ export function GetUltrabeamStatus() {
|
|||||||
return window['go']['main']['App']['GetUltrabeamStatus']();
|
return window['go']['main']['App']['GetUltrabeamStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetWhatsNew() {
|
||||||
|
return window['go']['main']['App']['GetWhatsNew']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetWinkeyerSettings() {
|
export function GetWinkeyerSettings() {
|
||||||
return window['go']['main']['App']['GetWinkeyerSettings']();
|
return window['go']['main']['App']['GetWinkeyerSettings']();
|
||||||
}
|
}
|
||||||
@@ -1030,6 +1062,10 @@ export function ListCountries() {
|
|||||||
return window['go']['main']['App']['ListCountries']();
|
return window['go']['main']['App']['ListCountries']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListDenkoviDevices() {
|
||||||
|
return window['go']['main']['App']['ListDenkoviDevices']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ListOperatingTree() {
|
export function ListOperatingTree() {
|
||||||
return window['go']['main']['App']['ListOperatingTree']();
|
return window['go']['main']['App']['ListOperatingTree']();
|
||||||
}
|
}
|
||||||
@@ -1290,6 +1326,10 @@ export function QuitApp() {
|
|||||||
return window['go']['main']['App']['QuitApp']();
|
return window['go']['main']['App']['QuitApp']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RecomputeAllAwardRefs() {
|
||||||
|
return window['go']['main']['App']['RecomputeAllAwardRefs']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RefreshCtyDat() {
|
export function RefreshCtyDat() {
|
||||||
return window['go']['main']['App']['RefreshCtyDat']();
|
return window['go']['main']['App']['RefreshCtyDat']();
|
||||||
}
|
}
|
||||||
@@ -1306,6 +1346,10 @@ export function RemovePassphrase(arg1) {
|
|||||||
return window['go']['main']['App']['RemovePassphrase'](arg1);
|
return window['go']['main']['App']['RemovePassphrase'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RenameDatabase(arg1) {
|
||||||
|
return window['go']['main']['App']['RenameDatabase'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function RenderEQSL(arg1, arg2) {
|
export function RenderEQSL(arg1, arg2) {
|
||||||
return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
|
return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -1358,6 +1402,18 @@ export function RunBackupNow() {
|
|||||||
return window['go']['main']['App']['RunBackupNow']();
|
return window['go']['main']['App']['RunBackupNow']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SPESetOperate(arg1) {
|
||||||
|
return window['go']['main']['App']['SPESetOperate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SPESetPower(arg1) {
|
||||||
|
return window['go']['main']['App']['SPESetPower'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SPESetPowerLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['SPESetPowerLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveADIFFile() {
|
export function SaveADIFFile() {
|
||||||
return window['go']['main']['App']['SaveADIFFile']();
|
return window['go']['main']['App']['SaveADIFFile']();
|
||||||
}
|
}
|
||||||
@@ -1518,10 +1574,6 @@ export function SetCATMode(arg1) {
|
|||||||
return window['go']['main']['App']['SetCATMode'](arg1);
|
return window['go']['main']['App']['SetCATMode'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SetCWDecoderPitch(arg1) {
|
|
||||||
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SetClublogCtyEnabled(arg1) {
|
export function SetClublogCtyEnabled(arg1) {
|
||||||
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1538,10 +1590,6 @@ export function SetDVKLabel(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SetLiveStatusEnabled(arg1) {
|
|
||||||
return window['go']['main']['App']['SetLiveStatusEnabled'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SetPassphrase(arg1) {
|
export function SetPassphrase(arg1) {
|
||||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1558,18 +1606,10 @@ export function SetUltrabeamDirection(arg1) {
|
|||||||
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StartCWDecoder() {
|
|
||||||
return window['go']['main']['App']['StartCWDecoder']();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StationSetRelay(arg1, arg2, arg3) {
|
export function StationSetRelay(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
|
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StopCWDecoder() {
|
|
||||||
return window['go']['main']['App']['StopCWDecoder']();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SwitchCATRig(arg1) {
|
export function SwitchCATRig(arg1) {
|
||||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1618,6 +1658,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,58 @@
|
|||||||
|
export namespace acom {
|
||||||
|
|
||||||
|
export class Status {
|
||||||
|
connected: boolean;
|
||||||
|
port_open: boolean;
|
||||||
|
transport: string;
|
||||||
|
last_error?: string;
|
||||||
|
model?: string;
|
||||||
|
state?: string;
|
||||||
|
operate: boolean;
|
||||||
|
tx: boolean;
|
||||||
|
fwd_w: number;
|
||||||
|
refl_w: number;
|
||||||
|
swr: number;
|
||||||
|
drive_w: number;
|
||||||
|
dc_w: number;
|
||||||
|
temp_c: number;
|
||||||
|
band?: string;
|
||||||
|
fan: number;
|
||||||
|
err_code: number;
|
||||||
|
err_text?: string;
|
||||||
|
nominal_w: number;
|
||||||
|
max_w: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Status(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.connected = source["connected"];
|
||||||
|
this.port_open = source["port_open"];
|
||||||
|
this.transport = source["transport"];
|
||||||
|
this.last_error = source["last_error"];
|
||||||
|
this.model = source["model"];
|
||||||
|
this.state = source["state"];
|
||||||
|
this.operate = source["operate"];
|
||||||
|
this.tx = source["tx"];
|
||||||
|
this.fwd_w = source["fwd_w"];
|
||||||
|
this.refl_w = source["refl_w"];
|
||||||
|
this.swr = source["swr"];
|
||||||
|
this.drive_w = source["drive_w"];
|
||||||
|
this.dc_w = source["dc_w"];
|
||||||
|
this.temp_c = source["temp_c"];
|
||||||
|
this.band = source["band"];
|
||||||
|
this.fan = source["fan"];
|
||||||
|
this.err_code = source["err_code"];
|
||||||
|
this.err_text = source["err_text"];
|
||||||
|
this.nominal_w = source["nominal_w"];
|
||||||
|
this.max_w = source["max_w"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace adif {
|
export namespace adif {
|
||||||
|
|
||||||
export class ExportResult {
|
export class ExportResult {
|
||||||
@@ -1728,6 +1783,24 @@ export namespace main {
|
|||||||
this.path = source["path"];
|
this.path = source["path"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class ChangelogEntry {
|
||||||
|
version: string;
|
||||||
|
date: string;
|
||||||
|
en: string[];
|
||||||
|
fr: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ChangelogEntry(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.version = source["version"];
|
||||||
|
this.date = source["date"];
|
||||||
|
this.en = source["en"];
|
||||||
|
this.fr = source["fr"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ChatMessage {
|
export class ChatMessage {
|
||||||
id: number;
|
id: number;
|
||||||
operator: string;
|
operator: string;
|
||||||
@@ -2161,8 +2234,12 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
export class PGXLSettings {
|
export class PGXLSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
type: string;
|
||||||
|
transport: string;
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
com_port: string;
|
||||||
|
baud: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new PGXLSettings(source);
|
return new PGXLSettings(source);
|
||||||
@@ -2171,8 +2248,12 @@ export namespace main {
|
|||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.enabled = source["enabled"];
|
this.enabled = source["enabled"];
|
||||||
|
this.type = source["type"];
|
||||||
|
this.transport = source["transport"];
|
||||||
this.host = source["host"];
|
this.host = source["host"];
|
||||||
this.port = source["port"];
|
this.port = source["port"];
|
||||||
|
this.com_port = source["com_port"];
|
||||||
|
this.baud = source["baud"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class POTAUnmatched {
|
export class POTAUnmatched {
|
||||||
@@ -2600,6 +2681,7 @@ export namespace main {
|
|||||||
host: string;
|
host: string;
|
||||||
user?: string;
|
user?: string;
|
||||||
pass?: string;
|
pass?: string;
|
||||||
|
channels?: number;
|
||||||
labels: string[];
|
labels: string[];
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
@@ -2614,6 +2696,7 @@ export namespace main {
|
|||||||
this.host = source["host"];
|
this.host = source["host"];
|
||||||
this.user = source["user"];
|
this.user = source["user"];
|
||||||
this.pass = source["pass"];
|
this.pass = source["pass"];
|
||||||
|
this.channels = source["channels"];
|
||||||
this.labels = source["labels"];
|
this.labels = source["labels"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2718,6 +2801,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;
|
||||||
@@ -3401,6 +3500,26 @@ export namespace qslcard {
|
|||||||
|
|
||||||
export namespace qso {
|
export namespace qso {
|
||||||
|
|
||||||
|
export class BandCategory {
|
||||||
|
band: string;
|
||||||
|
cw: number;
|
||||||
|
phone: number;
|
||||||
|
data: number;
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new BandCategory(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.band = source["band"];
|
||||||
|
this.cw = source["cw"];
|
||||||
|
this.phone = source["phone"];
|
||||||
|
this.data = source["data"];
|
||||||
|
this.total = source["total"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class BandMode {
|
export class BandMode {
|
||||||
band: string;
|
band: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
@@ -3648,6 +3767,7 @@ export namespace qso {
|
|||||||
my_arrl_sect?: string;
|
my_arrl_sect?: string;
|
||||||
my_vucc_grids?: string;
|
my_vucc_grids?: string;
|
||||||
extras?: Record<string, string>;
|
extras?: Record<string, string>;
|
||||||
|
award_refs?: string;
|
||||||
// Go type: time
|
// Go type: time
|
||||||
created_at: any;
|
created_at: any;
|
||||||
// Go type: time
|
// Go type: time
|
||||||
@@ -3785,6 +3905,7 @@ export namespace qso {
|
|||||||
this.my_arrl_sect = source["my_arrl_sect"];
|
this.my_arrl_sect = source["my_arrl_sect"];
|
||||||
this.my_vucc_grids = source["my_vucc_grids"];
|
this.my_vucc_grids = source["my_vucc_grids"];
|
||||||
this.extras = source["extras"];
|
this.extras = source["extras"];
|
||||||
|
this.award_refs = source["award_refs"];
|
||||||
this.created_at = this.convertValues(source["created_at"], null);
|
this.created_at = this.convertValues(source["created_at"], null);
|
||||||
this.updated_at = this.convertValues(source["updated_at"], null);
|
this.updated_at = this.convertValues(source["updated_at"], null);
|
||||||
}
|
}
|
||||||
@@ -3888,12 +4009,17 @@ export namespace qso {
|
|||||||
confirmed_any: number;
|
confirmed_any: number;
|
||||||
by_mode: Bucket[];
|
by_mode: Bucket[];
|
||||||
by_band: Bucket[];
|
by_band: Bucket[];
|
||||||
|
by_band_category: BandCategory[];
|
||||||
by_operator: Bucket[];
|
by_operator: Bucket[];
|
||||||
by_station: Bucket[];
|
by_station: Bucket[];
|
||||||
by_continent: Bucket[];
|
by_continent: Bucket[];
|
||||||
top_entities: Bucket[];
|
top_entities: Bucket[];
|
||||||
by_year: Bucket[];
|
by_year: Bucket[];
|
||||||
by_month: Bucket[];
|
by_month: Bucket[];
|
||||||
|
by_hour: Bucket[];
|
||||||
|
by_day7: Bucket[];
|
||||||
|
by_day30: Bucket[];
|
||||||
|
by_month12: Bucket[];
|
||||||
window_start: string;
|
window_start: string;
|
||||||
window_end: string;
|
window_end: string;
|
||||||
window_hours: number;
|
window_hours: number;
|
||||||
@@ -3927,12 +4053,17 @@ export namespace qso {
|
|||||||
this.confirmed_any = source["confirmed_any"];
|
this.confirmed_any = source["confirmed_any"];
|
||||||
this.by_mode = this.convertValues(source["by_mode"], Bucket);
|
this.by_mode = this.convertValues(source["by_mode"], Bucket);
|
||||||
this.by_band = this.convertValues(source["by_band"], Bucket);
|
this.by_band = this.convertValues(source["by_band"], Bucket);
|
||||||
|
this.by_band_category = this.convertValues(source["by_band_category"], BandCategory);
|
||||||
this.by_operator = this.convertValues(source["by_operator"], Bucket);
|
this.by_operator = this.convertValues(source["by_operator"], Bucket);
|
||||||
this.by_station = this.convertValues(source["by_station"], Bucket);
|
this.by_station = this.convertValues(source["by_station"], Bucket);
|
||||||
this.by_continent = this.convertValues(source["by_continent"], Bucket);
|
this.by_continent = this.convertValues(source["by_continent"], Bucket);
|
||||||
this.top_entities = this.convertValues(source["top_entities"], Bucket);
|
this.top_entities = this.convertValues(source["top_entities"], Bucket);
|
||||||
this.by_year = this.convertValues(source["by_year"], Bucket);
|
this.by_year = this.convertValues(source["by_year"], Bucket);
|
||||||
this.by_month = this.convertValues(source["by_month"], Bucket);
|
this.by_month = this.convertValues(source["by_month"], Bucket);
|
||||||
|
this.by_hour = this.convertValues(source["by_hour"], Bucket);
|
||||||
|
this.by_day7 = this.convertValues(source["by_day7"], Bucket);
|
||||||
|
this.by_day30 = this.convertValues(source["by_day30"], Bucket);
|
||||||
|
this.by_month12 = this.convertValues(source["by_month12"], Bucket);
|
||||||
this.window_start = source["window_start"];
|
this.window_start = source["window_start"];
|
||||||
this.window_end = source["window_end"];
|
this.window_end = source["window_end"];
|
||||||
this.window_hours = source["window_hours"];
|
this.window_hours = source["window_hours"];
|
||||||
@@ -4096,6 +4227,53 @@ export namespace solar {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace spe {
|
||||||
|
|
||||||
|
export class Status {
|
||||||
|
connected: boolean;
|
||||||
|
last_error?: string;
|
||||||
|
model?: string;
|
||||||
|
operate: boolean;
|
||||||
|
tx: boolean;
|
||||||
|
input?: string;
|
||||||
|
band?: string;
|
||||||
|
power_level?: string;
|
||||||
|
output_w: number;
|
||||||
|
swr_atu: number;
|
||||||
|
swr_ant: number;
|
||||||
|
volt_pa: number;
|
||||||
|
curr_pa: number;
|
||||||
|
temp_c: number;
|
||||||
|
warnings?: string;
|
||||||
|
alarms?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Status(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.connected = source["connected"];
|
||||||
|
this.last_error = source["last_error"];
|
||||||
|
this.model = source["model"];
|
||||||
|
this.operate = source["operate"];
|
||||||
|
this.tx = source["tx"];
|
||||||
|
this.input = source["input"];
|
||||||
|
this.band = source["band"];
|
||||||
|
this.power_level = source["power_level"];
|
||||||
|
this.output_w = source["output_w"];
|
||||||
|
this.swr_atu = source["swr_atu"];
|
||||||
|
this.swr_ant = source["swr_ant"];
|
||||||
|
this.volt_pa = source["volt_pa"];
|
||||||
|
this.curr_pa = source["curr_pa"];
|
||||||
|
this.temp_c = source["temp_c"];
|
||||||
|
this.warnings = source["warnings"];
|
||||||
|
this.alarms = source["alarms"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace udp {
|
export namespace udp {
|
||||||
|
|
||||||
export class Config {
|
export class Config {
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
// Package acom drives the ACOM solid-state amplifiers (500S / 600S / 700S /
|
||||||
|
// 1200S / 2020S) over their (unpublished) RS-232 protocol, reverse-engineered by
|
||||||
|
// the ACOM-Controller project (bjornekelund, C#). The amp is reached either
|
||||||
|
// directly over a serial COM port (9600 8N1) or over TCP via an RS232-to-Ethernet
|
||||||
|
// bridge — both are just an io.ReadWriteCloser to this code, same as the SPE
|
||||||
|
// backend.
|
||||||
|
//
|
||||||
|
// Wire format (host → amp): fixed raw byte strings, validated by the same rule as
|
||||||
|
// telemetry (sum of ALL frame bytes ≡ 0 mod 256):
|
||||||
|
//
|
||||||
|
// enable telemetry: 55 92 04 15
|
||||||
|
// disable telemetry: 55 91 04 16
|
||||||
|
// OPERATE: 55 81 08 02 00 06 00 1A
|
||||||
|
// STANDBY: 55 81 08 02 00 05 00 1B
|
||||||
|
// OFF (power down): 55 81 08 02 00 0A 00 16
|
||||||
|
//
|
||||||
|
// Power ON is NOT a data command: it is a hardware pulse on the serial DTR/RTS
|
||||||
|
// lines (the amp's remote power-on pins) — serial transport only, and only if the
|
||||||
|
// cable wires those pins (a telemetry-only cable uses just RX/TX/GND).
|
||||||
|
//
|
||||||
|
// IMPORTANT: DTR and RTS must be held LOW at all times otherwise — asserting them
|
||||||
|
// permanently blocks the amplifier's front-panel power button.
|
||||||
|
//
|
||||||
|
// Telemetry (amp → host): once enabled, the amp streams 72-byte frames starting
|
||||||
|
// 0x55 0x2F, valid when the sum of all 72 bytes ≡ 0 (mod 256). Field offsets are
|
||||||
|
// decoded in decodeFrame below.
|
||||||
|
package acom
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cmdEnableTelemetry = []byte{0x55, 0x92, 0x04, 0x15}
|
||||||
|
cmdDisableTelemetry = []byte{0x55, 0x91, 0x04, 0x16}
|
||||||
|
cmdOperate = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x06, 0x00, 0x1A}
|
||||||
|
cmdStandby = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x05, 0x00, 0x1B}
|
||||||
|
cmdOff = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x0A, 0x00, 0x16}
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
frameLen = 72
|
||||||
|
sync0 = 0x55
|
||||||
|
sync1 = 0x2F
|
||||||
|
dialTimeout = 5 * time.Second
|
||||||
|
ioTimeout = 3 * time.Second
|
||||||
|
// The amp streams roughly 4-5 frames/s once telemetry is on; if nothing valid
|
||||||
|
// arrives for this long the link (or the amp) is considered down.
|
||||||
|
staleAfter = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// model carries the per-model constants: the temperature word offset and the
|
||||||
|
// nominal/max forward power (for UI bar scaling).
|
||||||
|
type model struct {
|
||||||
|
Name string
|
||||||
|
TempOffset int
|
||||||
|
NominalW int
|
||||||
|
MaxW int
|
||||||
|
}
|
||||||
|
|
||||||
|
var models = map[string]model{
|
||||||
|
"500S": {"500S", 282, 500, 600},
|
||||||
|
"600S": {"600S", 273, 600, 700},
|
||||||
|
"700S": {"700S", 282, 700, 800},
|
||||||
|
"1200S": {"1200S", 281, 1200, 1400},
|
||||||
|
"2020S": {"2020S", 282, 1800, 2000},
|
||||||
|
}
|
||||||
|
|
||||||
|
// paStatusNames maps the PAstatus nibble to a display string.
|
||||||
|
var paStatusNames = map[int]string{
|
||||||
|
1: "RESET", 2: "INIT", 3: "DEBUG", 4: "SERVICE",
|
||||||
|
5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 9: "SYSTEM", 10: "OFF",
|
||||||
|
}
|
||||||
|
|
||||||
|
// acomBands maps the band nibble to a band label.
|
||||||
|
var acomBands = []string{"?", "160m", "80m", "40/60m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m"}
|
||||||
|
|
||||||
|
// errText translates the error code (frame byte 66) shown on the amp's display.
|
||||||
|
// 0xFF means NO error — everything else, including 0x00, is a fault/warning.
|
||||||
|
func errText(code int) string {
|
||||||
|
switch code {
|
||||||
|
case 0xFF:
|
||||||
|
return ""
|
||||||
|
case 0x00, 0x08:
|
||||||
|
return "Hot switching"
|
||||||
|
case 0x03:
|
||||||
|
return "Drive power at wrong time"
|
||||||
|
case 0x04, 0x05:
|
||||||
|
return "Reflected power warning"
|
||||||
|
case 0x06, 0x07:
|
||||||
|
return "Drive power too high"
|
||||||
|
case 0x0C:
|
||||||
|
return "RF power at wrong time"
|
||||||
|
case 0x0E:
|
||||||
|
return "Stop transmission first"
|
||||||
|
case 0x0F:
|
||||||
|
return "Remove drive power"
|
||||||
|
case 0x24, 0x25, 0x39, 0x44, 0x45, 0x59:
|
||||||
|
return "Excessive PAM current"
|
||||||
|
case 0x70:
|
||||||
|
return "CAT error"
|
||||||
|
default:
|
||||||
|
return "ERROR — see display"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is the decoded amplifier state for the UI.
|
||||||
|
type Status struct {
|
||||||
|
Connected bool `json:"connected"` // valid telemetry is flowing
|
||||||
|
PortOpen bool `json:"port_open"` // transport is open (serial port / TCP socket) — power-on possible even when the amp itself is off
|
||||||
|
Transport string `json:"transport"` // "serial" | "tcp" — the UI disables power-ON over tcp (no DTR line)
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
State string `json:"state,omitempty"` // STANDBY / RECEIVE / TRANSMIT / OFF / …
|
||||||
|
Operate bool `json:"operate"` // RECEIVE or TRANSMIT (vs STANDBY)
|
||||||
|
TX bool `json:"tx"`
|
||||||
|
FwdW int `json:"fwd_w"` // forward/output power
|
||||||
|
ReflW int `json:"refl_w"` // reflected power
|
||||||
|
SWR float64 `json:"swr"`
|
||||||
|
DriveW int `json:"drive_w"`
|
||||||
|
DCPowerW int `json:"dc_w"`
|
||||||
|
TempC int `json:"temp_c"` // PA temperature
|
||||||
|
Band string `json:"band,omitempty"`
|
||||||
|
FanLevel int `json:"fan"` // 1..4
|
||||||
|
ErrCode int `json:"err_code"` // raw code from the frame; 0xFF = none
|
||||||
|
ErrText string `json:"err_text,omitempty"` // human message; empty = no error
|
||||||
|
NominalW int `json:"nominal_w"`
|
||||||
|
MaxW int `json:"max_w"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config selects the transport and amplifier model.
|
||||||
|
type Config struct {
|
||||||
|
Model string // "500S" | "600S" | "700S" | "1200S" | "2020S"
|
||||||
|
Transport string // "serial" | "tcp"
|
||||||
|
ComPort string // serial
|
||||||
|
Baud int // serial (the amp is fixed 9600 8N1)
|
||||||
|
Host string // tcp (RS232-to-Ethernet bridge)
|
||||||
|
Port int // tcp
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
cfg Config
|
||||||
|
mdl model
|
||||||
|
|
||||||
|
mu sync.Mutex // serialises access to the connection
|
||||||
|
conn io.ReadWriteCloser
|
||||||
|
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
status Status
|
||||||
|
|
||||||
|
// Diagnostics: raw byte count + first-bytes capture, so a hardware session log
|
||||||
|
// tells apart "nothing on the wire" (cable/COM/amp) from "bytes but no valid
|
||||||
|
// frame" (framing/checksum) without a serial sniffer.
|
||||||
|
rawSeen int64
|
||||||
|
dbgBytes []byte
|
||||||
|
dbgLogged bool
|
||||||
|
ckFails int
|
||||||
|
frameLogged bool
|
||||||
|
|
||||||
|
stop chan struct{}
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Client {
|
||||||
|
if cfg.Baud <= 0 {
|
||||||
|
cfg.Baud = 9600
|
||||||
|
}
|
||||||
|
mdl, ok := models[strings.ToUpper(strings.TrimSpace(cfg.Model))]
|
||||||
|
if !ok {
|
||||||
|
mdl = models["700S"]
|
||||||
|
}
|
||||||
|
c := &Client{cfg: cfg, mdl: mdl, stop: make(chan struct{})}
|
||||||
|
c.status.Model = mdl.Name
|
||||||
|
c.status.Transport = cfg.Transport
|
||||||
|
c.status.NominalW = mdl.NominalW
|
||||||
|
c.status.MaxW = mdl.MaxW
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Start() error {
|
||||||
|
if c.running {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.running = true
|
||||||
|
go c.readLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Stop() {
|
||||||
|
if !c.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.running = false
|
||||||
|
close(c.stop)
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
_, _ = c.conn.Write(cmdDisableTelemetry) // best effort: stop the stream
|
||||||
|
}
|
||||||
|
c.dropLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetStatus() Status {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
return c.status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) setErr(msg string) {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.status.Connected = false
|
||||||
|
c.status.LastError = msg
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operate puts the amp in OPERATE (true) or STANDBY (false). Unlike the SPE's
|
||||||
|
// single toggle key, the ACOM protocol has explicit commands for each state.
|
||||||
|
func (c *Client) Operate(on bool) error {
|
||||||
|
if on {
|
||||||
|
return c.send(cmdOperate)
|
||||||
|
}
|
||||||
|
return c.send(cmdStandby)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PowerOff sends the power-down command (same as pressing OFF on the amp).
|
||||||
|
func (c *Client) PowerOff() error { return c.send(cmdOff) }
|
||||||
|
|
||||||
|
// PowerOn pulses the serial DTR/RTS lines — the amp's remote power-on pins, wired
|
||||||
|
// like a press of the front-panel power button. Hardware line ⇒ serial transport
|
||||||
|
// only (an RS232-to-Ethernet bridge doesn't forward DTR), and the cable must have
|
||||||
|
// those pins connected. Pulse length to be confirmed on real hardware.
|
||||||
|
func (c *Client) PowerOn() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
sp, ok := c.conn.(serial.Port)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("power-on needs the serial DTR/RTS lines — not available over a network bridge")
|
||||||
|
}
|
||||||
|
if err := sp.SetDTR(true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := sp.SetRTS(true); err != nil {
|
||||||
|
_ = sp.SetDTR(false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(2500 * time.Millisecond)
|
||||||
|
err1 := sp.SetDTR(false)
|
||||||
|
err2 := sp.SetRTS(false)
|
||||||
|
if err1 != nil {
|
||||||
|
return err1
|
||||||
|
}
|
||||||
|
return err2
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) send(cmd []byte) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn == nil {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
if nc, ok := c.conn.(net.Conn); ok {
|
||||||
|
_ = nc.SetWriteDeadline(time.Now().Add(ioTimeout))
|
||||||
|
}
|
||||||
|
_, err := c.conn.Write(cmd)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLoop keeps the link up and consumes the telemetry stream. When no valid
|
||||||
|
// frame arrives for a while it re-arms telemetry (the amp forgets the enable
|
||||||
|
// across a power cycle) and, on TCP, reconnects. A serial port is kept open even
|
||||||
|
// with the amp off, so the DTR power-on pulse stays available.
|
||||||
|
func (c *Client) readLoop() {
|
||||||
|
var lastFrame time.Time
|
||||||
|
var lastEnable time.Time
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if err := c.ensureConn(); err != nil {
|
||||||
|
c.setErr("connect: " + err.Error())
|
||||||
|
select {
|
||||||
|
case <-c.stop:
|
||||||
|
return
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
frame, err := c.readFrame()
|
||||||
|
now := time.Now()
|
||||||
|
if err == nil {
|
||||||
|
lastFrame = now
|
||||||
|
if !c.frameLogged {
|
||||||
|
c.frameLogged = true
|
||||||
|
applog.Printf("acom: telemetry up — first valid frame received")
|
||||||
|
}
|
||||||
|
c.decodeFrame(frame)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// No valid frame. Re-send the telemetry enable briskly — there is no way to
|
||||||
|
// know whether the amp has it on (the reference controller re-sends every
|
||||||
|
// 200ms until frames flow), and it revives the stream after a power cycle.
|
||||||
|
if now.Sub(lastEnable) >= 500*time.Millisecond {
|
||||||
|
lastEnable = now
|
||||||
|
_ = c.send(cmdEnableTelemetry)
|
||||||
|
}
|
||||||
|
if lastFrame.IsZero() || now.Sub(lastFrame) > staleAfter {
|
||||||
|
if c.cfg.Transport == "tcp" {
|
||||||
|
// The bridge socket may be dead — force a reconnect.
|
||||||
|
c.mu.Lock()
|
||||||
|
c.dropLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
if prev := c.GetStatus(); prev.Connected || prev.LastError == "" {
|
||||||
|
applog.Printf("acom: no telemetry (raw bytes seen so far: %d, checksum fails: %d)", c.rawSeen, c.ckFails)
|
||||||
|
}
|
||||||
|
c.setErr("no telemetry — amplifier off or cable/bridge issue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ensureConn() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if c.cfg.Transport == "tcp" {
|
||||||
|
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.conn = nc
|
||||||
|
} else {
|
||||||
|
sp, err := serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// CRITICAL: hold DTR/RTS LOW. Windows may assert them on open, and while
|
||||||
|
// asserted the amp's front-panel power button is blocked (they are its
|
||||||
|
// remote power-on lines).
|
||||||
|
_ = sp.SetDTR(false)
|
||||||
|
_ = sp.SetRTS(false)
|
||||||
|
// Short read timeout so the frame reader can poll the stop channel and
|
||||||
|
// detect staleness rather than blocking forever.
|
||||||
|
_ = sp.SetReadTimeout(200 * time.Millisecond)
|
||||||
|
c.conn = sp
|
||||||
|
}
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.status.PortOpen = true
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
applog.Printf("acom: %s link open (%s)", c.mdl.Name, c.cfg.Transport)
|
||||||
|
_, _ = c.conn.Write(cmdEnableTelemetry)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) dropLocked() {
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.status.PortOpen = false
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// readByte reads a single byte, honouring the transport's short timeout. A serial
|
||||||
|
// read that times out returns (0, nil) with go.bug.st/serial — mapped to an error
|
||||||
|
// here so callers can distinguish "no data yet".
|
||||||
|
var errNoData = fmt.Errorf("no data")
|
||||||
|
|
||||||
|
func (c *Client) readByte(deadline time.Time) (byte, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
c.mu.Unlock()
|
||||||
|
if conn == nil {
|
||||||
|
return 0, fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
for {
|
||||||
|
if nc, ok := conn.(net.Conn); ok {
|
||||||
|
_ = nc.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
|
||||||
|
}
|
||||||
|
n, err := conn.Read(buf)
|
||||||
|
if n == 1 {
|
||||||
|
c.rawSeen++
|
||||||
|
// Capture the first 144 raw bytes (~2 frames) once, so a session log shows
|
||||||
|
// what the wire actually carries when frames won't validate.
|
||||||
|
if !c.dbgLogged {
|
||||||
|
c.dbgBytes = append(c.dbgBytes, buf[0])
|
||||||
|
if len(c.dbgBytes) >= 144 {
|
||||||
|
c.dbgLogged = true
|
||||||
|
applog.Printf("acom: first raw bytes: % X", c.dbgBytes)
|
||||||
|
c.dbgBytes = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf[0], nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||||
|
err = nil // treat like the serial short-timeout: just no data yet
|
||||||
|
} else {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-c.stop:
|
||||||
|
return 0, fmt.Errorf("stopped")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return 0, errNoData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFrame syncs on 0x55 0x2F, reads the rest of the 72-byte frame and verifies
|
||||||
|
// the mod-256 checksum (sum of ALL 72 bytes ≡ 0).
|
||||||
|
func (c *Client) readFrame() ([]byte, error) {
|
||||||
|
deadline := time.Now().Add(ioTimeout)
|
||||||
|
// Sync: hunt for 0x55 followed by 0x2F.
|
||||||
|
for {
|
||||||
|
b, err := c.readByte(deadline)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if b != sync0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b2, err := c.readByte(deadline)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if b2 == sync1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frame := make([]byte, frameLen)
|
||||||
|
frame[0], frame[1] = sync0, sync1
|
||||||
|
for i := 2; i < frameLen; i++ {
|
||||||
|
b, err := c.readByte(deadline)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
frame[i] = b
|
||||||
|
}
|
||||||
|
var sum int
|
||||||
|
for _, b := range frame {
|
||||||
|
sum += int(b)
|
||||||
|
}
|
||||||
|
if sum%256 != 0 {
|
||||||
|
c.ckFails++
|
||||||
|
if c.ckFails <= 3 {
|
||||||
|
applog.Printf("acom: bad checksum (fail #%d): % X", c.ckFails, frame)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("bad checksum")
|
||||||
|
}
|
||||||
|
return frame, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeFrame extracts the telemetry fields (see the package comment for the
|
||||||
|
// reverse-engineered layout; 16-bit values are little-endian lo + hi*256).
|
||||||
|
func (c *Client) decodeFrame(f []byte) {
|
||||||
|
u16 := func(i int) int { return int(f[i]) + int(f[i+1])*256 }
|
||||||
|
paStatus := int(f[3]&0xF0) >> 4
|
||||||
|
state := paStatusNames[paStatus]
|
||||||
|
if state == "" {
|
||||||
|
state = fmt.Sprintf("?%d", paStatus)
|
||||||
|
}
|
||||||
|
bandIdx := int(f[69] & 0x0F)
|
||||||
|
band := ""
|
||||||
|
if bandIdx > 0 && bandIdx < len(acomBands) {
|
||||||
|
band = acomBands[bandIdx]
|
||||||
|
}
|
||||||
|
|
||||||
|
c.statusMu.Lock()
|
||||||
|
defer c.statusMu.Unlock()
|
||||||
|
c.status.Connected = true
|
||||||
|
c.status.LastError = ""
|
||||||
|
c.status.State = state
|
||||||
|
c.status.Operate = paStatus == 6 || paStatus == 7
|
||||||
|
c.status.TX = paStatus == 7
|
||||||
|
c.status.DCPowerW = u16(8) / 10
|
||||||
|
c.status.TempC = u16(16) - c.mdl.TempOffset
|
||||||
|
c.status.DriveW = u16(20)
|
||||||
|
c.status.FwdW = u16(22)
|
||||||
|
c.status.ReflW = u16(24)
|
||||||
|
c.status.SWR = float64(u16(26)) / 100
|
||||||
|
c.status.ErrCode = int(f[66])
|
||||||
|
c.status.ErrText = errText(int(f[66]))
|
||||||
|
c.status.Band = band
|
||||||
|
c.status.FanLevel = int(f[69]&0xF0) >> 4
|
||||||
|
}
|
||||||
@@ -47,10 +47,15 @@ func Init(dataDir string) (string, error) {
|
|||||||
_ = os.Rename(oldLog, logPath)
|
_ = os.Rename(oldLog, logPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Truncate if the file grew past ~5MB so we don't accumulate logs
|
// Rotate (don't delete) once the file grows past ~10MB: rename it to
|
||||||
// forever. We keep one file — simple and adequate for diagnostics.
|
// opslog.log.1 so the PREVIOUS session's log survives. Deleting it outright used
|
||||||
if fi, err := os.Stat(logPath); err == nil && fi.Size() > 5*1024*1024 {
|
// to erase exactly the diagnostics we needed when a user reported an issue from
|
||||||
_ = os.Remove(logPath)
|
// the session that just ended. One generation kept — enough, without unbounded growth.
|
||||||
|
if fi, err := os.Stat(logPath); err == nil && fi.Size() > 10*1024*1024 {
|
||||||
|
_ = os.Remove(logPath + ".1") // drop the older generation
|
||||||
|
if err := os.Rename(logPath, logPath+".1"); err != nil {
|
||||||
|
_ = os.Remove(logPath) // rename failed (locked?) → fall back to the old behaviour
|
||||||
|
}
|
||||||
}
|
}
|
||||||
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,11 +4,27 @@
|
|||||||
"name": "Départements Français Métropolitains",
|
"name": "Départements Français Métropolitains",
|
||||||
"valid": true,
|
"valid": true,
|
||||||
"protected": true,
|
"protected": true,
|
||||||
|
"ref_display": "name",
|
||||||
"type": "QSOFIELDS",
|
"type": "QSOFIELDS",
|
||||||
"field": "note",
|
"field": "note",
|
||||||
"pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b",
|
"pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b",
|
||||||
|
"or_rules": [
|
||||||
|
{
|
||||||
|
"field": "address",
|
||||||
|
"match_by": "code",
|
||||||
|
"pattern": "\\b(\\d{2})\\d{3}\\b",
|
||||||
|
"prefix": "D"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "code",
|
||||||
|
"pattern": "\\b(\\d{2})\\d{3}\\b",
|
||||||
|
"prefix": "D"
|
||||||
|
}
|
||||||
|
],
|
||||||
"dxcc_filter": [
|
"dxcc_filter": [
|
||||||
227
|
227,
|
||||||
|
214
|
||||||
],
|
],
|
||||||
"confirm": [
|
"confirm": [
|
||||||
"lotw",
|
"lotw",
|
||||||
@@ -18,6 +34,777 @@
|
|||||||
"lotw"
|
"lotw"
|
||||||
],
|
],
|
||||||
"total": 96,
|
"total": 96,
|
||||||
"builtin": true
|
"builtin": true,
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"code": "D01",
|
||||||
|
"name": "Ain",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D02",
|
||||||
|
"name": "Aisne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D03",
|
||||||
|
"name": "Allier",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D04",
|
||||||
|
"name": "Alpes-de-Haute-Provence",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D05",
|
||||||
|
"name": "Hautes-Alpes",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D06",
|
||||||
|
"name": "Alpes-Maritimes",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D07",
|
||||||
|
"name": "Ardèche",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D08",
|
||||||
|
"name": "Ardennes",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D09",
|
||||||
|
"name": "Ariège",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D10",
|
||||||
|
"name": "Aube",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D11",
|
||||||
|
"name": "Aude",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D12",
|
||||||
|
"name": "Aveyron",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D13",
|
||||||
|
"name": "Bouches-du-Rhône",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D14",
|
||||||
|
"name": "Calvados",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D15",
|
||||||
|
"name": "Cantal",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D16",
|
||||||
|
"name": "Charente",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D17",
|
||||||
|
"name": "Charente-Maritime",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D18",
|
||||||
|
"name": "Cher",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D19",
|
||||||
|
"name": "Corrèze",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D21",
|
||||||
|
"name": "Côte-d'Or",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D22",
|
||||||
|
"name": "Côtes-d'Armor",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D23",
|
||||||
|
"name": "Creuse",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D24",
|
||||||
|
"name": "Dordogne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D25",
|
||||||
|
"name": "Doubs",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D26",
|
||||||
|
"name": "Drôme",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D27",
|
||||||
|
"name": "Eure",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D28",
|
||||||
|
"name": "Eure-et-Loir",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D29",
|
||||||
|
"name": "Finistère",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D2A",
|
||||||
|
"name": "Corse-du-Sud",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D2B",
|
||||||
|
"name": "Haute-Corse",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D30",
|
||||||
|
"name": "Gard",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D31",
|
||||||
|
"name": "Haute-Garonne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D32",
|
||||||
|
"name": "Gers",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D33",
|
||||||
|
"name": "Gironde",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D34",
|
||||||
|
"name": "Hérault",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D35",
|
||||||
|
"name": "Ille-et-Vilaine",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D36",
|
||||||
|
"name": "Indre",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D37",
|
||||||
|
"name": "Indre-et-Loire",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D38",
|
||||||
|
"name": "Isère",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D39",
|
||||||
|
"name": "Jura",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D40",
|
||||||
|
"name": "Landes",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D41",
|
||||||
|
"name": "Loir-et-Cher",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D42",
|
||||||
|
"name": "Loire",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D43",
|
||||||
|
"name": "Haute-Loire",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D44",
|
||||||
|
"name": "Loire-Atlantique",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D45",
|
||||||
|
"name": "Loiret",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D46",
|
||||||
|
"name": "Lot",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D47",
|
||||||
|
"name": "Lot-et-Garonne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D48",
|
||||||
|
"name": "Lozère",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D49",
|
||||||
|
"name": "Maine-et-Loire",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D50",
|
||||||
|
"name": "Manche",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D51",
|
||||||
|
"name": "Marne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D52",
|
||||||
|
"name": "Haute-Marne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D53",
|
||||||
|
"name": "Mayenne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D54",
|
||||||
|
"name": "Meurthe-et-Moselle",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D55",
|
||||||
|
"name": "Meuse",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D56",
|
||||||
|
"name": "Morbihan",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D57",
|
||||||
|
"name": "Moselle",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D58",
|
||||||
|
"name": "Nièvre",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D59",
|
||||||
|
"name": "Nord",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D60",
|
||||||
|
"name": "Oise",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D61",
|
||||||
|
"name": "Orne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D62",
|
||||||
|
"name": "Pas-de-Calais",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D63",
|
||||||
|
"name": "Puy-de-Dôme",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D64",
|
||||||
|
"name": "Pyrénées-Atlantiques",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D65",
|
||||||
|
"name": "Hautes-Pyrénées",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D66",
|
||||||
|
"name": "Pyrénées-Orientales",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D67",
|
||||||
|
"name": "Bas-Rhin",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D68",
|
||||||
|
"name": "Haut-Rhin",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D69",
|
||||||
|
"name": "Rhône",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D70",
|
||||||
|
"name": "Haute-Saône",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D71",
|
||||||
|
"name": "Saône-et-Loire",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D72",
|
||||||
|
"name": "Sarthe",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D73",
|
||||||
|
"name": "Savoie",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D74",
|
||||||
|
"name": "Haute-Savoie",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D75",
|
||||||
|
"name": "Paris",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D76",
|
||||||
|
"name": "Seine-Maritime",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D77",
|
||||||
|
"name": "Seine-et-Marne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D78",
|
||||||
|
"name": "Yvelines",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D79",
|
||||||
|
"name": "Deux-Sèvres",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D80",
|
||||||
|
"name": "Somme",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D81",
|
||||||
|
"name": "Tarn",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D82",
|
||||||
|
"name": "Tarn-et-Garonne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D83",
|
||||||
|
"name": "Var",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D84",
|
||||||
|
"name": "Vaucluse",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D85",
|
||||||
|
"name": "Vendée",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D86",
|
||||||
|
"name": "Vienne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D87",
|
||||||
|
"name": "Haute-Vienne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D88",
|
||||||
|
"name": "Vosges",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D89",
|
||||||
|
"name": "Yonne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D90",
|
||||||
|
"name": "Territoire de Belfort",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D91",
|
||||||
|
"name": "Essonne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D92",
|
||||||
|
"name": "Hauts-de-Seine",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D93",
|
||||||
|
"name": "Seine-Saint-Denis",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D94",
|
||||||
|
"name": "Val-de-Marne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D95",
|
||||||
|
"name": "Val-d'Oise",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -394,6 +394,14 @@ type FlexController interface {
|
|||||||
SetCWSpeed(int) error
|
SetCWSpeed(int) error
|
||||||
SetCWPitch(int) error
|
SetCWPitch(int) error
|
||||||
SetCWBreakInDelay(int) error
|
SetCWBreakInDelay(int) error
|
||||||
|
// CWX keyer — buffered CW keying via SmartSDR's CWX subsystem, so a Flex needs
|
||||||
|
// no WinKeyer / SmartCAT. SendCW queues text (the radio buffers and keys it);
|
||||||
|
// StopCW clears the buffer, aborting the send. BackspaceCW removes the last n
|
||||||
|
// not-yet-keyed characters from the buffer (un-send while sending — for
|
||||||
|
// type-ahead corrections).
|
||||||
|
SendCW(string) error
|
||||||
|
StopCW() error
|
||||||
|
BackspaceCW(int) error
|
||||||
SetCWSidetone(bool) error
|
SetCWSidetone(bool) error
|
||||||
SetSidetoneLevel(int) error
|
SetSidetoneLevel(int) error
|
||||||
SetCWFilter(int) error
|
SetCWFilter(int) error
|
||||||
|
|||||||
@@ -1581,6 +1581,37 @@ func (f *Flex) SetCWBreakInDelay(ms int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendCW queues text on the radio's CWX keyer. SmartSDR buffers and keys it, so
|
||||||
|
// no WinKeyer / SmartCAT is needed — and because the radio owns the buffer,
|
||||||
|
// characters fed while it's already sending append and key in order (the basis
|
||||||
|
// for type-ahead). Quotes/backslashes are escaped for the "cwx send \"…\"" form.
|
||||||
|
func (f *Flex) SendCW(text string) error {
|
||||||
|
text = strings.TrimRight(text, "\r\n")
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
esc := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(text)
|
||||||
|
f.send(`cwx send "` + esc + `"`)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopCW clears the CWX buffer, aborting whatever is currently being keyed.
|
||||||
|
func (f *Flex) StopCW() error {
|
||||||
|
f.send("cwx clear")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackspaceCW removes the last n not-yet-keyed characters from the CWX buffer —
|
||||||
|
// the "un-send while sending" a serial WinKeyer can't do. Used for type-ahead
|
||||||
|
// corrections (backspacing a mistyped call before the radio has keyed it).
|
||||||
|
func (f *Flex) BackspaceCW(n int) error {
|
||||||
|
if n < 1 {
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
f.send(fmt.Sprintf("cwx erase %d", n))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Flex) SetCWSidetone(on bool) error {
|
func (f *Flex) SetCWSidetone(on bool) error {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
f.tx.cwSidetone = on
|
f.tx.cwSidetone = on
|
||||||
|
|||||||
+10
-21
@@ -87,14 +87,6 @@ func (o *OmniRig) Connect() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isIC7610 reports whether the connected rig is an IC-7610. OmniRig's generic
|
|
||||||
// Freq property reads the wrong VFO on the 7610 (its Main/Sub model confuses the
|
|
||||||
// stock ini), so we read VFO A explicitly for it instead — matching what Log4OM
|
|
||||||
// shows.
|
|
||||||
func (o *OmniRig) isIC7610() bool {
|
|
||||||
return strings.Contains(strings.ToUpper(o.rigType), "7610")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *OmniRig) Disconnect() {
|
func (o *OmniRig) Disconnect() {
|
||||||
if o.rig != nil {
|
if o.rig != nil {
|
||||||
o.rig.Release()
|
o.rig.Release()
|
||||||
@@ -204,23 +196,20 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
|||||||
s.FreqHz, s.RxFreqHz = freqB, freqA
|
s.FreqHz, s.RxFreqHz = freqB, freqA
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Simplex: the operating frequency is OmniRig's generic Freq (the active
|
// Simplex: read VFO A first, fall back to the generic Freq — exactly like
|
||||||
// VFO), like Log4OM. Fall back to the per-VFO value only if Freq is 0.
|
// DXHunter/WSJT-X. PM_FREQA rigs (Yaesu, Kenwood) populate FreqA; some
|
||||||
|
// Icoms (IC-9100 etc.) only populate the generic Freq. On the IC-7610
|
||||||
|
// OmniRig's generic Freq reports VFO B (its Main/Sub model confuses the
|
||||||
|
// stock ini), so keying off FreqA gives the operator the VFO they expect.
|
||||||
s.Split = false
|
s.Split = false
|
||||||
s.RxFreqHz = 0
|
s.RxFreqHz = 0
|
||||||
|
switch {
|
||||||
|
case freqA != 0:
|
||||||
|
s.FreqHz = freqA
|
||||||
|
case freqMain != 0:
|
||||||
s.FreqHz = freqMain
|
s.FreqHz = freqMain
|
||||||
// IC-7610 quirk: OmniRig's generic Freq reports VFO B (its Main/Sub model
|
default:
|
||||||
// confuses the stock ini), so OpsLog showed the wrong VFO. Read VFO A
|
|
||||||
// explicitly for the 7610 — what the operator actually wants to see.
|
|
||||||
if o.isIC7610() && freqA != 0 {
|
|
||||||
s.FreqHz = freqA
|
|
||||||
}
|
|
||||||
if s.FreqHz == 0 {
|
|
||||||
if s.Vfo == "B" || s.Vfo == "BB" {
|
|
||||||
s.FreqHz = freqB
|
s.FreqHz = freqB
|
||||||
} else {
|
|
||||||
s.FreqHz = freqA
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
|
|||||||
@@ -1,389 +0,0 @@
|
|||||||
// Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of
|
|
||||||
// mono PCM samples into decoded text. The pipeline is the classic one — a bank
|
|
||||||
// of Goertzel tone detectors, a pitch LOCK that follows a single tone (so QRM
|
|
||||||
// at other pitches is ignored), an adaptive envelope/threshold on the LOCKED
|
|
||||||
// tone (level-independent, so weak or strong signals both key cleanly), an
|
|
||||||
// adaptive dot-length (WPM) estimate, and a timing state machine that maps
|
|
||||||
// marks/spaces to Morse and then to characters.
|
|
||||||
//
|
|
||||||
// It is deliberately self-contained and dependency-free so it can be unit
|
|
||||||
// tested with synthetic signals. As with every audio CW decoder, weak signals
|
|
||||||
// and very heavy QRM still degrade it; the pitch lock keeps QRM on other tones
|
|
||||||
// out of the decode.
|
|
||||||
package cwdecode
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math"
|
|
||||||
"sort"
|
|
||||||
"sync/atomic"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Status is a periodic snapshot for the UI (pitch lock, speed, signal).
|
|
||||||
type Status struct {
|
|
||||||
WPM int `json:"wpm"`
|
|
||||||
Pitch int `json:"pitch"` // Hz of the locked tone (0 = not locked)
|
|
||||||
Level float64 `json:"level"` // 0..1 input audio level (RMS) for the meter
|
|
||||||
Active bool `json:"active"` // a tone is currently keyed down
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decoder consumes PCM and emits decoded characters via onChar (one or more
|
|
||||||
// characters at a time, including " " for word gaps) and periodic onStatus.
|
|
||||||
type Decoder struct {
|
|
||||||
fs int
|
|
||||||
hop int // samples between updates
|
|
||||||
win int // Goertzel window length
|
|
||||||
freqs []float64
|
|
||||||
coeffs []float64 // precomputed 2*cos(w) per freq
|
|
||||||
|
|
||||||
ring []float64 // last win samples
|
|
||||||
acc int // samples since last hop
|
|
||||||
mags []float64 // per-bin magnitude this hop
|
|
||||||
nbuf []float64 // scratch for the noise percentile
|
|
||||||
|
|
||||||
// Fixed-pitch target (Hz). 0 = auto-search; >0 = lock to the nearest bin and
|
|
||||||
// ignore everything else (e.g. follow the radio's CW pitch). Set live from
|
|
||||||
// another goroutine, so it's atomic.
|
|
||||||
targetHz atomic.Int32
|
|
||||||
|
|
||||||
// Pitch lock.
|
|
||||||
lockIdx int // index of the locked tone bin, -1 = unlocked
|
|
||||||
candIdx int // current argmax candidate while unlocked
|
|
||||||
candHops int // consecutive hops the candidate has been dominant
|
|
||||||
quietHops int // consecutive key-up hops while locked
|
|
||||||
noise float64 // broadband noise estimate (percentile of bins)
|
|
||||||
relockHops int // quiet hops before the lock is released
|
|
||||||
acqSNR float64 // tone/noise ratio to acquire after a few stable hops
|
|
||||||
strongSNR float64 // tone/noise ratio to lock immediately (1 hop)
|
|
||||||
|
|
||||||
// Adaptive keying envelope, on the LOCKED bin's magnitude.
|
|
||||||
peak, floor float64
|
|
||||||
state bool // true = mark (key down)
|
|
||||||
stateHops int
|
|
||||||
dotHops float64 // adaptive dot length, in hops
|
|
||||||
markCount int // marks seen since lock (fast WPM adaptation while small)
|
|
||||||
elem []byte // current "." / "-" run for the in-progress character
|
|
||||||
charEmitted bool
|
|
||||||
wordEmitted bool
|
|
||||||
|
|
||||||
lastPitch float64
|
|
||||||
lastRMS float64
|
|
||||||
|
|
||||||
statusEvery int
|
|
||||||
sinceStatus int
|
|
||||||
|
|
||||||
onChar func(string)
|
|
||||||
onStatus func(Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
var morse = map[string]byte{
|
|
||||||
".-": 'A', "-...": 'B', "-.-.": 'C', "-..": 'D', ".": 'E', "..-.": 'F',
|
|
||||||
"--.": 'G', "....": 'H', "..": 'I', ".---": 'J', "-.-": 'K', ".-..": 'L',
|
|
||||||
"--": 'M', "-.": 'N', "---": 'O', ".--.": 'P', "--.-": 'Q', ".-.": 'R',
|
|
||||||
"...": 'S', "-": 'T', "..-": 'U', "...-": 'V', ".--": 'W', "-..-": 'X',
|
|
||||||
"-.--": 'Y', "--..": 'Z',
|
|
||||||
"-----": '0', ".----": '1', "..---": '2', "...--": '3', "....-": '4',
|
|
||||||
".....": '5', "-....": '6', "--...": '7', "---..": '8', "----.": '9',
|
|
||||||
".-.-.-": '.', "--..--": ',', "..--..": '?', "-..-.": '/', "-...-": '=',
|
|
||||||
".-.-.": '+', "-.-.--": '!', "---...": ':', "-....-": '-', ".--.-.": '@',
|
|
||||||
}
|
|
||||||
|
|
||||||
// New builds a decoder for the given sample rate. onChar receives decoded text
|
|
||||||
// incrementally; onStatus receives ~10 snapshots/second. Either may be nil.
|
|
||||||
func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
|
|
||||||
if sampleRate <= 0 {
|
|
||||||
sampleRate = 16000
|
|
||||||
}
|
|
||||||
d := &Decoder{
|
|
||||||
fs: sampleRate,
|
|
||||||
hop: sampleRate / 250, // ~4 ms resolution
|
|
||||||
win: sampleRate / 72, // ~14 ms Goertzel window (selective, fairly snappy)
|
|
||||||
dotHops: 15, // ~20 WPM seed
|
|
||||||
acqSNR: 1.9, // ignore noise spikes (looser locked onto noise = garbage)
|
|
||||||
strongSNR: 3.2, // only a genuinely strong tone locks in 1 hop
|
|
||||||
lockIdx: -1,
|
|
||||||
candIdx: -1,
|
|
||||||
statusEvery: 25, // ~10 Hz
|
|
||||||
onChar: onChar,
|
|
||||||
onStatus: onStatus,
|
|
||||||
}
|
|
||||||
if d.hop < 1 {
|
|
||||||
d.hop = 1
|
|
||||||
}
|
|
||||||
d.relockHops = int(0.8 * float64(d.fs) / float64(d.hop)) // release lock after ~0.8 s quiet
|
|
||||||
// Candidate CW tones: 400–1000 Hz every 25 Hz. Deliberately NOT lower: strong
|
|
||||||
// low-frequency noise/hum (pink/red noise rises toward DC) would otherwise win
|
|
||||||
// the argmax and lock the decoder onto ~250 Hz junk instead of the signal.
|
|
||||||
for f := 400.0; f <= 1000.0; f += 25 {
|
|
||||||
d.freqs = append(d.freqs, f)
|
|
||||||
d.coeffs = append(d.coeffs, 2*math.Cos(2*math.Pi*f/float64(d.fs)))
|
|
||||||
}
|
|
||||||
d.mags = make([]float64, len(d.freqs))
|
|
||||||
d.nbuf = make([]float64, len(d.freqs))
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetTarget fixes the decode pitch to hz (lock to the nearest bin, ignore other
|
|
||||||
// tones), or returns to auto-search when hz <= 0. Safe to call concurrently.
|
|
||||||
func (d *Decoder) SetTarget(hz int) { d.targetHz.Store(int32(hz)) }
|
|
||||||
|
|
||||||
// nearestBin returns the bin index closest to hz.
|
|
||||||
func (d *Decoder) nearestBin(hz float64) int {
|
|
||||||
best, bestD := 0, math.Inf(1)
|
|
||||||
for i, f := range d.freqs {
|
|
||||||
if dd := math.Abs(f - hz); dd < bestD {
|
|
||||||
bestD, best = dd, i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset clears decode state (e.g. when the user re-arms the decoder).
|
|
||||||
func (d *Decoder) Reset() {
|
|
||||||
d.ring = d.ring[:0]
|
|
||||||
d.acc = 0
|
|
||||||
d.lockIdx, d.candIdx, d.candHops, d.quietHops = -1, -1, 0, 0
|
|
||||||
d.peak, d.floor = 0, 0
|
|
||||||
d.state = false
|
|
||||||
d.stateHops = 0
|
|
||||||
d.dotHops = 15
|
|
||||||
d.markCount = 0
|
|
||||||
d.elem = d.elem[:0]
|
|
||||||
d.charEmitted, d.wordEmitted = false, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process feeds a block of mono samples through the decoder.
|
|
||||||
func (d *Decoder) Process(samples []int16) {
|
|
||||||
for _, s := range samples {
|
|
||||||
d.ring = append(d.ring, float64(s))
|
|
||||||
if len(d.ring) > d.win {
|
|
||||||
d.ring = d.ring[len(d.ring)-d.win:]
|
|
||||||
}
|
|
||||||
d.acc++
|
|
||||||
if d.acc >= d.hop && len(d.ring) >= d.win {
|
|
||||||
d.acc = 0
|
|
||||||
d.analyze()
|
|
||||||
d.step()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// analyze runs the Goertzel bank, estimates the noise floor, and maintains the
|
|
||||||
// pitch lock (which tone the envelope detector then follows).
|
|
||||||
func (d *Decoder) analyze() {
|
|
||||||
n := float64(len(d.ring))
|
|
||||||
var sumSq float64
|
|
||||||
maxIdx, maxMag := 0, -1.0
|
|
||||||
for i, coeff := range d.coeffs {
|
|
||||||
var s1, s2 float64
|
|
||||||
for _, x := range d.ring {
|
|
||||||
s0 := x + coeff*s1 - s2
|
|
||||||
s2 = s1
|
|
||||||
s1 = s0
|
|
||||||
}
|
|
||||||
m := math.Sqrt(math.Max(s1*s1+s2*s2-coeff*s1*s2, 0)) / n
|
|
||||||
d.mags[i] = m
|
|
||||||
if m > maxMag {
|
|
||||||
maxMag = m
|
|
||||||
maxIdx = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, x := range d.ring {
|
|
||||||
sumSq += x * x
|
|
||||||
}
|
|
||||||
d.lastRMS = math.Min(1, math.Sqrt(sumSq/n)/32768*4)
|
|
||||||
|
|
||||||
// Fixed-pitch mode: lock straight to the target bin, skip the auto search.
|
|
||||||
// A narrow filter at the known pitch is exactly how a skimmer avoids QRM.
|
|
||||||
if th := int(d.targetHz.Load()); th > 0 {
|
|
||||||
d.lockIdx = d.nearestBin(float64(th))
|
|
||||||
d.lastPitch = d.freqs[d.lockIdx]
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Noise floor = 40th percentile of the bins (robust to a few strong tones).
|
|
||||||
copy(d.nbuf, d.mags)
|
|
||||||
sort.Float64s(d.nbuf)
|
|
||||||
d.noise = d.nbuf[int(0.4*float64(len(d.nbuf)-1)+0.5)]
|
|
||||||
|
|
||||||
if d.lockIdx < 0 {
|
|
||||||
if maxIdx == d.candIdx {
|
|
||||||
d.candHops++
|
|
||||||
} else {
|
|
||||||
d.candIdx, d.candHops = maxIdx, 1
|
|
||||||
}
|
|
||||||
snr := maxMag / (d.noise + 1e-9)
|
|
||||||
// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so we
|
|
||||||
// don't eat the first element of a strong signal), a marginal/weak tone
|
|
||||||
// locks after a couple of stable hops (so we don't lock onto pure noise).
|
|
||||||
if snr > d.strongSNR || (d.candHops >= 2 && snr > d.acqSNR) {
|
|
||||||
d.lockIdx = maxIdx
|
|
||||||
d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
|
|
||||||
d.quietHops = 0
|
|
||||||
d.markCount = 0 // relearn WPM fast for this new signal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if d.lockIdx >= 0 {
|
|
||||||
d.lastPitch = d.freqs[d.lockIdx]
|
|
||||||
} else {
|
|
||||||
d.lastPitch = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// step runs the adaptive envelope on the locked bin and the timing state
|
|
||||||
// machine, one hop. The envelope adapts to the signal level (not an absolute
|
|
||||||
// threshold), so weak and strong signals both key correctly.
|
|
||||||
func (d *Decoder) step() {
|
|
||||||
on := false
|
|
||||||
if d.lockIdx >= 0 {
|
|
||||||
m := d.mags[d.lockIdx]
|
|
||||||
// Peak: fast attack, slow release.
|
|
||||||
if m > d.peak {
|
|
||||||
d.peak += (m - d.peak) * 0.4
|
|
||||||
} else {
|
|
||||||
d.peak += (m - d.peak) * 0.02
|
|
||||||
}
|
|
||||||
// Floor: drops fast toward the signal, but only RISES between marks (when
|
|
||||||
// keyed up). Letting the floor rise during a long dash would shrink the
|
|
||||||
// span until the dash drops below the threshold and fragments into dots —
|
|
||||||
// the cause of the "all dots" garbage on a strong clean signal.
|
|
||||||
if m < d.floor {
|
|
||||||
d.floor += (m - d.floor) * 0.4
|
|
||||||
} else if !d.state {
|
|
||||||
d.floor += (m - d.floor) * 0.02
|
|
||||||
}
|
|
||||||
span := d.peak - d.floor
|
|
||||||
// The frozen floor already stops dashes fragmenting, so keep balanced
|
|
||||||
// thresholds: low enough that short inter-element GAPS are still seen
|
|
||||||
// (otherwise elements merge into >7-symbol runs that decode to nothing).
|
|
||||||
if span > d.floor*0.3+1e-9 {
|
|
||||||
onTh := d.floor + 0.55*span
|
|
||||||
offTh := d.floor + 0.35*span
|
|
||||||
if d.state {
|
|
||||||
on = m > offTh
|
|
||||||
} else {
|
|
||||||
on = m > onTh
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Release the lock after a long quiet so we can retune to a new signal.
|
|
||||||
if on {
|
|
||||||
d.quietHops = 0
|
|
||||||
} else {
|
|
||||||
d.quietHops++
|
|
||||||
if d.quietHops > d.relockHops {
|
|
||||||
// End of the over: flush any pending character and drop a word
|
|
||||||
// space so the next transmission starts a fresh word (the word-gap
|
|
||||||
// timer above can't fire once the lock is gone).
|
|
||||||
if len(d.elem) > 0 && !d.charEmitted {
|
|
||||||
d.flushChar()
|
|
||||||
d.charEmitted = true
|
|
||||||
}
|
|
||||||
if !d.wordEmitted && d.onChar != nil {
|
|
||||||
d.onChar(" ")
|
|
||||||
d.wordEmitted = true
|
|
||||||
}
|
|
||||||
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if on == d.state {
|
|
||||||
d.stateHops++
|
|
||||||
if !d.state {
|
|
||||||
d.spaceProgress()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if d.state {
|
|
||||||
d.endMark(d.stateHops)
|
|
||||||
}
|
|
||||||
d.state = on
|
|
||||||
d.stateHops = 1
|
|
||||||
if on {
|
|
||||||
d.charEmitted, d.wordEmitted = false, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
d.emitStatus(on)
|
|
||||||
}
|
|
||||||
|
|
||||||
// endMark classifies a finished key-down run as a dot or dash and adapts the
|
|
||||||
// dot-length estimate. Runs shorter than a third of a dot are rejected as
|
|
||||||
// clicks/noise.
|
|
||||||
func (d *Decoder) endMark(hops int) {
|
|
||||||
h := float64(hops)
|
|
||||||
// Reject clicks/noise: shorter than a third of a dot AND an absolute floor
|
|
||||||
// of ~4 hops (~16 ms, i.e. faster than ~75 WPM) so noise can't drag the
|
|
||||||
// dot-length estimate down to the clamp (which produced 100 WPM garbage).
|
|
||||||
if h < d.dotHops*0.35 || h < 4 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if h > d.dotHops*2 {
|
|
||||||
d.elem = append(d.elem, '-')
|
|
||||||
d.adaptDot(h / 3)
|
|
||||||
} else {
|
|
||||||
d.elem = append(d.elem, '.')
|
|
||||||
d.adaptDot(h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// adaptDot nudges the dot-length estimate toward an observation (EMA, clamped
|
|
||||||
// to ~5–60 WPM). The EMA is deliberately GENTLE (0.2) and NOT accelerated on the
|
|
||||||
// opening marks: a fast alpha let short noise blips (misclassified as dots) drag
|
|
||||||
// the dot-length down to the clamp within a few marks — the "60 WPM, all dits"
|
|
||||||
// garbage. The slow EMA is self-correcting because genuine marks pull it back up.
|
|
||||||
func (d *Decoder) adaptDot(obs float64) {
|
|
||||||
const alpha = 0.2
|
|
||||||
d.markCount++
|
|
||||||
d.dotHops = d.dotHops*(1-alpha) + obs*alpha
|
|
||||||
if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
|
|
||||||
d.dotHops = 5
|
|
||||||
}
|
|
||||||
if d.dotHops > 55 {
|
|
||||||
d.dotHops = 55
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// spaceProgress flushes the current character once the gap exceeds a character
|
|
||||||
// gap, and a word space once it exceeds a word gap.
|
|
||||||
func (d *Decoder) spaceProgress() {
|
|
||||||
g := float64(d.stateHops)
|
|
||||||
if !d.charEmitted && g > d.dotHops*2 {
|
|
||||||
d.flushChar()
|
|
||||||
d.charEmitted = true
|
|
||||||
}
|
|
||||||
if !d.wordEmitted && g > d.dotHops*5 {
|
|
||||||
if d.onChar != nil {
|
|
||||||
d.onChar(" ")
|
|
||||||
}
|
|
||||||
d.wordEmitted = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// flushChar looks up the accumulated element string and emits the character.
|
|
||||||
func (d *Decoder) flushChar() {
|
|
||||||
if len(d.elem) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if c, ok := morse[string(d.elem)]; ok {
|
|
||||||
if d.onChar != nil {
|
|
||||||
d.onChar(string(c))
|
|
||||||
}
|
|
||||||
} else if d.onChar != nil && len(d.elem) <= 7 {
|
|
||||||
// Only flag a genuinely Morse-shaped but unknown char with "?". An
|
|
||||||
// over-long element run is noise — drop it silently rather than spam "?".
|
|
||||||
d.onChar("?")
|
|
||||||
}
|
|
||||||
d.elem = d.elem[:0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Decoder) emitStatus(on bool) {
|
|
||||||
d.sinceStatus++
|
|
||||||
if d.sinceStatus < d.statusEvery || d.onStatus == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d.sinceStatus = 0
|
|
||||||
hopMs := float64(d.hop) / float64(d.fs) * 1000
|
|
||||||
wpm := 0
|
|
||||||
if d.dotHops > 0 {
|
|
||||||
wpm = int(math.Round(1200 / (d.dotHops * hopMs)))
|
|
||||||
}
|
|
||||||
d.onStatus(Status{WPM: wpm, Pitch: int(math.Round(d.lastPitch)), Level: d.lastRMS, Active: on})
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
package cwdecode
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// reverse Morse map for the synthesizer.
|
|
||||||
func charToMorse() map[byte]string {
|
|
||||||
m := map[byte]string{}
|
|
||||||
for code, ch := range morse {
|
|
||||||
m[ch] = code
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// keyMessage synthesizes a clean keyed tone for msg at the given WPM/pitch.
|
|
||||||
func keyMessage(msg string, fs, wpm int, pitch float64) []int16 {
|
|
||||||
return keyMessageAmp(msg, fs, wpm, pitch, 9000)
|
|
||||||
}
|
|
||||||
|
|
||||||
func keyMessageAmp(msg string, fs, wpm int, pitch, amp float64) []int16 {
|
|
||||||
dot := fs * 1200 / (wpm * 1000) // samples per dot
|
|
||||||
c2m := charToMorse()
|
|
||||||
var out []int16
|
|
||||||
phase := 0.0
|
|
||||||
dphi := 2 * math.Pi * pitch / float64(fs)
|
|
||||||
|
|
||||||
tone := func(n int) {
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
out = append(out, int16(amp*math.Sin(phase)))
|
|
||||||
phase += dphi
|
|
||||||
}
|
|
||||||
}
|
|
||||||
silence := func(n int) {
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
out = append(out, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
silence(fs / 4) // 250 ms lead-in for AGC warmup
|
|
||||||
for i := 0; i < len(msg); i++ {
|
|
||||||
ch := msg[i]
|
|
||||||
if ch == ' ' {
|
|
||||||
silence(7 * dot)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
code := c2m[ch]
|
|
||||||
for j := 0; j < len(code); j++ {
|
|
||||||
if code[j] == '.' {
|
|
||||||
tone(dot)
|
|
||||||
} else {
|
|
||||||
tone(3 * dot)
|
|
||||||
}
|
|
||||||
silence(dot) // inter-element gap
|
|
||||||
}
|
|
||||||
silence(3 * dot) // inter-character gap (on top of the trailing element gap)
|
|
||||||
}
|
|
||||||
silence(fs / 4)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeCleanSignal(t *testing.T) {
|
|
||||||
const fs = 16000
|
|
||||||
var sb strings.Builder
|
|
||||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
|
||||||
|
|
||||||
// Repeat so AGC warm-up only costs the first word.
|
|
||||||
samples := keyMessage("PARIS PARIS PARIS", fs, 22, 700)
|
|
||||||
// Feed in small chunks like the live capture would.
|
|
||||||
for i := 0; i < len(samples); i += 256 {
|
|
||||||
end := i + 256
|
|
||||||
if end > len(samples) {
|
|
||||||
end = len(samples)
|
|
||||||
}
|
|
||||||
d.Process(samples[i:end])
|
|
||||||
}
|
|
||||||
|
|
||||||
got := strings.ToUpper(sb.String())
|
|
||||||
if !strings.Contains(got, "PARIS") {
|
|
||||||
t.Fatalf("decoded %q, want it to contain PARIS", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeWithQRM(t *testing.T) {
|
|
||||||
const fs = 16000
|
|
||||||
// Target at 700 Hz; a strong interfering keyed signal at 950 Hz, slightly
|
|
||||||
// quieter, sending different text. The pitch lock should hold on the target.
|
|
||||||
target := keyMessageAmp("PARIS PARIS PARIS", fs, 20, 700, 9000)
|
|
||||||
qrm := keyMessageAmp("BK DE QRZ QRZ TEST", fs, 26, 950, 6500)
|
|
||||||
mix := make([]int16, len(target))
|
|
||||||
for i := range target {
|
|
||||||
v := int(target[i])
|
|
||||||
if i < len(qrm) {
|
|
||||||
v += int(qrm[i])
|
|
||||||
}
|
|
||||||
if v > 32767 {
|
|
||||||
v = 32767
|
|
||||||
} else if v < -32768 {
|
|
||||||
v = -32768
|
|
||||||
}
|
|
||||||
mix[i] = int16(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
|
||||||
for i := 0; i < len(mix); i += 256 {
|
|
||||||
end := i + 256
|
|
||||||
if end > len(mix) {
|
|
||||||
end = len(mix)
|
|
||||||
}
|
|
||||||
d.Process(mix[i:end])
|
|
||||||
}
|
|
||||||
got := strings.ToUpper(sb.String())
|
|
||||||
if !strings.Contains(got, "PARIS") {
|
|
||||||
t.Fatalf("with QRM, decoded %q, want it to contain PARIS", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeFirstCharStrong(t *testing.T) {
|
|
||||||
const fs = 16000
|
|
||||||
var sb strings.Builder
|
|
||||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
|
||||||
// Strong signal: the very first element (T = a dash) must not be eaten by
|
|
||||||
// lock acquisition. Output should begin with the first character.
|
|
||||||
samples := keyMessageAmp("TEST DE", fs, 20, 700, 16000)
|
|
||||||
for i := 0; i < len(samples); i += 200 {
|
|
||||||
end := i + 200
|
|
||||||
if end > len(samples) {
|
|
||||||
end = len(samples)
|
|
||||||
}
|
|
||||||
d.Process(samples[i:end])
|
|
||||||
}
|
|
||||||
got := strings.ToUpper(strings.TrimSpace(sb.String()))
|
|
||||||
if !strings.HasPrefix(got, "TEST") {
|
|
||||||
t.Fatalf("first chars lost on a strong signal: decoded %q, want it to start with TEST", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeWithAmplitudeRipple(t *testing.T) {
|
|
||||||
const fs = 16000
|
|
||||||
// A real signal's tone amplitude wobbles within a mark; if the floor chases
|
|
||||||
// it, dashes fragment into dots ("all dots" garbage). Apply ±30% ripple.
|
|
||||||
samples := keyMessageAmp("CQ TEST DE OM", fs, 24, 800, 10000)
|
|
||||||
rp := 0.0
|
|
||||||
for i := range samples {
|
|
||||||
rp += 2 * math.Pi * 35 / float64(fs) // 35 Hz amplitude wobble
|
|
||||||
samples[i] = int16(float64(samples[i]) * (1 + 0.3*math.Sin(rp)))
|
|
||||||
}
|
|
||||||
var sb strings.Builder
|
|
||||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
|
||||||
for i := 0; i < len(samples); i += 256 {
|
|
||||||
end := i + 256
|
|
||||||
if end > len(samples) {
|
|
||||||
end = len(samples)
|
|
||||||
}
|
|
||||||
d.Process(samples[i:end])
|
|
||||||
}
|
|
||||||
got := strings.ToUpper(sb.String())
|
|
||||||
if !strings.Contains(got, "TEST DE OM") {
|
|
||||||
t.Fatalf("dashes fragmented under amplitude ripple: decoded %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeCQFixedPitch(t *testing.T) {
|
|
||||||
const fs = 16000
|
|
||||||
var sb strings.Builder
|
|
||||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
|
||||||
d.SetTarget(700) // fixed pitch like the user's manual override
|
|
||||||
samples := keyMessageAmp("CQ CQ CQ DE OM", fs, 26, 700, 9000)
|
|
||||||
for i := 0; i < len(samples); i += 200 {
|
|
||||||
end := i + 200
|
|
||||||
if end > len(samples) {
|
|
||||||
end = len(samples)
|
|
||||||
}
|
|
||||||
d.Process(samples[i:end])
|
|
||||||
}
|
|
||||||
got := strings.ToUpper(sb.String())
|
|
||||||
if n := strings.Count(got, "CQ"); n < 2 {
|
|
||||||
t.Fatalf("first element of CQ dropped: decoded %q (only %d CQ)", got, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeNumbersAndProsign(t *testing.T) {
|
|
||||||
const fs = 16000
|
|
||||||
var sb strings.Builder
|
|
||||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
|
||||||
samples := keyMessage("TEST 599 TEST", fs, 18, 650)
|
|
||||||
for i := 0; i < len(samples); i += 200 {
|
|
||||||
end := i + 200
|
|
||||||
if end > len(samples) {
|
|
||||||
end = len(samples)
|
|
||||||
}
|
|
||||||
d.Process(samples[i:end])
|
|
||||||
}
|
|
||||||
got := strings.ToUpper(sb.String())
|
|
||||||
if !strings.Contains(got, "599") {
|
|
||||||
t.Fatalf("decoded %q, want it to contain 599", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+19
-7
@@ -198,14 +198,26 @@ func migrate(conn *sql.DB, translate func(string) string) error {
|
|||||||
}
|
}
|
||||||
sort.Strings(names)
|
sort.Strings(names)
|
||||||
|
|
||||||
for _, name := range names {
|
// Fetch every applied migration name in ONE query rather than one round-trip
|
||||||
var dummy string
|
// per migration. On a high-latency remote MySQL those 20+ SELECTs dominated the
|
||||||
err := conn.QueryRow(`SELECT name FROM schema_migrations WHERE name = ?`, name).Scan(&dummy)
|
// connect time (each RTT × migration count) — the whole logbook connect could
|
||||||
if err == nil {
|
// take tens of seconds. One SELECT + an in-memory set is effectively instant.
|
||||||
continue // already applied
|
applied := map[string]bool{}
|
||||||
|
if rows, err := conn.Query(`SELECT name FROM schema_migrations`); err == nil {
|
||||||
|
for rows.Next() {
|
||||||
|
var n string
|
||||||
|
if rows.Scan(&n) == nil {
|
||||||
|
applied[n] = true
|
||||||
}
|
}
|
||||||
if err != sql.ErrNoRows {
|
}
|
||||||
return fmt.Errorf("check migration %s: %w", name, err)
|
rows.Close()
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("read applied migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
if applied[name] {
|
||||||
|
continue // already applied
|
||||||
}
|
}
|
||||||
content, err := migrationsFS.ReadFile("migrations/" + name)
|
content, err := migrationsFS.ReadFile("migrations/" + name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Materialised award references per QSO. As soon as a QSO matches an award
|
||||||
|
-- (via the operator's award definitions) or a reference is set by hand, the
|
||||||
|
-- resolved reference(s) are stored here as a compact JSON object keyed by award
|
||||||
|
-- code, e.g. {"DDFM":"74","WAJA":"12"}. The grid's per-award columns then read
|
||||||
|
-- straight from the row like any other column instead of recomputing the whole
|
||||||
|
-- award engine on every page load — much faster, and easy to display anywhere
|
||||||
|
-- (including the shared MySQL logbook). Kept in step by the app: written on log
|
||||||
|
-- / edit / UDP-import and bulk-recomputed when an award definition or reference
|
||||||
|
-- list changes. SQLite ADD COLUMN is metadata-only, fast even on large logbooks.
|
||||||
|
ALTER TABLE qso ADD COLUMN award_refs TEXT;
|
||||||
+39
-16
@@ -129,6 +129,28 @@ func translateTextColumns(s string) string {
|
|||||||
// OpenMySQL opens the shared MySQL logbook, creating the database if needed,
|
// OpenMySQL opens the shared MySQL logbook, creating the database if needed,
|
||||||
// then applies the (translated) embedded migrations. multiStatements is enabled
|
// then applies the (translated) embedded migrations. multiStatements is enabled
|
||||||
// so multi-statement migration files run in a single Exec.
|
// so multi-statement migration files run in a single Exec.
|
||||||
|
// tuneMySQLPool sizes the connection pool for a SHARED server. Two opposing needs:
|
||||||
|
// - A big per-instance pool (we shipped 50) let a handful of ops exhaust the
|
||||||
|
// server's global max_connections — "Error 1040: Too many connections".
|
||||||
|
// - Too SMALL a pool starves this instance's OWN concurrent UI queries — the live
|
||||||
|
// multi-op sync (revision poll every 2s + a 3-query grid refresh), live_status,
|
||||||
|
// chat, stats, cluster. Under real multi-op load, 8 and even 16 stalled: hot
|
||||||
|
// paths (WorkedBefore fires ~10 sequential round-trips; every poll uses the
|
||||||
|
// app-lifetime ctx with no per-query timeout) hold a connection for the whole
|
||||||
|
// chain of remote-MySQL latency, so a handful of concurrent UI bursts drained
|
||||||
|
// the pool and "after a while nothing updated" — grid, chat, live_status froze.
|
||||||
|
// 40 fits the actual deployment (max 5 ops on a server with max_connections=300 →
|
||||||
|
// 40*5 = 200, leaving ~100 for phpMyAdmin/server overhead). This is the per-INSTANCE
|
||||||
|
// pool, so keep max_connections >= pool*ops + headroom.
|
||||||
|
// Brisk idle reaping returns slots to the server; no max lifetime so a slow first
|
||||||
|
// migration on one connection isn't reaped mid-run (drops the selected database).
|
||||||
|
func tuneMySQLPool(conn *sql.DB) {
|
||||||
|
conn.SetMaxOpenConns(40)
|
||||||
|
conn.SetMaxIdleConns(8)
|
||||||
|
conn.SetConnMaxIdleTime(30 * time.Second)
|
||||||
|
conn.SetConnMaxLifetime(0)
|
||||||
|
}
|
||||||
|
|
||||||
func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
|
func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
|
||||||
if strings.TrimSpace(c.Host) == "" {
|
if strings.TrimSpace(c.Host) == "" {
|
||||||
return nil, fmt.Errorf("host is required")
|
return nil, fmt.Errorf("host is required")
|
||||||
@@ -137,30 +159,31 @@ 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)
|
||||||
}
|
}
|
||||||
// The UI fires bursts of concurrent queries (the Preferences dialog alone
|
tuneMySQLPool(conn)
|
||||||
// loads ~8 settings in parallel, plus the grid and live cluster status). A
|
// Connect DIRECTLY to the database first. Only if that fails — the DB doesn't
|
||||||
// low cap turns such a burst into a deadlock-like stall against a remote
|
// exist yet (first-time setup) or the server is unreachable — do we pay for the
|
||||||
// server, so keep a generous pool with idle reaping. SQLite ran uncapped.
|
// server-level connect + CREATE DATABASE (two more handshakes). On a normal
|
||||||
conn.SetMaxOpenConns(50)
|
// startup against an existing DB this halves the connection round-trips, which
|
||||||
conn.SetMaxIdleConns(10)
|
// matters a lot on a high-latency remote MySQL (each handshake is several RTTs).
|
||||||
conn.SetConnMaxIdleTime(90 * time.Second)
|
if err := conn.Ping(); err != nil {
|
||||||
// No max lifetime: a slow server's first migration can run for minutes on a
|
_ = conn.Close()
|
||||||
// single connection, and reaping it mid-migration drops the selected database
|
if perr := PingMySQL(c); perr != nil {
|
||||||
// (surfacing as "Unknown database"). Idle connections are still recycled
|
return nil, perr // creates the DB (or returns a clear "cannot create" error)
|
||||||
// after 90s, and the driver retries stale pooled connections.
|
}
|
||||||
conn.SetConnMaxLifetime(0)
|
conn, err = sql.Open("mysql", c.dsn())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open mysql: %w", err)
|
||||||
|
}
|
||||||
|
tuneMySQLPool(conn)
|
||||||
if err := conn.Ping(); err != nil {
|
if err := conn.Ping(); err != nil {
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
return nil, fmt.Errorf("connect to %s: %w", name, err)
|
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.
|
||||||
Dialect = "mysql"
|
Dialect = "mysql"
|
||||||
|
|||||||
+69
-15
@@ -197,6 +197,14 @@ type QSO struct {
|
|||||||
// ADIF field names (e.g. "MS_SHOWER"); values are the raw string content.
|
// ADIF field names (e.g. "MS_SHOWER"); values are the raw string content.
|
||||||
Extras map[string]string `json:"extras,omitempty"`
|
Extras map[string]string `json:"extras,omitempty"`
|
||||||
|
|
||||||
|
// AwardRefs is the materialised award-reference JSON for this QSO — a compact
|
||||||
|
// object keyed by award code, e.g. {"DDFM":"74","WAJA":"12"}. Derived (the app
|
||||||
|
// computes it on log/edit and bulk-recomputes it when awards change) and stored
|
||||||
|
// so the grid's award columns read straight from the row. Written ONLY via
|
||||||
|
// SetAwardRefs, never through the normal insert/update column list, so an edit
|
||||||
|
// that doesn't know about it can't clobber it.
|
||||||
|
AwardRefs string `json:"award_refs,omitempty"`
|
||||||
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -250,7 +258,10 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
|
|||||||
credit_granted, credit_submitted, my_arrl_sect, my_vucc_grids,
|
credit_granted, credit_submitted, my_arrl_sect, my_vucc_grids,
|
||||||
extras_json`
|
extras_json`
|
||||||
|
|
||||||
const selectCols = `id, ` + columnList + `, created_at, updated_at`
|
// award_refs is read here but is NOT part of columnList (the insert/update
|
||||||
|
// write path) — it is a derived cache written only via SetAwardRefs, so a
|
||||||
|
// normal QSO write can never clobber it.
|
||||||
|
const selectCols = `id, ` + columnList + `, award_refs, created_at, updated_at`
|
||||||
|
|
||||||
// columnCount is derived from columnList at init so they can never drift.
|
// columnCount is derived from columnList at init so they can never drift.
|
||||||
var columnCount = countColumns(columnList)
|
var columnCount = countColumns(columnList)
|
||||||
@@ -813,6 +824,49 @@ func (r *Repo) SetExtra(ctx context.Context, id int64, key, value string) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetAwardRefs stores the materialised award-reference JSON for one QSO. Like
|
||||||
|
// SetExtra it is a targeted single-column UPDATE — never a full-row write — so it
|
||||||
|
// cannot clobber a field another action changed meanwhile. updated_at is left
|
||||||
|
// UNTOUCHED on purpose: award_refs is a derived cache, and bumping updated_at
|
||||||
|
// would masquerade as a real edit (re-triggering uploads / sync that watch it).
|
||||||
|
func (r *Repo) SetAwardRefs(ctx context.Context, id int64, jsonStr string) error {
|
||||||
|
if id == 0 {
|
||||||
|
return fmt.Errorf("missing id")
|
||||||
|
}
|
||||||
|
if _, err := r.db.ExecContext(ctx,
|
||||||
|
`UPDATE qso SET award_refs = ? WHERE id = ?`, jsonStr, id); err != nil {
|
||||||
|
return fmt.Errorf("set award_refs: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAwardRefsBatch stores materialised award refs for many QSOs in a single
|
||||||
|
// transaction — used by the bulk recompute so a large logbook on a remote MySQL
|
||||||
|
// is one round-trip's worth of work, not N. Like SetAwardRefs it touches only the
|
||||||
|
// award_refs column and leaves updated_at alone (derived cache). A nil/empty map
|
||||||
|
// is a no-op.
|
||||||
|
func (r *Repo) SetAwardRefsBatch(ctx context.Context, byID map[int64]string) error {
|
||||||
|
if len(byID) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
stmt, err := tx.PrepareContext(ctx, `UPDATE qso SET award_refs = ? WHERE id = ?`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("prepare: %w", err)
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
for id, js := range byID {
|
||||||
|
if _, err := stmt.ExecContext(ctx, js, id); err != nil {
|
||||||
|
return fmt.Errorf("set award_refs %d: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
|
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
|
||||||
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
||||||
if q.ID == 0 {
|
if q.ID == 0 {
|
||||||
@@ -1900,24 +1954,22 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
|
|||||||
// operator who just worked someone before (re)starting OpsLog shows online right
|
// operator who just worked someone before (re)starting OpsLog shows online right
|
||||||
// away instead of waiting for their next QSO. Empty operator matches every QSO.
|
// away instead of waiting for their next QSO. Empty operator matches every QSO.
|
||||||
func (r *Repo) LastQSOTime(ctx context.Context, operator string) (time.Time, bool) {
|
func (r *Repo) LastQSOTime(ctx context.Context, operator string) (time.Time, bool) {
|
||||||
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 400`)
|
// ONE indexed row, not 400 filtered in Go: this runs every ~5 s (the ON-AIR
|
||||||
if err != nil {
|
// badge) and ~15 s (live-status publish), so pulling 400 rows each time over a
|
||||||
return time.Time{}, false
|
// remote MySQL hammered the connection pool and, on a busy multi-op event, was a
|
||||||
}
|
// big part of "after a while nothing updates". Match the operator the same
|
||||||
defer rows.Close()
|
// (case/space-insensitive) way, newest first.
|
||||||
opFilter := strings.ToUpper(strings.TrimSpace(operator))
|
opFilter := strings.ToUpper(strings.TrimSpace(operator))
|
||||||
for rows.Next() {
|
var dateStr sql.NullString
|
||||||
var oper, dateStr sql.NullString
|
err := r.db.QueryRowContext(ctx,
|
||||||
if err := rows.Scan(&oper, &dateStr); err != nil {
|
`SELECT qso_date FROM qso WHERE UPPER(TRIM(operator)) = ? AND qso_date IS NOT NULL AND qso_date <> '' ORDER BY id DESC LIMIT 1`,
|
||||||
return time.Time{}, false
|
opFilter).Scan(&dateStr)
|
||||||
}
|
if err != nil {
|
||||||
if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter {
|
return time.Time{}, false // ErrNoRows or a real error — no known last QSO
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if t := parseTimeLoose(dateStr.String).UTC(); !t.IsZero() {
|
if t := parseTimeLoose(dateStr.String).UTC(); !t.IsZero() {
|
||||||
return t, true
|
return t, true
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return time.Time{}, false
|
return time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2374,6 +2426,7 @@ func scanQSO(s scanner) (QSO, error) {
|
|||||||
creditGranted, creditSubmitted sql.NullString
|
creditGranted, creditSubmitted sql.NullString
|
||||||
myARRLSect, myVUCCGrids sql.NullString
|
myARRLSect, myVUCCGrids sql.NullString
|
||||||
extrasJSON sql.NullString
|
extrasJSON sql.NullString
|
||||||
|
awardRefs sql.NullString
|
||||||
createdStr, updatedStr string
|
createdStr, updatedStr string
|
||||||
)
|
)
|
||||||
if err := s.Scan(
|
if err := s.Scan(
|
||||||
@@ -2401,7 +2454,7 @@ func scanQSO(s scanner) (QSO, error) {
|
|||||||
&skcc, &fists, &tenTen, &contactedOp, &eqCall, &pfx, &myName, &class,
|
&skcc, &fists, &tenTen, &contactedOp, &eqCall, &pfx, &myName, &class,
|
||||||
&darcDOK, &myDarcDOK, ®ion, &silentKey, &swl, &qsoComplete, &qsoRandom,
|
&darcDOK, &myDarcDOK, ®ion, &silentKey, &swl, &qsoComplete, &qsoRandom,
|
||||||
&creditGranted, &creditSubmitted, &myARRLSect, &myVUCCGrids,
|
&creditGranted, &creditSubmitted, &myARRLSect, &myVUCCGrids,
|
||||||
&extrasJSON, &createdStr, &updatedStr,
|
&extrasJSON, &awardRefs, &createdStr, &updatedStr,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return QSO{}, fmt.Errorf("scan qso: %w", err)
|
return QSO{}, fmt.Errorf("scan qso: %w", err)
|
||||||
}
|
}
|
||||||
@@ -2598,6 +2651,7 @@ func scanQSO(s scanner) (QSO, error) {
|
|||||||
q.MyARRLSect = myARRLSect.String
|
q.MyARRLSect = myARRLSect.String
|
||||||
q.MyVUCCGrids = myVUCCGrids.String
|
q.MyVUCCGrids = myVUCCGrids.String
|
||||||
q.Extras = decodeExtras(extrasJSON.String)
|
q.Extras = decodeExtras(extrasJSON.String)
|
||||||
|
q.AwardRefs = awardRefs.String
|
||||||
return q, nil
|
return q, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,30 @@ type Bucket struct {
|
|||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BandCategory is one band's QSO count split by mode category (CW / phone /
|
||||||
|
// digital), for the per-band mode-split chart.
|
||||||
|
type BandCategory struct {
|
||||||
|
Band string `json:"band"`
|
||||||
|
CW int `json:"cw"`
|
||||||
|
Phone int `json:"phone"`
|
||||||
|
Data int `json:"data"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// modeCategory buckets a mode into "cw", "phone" or "data" (digital). Voice modes
|
||||||
|
// (SSB and the digital-voice family) count as phone; CW is CW; everything else is
|
||||||
|
// data. Mirrors the frontend's mode colouring.
|
||||||
|
func modeCategory(mode string) string {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
|
case "CW":
|
||||||
|
return "cw"
|
||||||
|
case "SSB", "USB", "LSB", "AM", "FM", "DV", "DIGITALVOICE", "FREEDV", "C4FM", "DSTAR", "FUSION":
|
||||||
|
return "phone"
|
||||||
|
default:
|
||||||
|
return "data"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Gap is a stretch with no QSO at all — the off-air periods. In a contest these
|
// Gap is a stretch with no QSO at all — the off-air periods. In a contest these
|
||||||
// are the expensive minutes: they are where the score went.
|
// are the expensive minutes: they are where the score went.
|
||||||
type Gap struct {
|
type Gap struct {
|
||||||
@@ -131,6 +155,7 @@ type Stats struct {
|
|||||||
// Breakdowns, each sorted most → least (bands keep frequency order).
|
// Breakdowns, each sorted most → least (bands keep frequency order).
|
||||||
ByMode []Bucket `json:"by_mode"`
|
ByMode []Bucket `json:"by_mode"`
|
||||||
ByBand []Bucket `json:"by_band"`
|
ByBand []Bucket `json:"by_band"`
|
||||||
|
ByBandCategory []BandCategory `json:"by_band_category"` // per band: CW / phone / data split
|
||||||
ByOperator []Bucket `json:"by_operator"`
|
ByOperator []Bucket `json:"by_operator"`
|
||||||
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
|
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
|
||||||
ByContinent []Bucket `json:"by_continent"`
|
ByContinent []Bucket `json:"by_continent"`
|
||||||
@@ -138,6 +163,14 @@ type Stats struct {
|
|||||||
ByYear []Bucket `json:"by_year"` // chronological
|
ByYear []Bucket `json:"by_year"` // chronological
|
||||||
ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological
|
ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological
|
||||||
|
|
||||||
|
// Rolling chronological activity windows (UTC), anchored to the newest QSO, for
|
||||||
|
// the "activity over time" chart's day/week/month/year granularity selector.
|
||||||
|
// Real dates in order (empty buckets are real, just zero) — no cyclical wrap.
|
||||||
|
ByHour []Bucket `json:"by_hour"` // the newest QSO's day, hour by hour (00..23)
|
||||||
|
ByDay7 []Bucket `json:"by_day7"` // the last 7 days ending at the newest QSO
|
||||||
|
ByDay30 []Bucket `json:"by_day30"` // the last 30 days
|
||||||
|
ByMonth12 []Bucket `json:"by_month12"` // the last 12 months
|
||||||
|
|
||||||
// ── Period / contest metrics ──
|
// ── Period / contest metrics ──
|
||||||
// Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says
|
// Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says
|
||||||
// nothing, but across a contest weekend it is the score. The window is the
|
// nothing, but across a contest weekend it is the score. The window is the
|
||||||
@@ -268,6 +301,7 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
|||||||
entities = map[int]struct{}{}
|
entities = map[int]struct{}{}
|
||||||
modeC = map[string]int{}
|
modeC = map[string]int{}
|
||||||
bandC = map[string]int{}
|
bandC = map[string]int{}
|
||||||
|
bandCat = map[string]*BandCategory{} // per band: cw/phone/data
|
||||||
opC = map[string]int{}
|
opC = map[string]int{}
|
||||||
stationC = map[string]int{}
|
stationC = map[string]int{}
|
||||||
contC = map[string]int{}
|
contC = map[string]int{}
|
||||||
@@ -338,14 +372,33 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
|||||||
}
|
}
|
||||||
if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" {
|
if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" {
|
||||||
bandC[b]++
|
bandC[b]++
|
||||||
|
bc := bandCat[b]
|
||||||
|
if bc == nil {
|
||||||
|
bc = &BandCategory{Band: b}
|
||||||
|
bandCat[b] = bc
|
||||||
|
}
|
||||||
|
switch modeCategory(mode.String) {
|
||||||
|
case "cw":
|
||||||
|
bc.CW++
|
||||||
|
case "phone":
|
||||||
|
bc.Phone++
|
||||||
|
default:
|
||||||
|
bc.Data++
|
||||||
|
}
|
||||||
|
bc.Total++
|
||||||
}
|
}
|
||||||
// op was resolved above (with the operator filter applied).
|
// op was resolved above (with the operator filter applied).
|
||||||
opC[op]++
|
opC[op]++
|
||||||
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
|
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
|
||||||
stationC[st]++
|
stationC[st]++
|
||||||
}
|
}
|
||||||
|
// Bucket a blank/unknown continent under "Unknown" rather than dropping it,
|
||||||
|
// so the continent breakdown totals the same as the QSO count (a handful of
|
||||||
|
// QSOs with no resolved continent made the donut read a few short).
|
||||||
if c := strings.ToUpper(strings.TrimSpace(cont.String)); c != "" {
|
if c := strings.ToUpper(strings.TrimSpace(cont.String)); c != "" {
|
||||||
contC[c]++
|
contC[c]++
|
||||||
|
} else {
|
||||||
|
contC["Unknown"]++
|
||||||
}
|
}
|
||||||
if c := strings.TrimSpace(country.String); c != "" {
|
if c := strings.TrimSpace(country.String); c != "" {
|
||||||
entityC[c]++
|
entityC[c]++
|
||||||
@@ -388,6 +441,9 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
|||||||
s.UniqueCalls = len(calls)
|
s.UniqueCalls = len(calls)
|
||||||
s.Entities = len(entities)
|
s.Entities = len(entities)
|
||||||
s.Continents = len(contC)
|
s.Continents = len(contC)
|
||||||
|
if _, ok := contC["Unknown"]; ok {
|
||||||
|
s.Continents-- // "Unknown" is a catch-all bucket, not a real continent
|
||||||
|
}
|
||||||
if !first.IsZero() {
|
if !first.IsZero() {
|
||||||
s.FirstQSO = first.UTC().Format(time.RFC3339)
|
s.FirstQSO = first.UTC().Format(time.RFC3339)
|
||||||
s.LastQSO = last.UTC().Format(time.RFC3339)
|
s.LastQSO = last.UTC().Format(time.RFC3339)
|
||||||
@@ -414,6 +470,13 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
|||||||
}
|
}
|
||||||
return a < b
|
return a < b
|
||||||
})
|
})
|
||||||
|
// Per-band CW/phone/data split, in the SAME band-plan order as ByBand.
|
||||||
|
s.ByBandCategory = []BandCategory{}
|
||||||
|
for _, b := range s.ByBand {
|
||||||
|
if bc := bandCat[b.Key]; bc != nil {
|
||||||
|
s.ByBandCategory = append(s.ByBandCategory, *bc)
|
||||||
|
}
|
||||||
|
}
|
||||||
// The time axis must be CONTINUOUS. Emitting only the months that have QSOs
|
// The time axis must be CONTINUOUS. Emitting only the months that have QSOs
|
||||||
// would place, say, 2012-08 next to 2022-01 as if they were consecutive — the
|
// would place, say, 2012-08 next to 2022-01 as if they were consecutive — the
|
||||||
// chart would invent activity that never happened. A gap in the log is real
|
// chart would invent activity that never happened. A gap in the log is real
|
||||||
@@ -422,11 +485,58 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
|||||||
s.ByMonth = fillMonths(monthC, first, last)
|
s.ByMonth = fillMonths(monthC, first, last)
|
||||||
|
|
||||||
s.periodMetrics(times, from, to, first, last)
|
s.periodMetrics(times, from, to, first, last)
|
||||||
|
// Anchor the rolling activity windows to the period end when filtered, else to
|
||||||
|
// now (so "week" is the last 7 days ending today, like the operator expects).
|
||||||
|
ref := to
|
||||||
|
if ref.IsZero() {
|
||||||
|
ref = time.Now()
|
||||||
|
}
|
||||||
|
s.recentSeries(times, ref)
|
||||||
s.ensureNonNil()
|
s.ensureNonNil()
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recentSeries builds the rolling chronological activity windows for the chart's
|
||||||
|
// day/week/month/year selector, in UTC and anchored to `ref` (the period end when
|
||||||
|
// filtered, else now). Real dates in order — today and the days before it — so the
|
||||||
|
// axis reads chronologically and empty days are just zero, not misordered/wrapped.
|
||||||
|
func (s *Stats) recentSeries(times []entry, ref time.Time) {
|
||||||
|
ref = ref.UTC()
|
||||||
|
day0 := time.Date(ref.Year(), ref.Month(), ref.Day(), 0, 0, 0, 0, time.UTC) // midnight of the anchor day
|
||||||
|
hour := make([]int, 24)
|
||||||
|
daily := map[string]int{}
|
||||||
|
monthly := map[string]int{}
|
||||||
|
for _, e := range times {
|
||||||
|
t := e.t.UTC()
|
||||||
|
if t.Year() == ref.Year() && t.YearDay() == ref.YearDay() {
|
||||||
|
hour[t.Hour()]++
|
||||||
|
}
|
||||||
|
daily[t.Format("2006-01-02")]++
|
||||||
|
monthly[t.Format("2006-01")]++
|
||||||
|
}
|
||||||
|
// Day: the anchor day, hour by hour.
|
||||||
|
for h := 0; h < 24; h++ {
|
||||||
|
s.ByHour = append(s.ByHour, Bucket{Key: fmt.Sprintf("%02dh", h), Count: hour[h]})
|
||||||
|
}
|
||||||
|
// Week: the last 7 days ending today (today is last). Label = weekday + day.
|
||||||
|
for i := 6; i >= 0; i-- {
|
||||||
|
d := day0.AddDate(0, 0, -i)
|
||||||
|
s.ByDay7 = append(s.ByDay7, Bucket{Key: d.Format("Mon 2"), Count: daily[d.Format("2006-01-02")]})
|
||||||
|
}
|
||||||
|
// Month: the last 30 days.
|
||||||
|
for i := 29; i >= 0; i-- {
|
||||||
|
d := day0.AddDate(0, 0, -i)
|
||||||
|
s.ByDay30 = append(s.ByDay30, Bucket{Key: d.Format("2/1"), Count: daily[d.Format("2006-01-02")]})
|
||||||
|
}
|
||||||
|
// Year: the last 12 months.
|
||||||
|
m0 := time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
for i := 11; i >= 0; i-- {
|
||||||
|
m := m0.AddDate(0, -i, 0)
|
||||||
|
s.ByMonth12 = append(s.ByMonth12, Bucket{Key: m.Format("Jan 06"), Count: monthly[m.Format("2006-01")]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ensureNonNil replaces every nil slice with an empty one.
|
// ensureNonNil replaces every nil slice with an empty one.
|
||||||
//
|
//
|
||||||
// This is NOT cosmetic. A nil Go slice marshals to JSON `null`, not `[]`, and the
|
// This is NOT cosmetic. A nil Go slice marshals to JSON `null`, not `[]`, and the
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
// The Denkovi USB board is driven through FTDI's D2XX bit-bang API (ftd2xx.dll),
|
||||||
|
// which OpsLog only wires up on Windows. This stub keeps the package building on
|
||||||
|
// other platforms; every call reports the board is unavailable.
|
||||||
|
package relaydev
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type denkoviStub struct{ count int }
|
||||||
|
|
||||||
|
// NewDenkovi returns a stub on non-Windows builds.
|
||||||
|
func NewDenkovi(serial string, count int) Device {
|
||||||
|
if count != 4 {
|
||||||
|
count = 8
|
||||||
|
}
|
||||||
|
return denkoviStub{count: count}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s denkoviStub) Count() int { return s.count }
|
||||||
|
func (denkoviStub) Close() error { return nil }
|
||||||
|
func (denkoviStub) Status(context.Context) ([]bool, error) {
|
||||||
|
return nil, fmt.Errorf("Denkovi USB relay board is only supported on Windows")
|
||||||
|
}
|
||||||
|
func (denkoviStub) Set(context.Context, int, bool) error {
|
||||||
|
return fmt.Errorf("Denkovi USB relay board is only supported on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDenkovi has no devices to report off Windows.
|
||||||
|
func ListDenkovi() ([]string, error) {
|
||||||
|
return nil, fmt.Errorf("Denkovi USB relay board is only supported on Windows")
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
// Denkovi USB 8-channel relay board (FT245RL). Despite enumerating as a virtual
|
||||||
|
// COM port, this board is NOT driven by serial/ASCII: the FT245's 8 data lines
|
||||||
|
// each drive a relay, controlled through FTDI's D2XX "bit-bang" mode. One byte
|
||||||
|
// written = the 8 relays at once (bit 0 = relay 1). We load ftd2xx.dll at runtime
|
||||||
|
// (no CGO) and call the D2XX API directly, exactly as the vendor's tool does
|
||||||
|
// (which addresses the board by its FTDI serial, e.g. "DAE0006K").
|
||||||
|
package relaydev
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ftdll = syscall.NewLazyDLL("ftd2xx.dll")
|
||||||
|
procOpenEx = ftdll.NewProc("FT_OpenEx")
|
||||||
|
procClose = ftdll.NewProc("FT_Close")
|
||||||
|
procSetBitMode = ftdll.NewProc("FT_SetBitMode")
|
||||||
|
procSetBaudRate = ftdll.NewProc("FT_SetBaudRate")
|
||||||
|
procWrite = ftdll.NewProc("FT_Write")
|
||||||
|
procPurge = ftdll.NewProc("FT_Purge")
|
||||||
|
procCreateInfo = ftdll.NewProc("FT_CreateDeviceInfoList")
|
||||||
|
procGetInfoDetail = ftdll.NewProc("FT_GetDeviceInfoDetail")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ftOpenBySerial = 1 // FT_OPEN_BY_SERIAL_NUMBER
|
||||||
|
ftBitModeSyncBB = 0x04 // FT_BITMODE_SYNC_BITBANG (the mode Denkovi documents)
|
||||||
|
ftPurgeRX = 1 // FT_PURGE_RX
|
||||||
|
)
|
||||||
|
|
||||||
|
func ftOK(r uintptr) bool { return r == 0 } // FT_OK == 0
|
||||||
|
|
||||||
|
type denkovi struct {
|
||||||
|
serial string
|
||||||
|
count int
|
||||||
|
mu sync.Mutex
|
||||||
|
shadow byte // last output byte (bit n = relay n+1); authoritative state
|
||||||
|
h uintptr // FT handle
|
||||||
|
opened bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDenkovi builds a driver for a Denkovi USB relay board (4 or 8 relays)
|
||||||
|
// identified by its FTDI serial number (shown by the vendor tool / FT_PROG,
|
||||||
|
// e.g. "DAE0006K"). count defaults to 8; a 4-relay board just uses the low 4 bits.
|
||||||
|
func NewDenkovi(serial string, count int) Device {
|
||||||
|
if count != 4 && count != 8 {
|
||||||
|
count = 8
|
||||||
|
}
|
||||||
|
return &denkovi{serial: strings.TrimSpace(serial), count: count}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *denkovi) Count() int { return d.count }
|
||||||
|
|
||||||
|
// ensureOpen opens the board and puts it in synchronous bit-bang mode with all 8
|
||||||
|
// lines as outputs. Idempotent.
|
||||||
|
func (d *denkovi) ensureOpen() error {
|
||||||
|
if d.opened {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := ftdll.Load(); err != nil {
|
||||||
|
return fmt.Errorf("FTDI D2XX driver (ftd2xx.dll) not found — install the FTDI D2XX driver / Denkovi software: %w", err)
|
||||||
|
}
|
||||||
|
if d.serial == "" {
|
||||||
|
return fmt.Errorf("no FTDI serial number set for the Denkovi board")
|
||||||
|
}
|
||||||
|
ser, err := syscall.BytePtrFromString(d.serial)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var h uintptr
|
||||||
|
if r, _, _ := procOpenEx.Call(uintptr(unsafe.Pointer(ser)), ftOpenBySerial, uintptr(unsafe.Pointer(&h))); !ftOK(r) {
|
||||||
|
return fmt.Errorf("cannot open Denkovi board %q (FT_OpenEx status %d) — is it connected and not in use by another app?", d.serial, r)
|
||||||
|
}
|
||||||
|
// All 8 lines output, synchronous bit-bang.
|
||||||
|
if r, _, _ := procSetBitMode.Call(h, 0xFF, ftBitModeSyncBB); !ftOK(r) {
|
||||||
|
procClose.Call(h)
|
||||||
|
return fmt.Errorf("FT_SetBitMode failed (status %d)", r)
|
||||||
|
}
|
||||||
|
procSetBaudRate.Call(h, 9600) // bit-bang pin-update clock; relays don't need speed
|
||||||
|
d.h = h
|
||||||
|
d.opened = true
|
||||||
|
// Make the hardware match our shadow (starts all-off on first open).
|
||||||
|
return d.writeLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeLocked pushes the shadow byte to the relays. Caller holds d.mu.
|
||||||
|
func (d *denkovi) writeLocked() error {
|
||||||
|
var written uint32
|
||||||
|
b := d.shadow
|
||||||
|
if r, _, _ := procWrite.Call(d.h, uintptr(unsafe.Pointer(&b)), 1, uintptr(unsafe.Pointer(&written))); !ftOK(r) {
|
||||||
|
return fmt.Errorf("FT_Write failed (status %d)", r)
|
||||||
|
}
|
||||||
|
// Synchronous bit-bang echoes each written byte into the RX buffer; drop it so
|
||||||
|
// it doesn't fill over the life of the connection.
|
||||||
|
procPurge.Call(d.h, ftPurgeRX)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *denkovi) Set(ctx context.Context, relay int, on bool) error {
|
||||||
|
if relay < 1 || relay > d.count {
|
||||||
|
return fmt.Errorf("relay %d out of range 1..%d", relay, d.count)
|
||||||
|
}
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if err := d.ensureOpen(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
bit := byte(1) << uint(relay-1) // relay 1 → bit 0
|
||||||
|
if on {
|
||||||
|
d.shadow |= bit
|
||||||
|
} else {
|
||||||
|
d.shadow &^= bit
|
||||||
|
}
|
||||||
|
return d.writeLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the FTDI handle so the board can be reopened later (only one
|
||||||
|
// handle may hold a D2XX device at a time).
|
||||||
|
func (d *denkovi) Close() error {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if d.opened {
|
||||||
|
procClose.Call(d.h)
|
||||||
|
d.opened = false
|
||||||
|
d.h = 0
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *denkovi) Status(ctx context.Context) ([]bool, error) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if err := d.ensureOpen(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]bool, d.count)
|
||||||
|
for i := 0; i < d.count; i++ {
|
||||||
|
out[i] = d.shadow&(byte(1)<<uint(i)) != 0
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDenkovi returns the FTDI serial numbers of connected devices, for the
|
||||||
|
// settings UI to pick from. Requires ftd2xx.dll.
|
||||||
|
func ListDenkovi() ([]string, error) {
|
||||||
|
if err := ftdll.Load(); err != nil {
|
||||||
|
return nil, fmt.Errorf("FTDI D2XX driver (ftd2xx.dll) not found — install the FTDI D2XX driver / Denkovi software")
|
||||||
|
}
|
||||||
|
var n uint32
|
||||||
|
if r, _, _ := procCreateInfo.Call(uintptr(unsafe.Pointer(&n))); !ftOK(r) {
|
||||||
|
return nil, fmt.Errorf("FT_CreateDeviceInfoList failed (status %d)", r)
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for i := uint32(0); i < n; i++ {
|
||||||
|
var flags, typ, id, loc uint32
|
||||||
|
serial := make([]byte, 16)
|
||||||
|
desc := make([]byte, 64)
|
||||||
|
var h uintptr
|
||||||
|
r, _, _ := procGetInfoDetail.Call(
|
||||||
|
uintptr(i),
|
||||||
|
uintptr(unsafe.Pointer(&flags)), uintptr(unsafe.Pointer(&typ)),
|
||||||
|
uintptr(unsafe.Pointer(&id)), uintptr(unsafe.Pointer(&loc)),
|
||||||
|
uintptr(unsafe.Pointer(&serial[0])), uintptr(unsafe.Pointer(&desc[0])),
|
||||||
|
uintptr(unsafe.Pointer(&h)),
|
||||||
|
)
|
||||||
|
if !ftOK(r) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s := cstr(serial); s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cstr trims a C string (up to the first NUL) from a fixed buffer.
|
||||||
|
func cstr(b []byte) string {
|
||||||
|
if i := indexByte(b, 0); i >= 0 {
|
||||||
|
b = b[:i]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexByte(b []byte, c byte) int {
|
||||||
|
for i := range b {
|
||||||
|
if b[i] == c {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -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} }
|
||||||
@@ -63,6 +68,7 @@ type webswitch struct {
|
|||||||
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 {
|
||||||
@@ -119,6 +125,7 @@ func NewKMTronic(host, user, pass string) Device {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package relaydev
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serialRelay drives the common cheap USB-serial relay boards (CH340 / LCUS-1
|
||||||
|
// style) that use the "A0" command protocol over a virtual COM port:
|
||||||
|
//
|
||||||
|
// [0xA0] [relay 1-based] [state 0|1] [checksum] checksum = (0xA0+relay+state) & 0xFF
|
||||||
|
//
|
||||||
|
// e.g. relay 1 ON = A0 01 01 A2, relay 1 OFF = A0 01 00 A1. These boards are
|
||||||
|
// write-only (no state readback), so Status reflects a shadow of what we sent.
|
||||||
|
// Channel count varies by board (1/2/4/8/16), so it is configurable.
|
||||||
|
type serialRelay struct {
|
||||||
|
portName string
|
||||||
|
count int
|
||||||
|
baud int
|
||||||
|
mu sync.Mutex
|
||||||
|
port serial.Port
|
||||||
|
shadow []bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSerialRelay builds a driver for a CH340/LCUS-style USB-serial relay board on
|
||||||
|
// the given COM port with `count` channels (defaults to 8).
|
||||||
|
func NewSerialRelay(port string, count int) Device {
|
||||||
|
if count < 1 {
|
||||||
|
count = 8
|
||||||
|
}
|
||||||
|
return &serialRelay{portName: strings.TrimSpace(port), count: count, baud: 9600, shadow: make([]bool, 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 {
|
||||||
|
if s.port != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if s.portName == "" {
|
||||||
|
return fmt.Errorf("no COM port set for the USB relay board")
|
||||||
|
}
|
||||||
|
p, err := serial.Open(s.portName, &serial.Mode{BaudRate: s.baud})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open %s: %w", s.portName, err)
|
||||||
|
}
|
||||||
|
s.port = p
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialRelay) Set(ctx context.Context, relay int, on bool) error {
|
||||||
|
if relay < 1 || relay > s.count {
|
||||||
|
return fmt.Errorf("relay %d out of range 1..%d", relay, s.count)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if err := s.ensureOpen(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st := byte(0)
|
||||||
|
if on {
|
||||||
|
st = 1
|
||||||
|
}
|
||||||
|
r := byte(relay)
|
||||||
|
frame := []byte{0xA0, r, st, byte(0xA0) + r + st} // last byte = checksum
|
||||||
|
if _, err := s.port.Write(frame); err != nil {
|
||||||
|
// Drop the handle so the next call reopens (USB unplugged / port reset).
|
||||||
|
s.port.Close()
|
||||||
|
s.port = nil
|
||||||
|
return fmt.Errorf("write to %s: %w", s.portName, err)
|
||||||
|
}
|
||||||
|
s.shadow[relay-1] = on
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialRelay) Status(ctx context.Context) ([]bool, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if err := s.ensureOpen(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]bool, s.count)
|
||||||
|
copy(out, s.shadow)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
// Package spe drives the SPE Expert 1.3K-FA / 1.5K-FA / 2K-FA amplifiers over
|
||||||
|
// their proprietary serial protocol (SPE "Application Programmer's Guide" rev 1.1).
|
||||||
|
// The amp is reached either directly over USB (a virtual COM port) or over TCP via
|
||||||
|
// an RS232-to-Ethernet bridge — both are just an io.ReadWriteCloser to this code.
|
||||||
|
//
|
||||||
|
// Wire format (host → amp): 0x55 0x55 0x55 | CNT | DATA… | CHK
|
||||||
|
// CNT = number of DATA bytes, CHK = sum(DATA) mod 256.
|
||||||
|
// Status reply (amp → host): 0xAA 0xAA 0xAA | LEN | <LEN CSV bytes> | chk0 chk1 | CR LF
|
||||||
|
// LEN is 0x43 (67); the payload is 19 comma-separated fixed fields.
|
||||||
|
//
|
||||||
|
// This MVP implements the two commands anchored by worked examples in the guide:
|
||||||
|
// OPERATE (0x0D, toggles STANDBY↔OPERATE) and STATUS (0x90). Other keystroke codes
|
||||||
|
// exist but the guide's command table did not extract unambiguously, so they are
|
||||||
|
// left out rather than risk sending the wrong key to the amplifier.
|
||||||
|
package spe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE (confirmed on hw)
|
||||||
|
cmdStatus byte = 0x90 // request the status string
|
||||||
|
|
||||||
|
// Best-guess keystroke codes reconstructed from the APG command table after
|
||||||
|
// correcting the PDF's note-wrap misalignment (anchored to the confirmed
|
||||||
|
// OPERATE=0x0D): OFF=0x0A, POWER=0x0B. The POWER key doubles as power-on (when
|
||||||
|
// the amp is off) and the output-level cycle L→M→H (when it's on) — same as the
|
||||||
|
// single physical POWER button. To be verified on hardware.
|
||||||
|
cmdOff byte = 0x0A // OFF key — switch the amplifier off
|
||||||
|
cmdPower byte = 0x0B // POWER key — turn on / cycle output power level
|
||||||
|
|
||||||
|
syncHost = 0x55
|
||||||
|
syncAmp = 0xAA
|
||||||
|
|
||||||
|
dialTimeout = 5 * time.Second
|
||||||
|
ioTimeout = 3 * time.Second
|
||||||
|
pollEvery = 800 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status is the decoded amplifier state for the UI.
|
||||||
|
type Status struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"` // "20K" / "13K"
|
||||||
|
Operate bool `json:"operate"` // true = OPERATE, false = STANDBY
|
||||||
|
TX bool `json:"tx"` // true = transmitting
|
||||||
|
Input string `json:"input,omitempty"` // "1" / "2"
|
||||||
|
Band string `json:"band,omitempty"` // raw 2-char band code
|
||||||
|
PowerLevel string `json:"power_level,omitempty"` // L / M / H
|
||||||
|
OutputW int `json:"output_w"`
|
||||||
|
SWRATU float64 `json:"swr_atu"`
|
||||||
|
SWRAnt float64 `json:"swr_ant"`
|
||||||
|
VoltPA float64 `json:"volt_pa"`
|
||||||
|
CurrPA float64 `json:"curr_pa"`
|
||||||
|
TempC int `json:"temp_c"` // heatsink (upper) temperature
|
||||||
|
Warnings string `json:"warnings,omitempty"`
|
||||||
|
Alarms string `json:"alarms,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config selects the transport.
|
||||||
|
type Config struct {
|
||||||
|
Transport string // "serial" | "tcp"
|
||||||
|
ComPort string // serial
|
||||||
|
Baud int // serial
|
||||||
|
Host string // tcp
|
||||||
|
Port int // tcp
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
cfg Config
|
||||||
|
|
||||||
|
mu sync.Mutex // serialises access to the connection
|
||||||
|
conn io.ReadWriteCloser
|
||||||
|
r *bufio.Reader
|
||||||
|
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
status Status
|
||||||
|
lastRaw string // last raw status payload logged (log only on change)
|
||||||
|
|
||||||
|
stop chan struct{}
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Client {
|
||||||
|
if cfg.Baud <= 0 {
|
||||||
|
cfg.Baud = 115200
|
||||||
|
}
|
||||||
|
return &Client{cfg: cfg, stop: make(chan struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Start() error {
|
||||||
|
if c.running {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.running = true
|
||||||
|
go c.pollLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Stop() {
|
||||||
|
if !c.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.running = false
|
||||||
|
close(c.stop)
|
||||||
|
c.mu.Lock()
|
||||||
|
c.dropLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetStatus() Status {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
return c.status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) setErr(err error) {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.status.Connected = false
|
||||||
|
c.status.LastError = err.Error()
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operate toggles the amplifier between STANDBY and OPERATE (the amp has a single
|
||||||
|
// OPERATE key that flips the state, so we send it only when the desired state
|
||||||
|
// differs from the last-read one).
|
||||||
|
func (c *Client) Operate(on bool) error {
|
||||||
|
if c.GetStatus().Operate == on {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.sendCmd(cmdOperate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleOperate flips STANDBY/OPERATE unconditionally.
|
||||||
|
func (c *Client) ToggleOperate() error { return c.sendCmd(cmdOperate) }
|
||||||
|
|
||||||
|
// PowerOn turns the amplifier on. Per the SPE manual this is NOT a data command
|
||||||
|
// but a hardware DTR pulse on the serial line (the "Remote_ON" pin), which needs
|
||||||
|
// the special SPE cable — so it only applies to the serial transport. Over TCP
|
||||||
|
// there is no DTR line and power-on isn't possible remotely.
|
||||||
|
func (c *Client) PowerOn() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
sp, ok := c.conn.(serial.Port)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("power-on needs the serial DTR line (special SPE cable); not available over network")
|
||||||
|
}
|
||||||
|
// A single positive pulse (~1.2s) on DTR, as the manual describes.
|
||||||
|
if err := sp.SetDTR(true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(1200 * time.Millisecond)
|
||||||
|
return sp.SetDTR(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PowerOff presses the OFF key (switches the amp off).
|
||||||
|
func (c *Client) PowerOff() error { return c.sendCmd(cmdOff) }
|
||||||
|
|
||||||
|
// SetPowerLevel cycles the output power level (L/M/H) to the requested one by
|
||||||
|
// tapping the POWER key (0x0B) and WAITING for the amp to actually report the new
|
||||||
|
// level before the next tap — the status is streamed, so we poll GetStatus rather
|
||||||
|
// than sleeping a fixed time. `level` is "L", "M" or "H" (case-insensitive).
|
||||||
|
func (c *Client) SetPowerLevel(level string) error {
|
||||||
|
want := strings.ToUpper(strings.TrimSpace(level))
|
||||||
|
if want == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// At most 3 taps to walk the 3-way L→M→H cycle around to the target.
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if strings.ToUpper(strings.TrimSpace(c.GetStatus().PowerLevel)) == want {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
prev := strings.ToUpper(strings.TrimSpace(c.GetStatus().PowerLevel))
|
||||||
|
if err := c.sendCmd(cmdPower); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Wait (up to ~2s) for the streamed status to reflect the change.
|
||||||
|
for w := 0; w < 20; w++ {
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
if strings.ToUpper(strings.TrimSpace(c.GetStatus().PowerLevel)) != prev {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) pollLoop() {
|
||||||
|
t := time.NewTicker(pollEvery)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if err := c.ensureConn(); err != nil {
|
||||||
|
c.setErr(fmt.Errorf("connect: %w", err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The amp streams status frames faster than one per poll, so a backlog
|
||||||
|
// builds up and we'd read stale frames (the display lagged ~15s behind
|
||||||
|
// the real amp). Flush anything pending, THEN request + read exactly one
|
||||||
|
// fresh frame — the status we show is always current.
|
||||||
|
c.drainInput()
|
||||||
|
if err := c.sendCmd(cmdStatus); err != nil {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.dropLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.setErr(err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.readStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ensureConn() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var rwc io.ReadWriteCloser
|
||||||
|
var err error
|
||||||
|
if c.cfg.Transport == "tcp" {
|
||||||
|
var nc net.Conn
|
||||||
|
nc, err = net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout)
|
||||||
|
rwc = nc
|
||||||
|
} else {
|
||||||
|
rwc, err = serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.conn = rwc
|
||||||
|
c.r = bufio.NewReader(rwc)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) dropLocked() {
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
c.r = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainInput discards everything currently pending on the link (OS buffer + the
|
||||||
|
// bufio reader) so the next read returns a fresh frame rather than a queued stale
|
||||||
|
// one. Called before each status request.
|
||||||
|
func (c *Client) drainInput() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sp, ok := c.conn.(serial.Port); ok {
|
||||||
|
_ = sp.ResetInputBuffer()
|
||||||
|
} else if nc, ok := c.conn.(net.Conn); ok {
|
||||||
|
_ = nc.SetReadDeadline(time.Now().Add(15 * time.Millisecond))
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := nc.Read(buf)
|
||||||
|
if n == 0 || err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.r != nil {
|
||||||
|
c.r.Reset(c.conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendCmd frames one keystroke code and writes it. Single-byte payload → CHK is
|
||||||
|
// the code itself.
|
||||||
|
func (c *Client) sendCmd(code byte) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn == nil {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
if nc, ok := c.conn.(net.Conn); ok {
|
||||||
|
_ = nc.SetWriteDeadline(time.Now().Add(ioTimeout))
|
||||||
|
}
|
||||||
|
pkt := []byte{syncHost, syncHost, syncHost, 0x01, code, code}
|
||||||
|
_, err := c.conn.Write(pkt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// readStatus reads one amp packet and, when it's a status string, decodes it. ACK
|
||||||
|
// packets (short) are consumed and ignored.
|
||||||
|
func (c *Client) readStatus() {
|
||||||
|
c.mu.Lock()
|
||||||
|
r := c.r
|
||||||
|
if nc, ok := c.conn.(net.Conn); ok && nc != nil {
|
||||||
|
_ = nc.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
if r == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Sync on three 0xAA bytes.
|
||||||
|
run := 0
|
||||||
|
for run < 3 {
|
||||||
|
b, err := r.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.dropLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.setErr(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b == syncAmp {
|
||||||
|
run++
|
||||||
|
} else {
|
||||||
|
run = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
length, err := r.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := make([]byte, int(length))
|
||||||
|
if _, err := io.ReadFull(r, data); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Status strings are the long ones (LEN 0x43 = 67). Short packets are ACKs
|
||||||
|
// (1 checksum byte, no CRLF) — nothing else to consume for those.
|
||||||
|
if length >= 40 {
|
||||||
|
// consume the 2 checksum bytes + CR LF
|
||||||
|
_, _ = r.Discard(4)
|
||||||
|
c.decodeCSV(string(data))
|
||||||
|
} else {
|
||||||
|
_, _ = r.Discard(1) // ACK checksum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeCSV parses the 19-field comma-separated status payload.
|
||||||
|
func (c *Client) decodeCSV(payload string) {
|
||||||
|
f := strings.Split(payload, ",")
|
||||||
|
get := func(i int) string {
|
||||||
|
if i < len(f) {
|
||||||
|
return strings.TrimSpace(f[i])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
pf := func(s string) float64 { v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64); return v }
|
||||||
|
pi := func(s string) int { v, _ := strconv.Atoi(strings.TrimSpace(s)); return v }
|
||||||
|
|
||||||
|
c.statusMu.Lock()
|
||||||
|
defer c.statusMu.Unlock()
|
||||||
|
// Log the raw status frame ONCE per change, so field alignment can be verified
|
||||||
|
// against real hardware (the doc's 19-field layout may differ per firmware).
|
||||||
|
if payload != c.lastRaw {
|
||||||
|
c.lastRaw = payload
|
||||||
|
applog.Printf("spe: status raw=%q fields=%d", payload, len(f))
|
||||||
|
}
|
||||||
|
c.status.Connected = true
|
||||||
|
c.status.LastError = ""
|
||||||
|
// The real frame carries a leading empty field (it starts with a comma), so the
|
||||||
|
// 19 documented fields live at indices 1..19, not 0..18. Verified against a live
|
||||||
|
// 1.3K-FA: ",13K,S,R,A,1,05,1b,0r,M,0000, 0.00, 0.00, 1.3, 0.0, 26,000,000,N,N,".
|
||||||
|
c.status.Model = get(1)
|
||||||
|
c.status.Operate = get(2) == "O" // "O" = OPERATE, "S" = STANDBY
|
||||||
|
c.status.TX = get(3) == "T" // "T" = transmit, "R" = receive
|
||||||
|
c.status.Input = get(5)
|
||||||
|
c.status.Band = bandName(get(6))
|
||||||
|
c.status.PowerLevel = get(9)
|
||||||
|
c.status.OutputW = pi(get(10))
|
||||||
|
c.status.SWRATU = pf(get(11))
|
||||||
|
c.status.SWRAnt = pf(get(12))
|
||||||
|
c.status.VoltPA = pf(get(13))
|
||||||
|
c.status.CurrPA = pf(get(14))
|
||||||
|
c.status.TempC = pi(get(15))
|
||||||
|
if w := get(18); w != "" && w != "N" {
|
||||||
|
c.status.Warnings = w
|
||||||
|
} else {
|
||||||
|
c.status.Warnings = ""
|
||||||
|
}
|
||||||
|
if a := get(19); a != "" && a != "N" {
|
||||||
|
c.status.Alarms = a
|
||||||
|
} else {
|
||||||
|
c.status.Alarms = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bandName maps the SPE 2-digit band index to a human band label, falling back to
|
||||||
|
// the raw code for anything unrecognised.
|
||||||
|
func bandName(code string) string {
|
||||||
|
// SPE band index, verified against a live 1.3K-FA: the status frame reports the
|
||||||
|
// band as a 2-digit decimal string ordered by descending wavelength — 80m→"01"
|
||||||
|
// and 20m→"05" were both confirmed on hardware (the printed manual's table was
|
||||||
|
// off by one). 00=160m, 01=80m, 02=60m, 03=40m, 04=30m, 05=20m, 06=17m, 07=15m,
|
||||||
|
// 08=12m, 09=10m, 10=6m.
|
||||||
|
switch strings.TrimSpace(code) {
|
||||||
|
case "00":
|
||||||
|
return "160m"
|
||||||
|
case "01":
|
||||||
|
return "80m"
|
||||||
|
case "02":
|
||||||
|
return "60m"
|
||||||
|
case "03":
|
||||||
|
return "40m"
|
||||||
|
case "04":
|
||||||
|
return "30m"
|
||||||
|
case "05":
|
||||||
|
return "20m"
|
||||||
|
case "06":
|
||||||
|
return "17m"
|
||||||
|
case "07":
|
||||||
|
return "15m"
|
||||||
|
case "08":
|
||||||
|
return "12m"
|
||||||
|
case "09":
|
||||||
|
return "10m"
|
||||||
|
case "10":
|
||||||
|
return "6m"
|
||||||
|
case "":
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
-54
@@ -2,13 +2,24 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
|
||||||
"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
|
||||||
@@ -18,8 +29,6 @@ import (
|
|||||||
// to the DB — it is not a web server. Rows older than a couple of minutes are
|
// to the DB — it is not a web server. Rows older than a couple of minutes are
|
||||||
// "stale" (operator went offline); the web side ignores them.
|
// "stale" (operator went offline); the web side ignores them.
|
||||||
|
|
||||||
const keyLiveStatusEnabled = "livestatus.enabled"
|
|
||||||
|
|
||||||
// liveOnlineWindow is how long after the last logged contact an operator still
|
// liveOnlineWindow is how long after the last logged contact an operator still
|
||||||
// counts as "on air". Leaving the log open without working anyone flips them
|
// counts as "on air". Leaving the log open without working anyone flips them
|
||||||
// offline once this elapses; logging a new QSO flips them back online.
|
// offline once this elapses; logging a new QSO flips them back online.
|
||||||
@@ -37,37 +46,6 @@ func (a *App) noteLiveQSO() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLiveStatusEnabled reports whether this operator publishes live status.
|
|
||||||
func (a *App) GetLiveStatusEnabled() bool {
|
|
||||||
if a.settings == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
v, _ := a.settings.Get(a.ctx, keyLiveStatusEnabled)
|
|
||||||
return strings.TrimSpace(v) == "1"
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetLiveStatusEnabled turns live-status publishing on or off (off also removes
|
|
||||||
// this operator's row immediately).
|
|
||||||
func (a *App) SetLiveStatusEnabled(on bool) error {
|
|
||||||
if a.settings == nil {
|
|
||||||
return fmt.Errorf("db not initialized")
|
|
||||||
}
|
|
||||||
val := "0"
|
|
||||||
if on {
|
|
||||||
val = "1"
|
|
||||||
}
|
|
||||||
if err := a.settings.Set(a.ctx, keyLiveStatusEnabled, val); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if on {
|
|
||||||
applog.Printf("livestatus: enabled (logbook backend=%q, mysql conn=%v)", a.dbBackend, a.logDb != nil)
|
|
||||||
go a.publishLiveStatus() // show up right away
|
|
||||||
} else {
|
|
||||||
a.clearLiveStatus()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedLiveLastQSO primes liveLastQSOAt from the DB at launch, so an operator who
|
// seedLiveLastQSO primes liveLastQSOAt from the DB at launch, so an operator who
|
||||||
// worked someone shortly before (re)starting OpsLog is shown "on air" right away
|
// worked someone shortly before (re)starting OpsLog is shown "on air" right away
|
||||||
// instead of offline until their next QSO.
|
// instead of offline until their next QSO.
|
||||||
@@ -131,9 +109,11 @@ func (a *App) liveStatusLoop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// liveStatusActive reports whether publishing should run (MySQL logbook + on).
|
// liveStatusActive reports whether publishing should run. Always on for a shared
|
||||||
|
// MySQL logbook (no user toggle: going offline is automatic — no QSO for 5 min
|
||||||
|
// removes the row — so there is nothing to opt out of).
|
||||||
func (a *App) liveStatusActive() bool {
|
func (a *App) liveStatusActive() bool {
|
||||||
return a.logDb != nil && a.dbBackend == "mysql" && a.GetLiveStatusEnabled()
|
return a.logDb != nil && a.dbBackend == "mysql"
|
||||||
}
|
}
|
||||||
|
|
||||||
// liveStatusOperator returns this instance's operator id (the operator callsign,
|
// liveStatusOperator returns this instance's operator id (the operator callsign,
|
||||||
@@ -159,10 +139,23 @@ func (a *App) liveStatusOperator() (op, station string) {
|
|||||||
// ReportLiveActivity is called by the UI with the current entry-strip freq/band/
|
// ReportLiveActivity is called by the UI with the current entry-strip freq/band/
|
||||||
// mode, used as a fallback for live status when the CAT isn't connected.
|
// mode, used as a fallback for live status when the CAT isn't connected.
|
||||||
func (a *App) ReportLiveActivity(freqHz int64, band, mode string) {
|
func (a *App) ReportLiveActivity(freqHz int64, band, mode string) {
|
||||||
|
nb := strings.ToUpper(strings.TrimSpace(band))
|
||||||
|
nm := strings.ToUpper(strings.TrimSpace(mode))
|
||||||
a.liveActMu.Lock()
|
a.liveActMu.Lock()
|
||||||
|
changed := a.liveFreqHz != freqHz || a.liveBand != nb || a.liveMode != nm
|
||||||
a.liveFreqHz = freqHz
|
a.liveFreqHz = freqHz
|
||||||
a.liveBand = strings.ToUpper(strings.TrimSpace(band))
|
a.liveBand = nb
|
||||||
a.liveMode = strings.ToUpper(strings.TrimSpace(mode))
|
a.liveMode = nm
|
||||||
|
// Push a fresh row PROMPTLY when the operator's freq/band/mode changes, instead
|
||||||
|
// of waiting for the next 15 s heartbeat — otherwise "who's on air" showed a
|
||||||
|
// stale band and took up to a minute to catch up. Debounced so spinning the VFO
|
||||||
|
// doesn't hammer the shared MySQL.
|
||||||
|
if changed {
|
||||||
|
if a.livePublishTimer != nil {
|
||||||
|
a.livePublishTimer.Stop()
|
||||||
|
}
|
||||||
|
a.livePublishTimer = time.AfterFunc(1500*time.Millisecond, func() { a.publishLiveStatus() })
|
||||||
|
}
|
||||||
a.liveActMu.Unlock()
|
a.liveActMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,9 +165,6 @@ func (a *App) publishLiveStatus() {
|
|||||||
if a.logDb == nil || a.dbBackend != "mysql" {
|
if a.logDb == nil || a.dbBackend != "mysql" {
|
||||||
return // not a MySQL logbook — nothing to do (silent, runs every 15s)
|
return // not a MySQL logbook — nothing to do (silent, runs every 15s)
|
||||||
}
|
}
|
||||||
if !a.GetLiveStatusEnabled() {
|
|
||||||
return // disabled (silent)
|
|
||||||
}
|
|
||||||
op, station := a.liveStatusOperator()
|
op, station := a.liveStatusOperator()
|
||||||
if op == "" {
|
if op == "" {
|
||||||
applog.Printf("livestatus: nothing published — no operator/callsign set (Settings → Station)")
|
applog.Printf("livestatus: nothing published — no operator/callsign set (Settings → Station)")
|
||||||
@@ -202,33 +192,39 @@ func (a *App) publishLiveStatus() {
|
|||||||
}
|
}
|
||||||
a.liveActMu.Unlock()
|
a.liveActMu.Unlock()
|
||||||
lastQSO := a.liveLastQSOTime() // authoritative (in-memory OR shared DB)
|
lastQSO := a.liveLastQSOTime() // authoritative (in-memory OR shared DB)
|
||||||
// Online = a new contact was logged within the window. An operator who leaves
|
// On air = a new contact was logged within the window. An operator who leaves
|
||||||
// the log open but stops working shows offline after `liveOnlineWindow`; the
|
// the log open but stops working goes offline after `liveOnlineWindow`; the next
|
||||||
// next QSO flips them back on. never-logged (zero time) → offline.
|
// QSO puts them back on. never-logged (zero time) → offline.
|
||||||
online := 0
|
online := !lastQSO.IsZero() && time.Since(lastQSO) < liveOnlineWindow
|
||||||
var lastQSOArg any
|
|
||||||
if !lastQSO.IsZero() {
|
|
||||||
lastQSOArg = lastQSO.UTC()
|
|
||||||
if time.Since(lastQSO) < liveOnlineWindow {
|
|
||||||
online = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := a.ensureLiveStatusTable(); err != nil {
|
if err := a.ensureLiveStatusTable(); err != nil {
|
||||||
applog.Printf("livestatus: CREATE TABLE failed: %v", err)
|
applog.Printf("livestatus: CREATE TABLE failed: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Offline → REMOVE the row entirely, not just flip a flag: a status page that
|
||||||
|
// lists the present rows (the common case, keyed on updated_at) then shows the
|
||||||
|
// operator as gone without having to read the online column. The row reappears
|
||||||
|
// on the next QSO. This is the whole point — no one shows on air when they're not.
|
||||||
|
if !online {
|
||||||
|
if _, err := a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op); err != nil {
|
||||||
|
applog.Printf("livestatus: offline DELETE failed: %v", err)
|
||||||
|
}
|
||||||
|
a.emitLiveStatusChanged()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastQSOArg := lastQSO.UTC()
|
||||||
_, err := a.logDb.ExecContext(a.ctx,
|
_, err := a.logDb.ExecContext(a.ctx,
|
||||||
"INSERT INTO live_status (operator, station, freq_hz, band, mode, online, version, last_qso_at, updated_at) "+
|
"INSERT INTO live_status (operator, station, freq_hz, band, mode, online, version, last_qso_at, updated_at) "+
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
|
||||||
"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
|
"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
|
||||||
"band=VALUES(band), mode=VALUES(mode), online=VALUES(online), version=VALUES(version), "+
|
"band=VALUES(band), mode=VALUES(mode), online=VALUES(online), version=VALUES(version), "+
|
||||||
"last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()",
|
"last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()",
|
||||||
op, station, freqHz, band, mode, online, appVersion, lastQSOArg)
|
op, station, freqHz, band, mode, 1, appVersion, lastQSOArg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
applog.Printf("livestatus: INSERT failed: %v", err)
|
applog.Printf("livestatus: INSERT failed: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s online=%d", op, station, freqHz, band, mode, online)
|
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.
|
||||||
@@ -285,7 +281,29 @@ func (a *App) GetLiveStations() []LiveStation {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureLiveStatusTable creates/upgrades the live_status table ONCE per logbook
|
||||||
|
// connection. It used to run the CREATE + 3 ALTERs on every call — and it is
|
||||||
|
// called from every publish (15s heartbeat) AND every GetLiveStations poll (5s),
|
||||||
|
// so that was 4 wasted round-trips per call on a remote MySQL, adding latency to
|
||||||
|
// the "who's on air" widget and holding pool connections for nothing.
|
||||||
func (a *App) ensureLiveStatusTable() error {
|
func (a *App) ensureLiveStatusTable() error {
|
||||||
|
db := a.logDb
|
||||||
|
a.liveTableMu.Lock()
|
||||||
|
done := a.liveTableFor == db // re-ensure after a profile switch swaps the logbook
|
||||||
|
a.liveTableMu.Unlock()
|
||||||
|
if done {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := a.ensureLiveStatusTableDDL(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.liveTableMu.Lock()
|
||||||
|
a.liveTableFor = db
|
||||||
|
a.liveTableMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ensureLiveStatusTableDDL() error {
|
||||||
if _, err := a.logDb.ExecContext(a.ctx,
|
if _, err := a.logDb.ExecContext(a.ctx,
|
||||||
"CREATE TABLE IF NOT EXISTS live_status ("+
|
"CREATE TABLE IF NOT EXISTS live_status ("+
|
||||||
"operator VARCHAR(32) PRIMARY KEY, "+
|
"operator VARCHAR(32) PRIMARY KEY, "+
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.20.3"
|
appVersion = "0.20.7"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user