Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64b8ba7fdc | ||
|
|
a9ca50b2dd | ||
|
|
512b5fd35b | ||
|
|
77e3d011a0 | ||
|
|
b7dd8d4852 | ||
|
|
4c75680689 | ||
|
|
5aac28f564 | ||
|
|
c91c8c3b47 | ||
|
|
968da5488c | ||
|
|
1b5b0c2e90 | ||
|
|
0385aed760 | ||
|
|
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 |
@@ -46,3 +46,4 @@ cat.log
|
|||||||
.env.local
|
.env.local
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
*.syso
|
||||||
|
|||||||
+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
|
||||||
selector and fault display.
|
FlexRadio tab, or on its own when neither Flex nor Icom is active):
|
||||||
|
- **PowerGenius XL** (4O3A) over direct TCP — operate/standby, fan-mode
|
||||||
|
selector and fault display.
|
||||||
|
- **SPE Expert** (1.3K-FA / 1.5K-FA / 2K-FA) over **USB** (virtual COM) or the
|
||||||
|
**network** (RS232-to-Ethernet bridge) — operate/standby, ON/OFF,
|
||||||
|
Low / Mid / High output level, an output-power bar and live status (band,
|
||||||
|
SWR, PA current, temperature, warnings/alarms).
|
||||||
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
|
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
|
||||||
antenna-switch widget.
|
antenna-switch widget.
|
||||||
|
- **Station Control** panel (dockable, drag-to-reorder widgets): the **rotator**,
|
||||||
|
**Ultrabeam** element control and **relay boards** — WebSwitch 1216H, KMTronic,
|
||||||
|
**Denkovi** USB (FT245 D2XX bit-bang, 4 or 8 relays) and generic USB-serial
|
||||||
|
(CH340 / LCUS, A0 protocol) — for station power, antennas and accessories.
|
||||||
|
- **Relay auto-control** (Settings): switch Station-Control relays automatically
|
||||||
|
from the rig frequency / band (like PstRotator) — per relay, a frequency window
|
||||||
|
or a set of bands.
|
||||||
|
|
||||||
## QSL & awards
|
## QSL & awards
|
||||||
|
|
||||||
@@ -176,6 +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
|
||||||
|
}
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"version": "0.20.9",
|
||||||
|
"date": "2026-07-22",
|
||||||
|
"en": [
|
||||||
|
"The PGXL amplifier chip and Station Control widget now read the OPERATE state from the FlexRadio (like the Flex panel) — the chip no longer shows STANDBY while the amp is operating, and clicking it toggles the amp through the radio.",
|
||||||
|
"Right-click 'Send to LoTW / QRZ / Club Log…' now reports its result as a notification — a failed upload (e.g. TQSL not configured) was completely silent outside the QSL Manager, which looked like nothing was sent.",
|
||||||
|
"The number of selected QSOs is now always visible at the top of the log grid — no need to open the right-click menu to see it.",
|
||||||
|
"NET Control: the worked-before list gets the same right-click menu as Recent QSOs (update from cty/QRZ/Club Log, send to services, recording e-mail, eQSL, delete).",
|
||||||
|
"Leaving compact mode restores the window's previous size AND position (it used to snap to a fixed size wherever the compact strip had been dragged).",
|
||||||
|
"The advanced filter's 'My callsign' field is renamed 'Station callsign' so it is findable under its ADIF name (it filters STATION_CALLSIGN).",
|
||||||
|
"Filtering now shows EVERY match: the on-screen row limit (Max) only applies to the unfiltered log — a filter matching 200 QSOs displays all 200 even with Max at 100 (safety cap 10,000, with a warning to narrow the filter beyond that).",
|
||||||
|
"New 'Select all' button in the log grid toolbar — selects every displayed row (respecting active column filters) in one click, ready for send-to-LoTW, bulk edit or export; once everything is selected it flips to 'Unselect all'.",
|
||||||
|
"NET Control: drag & drop between the two lists — drag a roster station onto the on-air list to start its QSO, and drag an on-air station onto the roster to log it (same as the Log & end button).",
|
||||||
|
"New option (Settings → General): 'Group digital modes as one (DXCC-style)' — when on, the matrix newness badges and the cluster new/new-band/new-mode/new-slot colouring treat FT8/FT4/RTTY/PSK… as a single Digital mode, matching how DXCC counts; when off (default), each digital mode remains its own potential slot.",
|
||||||
|
"Multiple amplifiers: Settings → Amplifier is now a LIST — configure several amps (e.g. two SPEs run in parallel), each with its own name and connection. The amp cards in the Flex panel and Station Control get a dropdown to pick which amp they show, and the bottom status bar shows one clickable chip per amp. Existing single-amp setups migrate automatically.",
|
||||||
|
"FlexRadio panel: the SmartSDR v4 DSP filters are now controllable — NRL and ANFL (legacy LMS), NRS (spectral subtraction), NRF (noise reduction with filter) with their level sliders, plus RNN (AI noise reduction) and ANFT (FFT auto-notch) toggles. The section appears automatically on radios that support them (8000/Aurora series). Layout rebalanced: RIT/XIT moved to the Transmit column and the antenna selection now sits at the top of the Receive column, alongside a new DAX button (SmartSDR's transmit-bar DAX toggle — TX audio from DAX, for WSJT-X & co)."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"La pastille ampli PGXL et le widget Station Control lisent maintenant l'état OPERATE depuis le FlexRadio (comme le panneau Flex) — la pastille n'affiche plus STANDBY quand l'ampli est en service, et un clic bascule l'ampli via la radio.",
|
||||||
|
"Le clic droit « Send to LoTW / QRZ / Club Log… » affiche maintenant son résultat en notification — un envoi échoué (p. ex. TQSL non configuré) était totalement silencieux hors du QSL Manager, comme si rien n'était parti.",
|
||||||
|
"Le nombre de QSO sélectionnés est maintenant toujours visible en haut de la grille du log — plus besoin d'ouvrir le menu clic droit pour le voir.",
|
||||||
|
"NET Control : la liste worked-before dispose du même menu clic droit que QSO récents (mise à jour cty/QRZ/Club Log, envoi aux services, e-mail d'enregistrement, eQSL, suppression).",
|
||||||
|
"Quitter le mode compact restaure la taille ET la position précédentes de la fenêtre (avant, elle reprenait une taille fixe là où la barre compacte avait été déplacée).",
|
||||||
|
"Le champ « Mon indicatif » du filtre avancé est renommé « Station callsign » pour être trouvable sous son nom ADIF (il filtre STATION_CALLSIGN).",
|
||||||
|
"Le filtrage affiche maintenant TOUTES les correspondances : la limite d'affichage (Max) ne s'applique qu'au log non filtré — un filtre qui matche 200 QSO les affiche tous les 200 même avec Max à 100 (plafond de sécurité 10 000, avec un avertissement pour affiner au-delà).",
|
||||||
|
"Nouveau bouton « Tout sélectionner » dans la barre d'outils de la grille — sélectionne toutes les lignes affichées (en respectant les filtres de colonnes actifs) en un clic, prêt pour l'envoi LoTW, le bulk edit ou l'export ; une fois tout sélectionné il devient « Tout désélectionner ».",
|
||||||
|
"NET Control : glisser-déposer entre les deux listes — glisser une station du roster vers la liste on air démarre son QSO, et glisser une station on air vers le roster l'enregistre au log (comme le bouton Logger & terminer).",
|
||||||
|
"Nouvelle option (Réglages → Général) : « Regrouper les modes digitaux en un seul (style DXCC) » — activée, les badges de nouveauté de la matrice et le coloriage cluster new/new-band/new-mode/new-slot traitent FT8/FT4/RTTY/PSK… comme un seul mode Digital, comme le DXCC ; désactivée (défaut), chaque mode digital reste un slot potentiel distinct.",
|
||||||
|
"Plusieurs amplificateurs : Réglages → Amplificateur est maintenant une LISTE — configurez plusieurs amplis (p. ex. deux SPE en parallèle), chacun avec son nom et sa connexion. Les cartes ampli du panneau Flex et de Station Control ont une liste déroulante pour choisir lequel afficher, et la barre du bas montre une pastille cliquable par ampli. Les configurations mono-ampli existantes migrent automatiquement.",
|
||||||
|
"Panneau FlexRadio : les filtres DSP SmartSDR v4 sont maintenant pilotables — NRL et ANFL (LMS legacy), NRS (soustraction spectrale), NRF (réduction de bruit avec filtre) avec leurs curseurs de niveau, plus les interrupteurs RNN (réduction de bruit par IA) et ANFT (notch automatique FFT). La section apparaît automatiquement sur les radios qui les supportent (séries 8000/Aurora). Mise en page rééquilibrée : RIT/XIT passent dans la colonne Transmit et la sélection d'antennes s'affiche en haut de la colonne Receive, avec un nouveau bouton DAX (le toggle DAX du bandeau transmit de SmartSDR — audio d'émission via DAX, pour WSJT-X & co)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.20.8",
|
||||||
|
"date": "2026-07-21",
|
||||||
|
"en": [
|
||||||
|
"New: microHAM ARCO rotator controlled natively — no PstRotator needed. Over the LAN or over USB: set the ARCO's CONTROL PROTOCOL (LAN or USB) to 'Yaesu GS-232A' and pick 'microHAM ARCO' in Settings → Rotator.",
|
||||||
|
"Amplifier without a FlexRadio: the amplifier controls (OPERATE/STANDBY, ON/OFF, live power/SWR/temperature) now also live in the Station Control tab, and a status chip sits in the bottom bar next to Cluster/CAT/Rotator — green ON AIR = OPERATE, orange = STANDBY, red = offline. Clicking the chip toggles OPERATE/STANDBY on all three amps (PowerGenius XL included, over its direct network link).",
|
||||||
|
"NET Control: after logging a station, the next on-air station is selected automatically (chain log → log → log), and a new 'Log everyone' button logs every on-air station at once when the net ends.",
|
||||||
|
"Switching to a profile whose MySQL database is unreachable now shows a clear warning — before, OpsLog silently stayed on the previous logbook and QSOs could land in the wrong log.",
|
||||||
|
"The ON AIR badge is back for local (SQLite) logbooks — it had disappeared with the removal of the live-status option."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Nouveau : rotator microHAM ARCO contrôlé en natif — sans PstRotator. Par le réseau ou par USB : régler le CONTROL PROTOCOL de l'ARCO (LAN ou USB) sur « Yaesu GS-232A » et choisir « microHAM ARCO » dans Réglages → Rotator.",
|
||||||
|
"Amplificateur sans FlexRadio : les commandes d'ampli (OPERATE/STANDBY, ON/OFF, puissance/ROS/température en direct) sont maintenant aussi dans l'onglet Station Control, et une pastille de statut s'affiche dans la barre du bas à côté de Cluster/CAT/Rotator — vert = OPERATE, orange = STANDBY, rouge = hors ligne. Un clic sur la pastille bascule OPERATE/STANDBY sur les trois amplis (PowerGenius XL compris, via son lien réseau direct).",
|
||||||
|
"NET Control : après avoir loggé une station, la station on air suivante est sélectionnée automatiquement (enchaînement log → log → log), et un nouveau bouton « Logger tout le monde » enregistre toutes les stations on air d'un coup à la fin du net.",
|
||||||
|
"Passer sur un profil dont la base MySQL est injoignable affiche maintenant un avertissement clair — avant, OpsLog restait silencieusement sur l'ancien logbook et les QSO pouvaient partir dans le mauvais log.",
|
||||||
|
"Le badge ON AIR est de retour pour les logbooks locaux (SQLite) — il avait disparu avec la suppression de l'option de statut en direct."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
+317
-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,13 +36,15 @@ 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, GetActiveProfile, QuitApp,
|
GetUIPref, GetActiveProfile, QuitApp,
|
||||||
ReportLiveActivity, GetLiveStatusEnabled, LiveLastQSOAgeSec,
|
ReportLiveActivity, LiveLastQSOAgeSec,
|
||||||
|
GetAmpStatuses, AmpOperate,
|
||||||
|
GetFlexState, FlexAmpOperate,
|
||||||
} 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';
|
||||||
@@ -114,6 +116,9 @@ type ModePreset = ModePresetForm;
|
|||||||
type WB = WorkedBeforeView;
|
type WB = WorkedBeforeView;
|
||||||
type CATState = Omit<catModels.RigState, 'convertValues'>;
|
type CATState = Omit<catModels.RigState, 'convertValues'>;
|
||||||
|
|
||||||
|
// Safety cap on how many rows an ACTIVE filter may load (the normal row
|
||||||
|
// threshold is bypassed while filtering — see buildActiveFilter).
|
||||||
|
const FILTERED_LIMIT_CAP = 10000;
|
||||||
const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm'];
|
const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm'];
|
||||||
const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE'];
|
const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE'];
|
||||||
// Modes the QSO recorder captures (phone only). Mirrors recordableMode() in
|
// Modes the QSO recorder captures (phone only). Mirrors recordableMode() in
|
||||||
@@ -283,7 +288,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);
|
||||||
@@ -436,18 +441,46 @@ export default function App() {
|
|||||||
// click reverts the UI and the click looks like it did nothing.
|
// click reverts the UI and the click looks like it did nothing.
|
||||||
const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
|
const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
|
||||||
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
|
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
|
||||||
|
// Amplifier chips in the status bar — ONE PER configured amp (several are
|
||||||
|
// possible: some ops run two SPEs in parallel). Green OPERATE / orange
|
||||||
|
// STANDBY / red offline; click toggles. The Flex state rides along because a
|
||||||
|
// PGXL's OPERATE state comes from the radio when it reports the amp.
|
||||||
|
const [ampSts, setAmpSts] = useState<any[]>([]);
|
||||||
|
const [flexAmp, setFlexAmp] = useState<any>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = () => Promise.all([
|
||||||
|
GetAmpStatuses().catch(() => []),
|
||||||
|
GetFlexState().catch(() => null),
|
||||||
|
]).then(([l, fx]: any[]) => { if (alive) { setAmpSts((l ?? []) as any[]); setFlexAmp(fx); } });
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 2000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
// Multi-op "who's on air" widget: every operator's live status from the shared
|
// Multi-op "who's on air" widget: every operator's live status from the shared
|
||||||
// MySQL logbook (freq/mode/version). Only polled on a MySQL logbook.
|
// MySQL logbook (freq/mode/version). Only polled on a MySQL logbook.
|
||||||
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
|
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.
|
||||||
@@ -775,7 +808,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
|
||||||
@@ -803,43 +836,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);
|
||||||
@@ -1132,22 +1142,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]);
|
||||||
@@ -1167,12 +1177,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;
|
||||||
@@ -1180,7 +1209,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.
|
||||||
@@ -1382,13 +1411,21 @@ export default function App() {
|
|||||||
|
|
||||||
// The full filter sent to the backend = the builder conditions + the quick
|
// The full filter sent to the backend = the builder conditions + the quick
|
||||||
// callsign search box (always ANDed) + the on-screen row threshold.
|
// callsign search box (always ANDed) + the on-screen row threshold.
|
||||||
const buildActiveFilter = useCallback((): QueryFilter => ({
|
// When a filter is ACTIVE the row threshold is lifted to a high safety cap:
|
||||||
quick_callsign: filterCallsign,
|
// the threshold exists to keep the UNFILTERED 30k-QSO log fast, but applying
|
||||||
conditions: activeFilter.conditions ?? [],
|
// it to a filter meant "200 matches, only the first 100 shown". A filtered
|
||||||
match: activeFilter.match ?? 'AND',
|
// result set is almost always small, and the cap protects against a
|
||||||
limit: qsoLimit,
|
// broad filter (e.g. band=20m) pulling half the log.
|
||||||
offset: 0,
|
const buildActiveFilter = useCallback((): QueryFilter => {
|
||||||
}), [filterCallsign, activeFilter, qsoLimit]);
|
const hasFilter = !!(filterCallsign || (activeFilter.conditions ?? []).length);
|
||||||
|
return {
|
||||||
|
quick_callsign: filterCallsign,
|
||||||
|
conditions: activeFilter.conditions ?? [],
|
||||||
|
match: activeFilter.match ?? 'AND',
|
||||||
|
limit: hasFilter ? FILTERED_LIMIT_CAP : qsoLimit,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
}, [filterCallsign, activeFilter, qsoLimit]);
|
||||||
|
|
||||||
// refresh reloads the grid. Returns false on failure. When silent, it doesn't
|
// refresh reloads the grid. Returns false on failure. When silent, it doesn't
|
||||||
// surface the error — used by the startup retry, since the logbook DB (a remote
|
// surface the error — used by the startup retry, since the logbook DB (a remote
|
||||||
@@ -1935,7 +1972,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 () => {
|
||||||
@@ -2036,7 +2074,7 @@ 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 */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -2126,39 +2164,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) => {
|
||||||
setWkSent(resolved);
|
const end = Date.now() + ms;
|
||||||
await IcomSendCW(keyed).catch((e) => setError(String(e?.message ?? e)));
|
while (Date.now() < end && !aborted()) await sleep(50);
|
||||||
if (doLog) { await sleep(Math.round(estimateCwMs(resolved, wkWpm)) + 600); void save(); }
|
};
|
||||||
return;
|
// Split the macro on <LOGQSO> so the log happens AT the token's position, not
|
||||||
|
// only at the very end: "TU <LOGQSO> QRZ" sends "TU", logs, then sends "QRZ".
|
||||||
|
// Each split boundary (every part except the last) is one <LOGQSO>.
|
||||||
|
// RESOLVE every segment up front — while the form still holds the callsign — so a
|
||||||
|
// segment AFTER the <LOGQSO> (which logs and clears the form) still expands its
|
||||||
|
// variables correctly.
|
||||||
|
const parts = rawText.split(/<LOGQSO>/i).map((pt) => resolveCW(pt));
|
||||||
|
const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex';
|
||||||
|
for (let p = 0; p < parts.length; p++) {
|
||||||
|
if (aborted()) return; // ESC / Stop before this segment → stop sending, don't log
|
||||||
|
const resolved = parts[p];
|
||||||
|
const isLast = p === parts.length - 1;
|
||||||
|
const logAfter = !isLast; // a <LOGQSO> follows this segment
|
||||||
|
if (resolved) {
|
||||||
|
// Trailing word space so back-to-back sends don't run together in the keyer
|
||||||
|
// buffer ("CQ"+"TEST" → "CQTEST"); the keyer keys it as a word gap at the
|
||||||
|
// current WPM, so it scales automatically.
|
||||||
|
const keyed = resolved + ' ';
|
||||||
|
setWkSent(resolved);
|
||||||
|
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : null;
|
||||||
|
if (sendFn) await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||||
|
else await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||||
|
// Only WAIT for the CW to finish on the FINAL segment — auto-call needs the
|
||||||
|
// send done before its gap+resend. A segment a <LOGQSO> follows is NOT waited
|
||||||
|
// on: the keyer has already buffered the CW, so logging happens AT ONCE and
|
||||||
|
// never interrupts what's being sent. (Gating the log on a CW-length ESTIMATE
|
||||||
|
// made <LOGQSO> log up to ~10s late when the estimate ran long.)
|
||||||
|
if (isLast) {
|
||||||
|
if (isRig) {
|
||||||
|
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + 600);
|
||||||
|
} else {
|
||||||
|
// WinKeyer: watch the busy echo, capped by the estimate (+slack) since
|
||||||
|
// over a remote/serial-over-IP link that echo can lag tens of seconds.
|
||||||
|
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500;
|
||||||
|
for (let i = 0; i < 20 && !wkBusyRef.current && !aborted(); i++) await sleep(50); // ≤1s to start
|
||||||
|
const deadline = Date.now() + capMs;
|
||||||
|
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (aborted()) return; // aborted while this segment was sending → don't log
|
||||||
|
if (logAfter) void save(); // log NOW — the buffered CW keeps sending
|
||||||
}
|
}
|
||||||
await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
|
|
||||||
// <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has
|
|
||||||
// finished sending — so the QSO isn't logged (and the form cleared) while CW
|
|
||||||
// is still going out. We'd like to wait for the busy flag to rise then fall,
|
|
||||||
// but over a remote/serial-over-IP link that status echo can lag by tens of
|
|
||||||
// seconds, which used to delay logging ~50s. So cap the wait at the ESTIMATED
|
|
||||||
// send duration (text length × WPM) plus slack: log when busy clears OR the
|
|
||||||
// estimate elapses, whichever comes first.
|
|
||||||
if (!doLog) return;
|
|
||||||
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500; // slack for keyer buffering/lag
|
|
||||||
for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start
|
|
||||||
const deadline = Date.now() + capMs;
|
|
||||||
while (wkBusyRef.current && Date.now() < deadline) await sleep(50);
|
|
||||||
void save();
|
|
||||||
}
|
}
|
||||||
// stopAutoCall cancels any running auto-call loop.
|
// 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) {
|
||||||
@@ -2168,20 +2236,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
|
||||||
}
|
}
|
||||||
@@ -2212,8 +2271,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
|
||||||
@@ -2659,7 +2724,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
|
||||||
@@ -2783,7 +2852,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' },
|
||||||
@@ -2797,9 +2865,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) {
|
||||||
@@ -2820,14 +2889,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2880,7 +2949,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();
|
||||||
@@ -3627,7 +3704,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>
|
||||||
);
|
);
|
||||||
@@ -3640,7 +3717,12 @@ export default function App() {
|
|||||||
case 'netcontrol':
|
case 'netcontrol':
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full min-h-0 flex flex-col rounded-lg overflow-hidden border border-border">
|
<div className="h-full w-full min-h-0 flex flex-col rounded-lg overflow-hidden border border-border">
|
||||||
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} />
|
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes}
|
||||||
|
qsoMenuHandlers={{
|
||||||
|
onUpdateFromCty: bulkUpdateFromCty, onUpdateFromQRZ: bulkUpdateFromQRZ, onUpdateFromClublog: bulkUpdateFromClublog,
|
||||||
|
onSendTo: bulkSendTo, onSendRecording: bulkSendRecording, onSendEQSL: (ids: number[]) => setEqslQsoId(ids[0] ?? null),
|
||||||
|
onDelete: (ids: number[]) => setDeletingIds(ids),
|
||||||
|
}} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'recent':
|
case 'recent':
|
||||||
@@ -3670,7 +3752,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 ? (
|
||||||
@@ -3839,24 +3921,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'); }}
|
||||||
@@ -4128,12 +4192,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>
|
||||||
@@ -4146,6 +4212,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:
|
||||||
@@ -4322,31 +4433,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.freq_hz ? (s.freq_hz / 1e6).toFixed(3) : '—'}
|
||||||
{s.version && <span className="text-[9px] text-muted-foreground shrink-0 tabular-nums ml-auto">v{s.version}</span>}
|
</span>
|
||||||
</div>
|
{s.mode && (
|
||||||
<div className="flex items-center gap-1.5 mt-0.5 min-w-0">
|
<span className="text-[9px] font-bold uppercase px-1 rounded-full leading-[15px] shrink-0"
|
||||||
<span className="font-mono text-[11px] font-semibold tabular-nums" style={{ color: mc }}>
|
style={{ background: `${mc}22`, color: mc }}>{s.mode}</span>
|
||||||
{s.freq_hz ? (s.freq_hz / 1e6).toFixed(3) : '—'}
|
)}
|
||||||
</span>
|
{s.band && <span className="text-[10px] text-muted-foreground shrink-0">{s.band}</span>}
|
||||||
{s.mode && (
|
<span className="flex-1" />
|
||||||
<span className="text-[9px] font-bold uppercase px-1.5 rounded-full leading-[15px] shrink-0"
|
{s.version && <span className="text-[9px] text-muted-foreground shrink-0 tabular-nums">v{s.version}</span>}
|
||||||
style={{ background: `${mc}22`, color: mc }}>{s.mode}</span>
|
|
||||||
)}
|
|
||||||
{s.band && <span className="text-[10px] text-muted-foreground shrink-0">{s.band}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -4406,8 +4513,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}
|
||||||
@@ -4424,11 +4531,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}
|
||||||
@@ -4467,53 +4575,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)]',
|
||||||
@@ -4728,9 +4789,13 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{qsos.length >= qsoLimit && qsos.length < total && (
|
{(activeFilter.conditions?.length || filterCallsign)
|
||||||
<span className="text-warning">Limit reached — raise Max to see more.</span>
|
? (matchCount != null && matchCount > FILTERED_LIMIT_CAP && (
|
||||||
)}
|
<span className="text-warning">{matchCount.toLocaleString('en-US')} matches — only {FILTERED_LIMIT_CAP.toLocaleString('en-US')} shown, narrow the filter (or use the filtered ADIF export).</span>
|
||||||
|
))
|
||||||
|
: (qsos.length >= qsoLimit && qsos.length < total && (
|
||||||
|
<span className="text-warning">Limit reached — raise Max to see more.</span>
|
||||||
|
))}
|
||||||
<Label className="text-[11px] text-muted-foreground">Max</Label>
|
<Label className="text-[11px] text-muted-foreground">Max</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -4966,7 +5031,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>
|
||||||
)}
|
)}
|
||||||
@@ -4984,7 +5049,12 @@ export default function App() {
|
|||||||
tune the rig. */}
|
tune the rig. */}
|
||||||
{netEnabled && (
|
{netEnabled && (
|
||||||
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
|
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||||
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} />
|
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes}
|
||||||
|
qsoMenuHandlers={{
|
||||||
|
onUpdateFromCty: bulkUpdateFromCty, onUpdateFromQRZ: bulkUpdateFromQRZ, onUpdateFromClublog: bulkUpdateFromClublog,
|
||||||
|
onSendTo: bulkSendTo, onSendRecording: bulkSendRecording, onSendEQSL: (ids: number[]) => setEqslQsoId(ids[0] ?? null),
|
||||||
|
onDelete: (ids: number[]) => setDeletingIds(ids),
|
||||||
|
}} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -5095,16 +5165,51 @@ export default function App() {
|
|||||||
disabled={!rotatorHeading.enabled}
|
disabled={!rotatorHeading.enabled}
|
||||||
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
||||||
/>
|
/>
|
||||||
{liveStatusOn && (
|
{/* Amplifier chips — one per configured amp: green = OPERATE, orange =
|
||||||
<div
|
STANDBY, red = offline. CLICK toggles OPERATE ↔ STANDBY (optimistic
|
||||||
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',
|
flip, the 2s poll reconciles); offline → click opens the settings. */}
|
||||||
onAir ? 'border-danger-border bg-danger-muted text-danger-muted-foreground' : 'border-border text-muted-foreground')}
|
{ampSts.map((a: any) => {
|
||||||
title={onAir ? t('live.onAirTip') : t('live.offlineTip')}
|
const isPGXL = !a.spe && !a.acom;
|
||||||
>
|
const viaFlex = isPGXL && !!flexAmp?.amp_available;
|
||||||
<span className={cn('size-2 rounded-full', onAir ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')} />
|
const raw = a.spe ?? a.acom ?? a.pgxl ?? { connected: false };
|
||||||
{onAir ? t('live.onAir') : t('live.offline')}
|
const connected = !!raw.connected || viaFlex;
|
||||||
</div>
|
const operate = viaFlex ? !!flexAmp.amp_operate : !!raw.operate;
|
||||||
)}
|
const dot = !connected ? 'bg-danger' : operate ? 'bg-success' : 'bg-warning';
|
||||||
|
const state = !connected ? (raw.last_error || 'offline') : operate ? 'OPERATE' : 'STANDBY';
|
||||||
|
const toggle = () => {
|
||||||
|
if (!connected) { setSettingsSection('pgxl'); setShowSettings(true); return; }
|
||||||
|
const want = !operate;
|
||||||
|
if (viaFlex) setFlexAmp((f: any) => ({ ...f, amp_operate: want }));
|
||||||
|
else setAmpSts((l) => l.map((x: any) => x.id === a.id
|
||||||
|
? { ...x, spe: x.spe && { ...x.spe, operate: want }, acom: x.acom && { ...x.acom, operate: want }, pgxl: x.pgxl && { ...x.pgxl, operate: want } }
|
||||||
|
: x));
|
||||||
|
(viaFlex ? FlexAmpOperate(want) : AmpOperate(a.id, want)).catch(() => {});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
type="button"
|
||||||
|
title={`${a.name}: ${state}${connected ? (operate ? ' — click for STANDBY' : ' — click for OPERATE') : ' — click for settings'}`}
|
||||||
|
onClick={toggle}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2 h-5 rounded border text-[11px] transition-colors border-border hover:bg-muted cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className={cn('size-2 rounded-full', dot)} />
|
||||||
|
{a.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* ON AIR badge: "did I log a QSO in the last 5 min" — meaningful on ANY
|
||||||
|
logbook backend (only the live_status PUBLISHING is MySQL-specific),
|
||||||
|
so it is always shown. Gating it on MySQL made it vanish for
|
||||||
|
local-SQLite operators when the Settings toggle was removed. */}
|
||||||
|
<div
|
||||||
|
className={cn('inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider shrink-0 transition-colors',
|
||||||
|
onAir ? 'border-danger-border bg-danger-muted text-danger-muted-foreground' : 'border-border text-muted-foreground')}
|
||||||
|
title={onAir ? t('live.onAirTip') : t('live.offlineTip')}
|
||||||
|
>
|
||||||
|
<span className={cn('size-2 rounded-full', onAir ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')} />
|
||||||
|
{onAir ? t('live.onAir') : t('live.offline')}
|
||||||
|
</div>
|
||||||
{/* Toasts / errors: the status bar's free space is far wider than the
|
{/* Toasts / errors: the status bar's free space is far wider than the
|
||||||
header band they used to sit in. Still one line (the bar is 28px),
|
header band they used to sit in. Still one line (the bar is 28px),
|
||||||
but CLICK opens the full text wrapped — long messages (a TQSL or
|
but CLICK opens the full text wrapped — long messages (a TQSL or
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -109,14 +109,21 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
|
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
|
||||||
// Derived straight from the entity's real band_status (all bands it was
|
// Derived straight from the entity's real band_status (all bands it was
|
||||||
// worked on — not just the operator's configured column list).
|
// worked on — not just the operator's configured column list).
|
||||||
// Newness uses the ACTUAL mode (FT8 / FT4 / RTTY…), not the PH/CW/DIG class:
|
// By default newness uses the ACTUAL mode (FT8 / FT4 / RTTY…): DIG is a
|
||||||
// DIG is a group, so FT4 after FT8 is genuinely a new mode. dxcc_band_modes
|
// group, so FT4 after FT8 is genuinely a new mode. The operator can opt into
|
||||||
// lists every real (band, mode) the entity was worked on.
|
// DXCC-style grouping instead (Settings → General), where all digital modes
|
||||||
|
// count as ONE — then FT4 after FT8 is just "worked".
|
||||||
|
const groupDigital = localStorage.getItem('opslog.groupDigitalSlots') === '1';
|
||||||
|
const normMode = (m: string): string => {
|
||||||
|
const u = (m || '').toUpperCase().trim();
|
||||||
|
if (!groupDigital) return u;
|
||||||
|
return u === '' || u === 'CW' || PHONE_MODES.has(u) ? u : 'DIG';
|
||||||
|
};
|
||||||
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
|
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
|
||||||
const curMode = (currentMode || '').toUpperCase().trim();
|
const curMode = normMode(currentMode);
|
||||||
const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode)
|
const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode)
|
||||||
const modeWorked = !!curMode && bandModes.some((bm) => (bm.mode || '').toUpperCase() === curMode); // …in this exact mode (any band)
|
const modeWorked = !!curMode && bandModes.some((bm) => normMode(bm.mode) === curMode); // …in this (normalised) mode (any band)
|
||||||
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && (bm.mode || '').toUpperCase() === curMode);
|
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && normMode(bm.mode) === curMode);
|
||||||
// Mutually-exclusive badges, shown only when the entity is worked but this
|
// Mutually-exclusive badges, shown only when the entity is worked but this
|
||||||
// exact band+mode is NOT yet:
|
// exact band+mode is NOT yet:
|
||||||
// New Band & Mode = both the band AND the mode are new for this entity.
|
// New Band & Mode = both the band AND the mode are new for this entity.
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import {
|
|||||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||||
FlexMox, FlexAmpOperate,
|
FlexMox, FlexAmpOperate,
|
||||||
GetPGXLStatus, PGXLSetFanMode,
|
GetPGXLStatus, PGXLSetFanMode,
|
||||||
|
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel,
|
||||||
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,
|
||||||
|
FlexSetLMSNR, FlexSetLMSNRLevel, FlexSetLMSANF, FlexSetLMSANFLevel,
|
||||||
|
FlexSetSpeexNR, FlexSetSpeexNRLevel, FlexSetRNN, FlexSetANFT, FlexSetNRF, FlexSetNRFLevel, FlexSetTXDAX,
|
||||||
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
|
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
|
||||||
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
||||||
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
||||||
@@ -30,6 +33,11 @@ type FlexState = {
|
|||||||
rit: boolean; rit_freq: number; xit: boolean; xit_freq: number;
|
rit: boolean; rit_freq: number; xit: boolean; xit_freq: number;
|
||||||
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
||||||
wnb: boolean; wnb_level: number;
|
wnb: boolean; wnb_level: number;
|
||||||
|
// SmartSDR v4 DSP (8000/Aurora) — dsp_v4 is true once the radio reports them.
|
||||||
|
lms_nr?: boolean; lms_nr_level?: number; lms_anf?: boolean; lms_anf_level?: number;
|
||||||
|
speex_nr?: boolean; speex_nr_level?: number; rnn?: boolean; anft?: boolean;
|
||||||
|
nrf?: boolean; nrf_level?: number; dsp_v4?: boolean;
|
||||||
|
dax_ch?: number; tx_dax?: boolean;
|
||||||
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
|
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
|
||||||
mode?: string;
|
mode?: string;
|
||||||
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
|
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
|
||||||
@@ -263,12 +271,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 +349,32 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Configured amplifiers (Settings → Amplifier) — possibly SEVERAL (some ops
|
||||||
|
// run two SPEs in parallel). The card shows ONE at a time; the dropdown picks
|
||||||
|
// which, and the choice is remembered per panel.
|
||||||
|
const [ampList, setAmpList] = useState<any[]>([]);
|
||||||
|
const [ampSel, setAmpSel] = useState<string>(() => localStorage.getItem('opslog.ampSel.flex') || '');
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = () => GetAmpStatuses().then((l: any) => alive && setAmpList((l ?? []) as any[])).catch(() => {});
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1500);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
const selAmp = ampList.find((a: any) => a.id === ampSel) ?? ampList[0];
|
||||||
|
const isSPE = !!selAmp?.spe;
|
||||||
|
const isACOM = !!selAmp?.acom;
|
||||||
|
const spe = selAmp?.spe ?? { connected: false };
|
||||||
|
const acom = selAmp?.acom ?? { connected: false };
|
||||||
|
const ampPicker = ampList.length > 1 && selAmp ? (
|
||||||
|
<select value={selAmp.id}
|
||||||
|
onChange={(e) => { setAmpSel(e.target.value); localStorage.setItem('opslog.ampSel.flex', e.target.value); }}
|
||||||
|
title={t('flxp.ampPick')}
|
||||||
|
className="h-8 rounded-md border border-border bg-card px-2 text-xs font-semibold outline-none">
|
||||||
|
{ampList.map((a: any) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||||
|
</select>
|
||||||
|
) : null;
|
||||||
|
|
||||||
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 }));
|
||||||
@@ -636,12 +701,24 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} />
|
onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* RIT/XIT live on the TRANSMIT side to balance the two columns — the
|
||||||
|
RECEIVE card grew tall with the v4 DSP rows. */}
|
||||||
|
<div className="space-y-1.5 border-t border-border/60 pt-3">
|
||||||
|
<OffsetRow label="RIT" on={st.rit} disabled={rxOff} hz={st.rit_freq} title={t('flxp.ritHint')}
|
||||||
|
onToggle={() => change('rit', !st.rit, () => FlexSetRIT(!st.rit))}
|
||||||
|
onHz={(v) => change('rit_freq', v, () => FlexSetRITFreq(v))} />
|
||||||
|
<OffsetRow label="XIT" on={st.xit} disabled={rxOff} hz={st.xit_freq} title={t('flxp.xitHint')}
|
||||||
|
onToggle={() => change('xit', !st.xit, () => FlexSetXIT(!st.xit))}
|
||||||
|
onHz={(v) => change('xit_freq', v, () => FlexSetXITFreq(v))} />
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* RECEIVE */}
|
{/* RECEIVE */}
|
||||||
<Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2">
|
<Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2">
|
||||||
|
{/* Antenna selection sits at the very top of the RX column. */}
|
||||||
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
|
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 pb-3 border-b border-border/60">
|
||||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span>
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span>
|
||||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||||
<span className="text-[10px] text-muted-foreground">RX</span>
|
<span className="text-[10px] text-muted-foreground">RX</span>
|
||||||
@@ -656,21 +733,17 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
|
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
|
||||||
{((st.tx_ant_list?.length ? st.tx_ant_list : st.ant_list) ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
|
{((st.tx_ant_list?.length ? st.tx_ant_list : st.ant_list) ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
{/* DAX on/off — SmartSDR's transmit-bar DAX button (TX audio from
|
||||||
|
DAX, e.g. WSJT-X). "transmit set dax=", not the slice channel. */}
|
||||||
|
<button type="button" disabled={off} title={t('flxp.daxHint')}
|
||||||
|
onClick={() => change('tx_dax', !st.tx_dax, () => FlexSetTXDAX(!st.tx_dax))}
|
||||||
|
className={cn('h-7 px-2.5 shrink-0 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-40',
|
||||||
|
st.tx_dax ? 'bg-info text-info-foreground border-info' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
DAX
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* RIT / XIT — on the RECEIVE side: RIT is what you reach for while
|
|
||||||
listening (chasing a station that drifts off your transmit frequency),
|
|
||||||
so it belongs with the other things you touch mid-QSO. Both act on the
|
|
||||||
ACTIVE slice and follow slice focus like every control here. */}
|
|
||||||
<div className="space-y-1.5 pb-3 border-b border-border/60">
|
|
||||||
<OffsetRow label="RIT" on={st.rit} disabled={rxOff} hz={st.rit_freq} title={t('flxp.ritHint')}
|
|
||||||
onToggle={() => change('rit', !st.rit, () => FlexSetRIT(!st.rit))}
|
|
||||||
onHz={(v) => change('rit_freq', v, () => FlexSetRITFreq(v))} />
|
|
||||||
<OffsetRow label="XIT" on={st.xit} disabled={rxOff} hz={st.xit_freq} title={t('flxp.xitHint')}
|
|
||||||
onToggle={() => change('xit', !st.xit, () => FlexSetXIT(!st.xit))}
|
|
||||||
onHz={(v) => change('xit_freq', v, () => FlexSetXITFreq(v))} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
|
||||||
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
|
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
|
||||||
@@ -711,6 +784,49 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
|
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* SmartSDR v4 DSP (8000/Aurora series) — NRL/ANFL (legacy LMS), NRS
|
||||||
|
(spectral subtraction), NRF (NR w/ filter), RNN (AI NR), ANFT (FFT
|
||||||
|
notch). Only shown when the radio actually reports these slice keys
|
||||||
|
(older 6000s never do). API keys per the FlexLib slice docs. */}
|
||||||
|
{st.dsp_v4 && (
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
<LevelRow label="NRL" on={!!st.lms_nr} disabled={rxOff} value={st.lms_nr_level ?? 0} accent="cyan" sliderAccent="#0e7490"
|
||||||
|
onToggle={() => change('lms_nr', !st.lms_nr, () => FlexSetLMSNR(!st.lms_nr))}
|
||||||
|
onLevel={(v) => change('lms_nr_level', v, () => FlexSetLMSNRLevel(v))} />
|
||||||
|
<LevelRow label="NRS" on={!!st.speex_nr} disabled={rxOff} value={st.speex_nr_level ?? 0} accent="cyan" sliderAccent="#06b6d4"
|
||||||
|
onToggle={() => change('speex_nr', !st.speex_nr, () => FlexSetSpeexNR(!st.speex_nr))}
|
||||||
|
onLevel={(v) => change('speex_nr_level', v, () => FlexSetSpeexNRLevel(v))} />
|
||||||
|
<LevelRow label="NRF" on={!!st.nrf} disabled={rxOff} value={st.nrf_level ?? 0} accent="cyan" sliderAccent="#0369a1"
|
||||||
|
onToggle={() => change('nrf', !st.nrf, () => FlexSetNRF(!st.nrf))}
|
||||||
|
onLevel={(v) => change('nrf_level', v, () => FlexSetNRFLevel(v))} />
|
||||||
|
{/* Notch filters target carriers in voice — hidden in CW like ANF. */}
|
||||||
|
{!isCW && (
|
||||||
|
<LevelRow label="ANFL" on={!!st.lms_anf} disabled={rxOff} value={st.lms_anf_level ?? 0} accent="violet" sliderAccent="#8b5cf6"
|
||||||
|
onToggle={() => change('lms_anf', !st.lms_anf, () => FlexSetLMSANF(!st.lms_anf))}
|
||||||
|
onLevel={(v) => change('lms_anf_level', v, () => FlexSetLMSANFLevel(v))} />
|
||||||
|
)}
|
||||||
|
{/* RNN and ANFT are on/off only — no level in the API. */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground" title={t('flxp.dspV4Hint')}>AI/FFT</span>
|
||||||
|
<button type="button" disabled={rxOff}
|
||||||
|
title={t('flxp.rnnHint')}
|
||||||
|
onClick={() => change('rnn', !st.rnn, () => FlexSetRNN(!st.rnn))}
|
||||||
|
className={cn('px-2.5 py-1 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-30',
|
||||||
|
st.rnn ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
RNN
|
||||||
|
</button>
|
||||||
|
{!isCW && (
|
||||||
|
<button type="button" disabled={rxOff}
|
||||||
|
title={t('flxp.anftHint')}
|
||||||
|
onClick={() => change('anft', !st.anft, () => FlexSetANFT(!st.anft))}
|
||||||
|
className={cn('px-2.5 py-1 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-30',
|
||||||
|
st.anft ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
ANFT
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isCW && (
|
{isCW && (
|
||||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
<LevelRow label="APF" on={st.apf} disabled={rxOff} value={st.apf_level} accent="emerald" sliderAccent="#16a34a"
|
<LevelRow label="APF" on={st.apf} disabled={rxOff} value={st.apf_level} accent="emerald" sliderAccent="#16a34a"
|
||||||
@@ -760,10 +876,118 @@ 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')} · ${selAmp?.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{ampPicker}
|
||||||
|
<button type="button" disabled={!spe.connected}
|
||||||
|
onClick={() => AmpOperate(selAmp.id, !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={() => AmpPower(selAmp.id, 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={() => AmpPower(selAmp.id, 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={() => AmpPowerLevel(selAmp.id, 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')} · ${selAmp?.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{ampPicker}
|
||||||
|
<button type="button" disabled={!acom.connected}
|
||||||
|
onClick={() => AmpOperate(selAmp.id, !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={() => AmpPower(selAmp.id, 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={() => AmpPower(selAmp.id, 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">
|
||||||
|
{ampPicker}
|
||||||
<button type="button" disabled={off}
|
<button type="button" disabled={off}
|
||||||
onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))}
|
onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))}
|
||||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
@@ -776,7 +1000,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) && (
|
{selAmp?.type === '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>
|
||||||
|
|||||||
@@ -42,14 +42,28 @@ function fmtTimeOn(s: any): string {
|
|||||||
|
|
||||||
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
|
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
|
||||||
|
|
||||||
|
// The right-click actions of the main Recent QSOs grid (update from cty/QRZ/
|
||||||
|
// Club Log, send to services, recording e-mail, eQSL, delete) — passed down so
|
||||||
|
// the worked-before grid here offers the SAME context menu on logged QSOs.
|
||||||
|
type QSOMenuHandlers = {
|
||||||
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
|
onSendRecording?: (ids: number[]) => void;
|
||||||
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
|
onDelete?: (ids: number[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onLogged?: () => void;
|
onLogged?: () => void;
|
||||||
countries?: string[];
|
countries?: string[];
|
||||||
bands?: string[];
|
bands?: string[];
|
||||||
modes?: string[];
|
modes?: string[];
|
||||||
|
qsoMenuHandlers?: QSOMenuHandlers;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHandlers }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [nets, setNets] = useState<Net[]>([]);
|
const [nets, setNets] = useState<Net[]>([]);
|
||||||
const [selId, setSelId] = useState<string>('');
|
const [selId, setSelId] = useState<string>('');
|
||||||
@@ -61,6 +75,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
|
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
|
||||||
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
|
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
|
||||||
const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]);
|
const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]);
|
||||||
|
// Programmatic row selection in the on-air grid (auto-advance after logging).
|
||||||
|
const [selectRow, setSelectRow] = useState<{ id: number; seq: number }>({ id: 0, seq: 0 });
|
||||||
|
|
||||||
// Add/edit-contact dialog.
|
// Add/edit-contact dialog.
|
||||||
const [contactOpen, setContactOpen] = useState(false);
|
const [contactOpen, setContactOpen] = useState(false);
|
||||||
@@ -68,6 +84,34 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
const [looking, setLooking] = useState(false);
|
const [looking, setLooking] = useState(false);
|
||||||
|
|
||||||
const rosterGrid = useRef<any>(null);
|
const rosterGrid = useRef<any>(null);
|
||||||
|
// Cross-grid drag & drop: roster row → on-air grid puts the station on air;
|
||||||
|
// on-air row → roster grid logs it (same as the "Log & end" button). AG-Grid
|
||||||
|
// external drop zones need each grid's api + the OTHER grid's container div.
|
||||||
|
const onAirApi = useRef<any>(null);
|
||||||
|
const onAirWrap = useRef<HTMLDivElement | null>(null);
|
||||||
|
const rosterWrap = useRef<HTMLDivElement | null>(null);
|
||||||
|
const dropZonesDone = useRef({ roster: false, onair: false });
|
||||||
|
// Callbacks live in refs so the drop-zone closures (registered once) always
|
||||||
|
// call the CURRENT activate/deactivate, not a stale first-render one.
|
||||||
|
const activateRef = useRef<(call: string) => void>(() => {});
|
||||||
|
const deactivateRef = useRef<(id?: number) => void>(() => {});
|
||||||
|
const wireDropZones = useCallback(() => {
|
||||||
|
const rApi = rosterGrid.current?.api;
|
||||||
|
if (rApi && onAirWrap.current && !dropZonesDone.current.roster) {
|
||||||
|
dropZonesDone.current.roster = true;
|
||||||
|
rApi.addRowDropZone({
|
||||||
|
getContainer: () => onAirWrap.current!,
|
||||||
|
onDragStop: (p: any) => { const call = p.node?.data?.callsign; if (call) activateRef.current(call); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (onAirApi.current && rosterWrap.current && !dropZonesDone.current.onair) {
|
||||||
|
dropZonesDone.current.onair = true;
|
||||||
|
onAirApi.current.addRowDropZone({
|
||||||
|
getContainer: () => rosterWrap.current!,
|
||||||
|
onDragStop: (p: any) => { const id = p.node?.data?.id; if (id != null) deactivateRef.current(id); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Worked-before for the clicked station (see if/when we contacted it before).
|
// Worked-before for the clicked station (see if/when we contacted it before).
|
||||||
const [wbCall, setWbCall] = useState('');
|
const [wbCall, setWbCall] = useState('');
|
||||||
@@ -177,10 +221,38 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
// Active → logged (end QSO, removed from session, written to the logbook).
|
// Active → logged (end QSO, removed from session, written to the logbook).
|
||||||
|
// Then auto-select the FIRST remaining on-air station, so the operator can
|
||||||
|
// chain "log → log → log" without re-clicking a row each time.
|
||||||
async function deactivate(id?: number) {
|
async function deactivate(id?: number) {
|
||||||
if (id == null) return;
|
if (id == null) return;
|
||||||
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); }
|
const next = active.find((a) => a.id !== id); // computed BEFORE the list shrinks
|
||||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
try {
|
||||||
|
await NetDeactivate(id);
|
||||||
|
await refreshActive();
|
||||||
|
onLogged?.();
|
||||||
|
if (next?.id != null) {
|
||||||
|
setSelectRow((s) => ({ id: next.id as number, seq: s.seq + 1 }));
|
||||||
|
setSelectedActiveIds([next.id as number]);
|
||||||
|
showWorkedBefore(next.callsign, (next as any).dxcc ?? 0);
|
||||||
|
}
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
// Keep the drop-zone closures pointed at the CURRENT handlers.
|
||||||
|
activateRef.current = activate;
|
||||||
|
deactivateRef.current = deactivate;
|
||||||
|
|
||||||
|
// Log EVERYONE still on air (end of net): each draft is written to the logbook
|
||||||
|
// exactly as the single-log button would.
|
||||||
|
async function logAll() {
|
||||||
|
if (active.length === 0) return;
|
||||||
|
if (!window.confirm(t('ncp.logAllConfirm', { n: active.length }))) return;
|
||||||
|
try {
|
||||||
|
for (const a of [...active]) {
|
||||||
|
if (a.id != null) await NetDeactivate(a.id as number);
|
||||||
|
}
|
||||||
|
await refreshActive();
|
||||||
|
onLogged?.();
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); await refreshActive(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit-modal handlers (operate on the in-memory draft, not the DB).
|
// Edit-modal handlers (operate on the in-memory draft, not the DB).
|
||||||
@@ -232,7 +304,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
||||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
// rowDrag: drag a roster station onto the on-air grid to start its QSO.
|
||||||
|
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold', rowDrag: true },
|
||||||
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
||||||
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||||
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 },
|
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 },
|
||||||
@@ -281,11 +354,14 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
{t('ncp.onAirActive')}
|
{t('ncp.onAirActive')}
|
||||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-h-0 flex-1">
|
<div ref={onAirWrap} className="flex flex-col min-h-0 flex-1">
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
rows={active}
|
rows={active}
|
||||||
total={active.length}
|
total={active.length}
|
||||||
storageKey="net.onair"
|
storageKey="net.onair"
|
||||||
|
selectRowSignal={selectRow}
|
||||||
|
rowDragCall
|
||||||
|
onGridApi={(api) => { onAirApi.current = api; wireDropZones(); }}
|
||||||
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
|
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
|
||||||
onRowDoubleClicked={(q) => setEditingDraft(q)}
|
onRowDoubleClicked={(q) => setEditingDraft(q)}
|
||||||
onRowSelected={setSelectedActiveIds}
|
onRowSelected={setSelectedActiveIds}
|
||||||
@@ -297,6 +373,9 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
|
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
|
||||||
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto" onClick={logAll}>
|
||||||
|
<MinusCircle className="size-3.5" /> {t('ncp.logAll', { n: active.length })}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -330,6 +409,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
rows={(wb.entries ?? []) as any}
|
rows={(wb.entries ?? []) as any}
|
||||||
total={wb.count}
|
total={wb.count}
|
||||||
storageKey="net.wb"
|
storageKey="net.wb"
|
||||||
|
{...qsoMenuHandlers}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -343,7 +423,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
{t('ncp.netUsersRoster')}
|
{t('ncp.netUsersRoster')}
|
||||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
|
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
<div ref={rosterWrap} style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||||
<div style={{ position: 'absolute', inset: 0 }}>
|
<div style={{ position: 'absolute', inset: 0 }}>
|
||||||
<AgGridReact<Station>
|
<AgGridReact<Station>
|
||||||
ref={rosterGrid}
|
ref={rosterGrid}
|
||||||
@@ -352,6 +432,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
columnDefs={rosterCols}
|
columnDefs={rosterCols}
|
||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||||
|
onGridReady={() => wireDropZones()}
|
||||||
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
||||||
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
|
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
|
||||||
animateRows={false}
|
animateRows={false}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Columns3, FilterX } from 'lucide-react';
|
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +30,14 @@ type Props = {
|
|||||||
// Bump this number to programmatically select every row (e.g. after a search
|
// Bump this number to programmatically select every row (e.g. after a search
|
||||||
// in the QSL Manager, where the default is "all selected").
|
// in the QSL Manager, where the default is "all selected").
|
||||||
selectAllSignal?: number;
|
selectAllSignal?: number;
|
||||||
|
// Bump `seq` to programmatically select the single row with this id (e.g. NET
|
||||||
|
// Control auto-advancing to the next on-air station after logging one).
|
||||||
|
selectRowSignal?: { id: number; seq: number };
|
||||||
|
// Show a row-drag handle on the callsign column (NET Control: drag an on-air
|
||||||
|
// row onto the roster to log it). Pair with onGridApi so the parent can
|
||||||
|
// register external drop zones via api.addRowDropZone.
|
||||||
|
rowDragCall?: boolean;
|
||||||
|
onGridApi?: (api: any) => void;
|
||||||
// storageKey scopes the persisted column layout/visibility. Omit for the main
|
// storageKey scopes the persisted column layout/visibility. Omit for the main
|
||||||
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
|
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
|
||||||
// reuse this grid elsewhere with its OWN independent column config.
|
// reuse this grid elsewhere with its OWN independent column config.
|
||||||
@@ -249,7 +257,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
|||||||
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
||||||
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDragCall, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
@@ -257,6 +265,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
// (e.g. the Net panel) keeps its own layout independent of the main log.
|
// (e.g. the Net panel) keeps its own layout independent of the main log.
|
||||||
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
|
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
|
||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
|
const [selCount, setSelCount] = useState(0); // live selection count shown in the toolbar
|
||||||
|
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
|
||||||
|
|
||||||
// Localized column catalog — rebuilt when the language changes.
|
// Localized column catalog — rebuilt when the language changes.
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||||
@@ -316,7 +326,11 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
restoringRef.current = true;
|
restoringRef.current = true;
|
||||||
const base = COL_CATALOG.map((c) => {
|
const base = COL_CATALOG.map((c) => {
|
||||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||||
return { ...rest, hide: !defaultVisible };
|
const col: ColDef<QSOForm> = { ...rest, hide: !defaultVisible };
|
||||||
|
if (rowDragCall && ((rest as any).colId === 'callsign' || (rest as any).field === 'callsign')) {
|
||||||
|
col.rowDrag = true;
|
||||||
|
}
|
||||||
|
return col;
|
||||||
});
|
});
|
||||||
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
||||||
colId: `award_${a.code}`,
|
colId: `award_${a.code}`,
|
||||||
@@ -330,7 +344,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||||
}));
|
}));
|
||||||
return [...base, ...awards];
|
return [...base, ...awards];
|
||||||
}, [awardCols, COL_CATALOG, t, awardShown]);
|
}, [awardCols, COL_CATALOG, t, awardShown, rowDragCall]);
|
||||||
|
|
||||||
// Enforce award-column visibility via the API after the columns (re)appear —
|
// Enforce award-column visibility via the API after the columns (re)appear —
|
||||||
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
|
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
|
||||||
@@ -353,6 +367,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
function onGridReady(e: GridReadyEvent) {
|
||||||
|
onGridApi?.(e.api);
|
||||||
const local = loadLocal(colStateKey);
|
const local = loadLocal(colStateKey);
|
||||||
if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
|
if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
|
||||||
// Fall back to the portable DB copy when the local cache is empty
|
// Fall back to the portable DB copy when the local cache is empty
|
||||||
@@ -368,6 +383,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
// null when no column filter is active, so "Showing X of Y" reflects them.
|
// null when no column filter is active, so "Showing X of Y" reflects them.
|
||||||
const reportFilteredCount = useCallback((e: { api?: any }) => {
|
const reportFilteredCount = useCallback((e: { api?: any }) => {
|
||||||
const api = e?.api ?? gridRef.current?.api;
|
const api = e?.api ?? gridRef.current?.api;
|
||||||
|
if (api?.getDisplayedRowCount) setDispCount(api.getDisplayedRowCount());
|
||||||
if (!api || !onFilteredCountChange) return;
|
if (!api || !onFilteredCountChange) return;
|
||||||
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
|
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
|
||||||
}, [onFilteredCountChange]);
|
}, [onFilteredCountChange]);
|
||||||
@@ -397,7 +413,10 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
}
|
}
|
||||||
function onSelectionChanged() {
|
function onSelectionChanged() {
|
||||||
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
const api = gridRef.current?.api;
|
||||||
|
const sel = (api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
||||||
|
setSelCount(sel.length);
|
||||||
|
setDispCount(api?.getDisplayedRowCount?.() ?? 0);
|
||||||
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
||||||
}
|
}
|
||||||
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
|
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
|
||||||
@@ -406,6 +425,21 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
gridRef.current?.api?.selectAll();
|
gridRef.current?.api?.selectAll();
|
||||||
}, [selectAllSignal]);
|
}, [selectAllSignal]);
|
||||||
|
|
||||||
|
// Select ONE row by id when the caller bumps selectRowSignal.seq. Deferred a
|
||||||
|
// tick so a row-data refresh in the same render settles first.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectRowSignal || selectRowSignal.seq === 0) return;
|
||||||
|
const t = window.setTimeout(() => {
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (!api) return;
|
||||||
|
api.deselectAll();
|
||||||
|
api.forEachNode((n: any) => {
|
||||||
|
if (n.data?.id === selectRowSignal.id) n.setSelected(true);
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [selectRowSignal?.seq]);
|
||||||
|
|
||||||
// ── Column picker (visibility) ──
|
// ── Column picker (visibility) ──
|
||||||
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
|
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
|
||||||
// state for "which columns are visible" — AG Grid's column state is the
|
// state for "which columns are visible" — AG Grid's column state is the
|
||||||
@@ -462,6 +496,32 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||||||
|
{/* Live selection count — visible without opening the context menu. */}
|
||||||
|
{selCount > 0 && (
|
||||||
|
<span className="mr-auto text-[11px] font-semibold text-primary tabular-nums px-1.5">
|
||||||
|
{t('rqg.selectedCount', { n: selCount })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Select every loaded row that passes the active column filters — so a
|
||||||
|
filtered view can be selected in one click (then send to LoTW, bulk
|
||||||
|
edit, export…). Once everything is selected the same button flips to
|
||||||
|
"Unselect all". */}
|
||||||
|
{(() => {
|
||||||
|
const allSelected = selCount > 0 && dispCount > 0 && selCount >= dispCount;
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
||||||
|
title={allSelected ? t('rqg.unselectAllTitle') : t('rqg.selectAllTitle')}
|
||||||
|
onClick={() => {
|
||||||
|
const api: any = gridRef.current?.api;
|
||||||
|
if (!api) return;
|
||||||
|
if (allSelected) api.deselectAll();
|
||||||
|
else if (typeof api.selectAllFiltered === 'function') api.selectAllFiltered();
|
||||||
|
else api.selectAll('filtered');
|
||||||
|
}}>
|
||||||
|
<ListChecks className="size-3.5" /> {allSelected ? t('rqg.unselectAll') : t('rqg.selectAll')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||||
title={t('rqg.clearFiltersTitle')}>
|
title={t('rqg.clearFiltersTitle')}>
|
||||||
<FilterX className="size-3.5" /> {t('rqg.clearFilters')}
|
<FilterX className="size-3.5" /> {t('rqg.clearFilters')}
|
||||||
|
|||||||
@@ -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,
|
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
||||||
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.
|
||||||
@@ -641,13 +620,84 @@ function ADIFMonitorPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AmpUI mirrors the backend AmpConfig — one configured amplifier.
|
||||||
|
type AmpUI = { id: string; name: string; enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number };
|
||||||
|
|
||||||
// RelayAutoPanel configures automatic control of the Station Control relay boards
|
// RelayAutoPanel configures automatic control of the Station Control relay boards
|
||||||
// from the rig's frequency / band (PstRotator-style). Each relay carries one rule:
|
// from the rig's frequency / band (PstRotator-style). Each relay carries one rule:
|
||||||
// off, a frequency window (kHz), or a set of bands.
|
// off, a frequency window (kHz), or a set of bands.
|
||||||
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 status + OPERATE/STANDBY toggle for ONE configured amplifier (by config
|
||||||
|
// id) — SPE / ACOM / PGXL alike. Module-scoped (not a nested component) so it
|
||||||
|
// isn't remounted on every parent render. Polls once a second while shown.
|
||||||
|
function AmpStatusCard({ id }: { id: string }) {
|
||||||
|
const [amp, setAmp] = useState<any>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = () => GetAmpStatuses().then((all: any[]) => {
|
||||||
|
if (alive) setAmp((all ?? []).find((a) => a.id === id) ?? null);
|
||||||
|
}).catch(() => {});
|
||||||
|
tick();
|
||||||
|
const t = window.setInterval(tick, 1000);
|
||||||
|
return () => { alive = false; window.clearInterval(t); };
|
||||||
|
}, [id]);
|
||||||
|
const st: any = amp?.spe ?? amp?.acom ?? amp?.pgxl ?? { connected: false };
|
||||||
|
const isSPE = !!amp?.spe;
|
||||||
|
const isACOM = !!amp?.acom;
|
||||||
|
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">{amp?.name || 'Amplifier'}{st.connected ? '' : ' — 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={() => AmpOperate(id, !operate).catch(() => {})}
|
||||||
|
title="Toggle OPERATE / STANDBY">
|
||||||
|
{operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{st.connected && isSPE && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{st.connected && isACOM && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{st.connected && !isSPE && !isACOM && (
|
||||||
|
<div className="grid grid-cols-3 gap-x-3 gap-y-1 font-mono text-[11px]">
|
||||||
|
<div>{st.state || ''}</div>
|
||||||
|
<div>Fan {st.fan_mode || '—'}</div>
|
||||||
|
<div>{st.temperature ? `${Math.round(st.temperature)}°C` : ''}</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 +748,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 +1063,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 list — operators can run SEVERAL amps (even two SPEs combined),
|
||||||
const [pgxl, setPgxl] = useState<{ enabled: boolean; host: string; port: number }>({ enabled: false, host: '', port: 9008 });
|
// each with its own connection. Saved as a whole via SaveAmplifiers.
|
||||||
|
const [amps, setAmps] = useState<AmpUI[]>([]);
|
||||||
|
|
||||||
// WinKeyer CW keyer settings + macro editor.
|
// WinKeyer CW keyer settings + macro editor.
|
||||||
type WKMac = { label: string; text: string };
|
type WKMac = { label: string; text: string };
|
||||||
@@ -1085,6 +1136,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||||
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
||||||
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
||||||
|
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
|
||||||
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
||||||
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
||||||
// Password-encryption (secret vault) state.
|
// Password-encryption (secret vault) state.
|
||||||
@@ -1331,7 +1383,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
setRotator(r);
|
setRotator(r);
|
||||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
try { setPgxl(await GetPGXLSettings() as any); } catch {}
|
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
||||||
setBackupCfg(b as any);
|
setBackupCfg(b as any);
|
||||||
setQslDefaults(qd as any);
|
setQslDefaults(qd as any);
|
||||||
setExtSvc(es as any);
|
setExtSvc(es as any);
|
||||||
@@ -1371,7 +1423,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
try { setRotator(await GetRotatorSettings() as any); } catch {}
|
try { setRotator(await GetRotatorSettings() as any); } catch {}
|
||||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
try { setPgxl(await GetPGXLSettings() as any); } catch {}
|
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
||||||
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
||||||
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
||||||
try { setExtSvc(await GetExternalServices() as any); } catch {}
|
try { setExtSvc(await GetExternalServices() as any); } catch {}
|
||||||
@@ -1540,7 +1592,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
await SaveRotatorSettings(rotator as any);
|
await SaveRotatorSettings(rotator as any);
|
||||||
await SaveUltrabeamSettings(ultrabeam as any);
|
await SaveUltrabeamSettings(ultrabeam as any);
|
||||||
await SaveAntGeniusSettings(antgenius as any);
|
await SaveAntGeniusSettings(antgenius as any);
|
||||||
await SavePGXLSettings(pgxl as any);
|
await SaveAmplifiers(amps as any);
|
||||||
await SaveWinkeyerSettings(wk as any);
|
await SaveWinkeyerSettings(wk as any);
|
||||||
await SaveAudioSettings(audioCfg as any);
|
await SaveAudioSettings(audioCfg as any);
|
||||||
await SaveEmailSettings(emailCfg as any);
|
await SaveEmailSettings(emailCfg as any);
|
||||||
@@ -2645,36 +2697,166 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PGXLPanelSettings() {
|
function PGXLPanelSettings() {
|
||||||
|
// The stored `type` stays a flat value ("spe13", "acom700", "pgxl"); the UI
|
||||||
|
// presents it as brand + model.
|
||||||
|
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' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const brandOf = (ty: string) => (!ty || ty === 'pgxl') ? 'pgxl' : ty.startsWith('acom') ? 'acom' : 'spe';
|
||||||
|
const patchAmp = (i: number, patch: Partial<AmpUI>) => setAmps((l) => l.map((a, j) => (j === i ? { ...a, ...patch } : a)));
|
||||||
|
// 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 = (i: number, v: string) => patchAmp(i, {
|
||||||
|
type: v,
|
||||||
|
transport: v === 'pgxl' ? 'tcp' : amps[i].transport,
|
||||||
|
baud: v.startsWith('acom') ? 9600 : v.startsWith('spe') ? 115200 : amps[i].baud,
|
||||||
|
});
|
||||||
|
const addAmp = () => setAmps((l) => [...l, {
|
||||||
|
id: '', name: '', enabled: true, type: 'spe13', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200,
|
||||||
|
}]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader title="Amplifier" hint={t('amp.hint')} />
|
||||||
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">
|
{amps.length === 0 && (
|
||||||
<Checkbox checked={pgxl.enabled} onCheckedChange={(c) => setPgxl((s) => ({ ...s, enabled: !!c }))} />
|
<p className="text-sm text-muted-foreground">{t('amp.none')}</p>
|
||||||
Enable PowerGenius fan control
|
)}
|
||||||
</label>
|
{amps.map((amp, i) => {
|
||||||
<div className="grid grid-cols-3 gap-3">
|
const brand = brandOf(amp.type);
|
||||||
<div className="space-y-1 col-span-2">
|
const isPGXL = brand === 'pgxl';
|
||||||
<Label>Host / IP</Label>
|
const isACOM = brand === 'acom';
|
||||||
<Input
|
const isSerial = !isPGXL && amp.transport === 'serial';
|
||||||
value={pgxl.host ?? ''}
|
return (
|
||||||
onChange={(e) => setPgxl((s) => ({ ...s, host: e.target.value }))}
|
<div key={amp.id || `new-${i}`} className="rounded-lg border border-border p-3 space-y-3">
|
||||||
placeholder="192.168.1.70"
|
<div className="flex items-center gap-2">
|
||||||
className="font-mono"
|
<Checkbox checked={amp.enabled} onCheckedChange={(c) => patchAmp(i, { enabled: !!c })} />
|
||||||
/>
|
<Input className="h-8 flex-1" value={amp.name}
|
||||||
</div>
|
placeholder={t('amp.namePh')}
|
||||||
<div className="space-y-1">
|
onChange={(e) => patchAmp(i, { name: e.target.value })} />
|
||||||
<Label>TCP port</Label>
|
<Button variant="ghost" size="sm" className="h-8 text-danger" title={t('amp.remove')}
|
||||||
<Input
|
onClick={() => setAmps((l) => l.filter((_, j) => j !== i))}>
|
||||||
type="number" min={1} max={65535}
|
<Trash2 className="size-3.5" />
|
||||||
value={pgxl.port}
|
</Button>
|
||||||
onChange={(e) => setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9008 }))}
|
</div>
|
||||||
className="font-mono"
|
|
||||||
/>
|
{/* Connection gets the widest column — "Network (RS232-to-Ethernet)"
|
||||||
</div>
|
was truncated with 3 equal columns. */}
|
||||||
</div>
|
<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(i, 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={amp.type} onValueChange={(v) => applyType(i, v)}>
|
||||||
|
<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={amp.transport} onValueChange={(v) => patchAmp(i, { 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={amp.com_port || '_'} onValueChange={(v) => patchAmp(i, { 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={amp.baud}
|
||||||
|
onChange={(e) => patchAmp(i, { baud: parseInt(e.target.value) || 115200 })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>Host / IP</Label>
|
||||||
|
<Input value={amp.host ?? ''} onChange={(e) => patchAmp(i, { host: e.target.value })}
|
||||||
|
placeholder="192.168.1.70" className="font-mono" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>TCP port</Label>
|
||||||
|
<Input type="number" min={1} max={65535} value={amp.port}
|
||||||
|
onChange={(e) => patchAmp(i, { port: parseInt(e.target.value) || 9008 })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{amp.enabled && amp.id && <AmpStatusCard id={amp.id} />}
|
||||||
|
{!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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Button variant="outline" size="sm" onClick={addAmp}>
|
||||||
|
<Plus className="size-3.5 mr-1" /> {t('amp.add')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -2682,11 +2864,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
|
|
||||||
function RotatorPanel() {
|
function RotatorPanel() {
|
||||||
const isRG = (rotator as any).type === 'rotgenius';
|
const isRG = (rotator as any).type === 'rotgenius';
|
||||||
|
const isARCO = (rotator as any).type === 'arco';
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Rotator"
|
title="Rotator"
|
||||||
hint={isRG ? undefined : t('rot.hint')}
|
hint={isRG || isARCO ? undefined : t('rot.hint')}
|
||||||
/>
|
/>
|
||||||
<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">
|
||||||
@@ -2696,14 +2879,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('rot.type')}</Label>
|
<Label>{t('rot.type')}</Label>
|
||||||
{/* Switching to Rotator Genius moves the default port to its native
|
{/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
|
||||||
9006; back to PstRotator restores 12000. */}
|
(placeholder — must match the ARCO's LAN menu), PstRotator 12000. */}
|
||||||
<Select value={(rotator as any).type ?? 'pst'}
|
<Select value={(rotator as any).type ?? 'pst'}
|
||||||
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : 12000 } as any))}>
|
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : v === 'arco' ? 4001 : 12000 } as any))}>
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
||||||
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
||||||
|
<SelectItem value="arco">microHAM ARCO (LAN, GS-232A)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -2720,34 +2904,68 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* The ARCO is reachable over the LAN (TCP) or its USB virtual COM. */}
|
||||||
|
{isARCO && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Connection</Label>
|
||||||
|
<Select value={(rotator as any).transport ?? 'tcp'}
|
||||||
|
onValueChange={(v) => setRotator((s) => ({ ...s, transport: v } as any))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="tcp">Network (LAN)</SelectItem>
|
||||||
|
<SelectItem value="serial">USB (serial COM)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
{isARCO && (rotator as any).transport === 'serial' ? (
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1 max-w-xs">
|
||||||
<Label>Host / IP</Label>
|
<Label>COM port</Label>
|
||||||
<Input
|
<div className="flex items-center gap-2">
|
||||||
value={rotator.host ?? ''}
|
<Select value={(rotator as any).com_port || '_'}
|
||||||
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
|
onValueChange={(v) => setRotator((s) => ({ ...s, com_port: v === '_' ? '' : v } as any))}>
|
||||||
placeholder={isRG ? '192.168.1.60' : '127.0.0.1'}
|
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||||
className="font-mono"
|
<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>
|
||||||
<div className="space-y-1">
|
) : (
|
||||||
<Label>{isRG ? 'TCP port' : 'UDP port'}</Label>
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<Input
|
<div className="space-y-1 col-span-2">
|
||||||
type="number" min={1} max={65535}
|
<Label>Host / IP</Label>
|
||||||
value={rotator.port}
|
<Input
|
||||||
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : 12000) }))}
|
value={rotator.host ?? ''}
|
||||||
className="font-mono"
|
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
|
||||||
/>
|
placeholder={isRG || isARCO ? '192.168.1.60' : '127.0.0.1'}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
|
||||||
|
<Input
|
||||||
|
type="number" min={1} max={65535}
|
||||||
|
value={rotator.port}
|
||||||
|
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : isARCO ? 4001 : 12000) }))}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
{!isRG && (
|
{!isRG && !isARCO && (
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
|
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
|
||||||
This rotator supports elevation (VHF / satellite)
|
This rotator supports elevation (VHF / satellite)
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
||||||
|
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
|
||||||
<div className="flex items-center gap-2 pt-2">
|
<div className="flex items-center gap-2 pt-2">
|
||||||
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
|
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
|
||||||
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
|
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
|
||||||
@@ -2807,6 +3025,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 +3056,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 +4217,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 +4306,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>
|
||||||
@@ -4379,12 +4630,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
|
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
|
||||||
{t('gen.lookupOnBlur')} <span className="text-xs text-muted-foreground">{t('gen.lookupOnBlurHint')}</span>
|
{t('gen.lookupOnBlur')} <span className="text-xs text-muted-foreground">{t('gen.lookupOnBlurHint')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={groupDigital} onCheckedChange={(c) => { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} />
|
||||||
|
{t('gen.groupDigital')} <span className="text-xs text-muted-foreground">{t('gen.groupDigitalHint')}</span>
|
||||||
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
|
<Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
|
||||||
{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} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, Flame } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -12,16 +12,23 @@ 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,
|
||||||
|
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, GetFlexState, FlexAmpOperate,
|
||||||
} 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('') };
|
||||||
@@ -240,6 +247,139 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AmplifierWidget brings the amplifier controls (Settings → Amplifier) into the
|
||||||
|
// Station Control tab, so an operator WITHOUT a FlexRadio/Icom panel still has
|
||||||
|
// them. Several amps can be configured (some ops run two SPEs in parallel) —
|
||||||
|
// the header dropdown picks which one this card shows; the choice is remembered.
|
||||||
|
function AmplifierWidget({ t }: { t: (k: string, v?: any) => string }) {
|
||||||
|
const [list, setList] = useState<any[]>([]);
|
||||||
|
const [sel, setSel] = useState<string>(() => localStorage.getItem('opslog.ampSel.station') || '');
|
||||||
|
const [flex, setFlex] = useState<any>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
// The Flex state rides along because a PGXL's OPERATE state comes from the
|
||||||
|
// radio (the direct GSCP status doesn't carry it).
|
||||||
|
const tick = () => Promise.all([
|
||||||
|
GetAmpStatuses().catch(() => []),
|
||||||
|
GetFlexState().catch(() => null),
|
||||||
|
]).then(([l, fx]: any[]) => { if (alive) { setList((l ?? []) as any[]); setFlex(fx); } });
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1500);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
const amp = list.find((a) => a.id === sel) ?? list[0];
|
||||||
|
if (!amp) return null;
|
||||||
|
const isACOM = !!amp.acom;
|
||||||
|
const isSPE = !!amp.spe;
|
||||||
|
const isPGXL = !isACOM && !isSPE;
|
||||||
|
const viaFlex = isPGXL && !!flex?.amp_available;
|
||||||
|
const raw = amp.spe ?? amp.acom ?? amp.pgxl ?? { connected: false };
|
||||||
|
const st: any = isPGXL
|
||||||
|
? { ...raw, connected: !!raw.connected || viaFlex, operate: viaFlex ? !!flex.amp_operate : !!raw.operate }
|
||||||
|
: raw;
|
||||||
|
const doOperate = () => {
|
||||||
|
const want = !st.operate;
|
||||||
|
(isPGXL && viaFlex ? FlexAmpOperate(want) : AmpOperate(amp.id, want)).catch(() => {});
|
||||||
|
};
|
||||||
|
const maxW = isACOM ? (Number(st.max_w) || 800) : ({ '13K': 1300, '15K': 1500, '2K': 2000 } as Record<string, number>)[st.model] || 1500;
|
||||||
|
const outW = Number(isACOM ? st.fwd_w : st.output_w) || 0;
|
||||||
|
const frac = Math.min(1, outW / maxW);
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<Flame className="size-4 text-primary" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold truncate">{t('flxp.amplifier')}</div>
|
||||||
|
{list.length <= 1 && <div className="text-[10px] text-muted-foreground font-mono truncate">{amp.name}</div>}
|
||||||
|
</div>
|
||||||
|
{list.length > 1 && (
|
||||||
|
<select value={amp.id} title={t('flxp.ampPick')}
|
||||||
|
onChange={(e) => { setSel(e.target.value); localStorage.setItem('opslog.ampSel.station', e.target.value); }}
|
||||||
|
className="h-7 rounded-md border border-border bg-card px-1.5 text-xs font-semibold outline-none min-w-0">
|
||||||
|
{list.map((a: any) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<span className={cn('ml-auto size-2 rounded-full shrink-0', st.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||||
|
title={st.connected ? t('station.online') : (st.last_error || t('station.offline'))} />
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
{isPGXL ? (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button type="button" disabled={!st.connected}
|
||||||
|
onClick={doOperate}
|
||||||
|
className={cn('px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 transition-all disabled:opacity-40',
|
||||||
|
st.operate ? 'bg-warning text-warning-foreground border-warning' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
|
{st.operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</button>
|
||||||
|
{(['STANDARD', 'CONTEST', 'BROADCAST'] as const).map((m) => (
|
||||||
|
<button key={m} type="button" disabled={!st.connected}
|
||||||
|
onClick={() => AmpFanMode(amp.id, m).catch(() => {})}
|
||||||
|
className={cn('px-2.5 py-1.5 rounded-md border text-xs font-semibold disabled:opacity-40',
|
||||||
|
st.fan_mode === m ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted/30 border-border hover:bg-muted')}>
|
||||||
|
{m === 'STANDARD' ? t('flxp.fanStandard') : m === 'CONTEST' ? t('flxp.fanContest') : t('flxp.fanBroadcast')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">{st.state || ''}{st.temperature ? ` · ${Math.round(st.temperature)}°C` : ''}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button type="button" disabled={!st.connected}
|
||||||
|
onClick={doOperate}
|
||||||
|
className={cn('px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 transition-all disabled:opacity-40',
|
||||||
|
st.operate ? 'bg-warning text-warning-foreground border-warning' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
|
{st.operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</button>
|
||||||
|
<div className="inline-flex rounded-lg overflow-hidden border border-success/70">
|
||||||
|
<button type="button"
|
||||||
|
disabled={isACOM ? !(st.port_open && st.transport === 'serial') : !st.connected}
|
||||||
|
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||||
|
className="px-2.5 py-1.5 text-xs font-bold bg-card text-success hover:bg-success/15 disabled:opacity-40">ON</button>
|
||||||
|
<button type="button" disabled={!st.connected}
|
||||||
|
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||||
|
className="px-2.5 py-1.5 text-xs font-bold bg-card text-danger border-l border-success/70 hover:bg-danger/15 disabled:opacity-40">OFF</button>
|
||||||
|
</div>
|
||||||
|
{!isACOM && (
|
||||||
|
<div className="inline-flex rounded-lg overflow-hidden border border-border">
|
||||||
|
{(['L', 'M', 'H'] as const).map((lvl, i) => (
|
||||||
|
<button key={lvl} type="button" disabled={!st.connected}
|
||||||
|
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
|
||||||
|
className={cn('px-2.5 py-1.5 text-xs font-bold disabled:opacity-40', i > 0 && 'border-l border-border',
|
||||||
|
(st.power_level || '').trim().toUpperCase() === lvl ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{lvl}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-mono text-muted-foreground tabular-nums">
|
||||||
|
{st.connected
|
||||||
|
? <>
|
||||||
|
{isACOM ? (st.state || '') : (st.tx ? 'TX' : 'RX')}
|
||||||
|
{st.band ? ` · ${st.band}` : ''} · {outW}W · SWR {Number((isACOM ? st.swr : st.swr_ant) ?? 0).toFixed(1)} · {st.temp_c}°C
|
||||||
|
{isACOM && st.fan ? ` · Fan ${st.fan}` : ''}
|
||||||
|
</>
|
||||||
|
: (isACOM ? t('flxp.acomOffline') : t('flxp.speOffline'))}
|
||||||
|
</div>
|
||||||
|
{st.connected && (
|
||||||
|
<div>
|
||||||
|
<div className="h-2 rounded bg-muted overflow-hidden">
|
||||||
|
<div className={cn('h-full transition-all', frac > 0.9 ? 'bg-danger' : frac > 0.75 ? 'bg-warning' : 'bg-primary')}
|
||||||
|
style={{ width: `${Math.round(frac * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground mt-0.5">{t('flxp.outputPower')} — {outW} W / {maxW} W</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(st.err_text || st.warnings || st.alarms) && (
|
||||||
|
<div className="text-[11px] font-bold text-danger">⚠ {st.err_text || `${st.warnings || ''} ${st.alarms || ''}`}</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
|
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [devices, setDevices] = useState<Device[]>([]);
|
const [devices, setDevices] = useState<Device[]>([]);
|
||||||
@@ -254,6 +394,17 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
});
|
});
|
||||||
const dragId = useRef<string | null>(null);
|
const dragId = useRef<string | null>(null);
|
||||||
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
||||||
|
// Amplifiers (Settings → Amplifier): shown here so operators without a
|
||||||
|
// FlexRadio panel still get the controls. Re-read every 5s so enabling an
|
||||||
|
// amp in Settings makes the widget appear without reopening the tab.
|
||||||
|
const [ampCount, setAmpCount] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = () => GetAmpStatuses().then((l: any) => { if (alive) setAmpCount((l ?? []).length); }).catch(() => {});
|
||||||
|
load();
|
||||||
|
const id = window.setInterval(load, 5000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadDevices = useCallback(async () => {
|
const loadDevices = useCallback(async () => {
|
||||||
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
||||||
@@ -374,13 +525,16 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
if (ant.enabled) {
|
if (ant.enabled) {
|
||||||
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
||||||
}
|
}
|
||||||
|
if (ampCount > 0) {
|
||||||
|
widgets.push({ id: 'amplifier', node: <AmplifierWidget t={t} /> });
|
||||||
|
}
|
||||||
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
||||||
|
|
||||||
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
||||||
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
|
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
|
||||||
const widgetIds = ordered.map((w) => w.id);
|
const widgetIds = ordered.map((w) => w.id);
|
||||||
|
|
||||||
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled;
|
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && ampCount === 0;
|
||||||
|
|
||||||
// Column count controls the layout: with 4 widgets, choosing "2" lays them out
|
// Column count controls the layout: with 4 widgets, choosing "2" lays them out
|
||||||
// 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to
|
// 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to
|
||||||
@@ -445,11 +599,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 +660,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 +670,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 +737,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,9 +747,21 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
|
{testMsg && (
|
||||||
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
|
<span className={cn('inline-flex items-center gap-1.5 text-xs font-medium', testMsg.ok ? 'text-success' : 'text-danger')}>
|
||||||
|
<span className={cn('size-2 rounded-full', testMsg.ok ? 'bg-success' : 'bg-danger')} />
|
||||||
|
{testMsg.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={testDevice} disabled={testing || !device.host.trim()}>
|
||||||
|
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <PlugZap className="size-3.5 mr-1" />}
|
||||||
|
{t('station.test')}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
|
||||||
|
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
+38
-31
File diff suppressed because one or more lines are too long
@@ -25,6 +25,7 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
||||||
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
||||||
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
||||||
|
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
|
||||||
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||||
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
||||||
// Cluster filter selections — restored on reopen.
|
// Cluster filter selections — restored on reopen.
|
||||||
|
|||||||
@@ -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.4';
|
export const APP_VERSION = '0.20.9';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+76
-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>;
|
||||||
@@ -30,6 +36,14 @@ export function ActivateProfile(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
||||||
|
|
||||||
|
export function AmpFanMode(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function AmpOperate(arg1:string,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function AmpPower(arg1:string,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function AmpPowerLevel(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
|
export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function AntGeniusDeselect(arg1:number):Promise<void>;
|
export function AntGeniusDeselect(arg1:number):Promise<void>;
|
||||||
@@ -72,8 +86,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 +180,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 +204,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>;
|
||||||
@@ -200,6 +218,8 @@ export function FlexSetANF(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetANFLevel(arg1:number):Promise<void>;
|
export function FlexSetANFLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetANFT(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetAPF(arg1:boolean):Promise<void>;
|
export function FlexSetAPF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetAPFLevel(arg1:number):Promise<void>;
|
export function FlexSetAPFLevel(arg1:number):Promise<void>;
|
||||||
@@ -220,8 +240,20 @@ export function FlexSetCWSidetone(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetCWSpeed(arg1:number):Promise<void>;
|
export function FlexSetCWSpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetDAX(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 FlexSetLMSANF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetLMSANFLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetLMSNR(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetLMSNRLevel(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>;
|
||||||
@@ -238,6 +270,10 @@ export function FlexSetNBLevel(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetNR(arg1:boolean):Promise<void>;
|
export function FlexSetNR(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetNRF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetNRFLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetNRLevel(arg1:number):Promise<void>;
|
export function FlexSetNRLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetPower(arg1:number):Promise<void>;
|
export function FlexSetPower(arg1:number):Promise<void>;
|
||||||
@@ -250,14 +286,22 @@ export function FlexSetRIT(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetRITFreq(arg1:number):Promise<void>;
|
export function FlexSetRITFreq(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetRNN(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetRXAntenna(arg1:string):Promise<void>;
|
export function FlexSetRXAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetSpeexNR(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetSpeexNRLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetSplit(arg1:boolean):Promise<void>;
|
export function FlexSetSplit(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetTXAntenna(arg1:string):Promise<void>;
|
export function FlexSetTXAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetTXDAX(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetTXFilter(arg1:number,arg2:number):Promise<void>;
|
export function FlexSetTXFilter(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetTXSlice(arg1:number):Promise<void>;
|
export function FlexSetTXSlice(arg1:number):Promise<void>;
|
||||||
@@ -278,14 +322,22 @@ 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>;
|
||||||
|
|
||||||
export function GetAlertEmailTo():Promise<string>;
|
export function GetAlertEmailTo():Promise<string>;
|
||||||
|
|
||||||
|
export function GetAmpStatuses():Promise<Array<main.AmpStatus>>;
|
||||||
|
|
||||||
|
export function GetAmplifiers():Promise<Array<main.AmpConfig>>;
|
||||||
|
|
||||||
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
|
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
|
||||||
|
|
||||||
export function GetAntGeniusStatus():Promise<antgenius.Status>;
|
export function GetAntGeniusStatus():Promise<antgenius.Status>;
|
||||||
@@ -314,10 +366,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 +408,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 +446,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 +470,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 +590,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>>;
|
||||||
@@ -610,6 +666,8 @@ export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefau
|
|||||||
|
|
||||||
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function PGXLSetOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function PickADIFMonitorFile():Promise<string>;
|
export function PickADIFMonitorFile():Promise<string>;
|
||||||
|
|
||||||
export function PickAudioFolder():Promise<string>;
|
export function PickAudioFolder():Promise<string>;
|
||||||
@@ -676,6 +734,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>;
|
||||||
@@ -702,12 +762,20 @@ 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>;
|
||||||
|
|
||||||
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
|
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
|
||||||
|
|
||||||
|
export function SaveAmplifiers(arg1:Array<main.AmpConfig>):Promise<void>;
|
||||||
|
|
||||||
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
|
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
||||||
@@ -782,8 +850,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>;
|
||||||
@@ -792,8 +858,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>;
|
||||||
@@ -802,12 +866,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>;
|
||||||
@@ -832,6 +892,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>;
|
||||||
|
|||||||
+148
-28
@@ -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']();
|
||||||
}
|
}
|
||||||
@@ -18,6 +26,22 @@ export function AddQSO(arg1) {
|
|||||||
return window['go']['main']['App']['AddQSO'](arg1);
|
return window['go']['main']['App']['AddQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AmpFanMode(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['AmpFanMode'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmpOperate(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['AmpOperate'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmpPower(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['AmpPower'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmpPowerLevel(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['AmpPowerLevel'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function AntGeniusActivate(arg1, arg2) {
|
export function AntGeniusActivate(arg1, arg2) {
|
||||||
return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2);
|
return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -102,10 +126,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 +314,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 +362,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);
|
||||||
}
|
}
|
||||||
@@ -358,6 +390,10 @@ export function FlexSetANFLevel(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetANFLevel'](arg1);
|
return window['go']['main']['App']['FlexSetANFLevel'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetANFT(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetANFT'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetAPF(arg1) {
|
export function FlexSetAPF(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetAPF'](arg1);
|
return window['go']['main']['App']['FlexSetAPF'](arg1);
|
||||||
}
|
}
|
||||||
@@ -398,10 +434,34 @@ export function FlexSetCWSpeed(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetCWSpeed'](arg1);
|
return window['go']['main']['App']['FlexSetCWSpeed'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetDAX(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetDAX'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetFilter(arg1, arg2) {
|
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 FlexSetLMSANF(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetLMSANF'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetLMSANFLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetLMSANFLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetLMSNR(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetLMSNR'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetLMSNRLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetLMSNRLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetMic(arg1) {
|
export function FlexSetMic(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetMic'](arg1);
|
return window['go']['main']['App']['FlexSetMic'](arg1);
|
||||||
}
|
}
|
||||||
@@ -434,6 +494,14 @@ export function FlexSetNR(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetNR'](arg1);
|
return window['go']['main']['App']['FlexSetNR'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetNRF(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetNRF'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetNRFLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetNRFLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetNRLevel(arg1) {
|
export function FlexSetNRLevel(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetNRLevel'](arg1);
|
return window['go']['main']['App']['FlexSetNRLevel'](arg1);
|
||||||
}
|
}
|
||||||
@@ -458,6 +526,10 @@ export function FlexSetRITFreq(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetRITFreq'](arg1);
|
return window['go']['main']['App']['FlexSetRITFreq'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetRNN(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetRNN'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetRXAntenna(arg1) {
|
export function FlexSetRXAntenna(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
|
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
|
||||||
}
|
}
|
||||||
@@ -466,6 +538,14 @@ export function FlexSetSidetoneLevel(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
|
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetSpeexNR(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetSpeexNR'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetSpeexNRLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetSpeexNRLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetSplit(arg1) {
|
export function FlexSetSplit(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetSplit'](arg1);
|
return window['go']['main']['App']['FlexSetSplit'](arg1);
|
||||||
}
|
}
|
||||||
@@ -474,6 +554,10 @@ export function FlexSetTXAntenna(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
|
return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetTXDAX(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetTXDAX'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetTXFilter(arg1, arg2) {
|
export function FlexSetTXFilter(arg1, arg2) {
|
||||||
return window['go']['main']['App']['FlexSetTXFilter'](arg1, arg2);
|
return window['go']['main']['App']['FlexSetTXFilter'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -514,10 +598,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']();
|
||||||
}
|
}
|
||||||
@@ -530,6 +622,14 @@ export function GetAlertEmailTo() {
|
|||||||
return window['go']['main']['App']['GetAlertEmailTo']();
|
return window['go']['main']['App']['GetAlertEmailTo']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAmpStatuses() {
|
||||||
|
return window['go']['main']['App']['GetAmpStatuses']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetAmplifiers() {
|
||||||
|
return window['go']['main']['App']['GetAmplifiers']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAntGeniusSettings() {
|
export function GetAntGeniusSettings() {
|
||||||
return window['go']['main']['App']['GetAntGeniusSettings']();
|
return window['go']['main']['App']['GetAntGeniusSettings']();
|
||||||
}
|
}
|
||||||
@@ -586,14 +686,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 +770,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 +846,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 +894,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 +1134,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']();
|
||||||
}
|
}
|
||||||
@@ -1178,6 +1286,10 @@ export function PGXLSetFanMode(arg1) {
|
|||||||
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
|
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PGXLSetOperate(arg1) {
|
||||||
|
return window['go']['main']['App']['PGXLSetOperate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function PickADIFMonitorFile() {
|
export function PickADIFMonitorFile() {
|
||||||
return window['go']['main']['App']['PickADIFMonitorFile']();
|
return window['go']['main']['App']['PickADIFMonitorFile']();
|
||||||
}
|
}
|
||||||
@@ -1310,6 +1422,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);
|
||||||
}
|
}
|
||||||
@@ -1362,6 +1478,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']();
|
||||||
}
|
}
|
||||||
@@ -1374,6 +1502,10 @@ export function SaveAlertRule(arg1) {
|
|||||||
return window['go']['main']['App']['SaveAlertRule'](arg1);
|
return window['go']['main']['App']['SaveAlertRule'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveAmplifiers(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveAmplifiers'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveAntGeniusSettings(arg1) {
|
export function SaveAntGeniusSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveAntGeniusSettings'](arg1);
|
return window['go']['main']['App']['SaveAntGeniusSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1522,10 +1654,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);
|
||||||
}
|
}
|
||||||
@@ -1542,10 +1670,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);
|
||||||
}
|
}
|
||||||
@@ -1562,18 +1686,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);
|
||||||
}
|
}
|
||||||
@@ -1622,6 +1738,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 {
|
||||||
@@ -722,6 +777,19 @@ export namespace cat {
|
|||||||
anf_level: number;
|
anf_level: number;
|
||||||
wnb: boolean;
|
wnb: boolean;
|
||||||
wnb_level: number;
|
wnb_level: number;
|
||||||
|
lms_nr: boolean;
|
||||||
|
lms_nr_level: number;
|
||||||
|
lms_anf: boolean;
|
||||||
|
lms_anf_level: number;
|
||||||
|
speex_nr: boolean;
|
||||||
|
speex_nr_level: number;
|
||||||
|
rnn: boolean;
|
||||||
|
anft: boolean;
|
||||||
|
nrf: boolean;
|
||||||
|
nrf_level: number;
|
||||||
|
dsp_v4: boolean;
|
||||||
|
dax_ch: number;
|
||||||
|
tx_dax: boolean;
|
||||||
rit: boolean;
|
rit: boolean;
|
||||||
rit_freq: number;
|
rit_freq: number;
|
||||||
xit: boolean;
|
xit: boolean;
|
||||||
@@ -789,6 +857,19 @@ export namespace cat {
|
|||||||
this.anf_level = source["anf_level"];
|
this.anf_level = source["anf_level"];
|
||||||
this.wnb = source["wnb"];
|
this.wnb = source["wnb"];
|
||||||
this.wnb_level = source["wnb_level"];
|
this.wnb_level = source["wnb_level"];
|
||||||
|
this.lms_nr = source["lms_nr"];
|
||||||
|
this.lms_nr_level = source["lms_nr_level"];
|
||||||
|
this.lms_anf = source["lms_anf"];
|
||||||
|
this.lms_anf_level = source["lms_anf_level"];
|
||||||
|
this.speex_nr = source["speex_nr"];
|
||||||
|
this.speex_nr_level = source["speex_nr_level"];
|
||||||
|
this.rnn = source["rnn"];
|
||||||
|
this.anft = source["anft"];
|
||||||
|
this.nrf = source["nrf"];
|
||||||
|
this.nrf_level = source["nrf_level"];
|
||||||
|
this.dsp_v4 = source["dsp_v4"];
|
||||||
|
this.dax_ch = source["dax_ch"];
|
||||||
|
this.tx_dax = source["tx_dax"];
|
||||||
this.rit = source["rit"];
|
this.rit = source["rit"];
|
||||||
this.rit_freq = source["rit_freq"];
|
this.rit_freq = source["rit_freq"];
|
||||||
this.xit = source["xit"];
|
this.xit = source["xit"];
|
||||||
@@ -1345,6 +1426,74 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AmpConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
type: string;
|
||||||
|
transport: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
com_port: string;
|
||||||
|
baud: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AmpConfig(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.type = source["type"];
|
||||||
|
this.transport = source["transport"];
|
||||||
|
this.host = source["host"];
|
||||||
|
this.port = source["port"];
|
||||||
|
this.com_port = source["com_port"];
|
||||||
|
this.baud = source["baud"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class AmpStatus {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
pgxl?: powergenius.Status;
|
||||||
|
spe?: spe.Status;
|
||||||
|
acom?: acom.Status;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AmpStatus(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.type = source["type"];
|
||||||
|
this.pgxl = this.convertValues(source["pgxl"], powergenius.Status);
|
||||||
|
this.spe = this.convertValues(source["spe"], spe.Status);
|
||||||
|
this.acom = this.convertValues(source["acom"], acom.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
export class AntGeniusSettings {
|
export class AntGeniusSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
host: string;
|
host: string;
|
||||||
@@ -1728,6 +1877,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 +2328,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 +2342,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 {
|
||||||
@@ -2502,6 +2677,8 @@ export namespace main {
|
|||||||
port: number;
|
port: number;
|
||||||
has_elevation: boolean;
|
has_elevation: boolean;
|
||||||
rotator_num: number;
|
rotator_num: number;
|
||||||
|
transport: string;
|
||||||
|
com_port: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new RotatorSettings(source);
|
return new RotatorSettings(source);
|
||||||
@@ -2515,6 +2692,8 @@ export namespace main {
|
|||||||
this.port = source["port"];
|
this.port = source["port"];
|
||||||
this.has_elevation = source["has_elevation"];
|
this.has_elevation = source["has_elevation"];
|
||||||
this.rotator_num = source["rotator_num"];
|
this.rotator_num = source["rotator_num"];
|
||||||
|
this.transport = source["transport"];
|
||||||
|
this.com_port = source["com_port"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class SecretStatus {
|
export class SecretStatus {
|
||||||
@@ -2600,6 +2779,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 +2794,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 +2899,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;
|
||||||
@@ -3109,6 +3306,7 @@ export namespace powergenius {
|
|||||||
state?: string;
|
state?: string;
|
||||||
fan_mode?: string;
|
fan_mode?: string;
|
||||||
temperature: number;
|
temperature: number;
|
||||||
|
operate: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Status(source);
|
return new Status(source);
|
||||||
@@ -3122,6 +3320,7 @@ export namespace powergenius {
|
|||||||
this.state = source["state"];
|
this.state = source["state"];
|
||||||
this.fan_mode = source["fan_mode"];
|
this.fan_mode = source["fan_mode"];
|
||||||
this.temperature = source["temperature"];
|
this.temperature = source["temperature"];
|
||||||
|
this.operate = source["operate"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3401,6 +3600,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;
|
||||||
@@ -3890,12 +4109,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;
|
||||||
@@ -3929,12 +4153,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"];
|
||||||
@@ -4098,6 +4327,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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+87
-50
@@ -259,30 +259,30 @@ type FlexSliceInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FlexTXState struct {
|
type FlexTXState struct {
|
||||||
Available bool `json:"available"` // backend is Flex and handshaked
|
Available bool `json:"available"` // backend is Flex and handshaked
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
// Slices lists every in-use receiver slice (A/B/C/D…) so the panel can show
|
// Slices lists every in-use receiver slice (A/B/C/D…) so the panel can show
|
||||||
// them all and highlight the active one. The active slice drives everything.
|
// them all and highlight the active one. The active slice drives everything.
|
||||||
Slices []FlexSliceInfo `json:"slices,omitempty"`
|
Slices []FlexSliceInfo `json:"slices,omitempty"`
|
||||||
RFPower int `json:"rf_power"`
|
RFPower int `json:"rf_power"`
|
||||||
TunePower int `json:"tune_power"`
|
TunePower int `json:"tune_power"`
|
||||||
Tune bool `json:"tune"` // tune carrier active
|
Tune bool `json:"tune"` // tune carrier active
|
||||||
Transmitting bool `json:"transmitting"` // interlock state = TRANSMITTING
|
Transmitting bool `json:"transmitting"` // interlock state = TRANSMITTING
|
||||||
VoxEnable bool `json:"vox_enable"`
|
VoxEnable bool `json:"vox_enable"`
|
||||||
VoxLevel int `json:"vox_level"`
|
VoxLevel int `json:"vox_level"`
|
||||||
VoxDelay int `json:"vox_delay"`
|
VoxDelay int `json:"vox_delay"`
|
||||||
ProcEnable bool `json:"proc_enable"`
|
ProcEnable bool `json:"proc_enable"`
|
||||||
ProcLevel int `json:"proc_level"`
|
ProcLevel int `json:"proc_level"`
|
||||||
Mon bool `json:"mon"`
|
Mon bool `json:"mon"`
|
||||||
MonLevel int `json:"mon_level"`
|
MonLevel int `json:"mon_level"`
|
||||||
MicLevel int `json:"mic_level"`
|
MicLevel int `json:"mic_level"`
|
||||||
TXFilterLow int `json:"tx_filter_low"` // TX filter low cut (Hz)
|
TXFilterLow int `json:"tx_filter_low"` // TX filter low cut (Hz)
|
||||||
TXFilterHigh int `json:"tx_filter_high"` // TX filter high cut (Hz)
|
TXFilterHigh int `json:"tx_filter_high"` // TX filter high cut (Hz)
|
||||||
// Mic profiles (SmartSDR): the list of available profiles + the loaded one.
|
// Mic profiles (SmartSDR): the list of available profiles + the loaded one.
|
||||||
MicProfile string `json:"mic_profile,omitempty"`
|
MicProfile string `json:"mic_profile,omitempty"`
|
||||||
MicProfiles []string `json:"mic_profiles,omitempty"`
|
MicProfiles []string `json:"mic_profiles,omitempty"`
|
||||||
ATUStatus string `json:"atu_status,omitempty"`
|
ATUStatus string `json:"atu_status,omitempty"`
|
||||||
ATUMemories bool `json:"atu_memories"`
|
ATUMemories bool `json:"atu_memories"`
|
||||||
// Active RX slice DSP controls.
|
// Active RX slice DSP controls.
|
||||||
RXAvail bool `json:"rx_avail"` // an RX slice exists
|
RXAvail bool `json:"rx_avail"` // an RX slice exists
|
||||||
Split bool `json:"split"` // RX/TX on separate slices
|
Split bool `json:"split"` // RX/TX on separate slices
|
||||||
@@ -304,6 +304,22 @@ type FlexTXState struct {
|
|||||||
ANFLevel int `json:"anf_level"`
|
ANFLevel int `json:"anf_level"`
|
||||||
WNB bool `json:"wnb"`
|
WNB bool `json:"wnb"`
|
||||||
WNBLevel int `json:"wnb_level"`
|
WNBLevel int `json:"wnb_level"`
|
||||||
|
// SmartSDR v4 DSP (8000/Aurora series): NRL/ANFL (legacy LMS), NRS
|
||||||
|
// (spectral subtraction), RNN (AI NR), ANFT (FFT notch), NRF (NR w/filter).
|
||||||
|
// DSPV4 is true once the radio reported any of these keys — gates the UI.
|
||||||
|
LMSNR bool `json:"lms_nr"`
|
||||||
|
LMSNRLevel int `json:"lms_nr_level"`
|
||||||
|
LMSANF bool `json:"lms_anf"`
|
||||||
|
LMSANFLevel int `json:"lms_anf_level"`
|
||||||
|
SpeexNR bool `json:"speex_nr"`
|
||||||
|
SpeexNRLevel int `json:"speex_nr_level"`
|
||||||
|
RNN bool `json:"rnn"`
|
||||||
|
ANFT bool `json:"anft"`
|
||||||
|
NRF bool `json:"nrf"`
|
||||||
|
NRFLevel int `json:"nrf_level"`
|
||||||
|
DSPV4 bool `json:"dsp_v4"`
|
||||||
|
DAXCh int `json:"dax_ch"` // DAX audio channel of the active slice (0 = off, 1-8)
|
||||||
|
TXDAX bool `json:"tx_dax"` // DAX is the TX audio source (SmartSDR transmit-bar DAX button)
|
||||||
// RIT/XIT — offsets applied to the active slice's RX / TX frequency without
|
// RIT/XIT — offsets applied to the active slice's RX / TX frequency without
|
||||||
// moving the slice. The offset survives the switch being turned off, so
|
// moving the slice. The offset survives the switch being turned off, so
|
||||||
// re-enabling restores it, exactly like the radio's own knob.
|
// re-enabling restores it, exactly like the radio's own knob.
|
||||||
@@ -337,7 +353,7 @@ type FlexMeter struct {
|
|||||||
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
|
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
|
||||||
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
|
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
|
||||||
Unit string `json:"unit,omitempty"`
|
Unit string `json:"unit,omitempty"`
|
||||||
Slice int `json:"slice"` // for SLC meters, the slice index it belongs to; -1 otherwise
|
Slice int `json:"slice"` // for SLC meters, the slice index it belongs to; -1 otherwise
|
||||||
Value float64 `json:"value"`
|
Value float64 `json:"value"`
|
||||||
Lo float64 `json:"lo"`
|
Lo float64 `json:"lo"`
|
||||||
Hi float64 `json:"hi"`
|
Hi float64 `json:"hi"`
|
||||||
@@ -386,6 +402,19 @@ type FlexController interface {
|
|||||||
SetAPFLevel(int) error
|
SetAPFLevel(int) error
|
||||||
SetWNB(bool) error
|
SetWNB(bool) error
|
||||||
SetWNBLevel(int) error
|
SetWNBLevel(int) error
|
||||||
|
// SmartSDR v4 DSP (8000/Aurora): NRL / ANFL / NRS / RNN / ANFT / NRF.
|
||||||
|
SetLMSNR(bool) error
|
||||||
|
SetLMSNRLevel(int) error
|
||||||
|
SetLMSANF(bool) error
|
||||||
|
SetLMSANFLevel(int) error
|
||||||
|
SetSpeexNR(bool) error
|
||||||
|
SetSpeexNRLevel(int) error
|
||||||
|
SetRNN(bool) error
|
||||||
|
SetANFT(bool) error
|
||||||
|
SetNRF(bool) error
|
||||||
|
SetNRFLevel(int) error
|
||||||
|
SetDAX(int) error // slice RX DAX channel 0 (off) - 8
|
||||||
|
SetTXDAX(bool) error // TX audio from DAX (transmit set dax=)
|
||||||
SetRIT(bool) error
|
SetRIT(bool) error
|
||||||
SetRITFreq(int) error
|
SetRITFreq(int) error
|
||||||
SetXIT(bool) error
|
SetXIT(bool) error
|
||||||
@@ -394,6 +423,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
|
||||||
@@ -437,7 +474,7 @@ type IcomTXState struct {
|
|||||||
// Transmit + live status (polled).
|
// Transmit + live status (polled).
|
||||||
Transmitting bool `json:"transmitting"`
|
Transmitting bool `json:"transmitting"`
|
||||||
Split bool `json:"split"`
|
Split bool `json:"split"`
|
||||||
SMeter int `json:"s_meter"` // 0-100 (raw 0-255; S9≈120)
|
SMeter int `json:"s_meter"` // 0-100 (raw 0-255; S9≈120)
|
||||||
PowerMeter int `json:"power_meter"` // 0-100 (TX Po)
|
PowerMeter int `json:"power_meter"` // 0-100 (TX Po)
|
||||||
SWRMeter int `json:"swr_meter"` // 0-100 (TX SWR)
|
SWRMeter int `json:"swr_meter"` // 0-100 (TX SWR)
|
||||||
// RIT / ΔTX (XIT).
|
// RIT / ΔTX (XIT).
|
||||||
@@ -448,20 +485,20 @@ type IcomTXState struct {
|
|||||||
KeySpeedWPM int `json:"key_speed_wpm"` // current KEY SPEED in WPM
|
KeySpeedWPM int `json:"key_speed_wpm"` // current KEY SPEED in WPM
|
||||||
BreakIn int `json:"break_in"` // CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
BreakIn int `json:"break_in"` // CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||||
// Set controls.
|
// Set controls.
|
||||||
RFPower int `json:"rf_power"` // 0-100 (TX output)
|
RFPower int `json:"rf_power"` // 0-100 (TX output)
|
||||||
MicGain int `json:"mic_gain"` // 0-100
|
MicGain int `json:"mic_gain"` // 0-100
|
||||||
AFGain int `json:"af_gain"`
|
AFGain int `json:"af_gain"`
|
||||||
RFGain int `json:"rf_gain"`
|
RFGain int `json:"rf_gain"`
|
||||||
NB bool `json:"nb"`
|
NB bool `json:"nb"`
|
||||||
NBLevel int `json:"nb_level"`
|
NBLevel int `json:"nb_level"`
|
||||||
NR bool `json:"nr"`
|
NR bool `json:"nr"`
|
||||||
NRLevel int `json:"nr_level"`
|
NRLevel int `json:"nr_level"`
|
||||||
ANF bool `json:"anf"`
|
ANF bool `json:"anf"`
|
||||||
APF bool `json:"apf"` // audio peak filter (CW only)
|
APF bool `json:"apf"` // audio peak filter (CW only)
|
||||||
AGC string `json:"agc,omitempty"` // FAST | MID | SLOW
|
AGC string `json:"agc,omitempty"` // FAST | MID | SLOW
|
||||||
Preamp int `json:"preamp"` // 0=off, 1=P.AMP1, 2=P.AMP2
|
Preamp int `json:"preamp"` // 0=off, 1=P.AMP1, 2=P.AMP2
|
||||||
Att int `json:"att"` // dB attenuation, 0=off
|
Att int `json:"att"` // dB attenuation, 0=off
|
||||||
Filter int `json:"filter"` // 1 | 2 | 3 (FIL1/2/3)
|
Filter int `json:"filter"` // 1 | 2 | 3 (FIL1/2/3)
|
||||||
// Antenna (IC-7610 = ANT1/ANT2).
|
// Antenna (IC-7610 = ANT1/ANT2).
|
||||||
Antenna int `json:"antenna"` // 1 | 2 (0 = unknown)
|
Antenna int `json:"antenna"` // 1 | 2 (0 = unknown)
|
||||||
// Filter fine controls: Twin PBT + manual notch (0-100, 50 = centre).
|
// Filter fine controls: Twin PBT + manual notch (0-100, 50 = centre).
|
||||||
@@ -502,20 +539,20 @@ type IcomController interface {
|
|||||||
SetMicGain(int) error
|
SetMicGain(int) error
|
||||||
SetIcomSplit(bool) error
|
SetIcomSplit(bool) error
|
||||||
TuneATU() error
|
TuneATU() error
|
||||||
SetScope(bool) error // enable/disable the spectrum-scope waveform stream
|
SetScope(bool) error // enable/disable the spectrum-scope waveform stream
|
||||||
SetScopeMode(bool) error // true = fixed span, false = center-on-VFO
|
SetScopeMode(bool) error // true = fixed span, false = center-on-VFO
|
||||||
SetScopeEdges(int64, int64) error // point the fixed scope at low..high Hz (centre/pan)
|
SetScopeEdges(int64, int64) error // point the fixed scope at low..high Hz (centre/pan)
|
||||||
ScopeData() ScopeSweep // latest assembled sweep (empty until enabled)
|
ScopeData() ScopeSweep // latest assembled sweep (empty until enabled)
|
||||||
SetRIT(int) error // RIT/ΔTX offset in signed Hz
|
SetRIT(int) error // RIT/ΔTX offset in signed Hz
|
||||||
SetRITOn(bool) error // RIT on/off
|
SetRITOn(bool) error // RIT on/off
|
||||||
SetXITOn(bool) error // ΔTX (XIT) on/off
|
SetXITOn(bool) error // ΔTX (XIT) on/off
|
||||||
SendCW(string) error // key a CW message via the rig's keyer (CI-V 0x17)
|
SendCW(string) error // key a CW message via the rig's keyer (CI-V 0x17)
|
||||||
StopCW() error // abort the CW message being sent
|
StopCW() error // abort the CW message being sent
|
||||||
SetKeySpeed(int) error // CW keyer speed in WPM
|
SetKeySpeed(int) error // CW keyer speed in WPM
|
||||||
SetBreakIn(int) error // CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
SetBreakIn(int) error // CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||||
SetAntenna(int) error // 1 = ANT1, 2 = ANT2
|
SetAntenna(int) error // 1 = ANT1, 2 = ANT2
|
||||||
SetPBTInner(int) error // Twin PBT inside (0-100, 50 = centre)
|
SetPBTInner(int) error // Twin PBT inside (0-100, 50 = centre)
|
||||||
SetPBTOuter(int) error // Twin PBT outside (0-100, 50 = centre)
|
SetPBTOuter(int) error // Twin PBT outside (0-100, 50 = centre)
|
||||||
SetManualNotch(bool) error
|
SetManualNotch(bool) error
|
||||||
SetNotchPos(int) error // manual-notch position (0-100, 50 = centre)
|
SetNotchPos(int) error // manual-notch position (0-100, 50 = centre)
|
||||||
SetSquelch(int) error
|
SetSquelch(int) error
|
||||||
|
|||||||
+155
-16
@@ -30,10 +30,10 @@ type Flex struct {
|
|||||||
conn net.Conn
|
conn net.Conn
|
||||||
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop); nil when not dialing
|
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop); nil when not dialing
|
||||||
wmu sync.Mutex // serialises writes to conn
|
wmu sync.Mutex // serialises writes to conn
|
||||||
seq int
|
seq int
|
||||||
handle string
|
handle string
|
||||||
model string
|
model string
|
||||||
gotHandle bool
|
gotHandle bool
|
||||||
|
|
||||||
slices map[int]*flexSlice
|
slices map[int]*flexSlice
|
||||||
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
|
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
|
||||||
@@ -90,12 +90,28 @@ type flexSlice struct {
|
|||||||
apfLevel int
|
apfLevel int
|
||||||
wnb bool // wideband noise blanker
|
wnb bool // wideband noise blanker
|
||||||
wnbLevel int
|
wnbLevel int
|
||||||
rit bool // receive incremental tuning enabled
|
// SmartSDR v4 DSP (8000/Aurora series). Key names per the FlexLib slice
|
||||||
ritFreq int // RIT offset in Hz (negative = down)
|
// docs: lms_nr(NRL) / lms_anf(ANFL) / speex_nr(NRS) / rnnoise(RNN) /
|
||||||
xit bool // transmit incremental tuning enabled
|
// anft(ANFT) / nrf(NRF). Older radios never report them — dspV4 flips true
|
||||||
xitFreq int // XIT offset in Hz
|
// the first time any of these keys appears, and gates the extra UI rows.
|
||||||
filterLo int // slice filter low cut (Hz)
|
lmsNR bool // NRL — legacy LMS noise reduction
|
||||||
filterHi int // slice filter high cut (Hz)
|
lmsNRLevel int
|
||||||
|
lmsANF bool // ANFL — legacy LMS auto-notch
|
||||||
|
lmsANFLevel int
|
||||||
|
speexNR bool // NRS — spectral subtraction NR
|
||||||
|
speexNRLevel int
|
||||||
|
rnnoise bool // RNN — AI noise reduction (on/off only)
|
||||||
|
anft bool // ANFT — FFT-based auto-notch (on/off only)
|
||||||
|
nrf bool // NRF — noise reduction w/ filter
|
||||||
|
nrfLevel int
|
||||||
|
dspV4 bool
|
||||||
|
daxCh int // DAX audio channel for this slice (0 = off, 1-8)
|
||||||
|
rit bool // receive incremental tuning enabled
|
||||||
|
ritFreq int // RIT offset in Hz (negative = down)
|
||||||
|
xit bool // transmit incremental tuning enabled
|
||||||
|
xitFreq int // XIT offset in Hz
|
||||||
|
filterLo int // slice filter low cut (Hz)
|
||||||
|
filterHi int // slice filter high cut (Hz)
|
||||||
rxAnt string // selected RX antenna (e.g. ANT1, ANT2, RX_A)
|
rxAnt string // selected RX antenna (e.g. ANT1, ANT2, RX_A)
|
||||||
txAnt string // selected TX antenna
|
txAnt string // selected TX antenna
|
||||||
antList []string // antennas valid for this slice (RX side)
|
antList []string // antennas valid for this slice (RX side)
|
||||||
@@ -118,8 +134,9 @@ type flexTX struct {
|
|||||||
mon bool
|
mon bool
|
||||||
monLevel int
|
monLevel int
|
||||||
micLevel int
|
micLevel int
|
||||||
filterLow int // TX filter low cut (Hz)
|
filterLow int // TX filter low cut (Hz)
|
||||||
filterHigh int // TX filter high cut (Hz)
|
filterHigh int // TX filter high cut (Hz)
|
||||||
|
dax bool // TX audio comes from DAX (SmartSDR's transmit-bar DAX button)
|
||||||
atuStatus string
|
atuStatus string
|
||||||
atuMemories bool
|
atuMemories bool
|
||||||
// CW keyer params (set via the top-level "cw" commands).
|
// CW keyer params (set via the top-level "cw" commands).
|
||||||
@@ -431,6 +448,8 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
f.tx.tunePower = atoiDefault(val, f.tx.tunePower)
|
f.tx.tunePower = atoiDefault(val, f.tx.tunePower)
|
||||||
case "tune":
|
case "tune":
|
||||||
f.tx.tune = val == "1"
|
f.tx.tune = val == "1"
|
||||||
|
case "dax":
|
||||||
|
f.tx.dax = val == "1"
|
||||||
case "vox_enable":
|
case "vox_enable":
|
||||||
f.tx.voxEnable = val == "1"
|
f.tx.voxEnable = val == "1"
|
||||||
case "vox_level":
|
case "vox_level":
|
||||||
@@ -809,6 +828,28 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
s.wnb = val == "1"
|
s.wnb = val == "1"
|
||||||
case "wnb_level":
|
case "wnb_level":
|
||||||
s.wnbLevel = atoiDefault(val, s.wnbLevel)
|
s.wnbLevel = atoiDefault(val, s.wnbLevel)
|
||||||
|
case "lms_nr":
|
||||||
|
s.lmsNR, s.dspV4 = val == "1", true
|
||||||
|
case "lms_nr_level":
|
||||||
|
s.lmsNRLevel, s.dspV4 = atoiDefault(val, s.lmsNRLevel), true
|
||||||
|
case "lms_anf":
|
||||||
|
s.lmsANF, s.dspV4 = val == "1", true
|
||||||
|
case "lms_anf_level":
|
||||||
|
s.lmsANFLevel, s.dspV4 = atoiDefault(val, s.lmsANFLevel), true
|
||||||
|
case "speex_nr":
|
||||||
|
s.speexNR, s.dspV4 = val == "1", true
|
||||||
|
case "speex_nr_level":
|
||||||
|
s.speexNRLevel, s.dspV4 = atoiDefault(val, s.speexNRLevel), true
|
||||||
|
case "rnnoise":
|
||||||
|
s.rnnoise, s.dspV4 = val == "1", true
|
||||||
|
case "anft":
|
||||||
|
s.anft, s.dspV4 = val == "1", true
|
||||||
|
case "nrf":
|
||||||
|
s.nrf, s.dspV4 = val == "1", true
|
||||||
|
case "nrf_level":
|
||||||
|
s.nrfLevel, s.dspV4 = atoiDefault(val, s.nrfLevel), true
|
||||||
|
case "dax":
|
||||||
|
s.daxCh = atoiDefault(val, s.daxCh)
|
||||||
case "rit_on":
|
case "rit_on":
|
||||||
s.rit = val == "1"
|
s.rit = val == "1"
|
||||||
case "rit_freq":
|
case "rit_freq":
|
||||||
@@ -1274,6 +1315,7 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
MicLevel: f.tx.micLevel,
|
MicLevel: f.tx.micLevel,
|
||||||
TXFilterLow: f.tx.filterLow,
|
TXFilterLow: f.tx.filterLow,
|
||||||
TXFilterHigh: f.tx.filterHigh,
|
TXFilterHigh: f.tx.filterHigh,
|
||||||
|
TXDAX: f.tx.dax,
|
||||||
MicProfile: f.micProfile,
|
MicProfile: f.micProfile,
|
||||||
MicProfiles: f.micProfiles,
|
MicProfiles: f.micProfiles,
|
||||||
ATUStatus: f.tx.atuStatus,
|
ATUStatus: f.tx.atuStatus,
|
||||||
@@ -1328,6 +1370,18 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
st.APFLevel = rx.apfLevel
|
st.APFLevel = rx.apfLevel
|
||||||
st.WNB = rx.wnb
|
st.WNB = rx.wnb
|
||||||
st.WNBLevel = rx.wnbLevel
|
st.WNBLevel = rx.wnbLevel
|
||||||
|
st.LMSNR = rx.lmsNR
|
||||||
|
st.LMSNRLevel = rx.lmsNRLevel
|
||||||
|
st.LMSANF = rx.lmsANF
|
||||||
|
st.LMSANFLevel = rx.lmsANFLevel
|
||||||
|
st.SpeexNR = rx.speexNR
|
||||||
|
st.SpeexNRLevel = rx.speexNRLevel
|
||||||
|
st.RNN = rx.rnnoise
|
||||||
|
st.ANFT = rx.anft
|
||||||
|
st.NRF = rx.nrf
|
||||||
|
st.NRFLevel = rx.nrfLevel
|
||||||
|
st.DSPV4 = rx.dspV4
|
||||||
|
st.DAXCh = rx.daxCh
|
||||||
st.RIT = rx.rit
|
st.RIT = rx.rit
|
||||||
st.RITFreq = rx.ritFreq
|
st.RITFreq = rx.ritFreq
|
||||||
st.XIT = rx.xit
|
st.XIT = rx.xit
|
||||||
@@ -1396,6 +1450,28 @@ func (f *Flex) sendSlice(param string, val any) error {
|
|||||||
rx.apf = val == "1"
|
rx.apf = val == "1"
|
||||||
case "apf_level":
|
case "apf_level":
|
||||||
rx.apfLevel = toInt(val)
|
rx.apfLevel = toInt(val)
|
||||||
|
case "lms_nr":
|
||||||
|
rx.lmsNR = val == "1"
|
||||||
|
case "lms_nr_level":
|
||||||
|
rx.lmsNRLevel = toInt(val)
|
||||||
|
case "lms_anf":
|
||||||
|
rx.lmsANF = val == "1"
|
||||||
|
case "lms_anf_level":
|
||||||
|
rx.lmsANFLevel = toInt(val)
|
||||||
|
case "speex_nr":
|
||||||
|
rx.speexNR = val == "1"
|
||||||
|
case "speex_nr_level":
|
||||||
|
rx.speexNRLevel = toInt(val)
|
||||||
|
case "rnnoise":
|
||||||
|
rx.rnnoise = val == "1"
|
||||||
|
case "anft":
|
||||||
|
rx.anft = val == "1"
|
||||||
|
case "nrf":
|
||||||
|
rx.nrf = val == "1"
|
||||||
|
case "nrf_level":
|
||||||
|
rx.nrfLevel = toInt(val)
|
||||||
|
case "dax":
|
||||||
|
rx.daxCh = toInt(val)
|
||||||
case "rxant":
|
case "rxant":
|
||||||
rx.rxAnt = fmt.Sprint(val)
|
rx.rxAnt = fmt.Sprint(val)
|
||||||
case "txant":
|
case "txant":
|
||||||
@@ -1515,14 +1591,15 @@ func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on))
|
|||||||
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
|
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
|
||||||
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
|
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
|
||||||
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
|
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
|
||||||
|
|
||||||
// RIT/XIT — an offset applied to the RX (RIT) or TX (XIT) frequency of the active
|
// RIT/XIT — an offset applied to the RX (RIT) or TX (XIT) frequency of the active
|
||||||
// slice, without moving the slice itself. SmartSDR keeps the offset even while the
|
// slice, without moving the slice itself. SmartSDR keeps the offset even while the
|
||||||
// switch is off, so turning RIT back on restores the last offset — same as the
|
// switch is off, so turning RIT back on restores the last offset — same as the
|
||||||
// radio's own knob.
|
// radio's own knob.
|
||||||
func (f *Flex) SetRIT(on bool) error { return f.sendSlice("rit_on", boolFlex(on)) }
|
func (f *Flex) SetRIT(on bool) error { return f.sendSlice("rit_on", boolFlex(on)) }
|
||||||
func (f *Flex) SetRITFreq(hz int) error { return f.sendSlice("rit_freq", clampOffset(hz)) }
|
func (f *Flex) SetRITFreq(hz int) error { return f.sendSlice("rit_freq", clampOffset(hz)) }
|
||||||
func (f *Flex) SetXIT(on bool) error { return f.sendSlice("xit_on", boolFlex(on)) }
|
func (f *Flex) SetXIT(on bool) error { return f.sendSlice("xit_on", boolFlex(on)) }
|
||||||
func (f *Flex) SetXITFreq(hz int) error { return f.sendSlice("xit_freq", clampOffset(hz)) }
|
func (f *Flex) SetXITFreq(hz int) error { return f.sendSlice("xit_freq", clampOffset(hz)) }
|
||||||
|
|
||||||
// clampOffset keeps a RIT/XIT offset inside what SmartSDR accepts (±99 999 Hz).
|
// clampOffset keeps a RIT/XIT offset inside what SmartSDR accepts (±99 999 Hz).
|
||||||
func clampOffset(hz int) int {
|
func clampOffset(hz int) int {
|
||||||
@@ -1537,6 +1614,37 @@ func clampOffset(hz int) int {
|
|||||||
|
|
||||||
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) }
|
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) }
|
||||||
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
|
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
|
||||||
|
|
||||||
|
// SmartSDR v4 DSP (8000/Aurora series) — the extra noise tools SmartSDR ships
|
||||||
|
// beyond nb/nr/anf/wnb. Key names per the FlexLib slice command docs.
|
||||||
|
func (f *Flex) SetLMSNR(on bool) error { return f.sendSlice("lms_nr", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetLMSNRLevel(l int) error { return f.sendSlice("lms_nr_level", clampLevel(l)) }
|
||||||
|
func (f *Flex) SetLMSANF(on bool) error { return f.sendSlice("lms_anf", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetLMSANFLevel(l int) error { return f.sendSlice("lms_anf_level", clampLevel(l)) }
|
||||||
|
func (f *Flex) SetSpeexNR(on bool) error { return f.sendSlice("speex_nr", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetSpeexNRLevel(l int) error { return f.sendSlice("speex_nr_level", clampLevel(l)) }
|
||||||
|
func (f *Flex) SetRNN(on bool) error { return f.sendSlice("rnnoise", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetANFT(on bool) error { return f.sendSlice("anft", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetNRF(on bool) error { return f.sendSlice("nrf", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetNRFLevel(l int) error { return f.sendSlice("nrf_level", clampLevel(l)) }
|
||||||
|
|
||||||
|
// SetDAX routes the active slice's RX audio to a DAX channel (0 = off, 1-8) —
|
||||||
|
// the SmartSDR slice "DAX" selector, e.g. to feed WSJT-X via DAX audio.
|
||||||
|
func (f *Flex) SetDAX(ch int) error {
|
||||||
|
if ch < 0 {
|
||||||
|
ch = 0
|
||||||
|
}
|
||||||
|
if ch > 8 {
|
||||||
|
ch = 8
|
||||||
|
}
|
||||||
|
return f.sendSlice("dax", ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTXDAX toggles DAX as the TRANSMIT audio source — SmartSDR's transmit-bar
|
||||||
|
// DAX button ("transmit set dax="), the one used to send WSJT-X audio.
|
||||||
|
func (f *Flex) SetTXDAX(on bool) error {
|
||||||
|
return f.txSet("transmit set dax="+boolFlex(on), "dax", func(t *flexTX) { t.dax = on })
|
||||||
|
}
|
||||||
func (f *Flex) SetWNB(on bool) error { return f.sendSlice("wnb", boolFlex(on)) }
|
func (f *Flex) SetWNB(on bool) error { return f.sendSlice("wnb", boolFlex(on)) }
|
||||||
func (f *Flex) SetWNBLevel(l int) error { return f.sendSlice("wnb_level", clampLevel(l)) }
|
func (f *Flex) SetWNBLevel(l int) error { return f.sendSlice("wnb_level", clampLevel(l)) }
|
||||||
|
|
||||||
@@ -1581,6 +1689,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
|
||||||
|
|||||||
@@ -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 {
|
rows.Close()
|
||||||
return fmt.Errorf("check migration %s: %w", name, err)
|
} else {
|
||||||
|
return fmt.Errorf("read applied migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
if applied[name] {
|
||||||
|
continue // already applied
|
||||||
}
|
}
|
||||||
content, err := migrationsFS.ReadFile("migrations/" + name)
|
content, err := migrationsFS.ReadFile("migrations/" + name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+40
-17
@@ -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,29 +159,30 @@ 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)
|
|
||||||
// No max lifetime: a slow server's first migration can run for minutes on a
|
|
||||||
// single connection, and reaping it mid-migration drops the selected database
|
|
||||||
// (surfacing as "Unknown database"). Idle connections are still recycled
|
|
||||||
// after 90s, and the driver retries stale pooled connections.
|
|
||||||
conn.SetConnMaxLifetime(0)
|
|
||||||
if err := conn.Ping(); err != nil {
|
if err := conn.Ping(); err != nil {
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
return nil, fmt.Errorf("connect to %s: %w", name, err)
|
if perr := PingMySQL(c); perr != nil {
|
||||||
|
return nil, perr // creates the DB (or returns a clear "cannot create" error)
|
||||||
|
}
|
||||||
|
conn, err = sql.Open("mysql", c.dsn())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open mysql: %w", err)
|
||||||
|
}
|
||||||
|
tuneMySQLPool(conn)
|
||||||
|
if err := conn.Ping(); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, fmt.Errorf("connect to %s: %w", name, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Set the dialect before migrating so the runner takes the MySQL path
|
// 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.
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -33,6 +35,7 @@ type Status struct {
|
|||||||
State string `json:"state,omitempty"` // IDLE / TRANSMIT_A …
|
State string `json:"state,omitempty"` // IDLE / TRANSMIT_A …
|
||||||
FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST
|
FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST
|
||||||
Temperature float64 `json:"temperature"`
|
Temperature float64 `json:"temperature"`
|
||||||
|
Operate bool `json:"operate"` // OPERATE vs STANDBY (optimistic until the amp reports it)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
@@ -45,6 +48,7 @@ type Client struct {
|
|||||||
|
|
||||||
statusMu sync.RWMutex
|
statusMu sync.RWMutex
|
||||||
status Status
|
status Status
|
||||||
|
lastRaw string // last raw status payload — logged on change so unknown fields (operate?) can be mapped from a real amp
|
||||||
// Optimistic fan mode kept until the amp's status poll confirms it (or it
|
// Optimistic fan mode kept until the amp's status poll confirms it (or it
|
||||||
// ages out) — otherwise a stale poll right after a change reverts the UI.
|
// ages out) — otherwise a stale poll right after a change reverts the UI.
|
||||||
fanPending string
|
fanPending string
|
||||||
@@ -119,8 +123,15 @@ func (c *Client) SetOperate(on bool) error {
|
|||||||
if on {
|
if on {
|
||||||
v = "1"
|
v = "1"
|
||||||
}
|
}
|
||||||
_, err := c.command("operate=" + v)
|
if _, err := c.command("operate=" + v); err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
// Optimistic: the status poll's "operate" field (when the firmware reports
|
||||||
|
// one) confirms or corrects this.
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.status.Operate = on
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) pollLoop() {
|
func (c *Client) pollLoop() {
|
||||||
@@ -215,6 +226,12 @@ func (c *Client) parse(resp string) {
|
|||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
c.status.Connected = true
|
c.status.Connected = true
|
||||||
c.status.LastError = ""
|
c.status.LastError = ""
|
||||||
|
// Log each DISTINCT status payload once: the PGXL's field set isn't fully
|
||||||
|
// documented, so this is how we learn the real key for e.g. operate/standby.
|
||||||
|
if data != c.lastRaw {
|
||||||
|
c.lastRaw = data
|
||||||
|
applog.Printf("pgxl: status raw=%q", data)
|
||||||
|
}
|
||||||
for _, pair := range strings.Fields(data) {
|
for _, pair := range strings.Fields(data) {
|
||||||
kv := strings.SplitN(pair, "=", 2)
|
kv := strings.SplitN(pair, "=", 2)
|
||||||
if len(kv) != 2 {
|
if len(kv) != 2 {
|
||||||
@@ -223,6 +240,8 @@ func (c *Client) parse(resp string) {
|
|||||||
switch kv[0] {
|
switch kv[0] {
|
||||||
case "state":
|
case "state":
|
||||||
c.status.State = kv[1]
|
c.status.State = kv[1]
|
||||||
|
case "operate":
|
||||||
|
c.status.Operate = kv[1] == "1"
|
||||||
case "fanmode":
|
case "fanmode":
|
||||||
dev := strings.ToUpper(kv[1])
|
dev := strings.ToUpper(kv[1])
|
||||||
// Honour a recent optimistic change until the amp confirms it.
|
// Honour a recent optimistic change until the amp confirms it.
|
||||||
|
|||||||
+33
-17
@@ -1371,6 +1371,17 @@ func modeClass(mode string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GroupDigitalMode collapses every digital mode into the single "DIG" bucket
|
||||||
|
// (FT8, FT4, RTTY, PSK… all become one mode) while leaving phone modes and CW
|
||||||
|
// untouched. Used as the optional slot normaliser when the operator prefers
|
||||||
|
// DXCC-style mode classes over per-mode slots (Settings → General).
|
||||||
|
func GroupDigitalMode(mode string) string {
|
||||||
|
if modeClass(mode) == "DIG" {
|
||||||
|
return "DIG"
|
||||||
|
}
|
||||||
|
return strings.ToUpper(mode)
|
||||||
|
}
|
||||||
|
|
||||||
// BandMode is a (band, mode) pair used for the NEW SLOT check.
|
// BandMode is a (band, mode) pair used for the NEW SLOT check.
|
||||||
type BandMode struct {
|
type BandMode struct {
|
||||||
Band string `json:"band"`
|
Band string `json:"band"`
|
||||||
@@ -1802,7 +1813,11 @@ type EntitySlot struct {
|
|||||||
// 0 (unresolvable) skips the QSO.
|
// 0 (unresolvable) skips the QSO.
|
||||||
//
|
//
|
||||||
// One DB scan regardless of input size. Cheap to call per cluster batch.
|
// One DB scan regardless of input size. Cheap to call per cluster batch.
|
||||||
func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int) (map[int]*EntitySlot, error) {
|
//
|
||||||
|
// normMode (nil = identity) maps each QSO's mode before it is stored as a
|
||||||
|
// Modes/Slots key — pass GroupDigitalMode to collapse all digital modes into
|
||||||
|
// one bucket. Callers must normalise their lookup mode the same way.
|
||||||
|
func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int, normMode func(string) string) (map[int]*EntitySlot, error) {
|
||||||
rows, err := r.db.QueryContext(ctx,
|
rows, err := r.db.QueryContext(ctx,
|
||||||
`SELECT callsign, coalesce(dxcc,0), lower(coalesce(country,'')), lower(band), upper(mode) FROM qso
|
`SELECT callsign, coalesce(dxcc,0), lower(coalesce(country,'')), lower(band), upper(mode) FROM qso
|
||||||
WHERE band IS NOT NULL AND band != ''
|
WHERE band IS NOT NULL AND band != ''
|
||||||
@@ -1827,6 +1842,9 @@ func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, store
|
|||||||
if key == 0 {
|
if key == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if normMode != nil {
|
||||||
|
mode = normMode(mode)
|
||||||
|
}
|
||||||
e, ok := out[key]
|
e, ok := out[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
e = &EntitySlot{
|
e = &EntitySlot{
|
||||||
@@ -1954,23 +1972,21 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
+112
-2
@@ -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 {
|
||||||
@@ -129,8 +153,9 @@ type Stats struct {
|
|||||||
ConfirmedAny int `json:"confirmed_any"`
|
ConfirmedAny int `json:"confirmed_any"`
|
||||||
|
|
||||||
// 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} }
|
||||||
@@ -62,7 +67,8 @@ type webswitch struct {
|
|||||||
// NewWebswitch builds a WebSwitch 1216H client (5 relays).
|
// NewWebswitch builds a WebSwitch 1216H client (5 relays).
|
||||||
func NewWebswitch(host string) Device { return &webswitch{host: host, count: 5} }
|
func NewWebswitch(host string) Device { return &webswitch{host: host, count: 5} }
|
||||||
|
|
||||||
func (w *webswitch) Count() int { return w.count }
|
func (w *webswitch) Count() int { return w.count }
|
||||||
|
func (w *webswitch) Close() error { return nil } // stateless HTTP, nothing to release
|
||||||
|
|
||||||
func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
|
func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
|
||||||
if relay < 1 || relay > w.count {
|
if relay < 1 || relay > w.count {
|
||||||
@@ -118,7 +124,8 @@ func NewKMTronic(host, user, pass string) Device {
|
|||||||
return &kmtronic{host: host, user: user, pass: pass, count: 8}
|
return &kmtronic{host: host, user: user, pass: pass, count: 8}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *kmtronic) Count() int { return k.count }
|
func (k *kmtronic) Count() int { return k.count }
|
||||||
|
func (k *kmtronic) Close() error { return nil } // stateless HTTP, nothing to release
|
||||||
|
|
||||||
func (k *kmtronic) Set(ctx context.Context, relay int, on bool) error {
|
func (k *kmtronic) Set(ctx context.Context, relay int, on bool) error {
|
||||||
if relay < 1 || relay > k.count {
|
if relay < 1 || relay > k.count {
|
||||||
|
|||||||
@@ -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,143 @@
|
|||||||
|
// Package gs232 drives rotator controllers that speak the Yaesu GS-232A
|
||||||
|
// protocol, over a raw TCP socket or a serial COM port. The target device is
|
||||||
|
// the microHAM ARCO: both its LAN "CONTROL PROTOCOL" setting (a TCP port) and
|
||||||
|
// its USB port ("USB CONTROL PROTOCOL", a virtual COM where the baud rate is
|
||||||
|
// irrelevant) can be set to speak Yaesu GS-232A — so OpsLog controls it
|
||||||
|
// directly, no PstRotator in between. ARCO accepts up to four parallel LAN
|
||||||
|
// connections, and commands are single CR-terminated lines, so short
|
||||||
|
// per-call connections (same idiom as the other rotator backends) work fine.
|
||||||
|
//
|
||||||
|
// GS-232A subset used:
|
||||||
|
//
|
||||||
|
// Maaa<CR> move to azimuth aaa (000-450)
|
||||||
|
// S<CR> stop rotation
|
||||||
|
// C<CR> query azimuth — replies "+0aaa" (GS-232A) or "AZ=aaa" (GS-232B
|
||||||
|
// flavour); both are parsed.
|
||||||
|
package gs232
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dialTimeout = 3 * time.Second
|
||||||
|
ioTimeout = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is a stateless per-call sender, mirroring the pst/rotgenius idiom.
|
||||||
|
// Exactly one of (Host, Port) or ComPort is used, per Transport.
|
||||||
|
type Client struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
ComPort string // serial transport: "COM5" etc. (ARCO USB virtual COM — any baud)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a TCP Client with sane defaults applied for empty fields. There
|
||||||
|
// is no standard port: the number is whatever the user typed into the ARCO's
|
||||||
|
// LAN CONTROL PROTOCOL setting — 4001 is only a placeholder.
|
||||||
|
func New(host string, port int) *Client {
|
||||||
|
if host == "" {
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
if port <= 0 || port > 65535 {
|
||||||
|
port = 4001
|
||||||
|
}
|
||||||
|
return &Client{Host: host, Port: port}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSerial returns a Client talking over the ARCO's USB virtual COM port. The
|
||||||
|
// baud rate is irrelevant on USB per the ARCO manual (8N1 framing matters); we
|
||||||
|
// open at 9600 which also suits a real RS-232 hookup left at its default.
|
||||||
|
func NewSerial(comPort string) *Client {
|
||||||
|
return &Client{ComPort: comPort}
|
||||||
|
}
|
||||||
|
|
||||||
|
// roundTrip opens a connection (TCP or serial per the client's config), sends
|
||||||
|
// one CR-terminated command and (when wantReply) reads one CR/LF-terminated
|
||||||
|
// reply line.
|
||||||
|
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
|
||||||
|
var conn io.ReadWriteCloser
|
||||||
|
if c.ComPort != "" {
|
||||||
|
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: 9600})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("open ARCO %s: %w", c.ComPort, err)
|
||||||
|
}
|
||||||
|
_ = sp.SetReadTimeout(200 * time.Millisecond)
|
||||||
|
conn = sp
|
||||||
|
} else {
|
||||||
|
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("connect ARCO %s:%d: %w", c.Host, c.Port, err)
|
||||||
|
}
|
||||||
|
_ = nc.SetDeadline(time.Now().Add(ioTimeout))
|
||||||
|
conn = nc
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if _, err := conn.Write([]byte(cmd + "\r")); err != nil {
|
||||||
|
return "", fmt.Errorf("send %q: %w", cmd, err)
|
||||||
|
}
|
||||||
|
if !wantReply {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
buf := make([]byte, 64)
|
||||||
|
var sb strings.Builder
|
||||||
|
deadline := time.Now().Add(ioTimeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
n, err := conn.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
sb.Write(buf[:n])
|
||||||
|
if strings.ContainsAny(sb.String(), "\r\n") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A serial read that times out returns (0, nil) — keep polling until the
|
||||||
|
// overall deadline; a real error ends the read.
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line := strings.TrimSpace(sb.String())
|
||||||
|
if line == "" {
|
||||||
|
return "", fmt.Errorf("no reply to %q", cmd)
|
||||||
|
}
|
||||||
|
return line, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoTo points the antenna at the given azimuth (0-359). GS-232A takes M000-M450
|
||||||
|
// (overlap rotators accept >360); we normalise to [0,360).
|
||||||
|
func (c *Client) GoTo(az int) error {
|
||||||
|
az = ((az % 360) + 360) % 360
|
||||||
|
_, err := c.roundTrip(fmt.Sprintf("M%03d", az), false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop interrupts any in-progress rotation.
|
||||||
|
func (c *Client) Stop() error {
|
||||||
|
_, err := c.roundTrip("S", false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// azRe matches both reply flavours: "+0aaa" (GS-232A) and "AZ=aaa" (GS-232B).
|
||||||
|
var azRe = regexp.MustCompile(`(?:\+0|AZ=)(\d{3})`)
|
||||||
|
|
||||||
|
// Heading queries the current azimuth. Returns the raw reply for diagnostics.
|
||||||
|
func (c *Client) Heading() (az int, raw string, err error) {
|
||||||
|
raw, err = c.roundTrip("C", true)
|
||||||
|
if err != nil {
|
||||||
|
return 0, raw, err
|
||||||
|
}
|
||||||
|
m := azRe.FindStringSubmatch(raw)
|
||||||
|
if m == nil {
|
||||||
|
return 0, raw, fmt.Errorf("unrecognised azimuth reply %q", raw)
|
||||||
|
}
|
||||||
|
az, _ = strconv.Atoi(m[1])
|
||||||
|
return az % 360, raw, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
-41
@@ -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)")
|
||||||
@@ -218,6 +208,7 @@ func (a *App) publishLiveStatus() {
|
|||||||
if _, err := a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op); err != nil {
|
if _, err := a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op); err != nil {
|
||||||
applog.Printf("livestatus: offline DELETE failed: %v", err)
|
applog.Printf("livestatus: offline DELETE failed: %v", err)
|
||||||
}
|
}
|
||||||
|
a.emitLiveStatusChanged()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastQSOArg := lastQSO.UTC()
|
lastQSOArg := lastQSO.UTC()
|
||||||
@@ -233,6 +224,7 @@ func (a *App) publishLiveStatus() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s ON AIR", op, station, freqHz, band, mode)
|
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s ON AIR", op, station, freqHz, band, mode)
|
||||||
|
a.emitLiveStatusChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
// LiveStation is one operator's live status for the multi-op "who's on air" widget.
|
// LiveStation is one operator's live status for the multi-op "who's on air" widget.
|
||||||
@@ -289,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.4"
|
appVersion = "0.20.9"
|
||||||
|
|
||||||
// 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